fc0f91d1e6
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>
220 lines
6.6 KiB
Python
220 lines
6.6 KiB
Python
"""
|
|
Projekt-Verwaltung — Stefans Idee fuer „Threads im Hauptchat verankert".
|
|
|
|
Ein Projekt ist ein benanntes Thema-Bündel. Zwei Modi:
|
|
- Hauptthread (kein aktives Projekt): klassischer rollender Chat.
|
|
- In-Projekt: alle neuen Turns werden mit project_id getaggt. Die App
|
|
zeigt sie als zusammenhängenden Block, einklappbar.
|
|
|
|
Voice-Pattern (vom LLM via Meta-Tools getriggert):
|
|
- „neues Projekt 'Aria-Wakeword'" → project_create
|
|
- „steig in Projekt Spotify-Setup ein" → project_enter (Fuzzy-Match)
|
|
- „Projekt Ende" → project_exit (zurueck zu Hauptthread)
|
|
- „welche Projekte gibt's?" → project_list
|
|
- „hol mich ab — was war zuletzt bei Projekt X?" → project_summary
|
|
|
|
Persistenz: JSON-Liste in /shared/config/projects.json + aktive ID
|
|
in /shared/config/active_project.txt. Single-User, single-active —
|
|
keine Concurrency-Probleme.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import time
|
|
import uuid
|
|
from difflib import SequenceMatcher
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PROJECTS_DIR = Path(os.environ.get("PROJECTS_DIR", "/shared/config"))
|
|
PROJECTS_FILE = PROJECTS_DIR / "projects.json"
|
|
ACTIVE_PROJECT_FILE = PROJECTS_DIR / "active_project.txt"
|
|
|
|
|
|
def _now() -> int:
|
|
return int(time.time())
|
|
|
|
|
|
def _load_all() -> list[dict]:
|
|
if not PROJECTS_FILE.exists():
|
|
return []
|
|
try:
|
|
data = json.loads(PROJECTS_FILE.read_text(encoding="utf-8"))
|
|
return data if isinstance(data, list) else []
|
|
except Exception as exc:
|
|
logger.warning("[projects] load failed: %s", exc)
|
|
return []
|
|
|
|
|
|
def _save_all(projects: list[dict]) -> None:
|
|
PROJECTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
PROJECTS_FILE.write_text(
|
|
json.dumps(projects, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
|
|
|
|
def _slug(name: str) -> str:
|
|
"""Stabile ID aus Namen — fuer Voice-Matches. Lowercase, only a-z 0-9 _."""
|
|
s = name.strip().lower()
|
|
s = re.sub(r"[^a-z0-9]+", "_", s)
|
|
s = s.strip("_")
|
|
return s or f"project_{_now()}"
|
|
|
|
|
|
def list_projects(include_archived: bool = False) -> list[dict]:
|
|
projects = _load_all()
|
|
if not include_archived:
|
|
projects = [p for p in projects if p.get("status") != "archived"]
|
|
projects.sort(key=lambda p: p.get("last_activity_at", 0), reverse=True)
|
|
return projects
|
|
|
|
|
|
def get_project(project_id: str) -> Optional[dict]:
|
|
if not project_id:
|
|
return None
|
|
for p in _load_all():
|
|
if p.get("id") == project_id:
|
|
return p
|
|
return None
|
|
|
|
|
|
def find_project(query: str) -> Optional[dict]:
|
|
"""Fuzzy-Match auf Projekt-Namen — fuer Voice-Commands.
|
|
Trifft auf: exact slug, prefix, substring, oder hoechste similarity > 0.6."""
|
|
q = (query or "").strip().lower()
|
|
if not q:
|
|
return None
|
|
projects = _load_all()
|
|
# 1. Exact ID-Match
|
|
for p in projects:
|
|
if p.get("id") == q:
|
|
return p
|
|
# 2. Exact / Prefix / Substring auf Slug + Name
|
|
q_slug = _slug(q)
|
|
for p in projects:
|
|
if p.get("id") == q_slug:
|
|
return p
|
|
name_low = (p.get("name", "")).lower()
|
|
if name_low == q or name_low.startswith(q) or q in name_low:
|
|
return p
|
|
# 3. Fuzzy
|
|
best, best_score = None, 0.0
|
|
for p in projects:
|
|
s = SequenceMatcher(None, q, p.get("name", "").lower()).ratio()
|
|
if s > best_score:
|
|
best, best_score = p, s
|
|
if best and best_score >= 0.6:
|
|
return best
|
|
return None
|
|
|
|
|
|
def create_project(name: str, description: str = "") -> dict:
|
|
name = (name or "").strip()
|
|
if not name:
|
|
raise ValueError("Projektname darf nicht leer sein")
|
|
base_id = _slug(name)
|
|
projects = _load_all()
|
|
# Dedup by id with suffix
|
|
used_ids = {p["id"] for p in projects}
|
|
pid = base_id
|
|
counter = 2
|
|
while pid in used_ids:
|
|
pid = f"{base_id}_{counter}"
|
|
counter += 1
|
|
now = _now()
|
|
project = {
|
|
"id": pid,
|
|
"name": name,
|
|
"description": description.strip(),
|
|
"status": "active", # active | ended | archived
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
"last_activity_at": now,
|
|
"turn_count": 0,
|
|
}
|
|
projects.append(project)
|
|
_save_all(projects)
|
|
set_active(pid)
|
|
logger.info("[projects] created %r (id=%s)", name, pid)
|
|
return project
|
|
|
|
|
|
def update_project(project_id: str, patch: dict) -> Optional[dict]:
|
|
projects = _load_all()
|
|
for p in projects:
|
|
if p["id"] == project_id:
|
|
for k in ("name", "description", "status"):
|
|
if k in patch and patch[k] is not None:
|
|
p[k] = patch[k]
|
|
p["updated_at"] = _now()
|
|
_save_all(projects)
|
|
return p
|
|
return None
|
|
|
|
|
|
def archive_project(project_id: str) -> bool:
|
|
if update_project(project_id, {"status": "archived"}) is not None:
|
|
if get_active() == project_id:
|
|
set_active("")
|
|
return True
|
|
return False
|
|
|
|
|
|
def end_project(project_id: str) -> bool:
|
|
"""Markiert als beendet, aktive-Projekt-Pointer raus."""
|
|
if update_project(project_id, {"status": "ended"}) is not None:
|
|
if get_active() == project_id:
|
|
set_active("")
|
|
return True
|
|
return False
|
|
|
|
|
|
def touch_project(project_id: str) -> None:
|
|
"""Bei jedem Turn im Projekt: last_activity + turn_count erhoehen."""
|
|
if not project_id:
|
|
return
|
|
projects = _load_all()
|
|
changed = False
|
|
for p in projects:
|
|
if p["id"] == project_id:
|
|
p["last_activity_at"] = _now()
|
|
p["turn_count"] = int(p.get("turn_count", 0)) + 1
|
|
changed = True
|
|
break
|
|
if changed:
|
|
_save_all(projects)
|
|
|
|
|
|
# ── Active-Project-Pointer ─────────────────────────────────────────
|
|
|
|
def get_active() -> str:
|
|
"""Returns die aktive Projekt-ID oder leer (= Hauptthread)."""
|
|
try:
|
|
if ACTIVE_PROJECT_FILE.exists():
|
|
return ACTIVE_PROJECT_FILE.read_text(encoding="utf-8").strip()
|
|
except Exception:
|
|
pass
|
|
return ""
|
|
|
|
|
|
def set_active(project_id: str) -> None:
|
|
PROJECTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
ACTIVE_PROJECT_FILE.write_text(project_id or "", encoding="utf-8")
|
|
logger.info("[projects] active project: %r", project_id or "(main)")
|
|
|
|
|
|
def status() -> dict:
|
|
"""Status-Snapshot fuer App/Diagnostic."""
|
|
active_id = get_active()
|
|
active = get_project(active_id) if active_id else None
|
|
return {
|
|
"active_id": active_id,
|
|
"active": active,
|
|
"projects": list_projects(include_archived=False),
|
|
}
|