Auftragsbuch: Konzept-Ansicht fuer IDE-Projekte (KONZEPT.md inline)

IDE-Vorbereiten-Karten bekommen einen 'Konzept'-Knopf: MC2 holt KONZEPT.md
aus dem vorbereiteten Gitea-Repo (Gitea-Raw-API + Auth aus git-credentials)
und rendert es direkt im Auftragsbuch (leichter MD-Renderer lib/markdown.tsx).
So liest der Commander das ausgearbeitete Konzept in der GUI, ohne das Repo
aufzumachen (User-Wunsch: 'lesen gehoert in MC2, feilen in die IDE').

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-20 16:02:12 +02:00
parent ff8125d2ac
commit bf9c22ab78
31 changed files with 322 additions and 70 deletions
+10
View File
@@ -556,6 +556,16 @@ export interface IdeenErgebnis {
result: string
}
// Konzept eines vorbereiteten IDE-Projekts (KONZEPT.md aus dem Gitea-Repo)
export interface IdeenKonzept {
available: boolean
repo?: string | null
clone_url?: string
datei?: string
konzept?: string
error?: string
}
// ── Chronik (Timeline aus dem Melde-Briefkasten) ─────────────────────────────
export interface ChronikItem {
id: number
+71
View File
@@ -0,0 +1,71 @@
import { cn } from "@/lib/utils"
// Leichtgewichtiger Markdown-Renderer (Zeilen-basiert, XSS-frei da reines JSX,
// bewusst ohne externe Lib). Überschriften, Listen, fett/kursiv, `code`, Codeblöcke.
// Verwandt mit dem wiki-link-Renderer in WissenView, hier aber ohne [[links]] —
// gedacht für Doku-Anzeige (z. B. das KONZEPT.md eines vorbereiteten IDE-Projekts).
export function Markdown({ text, className }: { text: string; className?: string }) {
const lines = (text || "").split("\n")
const out: React.ReactNode[] = []
let list: React.ReactNode[] = []
let code: string[] | null = null
const flushList = (key: string) => {
if (list.length) {
out.push(<ul key={key} className="mb-3 ml-4 list-disc space-y-1">{list}</ul>)
list = []
}
}
lines.forEach((raw, i) => {
const key = `l${i}`
if (code !== null) {
if (raw.trimEnd() === "```") {
out.push(<pre key={key} className="mb-3 overflow-x-auto rounded-xl border border-border/50 bg-background/50 p-3 font-mono text-[11px] leading-relaxed text-foreground/85">{code.join("\n")}</pre>)
code = null
} else code.push(raw)
return
}
if (raw.trimStart().startsWith("```")) { flushList(key); code = []; return }
const line = raw.trimEnd()
if (!line.trim()) { flushList(key); return }
const h = line.match(/^(#{1,4})\s+(.*)$/)
if (h) {
flushList(key)
const level = h[1].length
const cls = level === 1 ? "text-base font-bold mt-1 mb-2" : level === 2 ? "text-sm font-bold mt-4 mb-1.5" : "text-xs font-bold mt-3 mb-1 uppercase tracking-wide text-muted-foreground"
out.push(<p key={key} className={cn(cls, "font-space text-foreground")}>{inline(h[2], key)}</p>)
return
}
const li = line.match(/^\s*[-*•]\s+(.*)$/)
if (li) {
list.push(<li key={key} className="text-xs leading-relaxed text-foreground/85">{inline(li[1], key)}</li>)
return
}
const ol = line.match(/^\s*\d+\.\s+(.*)$/)
if (ol) {
list.push(<li key={key} className="text-xs leading-relaxed text-foreground/85">{inline(ol[1], key)}</li>)
return
}
flushList(key)
out.push(<p key={key} className="mb-2 text-xs leading-relaxed text-foreground/85">{inline(line, key)}</p>)
})
flushList("end")
if (code !== null) out.push(<pre key="code-end" className="mb-3 overflow-x-auto rounded-xl border border-border/50 bg-background/50 p-3 font-mono text-[11px]">{(code as string[]).join("\n")}</pre>)
return <div className={cn("max-w-3xl", className)}>{out}</div>
}
// Inline: **fett**, *kursiv*, `code` — per Regex-Split, reines JSX (kein HTML-Inject).
function inline(text: string, keyBase: string): React.ReactNode[] {
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g)
return parts.map((p, i) => {
const key = `${keyBase}-${i}`
if (/^\*\*[^*]+\*\*$/.test(p)) return <b key={key} className="font-semibold text-foreground">{p.slice(2, -2)}</b>
if (/^\*[^*]+\*$/.test(p)) return <i key={key}>{p.slice(1, -1)}</i>
if (/^`[^`]+`$/.test(p)) return <code key={key} className="rounded bg-background/50 px-1 font-mono text-[11px] text-foreground/90">{p.slice(1, -1)}</code>
return <span key={key}>{p}</span>
})
}
+66 -2
View File
@@ -2,9 +2,10 @@ import { useEffect, useState } from "react"
import {
Inbox, Check, X, GitBranch, FileDiff, Loader2, AlertTriangle, Sparkles,
ChevronDown, ChevronRight, Clock, ClipboardCheck, Hammer, RefreshCw, Layers, Lightbulb, Send, HelpCircle,
RotateCcw, Square, ArrowUp, ArrowDown, Code2,
RotateCcw, Square, ArrowUp, ArrowDown, Code2, FileText, GitFork,
} from "lucide-react"
import { api, type AuftragItem, type IdeenErgebnis, type IdeenItem, type IdeenProjekt, type SkillKandidat } from "@/lib/api"
import { api, type AuftragItem, type IdeenErgebnis, type IdeenItem, type IdeenKonzept, type IdeenProjekt, type SkillKandidat } from "@/lib/api"
import { Markdown } from "@/lib/markdown"
import { useAuftragsbuch, useIdeen, useIdeenLog, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils"
@@ -167,6 +168,7 @@ function IdeenSection() {
const [sending, setSending] = useState(false)
const [busy, setBusy] = useState<Record<string, boolean>>({})
const [openLog, setOpenLog] = useState<string | null>(null)
const [openKonzept, setOpenKonzept] = useState<string | null>(null)
const [antworten, setAntworten] = useState<Record<string, string>>({})
const items = data?.items ?? []
@@ -329,6 +331,15 @@ function IdeenSection() {
{busy[it.id] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
</button>
)}
{it.welt === "ide" && (it.status === "running" || it.status === "done") && (
<button
onClick={() => setOpenKonzept(openKonzept === it.id ? null : it.id)}
title="Konzept ansehen (das ausgearbeitete KONZEPT.md)"
className={cn("flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[10px] font-bold transition-colors cursor-pointer",
openKonzept === it.id ? "text-violet-300" : "text-violet-300/70 hover:text-violet-200")}>
<FileText className="h-3.5 w-3.5" /> Konzept
</button>
)}
{canOpen && (
<button
onClick={() => setOpenLog(isOpen ? null : it.id)}
@@ -339,6 +350,8 @@ function IdeenSection() {
)}
</div>
{openKonzept === it.id && <KonzeptPanel id={it.id} />}
{it.status === "running" && it.notiz && (
<p className="-mt-1 ml-1 text-[11px] italic leading-relaxed text-sky-200/80" title="Jüngste Zwischenmeldung des Workers (Heartbeat)">
{it.notiz}
@@ -551,6 +564,57 @@ function ProjektGruppe({ projekt, karten, zeile }: {
)
}
// Konzept eines vorbereiteten IDE-Projekts: holt KONZEPT.md aus dem Gitea-Repo und
// rendert es direkt (User-Wunsch 20.07.: „lesen gehört in MC2"). Läuft die Vorbereitung
// noch, gibt es evtl. noch kein Repo/Konzept — dann ehrlicher Hinweis + Nachlade-Button.
function KonzeptPanel({ id }: { id: string }) {
const [data, setData] = useState<IdeenKonzept | null>(null)
const [fehler, setFehler] = useState(false)
const [versuch, setVersuch] = useState(0)
useEffect(() => {
let weg = false
setData(null); setFehler(false)
api<IdeenKonzept>(`/api/ideen/${id}/konzept`)
.then((r) => { if (!weg) setData(r) })
.catch(() => { if (!weg) setFehler(true) })
return () => { weg = true }
}, [id, versuch])
return (
<div className="ml-2 -mt-1">
<div className="rounded-lg border border-violet-500/25 bg-violet-500/[0.04] p-3">
<div className="mb-2 flex items-center gap-2">
<p className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wide text-violet-300">
<FileText className="h-3 w-3" /> Konzept
</p>
{data?.repo && (
<a href={`https://git.tobisniceshomelab.ddnsfree.com/${data.repo}`}
className="flex items-center gap-1 text-[10px] text-violet-300/70 hover:text-violet-200">
<GitFork className="h-3 w-3" /> {data.repo}
</a>
)}
<button onClick={() => setVersuch((v) => v + 1)} title="Neu laden"
className="ml-auto rounded p-0.5 text-muted-foreground/50 hover:text-foreground cursor-pointer">
<RefreshCw className="h-3 w-3" />
</button>
</div>
{fehler ? (
<p className="text-[11px] text-red-400">Konzept nicht ladbar später nochmal versuchen.</p>
) : data === null ? (
<p className="text-[10px] text-muted-foreground animate-pulse">Konzept wird geladen </p>
) : data.konzept ? (
<div className="max-h-[28rem] overflow-y-auto pr-1">
<Markdown text={data.konzept} />
</div>
) : (
<p className="text-[11px] text-muted-foreground italic">
{data.error || "Noch kein Konzept — das Projekt wird gerade vorbereitet."}
</p>
)}
</div>
</div>
)
}
// Ergebnis einer FERTIGEN Queue-Aufgabe: die Abschluss-Zusammenfassung des Workers
// (kanban_complete). Lazy — wird erst beim Aufklappen geladen, ändert sich nie mehr.
function ErgebnisPanel({ id }: { id: string }) {