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
+154
View File
@@ -992,6 +992,41 @@
<!-- Alte Sessions-Sicherung entfernt — aria-core ist raus. -->
<!-- Projekte — Threads-im-Hauptchat-Konzept -->
<div class="settings-section">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<h2 style="margin:0;">📁 Projekte</h2>
<div>
<button class="btn secondary" onclick="loadProjects()" style="padding:4px 10px;font-size:11px;">🔄 Aktualisieren</button>
<button class="btn" onclick="openCreateProjectModal()" style="padding:4px 10px;font-size:11px;">+ Neues Projekt</button>
</div>
</div>
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;">
Projekte bündeln zusammengehörige Turns als Block im Hauptchat. Stefan sagt zu ARIA
„lass uns ein Projekt anlegen" oder klickt hier auf „+ Neues Projekt". Aktives Projekt:
<span id="project-active-label" style="color:#34C759;font-weight:600;">(wird geladen...)</span>
</div>
<div id="project-list" class="card" style="padding:0;">
<div style="padding:14px;color:#8888AA;font-size:12px;">Lade Projekte...</div>
</div>
</div>
<!-- Neues-Projekt Modal -->
<div id="project-create-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:1000;align-items:center;justify-content:center;">
<div style="background:#15151E;padding:20px;border-radius:8px;min-width:340px;max-width:90vw;">
<h3 style="margin-top:0;color:#E0E0F0;">Neues Projekt</h3>
<label style="display:block;color:#8888AA;font-size:12px;margin-bottom:4px;">Name</label>
<input type="text" id="project-create-name" placeholder="z.B. Frankreich-Urlaub"
style="width:100%;box-sizing:border-box;background:#0A0A14;color:#E0E0F0;border:1px solid #2A2A3E;padding:8px;border-radius:4px;font-size:14px;margin-bottom:10px;">
<label style="display:block;color:#8888AA;font-size:12px;margin-bottom:4px;">Beschreibung (optional)</label>
<textarea id="project-create-desc" placeholder="1 Satz worum's geht. Hilft beim Wiederfinden."
style="width:100%;box-sizing:border-box;background:#0A0A14;color:#E0E0F0;border:1px solid #2A2A3E;padding:8px;border-radius:4px;font-size:13px;height:60px;resize:vertical;margin-bottom:14px;"></textarea>
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button class="btn secondary" onclick="closeCreateProjectModal()" style="padding:6px 14px;font-size:12px;">Abbrechen</button>
<button class="btn primary" onclick="submitCreateProject()" style="padding:6px 14px;font-size:12px;">Anlegen + aktivieren</button>
</div>
</div>
</div>
<div class="settings-section">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
@@ -1543,6 +1578,13 @@
return;
}
if (msg.type === 'project_changed') {
// ARIA hat in einem Tool-Call ein Projekt erstellt/betreten/verlassen/beendet.
// Liste neu laden falls sichtbar.
loadProjects();
return;
}
if (msg.type === 'voice_id_delete_response') {
const p = msg.payload || msg;
if (p.removed) {
@@ -2694,6 +2736,117 @@
send({ action: 'voice_id_delete' });
}
// ── Projekte ────────────────────────────────────────────
async function loadProjects() {
const listEl = document.getElementById('project-list');
const activeLabel = document.getElementById('project-active-label');
try {
const r = await fetch('/api/brain/projects/status');
const status = await r.json();
const projects = status.projects || [];
const activeId = status.active_id || '';
activeLabel.textContent = status.active ? status.active.name : '💬 Hauptchat';
activeLabel.style.color = status.active ? '#34C759' : '#8888AA';
const rows = [];
// Hauptchat-Eintrag
rows.push(`
<div onclick="switchProject('')" style="cursor:pointer;padding:12px 14px;border-bottom:1px solid #1E1E2E;${!activeId ? 'background:rgba(52,199,89,0.08);border-left:3px solid #34C759;' : ''}">
<div style="color:${!activeId ? '#34C759' : '#E0E0F0'};font-weight:600;">💬 Hauptchat ${!activeId ? '<span style="font-size:10px;font-weight:800;">✓ AKTIV</span>' : ''}</div>
<div style="color:#555570;font-size:11px;margin-top:2px;">Standard-Verlauf, keine Projekt-Zuordnung</div>
</div>`);
for (const p of projects) {
const isActive = p.id === activeId;
const since = p.last_activity_at ? new Date(p.last_activity_at * 1000).toLocaleString('de-DE') : '?';
const ended = p.status === 'ended';
rows.push(`
<div style="padding:12px 14px;border-bottom:1px solid #1E1E2E;${isActive ? 'background:rgba(52,199,89,0.08);border-left:3px solid #34C759;' : ''}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;">
<div onclick="switchProject('${p.id}')" style="cursor:pointer;flex:1;">
<div style="color:${isActive ? '#34C759' : '#E0E0F0'};font-weight:600;">
📁 ${escapeHtml(p.name)}
${ended ? '<span style="color:#FFD60A;font-size:10px;font-weight:700;margin-left:6px;background:rgba(255,214,10,0.15);padding:2px 6px;border-radius:3px;">beendet</span>' : ''}
${isActive ? '<span style="color:#34C759;font-size:10px;font-weight:800;margin-left:6px;">✓ AKTIV</span>' : ''}
</div>
${p.description ? `<div style="color:#8888AA;font-size:12px;margin-top:2px;">${escapeHtml(p.description)}</div>` : ''}
<div style="color:#555570;font-size:11px;margin-top:4px;">${p.turn_count} Turns · zuletzt ${since}</div>
</div>
<div style="display:flex;gap:4px;">
${!ended ? `<button class="btn secondary" onclick="endProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;" title="Projekt beenden"></button>` : ''}
<button class="btn secondary" onclick="archiveProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;color:#E55C5C;" title="Archivieren">🗑</button>
</div>
</div>
</div>`);
}
if (projects.length === 0) {
rows.push('<div style="padding:18px;color:#555570;font-size:12px;text-align:center;">Noch keine Projekte. „+ Neues Projekt" oder sag ARIA „lass uns ein Projekt anlegen".</div>');
}
listEl.innerHTML = rows.join('');
} catch (e) {
listEl.innerHTML = `<div style="padding:14px;color:#FF6E6E;font-size:12px;">Fehler: ${e.message}</div>`;
}
}
async function switchProject(projectId) {
try {
await fetch('/api/brain/projects/switch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: projectId }),
});
loadProjects();
} catch (e) { alert('Wechsel fehlgeschlagen: ' + e.message); }
}
async function endProject(id, name) {
if (!confirm(`Projekt "${name}" beenden?\n\nBleibt sichtbar, aktiv ist dann der Hauptchat.`)) return;
try {
await fetch(`/api/brain/projects/${encodeURIComponent(id)}/end`, { method: 'POST' });
loadProjects();
} catch (e) { alert('Beenden fehlgeschlagen: ' + e.message); }
}
async function archiveProject(id, name) {
if (!confirm(`Projekt "${name}" archivieren?\n\nVerschwindet aus der Liste.`)) return;
try {
await fetch(`/api/brain/projects/${encodeURIComponent(id)}/archive`, { method: 'POST' });
loadProjects();
} catch (e) { alert('Archivieren fehlgeschlagen: ' + e.message); }
}
function openCreateProjectModal() {
document.getElementById('project-create-name').value = '';
document.getElementById('project-create-desc').value = '';
document.getElementById('project-create-modal').style.display = 'flex';
setTimeout(() => document.getElementById('project-create-name').focus(), 50);
}
function closeCreateProjectModal() {
document.getElementById('project-create-modal').style.display = 'none';
}
async function submitCreateProject() {
const name = document.getElementById('project-create-name').value.trim();
const description = document.getElementById('project-create-desc').value.trim();
if (!name) { alert('Name darf nicht leer sein.'); return; }
try {
await fetch('/api/brain/projects/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description }),
});
closeCreateProjectModal();
loadProjects();
} catch (e) { alert('Anlegen fehlgeschlagen: ' + e.message); }
}
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function escapeHtmlAttr(str) {
return String(str).replace(/['"\\]/g, '\\$&');
}
function deleteXttsVoice(name) {
if (!confirm(`Stimme "${name}" endgueltig loeschen?`)) return;
send({ action: 'xtts_delete_voice', name });
@@ -3450,6 +3603,7 @@
loadBrainMemoryList();
refreshImportFiles();
loadMetrics();
loadProjects();
} else if (tab === 'files') {
loadFiles();
} else if (tab === 'skills') {