teamsbot
This commit is contained in:
parent
bd9807e06e
commit
012d4ef4d3
8 changed files with 1395 additions and 1 deletions
|
|
@ -157,6 +157,9 @@ function App() {
|
||||||
<Route path="templates" element={<FeatureViewPage view="templates" />} />
|
<Route path="templates" element={<FeatureViewPage view="templates" />} />
|
||||||
<Route path="logs" element={<FeatureViewPage view="logs" />} />
|
<Route path="logs" element={<FeatureViewPage view="logs" />} />
|
||||||
|
|
||||||
|
{/* Teams Bot Feature Views */}
|
||||||
|
<Route path="sessions" element={<FeatureViewPage view="sessions" />} />
|
||||||
|
|
||||||
{/* Catch-all für unbekannte Sub-Pfade */}
|
{/* Catch-all für unbekannte Sub-Pfade */}
|
||||||
<Route path="*" element={<FeatureViewPage view="not-found" />} />
|
<Route path="*" element={<FeatureViewPage view="not-found" />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
183
src/api/teamsbotApi.ts
Normal file
183
src/api/teamsbotApi.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES & INTERFACES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface TeamsbotSession {
|
||||||
|
id: string;
|
||||||
|
instanceId: string;
|
||||||
|
mandateId: string;
|
||||||
|
meetingLink: string;
|
||||||
|
botName: string;
|
||||||
|
backgroundImageUrl?: string;
|
||||||
|
status: 'pending' | 'joining' | 'active' | 'leaving' | 'ended' | 'error';
|
||||||
|
startedAt?: string;
|
||||||
|
endedAt?: string;
|
||||||
|
startedByUserId: string;
|
||||||
|
bridgeSessionId?: string;
|
||||||
|
meetingChatId?: string;
|
||||||
|
summary?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
transcriptSegmentCount: number;
|
||||||
|
botResponseCount: number;
|
||||||
|
creationDate?: string;
|
||||||
|
lastModified?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamsbotTranscript {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
speaker?: string;
|
||||||
|
text: string;
|
||||||
|
timestamp: string;
|
||||||
|
confidence: number;
|
||||||
|
language?: string;
|
||||||
|
isFinal: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamsbotBotResponse {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
responseText: string;
|
||||||
|
responseType: 'audio' | 'chat' | 'both';
|
||||||
|
detectedIntent: 'addressed' | 'question' | 'proactive' | 'none';
|
||||||
|
reasoning?: string;
|
||||||
|
triggeredByTranscriptId?: string;
|
||||||
|
modelName?: string;
|
||||||
|
processingTime: number;
|
||||||
|
priceCHF: number;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamsbotConfig {
|
||||||
|
botName: string;
|
||||||
|
backgroundImageUrl?: string;
|
||||||
|
aiSystemPrompt: string;
|
||||||
|
responseMode: 'auto' | 'manual' | 'transcribeOnly';
|
||||||
|
language: string;
|
||||||
|
voiceId?: string;
|
||||||
|
bridgeUrl?: string;
|
||||||
|
triggerIntervalSeconds: number;
|
||||||
|
triggerCooldownSeconds: number;
|
||||||
|
contextWindowSegments: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamsbotSessionStats {
|
||||||
|
transcriptSegments: number;
|
||||||
|
botResponses: number;
|
||||||
|
totalCostCHF: number;
|
||||||
|
totalProcessingTime: number;
|
||||||
|
speakers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartSessionRequest {
|
||||||
|
meetingLink: string;
|
||||||
|
botName?: string;
|
||||||
|
backgroundImageUrl?: string;
|
||||||
|
connectionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigUpdateRequest {
|
||||||
|
botName?: string;
|
||||||
|
backgroundImageUrl?: string;
|
||||||
|
aiSystemPrompt?: string;
|
||||||
|
responseMode?: 'auto' | 'manual' | 'transcribeOnly';
|
||||||
|
language?: string;
|
||||||
|
voiceId?: string;
|
||||||
|
bridgeUrl?: string;
|
||||||
|
triggerIntervalSeconds?: number;
|
||||||
|
triggerCooldownSeconds?: number;
|
||||||
|
contextWindowSegments?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE Event Types
|
||||||
|
export interface TeamsbotSSEEvent {
|
||||||
|
type: 'transcript' | 'botResponse' | 'analysis' | 'suggestedResponse' | 'statusChange' | 'error' | 'ping' | 'sessionState';
|
||||||
|
data: any;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API FUNCTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a new Teams Bot session.
|
||||||
|
*/
|
||||||
|
export async function startSession(instanceId: string, request: StartSessionRequest): Promise<{ session: TeamsbotSession }> {
|
||||||
|
const response = await api.post(`/api/teamsbot/${instanceId}/sessions`, request);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all sessions for a feature instance.
|
||||||
|
*/
|
||||||
|
export async function listSessions(instanceId: string, includeEnded = true): Promise<{ sessions: TeamsbotSession[] }> {
|
||||||
|
const response = await api.get(`/api/teamsbot/${instanceId}/sessions`, {
|
||||||
|
params: { includeEnded },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get session details with transcripts and bot responses.
|
||||||
|
*/
|
||||||
|
export async function getSession(
|
||||||
|
instanceId: string,
|
||||||
|
sessionId: string,
|
||||||
|
includeTranscripts = true,
|
||||||
|
includeResponses = true,
|
||||||
|
): Promise<{
|
||||||
|
session: TeamsbotSession;
|
||||||
|
transcripts?: TeamsbotTranscript[];
|
||||||
|
botResponses?: TeamsbotBotResponse[];
|
||||||
|
stats?: TeamsbotSessionStats;
|
||||||
|
}> {
|
||||||
|
const response = await api.get(`/api/teamsbot/${instanceId}/sessions/${sessionId}`, {
|
||||||
|
params: { includeTranscripts, includeResponses },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop an active session.
|
||||||
|
*/
|
||||||
|
export async function stopSession(instanceId: string, sessionId: string): Promise<{ status: string; sessionId: string }> {
|
||||||
|
const response = await api.post(`/api/teamsbot/${instanceId}/sessions/${sessionId}/stop`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a session and all related data.
|
||||||
|
*/
|
||||||
|
export async function deleteSession(instanceId: string, sessionId: string): Promise<{ deleted: boolean }> {
|
||||||
|
const response = await api.delete(`/api/teamsbot/${instanceId}/sessions/${sessionId}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get teamsbot configuration.
|
||||||
|
*/
|
||||||
|
export async function getConfig(instanceId: string): Promise<{ config: TeamsbotConfig }> {
|
||||||
|
const response = await api.get(`/api/teamsbot/${instanceId}/config`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update teamsbot configuration.
|
||||||
|
*/
|
||||||
|
export async function updateConfig(instanceId: string, updates: ConfigUpdateRequest): Promise<{ config: TeamsbotConfig }> {
|
||||||
|
const response = await api.put(`/api/teamsbot/${instanceId}/config`, updates);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an SSE EventSource for live session streaming.
|
||||||
|
* Returns the EventSource instance for the caller to manage.
|
||||||
|
*/
|
||||||
|
export function createSessionStream(instanceId: string, sessionId: string): EventSource {
|
||||||
|
const baseUrl = api.defaults.baseURL || '';
|
||||||
|
const url = `${baseUrl}/api/teamsbot/${instanceId}/sessions/${sessionId}/stream`;
|
||||||
|
return new EventSource(url, { withCredentials: true });
|
||||||
|
}
|
||||||
|
|
@ -20,7 +20,8 @@ import {
|
||||||
FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt,
|
FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt,
|
||||||
FaLightbulb, FaRegFileAlt, FaLink, FaComments,
|
FaLightbulb, FaRegFileAlt, FaLink, FaComments,
|
||||||
FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase,
|
FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase,
|
||||||
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock
|
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
|
||||||
|
FaHeadset, FaVideo,
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -79,6 +80,11 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
'page.feature.realestate.projects': <FaProjectDiagram />,
|
'page.feature.realestate.projects': <FaProjectDiagram />,
|
||||||
'page.feature.realestate.parcels': <FaMapMarkedAlt />,
|
'page.feature.realestate.parcels': <FaMapMarkedAlt />,
|
||||||
|
|
||||||
|
// Feature pages - Teams Bot
|
||||||
|
'page.feature.teamsbot.dashboard': <FaChartLine />,
|
||||||
|
'page.feature.teamsbot.sessions': <FaVideo />,
|
||||||
|
'page.feature.teamsbot.settings': <FaCog />,
|
||||||
|
|
||||||
// Feature icons (for feature grouping in navigation)
|
// Feature icons (for feature grouping in navigation)
|
||||||
'feature.trustee': <FaBriefcase />,
|
'feature.trustee': <FaBriefcase />,
|
||||||
'feature.realestate': <FaBuilding />,
|
'feature.realestate': <FaBuilding />,
|
||||||
|
|
@ -86,6 +92,7 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
'feature.chatplayground': <FaPlay />,
|
'feature.chatplayground': <FaPlay />,
|
||||||
'feature.automation': <FaCogs />,
|
'feature.automation': <FaCogs />,
|
||||||
'feature.chatbot': <FaComments />,
|
'feature.chatbot': <FaComments />,
|
||||||
|
'feature.teamsbot': <FaHeadset />,
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,11 @@ import { PlaygroundPage, WorkflowsPage } from './workflows';
|
||||||
// Automation Views (reusing existing workflow pages)
|
// Automation Views (reusing existing workflow pages)
|
||||||
import { AutomationsPage, AutomationTemplatesPage } from './workflows';
|
import { AutomationsPage, AutomationTemplatesPage } from './workflows';
|
||||||
|
|
||||||
|
// Teamsbot Views
|
||||||
|
import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
|
||||||
|
import { TeamsbotSessionView } from './views/teamsbot/TeamsbotSessionView';
|
||||||
|
import { TeamsbotSettingsView } from './views/teamsbot/TeamsbotSettingsView';
|
||||||
|
|
||||||
import styles from './FeatureView.module.css';
|
import styles from './FeatureView.module.css';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -118,6 +123,11 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||||
templates: AutomationTemplatesPage,
|
templates: AutomationTemplatesPage,
|
||||||
logs: () => <PlaceholderView title="Execution Logs" description="Automatisierungs-Ausführungsprotokolle" />,
|
logs: () => <PlaceholderView title="Execution Logs" description="Automatisierungs-Ausführungsprotokolle" />,
|
||||||
},
|
},
|
||||||
|
teamsbot: {
|
||||||
|
dashboard: TeamsbotDashboardView,
|
||||||
|
sessions: TeamsbotSessionView,
|
||||||
|
settings: TeamsbotSettingsView,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
497
src/pages/views/teamsbot/Teamsbot.module.css
Normal file
497
src/pages/views/teamsbot/Teamsbot.module.css
Normal file
|
|
@ -0,0 +1,497 @@
|
||||||
|
/* ============================================================================
|
||||||
|
Teamsbot Feature Styles
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
/* Dashboard */
|
||||||
|
.dashboardContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 960px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.startSessionCard {
|
||||||
|
background: var(--surface-color, #fff);
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardTitle {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardDescription {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formGroup {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formRow .formGroup {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-tertiary, #999);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.select,
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: var(--input-bg, #fff);
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus,
|
||||||
|
.select:focus,
|
||||||
|
.textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color, #4A90D9);
|
||||||
|
box-shadow: 0 0 0 2px rgba(74, 144, 217, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.startButton,
|
||||||
|
.saveButton {
|
||||||
|
padding: 0.6rem 1.5rem;
|
||||||
|
background: var(--primary-color, #4A90D9);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.startButton:hover,
|
||||||
|
.saveButton:hover {
|
||||||
|
background: var(--primary-hover, #3A7BC8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.startButton:disabled,
|
||||||
|
.saveButton:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stopButton {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
background: var(--danger-color, #D94A4A);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stopButton:hover {
|
||||||
|
background: var(--danger-hover, #C83A3A);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewButton {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
background: var(--surface-color, #f5f5f5);
|
||||||
|
color: var(--primary-color, #4A90D9);
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteButton {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--danger-color, #D94A4A);
|
||||||
|
border: 1px solid var(--danger-color, #D94A4A);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error/Success Banners */
|
||||||
|
.errorBanner {
|
||||||
|
background: rgba(217, 74, 74, 0.1);
|
||||||
|
color: var(--danger-color, #D94A4A);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border-left: 3px solid var(--danger-color, #D94A4A);
|
||||||
|
}
|
||||||
|
|
||||||
|
.successBanner {
|
||||||
|
background: rgba(74, 217, 154, 0.1);
|
||||||
|
color: var(--success-color, #2D8E5C);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border-left: 3px solid var(--success-color, #2D8E5C);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section */
|
||||||
|
.sectionContainer {
|
||||||
|
background: var(--surface-color, #fff);
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Session List */
|
||||||
|
.sessionList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionCard {
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--surface-alt, #fafafa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionBotName {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionMeta {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionSummary {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
background: var(--surface-color, #fff);
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionError {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--danger-color, #D94A4A);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Badges */
|
||||||
|
.statusBadge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.15rem 0.6rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusActive {
|
||||||
|
background: rgba(74, 217, 154, 0.15);
|
||||||
|
color: #2D8E5C;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusJoining,
|
||||||
|
.statusPending {
|
||||||
|
background: rgba(74, 144, 217, 0.15);
|
||||||
|
color: #4A90D9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusEnded {
|
||||||
|
background: rgba(128, 128, 128, 0.15);
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusError {
|
||||||
|
background: rgba(217, 74, 74, 0.15);
|
||||||
|
color: #D94A4A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusLeaving {
|
||||||
|
background: rgba(217, 168, 74, 0.15);
|
||||||
|
color: #B8860B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liveBadge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
background: #D94A4A;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
color: var(--text-tertiary, #999);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Session View
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.sessionContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionViewHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--surface-color, #fff);
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionInfo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionControls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionContent {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transcript Panel */
|
||||||
|
.transcriptPanel,
|
||||||
|
.responsesPanel {
|
||||||
|
background: var(--surface-color, #fff);
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelTitle {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
background: var(--surface-alt, #fafafa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcriptList,
|
||||||
|
.responseList {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcriptItem {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcriptItem:hover {
|
||||||
|
background: var(--surface-alt, #f5f5f5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcriptTime {
|
||||||
|
color: var(--text-tertiary, #999);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 55px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcriptSpeaker {
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcriptText {
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Response Items */
|
||||||
|
.responseItem {
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
background: var(--surface-alt, #fafafa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.responseHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.responseIntent {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--primary-color, #4A90D9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.responseTime {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-tertiary, #999);
|
||||||
|
}
|
||||||
|
|
||||||
|
.responseText {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.responseReasoning {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.responseMeta {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-tertiary, #999);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Summary Card */
|
||||||
|
.summaryCard {
|
||||||
|
background: var(--surface-color, #fff);
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryText {
|
||||||
|
padding: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Settings View
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.settingsContainer {
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsCard {
|
||||||
|
background: var(--surface-color, #fff);
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsSection {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsSection:last-of-type {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsActions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
229
src/pages/views/teamsbot/TeamsbotDashboardView.tsx
Normal file
229
src/pages/views/teamsbot/TeamsbotDashboardView.tsx
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
|
import * as teamsbotApi from '../../../api/teamsbotApi';
|
||||||
|
import type { TeamsbotSession, StartSessionRequest } from '../../../api/teamsbotApi';
|
||||||
|
import styles from './Teamsbot.module.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TeamsbotDashboardView - Overview of all Teams Bot sessions.
|
||||||
|
* Allows starting new sessions and viewing active/past sessions.
|
||||||
|
*/
|
||||||
|
export const TeamsbotDashboardView: React.FC = () => {
|
||||||
|
const { instance } = useCurrentInstance();
|
||||||
|
const instanceId = instance?.id || '';
|
||||||
|
|
||||||
|
const [sessions, setSessions] = useState<TeamsbotSession[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// New session form
|
||||||
|
const [meetingLink, setMeetingLink] = useState('');
|
||||||
|
const [botName, setBotName] = useState('');
|
||||||
|
const [isStarting, setIsStarting] = useState(false);
|
||||||
|
|
||||||
|
const _loadSessions = useCallback(async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const result = await teamsbotApi.listSessions(instanceId);
|
||||||
|
setSessions(result.sessions || []);
|
||||||
|
setError(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Fehler beim Laden der Sitzungen');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
_loadSessions();
|
||||||
|
}, [_loadSessions]);
|
||||||
|
|
||||||
|
const _handleStartSession = async () => {
|
||||||
|
if (!meetingLink.trim()) return;
|
||||||
|
|
||||||
|
setIsStarting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request: StartSessionRequest = {
|
||||||
|
meetingLink: meetingLink.trim(),
|
||||||
|
botName: botName.trim() || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
await teamsbotApi.startSession(instanceId, request);
|
||||||
|
setMeetingLink('');
|
||||||
|
setBotName('');
|
||||||
|
await _loadSessions();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Fehler beim Starten der Sitzung');
|
||||||
|
} finally {
|
||||||
|
setIsStarting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleStopSession = async (sessionId: string) => {
|
||||||
|
try {
|
||||||
|
await teamsbotApi.stopSession(instanceId, sessionId);
|
||||||
|
await _loadSessions();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Fehler beim Stoppen der Sitzung');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleDeleteSession = async (sessionId: string) => {
|
||||||
|
if (!window.confirm('Sitzung und alle zugehoerigen Daten loeschen?')) return;
|
||||||
|
try {
|
||||||
|
await teamsbotApi.deleteSession(instanceId, sessionId);
|
||||||
|
await _loadSessions();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Fehler beim Loeschen der Sitzung');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _getStatusBadgeClass = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'active': return styles.statusActive;
|
||||||
|
case 'joining': return styles.statusJoining;
|
||||||
|
case 'pending': return styles.statusPending;
|
||||||
|
case 'ended': return styles.statusEnded;
|
||||||
|
case 'error': return styles.statusError;
|
||||||
|
case 'leaving': return styles.statusLeaving;
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _getStatusLabel = (status: string) => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
pending: 'Wartend',
|
||||||
|
joining: 'Beitritt...',
|
||||||
|
active: 'Aktiv',
|
||||||
|
leaving: 'Verlassen...',
|
||||||
|
ended: 'Beendet',
|
||||||
|
error: 'Fehler',
|
||||||
|
};
|
||||||
|
return labels[status] || status;
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeSessions = sessions.filter(s => ['pending', 'joining', 'active'].includes(s.status));
|
||||||
|
const pastSessions = sessions.filter(s => ['ended', 'error', 'leaving'].includes(s.status));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.dashboardContainer}>
|
||||||
|
{/* Start New Session Card */}
|
||||||
|
<div className={styles.startSessionCard}>
|
||||||
|
<h3 className={styles.cardTitle}>Neue Bot-Sitzung starten</h3>
|
||||||
|
<p className={styles.cardDescription}>
|
||||||
|
Fuege den Teams Meeting-Link ein, um den AI-Bot in ein Meeting einzuschleusen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label className={styles.label}>Teams Meeting-Link *</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
className={styles.input}
|
||||||
|
placeholder="https://teams.microsoft.com/l/meetup-join/..."
|
||||||
|
value={meetingLink}
|
||||||
|
onChange={(e) => setMeetingLink(e.target.value)}
|
||||||
|
disabled={isStarting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label className={styles.label}>Bot-Name (optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.input}
|
||||||
|
placeholder="AI Assistant"
|
||||||
|
value={botName}
|
||||||
|
onChange={(e) => setBotName(e.target.value)}
|
||||||
|
disabled={isStarting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={styles.startButton}
|
||||||
|
onClick={_handleStartSession}
|
||||||
|
disabled={isStarting || !meetingLink.trim()}
|
||||||
|
>
|
||||||
|
{isStarting ? 'Wird gestartet...' : 'Bot ins Meeting senden'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className={styles.errorBanner}>{error}</div>}
|
||||||
|
|
||||||
|
{/* Active Sessions */}
|
||||||
|
{activeSessions.length > 0 && (
|
||||||
|
<div className={styles.sectionContainer}>
|
||||||
|
<h3 className={styles.sectionTitle}>Aktive Sitzungen</h3>
|
||||||
|
<div className={styles.sessionList}>
|
||||||
|
{activeSessions.map((session) => (
|
||||||
|
<div key={session.id} className={styles.sessionCard}>
|
||||||
|
<div className={styles.sessionHeader}>
|
||||||
|
<span className={styles.sessionBotName}>{session.botName}</span>
|
||||||
|
<span className={`${styles.statusBadge} ${_getStatusBadgeClass(session.status)}`}>
|
||||||
|
{_getStatusLabel(session.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.sessionMeta}>
|
||||||
|
<span>{session.transcriptSegmentCount} Segmente</span>
|
||||||
|
<span>{session.botResponseCount} Antworten</span>
|
||||||
|
{session.startedAt && <span>Seit: {new Date(session.startedAt).toLocaleTimeString('de-CH')}</span>}
|
||||||
|
</div>
|
||||||
|
<div className={styles.sessionActions}>
|
||||||
|
<a href={`sessions?sessionId=${session.id}`} className={styles.viewButton}>Live ansehen</a>
|
||||||
|
{session.status === 'active' && (
|
||||||
|
<button className={styles.stopButton} onClick={() => _handleStopSession(session.id)}>
|
||||||
|
Stoppen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Past Sessions */}
|
||||||
|
<div className={styles.sectionContainer}>
|
||||||
|
<h3 className={styles.sectionTitle}>
|
||||||
|
{loading ? 'Lade Sitzungen...' : `Vergangene Sitzungen (${pastSessions.length})`}
|
||||||
|
</h3>
|
||||||
|
{pastSessions.length === 0 && !loading && (
|
||||||
|
<p className={styles.emptyState}>Noch keine vergangenen Sitzungen.</p>
|
||||||
|
)}
|
||||||
|
<div className={styles.sessionList}>
|
||||||
|
{pastSessions.map((session) => (
|
||||||
|
<div key={session.id} className={styles.sessionCard}>
|
||||||
|
<div className={styles.sessionHeader}>
|
||||||
|
<span className={styles.sessionBotName}>{session.botName}</span>
|
||||||
|
<span className={`${styles.statusBadge} ${_getStatusBadgeClass(session.status)}`}>
|
||||||
|
{_getStatusLabel(session.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.sessionMeta}>
|
||||||
|
<span>{session.transcriptSegmentCount} Segmente</span>
|
||||||
|
<span>{session.botResponseCount} Antworten</span>
|
||||||
|
{session.startedAt && <span>{new Date(session.startedAt).toLocaleDateString('de-CH')}</span>}
|
||||||
|
</div>
|
||||||
|
{session.summary && (
|
||||||
|
<div className={styles.sessionSummary}>{session.summary.substring(0, 200)}...</div>
|
||||||
|
)}
|
||||||
|
{session.errorMessage && (
|
||||||
|
<div className={styles.sessionError}>{session.errorMessage}</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.sessionActions}>
|
||||||
|
<a href={`sessions?sessionId=${session.id}`} className={styles.viewButton}>Details</a>
|
||||||
|
<button className={styles.deleteButton} onClick={() => _handleDeleteSession(session.id)}>
|
||||||
|
Loeschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TeamsbotDashboardView;
|
||||||
225
src/pages/views/teamsbot/TeamsbotSessionView.tsx
Normal file
225
src/pages/views/teamsbot/TeamsbotSessionView.tsx
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
|
import * as teamsbotApi from '../../../api/teamsbotApi';
|
||||||
|
import type { TeamsbotSession, TeamsbotTranscript, TeamsbotBotResponse, TeamsbotSSEEvent } from '../../../api/teamsbotApi';
|
||||||
|
import styles from './Teamsbot.module.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TeamsbotSessionView - Live session view with real-time transcript and bot responses.
|
||||||
|
*/
|
||||||
|
export const TeamsbotSessionView: React.FC = () => {
|
||||||
|
const { instance } = useCurrentInstance();
|
||||||
|
const instanceId = instance?.id || '';
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const sessionId = searchParams.get('sessionId') || '';
|
||||||
|
|
||||||
|
const [session, setSession] = useState<TeamsbotSession | null>(null);
|
||||||
|
const [transcripts, setTranscripts] = useState<TeamsbotTranscript[]>([]);
|
||||||
|
const [botResponses, setBotResponses] = useState<TeamsbotBotResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLive, setIsLive] = useState(false);
|
||||||
|
|
||||||
|
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
|
|
||||||
|
// Load session data
|
||||||
|
const _loadSession = useCallback(async () => {
|
||||||
|
if (!instanceId || !sessionId) return;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const result = await teamsbotApi.getSession(instanceId, sessionId);
|
||||||
|
setSession(result.session);
|
||||||
|
setTranscripts(result.transcripts || []);
|
||||||
|
setBotResponses(result.botResponses || []);
|
||||||
|
setError(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Fehler beim Laden der Sitzung');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [instanceId, sessionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
_loadSession();
|
||||||
|
}, [_loadSession]);
|
||||||
|
|
||||||
|
// SSE Live Stream
|
||||||
|
useEffect(() => {
|
||||||
|
if (!instanceId || !sessionId || !session) return;
|
||||||
|
if (!['active', 'joining', 'pending'].includes(session.status)) return;
|
||||||
|
|
||||||
|
const eventSource = teamsbotApi.createSessionStream(instanceId, sessionId);
|
||||||
|
eventSourceRef.current = eventSource;
|
||||||
|
setIsLive(true);
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const sseEvent: TeamsbotSSEEvent = JSON.parse(event.data);
|
||||||
|
|
||||||
|
switch (sseEvent.type) {
|
||||||
|
case 'transcript':
|
||||||
|
setTranscripts(prev => [...prev, sseEvent.data as TeamsbotTranscript]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'botResponse':
|
||||||
|
setBotResponses(prev => [...prev, sseEvent.data as TeamsbotBotResponse]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'statusChange':
|
||||||
|
setSession(prev => prev ? { ...prev, status: sseEvent.data.status } : null);
|
||||||
|
if (['ended', 'error'].includes(sseEvent.data.status)) {
|
||||||
|
setIsLive(false);
|
||||||
|
eventSource.close();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'analysis':
|
||||||
|
// Debug info - could show in UI
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'suggestedResponse':
|
||||||
|
// Manual mode: show suggested response
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ping':
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('SSE parse error:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
setIsLive(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
eventSource.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
setIsLive(false);
|
||||||
|
};
|
||||||
|
}, [instanceId, sessionId, session?.status]);
|
||||||
|
|
||||||
|
// Auto-scroll transcript
|
||||||
|
useEffect(() => {
|
||||||
|
transcriptEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [transcripts]);
|
||||||
|
|
||||||
|
const _handleStop = async () => {
|
||||||
|
if (!instanceId || !sessionId) return;
|
||||||
|
try {
|
||||||
|
await teamsbotApi.stopSession(instanceId, sessionId);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _formatTime = (timestamp: string) => {
|
||||||
|
try {
|
||||||
|
return new Date(timestamp).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _getSpeakerColor = (speaker: string) => {
|
||||||
|
const colors = ['#4A90D9', '#D94A4A', '#4AD99A', '#D9A84A', '#9A4AD9', '#4AD9D9'];
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < speaker.length; i++) {
|
||||||
|
hash = speaker.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
return colors[Math.abs(hash) % colors.length];
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className={styles.loading}>Lade Sitzung...</div>;
|
||||||
|
if (!session) return <div className={styles.errorBanner}>Sitzung nicht gefunden</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.sessionContainer}>
|
||||||
|
{/* Session Header */}
|
||||||
|
<div className={styles.sessionViewHeader}>
|
||||||
|
<div className={styles.sessionInfo}>
|
||||||
|
<h3 className={styles.sessionTitle}>{session.botName}</h3>
|
||||||
|
<span className={`${styles.statusBadge} ${styles[`status${session.status.charAt(0).toUpperCase() + session.status.slice(1)}`] || ''}`}>
|
||||||
|
{session.status}
|
||||||
|
</span>
|
||||||
|
{isLive && <span className={styles.liveBadge}>LIVE</span>}
|
||||||
|
</div>
|
||||||
|
<div className={styles.sessionControls}>
|
||||||
|
{['active', 'joining'].includes(session.status) && (
|
||||||
|
<button className={styles.stopButton} onClick={_handleStop}>Sitzung beenden</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className={styles.errorBanner}>{error}</div>}
|
||||||
|
|
||||||
|
{/* Main Content: Transcript + Responses */}
|
||||||
|
<div className={styles.sessionContent}>
|
||||||
|
{/* Left: Transcript */}
|
||||||
|
<div className={styles.transcriptPanel}>
|
||||||
|
<h4 className={styles.panelTitle}>Transkript ({transcripts.length} Segmente)</h4>
|
||||||
|
<div className={styles.transcriptList}>
|
||||||
|
{transcripts.map((t) => (
|
||||||
|
<div key={t.id} className={styles.transcriptItem}>
|
||||||
|
<span className={styles.transcriptTime}>{_formatTime(t.timestamp)}</span>
|
||||||
|
<span
|
||||||
|
className={styles.transcriptSpeaker}
|
||||||
|
style={{ color: _getSpeakerColor(t.speaker || 'Unknown') }}
|
||||||
|
>
|
||||||
|
{t.speaker || 'Unknown'}:
|
||||||
|
</span>
|
||||||
|
<span className={styles.transcriptText}>{t.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={transcriptEndRef} />
|
||||||
|
{transcripts.length === 0 && (
|
||||||
|
<div className={styles.emptyState}>Noch kein Transkript vorhanden.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Bot Responses */}
|
||||||
|
<div className={styles.responsesPanel}>
|
||||||
|
<h4 className={styles.panelTitle}>Bot-Antworten ({botResponses.length})</h4>
|
||||||
|
<div className={styles.responseList}>
|
||||||
|
{botResponses.map((r) => (
|
||||||
|
<div key={r.id} className={styles.responseItem}>
|
||||||
|
<div className={styles.responseHeader}>
|
||||||
|
<span className={styles.responseIntent}>{r.detectedIntent}</span>
|
||||||
|
<span className={styles.responseTime}>{_formatTime(r.timestamp || '')}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.responseText}>{r.responseText}</div>
|
||||||
|
{r.reasoning && (
|
||||||
|
<div className={styles.responseReasoning}>
|
||||||
|
<em>Reasoning: {r.reasoning}</em>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.responseMeta}>
|
||||||
|
<span>{r.modelName}</span>
|
||||||
|
<span>{r.processingTime.toFixed(1)}s</span>
|
||||||
|
<span>{r.priceCHF.toFixed(4)} CHF</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{botResponses.length === 0 && (
|
||||||
|
<div className={styles.emptyState}>Noch keine Bot-Antworten.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary (for ended sessions) */}
|
||||||
|
{session.summary && (
|
||||||
|
<div className={styles.summaryCard}>
|
||||||
|
<h4 className={styles.panelTitle}>Meeting-Zusammenfassung</h4>
|
||||||
|
<div className={styles.summaryText}>{session.summary}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TeamsbotSessionView;
|
||||||
240
src/pages/views/teamsbot/TeamsbotSettingsView.tsx
Normal file
240
src/pages/views/teamsbot/TeamsbotSettingsView.tsx
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
|
import * as teamsbotApi from '../../../api/teamsbotApi';
|
||||||
|
import type { TeamsbotConfig, ConfigUpdateRequest } from '../../../api/teamsbotApi';
|
||||||
|
import styles from './Teamsbot.module.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TeamsbotSettingsView - Bot configuration for a feature instance.
|
||||||
|
*/
|
||||||
|
export const TeamsbotSettingsView: React.FC = () => {
|
||||||
|
const { instance } = useCurrentInstance();
|
||||||
|
const instanceId = instance?.id || '';
|
||||||
|
|
||||||
|
const [config, setConfig] = useState<TeamsbotConfig | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [formData, setFormData] = useState<ConfigUpdateRequest>({});
|
||||||
|
|
||||||
|
const _loadConfig = useCallback(async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const result = await teamsbotApi.getConfig(instanceId);
|
||||||
|
setConfig(result.config);
|
||||||
|
setFormData(result.config);
|
||||||
|
setError(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Fehler beim Laden der Konfiguration');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
_loadConfig();
|
||||||
|
}, [_loadConfig]);
|
||||||
|
|
||||||
|
const _handleSave = async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccessMsg(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await teamsbotApi.updateConfig(instanceId, formData);
|
||||||
|
setConfig(result.config);
|
||||||
|
setFormData(result.config);
|
||||||
|
setSuccessMsg('Konfiguration gespeichert');
|
||||||
|
setTimeout(() => setSuccessMsg(null), 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Fehler beim Speichern');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _updateField = (field: keyof ConfigUpdateRequest, value: any) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className={styles.loading}>Lade Konfiguration...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.settingsContainer}>
|
||||||
|
<div className={styles.settingsCard}>
|
||||||
|
<h3 className={styles.cardTitle}>Bot-Einstellungen</h3>
|
||||||
|
|
||||||
|
{error && <div className={styles.errorBanner}>{error}</div>}
|
||||||
|
{successMsg && <div className={styles.successBanner}>{successMsg}</div>}
|
||||||
|
|
||||||
|
{/* Bot Identity */}
|
||||||
|
<div className={styles.settingsSection}>
|
||||||
|
<h4 className={styles.sectionTitle}>Bot-Identitaet</h4>
|
||||||
|
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label className={styles.label}>Bot-Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.input}
|
||||||
|
value={formData.botName || ''}
|
||||||
|
onChange={(e) => _updateField('botName', e.target.value)}
|
||||||
|
placeholder="AI Assistant"
|
||||||
|
/>
|
||||||
|
<span className={styles.hint}>Wird als Teilnehmer-Name im Meeting angezeigt</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label className={styles.label}>Hintergrundbild-URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
className={styles.input}
|
||||||
|
value={formData.backgroundImageUrl || ''}
|
||||||
|
onChange={(e) => _updateField('backgroundImageUrl', e.target.value)}
|
||||||
|
placeholder="https://example.com/background.jpg"
|
||||||
|
/>
|
||||||
|
<span className={styles.hint}>Hintergrundbild fuer den Video-Feed des Bots</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Behavior */}
|
||||||
|
<div className={styles.settingsSection}>
|
||||||
|
<h4 className={styles.sectionTitle}>AI-Verhalten</h4>
|
||||||
|
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label className={styles.label}>System-Prompt</label>
|
||||||
|
<textarea
|
||||||
|
className={styles.textarea}
|
||||||
|
value={formData.aiSystemPrompt || ''}
|
||||||
|
onChange={(e) => _updateField('aiSystemPrompt', e.target.value)}
|
||||||
|
rows={6}
|
||||||
|
placeholder="Beschreibe, wie sich der Bot im Meeting verhalten soll..."
|
||||||
|
/>
|
||||||
|
<span className={styles.hint}>Instruktionen fuer den AI-Bot: Wann und wie soll er antworten?</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label className={styles.label}>Antwort-Modus</label>
|
||||||
|
<select
|
||||||
|
className={styles.select}
|
||||||
|
value={formData.responseMode || 'auto'}
|
||||||
|
onChange={(e) => _updateField('responseMode', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="auto">Automatisch - AI entscheidet selbst</option>
|
||||||
|
<option value="manual">Manuell - Antworten muessen bestaetigt werden</option>
|
||||||
|
<option value="transcribeOnly">Nur Transkription - Keine AI-Antworten</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Voice Settings */}
|
||||||
|
<div className={styles.settingsSection}>
|
||||||
|
<h4 className={styles.sectionTitle}>Sprach-Einstellungen</h4>
|
||||||
|
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label className={styles.label}>Sprache</label>
|
||||||
|
<select
|
||||||
|
className={styles.select}
|
||||||
|
value={formData.language || 'de-DE'}
|
||||||
|
onChange={(e) => _updateField('language', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="de-DE">Deutsch (Deutschland)</option>
|
||||||
|
<option value="de-CH">Deutsch (Schweiz)</option>
|
||||||
|
<option value="en-US">English (US)</option>
|
||||||
|
<option value="en-GB">English (UK)</option>
|
||||||
|
<option value="fr-FR">Francais</option>
|
||||||
|
<option value="it-IT">Italiano</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label className={styles.label}>Stimme (Voice ID)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.input}
|
||||||
|
value={formData.voiceId || ''}
|
||||||
|
onChange={(e) => _updateField('voiceId', e.target.value)}
|
||||||
|
placeholder="de-DE-Standard-A"
|
||||||
|
/>
|
||||||
|
<span className={styles.hint}>Google TTS Voice ID fuer die Sprachausgabe</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Settings */}
|
||||||
|
<div className={styles.settingsSection}>
|
||||||
|
<h4 className={styles.sectionTitle}>Erweiterte Einstellungen</h4>
|
||||||
|
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label className={styles.label}>Analyse-Intervall (Sek.)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={styles.input}
|
||||||
|
value={formData.triggerIntervalSeconds || 10}
|
||||||
|
onChange={(e) => _updateField('triggerIntervalSeconds', parseInt(e.target.value))}
|
||||||
|
min={3}
|
||||||
|
max={60}
|
||||||
|
/>
|
||||||
|
<span className={styles.hint}>Periodisches Analyse-Intervall</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label className={styles.label}>Cooldown (Sek.)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={styles.input}
|
||||||
|
value={formData.triggerCooldownSeconds || 3}
|
||||||
|
onChange={(e) => _updateField('triggerCooldownSeconds', parseInt(e.target.value))}
|
||||||
|
min={1}
|
||||||
|
max={30}
|
||||||
|
/>
|
||||||
|
<span className={styles.hint}>Min. Abstand zwischen AI-Calls</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label className={styles.label}>Kontext-Fenster (Segmente)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={styles.input}
|
||||||
|
value={formData.contextWindowSegments || 20}
|
||||||
|
onChange={(e) => _updateField('contextWindowSegments', parseInt(e.target.value))}
|
||||||
|
min={5}
|
||||||
|
max={100}
|
||||||
|
/>
|
||||||
|
<span className={styles.hint}>Anzahl Transkript-Segmente fuer AI-Kontext</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label className={styles.label}>Media Bridge URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
className={styles.input}
|
||||||
|
value={formData.bridgeUrl || ''}
|
||||||
|
onChange={(e) => _updateField('bridgeUrl', e.target.value)}
|
||||||
|
placeholder="https://bridge.example.com"
|
||||||
|
/>
|
||||||
|
<span className={styles.hint}>URL des .NET Media Bridge Service</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className={styles.settingsActions}>
|
||||||
|
<button
|
||||||
|
className={styles.saveButton}
|
||||||
|
onClick={_handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Speichern...' : 'Konfiguration speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TeamsbotSettingsView;
|
||||||
Loading…
Reference in a new issue