feat: Modus-Anzeige im Chat + Plan-Präsentation [appimage]
All checks were successful
Build AppImage / build (push) Successful in 9m31s

- Mode-Badge über dem Textfeld zeigt aktiven Agent-Modus (Handlanger/Experten/Auto) mit Verarbeitungsphase
- Plan-Erkennung: erkennt strukturierte Pläne in Claude-Antworten (Mermaid, Sektionen, Schrittlisten)
- Automatisches Senden erkannter Pläne ans Präsentationsfenster als Slides
- PWA Docker Build Pipeline hinzugefügt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-04-21 13:16:10 +02:00
parent b90db222e7
commit ebbb65a1a9
4 changed files with 370 additions and 1 deletions

View file

@ -0,0 +1,77 @@
# Claude Desktop — PWA Docker Build Pipeline
# Triggert bei [pwa] oder [docker] im Commit-Message
# Baut das Docker-Image aus pwa/ und pusht es in die Forgejo Container-Registry
name: PWA Docker Build
on:
push:
branches:
- main
paths:
- 'pwa/**'
jobs:
build-pwa:
# Laeuft auf dem Debian-Runner (16-Forgejo-Runner-AppImage)
runs-on: appimage
if: contains(github.event.head_commit.message, '[pwa]') || contains(github.event.head_commit.message, '[docker]')
steps:
# Ntfy Start-Benachrichtigung
- name: Build gestartet (Ntfy)
run: |
curl -s \
-H "Authorization: ${{ secrets.NTFY_AUTH }}" \
-H "Title: PWA Build gestartet" \
-H "Tags: construction" \
-d "Claude Chat PWA Docker-Image wird gebaut..." \
https://notify.data-it-solution.de/vk-builds
- name: Checkout
run: |
git clone --depth 1 --branch "${GITHUB_REF_NAME}" \
"https://oauth2:${{ secrets.REGISTRY_TOKEN }}@git.data-it-solution.de/${GITHUB_REPOSITORY}.git" .
# Docker-Image bauen (Build-Kontext = pwa/)
- name: Docker Image bauen
run: |
cd pwa
docker build \
-t git.data-it-solution.de/data/claude-desktop/claude-chat-pwa:latest \
-t git.data-it-solution.de/data/claude-desktop/claude-chat-pwa:${{ github.sha }} \
.
# Login in die Forgejo Container-Registry
- name: Login Forgejo Registry
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | \
docker login git.data-it-solution.de -u data --password-stdin
# Image in die Registry pushen (latest + commit-sha)
- name: Push to Registry
run: |
docker push git.data-it-solution.de/data/claude-desktop/claude-chat-pwa:latest
docker push git.data-it-solution.de/data/claude-desktop/claude-chat-pwa:${{ github.sha }}
# Ntfy Erfolg-Benachrichtigung
- name: Build erfolgreich (Ntfy)
if: success()
run: |
curl -s \
-H "Authorization: ${{ secrets.NTFY_AUTH }}" \
-H "Title: PWA Build erfolgreich" \
-H "Tags: white_check_mark" \
-d "Claude Chat PWA Docker-Image gebaut und in Registry gepusht (SHA: ${{ github.sha }})" \
https://notify.data-it-solution.de/vk-builds
# Ntfy Fehler-Benachrichtigung
- name: Build fehlgeschlagen (Ntfy)
if: failure()
run: |
curl -s \
-H "Authorization: ${{ secrets.NTFY_AUTH }}" \
-H "Title: PWA Build fehlgeschlagen" \
-H "Tags: x" \
-d "Fehler beim Claude Chat PWA Build. Logs pruefen: https://git.data-it-solution.de/${GITHUB_REPOSITORY}/actions" \
https://notify.data-it-solution.de/vk-builds

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { emit, listen } from '@tauri-apps/api/event';
import { messages, currentInput, isProcessing, addMessage, currentSessionId, messageToDb, queuedMessage, messageQueue, type Message, type QuickAction } from '$lib/stores/app';
import { messages, currentInput, isProcessing, addMessage, currentSessionId, messageToDb, queuedMessage, messageQueue, agentMode, type Message, type QuickAction } from '$lib/stores/app';
import { currentTool, processingPhase } from '$lib/stores/events';
import { marked, type Tokens } from 'marked';
import { tick, onDestroy, onMount } from 'svelte';
@ -1208,6 +1208,31 @@
visible={showCommandPalette}
onSelect={handleCommandSelect}
/>
{#if $agentMode && $agentMode !== 'solo'}
<div class="mode-indicator mode-{$agentMode}">
<span class="mode-icon">
{#if $agentMode === 'handlanger'}👷
{:else if $agentMode === 'experten'}🎓
{:else if $agentMode === 'auto'}🤖
{/if}
</span>
<span class="mode-label">
{#if $agentMode === 'handlanger'}Handlanger-Modus
{:else if $agentMode === 'experten'}Experten-Modus
{:else if $agentMode === 'auto'}Auto-Modus
{/if}
</span>
{#if $processingPhase !== 'idle'}
<span class="mode-phase">
{#if $processingPhase === 'thinking'}denkt nach...
{:else if $processingPhase === 'streaming'}schreibt...
{:else if $processingPhase === 'tool-use'}nutzt Tool...
{:else if $processingPhase === 'subagent'}delegiert...
{/if}
</span>
{/if}
</div>
{/if}
{#if liveTranscript}
<div class="live-transcript">
<span class="transcript-icon">🎤</span>
@ -2061,6 +2086,57 @@
color: #ef4444;
}
/* Modus-Anzeige im Eingabebereich */
.mode-indicator {
display: flex;
align-items: center;
gap: var(--spacing-xs);
padding: 4px var(--spacing-sm);
margin-bottom: var(--spacing-xs);
border-radius: var(--radius-sm);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.02em;
animation: mode-fade-in 0.3s ease;
}
@keyframes mode-fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.mode-indicator.mode-handlanger {
background: rgba(245, 158, 11, 0.12);
border: 1px solid rgba(245, 158, 11, 0.3);
color: #fbbf24;
}
.mode-indicator.mode-experten {
background: rgba(139, 92, 246, 0.12);
border: 1px solid rgba(139, 92, 246, 0.3);
color: #a78bfa;
}
.mode-indicator.mode-auto {
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.3);
color: #4ade80;
}
.mode-icon {
flex-shrink: 0;
font-size: 0.8rem;
}
.mode-label {
flex-shrink: 0;
}
.mode-phase {
opacity: 0.7;
font-weight: 400;
}
.queued-pill {
display: flex;
align-items: center;

View file

@ -4,6 +4,7 @@
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { writable, get } from 'svelte/store';
import { planAnPraesentationSenden } from '$lib/utils/planPresentation';
import {
agents,
toolCalls,
@ -359,6 +360,11 @@ export async function initEventListeners(): Promise<void> {
// Nachricht in DB speichern (nur wenn Content vorhanden)
if (finalMessage && finalMessage.content && finalMessage.content.trim()) {
await saveMessageToDb(finalMessage);
// Plan-Erkennung → automatisch ans Präsentationsfenster senden
planAnPraesentationSenden(finalMessage.content).catch((err) => {
console.debug('Plan-Präsentation fehlgeschlagen:', err);
});
}
}

View file

@ -0,0 +1,210 @@
/**
* Plan-Erkennung + Präsentations-Konvertierung
*
* Erkennt ob eine Claude-Antwort einen strukturierten Plan enthält
* und wandelt diesen in Slides für das Präsentationsfenster um.
*/
import { invoke } from '@tauri-apps/api/core';
/** Slide-Typ passend zum Rust-Backend */
interface Slide {
type: 'mermaid' | 'code' | 'text';
content: string;
language?: string;
title?: string;
}
/**
* Prüft ob der Text einen strukturierten Plan enthält.
*
* Erkennt:
* - Markdown mit mehreren ## Überschriften (Phasen/Schritte)
* - Nummerierte Listen mit technischen Inhalten
* - Mermaid-Codeblöcke
* - Schlüsselwörter wie "Plan", "Phase", "Schritt", "Implementierung"
*/
export function istPlan(text: string): boolean {
if (!text || text.length < 200) return false;
// Mermaid-Block = definitiv ein Plan
if (/```mermaid/i.test(text)) return true;
// Mindestens 3 ## Überschriften → strukturierter Plan
const h2Count = (text.match(/^##\s+/gm) || []).length;
if (h2Count >= 3) {
// Plus Plan-Schlüsselwörter
const planKeywords = /\b(plan|phase|schritt|implementierung|architektur|zusammenfassung|dateien|reihenfolge|verifizierung)\b/i;
if (planKeywords.test(text)) return true;
}
// Nummerierte Schritte (1. ... 2. ... 3. ...) mit technischem Inhalt
const numSteps = (text.match(/^\d+\.\s+/gm) || []).length;
if (numSteps >= 4 && h2Count >= 2) return true;
return false;
}
/**
* Wandelt Plan-Text in Slides um.
*
* Strategie:
* 1. Mermaid-Blöcke eigene Mermaid-Slides
* 2. Code-Blöcke Code-Slides
* 3. ## Sektionen Text-Slides (Markdown-bereinigt)
* 4. Tabellen Text-Slides
*/
export function planZuSlides(text: string): Slide[] {
const slides: Slide[] = [];
// Titel extrahieren (erste # Überschrift)
const titelMatch = text.match(/^#\s+(.+)$/m);
const planTitel = titelMatch?.[1] ?? 'Plan';
// Übersichts-Slide
slides.push({
type: 'text',
title: `📋 ${planTitel}`,
content: extractZusammenfassung(text),
});
// Text in Sektionen aufteilen (## Überschriften)
const sektionen = text.split(/^(?=##\s+)/m).filter(s => s.trim());
for (const sektion of sektionen) {
// Sektions-Überschrift extrahieren
const headerMatch = sektion.match(/^##\s+(.+)$/m);
if (!headerMatch) continue;
const sektionTitel = headerMatch[1];
const sektionInhalt = sektion.replace(/^##\s+.+$/m, '').trim();
if (!sektionInhalt) continue;
// Mermaid-Blöcke extrahieren
const mermaidBlocks = sektionInhalt.match(/```mermaid\n([\s\S]*?)```/g);
if (mermaidBlocks) {
for (const block of mermaidBlocks) {
const code = block.replace(/```mermaid\n/, '').replace(/```$/, '').trim();
slides.push({
type: 'mermaid',
title: sektionTitel,
content: code,
});
}
}
// Code-Blöcke extrahieren
const codeBlocks = sektionInhalt.match(/```(\w+)\n([\s\S]*?)```/g);
if (codeBlocks) {
for (const block of codeBlocks) {
if (block.startsWith('```mermaid')) continue; // schon verarbeitet
const langMatch = block.match(/```(\w+)\n/);
const lang = langMatch?.[1] ?? 'text';
const code = block.replace(/```\w+\n/, '').replace(/```$/, '').trim();
slides.push({
type: 'code',
title: sektionTitel,
content: code,
language: lang,
});
}
}
// Restlichen Text als Text-Slide (ohne Code-/Mermaid-Blöcke)
let textInhalt = sektionInhalt
.replace(/```(?:mermaid|\w+)\n[\s\S]*?```/g, '')
.trim();
if (textInhalt.length > 30) {
// Markdown-Formatierung in lesbaren Text umwandeln
textInhalt = markdownBereinigen(textInhalt);
slides.push({
type: 'text',
title: sektionTitel,
content: textInhalt,
});
}
}
// Falls keine Sektionen erkannt wurden, den ganzen Text als ein Slide
if (slides.length <= 1) {
slides.push({
type: 'text',
title: planTitel,
content: markdownBereinigen(text.substring(0, 2000)),
});
}
return slides;
}
/**
* Extrahiert eine kurze Zusammenfassung aus dem Plan-Text.
* Nimmt den Text vor der ersten ## Überschrift oder die ersten Zeilen.
*/
function extractZusammenfassung(text: string): string {
// Text vor erster ## Überschrift
const vorErsterSektion = text.split(/^##\s+/m)[0];
let zusammenfassung = vorErsterSektion
.replace(/^#\s+.+$/m, '') // Haupttitel entfernen
.trim();
if (zusammenfassung.length < 20) {
// Fallback: Sektions-Überschriften als Gliederung
const headers = text.match(/^##\s+(.+)$/gm) || [];
zusammenfassung = headers
.map((h, i) => `${i + 1}. ${h.replace(/^##\s+/, '')}`)
.join('\n');
}
// Kürzen auf max 500 Zeichen
if (zusammenfassung.length > 500) {
zusammenfassung = zusammenfassung.substring(0, 497) + '...';
}
return zusammenfassung || 'Plan wird im Detail auf den folgenden Slides angezeigt.';
}
/**
* Bereinigt Markdown-Formatierung für Klartext-Anzeige.
*/
function markdownBereinigen(text: string): string {
return text
.replace(/\*\*(.+?)\*\*/g, '$1') // **bold** → bold
.replace(/\*(.+?)\*/g, '$1') // *italic* → italic
.replace(/`(.+?)`/g, '$1') // `code` → code
.replace(/^[-*]\s+/gm, '• ') // Listen-Bullets vereinheitlichen
.replace(/^\|.*\|$/gm, (line) => { // Tabellen-Zeilen beibehalten
if (/^[\s|:-]+$/.test(line)) return ''; // Separator-Zeile entfernen
return line;
})
.replace(/\n{3,}/g, '\n\n') // Max 2 Leerzeilen
.trim();
}
/**
* Sendet einen erkannten Plan ans Präsentationsfenster.
* Wird aus dem Event-Bridge aufgerufen wenn eine Nachricht fertig ist.
*/
export async function planAnPraesentationSenden(text: string): Promise<boolean> {
if (!istPlan(text)) return false;
const slides = planZuSlides(text);
if (slides.length === 0) return false;
try {
// Alte Slides löschen
await invoke('presentation_clear');
// Alle Slides nacheinander senden
for (const slide of slides) {
await invoke('presentation_send_slide', { slide });
}
console.log(`📊 Plan erkannt → ${slides.length} Slides ans Präsentationsfenster gesendet`);
return true;
} catch (err) {
console.warn('Präsentationsfenster konnte nicht erreicht werden:', err);
return false;
}
}