This commit is contained in:
team3
2026-07-23 11:54:31 +02:00
parent f07ef9653d
commit 09f9ea74a6
18 changed files with 364 additions and 85 deletions

View File

@@ -1,14 +1,40 @@
# Nach .env kopieren und Werte setzen. Die .env ist die EINZIGE Stelle, an der
# Modelle stehen — Code enthält keine Defaults und keine Fallbacks (Leitprinzip 2).
# Die .env wird nur beim Serverstart gelesen — nach Änderungen Backend neu starten.
# --- Provider-Zugang ---------------------------------------------------------
# Pflicht, sobald eine Rolle den Provider minimax nutzt. Fehlt oder leer =>
# Vorflug stoppt den Lauf hart. Wird auch von dev-ops/opencode-slim.json gelesen.
MINIMAX_API_KEY=
# Rollen-Belegung (Format provider:modell). Start-Belegung: alles MiniMax.
# --- Rollen-Belegung (Pflicht, Format: provider:modell) -----------------------
# provider: minimax (opencode-CLI) oder claude. Fehlt/leer/ungültig => harter
# Stopp, nie ein stiller Default.
# modell bei minimax: <opencode-provider>/<modell> aus dev-ops/opencode-slim.json.
# Belegte MiniMax-Modelle:
# minimax/MiniMax-M3 nativ, mit Thinking — stärkstes (0 Fehler in Phase 0)
# minimax-kalt/MiniMax-M3 kalt, temperature 0.2, ohne Thinking
# minimax-kalt/MiniMax-M2.7-highspeed kalt, schwächer bei wörtlichen Zitaten
# Start-Belegung: alles MiniMax-M3 nativ (Evidenz: phase0/erkenntnisse.md, Erk. 2+3).
ROLLE_SCHNEIDEN=minimax:minimax/MiniMax-M3
ROLLE_NACHFASS=minimax:minimax/MiniMax-M3
ROLLE_SICHTEN=minimax:minimax/MiniMax-M3
ROLLE_JUDGE=minimax:minimax/MiniMax-M3
# Engine-Tuning (optional, Defaults siehe backend/config.py)
# MAX_CONCURRENT_AGENTS=6
# RAM_MIN_FREE_PCT=20
# --- Engine-Tuning (optional; Defaults im Code, backend/config.py) -------------
# Zum Ändern Zeile einkommentieren UND Wert setzen — nie leer lassen
# (leerer Wert crasht beim Import, int("")).
# KANBAN_BATCH=4 # Karten je Paket
# MAX_CARD_RETRIES=3 # danach Dead-Letter
# RETRY_BACKOFF=30 # Basis-Sekunden, exponentiell
# MAX_CONCURRENT_AGENTS=6 # Prozess-Tier (opencode/claude), ~310 MB je Agent
# MAX_CONCURRENT_AGENTS_PER_TOPIC=6 # Cap je Projekt
# MAX_CONCURRENT_API_AGENTS=12 # direkter API-Tier (~0 RAM)
# RAM_MIN_FREE_PCT=20 # 0 = RAM-Gate aus
# AGENT_TIMEOUT=360 # Sekunden je LLM-Call
# OPENCODE_START_DELAY=0.5 # Pause zwischen opencode-Kaltstarts (Sek.)
# --- Schalter (optional) -------------------------------------------------------
# PLANER_TEXT_API=1 # 1 = direkter MiniMax-API-Pfad für tool-lose Calls; 0 = aus
# PLANER_FAKE_AGENTS=1 # 1 = deterministische Fake-Agenten (Tests, kein Key nötig);
# # leer oder 0 = aus

View File

@@ -29,7 +29,7 @@ import httpx
from config import (MAX_CONCURRENT_AGENTS, MAX_CONCURRENT_AGENTS_PER_TOPIC,
MAX_CONCURRENT_API_AGENTS, OPENCODE_CONFIG, PROVIDERS,
RAM_MIN_FREE_PCT, resolve_role)
RAM_MIN_FREE_PCT, fake_agents_aktiv, resolve_role)
log = logging.getLogger("planer.agents")
@@ -270,7 +270,7 @@ async def run_agent(
"""Ein LLM-Call der Rolle `role`. → (rc, stdout, stderr). Wirft AgentInfraError,
wenn die Infrastruktur nicht nutzbar ist (Rolle unkonfiguriert, Provider down,
leere Antwort ohne Session) — der Lauf soll dann pausieren, nicht raten."""
if os.getenv("PLANER_FAKE_AGENTS"): # Sekunden-Smoke: deterministisch statt LLM
if fake_agents_aktiv(): # Sekunden-Smoke: deterministisch statt LLM
import fake_agents
start = time.monotonic()
res = await fake_agents.respond(agent_key, prompt, capabilities)
@@ -514,7 +514,10 @@ async def _run_text_api(agent_key: str, prompt: str, timeout: int, model: str,
"messages": [{"role": "user", "content": prompt}],
**_api_model_opts(model),
}
headers = {"x-api-key": os.environ.get("MINIMAX_API_KEY", ""), "anthropic-version": _API_VERSION}
api_key = os.environ.get("MINIMAX_API_KEY")
if not api_key:
raise AgentInfraError("MINIMAX_API_KEY fehlt — in .env setzen")
headers = {"x-api-key": api_key, "anthropic-version": _API_VERSION}
track_key = agent_key
n = 2
while track_key in _active_api:

View File

@@ -61,6 +61,12 @@ def resolve_role(role: str) -> tuple[str, str]:
return provider, modell
def fake_agents_aktiv() -> bool:
"""PLANER_FAKE_AGENTS: leer oder "0" = aus. Ein truthy "0" würde sonst still den
Fake-Modus (und damit den Rollen-Vorflug-Check) aktivieren."""
return os.getenv("PLANER_FAKE_AGENTS", "").strip() not in ("", "0")
# --- Engine-Konstanten (Kanban + Agenten) -----------------------------------------
KANBAN_BATCH = int(os.getenv("KANBAN_BATCH", "4")) # Karten je Paket
MAX_CARD_RETRIES = int(os.getenv("MAX_CARD_RETRIES", "3")) # dann dead-letter

View File

@@ -1,5 +1,5 @@
"""Projektliste: konfigurierte Pfade aus projekte.txt (eine Zeile = absoluter Pfad,
#-Kommentare). Von Hand editierbar — UI-Verwaltung kommt später (YAGNI)."""
#-Kommentare). Von Hand editierbar; hinzufuegen/entfernen bedienen die UI."""
from pathlib import Path
import config
@@ -34,3 +34,41 @@ def finde(name: str) -> Path | None:
if p["name"] == name and "fehler" not in p:
return Path(p["pfad"])
return None
def hinzufuegen(pfad: str) -> None:
"""Hängt einen Pfad an projekte.txt an. Wirft ValueError bei ungültigem
Verzeichnis, Duplikat oder Basename-Kollision."""
p = Path(pfad).expanduser()
if not p.is_absolute():
raise ValueError("Pfad muss absolut sein")
if not p.is_dir():
raise ValueError(f"{p} ist kein Verzeichnis")
for e in lade():
if e["pfad"] == str(p):
raise ValueError(f"{p} ist schon eingetragen")
if e["name"] == p.name:
raise ValueError(f"Namenskollision mit {e['pfad']}")
text = ""
if config.PROJEKTE_DATEI.is_file():
text = config.PROJEKTE_DATEI.read_text(encoding="utf-8")
if text and not text.endswith("\n"):
text += "\n"
config.PROJEKTE_DATEI.write_text(text + str(p) + "\n", encoding="utf-8")
def entfernen(name: str) -> None:
"""Streicht den Eintrag aus projekte.txt — nur die Zeile; .planer/ im Repo
bleibt unberührt. Wirft ValueError, wenn der Name nicht eingetragen ist."""
if not config.PROJEKTE_DATEI.is_file():
raise ValueError(f"'{name}' nicht in projekte.txt")
rest, getroffen = [], False
for zeile in config.PROJEKTE_DATEI.read_text(encoding="utf-8").splitlines():
s = zeile.strip()
if s and not s.startswith("#") and Path(s).expanduser().name == name:
getroffen = True
continue
rest.append(zeile)
if not getroffen:
raise ValueError(f"'{name}' nicht in projekte.txt")
config.PROJEKTE_DATEI.write_text("\n".join(rest) + ("\n" if rest else ""), encoding="utf-8")

View File

@@ -24,6 +24,47 @@ async def health():
return {"ok": True}
@router.get("/fs")
async def fs(pfad: str = ""):
"""Ordner-Browser für die Projekt-Auswahl: nur Verzeichnisse, keine versteckten."""
basis = Path(pfad).expanduser() if pfad else Path.home()
if not basis.is_dir():
raise HTTPException(404, f"{basis} ist kein Verzeichnis")
basis = basis.resolve()
try:
kinder = sorted(basis.iterdir(), key=lambda p: p.name.lower())
except PermissionError:
raise HTTPException(403, f"kein Zugriff auf {basis}")
ordner = [{"name": k.name, "pfad": str(k), "hat_planer": (k / ".planer").is_dir()}
for k in kinder if k.is_dir() and not k.name.startswith(".")]
eltern = str(basis.parent) if basis.parent != basis else None
return {"pfad": str(basis), "eltern": eltern, "ordner": ordner}
@router.post("/projekte")
async def projekt_hinzufuegen(body: dict):
pfad = (body.get("pfad") or "").strip()
if not pfad:
raise HTTPException(400, "pfad fehlt")
try:
projekte.hinzufuegen(pfad)
except ValueError as e:
raise HTTPException(409, str(e))
return {"ok": True}
@router.delete("/projekte/{name}")
async def projekt_entfernen(name: str):
lauf = scan_dienst.status(name)
if lauf and lauf["laeuft"]:
raise HTTPException(409, "Scan läuft — erst abwarten oder abbrechen")
try:
projekte.entfernen(name)
except ValueError as e:
raise HTTPException(404, str(e))
return {"ok": True}
@router.get("/projekte")
async def liste():
out = []

View File

@@ -34,7 +34,7 @@ def vorflug(wurzel: Path) -> str:
raise VorflugFehler(f"{wurzel} ist kein git-Repo — Anker brauchen Commit-Hashes (git init && commit)")
if not repo.working_tree_sauber(wurzel):
raise VorflugFehler(f"{wurzel} hat uncommittete Änderungen — erst committen, dann scannen")
if not os.getenv("PLANER_FAKE_AGENTS"):
if not config.fake_agents_aktiv():
for rolle in config.ROLLEN:
try:
provider, _ = config.resolve_role(rolle)

View File

@@ -85,3 +85,34 @@ async def test_scan_409_bei_dirty_tree(client):
r = await c.post("/api/projekte/mini/scan", json={"art": "scan"})
assert r.status_code == 409
assert "uncommittete" in r.json()["detail"]
async def test_fs_browse(client, tmp_path):
c, _, _ = client
(tmp_path / "unter" / ".planer").mkdir(parents=True)
(tmp_path / ".versteckt").mkdir()
r = await c.get("/api/fs", params={"pfad": str(tmp_path)})
daten = r.json()
assert daten["pfad"] == str(tmp_path.resolve())
assert daten["eltern"] == str(tmp_path.resolve().parent)
ordner = {o["name"]: o for o in daten["ordner"]}
assert ordner["unter"]["hat_planer"] is True
assert ".versteckt" not in ordner
assert (await c.get("/api/fs", params={"pfad": str(tmp_path / "fehlt")})).status_code == 404
async def test_projekt_hinzufuegen_und_entfernen(client, tmp_path):
c, _, _ = client
neu = tmp_path / "neues-repo"
(neu / ".planer").mkdir(parents=True)
assert (await c.post("/api/projekte", json={"pfad": str(neu)})).status_code == 200
namen = [p["name"] for p in (await c.get("/api/projekte")).json()]
assert "neues-repo" in namen and "mini" in namen
assert (await c.post("/api/projekte", json={"pfad": str(neu)})).status_code == 409
assert (await c.delete("/api/projekte/neues-repo")).status_code == 200
namen = [p["name"] for p in (await c.get("/api/projekte")).json()]
assert "neues-repo" not in namen and "mini" in namen
assert (neu / ".planer").is_dir() # Artefakte bleiben erhalten
assert (await c.delete("/api/projekte/neues-repo")).status_code == 404

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

64
frontend/dist/assets/index-PAbaEjVj.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -13,8 +13,8 @@
if (dark) document.documentElement.classList.add('dark')
})()
</script>
<script type="module" crossorigin src="/assets/index-DIiv29cS.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-AxkPXHNh.css">
<script type="module" crossorigin src="/assets/index-PAbaEjVj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CzKrDvqu.css">
</head>
<body>
<div id="app"></div>

View File

@@ -4,10 +4,19 @@ import { fetchProjekte } from './api.js'
import { usePolling } from './composables/usePolling.js'
import StartSeite from './components/StartSeite.vue'
import ProjektAnsicht from './components/ProjektAnsicht.vue'
import OrdnerBrowser from './components/OrdnerBrowser.vue'
const projekte = ref([])
const aktivesProjekt = ref(null) // null = Übersicht
const ansicht = ref('features')
const zeigeBrowser = ref(false)
const listeVersion = ref(0) // remountet StartSeite nach Hinzufügen
function hinzugefuegt() {
zeigeBrowser.value = false
listeVersion.value++
laden()
}
const ANSICHTEN = [
['features', 'Features', '▦'],
@@ -55,6 +64,7 @@ function toggleDark() {
<span v-else-if="p.drift?.length" class="punkt gelb" title="Drift"></span>
<span v-else-if="p.hat_planer" class="punkt gruen" title="aktuell"></span>
</a>
<a href="#" class="hinzu" @click.prevent="zeigeBrowser = true"> Projekt hinzufügen</a>
</nav>
<nav v-if="aktivesProjekt" class="ansichten">
<span class="gruppe">{{ aktivesProjekt }}</span>
@@ -66,9 +76,11 @@ function toggleDark() {
<button class="dark-knopf" @click="toggleDark">{{ darkMode ? ' Hell' : ' Dunkel' }}</button>
</aside>
<main>
<StartSeite v-if="!aktivesProjekt" @oeffne="oeffne" />
<StartSeite v-if="!aktivesProjekt" :key="listeVersion" @oeffne="oeffne" @geaendert="laden" />
<ProjektAnsicht v-else :name="aktivesProjekt" :ansicht="ansicht" />
</main>
<OrdnerBrowser v-if="zeigeBrowser" @schliessen="zeigeBrowser = false"
@hinzugefuegt="hinzugefuegt" />
</div>
</template>
@@ -93,6 +105,8 @@ nav a {
nav a:hover { background: var(--bg); }
nav a.aktiv { background: var(--akzent); color: var(--akzent-text); }
nav a.gestoert { color: var(--dezent); cursor: default; }
nav a.hinzu { color: var(--dezent); font-size: 0.85rem; }
nav a.hinzu:hover { color: var(--text); }
.icon { width: 1.1rem; text-align: center; color: inherit; opacity: 0.75; }
.punkt { width: 8px; height: 8px; border-radius: 99px; margin-left: auto; }
.punkt.gruen { background: var(--gruen); }

View File

@@ -34,6 +34,11 @@ async function req(path, { method = 'GET', body, query } = {}) {
}
export const fetchProjekte = () => req('/projekte')
export const fetchOrdner = (pfad) => req('/fs', { query: { pfad } })
export const projektHinzufuegen = (pfad) =>
req('/projekte', { method: 'POST', body: { pfad } })
export const projektEntfernen = (name) =>
req(`/projekte/${encodeURIComponent(name)}`, { method: 'DELETE' })
export const fetchProjekt = (name) => req(`/projekte/${encodeURIComponent(name)}`)
export const fetchEinheit = (name, id) =>
req(`/projekte/${encodeURIComponent(name)}/einheit`, { query: { id } })

View File

@@ -0,0 +1,94 @@
<script setup>
import { onMounted, ref } from 'vue'
import { fetchOrdner, projektHinzufuegen } from '../api.js'
const emit = defineEmits(['schliessen', 'hinzugefuegt'])
const pfad = ref('')
const eingabe = ref('')
const eltern = ref(null)
const ordner = ref([])
const fehler = ref('')
async function lade(ziel) {
try {
const d = await fetchOrdner(ziel)
pfad.value = d.pfad
eingabe.value = d.pfad
eltern.value = d.eltern
ordner.value = d.ordner
fehler.value = ''
} catch (e) {
fehler.value = e.message
}
}
async function uebernehmen() {
try {
await projektHinzufuegen(pfad.value)
emit('hinzugefuegt')
} catch (e) {
fehler.value = e.message
}
}
onMounted(() => lade(''))
</script>
<template>
<div class="schleier" @click.self="$emit('schliessen')" @keydown.esc="$emit('schliessen')">
<div class="dialog">
<div class="kopf">
<h3>Projekt-Ordner wählen</h3>
<button class="zu" @click="$emit('schliessen')"></button>
</div>
<input v-model="eingabe" spellcheck="false"
@keydown.enter="lade(eingabe)" title="Pfad eintippen, Enter lädt" />
<div class="liste">
<a v-if="eltern" href="#" class="eintrag" @click.prevent="lade(eltern)"> ..</a>
<a v-for="o in ordner" :key="o.pfad" href="#" class="eintrag"
@click.prevent="lade(o.pfad)">
<span class="ordner-icon"></span>{{ o.name }}
<span v-if="o.hat_planer" class="badge gruen">.planer</span>
</a>
<p v-if="!ordner.length" class="leer">keine Unterordner</p>
</div>
<p v-if="fehler" class="fehler">{{ fehler }}</p>
<div class="fuss">
<code class="gewaehlt">{{ pfad }}</code>
<button class="primaer" @click="uebernehmen">Als Projekt übernehmen</button>
</div>
</div>
</div>
</template>
<style scoped>
.schleier {
position: fixed; inset: 0; background: rgb(0 0 0 / 0.4); z-index: 50;
display: flex; align-items: center; justify-content: center;
}
.dialog {
background: var(--panel); border: 1px solid var(--rand); border-radius: 12px;
width: min(560px, 92vw); max-height: 80vh; padding: 1rem 1.2rem;
display: flex; flex-direction: column; gap: 0.7rem;
}
.kopf { display: flex; align-items: center; }
.kopf h3 { margin: 0; font-size: 1rem; flex: 1; }
.zu { border: none; background: none; color: var(--dezent); padding: 0.2rem; }
input {
font: inherit; color: var(--text); background: var(--bg);
border: 1px solid var(--rand); border-radius: 6px; padding: 0.4rem 0.6rem;
}
.liste { overflow-y: auto; min-height: 8rem; display: flex; flex-direction: column; }
.eintrag {
display: flex; align-items: center; gap: 0.45rem;
color: var(--text); text-decoration: none; font-size: 0.92rem;
padding: 0.3rem 0.5rem; border-radius: 6px;
}
.eintrag:hover { background: var(--bg); }
.ordner-icon { color: var(--dezent); }
.leer { color: var(--dezent); font-size: 0.85rem; margin: 0.3rem 0.5rem; }
.fehler { color: var(--rot); font-size: 0.85rem; margin: 0; }
.fuss { display: flex; align-items: center; gap: 0.8rem; border-top: 1px solid var(--rand); padding-top: 0.7rem; }
.gewaehlt { flex: 1; font-size: 0.8rem; overflow-wrap: anywhere; color: var(--dezent); }
</style>

View File

@@ -1,6 +1,6 @@
<script setup>
import { ref } from 'vue'
import { starteScan } from '../api.js'
import { projektEntfernen, starteScan } from '../api.js'
const props = defineProps({ projekt: { type: Object, required: true } })
const emit = defineEmits(['oeffne', 'aktualisiert'])
@@ -16,6 +16,19 @@ async function scan(art) {
}
}
async function entfernen() {
const ok = confirm(
`${props.projekt.name}" aus der Projektliste entfernen?\n.planer/ im Repo bleibt erhalten.`)
if (!ok) return
meldung.value = ''
try {
await projektEntfernen(props.projekt.name)
emit('aktualisiert')
} catch (e) {
meldung.value = e.message
}
}
function zeit(ts) {
if (!ts) return 'nie gescannt'
return new Date(ts).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
@@ -39,10 +52,14 @@ function zeit(ts) {
{{ projekt.pfad }} · letzter Scan: {{ zeit(projekt.letzter_scan_ts) }}
</p>
<p class="detail drift" v-if="projekt.drift?.length">veraltet: {{ projekt.drift.join(', ') }}</p>
<div class="aktionen" v-if="!projekt.fehler">
<div class="aktionen">
<template v-if="!projekt.fehler">
<button v-if="!projekt.hat_planer" class="primaer" :disabled="projekt.scan_laeuft"
@click="scan('scan')">Scannen</button>
<button v-else :disabled="projekt.scan_laeuft" @click="scan('rescan')">Re-Scan</button>
</template>
<button class="entfernen" :disabled="projekt.scan_laeuft" @click="entfernen"
title=".planer/ im Repo bleibt erhalten">Entfernen</button>
</div>
<p v-if="meldung" class="meldung">{{ meldung }}</p>
</div>
@@ -61,6 +78,8 @@ function zeit(ts) {
a.name:hover { color: var(--akzent); }
.detail { color: var(--dezent); font-size: 0.85rem; margin: 0.3rem 0 0; overflow-wrap: anywhere; }
.drift { color: var(--gelb); }
.aktionen { margin-top: 0.7rem; }
.aktionen { margin-top: 0.7rem; display: flex; gap: 0.5rem; }
.entfernen { margin-left: auto; color: var(--dezent); }
.entfernen:hover:not(:disabled) { color: var(--rot); border-color: var(--rot); }
.meldung { color: var(--rot); font-size: 0.85rem; margin: 0.5rem 0 0; }
</style>

View File

@@ -4,7 +4,7 @@ import { fetchProjekte } from '../api.js'
import { usePolling } from '../composables/usePolling.js'
import ProjektKarte from './ProjektKarte.vue'
defineEmits(['oeffne'])
defineEmits(['oeffne', 'geaendert'])
const projekte = ref([])
const fehler = ref('')
@@ -32,7 +32,8 @@ onMounted(async () => {
</p>
<div class="liste">
<ProjektKarte v-for="p in projekte" :key="p.name" :projekt="p"
@oeffne="$emit('oeffne', p.name)" @aktualisiert="laden(); poll.start()" />
@oeffne="$emit('oeffne', p.name)"
@aktualisiert="laden(); poll.start(); $emit('geaendert')" />
</div>
</template>

View File

@@ -1,2 +1,3 @@
/home/arbeit/projects/creator
/home/arbeit/projects/planer
/home/arbeit/projects/tft