CG-Feedback-Monitor/src/pages/AiPromptConfig.tsx

401 lines
19 KiB
TypeScript
Raw Normal View History

2026-07-07 11:14:00 +02:00
import { useEffect, useRef, useState } from 'react'
import {
type FeedbackDimension, type FeedbackDimensionPrompt,
loadFeedbackStructure, listFeedbackDimensionPrompts, updateFeedbackDimensionPrompt,
resetAllDimensionPromptsToDefault,
getOrSeedDefaultDimensionPromptTemplate, saveDefaultDimensionPromptTemplate,
getOrSeedDefaultCategoryPromptTemplate, saveDefaultCategoryPromptTemplate,
getOrSeedCriterionTaggingPromptTemplate, saveCriterionTaggingPromptTemplate,
getOrSeedChecklistTaggingPromptTemplate, saveChecklistTaggingPromptTemplate,
2026-07-07 11:14:00 +02:00
} from '../db'
import { AI_CONFIG } from '../config/constants'
import { getAiSettings, saveAiSettings } from '../utils/aiClient'
2026-07-07 11:14:00 +02:00
export default function AiPromptConfig() {
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
const [prompts, setPrompts] = useState<FeedbackDimensionPrompt[]>([])
const [defaultTemplate, setDefaultTemplate] = useState('')
const [defaultExpanded, setDefaultExpanded] = useState(false)
const [defaultSaved, setDefaultSaved] = useState(false)
const [categoryTemplate, setCategoryTemplate] = useState('')
const [categoryExpanded, setCategoryExpanded] = useState(false)
const [categorySaved, setCategorySaved] = useState(false)
const [taggingTemplate, setTaggingTemplate] = useState('')
const [taggingExpanded, setTaggingExpanded] = useState(false)
const [taggingSaved, setTaggingSaved] = useState(false)
const [checklistTaggingTemplate, setChecklistTaggingTemplate] = useState('')
const [checklistTaggingExpanded, setChecklistTaggingExpanded] = useState(false)
const [checklistTaggingSaved, setChecklistTaggingSaved] = useState(false)
2026-07-07 11:14:00 +02:00
const [expanded, setExpanded] = useState<Set<number>>(new Set())
const [savedDimId, setSavedDimId] = useState<number | null>(null)
const [bulkResetMsg, setBulkResetMsg] = useState('')
const saveTimer = useRef<Map<number | 'default' | 'category' | 'tagging' | 'checklistTagging', ReturnType<typeof setTimeout>>>(new Map())
const [aiKey, setAiKey] = useState(getAiSettings().apiKey)
const [aiModel, setAiModel] = useState(getAiSettings().model)
const [aiSaved, setAiSaved] = useState(false)
2026-07-07 11:14:00 +02:00
const load = async () => {
const [structure, promptRows, defTemplate, catTemplate, taggingTpl, checklistTaggingTpl] = await Promise.all([
2026-07-07 11:14:00 +02:00
loadFeedbackStructure(),
listFeedbackDimensionPrompts(),
getOrSeedDefaultDimensionPromptTemplate(),
getOrSeedDefaultCategoryPromptTemplate(),
getOrSeedCriterionTaggingPromptTemplate(),
getOrSeedChecklistTaggingPromptTemplate(),
2026-07-07 11:14:00 +02:00
])
setDimensions([...structure.dimensions].sort((a, b) => a.order - b.order))
setPrompts(promptRows)
setDefaultTemplate(defTemplate)
setCategoryTemplate(catTemplate)
setTaggingTemplate(taggingTpl)
setChecklistTaggingTemplate(checklistTaggingTpl)
2026-07-07 11:14:00 +02:00
}
useEffect(() => { load() }, [])
const getPrompt = (dimId: number) => prompts.find(p => p.dimensionId === dimId)
const saveTemplate = (dimId: number, value: string) => {
setPrompts(prev => prev.map(p => p.dimensionId === dimId ? { ...p, promptTemplate: value } : p))
const prevTimer = saveTimer.current.get(dimId)
if (prevTimer) clearTimeout(prevTimer)
saveTimer.current.set(dimId, setTimeout(async () => {
const existing = getPrompt(dimId)
if (!existing?.id) return
await updateFeedbackDimensionPrompt(existing.id, { promptTemplate: value })
setSavedDimId(dimId)
setTimeout(() => setSavedDimId(null), 2000)
}, 600))
}
const resetToDefault = async (dimId: number) => {
const existing = getPrompt(dimId)
if (!existing?.id) return
await updateFeedbackDimensionPrompt(existing.id, { promptTemplate: defaultTemplate })
setPrompts(prev => prev.map(p => p.dimensionId === dimId ? { ...p, promptTemplate: defaultTemplate } : p))
}
const resetAllToDefault = async () => {
if (!confirm('Alle Dimension-Prompts wirklich auf den Standard (1–10) setzen? Individuelle Anpassungen gehen verloren.')) return
const { updated } = await resetAllDimensionPromptsToDefault()
const promptRows = await listFeedbackDimensionPrompts()
setPrompts(promptRows)
setBulkResetMsg(`${updated} Dimension-Prompt(s) auf Standard gesetzt.`)
setTimeout(() => setBulkResetMsg(''), 4000)
}
const saveDefaultTemplate = (value: string) => {
setDefaultTemplate(value)
const prevTimer = saveTimer.current.get('default')
if (prevTimer) clearTimeout(prevTimer)
saveTimer.current.set('default', setTimeout(async () => {
await saveDefaultDimensionPromptTemplate(value)
setDefaultSaved(true)
setTimeout(() => setDefaultSaved(false), 2000)
}, 600))
}
const saveCategoryTemplate = (value: string) => {
setCategoryTemplate(value)
const prevTimer = saveTimer.current.get('category')
if (prevTimer) clearTimeout(prevTimer)
saveTimer.current.set('category', setTimeout(async () => {
await saveDefaultCategoryPromptTemplate(value)
setCategorySaved(true)
setTimeout(() => setCategorySaved(false), 2000)
}, 600))
}
const resetTaggingToDefault = async () => {
const def = AI_CONFIG.defaultCriterionTaggingPromptTemplate
await saveCriterionTaggingPromptTemplate(def)
setTaggingTemplate(def)
}
const resetChecklistTaggingToDefault = async () => {
const def = AI_CONFIG.defaultChecklistTaggingPromptTemplate
await saveChecklistTaggingPromptTemplate(def)
setChecklistTaggingTemplate(def)
}
const saveTaggingTemplate = (value: string) => {
setTaggingTemplate(value)
const prevTimer = saveTimer.current.get('tagging')
if (prevTimer) clearTimeout(prevTimer)
saveTimer.current.set('tagging', setTimeout(async () => {
await saveCriterionTaggingPromptTemplate(value)
setTaggingSaved(true)
setTimeout(() => setTaggingSaved(false), 2000)
}, 600))
2026-07-07 11:14:00 +02:00
}
const saveChecklistTaggingTemplate = (value: string) => {
setChecklistTaggingTemplate(value)
const prevTimer = saveTimer.current.get('checklistTagging')
if (prevTimer) clearTimeout(prevTimer)
saveTimer.current.set('checklistTagging', setTimeout(async () => {
await saveChecklistTaggingPromptTemplate(value)
setChecklistTaggingSaved(true)
setTimeout(() => setChecklistTaggingSaved(false), 2000)
}, 600))
}
2026-07-07 11:14:00 +02:00
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold text-gray-800">KI-Feedback (Kaskade)</h2>
2026-07-07 11:14:00 +02:00
<p className="text-sm text-gray-500 mt-1">
Stufe A: ein Call pro Kategorie (globaler Kategorie-Prompt). Stufe B: ein Call pro Dimension
(Verdichtung aus Kategorie-Ergebnissen — pro Dimension editierbar unten).
</p>
</div>
<div className="bg-white rounded-xl border border-blue-100 shadow-sm p-4 space-y-3">
<div className="text-sm font-semibold text-blue-800">OpenRouter API-Key</div>
<p className="text-xs text-gray-500">
Wird lokal im Browser gespeichert (<span className="font-mono">localStorage</span>) — nötig für
KI-Feedback und „Vorschläge analysieren". Key von{' '}
<span className="font-mono">openrouter.ai</span>.
2026-07-07 11:14:00 +02:00
</p>
<input
type="password"
className="w-full border border-blue-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand"
placeholder="sk-or-..."
value={aiKey}
onChange={e => setAiKey(e.target.value)}
/>
<select
className="w-full border border-blue-200 rounded-lg px-3 py-2 text-sm bg-white"
value={aiModel}
onChange={e => setAiModel(e.target.value)}>
<option value="openai/gpt-4o-mini">GPT-4o mini (günstig)</option>
<option value="openai/gpt-4o">GPT-4o</option>
<option value="anthropic/claude-sonnet-4-5">Claude Sonnet 4.5</option>
<option value="anthropic/claude-haiku-4-5">Claude Haiku 4.5 (schnell)</option>
<option value="google/gemini-flash-1.5">Gemini Flash 1.5</option>
</select>
<button
onClick={() => {
saveAiSettings(aiKey, aiModel)
setAiSaved(true)
setTimeout(() => setAiSaved(false), 2000)
}}
className="w-full py-2.5 bg-brand text-white rounded-xl text-sm font-semibold">
API-Key speichern
</button>
{aiSaved && (
<div className="text-xs text-green-700 bg-green-50 border border-green-200 rounded-lg px-3 py-2 text-center">
Gespeichert.
</div>
)}
</div>
{/* Kategorie-Prompt Stufe A */}
<div className="bg-white rounded-xl border border-green-100 shadow-sm overflow-hidden">
<button
onClick={() => setCategoryExpanded(p => !p)}
className="w-full flex items-center justify-between p-3 bg-green-50 border-b border-green-100 text-left">
<span className="font-semibold text-sm text-green-800">Stufe A — Kategorie-Prompt (1 Call pro Kategorie)</span>
<span className="text-xs text-green-400">{categoryExpanded ? '▲' : '▼'}</span>
</button>
{categoryExpanded && (
<div className="p-3 space-y-2">
<p className="text-xs text-gray-500">
Wird für jede Kategorie einzeln aufgerufen. Der Trend-Vorschlag aus Meetings wird im Prompt
vorgegeben; die KI bestätigt oder passt maximal ±1 an.
</p>
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-xs font-mono focus:outline-none focus:border-brand min-h-[280px]"
value={categoryTemplate}
onChange={e => saveCategoryTemplate(e.target.value)}
/>
{categorySaved && <span className="text-xs text-green-600 font-medium">Gespeichert.</span>}
<div className="grid gap-1.5 pt-2">
{AI_CONFIG.categoryPromptPlaceholders.map(p => (
<div key={p.token} className="text-xs text-gray-600 flex gap-2">
<code className="bg-white border border-gray-200 rounded px-1.5 py-0.5 text-brand font-mono flex-shrink-0">{p.token}</code>
<span>{p.description}</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Standard-Prompt Stufe B */}
<div className="bg-white rounded-xl border border-blue-100 shadow-sm overflow-hidden">
<button
onClick={() => setDefaultExpanded(p => !p)}
className="w-full flex items-center justify-between p-3 bg-blue-50 border-b border-blue-100 text-left">
<span className="font-semibold text-sm text-blue-800">Stufe B — Dimensions-Verdichtung (Basis für neue Dimensionen &amp; „Zurücksetzen")</span>
<span className="text-xs text-blue-400">{defaultExpanded ? '▲' : '▼'}</span>
</button>
{defaultExpanded && (
<div className="p-3 space-y-2">
<p className="text-xs text-gray-500">
Input sind nur die Kategorie-Ergebnisse aus Stufe A — kein Rohprotokoll. Wird beim Anlegen
neuer Dimensionen automatisch übernommen und ist die Basis für „Auf Standard zurücksetzen".
</p>
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-xs font-mono focus:outline-none focus:border-brand min-h-[320px]"
value={defaultTemplate}
onChange={e => saveDefaultTemplate(e.target.value)}
/>
{defaultSaved && <span className="text-xs text-green-600 font-medium">Gespeichert.</span>}
</div>
)}
</div>
<div className="flex items-center gap-3 flex-wrap">
<button
onClick={resetAllToDefault}
className="text-xs bg-amber-50 hover:bg-amber-100 text-amber-800 border border-amber-200 px-3 py-1.5 rounded-lg font-medium">
Alle Dimension-Prompts auf Standard (Stufe B) setzen
</button>
{bulkResetMsg && <span className="text-xs text-green-600 font-medium">{bulkResetMsg}</span>}
2026-07-07 11:14:00 +02:00
</div>
{/* Platzhalter-Legende */}
<div className="bg-gray-50 rounded-xl border border-gray-100 p-4 space-y-2">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Verfügbare Platzhalter</div>
<div className="grid gap-1.5">
{AI_CONFIG.dimensionPromptPlaceholders.map(p => (
<div key={p.token} className="text-xs text-gray-600 flex gap-2">
<code className="bg-white border border-gray-200 rounded px-1.5 py-0.5 text-brand font-mono flex-shrink-0">{p.token}</code>
<span>{p.description}</span>
</div>
))}
</div>
</div>
<div>
<h2 className="text-lg font-bold text-gray-800">KI-Prompts: Kriterien-Zuordnungsvorschläge</h2>
<p className="text-sm text-gray-500 mt-1">
„🏷 Vorschläge analysieren" im Meeting löst <strong>zwei getrennte KI-Calls</strong> aus: Protokoll-Freitext
(Antwortformat <code className="font-mono text-xs">ZEILE n:</code>) und Checklistenpunkte
(<code className="font-mono text-xs">PUNKT n:</code>).
</p>
</div>
<div className="bg-white rounded-xl border border-indigo-100 shadow-sm overflow-hidden">
<button
onClick={() => setTaggingExpanded(p => !p)}
className="w-full flex items-center justify-between p-3 bg-indigo-50 border-b border-indigo-100 text-left">
<span className="font-semibold text-sm text-indigo-800">Protokoll — Tagging-Prompt</span>
<span className="text-xs text-indigo-400">{taggingExpanded ? '▲' : '▼'}</span>
</button>
{taggingExpanded && (
<div className="p-3 space-y-2">
<p className="text-xs text-gray-500">Freitext-Zeilen aus Gesprächsprotokoll und allgemeinen Notizen.</p>
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-xs font-mono focus:outline-none focus:border-brand min-h-[280px]"
value={taggingTemplate}
onChange={e => saveTaggingTemplate(e.target.value)}
/>
{taggingSaved && <span className="text-xs text-green-600 font-medium">Gespeichert.</span>}
<button onClick={resetTaggingToDefault}
className="text-xs bg-gray-100 hover:bg-gray-200 text-gray-600 px-3 py-1.5 rounded-lg font-medium">
Protokoll-Tagging auf Standard zurücksetzen
</button>
</div>
)}
</div>
<div className="bg-gray-50 rounded-xl border border-gray-100 p-4 space-y-2">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Platzhalter Protokoll-Tagging</div>
<div className="grid gap-1.5">
{AI_CONFIG.criterionTaggingPromptPlaceholders.map(p => (
<div key={p.token} className="text-xs text-gray-600 flex gap-2">
<code className="bg-white border border-gray-200 rounded px-1.5 py-0.5 text-brand font-mono flex-shrink-0">{p.token}</code>
<span>{p.description}</span>
</div>
))}
</div>
</div>
<div className="bg-white rounded-xl border border-teal-100 shadow-sm overflow-hidden">
<button
onClick={() => setChecklistTaggingExpanded(p => !p)}
className="w-full flex items-center justify-between p-3 bg-teal-50 border-b border-teal-100 text-left">
<span className="font-semibold text-sm text-teal-800">Checkliste — Tagging-Prompt</span>
<span className="text-xs text-teal-400">{checklistTaggingExpanded ? '▲' : '▼'}</span>
</button>
{checklistTaggingExpanded && (
<div className="p-3 space-y-2">
<p className="text-xs text-gray-500">
Strukturierte Prüfpunkte — nur Label (kein Score). Antwortformat: <code className="font-mono">PUNKT n:</code>.
In Config → Ass.-Typen als „Standard-Zuordnung (KI)" nutzbar; spart Meeting-Calls.
</p>
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-xs font-mono focus:outline-none focus:border-brand min-h-[280px]"
value={checklistTaggingTemplate}
onChange={e => saveChecklistTaggingTemplate(e.target.value)}
/>
{checklistTaggingSaved && <span className="text-xs text-green-600 font-medium">Gespeichert.</span>}
<button onClick={resetChecklistTaggingToDefault}
className="text-xs bg-gray-100 hover:bg-gray-200 text-gray-600 px-3 py-1.5 rounded-lg font-medium">
Checklisten-Tagging auf Standard zurücksetzen
</button>
</div>
)}
</div>
<div className="bg-gray-50 rounded-xl border border-gray-100 p-4 space-y-2">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Platzhalter Checklisten-Tagging</div>
<div className="grid gap-1.5">
{AI_CONFIG.checklistTaggingPromptPlaceholders.map(p => (
<div key={p.token} className="text-xs text-gray-600 flex gap-2">
<code className="bg-white border border-gray-200 rounded px-1.5 py-0.5 text-brand font-mono flex-shrink-0">{p.token}</code>
<span>{p.description}</span>
</div>
))}
2026-07-07 11:14:00 +02:00
</div>
</div>
{/* Dimensionen */}
{dimensions.map(dim => {
const isOpen = expanded.has(dim.id!)
const prompt = getPrompt(dim.id!)
return (
<div key={dim.id} className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
<button
onClick={() => setExpanded(prev => {
const next = new Set(prev)
if (next.has(dim.id!)) next.delete(dim.id!); else next.add(dim.id!)
return next
})}
className="w-full flex items-center justify-between p-3 bg-gray-50 border-b border-gray-100 text-left">
<span className="font-semibold text-sm text-gray-800">{dim.name}</span>
<span className="text-xs text-gray-400">{isOpen ? '▲' : '▼'}</span>
</button>
{isOpen && (
<div className="p-3 space-y-2">
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-xs font-mono focus:outline-none focus:border-brand min-h-[320px]"
value={prompt?.promptTemplate ?? ''}
onChange={e => saveTemplate(dim.id!, e.target.value)}
/>
<div className="flex items-center gap-2">
<button onClick={() => resetToDefault(dim.id!)}
className="text-xs bg-gray-100 hover:bg-gray-200 text-gray-600 px-3 py-1.5 rounded-lg font-medium">
Auf Standard zurücksetzen
</button>
{savedDimId === dim.id && (
<span className="text-xs text-green-600 font-medium">Gespeichert.</span>
)}
</div>
</div>
)}
</div>
)
})}
{dimensions.length === 0 && (
<div className="text-sm text-gray-400 text-center py-6">
Noch keine Dimensionen angelegt — siehe Tab „Feedback".
</div>
)}
</div>
)
}