feat(projects): Threads im Hauptchat verankert (Stefan-Konzept)

Projekte sind benannte Thema-Bündel die voice-gesteuert via Brain-Tools
geöffnet/verlassen werden. Default-Mode bleibt der Hauptthread — Projekte
sind eine optionale Bühne. Anchored-not-replaced: App-Open landet immer
im Hauptchat, Projekte sind nur sichtbar wenn aktiv betreten.

Brain:
- projects.py: CRUD + Fuzzy-Find + Active-State-Pointer
  (/shared/config/projects.json + active_project.txt).
- conversation.py: Turn.project_id-Feld + window(project_id) Filter.
- agent.py: 6 Meta-Tools — project_create / _enter / _exit / _list /
  _summary / _end. chat() liest aktive Projekt-ID, taggt User+Assistant-
  Turns damit, filtert das LLM-Window auf Projekt-Kontext und ergaenzt
  den System-Prompt um den aktiven Projekt-Hinweis. touch_project pflegt
  last_activity_at + turn_count.
- main.py: REST-Endpoints /projects/{status,list,create,switch,
  {id}/end,{id}/archive, PATCH /{id}}.

Bridge + RVS:
- aria_bridge.py: project_changed Event-Propagation Brain → RVS-Broadcast
  damit App + Diagnostic ihre Banner refreshen.
- rvs/server.js: project_changed in ALLOWED_TYPES.

App:
- brainApi.ts: Project-Type + 6 API-Methoden.
- ProjectsBrowser.tsx (neue Komponente, ~340 Zeilen): Status-Header,
  Hauptchat als Erster-Eintrag, Projekt-Liste mit Aktiv-Marker, Long-Press
  zum Editieren, Modals fuer Neu/Edit/End/Archiv.
- ChatScreen.tsx: Banner unterhalb des Status-Bars zeigt aktives Projekt
  oder „Hauptchat" — Tap öffnet ProjectsBrowser als Modal. Aktive Projekt-
  Info wird bei Mount + bei project_changed-Events refreshed.
- SettingsScreen.tsx: Neue Section 📁 „Projekte" zeigt ProjectsBrowser inline.

Diagnostic:
- Neue Sektion im Brain-Tab mit Liste, Aktiv-Marker, Beenden/Archivieren
  pro Zeile, Modal fuer Neu. Lädt automatisch bei Brain-Tab + bei
  project_changed-Event-Broadcast.

Was bewusst NICHT drin ist (Folgeschritte):
- Per-Message Filter im Chat-Verlauf (zeigt aktuell alle Bubbles, Banner
  zeigt Kontext) — App müsste Chat-History per project_id filtern.
- Files-by-Project Tagging.
- Inline-Collapse-Bloecke im Chat-Verlauf.
- Sub-Projekte (Stefan-Entscheidung: weglassen, „Mama-tauglich").

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 13:51:26 +02:00
parent f714cfc336
commit fc0f91d1e6
11 changed files with 1239 additions and 19 deletions
+71
View File
@@ -38,6 +38,7 @@ import watcher as watcher_mod
import background as background_mod
import oauth as oauth_mod
import seed_rules as seed_rules_mod
import projects as projects_mod
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("aria-brain")
@@ -639,6 +640,76 @@ def chat(body: ChatIn, background: BackgroundTasks):
)
# ── Projekte ────────────────────────────────────────────────────────
@app.get("/projects/status")
def projects_status():
"""Komplett-Status: aktives Projekt + Liste aller (nicht-archivierten)."""
return projects_mod.status()
@app.get("/projects/list")
def projects_list(include_archived: bool = False):
return {"projects": projects_mod.list_projects(include_archived=include_archived)}
class ProjectCreateBody(BaseModel):
name: str
description: str = ""
@app.post("/projects/create")
def projects_create(body: ProjectCreateBody):
try:
p = projects_mod.create_project(body.name, body.description)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return p
class ProjectSwitchBody(BaseModel):
project_id: str = ""
@app.post("/projects/switch")
def projects_switch(body: ProjectSwitchBody):
"""Aktive Projekt-ID setzen. Leerer String → Hauptthread."""
if body.project_id:
p = projects_mod.get_project(body.project_id)
if not p:
raise HTTPException(status_code=404, detail=f"Projekt {body.project_id} nicht gefunden")
projects_mod.set_active(body.project_id)
return projects_mod.status()
@app.post("/projects/{project_id}/end")
def projects_end(project_id: str):
if not projects_mod.end_project(project_id):
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
return projects_mod.get_project(project_id) or {"id": project_id, "status": "ended"}
@app.post("/projects/{project_id}/archive")
def projects_archive(project_id: str):
if not projects_mod.archive_project(project_id):
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
return {"id": project_id, "status": "archived"}
class ProjectUpdateBody(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
@app.patch("/projects/{project_id}")
def projects_update(project_id: str, body: ProjectUpdateBody):
patch = body.dict(exclude_unset=True)
p = projects_mod.update_project(project_id, patch)
if p is None:
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
return p
@app.get("/conversation/stats")
def conversation_stats():
return conversation().stats()