feat: Modus-Anzeige im Chat + Plan-Präsentation [appimage]
All checks were successful
Build AppImage / build (push) Successful in 9m31s
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:
parent
b90db222e7
commit
ebbb65a1a9
4 changed files with 370 additions and 1 deletions
77
.forgejo/workflows/build-pwa.yml
Normal file
77
.forgejo/workflows/build-pwa.yml
Normal 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
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { emit, listen } from '@tauri-apps/api/event';
|
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 { currentTool, processingPhase } from '$lib/stores/events';
|
||||||
import { marked, type Tokens } from 'marked';
|
import { marked, type Tokens } from 'marked';
|
||||||
import { tick, onDestroy, onMount } from 'svelte';
|
import { tick, onDestroy, onMount } from 'svelte';
|
||||||
|
|
@ -1208,6 +1208,31 @@
|
||||||
visible={showCommandPalette}
|
visible={showCommandPalette}
|
||||||
onSelect={handleCommandSelect}
|
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}
|
{#if liveTranscript}
|
||||||
<div class="live-transcript">
|
<div class="live-transcript">
|
||||||
<span class="transcript-icon">🎤</span>
|
<span class="transcript-icon">🎤</span>
|
||||||
|
|
@ -2061,6 +2086,57 @@
|
||||||
color: #ef4444;
|
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 {
|
.queued-pill {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { writable, get } from 'svelte/store';
|
import { writable, get } from 'svelte/store';
|
||||||
|
import { planAnPraesentationSenden } from '$lib/utils/planPresentation';
|
||||||
import {
|
import {
|
||||||
agents,
|
agents,
|
||||||
toolCalls,
|
toolCalls,
|
||||||
|
|
@ -359,6 +360,11 @@ export async function initEventListeners(): Promise<void> {
|
||||||
// Nachricht in DB speichern (nur wenn Content vorhanden)
|
// Nachricht in DB speichern (nur wenn Content vorhanden)
|
||||||
if (finalMessage && finalMessage.content && finalMessage.content.trim()) {
|
if (finalMessage && finalMessage.content && finalMessage.content.trim()) {
|
||||||
await saveMessageToDb(finalMessage);
|
await saveMessageToDb(finalMessage);
|
||||||
|
|
||||||
|
// Plan-Erkennung → automatisch ans Präsentationsfenster senden
|
||||||
|
planAnPraesentationSenden(finalMessage.content).catch((err) => {
|
||||||
|
console.debug('Plan-Präsentation fehlgeschlagen:', err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
210
src/lib/utils/planPresentation.ts
Normal file
210
src/lib/utils/planPresentation.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue