iteration 2 done

This commit is contained in:
ValueOn AG 2026-03-03 23:02:49 +01:00
parent cd14e8a6fb
commit 5186e58e00
7 changed files with 483 additions and 5 deletions

View file

@ -29,6 +29,7 @@ export interface CoachingSession {
contextId: string;
userId: string;
status: string;
personaId?: string;
summary?: string;
durationSeconds: number;
messageCount: number;
@ -38,6 +39,38 @@ export interface CoachingSession {
endedAt?: string;
}
export interface CoachingPersona {
id: string;
userId: string;
key: string;
label: string;
description: string;
gender?: string;
category: string;
isActive: boolean;
}
export interface CoachingDocument {
id: string;
contextId: string;
fileName: string;
mimeType: string;
fileSize: number;
extractedText?: string;
summary?: string;
createdAt?: string;
}
export interface CoachingBadge {
id: string;
userId: string;
badgeKey: string;
label?: string;
description?: string;
icon?: string;
awardedAt?: string;
}
export interface CoachingMessage {
id: string;
sessionId: string;
@ -100,6 +133,8 @@ export interface DashboardData {
openTasks: number;
completedTasks: number;
goalProgress?: number;
badges?: CoachingBadge[];
level?: { number: number; label: string; totalSessions: number };
contexts: Array<{ id: string; title: string; category: string; sessionCount: number; lastSessionAt?: string; goalProgress?: number }>;
}
@ -180,10 +215,12 @@ export async function startSessionStreamApi(
onEvent: (event: SSEEvent) => void,
onError?: (error: Error) => void,
onComplete?: () => void,
personaId?: string,
): Promise<void> {
try {
const baseURL = api.defaults.baseURL || '';
const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/sessions/start`;
const personaParam = personaId ? `?personaId=${encodeURIComponent(personaId)}` : '';
const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/sessions/start${personaParam}`;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const authToken = localStorage.getItem('authToken');
@ -474,3 +511,88 @@ export async function testVoiceApi(request: ApiRequestFunction, instanceId: stri
const data = await request({ url: `/api/commcoach/${instanceId}/voice/tts`, method: 'post', data: body });
return data;
}
// ============================================================================
// Persona API (Iteration 2)
// ============================================================================
export async function getPersonasApi(request: ApiRequestFunction, instanceId: string): Promise<CoachingPersona[]> {
const data = await request({ url: `/api/commcoach/${instanceId}/personas`, method: 'get' });
return data.personas || [];
}
export async function createPersonaApi(request: ApiRequestFunction, instanceId: string, body: {
label: string; description: string; gender?: string; systemPromptOverride?: string;
}): Promise<CoachingPersona> {
const data = await request({ url: `/api/commcoach/${instanceId}/personas`, method: 'post', data: body });
return data.persona;
}
export async function deletePersonaApi(request: ApiRequestFunction, instanceId: string, personaId: string): Promise<void> {
await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'delete' });
}
// ============================================================================
// Document API (Iteration 2)
// ============================================================================
export async function getDocumentsApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingDocument[]> {
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/documents`, method: 'get' });
return data.documents || [];
}
export async function uploadDocumentApi(instanceId: string, contextId: string, file: File): Promise<CoachingDocument> {
const baseURL = api.defaults.baseURL || '';
const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/documents`;
const formData = new FormData();
formData.append('file', file);
const headers: Record<string, string> = {};
const authToken = localStorage.getItem('authToken');
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
if (!getCSRFToken()) generateAndStoreCSRFToken();
addCSRFTokenToHeaders(headers);
const response = await fetch(url, { method: 'POST', headers, body: formData, credentials: 'include' });
if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
const data = await response.json();
return data.document;
}
export async function deleteDocumentApi(request: ApiRequestFunction, instanceId: string, documentId: string): Promise<void> {
await request({ url: `/api/commcoach/${instanceId}/documents/${documentId}`, method: 'delete' });
}
// ============================================================================
// Badge API (Iteration 2)
// ============================================================================
export async function getBadgesApi(request: ApiRequestFunction, instanceId: string): Promise<CoachingBadge[]> {
const data = await request({ url: `/api/commcoach/${instanceId}/badges`, method: 'get' });
return data.badges || [];
}
// ============================================================================
// Export API (Iteration 2)
// ============================================================================
export function getDossierExportUrl(instanceId: string, contextId: string, format: string = 'md'): string {
const baseURL = api.defaults.baseURL || '';
return `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/export?format=${format}`;
}
export function getSessionExportUrl(instanceId: string, sessionId: string, format: string = 'md'): string {
const baseURL = api.defaults.baseURL || '';
return `${baseURL}/api/commcoach/${instanceId}/sessions/${sessionId}/export?format=${format}`;
}
// ============================================================================
// Score History API (Iteration 2)
// ============================================================================
export async function getScoreHistoryApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<Record<string, Array<{
score: number; trend: string; evidence?: string; createdAt?: string; sessionId?: string;
}>>> {
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/scores/history`, method: 'get' });
return data.history || {};
}

View file

@ -40,7 +40,7 @@ export interface CommcoachHookReturn {
createContext: (title: string, description?: string, category?: string, goals?: string[]) => Promise<void>;
archiveContext: (contextId: string) => Promise<void>;
startSession: () => Promise<void>;
startSession: (personaId?: string) => Promise<void>;
sendMessage: (content: string) => Promise<void>;
sendAudio: (audioBlob: Blob) => Promise<void>;
completeSession: () => Promise<void>;
@ -212,7 +212,7 @@ export function useCommcoach(): CommcoachHookReturn {
}
}, [request, instanceId, selectedContextId, refreshContexts]);
const startSessionCb = useCallback(async () => {
const startSessionCb = useCallback(async (personaId?: string) => {
if (!instanceId || !selectedContextId) return;
await _unlockAudioForTts();
setError(null);
@ -278,6 +278,7 @@ export function useCommcoach(): CommcoachHookReturn {
setStreamingMessage(null);
}
},
personaId,
);
} catch (err: any) {
if (isMountedRef.current) {

View file

@ -9,6 +9,9 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useCommcoach } from '../../../hooks/useCommcoach';
import { useApiRequest } from '../../../hooks/useApi';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { getPersonasApi, type CoachingPersona } from '../../../api/commcoachApi';
import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@ -17,11 +20,15 @@ import styles from './CommcoachCoachingView.module.css';
export const CommcoachCoachingView: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const coach = useCommcoach();
const { request } = useApiRequest();
const instanceId = useInstanceId();
const [showNewContext, setShowNewContext] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [newDescription, setNewDescription] = useState('');
const [newCategory, setNewCategory] = useState('custom');
const inputRef = useRef<HTMLTextAreaElement>(null);
const [personas, setPersonas] = useState<CoachingPersona[]>([]);
const [selectedPersonaId, setSelectedPersonaId] = useState<string | undefined>(undefined);
const streamRef = useRef<MediaStream | null>(null);
const speechRecognitionRef = useRef<SpeechRecognition | null>(null);
@ -71,6 +78,13 @@ export const CommcoachCoachingView: React.FC = () => {
}
}, [coach.session]);
useEffect(() => {
if (!instanceId) return;
getPersonasApi(request, instanceId)
.then(p => setPersonas(p))
.catch(() => {});
}, [instanceId, request]);
useEffect(() => {
if (!coach.session || coach.isMuted) {
if (speechRecognitionRef.current) {
@ -284,8 +298,36 @@ export const CommcoachCoachingView: React.FC = () => {
<div className={styles.sessionStart}>
<h3>{coach.selectedContext?.title}</h3>
<p>{coach.selectedContext?.description || 'Starte eine neue Coaching-Session zu diesem Thema.'}</p>
<button className={styles.btnPrimary} onClick={coach.startSession}>
{personas.length > 0 && (
<div className={styles.personaSelector}>
<label className={styles.personaLabel}>Gespraechspartner waehlen:</label>
<div className={styles.personaGrid}>
{personas.map(p => (
<button
key={p.id}
className={`${styles.personaChip} ${selectedPersonaId === p.id ? styles.personaChipActive : ''}`}
onClick={() => setSelectedPersonaId(selectedPersonaId === p.id ? undefined : p.id)}
title={p.description}
>
<span className={styles.personaGender}>
{p.gender === 'f' ? '\u2640' : p.gender === 'm' ? '\u2642' : '\u25CB'}
</span>
<span className={styles.personaName}>{p.label}</span>
</button>
))}
</div>
</div>
)}
<button
className={styles.btnPrimary}
onClick={() => coach.startSession(selectedPersonaId)}
>
Session starten
{selectedPersonaId && personas.find(p => p.id === selectedPersonaId)
? ` mit ${personas.find(p => p.id === selectedPersonaId)!.label}`
: ''}
</button>
</div>
)}

View file

@ -114,6 +114,32 @@
border-radius: 10px;
}
.badgeGrid {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.badgeCard {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.9rem;
background: var(--bg-card, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 20px;
font-size: 0.85rem;
}
.badgeIcon {
font-size: 1.1rem;
}
.badgeLabel {
font-weight: 500;
color: var(--text-primary, #333);
}
.tipCard {
background: var(--bg-card, #fff);
border: 1px solid var(--border-color, #e0e0e0);

View file

@ -99,6 +99,27 @@ export const CommcoachDashboardView: React.FC = () => {
)}
</div>
{/* Level + Badges */}
{(dashboard.level || (dashboard.badges && dashboard.badges.length > 0)) && (
<div className={styles.section}>
<h3 className={styles.sectionTitle}>
{dashboard.level
? `Level ${dashboard.level.number}: ${dashboard.level.label}`
: 'Auszeichnungen'}
</h3>
{dashboard.badges && dashboard.badges.length > 0 && (
<div className={styles.badgeGrid}>
{dashboard.badges.map(b => (
<div key={b.id} className={styles.badgeCard} title={b.description || b.badgeKey}>
<div className={styles.badgeIcon}>{_badgeIcon(b.icon)}</div>
<div className={styles.badgeLabel}>{b.label || b.badgeKey}</div>
</div>
))}
</div>
)}
</div>
)}
{/* Quick Start */}
<div className={styles.section}>
<h3 className={styles.sectionTitle}>Tipp des Tages</h3>
@ -125,6 +146,15 @@ function _categoryLabel(category: string): string {
return labels[category] || category;
}
function _badgeIcon(icon?: string): string {
const icons: Record<string, string> = {
star: '\u2605', fire: '\u{1F525}', trophy: '\u{1F3C6}',
medal: '\u{1F3C5}', layers: '\u{1F4DA}', theater: '\u{1F3AD}',
compass: '\u{1F9ED}', 'check-circle': '\u2714',
};
return icons[icon || 'star'] || '\u2605';
}
function _formatDate(isoStr: string): string {
try {
const d = new Date(isoStr);

View file

@ -287,3 +287,116 @@
color: var(--text-secondary, #666);
line-height: 1.4;
}
/* Score History */
.scoreHistory {
margin-top: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.scoreHistoryLabel {
font-size: 0.75rem;
color: var(--text-secondary, #888);
}
.scoreHistoryPoints {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.scoreHistoryPoint {
padding: 0.15rem 0.4rem;
background: var(--bg-hover, #f0f0f0);
border-radius: 4px;
font-size: 0.7rem;
color: var(--text-secondary, #666);
}
/* Export Button */
.btnExport {
padding: 0.4rem 0.75rem;
background: transparent;
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
color: var(--text-primary, #333);
text-decoration: none;
display: inline-block;
}
.btnExport:hover {
border-color: var(--primary-color, #F25843);
color: var(--primary-color, #F25843);
}
.headerActions {
display: flex;
gap: 0.5rem;
align-items: center;
}
/* Session Export */
.sessionExport {
margin-left: 0.5rem;
font-size: 0.75rem;
color: var(--primary-color, #F25843);
text-decoration: none;
}
.sessionExport:hover {
text-decoration: underline;
}
/* Documents */
.uploadLabel {
padding: 0.5rem 1rem;
background: var(--primary-color, #F25843);
color: #fff;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
display: inline-block;
}
.uploadLabel:hover { filter: brightness(1.08); }
.documentList {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.documentItem {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
background: var(--bg-card, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
}
.documentInfo { flex: 1; }
.documentName {
font-size: 0.9rem;
font-weight: 500;
color: var(--text-primary, #333);
}
.documentMeta {
font-size: 0.75rem;
color: var(--text-secondary, #888);
margin-top: 0.2rem;
}
.documentSummary {
font-size: 0.8rem;
color: var(--text-secondary, #666);
margin-top: 0.4rem;
line-height: 1.4;
}

View file

@ -6,13 +6,26 @@
import React, { useState, useCallback, useEffect } from 'react';
import { useCommcoach } from '../../../hooks/useCommcoach';
import { useApiRequest } from '../../../hooks/useApi';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import {
getDossierExportUrl, getSessionExportUrl,
getDocumentsApi, uploadDocumentApi, deleteDocumentApi,
getScoreHistoryApi,
type CoachingDocument,
} from '../../../api/commcoachApi';
import ReactMarkdown from 'react-markdown';
import styles from './CommcoachDossierView.module.css';
export const CommcoachDossierView: React.FC = () => {
const coach = useCommcoach();
const { request } = useApiRequest();
const instanceId = useInstanceId();
const [newTaskTitle, setNewTaskTitle] = useState('');
const [activeTab, setActiveTab] = useState<'sessions' | 'tasks' | 'scores'>('tasks');
const [activeTab, setActiveTab] = useState<'sessions' | 'tasks' | 'scores' | 'documents'>('tasks');
const [documents, setDocuments] = useState<CoachingDocument[]>([]);
const [uploading, setUploading] = useState(false);
const [scoreHistory, setScoreHistory] = useState<Record<string, Array<{ score: number; createdAt?: string }>>>({});
useEffect(() => {
if (!coach.selectedContextId && coach.contexts.length > 0) {
@ -20,6 +33,41 @@ export const CommcoachDossierView: React.FC = () => {
}
}, [coach.contexts, coach.selectedContextId, coach.selectContext]);
useEffect(() => {
if (!instanceId || !coach.selectedContextId) return;
getDocumentsApi(request, instanceId, coach.selectedContextId)
.then(d => setDocuments(d))
.catch(() => {});
getScoreHistoryApi(request, instanceId, coach.selectedContextId)
.then(h => setScoreHistory(h))
.catch(() => {});
}, [instanceId, request, coach.selectedContextId]);
const handleUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !instanceId || !coach.selectedContextId) return;
setUploading(true);
try {
const doc = await uploadDocumentApi(instanceId, coach.selectedContextId, file);
setDocuments(prev => [doc, ...prev]);
} catch {
// upload failed
} finally {
setUploading(false);
e.target.value = '';
}
}, [instanceId, coach.selectedContextId]);
const handleDeleteDocument = useCallback(async (docId: string) => {
if (!instanceId) return;
try {
await deleteDocumentApi(request, instanceId, docId);
setDocuments(prev => prev.filter(d => d.id !== docId));
} catch {
// delete failed
}
}, [instanceId, request]);
const handleAddTask = useCallback(async () => {
if (!newTaskTitle.trim()) return;
await coach.addTask(newTaskTitle);
@ -65,6 +113,26 @@ export const CommcoachDossierView: React.FC = () => {
)}
</div>
<div className={styles.headerActions}>
{instanceId && coach.selectedContextId && (
<>
<a
className={styles.btnExport}
href={getDossierExportUrl(instanceId, coach.selectedContextId, 'md')}
target="_blank"
rel="noopener noreferrer"
>
Export MD
</a>
<a
className={styles.btnExport}
href={getDossierExportUrl(instanceId, coach.selectedContextId, 'pdf')}
target="_blank"
rel="noopener noreferrer"
>
Export PDF
</a>
</>
)}
<button className={styles.btnArchive} onClick={() => coach.archiveContext(coach.selectedContextId!)}>
Archivieren
</button>
@ -91,6 +159,12 @@ export const CommcoachDossierView: React.FC = () => {
>
Bewertungen ({coach.scores.length})
</button>
<button
className={`${styles.tab} ${activeTab === 'documents' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('documents')}
>
Dokumente ({documents.length})
</button>
</div>
{/* Tasks Tab */}
@ -166,6 +240,18 @@ export const CommcoachDossierView: React.FC = () => {
)}
<div className={styles.sessionMeta}>
{s.messageCount} Nachrichten | {Math.round(s.durationSeconds / 60)} Min.
{s.personaId && <span> | Persona</span>}
{instanceId && s.status === 'completed' && (
<a
className={styles.sessionExport}
href={getSessionExportUrl(instanceId, s.id, 'md')}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
>
Export
</a>
)}
</div>
</div>
))}
@ -196,6 +282,58 @@ export const CommcoachDossierView: React.FC = () => {
{group.latest.evidence && (
<div className={styles.scoreEvidence}>{group.latest.evidence}</div>
)}
{scoreHistory[group.dimension] && scoreHistory[group.dimension].length > 1 && (
<div className={styles.scoreHistory}>
<div className={styles.scoreHistoryLabel}>Verlauf:</div>
<div className={styles.scoreHistoryPoints}>
{scoreHistory[group.dimension].map((entry, i) => (
<span key={i} className={styles.scoreHistoryPoint} title={entry.createdAt || ''}>
{Math.round(entry.score)}
</span>
))}
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{/* Documents Tab */}
{activeTab === 'documents' && (
<div className={styles.tabContent}>
<div className={styles.addTaskRow}>
<label className={styles.uploadLabel}>
{uploading ? 'Wird hochgeladen...' : 'Dokument hochladen'}
<input
type="file"
accept=".txt,.md,.pdf,.doc,.docx"
onChange={handleUpload}
disabled={uploading}
style={{ display: 'none' }}
/>
</label>
</div>
{documents.length === 0 ? (
<div className={styles.emptyTab}>Keine Dokumente. Lade Dateien hoch, um sie mit diesem Kontext zu verknuepfen.</div>
) : (
<div className={styles.documentList}>
{documents.map(doc => (
<div key={doc.id} className={styles.documentItem}>
<div className={styles.documentInfo}>
<div className={styles.documentName}>{doc.fileName}</div>
<div className={styles.documentMeta}>
{_formatFileSize(doc.fileSize)} | {doc.createdAt ? new Date(doc.createdAt).toLocaleDateString('de-CH') : ''}
</div>
{doc.summary && (
<div className={styles.documentSummary}>{doc.summary}</div>
)}
</div>
<button className={styles.taskDelete} onClick={() => handleDeleteDocument(doc.id)}>
x
</button>
</div>
))}
</div>
@ -228,6 +366,12 @@ function _groupScoresByDimension(scores: any[]): ScoreGroup[] {
return Object.values(groups);
}
function _formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function _dimensionLabel(dim: string): string {
const labels: Record<string, string> = {
empathy: 'Einfuehlungsvermoegen',