CG-Feedback-Monitor/src/pages/AiPromptConfig.tsx
Lars d894c3c8e4 V2-Stand: UX-Dashboard, Personen v2, Starter-Paket und Dexie-Legacy entfernt.
Sichert den lokalen Entwicklungsstand nach Gitea: Nav/Dashboard, Personen-Excel/Archiv, Team-Setup, Assignment-Übergabe und C6/C7/T2.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 14:17:25 +02:00

401 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from 'react'
import {
type FeedbackDimension, type FeedbackDimensionPrompt,
loadFeedbackStructure, listFeedbackDimensionPrompts, updateFeedbackDimensionPrompt,
resetAllDimensionPromptsToDefault,
getOrSeedDefaultDimensionPromptTemplate, saveDefaultDimensionPromptTemplate,
getOrSeedDefaultCategoryPromptTemplate, saveDefaultCategoryPromptTemplate,
getOrSeedCriterionTaggingPromptTemplate, saveCriterionTaggingPromptTemplate,
getOrSeedChecklistTaggingPromptTemplate, saveChecklistTaggingPromptTemplate,
} from '../db'
import { AI_CONFIG } from '../config/constants'
import { getAiSettings, saveAiSettings } from '../utils/aiClient'
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)
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)
const load = async () => {
const [structure, promptRows, defTemplate, catTemplate, taggingTpl, checklistTaggingTpl] = await Promise.all([
loadFeedbackStructure(),
listFeedbackDimensionPrompts(),
getOrSeedDefaultDimensionPromptTemplate(),
getOrSeedDefaultCategoryPromptTemplate(),
getOrSeedCriterionTaggingPromptTemplate(),
getOrSeedChecklistTaggingPromptTemplate(),
])
setDimensions([...structure.dimensions].sort((a, b) => a.order - b.order))
setPrompts(promptRows)
setDefaultTemplate(defTemplate)
setCategoryTemplate(catTemplate)
setTaggingTemplate(taggingTpl)
setChecklistTaggingTemplate(checklistTaggingTpl)
}
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))
}
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))
}
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold text-gray-800">KI-Feedback (Kaskade)</h2>
<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>.
</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>}
</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>
))}
</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>
)
}