459 lines
13 KiB
Vue
459 lines
13 KiB
Vue
<script setup>
|
||
import { ref, watch } from 'vue'
|
||
import { updateElement, checkElement, styleElement, refineSuggestion } from '../../api.js'
|
||
import { renderMarkdown } from '../../markdown.js'
|
||
import ElementSuggestion from './ElementSuggestion.vue'
|
||
import ElementChatTab from './ElementChatTab.vue'
|
||
import ElementEditTab from './ElementEditTab.vue'
|
||
|
||
const props = defineProps({
|
||
element: { type: Object, required: true },
|
||
provider: { type: String, default: 'claude' },
|
||
})
|
||
|
||
const emit = defineEmits(['back', 'close', 'updated', 'changed'])
|
||
|
||
const tab = ref('overview') // 'overview' | 'chat' | 'edit'
|
||
const savingEdit = ref(false)
|
||
|
||
// Markdown-Zeichen aus dem Header-Titel entfernen
|
||
function plain(text) {
|
||
return (text || '').replace(/```[a-z]*\n?/g, '').replace(/[`*_#]/g, '')
|
||
}
|
||
|
||
// Anderes Element gewählt → Prüf-Zustand und Tab zurücksetzen
|
||
watch(() => props.element.id, () => {
|
||
tab.value = 'overview'
|
||
resetCheck()
|
||
})
|
||
|
||
// --- KI-Prüfung auf fehlende Infos (Ergebnisse landen als Inline-Vorschläge) ---
|
||
const checking = ref(false)
|
||
const statusMsg = ref(null)
|
||
|
||
function resetCheck() {
|
||
checking.value = false
|
||
statusMsg.value = null
|
||
resetStyle()
|
||
}
|
||
|
||
let checkRun = 0 // laufende Prüfung identifizieren; Abbruch ignoriert ihr Ergebnis
|
||
|
||
async function runCheck() {
|
||
if (checking.value) { // zweiter Klick = abbrechen
|
||
checkRun++
|
||
checking.value = false
|
||
return
|
||
}
|
||
const run = ++checkRun
|
||
checking.value = true
|
||
statusMsg.value = null
|
||
try {
|
||
const res = await checkElement(props.element.id, props.provider)
|
||
if (run !== checkRun) return // abgebrochen oder neue Prüfung gestartet
|
||
const mapped = res.suggestions.map((s) => ({
|
||
text: s.text, action: 'hinzufuegen', target: s.target, index: null, content: s.content,
|
||
}))
|
||
if (mapped.length) styleChanges.value = [...(styleChanges.value || []), ...mapped]
|
||
else statusMsg.value = 'Keine wichtigen Lücken gefunden.'
|
||
} catch (e) {
|
||
if (run !== checkRun) return
|
||
console.error('Prüfung fehlgeschlagen:', e)
|
||
statusMsg.value = 'Prüfung fehlgeschlagen — bitte erneut versuchen.'
|
||
} finally {
|
||
if (run === checkRun) checking.value = false
|
||
}
|
||
}
|
||
|
||
// --- Stil-Prüfung: KI schlägt Änderungen vor, Nutzer bestätigt ---
|
||
const styleChanges = ref(null) // null = noch nicht geprüft
|
||
const styling = ref(false)
|
||
const applyingStyle = ref(false)
|
||
const refiningIdx = ref(null)
|
||
let styleRun = 0
|
||
|
||
function resetStyle() {
|
||
styleChanges.value = null
|
||
styling.value = false
|
||
applyingStyle.value = false
|
||
refiningIdx.value = null
|
||
}
|
||
|
||
function suggBusy(i) {
|
||
return applyingStyle.value || refiningIdx.value === i
|
||
}
|
||
|
||
// Einzelnen Vorschlag per Anweisung überarbeiten (Stift-Icon)
|
||
async function refineChange(i, instruction) {
|
||
if (refiningIdx.value !== null || applyingStyle.value) return
|
||
refiningIdx.value = i
|
||
try {
|
||
const res = await refineSuggestion(props.element.id, styleChanges.value[i], instruction, props.provider)
|
||
const next = [...styleChanges.value]
|
||
next[i] = res.change
|
||
styleChanges.value = next
|
||
} catch (e) {
|
||
console.error('Überarbeitung fehlgeschlagen:', e)
|
||
statusMsg.value = 'Überarbeitung fehlgeschlagen — bitte erneut versuchen.'
|
||
} finally {
|
||
refiningIdx.value = null
|
||
}
|
||
}
|
||
|
||
async function runStyle() {
|
||
if (styling.value) { // zweiter Klick = abbrechen
|
||
styleRun++
|
||
styling.value = false
|
||
return
|
||
}
|
||
const run = ++styleRun
|
||
styling.value = true
|
||
statusMsg.value = null
|
||
try {
|
||
const res = await styleElement(props.element.id, props.provider)
|
||
if (run !== styleRun) return
|
||
if (res.changes.length) styleChanges.value = [...(styleChanges.value || []), ...res.changes]
|
||
else statusMsg.value = 'Stil passt bereits.'
|
||
} catch (e) {
|
||
if (run !== styleRun) return
|
||
console.error('Stil-Prüfung fehlgeschlagen:', e)
|
||
statusMsg.value = 'Stil-Prüfung fehlgeschlagen — bitte erneut versuchen.'
|
||
} finally {
|
||
if (run === styleRun) styling.value = false
|
||
}
|
||
}
|
||
|
||
// Chat-Vorschläge landen ebenfalls als Inline-Vorschläge in der Übersicht
|
||
function onChatChanges(changes) {
|
||
styleChanges.value = [...(styleChanges.value || []), ...changes]
|
||
}
|
||
|
||
// Vorschläge am Ziel-Ort anzeigen: anpassen/entfernen beim betroffenen Eintrag …
|
||
function styleAt(target, index = null) {
|
||
if (!styleChanges.value) return []
|
||
return styleChanges.value
|
||
.map((c, i) => [i, c])
|
||
.filter(([, c]) => c.target === target && c.index === index && c.action !== 'hinzufuegen')
|
||
}
|
||
|
||
// … Ergänzungen am Ende der jeweiligen Sektion
|
||
function styleAdds(target) {
|
||
if (!styleChanges.value) return []
|
||
return styleChanges.value
|
||
.map((c, i) => [i, c])
|
||
.filter(([, c]) => c.target === target && c.action === 'hinzufuegen')
|
||
}
|
||
|
||
function dismissStyleChange(i) {
|
||
styleChanges.value = styleChanges.value.filter((_, j) => j !== i)
|
||
}
|
||
|
||
async function applyStyleChange(i) {
|
||
if (applyingStyle.value) return
|
||
const c = styleChanges.value[i]
|
||
applyingStyle.value = true
|
||
try {
|
||
const STRING_TARGETS = ['title', 'description']
|
||
const fields = {
|
||
title: props.element.title,
|
||
description: props.element.description,
|
||
examples: [...props.element.examples],
|
||
hints: [...props.element.hints],
|
||
}
|
||
if (c.action === 'entfernen') fields[c.target].splice(c.index, 1)
|
||
else if (c.action === 'hinzufuegen') {
|
||
if (c.target === 'title') fields.title = c.content
|
||
else if (c.target === 'description')
|
||
fields[c.target] = fields[c.target] ? fields[c.target] + '\n\n' + c.content : c.content
|
||
else fields[c.target].push(c.content)
|
||
} else if (STRING_TARGETS.includes(c.target)) fields[c.target] = c.content
|
||
else fields[c.target][c.index] = c.content
|
||
|
||
const updated = await updateElement(props.element.id, fields)
|
||
emit('updated', updated)
|
||
|
||
// Rest-Vorschläge behalten; Indizes hinter einer Entfernung rücken auf
|
||
const rest = styleChanges.value.filter((_, j) => j !== i)
|
||
if (c.action === 'entfernen') {
|
||
for (const r of rest) {
|
||
if (r.target === c.target && r.index !== null && r.index > c.index) r.index--
|
||
}
|
||
}
|
||
styleChanges.value = rest
|
||
} catch (e) {
|
||
console.error('Übernehmen fehlgeschlagen:', e)
|
||
} finally {
|
||
applyingStyle.value = false
|
||
}
|
||
}
|
||
|
||
// --- Bearbeiten-Tab: Felder direkt speichern ---
|
||
async function saveEdit(fields) {
|
||
if (savingEdit.value) return
|
||
savingEdit.value = true
|
||
try {
|
||
const updated = await updateElement(props.element.id, fields)
|
||
emit('updated', updated)
|
||
tab.value = 'overview'
|
||
} catch (e) {
|
||
console.error('Speichern fehlgeschlagen:', e)
|
||
} finally {
|
||
savingEdit.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<header class="el-header">
|
||
<button class="el-back" title="Zur Liste" @click="emit('back')">←</button>
|
||
<span class="el-title">{{ plain(element.title) }}</span>
|
||
<button
|
||
class="el-tool" :class="{ busy: checking }"
|
||
:title="checking ? 'Prüfung abbrechen' : 'Auf fehlende Infos prüfen'" @click="runCheck"
|
||
>🔍</button>
|
||
<button
|
||
class="el-tool" :class="{ busy: styling }"
|
||
:title="styling ? 'Prüfung abbrechen' : 'Stil prüfen & anpassen'" @click="runStyle"
|
||
>✨</button>
|
||
<button class="el-close" title="Schließen" @click="emit('close')">×</button>
|
||
</header>
|
||
|
||
<nav class="el-tabs">
|
||
<button :class="{ active: tab === 'overview' }" @click="tab = 'overview'">Übersicht</button>
|
||
<button :class="{ active: tab === 'chat' }" @click="tab = 'chat'">Chat</button>
|
||
<button :class="{ active: tab === 'edit' }" @click="tab = 'edit'">Bearbeiten</button>
|
||
</nav>
|
||
|
||
<!-- Übersicht: untrennbar mit styleChanges/Apply verzahnt → bleibt hier -->
|
||
<div v-show="tab === 'overview'" class="el-detail">
|
||
<div v-if="element.description" class="el-desc markdown" v-html="renderMarkdown(element.description)"></div>
|
||
<ElementSuggestion
|
||
v-for="[ci, c] in [...styleAt('title'), ...styleAt('description'), ...styleAdds('description')]"
|
||
:key="'sgd' + ci" :change="c" :busy="suggBusy(ci)"
|
||
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
|
||
/>
|
||
<template v-for="(ex, i) in element.examples" :key="i">
|
||
<div class="el-entry markdown" v-html="renderMarkdown(ex)"></div>
|
||
<ElementSuggestion
|
||
v-for="[ci, c] in styleAt('examples', i)"
|
||
:key="'sge' + ci" :change="c" :busy="suggBusy(ci)"
|
||
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
|
||
/>
|
||
</template>
|
||
<ElementSuggestion
|
||
v-for="[ci, c] in styleAdds('examples')"
|
||
:key="'sgea' + ci" :change="c" :busy="suggBusy(ci)"
|
||
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
|
||
/>
|
||
<div v-if="element.hints.length || styleAdds('hints').length" class="el-hints-block">
|
||
<h4>Hinweise</h4>
|
||
<ul class="el-hints">
|
||
<li v-for="(h, i) in element.hints" :key="i">
|
||
<span class="markdown" v-html="renderMarkdown(h)"></span>
|
||
<ElementSuggestion
|
||
v-for="[ci, c] in styleAt('hints', i)"
|
||
:key="'sgh' + ci" :change="c" :busy="suggBusy(ci)"
|
||
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
|
||
/>
|
||
</li>
|
||
</ul>
|
||
<ElementSuggestion
|
||
v-for="[ci, c] in styleAdds('hints')"
|
||
:key="'sgha' + ci" :change="c" :busy="suggBusy(ci)"
|
||
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
|
||
/>
|
||
</div>
|
||
|
||
<div v-if="checking || styling || statusMsg" class="el-check">
|
||
<p v-if="checking" class="check-empty busy-text">Prüft auf fehlende Infos…</p>
|
||
<p v-if="styling" class="check-empty busy-text">Prüft den Stil…</p>
|
||
<p v-if="statusMsg && !checking && !styling" class="check-empty">{{ statusMsg }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- v-show erhält den Chat-Verlauf beim Tab-Wechsel -->
|
||
<ElementChatTab
|
||
v-show="tab === 'chat'"
|
||
:element="element"
|
||
:provider="provider"
|
||
@changes="onChatChanges"
|
||
/>
|
||
|
||
<!-- v-if lädt die Edit-Felder bei jedem Öffnen frisch -->
|
||
<ElementEditTab
|
||
v-if="tab === 'edit'"
|
||
:element="element"
|
||
:saving="savingEdit"
|
||
@save="saveEdit"
|
||
/>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.el-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 0.6rem 0.9rem;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.el-title {
|
||
flex: 1;
|
||
font-weight: 600;
|
||
font-size: 0.9rem;
|
||
color: var(--text);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.el-back,
|
||
.el-close {
|
||
border: none;
|
||
background: none;
|
||
color: var(--text-faint);
|
||
font-size: 1.2rem;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
padding: 0 4px;
|
||
}
|
||
|
||
.el-back:hover,
|
||
.el-close:hover {
|
||
color: var(--text);
|
||
}
|
||
|
||
.el-tool {
|
||
border: none;
|
||
background: none;
|
||
font-size: 0.95rem;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
padding: 2px 3px;
|
||
border-radius: 6px;
|
||
filter: grayscale(0.4);
|
||
}
|
||
|
||
.el-tool:hover {
|
||
background: var(--panel-soft);
|
||
filter: none;
|
||
}
|
||
|
||
.el-tool.busy {
|
||
filter: none;
|
||
animation: pulse 1.5s ease-in-out infinite;
|
||
}
|
||
|
||
.busy-text {
|
||
animation: pulse 1.5s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
50% { opacity: 0.35; }
|
||
}
|
||
|
||
.el-tabs {
|
||
display: flex;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.el-tabs button {
|
||
flex: 1;
|
||
padding: 0.5rem 0.25rem;
|
||
border: none;
|
||
background: none;
|
||
color: var(--text-muted);
|
||
font-size: 0.8rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
border-bottom: 2px solid transparent;
|
||
}
|
||
|
||
.el-tabs button.active {
|
||
color: var(--accent);
|
||
border-bottom-color: var(--accent);
|
||
}
|
||
|
||
.el-tabs button:hover:not(.active) {
|
||
color: var(--text);
|
||
}
|
||
|
||
.el-detail {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 0.9rem;
|
||
}
|
||
|
||
.el-desc {
|
||
margin: 0 0 0.9rem;
|
||
font-size: 0.85rem;
|
||
line-height: 1.6;
|
||
color: var(--text);
|
||
}
|
||
|
||
.el-hints-block {
|
||
margin-top: 0.9rem;
|
||
}
|
||
|
||
.el-hints-block h4 {
|
||
margin: 0 0 0.35rem;
|
||
font-size: 0.72rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: var(--text-faint);
|
||
}
|
||
|
||
.el-entry {
|
||
font-size: 0.82rem;
|
||
line-height: 1.5;
|
||
color: var(--text);
|
||
margin-bottom: 0.4rem;
|
||
}
|
||
|
||
.el-entry:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.el-hints {
|
||
margin: 0;
|
||
padding-left: 1.1rem;
|
||
}
|
||
|
||
.el-hints li {
|
||
font-size: 0.82rem;
|
||
line-height: 1.5;
|
||
color: var(--text);
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
|
||
/* Hinweis-Text inline neben dem Bullet halten (p ist sonst block) */
|
||
.el-hints li > .markdown {
|
||
display: inline;
|
||
}
|
||
|
||
.el-hints li > .markdown :deep(p) {
|
||
display: inline;
|
||
margin: 0;
|
||
}
|
||
|
||
/* Markdown: Basis global (assets/markdown.css); schmale Sidebar → kompaktere Code-Blöcke */
|
||
.markdown :deep(pre) {
|
||
padding: 8px 10px;
|
||
}
|
||
|
||
/* --- KI-Prüfung --- */
|
||
.el-check {
|
||
margin-top: 1rem;
|
||
padding-top: 0.8rem;
|
||
border-top: 1px dashed var(--border-strong);
|
||
}
|
||
|
||
.check-empty {
|
||
margin: 0.6rem 0 0;
|
||
font-size: 0.78rem;
|
||
color: var(--text-faint);
|
||
text-align: center;
|
||
}
|
||
</style>
|