Merge pull request #85 from valueonag/int
All checks were successful
Deploy Nyla / deploy (push) Successful in 3s

Int
This commit is contained in:
Patrick Motsch 2026-05-17 00:10:06 +02:00 committed by GitHub
commit ca6261fb1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 3369 additions and 1837 deletions

View file

@ -17,10 +17,10 @@ jobs:
steps:
- name: Checkout Code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'npm'

View file

@ -17,10 +17,10 @@ jobs:
steps:
- name: Checkout Code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'npm'

View file

@ -185,7 +185,10 @@
</div>
<div class="footer">
<p>&copy; 2025 PowerOn AI Platform. All rights reserved.</p>
<p class="legal-meta" style="margin-bottom: 0.75rem; color: #64748b; font-size: 0.85rem;">Company &amp; legal details &middot; May 2026</p>
<p><strong>PowerOn AG</strong> · Birmensdorferstrasse 94 · CH-8003 Zürich · Switzerland</p>
<p><a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
<p style="margin-top: 1rem;">&copy; 2026 PowerOn. All rights reserved.</p>
</div>
</div>
</body>

View file

@ -140,7 +140,7 @@
</div>
<div class="last-updated">
<strong>Last Updated:</strong> August 2025
<strong>Last Updated:</strong> May 2026
</div>
<div class="content-section">
@ -272,8 +272,13 @@
<h2>Contact Us</h2>
<p>If you have any questions about this Privacy Policy or our data practices, please contact us:</p>
<div class="highlight-box">
<p><strong>Email:</strong> privacy@poweron-ai.com</p>
<p><strong>Address:</strong> PowerOn AI Platform, Privacy Team</p>
<p><strong>Email:</strong> <a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
<p><strong>Address:</strong><br>
PowerOn AG<br>
Birmensdorferstrasse 94<br>
CH-8003 Zürich<br>
Switzerland
</p>
</div>
</div>
@ -283,7 +288,7 @@
</div>
<div class="footer">
<p>&copy; 2025 PowerOn AI Platform. All rights reserved.</p>
<p>&copy; 2026 PowerOn. All rights reserved.</p>
</div>
</div>
</body>

View file

@ -153,7 +153,7 @@
</div>
<div class="last-updated">
<strong>Last Updated:</strong> August 2025
<strong>Last Updated:</strong> May 2026
</div>
<div class="content-section">
@ -315,8 +315,13 @@
<h2>Contact Information</h2>
<p>If you have any questions about these Terms of Service, please contact us:</p>
<div class="highlight-box">
<p><strong>Email:</strong> legal@poweron-ai.com</p>
<p><strong>Address:</strong> PowerOn AI Platform, Legal Department</p>
<p><strong>Email:</strong> <a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
<p><strong>Address:</strong><br>
PowerOn AG<br>
Birmensdorferstrasse 94<br>
CH-8003 Zürich<br>
Switzerland
</p>
</div>
</div>
@ -326,7 +331,7 @@
</div>
<div class="footer">
<p>&copy; 2025 PowerOn AI Platform. All rights reserved.</p>
<p>&copy; 2026 PowerOn. All rights reserved.</p>
</div>
</div>
</body>

View file

@ -39,11 +39,12 @@ import { GDPRPage } from './pages/GDPR';
import StorePage from './pages/Store';
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
import { FeatureViewPage } from './pages/FeatureView';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage, AdminDatabaseHealthPage } from './pages/admin';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage, AdminDatabaseHealthPage, SttBenchmarkPage } from './pages/admin';
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
import { AutomationsDashboardPage } from './pages/AutomationsDashboardPage';
import { RagInventoryPage } from './pages/RagInventoryPage';
import { ComplianceAuditPage } from './pages/ComplianceAuditPage';
function App() {
// Load saved theme preference and set app name on app mount
@ -127,6 +128,11 @@ function App() {
{/* ============================================== */}
<Route path="automations" element={<AutomationsDashboardPage />} />
{/* ============================================== */}
{/* RAG INVENTORY */}
{/* ============================================== */}
<Route path="rag-inventory" element={<RagInventoryPage />} />
{/* Legacy top-level routes redirect to dashboard (migrated to feature-instance routes) */}
<Route path="chatbot" element={<Navigate to="/" replace />} />
<Route path="pek" element={<Navigate to="/" replace />} />
@ -167,7 +173,6 @@ function App() {
{/* Workspace + Automation2 Editor */}
<Route path="editor" element={<FeatureViewPage view="editor" />} />
<Route path="rag-insights" element={<FeatureViewPage view="rag-insights" />} />
{/* Automation2 Workflows & Tasks */}
<Route path="workflows" element={<FeatureViewPage view="workflows" />} />
@ -220,6 +225,7 @@ function App() {
<Route path="languages" element={null} />
<Route path="database-health" element={<AdminDatabaseHealthPage />} />
<Route path="demo-config" element={<AdminDemoConfigPage />} />
<Route path="stt-benchmark" element={<SttBenchmarkPage />} />
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
</Route>

View file

@ -6,17 +6,11 @@ import { ApiRequestOptions } from '../hooks/useApi';
export interface KnowledgePreferences {
schemaVersion?: number;
neutralizeBeforeEmbed?: boolean;
mailContentDepth?: 'metadata' | 'snippet' | 'full';
mailIndexAttachments?: boolean;
filesIndexBinaries?: boolean;
mimeAllowlist?: string[];
clickupScope?: 'titles' | 'title_description' | 'with_comments';
clickupIndexAttachments?: boolean;
surfaceToggles?: {
google?: { gmail?: boolean; drive?: boolean };
msft?: { sharepoint?: boolean; outlook?: boolean };
};
maxAgeDays?: number;
}
@ -292,3 +286,119 @@ export async function submitInfomaniakToken(
});
}
// ============================================================================
// RAG KNOWLEDGE CONSENT & CONTROL
// ============================================================================
export async function patchKnowledgeConsent(
request: ApiRequestFunction,
connectionId: string,
enabled: boolean
): Promise<{ connectionId: string; knowledgeIngestionEnabled: boolean; purged?: any; cancelledJobs?: number; bootstrapEnqueued?: boolean }> {
return await request({
url: `/api/connections/${connectionId}/knowledge-consent`,
method: 'patch',
data: { enabled }
});
}
export async function patchKnowledgePreferences(
request: ApiRequestFunction,
connectionId: string,
preferences: KnowledgePreferences
): Promise<{ connectionId: string; knowledgePreferences: KnowledgePreferences; updated: boolean }> {
return await request({
url: `/api/connections/${connectionId}/knowledge-preferences`,
method: 'patch',
data: { preferences }
});
}
export async function postKnowledgeStop(
request: ApiRequestFunction,
connectionId: string
): Promise<{ connectionId: string; cancelled: number }> {
return await request({
url: `/api/connections/${connectionId}/knowledge-stop`,
method: 'post'
});
}
export async function patchDataSourceRagIndex(
request: ApiRequestFunction,
dataSourceId: string,
ragIndexEnabled: boolean
): Promise<{ sourceId: string; ragIndexEnabled: boolean; updated: boolean }> {
return await request({
url: `/api/datasources/${dataSourceId}/rag-index`,
method: 'patch',
data: { ragIndexEnabled }
});
}
// ============================================================================
// RAG INVENTORY
// ============================================================================
export interface RagDataSourceDto {
id: string;
label: string;
path: string;
sourceType: string;
ragIndexEnabled: boolean;
neutralize: boolean;
lastIndexed: number | null;
chunkCount: number;
}
export interface RagConnectionDto {
id: string;
authority: string;
externalEmail: string;
knowledgeIngestionEnabled: boolean;
preferences: KnowledgePreferences;
dataSources: RagDataSourceDto[];
totalChunks: number;
runningJobs: { jobId: string; progress: number; progressMessage: string }[];
lastError?: { jobId: string; errorMessage: string; finishedAt: number | null } | null;
lastSuccess?: {
jobId: string;
finishedAt: number | null;
indexed: number;
skippedDuplicate: number;
skippedPolicy: number;
failed: number;
durationMs: number;
} | null;
}
export interface RagInventoryDto {
connections: RagConnectionDto[];
totals: { chunks: number; bytes?: number };
}
export interface RagActiveJobDto {
jobId: string;
connectionId: string;
connectionLabel?: string;
jobType: string;
progress: number | null;
progressMessage: string;
}
export async function getRagInventoryMe(request: ApiRequestFunction): Promise<RagInventoryDto> {
return await request({ url: '/api/rag/inventory/me', method: 'get' });
}
export async function getRagInventoryMandate(request: ApiRequestFunction): Promise<RagInventoryDto> {
return await request({ url: '/api/rag/inventory/mandate', method: 'get' });
}
export async function getRagInventoryPlatform(request: ApiRequestFunction): Promise<any> {
return await request({ url: '/api/rag/inventory/platform', method: 'get' });
}
export async function getRagActiveJobs(request: ApiRequestFunction): Promise<RagActiveJobDto[]> {
return await request({ url: '/api/rag/inventory/jobs', method: 'get' });
}

View file

@ -71,6 +71,7 @@ export interface TeamsbotConfig {
triggerCooldownSeconds: number;
contextWindowSegments: number;
debugMode?: boolean;
avatarFileId?: string;
}
export interface TeamsbotSessionStats {
@ -84,6 +85,7 @@ export interface TeamsbotSessionStats {
export interface StartSessionRequest {
meetingLink: string;
botName?: string;
moduleId?: string;
connectionId?: string;
joinMode?: TeamsbotJoinMode;
sessionContext?: string;
@ -102,6 +104,7 @@ export interface ConfigUpdateRequest {
triggerCooldownSeconds?: number;
contextWindowSegments?: number;
debugMode?: boolean;
avatarFileId?: string;
}
// Voice option type re-exported from the central voice catalog API
@ -462,6 +465,13 @@ export function createSessionStream(instanceId: string, sessionId: string): Even
return new EventSource(url, { withCredentials: true });
}
/** SSE dashboard stream: periodic { type: 'dashboardState', sessions, modules } */
export function createDashboardStream(instanceId: string): EventSource {
const baseUrl = api.defaults.baseURL || '';
const url = `${baseUrl}/api/teamsbot/${instanceId}/dashboard/stream`;
return new EventSource(url, { withCredentials: true });
}
// =========================================================================
// Debug Screenshots (SysAdmin only)
// =========================================================================
@ -592,6 +602,9 @@ export interface MeetingModule {
defaultDirectorPrompts?: string;
goals?: string;
kpiTargets?: string;
defaultMeetingLink?: string;
defaultBotName?: string;
defaultAvatarFileId?: string;
status: string;
}
@ -602,6 +615,7 @@ export async function listModules(instanceId: string): Promise<MeetingModule[]>
export async function createModule(instanceId: string, body: {
title: string; seriesType?: string; defaultBotId?: string; goals?: string; kpiTargets?: string;
defaultMeetingLink?: string; defaultBotName?: string; defaultAvatarFileId?: string;
}): Promise<MeetingModule> {
const response = await api.post(`/api/teamsbot/${instanceId}/modules`, body);
return response.data?.module;
@ -620,3 +634,31 @@ export async function updateModule(instanceId: string, moduleId: string, body: P
export async function deleteModule(instanceId: string, moduleId: string): Promise<void> {
await api.delete(`/api/teamsbot/${instanceId}/modules/${moduleId}`);
}
export interface MediaFileInfo {
id: string;
fileName: string;
mimeType: string;
}
export async function listMediaFiles(): Promise<MediaFileInfo[]> {
const response = await api.get('/api/files/list', {
params: { pagination: JSON.stringify({ pageSize: 500 }) },
});
const data = response.data;
let items: any[];
if (Array.isArray(data)) {
items = data;
} else if (Array.isArray(data?.items)) {
items = data.items;
} else {
console.warn('[listMediaFiles] unexpected response shape:', Object.keys(data || {}));
items = [];
}
const filtered = items.filter((f: any) => {
const mime = (f.mimeType || '').toLowerCase();
return mime.startsWith('image/') || mime.startsWith('video/');
});
console.log(`[listMediaFiles] ${items.length} total files, ${filtered.length} media files`);
return filtered.map((f: any) => ({ id: f.id, fileName: f.fileName, mimeType: f.mimeType }));
}

View file

@ -73,13 +73,12 @@
/* Connector grid (Step 0) */
.connectorGrid {
display: flex;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 1rem;
flex-wrap: wrap;
}
.connectorCard {
flex: 1 1 140px;
display: flex;
flex-direction: column;
align-items: center;
@ -447,6 +446,22 @@
cursor: not-allowed;
}
.patInput {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 0.9rem;
font-family: monospace;
margin: 12px 0 16px;
}
.patInput:focus {
outline: none;
border-color: var(--primary, #2563eb);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
}
/* Dark theme */
:global(.dark-theme) .connectorCard {
background: var(--surface-color);

View file

@ -1,153 +1,52 @@
/**
* AddConnectionWizard
*
* Multi-step modal for adding a new connector with optional knowledge
* ingestion consent and per-connection preferences (§2.6).
*
* Steps:
* 0 Connector wählen
* 1 Consent (Wissensdatenbank Ja/Nein)
* 2 Präferenzen (nur wenn Ja)
* 3 Zusammenfassung + OAuth starten
* Streamlined multi-step modal for adding a new connector.
* Steps are connector-type-aware:
* Base: Connector Consent Connect
* Microsoft: Connector Consent Admin Consent (optional) Connect
* Infomaniak: Connector Consent PAT Input (done)
*/
import React, { useState } from 'react';
import { Modal } from '../UiComponents/Modal/Modal';
import { FaGoogle, FaMicrosoft, FaTasks, FaDatabase, FaShieldAlt, FaCheck, FaArrowRight, FaInfoCircle } from 'react-icons/fa';
import type { KnowledgePreferences } from '../../api/connectionApi';
import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaCheck, FaArrowRight, FaShieldAlt } from 'react-icons/fa';
import styles from './AddConnectionWizard.module.css';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ConnectorType = 'google' | 'msft' | 'clickup';
export type ConnectorType = 'google' | 'msft' | 'clickup' | 'infomaniak';
type StepId = 'connector' | 'consent' | 'msftAdminConsent' | 'infomaniakPat' | 'connect';
interface WizardState {
step: 0 | 1 | 2 | 3;
currentStep: StepId;
connector: ConnectorType | null;
knowledgeEnabled: boolean;
prefs: KnowledgePreferences;
infomaniakToken: string;
adminConsentDone: boolean;
}
const DEFAULT_PREFS: KnowledgePreferences = {
schemaVersion: 1,
neutralizeBeforeEmbed: false,
mailContentDepth: 'full',
mailIndexAttachments: false,
filesIndexBinaries: true,
clickupScope: 'title_description',
clickupIndexAttachments: false,
maxAgeDays: 90,
};
const CONNECTOR_LABELS: Record<ConnectorType, string> = {
google: 'Google',
msft: 'Microsoft 365',
clickup: 'ClickUp',
infomaniak: 'Infomaniak',
};
const CONNECTOR_ICONS: Record<ConnectorType, React.ReactNode> = {
google: <FaGoogle style={{ color: '#4285f4' }} />,
msft: <FaMicrosoft style={{ color: '#00a4ef' }} />,
clickup: <FaTasks style={{ color: '#7b68ee' }} />,
infomaniak: <FaCloud style={{ color: '#0098db' }} />,
};
// ---------------------------------------------------------------------------
// Cost estimate helper
// ---------------------------------------------------------------------------
/**
* Returns a cost estimate broken into two lines:
*
* 1. Embedding (OpenAI text-embedding-3-small, $0.02 / 1M tokens) always tiny.
* 2. Neutralization (Private LLM / qwen2.5 on-premise, CHF 0.01 per LLM call)
* this is the DOMINANT cost when enabled. One call per email/task for
* short content; several calls for long threads or files.
*
* Numbers are conservative ranges. Subsequent syncs are cheaper because
* unchanged content is deduplicated before any LLM/embedding call.
*/
function computeCostEstimate(
connector: ConnectorType | null,
prefs: KnowledgePreferences,
): {
embeddingLow: string;
embeddingHigh: string;
neutralizationLow: string | null;
neutralizationHigh: string | null;
note: string;
} | null {
if (!connector) return null;
// ---- Embedding (OpenAI, USD) ----
const EMBED_USD_PER_M = 0.02;
const tokensPerMail: Record<string, number> = { metadata: 30, snippet: 120, full: 500 };
const depth = prefs.mailContentDepth ?? 'full';
const maxAge = prefs.maxAgeDays ?? 90;
const mailCount = Math.min(500, Math.round((maxAge / 90) * 500));
const taskCount = Math.min(500, Math.round((maxAge / 90) * 300));
let embedLowTokens = 0;
let embedHighTokens = 0;
if (connector === 'google' || connector === 'msft') {
const mailTokens = mailCount * tokensPerMail[depth];
embedLowTokens += mailTokens * 0.6;
embedHighTokens += mailTokens * 1.5 + 500_000; // Drive/SharePoint
if (prefs.mailIndexAttachments) embedHighTokens += 200_000;
} else if (connector === 'clickup') {
const scope = prefs.clickupScope ?? 'title_description';
const tpt = scope === 'titles' ? 30 : scope === 'title_description' ? 200 : 400;
embedLowTokens += taskCount * tpt * 0.6;
embedHighTokens += taskCount * tpt * 1.5;
}
const fmtUsd = (tokens: number) => {
const usd = (tokens / 1_000_000) * EMBED_USD_PER_M;
if (usd < 0.001) return '< 0.01 $';
if (usd < 0.10) return `~${usd.toFixed(3)} $`;
return `~${usd.toFixed(2)} $`;
};
// ---- Neutralization (Private LLM, CHF 0.01/call) ----
// Each item (email / task / file) = 1 LLM call for short content,
// 2-4 for long threads/documents.
const NEUT_CHF_PER_CALL = 0.01;
let neutLow: string | null = null;
let neutHigh: string | null = null;
if (prefs.neutralizeBeforeEmbed) {
let lowCalls = 0;
let highCalls = 0;
if (connector === 'google' || connector === 'msft') {
lowCalls += mailCount * 1; // 1 call / short email
highCalls += mailCount * 3; // up to 3 calls / long thread
lowCalls += 20; // Drive/SharePoint files (low)
highCalls += 200; // Drive/SharePoint files (high, large PDFs)
} else if (connector === 'clickup') {
lowCalls += taskCount * 1;
highCalls += taskCount * 2;
}
const fmtChf = (calls: number) => {
const chf = calls * NEUT_CHF_PER_CALL;
if (chf < 0.01) return '< 0.01 CHF';
return `~${chf.toFixed(2)} CHF`;
};
neutLow = fmtChf(lowCalls);
neutHigh = fmtChf(highCalls);
}
return {
embeddingLow: fmtUsd(embedLowTokens),
embeddingHigh: fmtUsd(embedHighTokens),
neutralizationLow: neutLow,
neutralizationHigh: neutHigh,
note: 'Einmalig beim ersten Sync. Folge-Syncs kosten weniger (nur neue Inhalte).',
};
function _getSteps(connector: ConnectorType | null): StepId[] {
if (connector === 'msft') return ['connector', 'consent', 'msftAdminConsent', 'connect'];
if (connector === 'infomaniak') return ['connector', 'consent', 'infomaniakPat'];
return ['connector', 'consent', 'connect'];
}
// ---------------------------------------------------------------------------
@ -157,11 +56,9 @@ function computeCostEstimate(
interface AddConnectionWizardProps {
open: boolean;
onClose: () => void;
onConnect: (
type: ConnectorType,
knowledgeEnabled: boolean,
prefs: KnowledgePreferences | null,
) => Promise<void>;
onConnect: (type: ConnectorType, knowledgeEnabled: boolean) => Promise<void>;
onInfomaniakConnect?: (token: string, knowledgeEnabled: boolean) => Promise<void>;
onMsftAdminConsent?: () => void;
isConnecting?: boolean;
}
@ -173,84 +70,91 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
open,
onClose,
onConnect,
onInfomaniakConnect,
onMsftAdminConsent,
isConnecting = false,
}) => {
const [state, setState] = useState<WizardState>({
step: 0,
currentStep: 'connector',
connector: null,
knowledgeEnabled: false,
prefs: { ...DEFAULT_PREFS },
infomaniakToken: '',
adminConsentDone: false,
});
const reset = () =>
setState({ step: 0, connector: null, knowledgeEnabled: false, prefs: { ...DEFAULT_PREFS } });
setState({ currentStep: 'connector', connector: null, knowledgeEnabled: false, infomaniakToken: '', adminConsentDone: false });
const handleClose = () => {
reset();
onClose();
const handleClose = () => { reset(); onClose(); };
const steps = _getSteps(state.connector);
const stepIndex = steps.indexOf(state.currentStep);
const goNext = () => {
const nextIdx = stepIndex + 1;
if (nextIdx < steps.length) {
setState(s => ({ ...s, currentStep: steps[nextIdx] }));
}
};
const setStep = (step: WizardState['step']) => setState(s => ({ ...s, step }));
const setConnector = (connector: ConnectorType) =>
setState(s => ({ ...s, connector, step: 1 }));
const setKnowledgeEnabled = (v: boolean) =>
setState(s => ({ ...s, knowledgeEnabled: v, step: v ? 2 : 3 }));
const updatePref = <K extends keyof KnowledgePreferences>(key: K, value: KnowledgePreferences[K]) =>
setState(s => ({ ...s, prefs: { ...s.prefs, [key]: value } }));
const goBack = () => {
const prevIdx = stepIndex - 1;
if (prevIdx >= 0) {
setState(s => ({ ...s, currentStep: steps[prevIdx] }));
}
};
const handleConnect = async () => {
const selectConnector = (c: ConnectorType) => {
setState(s => ({ ...s, connector: c, currentStep: 'consent' }));
};
const setConsent = (enabled: boolean) => {
setState(s => ({ ...s, knowledgeEnabled: enabled }));
goNext();
};
const handleFinalConnect = async () => {
if (!state.connector) return;
await onConnect(
state.connector,
state.knowledgeEnabled,
state.knowledgeEnabled ? state.prefs : null,
);
if (state.connector === 'infomaniak' && onInfomaniakConnect) {
await onInfomaniakConnect(state.infomaniakToken, state.knowledgeEnabled);
} else {
await onConnect(state.connector, state.knowledgeEnabled);
}
reset();
onClose();
};
const visibleSteps = state.knowledgeEnabled
? [0, 1, 2, 3]
: [0, 1, 3];
return (
<Modal
open={open}
onClose={handleClose}
title="Verbindung hinzufügen"
size="md"
closeOnEscape
>
<Modal open={open} onClose={handleClose} title="Verbindung hinzufügen" size="md" closeOnEscape>
{/* Stepper */}
<div className={styles.stepper}>
{[0, 1, 2, 3].map(i => (
{steps.map((s, i) => (
<div
key={i}
key={s}
className={[
styles.stepDot,
state.step === i ? styles.stepDotActive : '',
state.step > i ? styles.stepDotDone : '',
!visibleSteps.includes(i) ? styles.stepDotHidden : '',
stepIndex === i ? styles.stepDotActive : '',
stepIndex > i ? styles.stepDotDone : '',
].join(' ')}
>
{state.step > i ? <FaCheck size={10} /> : i + 1}
{stepIndex > i ? <FaCheck size={10} /> : i + 1}
</div>
))}
</div>
<div className={styles.body}>
{/* ---- Step 0: Connector ---- */}
{state.step === 0 && (
{/* ---- Step: Connector ---- */}
{state.currentStep === 'connector' && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Anbieter wählen</h3>
<p className={styles.stepHint}>Welchen Dienst möchtest du verbinden?</p>
<div className={styles.connectorGrid}>
{(['google', 'msft', 'clickup'] as ConnectorType[]).map(type => (
{(['google', 'msft', 'clickup', 'infomaniak'] as ConnectorType[]).map(type => (
<button
key={type}
type="button"
className={styles.connectorCard}
onClick={() => setConnector(type)}
onClick={() => selectConnector(type)}
>
<span className={styles.connectorIcon}>{CONNECTOR_ICONS[type]}</span>
<span className={styles.connectorLabel}>{CONNECTOR_LABELS[type]}</span>
@ -260,151 +164,103 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
</div>
)}
{/* ---- Step 1: Consent ---- */}
{state.step === 1 && (
{/* ---- Step: Consent ---- */}
{state.currentStep === 'consent' && (
<div className={styles.stepContent}>
<div className={styles.consentIcon}><FaDatabase size={32} /></div>
<h3 className={styles.stepTitle}>Wissensdatenbank</h3>
<p className={styles.stepBody}>
Möchtest du Inhalte aus dieser Verbindung in deine persönliche
Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen
aus{' '}
{state.connector ? CONNECTOR_LABELS[state.connector] : 'diesem Dienst'}{' '}
zurückgreifen kann?
aus {state.connector ? CONNECTOR_LABELS[state.connector] : 'diesem Dienst'} zurückgreifen kann?
</p>
<p className={styles.stepHint}>
Du kannst diese Einstellung später in den Verbindungsdetails ändern.
Du kannst dies später jederzeit in der UDB pro Datenquelle steuern.
</p>
<div className={styles.consentButtons}>
<button type="button" className={styles.consentButtonYes} onClick={() => setConsent(true)}>
<FaCheck /> Ja, aktivieren
</button>
<button type="button" className={styles.consentButtonNo} onClick={() => setConsent(false)}>
Nein, überspringen
</button>
</div>
<div className={styles.stepNavLeft}>
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button>
</div>
</div>
)}
{/* ---- Step: MSFT Admin Consent ---- */}
{state.currentStep === 'msftAdminConsent' && (
<div className={styles.stepContent}>
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<FaShieldAlt size={32} style={{ color: '#00a4ef' }} />
</div>
<h3 className={styles.stepTitle}>Organisations-Zustimmung (optional)</h3>
<p className={styles.stepBody}>
Falls du Mandant-Administrator bist, kannst du jetzt für deine ganze Organisation zustimmen.
So müssen andere Benutzer nicht einzeln bestätigen.
</p>
<p className={styles.stepHint}>
Wenn du kein Admin bist oder dies später tun möchtest, überspringe diesen Schritt.
</p>
<div className={styles.consentButtons}>
<button
type="button"
className={styles.consentButtonYes}
onClick={() => setKnowledgeEnabled(true)}
onClick={() => { onMsftAdminConsent?.(); setState(s => ({ ...s, adminConsentDone: true })); goNext(); }}
>
<FaCheck /> Ja, aufnehmen
<FaShieldAlt /> Admin-Zustimmung erteilen
</button>
<button
type="button"
className={styles.consentButtonNo}
onClick={() => setKnowledgeEnabled(false)}
>
Nein, überspringen
<button type="button" className={styles.consentButtonNo} onClick={goNext}>
Überspringen
</button>
</div>
<div className={styles.stepNavLeft}>
<button type="button" className={styles.navBack} onClick={() => setStep(0)}>
Zurück
</button>
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button>
</div>
</div>
)}
{/* ---- Step 2: Preferences ---- */}
{state.step === 2 && (
{/* ---- Step: Infomaniak PAT ---- */}
{state.currentStep === 'infomaniakPat' && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Einstellungen</h3>
<p className={styles.stepHint}>
Steuere, welche Inhalte und in welcher Form sie indexiert werden.
<h3 className={styles.stepTitle}>Infomaniak Personal Access Token</h3>
<p className={styles.stepBody}>
Erstelle einen Personal Access Token in deinem Infomaniak-Konto und füge ihn hier ein.
</p>
<div className={styles.prefGroup}>
<label className={styles.prefLabel}>
<FaShieldAlt className={styles.prefIcon} />
Anonymisierung vor dem Indexieren
<input
type="checkbox"
checked={!!state.prefs.neutralizeBeforeEmbed}
onChange={e => updatePref('neutralizeBeforeEmbed', e.target.checked)}
className={styles.prefCheck}
/>
</label>
<p className={styles.prefHint}>
Persönliche Daten (Namen, E-Mail-Adressen) werden vor dem Speichern ersetzt.
</p>
</div>
{(state.connector === 'google' || state.connector === 'msft') && (
<>
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
E-Mail-Inhalt
<select
value={state.prefs.mailContentDepth ?? 'full'}
onChange={e => updatePref('mailContentDepth', e.target.value as any)}
className={styles.prefSelect}
>
<option value="metadata">Nur Metadaten (Betreff, Absender, Datum)</option>
<option value="snippet">Vorschautext (ca. 250 Zeichen)</option>
<option value="full">Vollständiger Text</option>
</select>
</label>
</div>
<div className={styles.prefGroup}>
<label className={styles.prefLabel}>
E-Mail-Anhänge indexieren
<input
type="checkbox"
checked={!!state.prefs.mailIndexAttachments}
onChange={e => updatePref('mailIndexAttachments', e.target.checked)}
className={styles.prefCheck}
/>
</label>
</div>
</>
)}
{state.connector === 'clickup' && (
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
Aufgaben-Inhalt
<select
value={state.prefs.clickupScope ?? 'title_description'}
onChange={e => updatePref('clickupScope', e.target.value as any)}
className={styles.prefSelect}
>
<option value="titles">Nur Aufgabentitel</option>
<option value="title_description">Titel + Beschreibung</option>
<option value="with_comments">Titel + Beschreibung + Kommentare</option>
</select>
</label>
</div>
)}
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
Zeitfenster (Tage)
<input
type="number"
min={0}
max={3650}
value={state.prefs.maxAgeDays ?? 90}
onChange={e => updatePref('maxAgeDays', parseInt(e.target.value, 10) || 0)}
className={styles.prefNumber}
/>
</label>
<p className={styles.prefHint}>0 = kein Limit</p>
</div>
<input
type="password"
placeholder="pat_..."
value={state.infomaniakToken}
onChange={e => setState(s => ({ ...s, infomaniakToken: e.target.value }))}
className={styles.patInput}
autoFocus
/>
<div className={styles.stepNav}>
<button type="button" className={styles.navBack} onClick={() => setStep(1)}>
Zurück
</button>
<button type="button" className={styles.navNext} onClick={() => setStep(3)}>
Weiter <FaArrowRight size={12} />
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button>
<button
type="button"
className={styles.navConnect}
onClick={handleFinalConnect}
disabled={isConnecting || !state.infomaniakToken.trim()}
>
{isConnecting ? 'Verbinden…' : 'Verbinden'}
{!isConnecting && <FaArrowRight size={12} />}
</button>
</div>
</div>
)}
{/* ---- Step 3: Summary ---- */}
{state.step === 3 && (
{/* ---- Step: Connect ---- */}
{state.currentStep === 'connect' && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Zusammenfassung</h3>
<h3 className={styles.stepTitle}>Verbindung herstellen</h3>
<div className={styles.summary}>
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Anbieter</span>
<span className={styles.summaryVal}>
{CONNECTOR_ICONS[state.connector!]}&nbsp;
{state.connector && CONNECTOR_ICONS[state.connector]}&nbsp;
{state.connector ? CONNECTOR_LABELS[state.connector] : '—'}
</span>
</div>
@ -414,96 +270,13 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
{state.knowledgeEnabled ? '✓ Aktiv' : '✗ Nicht aktiv'}
</span>
</div>
{state.knowledgeEnabled && (
<>
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Anonymisierung</span>
<span className={styles.summaryVal}>
{state.prefs.neutralizeBeforeEmbed ? 'Ja' : 'Nein'}
</span>
</div>
{(state.connector === 'google' || state.connector === 'msft') && (
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>E-Mail-Tiefe</span>
<span className={styles.summaryVal}>
{{ metadata: 'Nur Metadaten', snippet: 'Vorschautext', full: 'Volltext' }[
state.prefs.mailContentDepth ?? 'full'
] ?? state.prefs.mailContentDepth}
</span>
</div>
)}
{state.connector === 'clickup' && (
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Aufgaben-Inhalt</span>
<span className={styles.summaryVal}>
{{
titles: 'Nur Titel',
title_description: 'Titel + Beschreibung',
with_comments: 'Titel + Beschreibung + Kommentare',
}[state.prefs.clickupScope ?? 'title_description'] ?? state.prefs.clickupScope}
</span>
</div>
)}
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Zeitfenster</span>
<span className={styles.summaryVal}>
{state.prefs.maxAgeDays ? `${state.prefs.maxAgeDays} Tage` : 'Unbegrenzt'}
</span>
</div>
</>
)}
</div>
{/* Cost estimate — only shown when knowledge ingestion is enabled */}
{state.knowledgeEnabled && (() => {
const est = computeCostEstimate(state.connector, state.prefs);
if (!est) return null;
return (
<div className={styles.costHint}>
<FaInfoCircle className={styles.costHintIcon} />
<div>
<span className={styles.costHintTitle}>Geschätzte Kosten (erster Sync)</span>
<table className={styles.costTable}>
<tbody>
<tr>
<td className={styles.costLabel}>Embedding</td>
<td className={styles.costVal}>
{est.embeddingLow} {est.embeddingHigh}
</td>
</tr>
{est.neutralizationLow && (
<tr className={styles.costRowNeut}>
<td className={styles.costLabel}>Anonymisierung (Private LLM)</td>
<td className={styles.costVal}>
{est.neutralizationLow} {est.neutralizationHigh}
</td>
</tr>
)}
</tbody>
</table>
{est.neutralizationLow && (
<span className={styles.costHintWarn}>
Anonymisierung ist der Hauptkostentreiber (CHF 0.01 pro LLM-Aufruf, on-premise).
</span>
)}
<span className={styles.costHintNote}>{est.note}</span>
</div>
</div>
);
})()}
<div className={styles.stepNav}>
<button
type="button"
className={styles.navBack}
onClick={() => setStep(state.knowledgeEnabled ? 2 : 1)}
>
Zurück
</button>
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button>
<button
type="button"
className={styles.navConnect}
onClick={handleConnect}
onClick={handleFinalConnect}
disabled={isConnecting}
>
{isConnecting ? 'Verbinden…' : `Mit ${state.connector ? CONNECTOR_LABELS[state.connector] : '…'} verbinden`}

View file

@ -254,35 +254,30 @@
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-primary, #fff);
overflow: visible;
}
/* Toolbar: context (load + name) is fluid with ellipsis; actions stay right-aligned. */
/* Toolbar: context (load + name) is fluid with ellipsis; actions wrap below on narrow viewports. */
.canvasHeaderRow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem 0.75rem;
align-items: center;
width: 100%;
}
@media (max-width: 900px) {
.canvasHeaderRow {
grid-template-columns: 1fr;
}
}
.canvasHeaderContext {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
flex: 1;
flex: 1 1 auto;
}
/* Closed <select> width must not follow the longest option label. */
.canvasHeaderWorkflowSelect {
flex: 0 0 auto;
width: 12.5rem;
flex: 0 1 12.5rem;
min-width: 8rem;
max-width: 100%;
padding: 0.4rem 0.5rem;
min-height: 2rem;
@ -347,6 +342,7 @@
background: var(--bg-secondary, #f8f9fa);
flex: 0 1 auto;
max-width: 100%;
margin-left: auto;
}
/* .retryButton sets margin-top for legacy error stacks — not wanted in the toolbar. */
@ -366,6 +362,8 @@
@media (max-width: 900px) {
.canvasHeaderActionPanel {
justify-content: flex-start;
margin-left: 0;
flex-basis: 100%;
}
}

View file

@ -101,9 +101,8 @@
flex-direction: column;
gap: 0.5rem;
min-width: 0;
/* Share remaining viewport among expanded groups; scroll when many groups */
flex: 1 1 280px;
min-height: 0;
flex: 1 1 400px;
min-height: 350px;
}
.groupSectionCollapsed {

View file

@ -681,7 +681,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
resizable = true,
pagination = true,
pageSize = 10,
pageSizeOptions = [10, 25, 50, 100, 500],
pageSizeOptions = [10, 25, 50, 100, 500, 1000, 2000, 10000],
showPageSizeSelector = true,
onRowClick,
onRowSelect,
@ -740,13 +740,16 @@ export function FormGeneratorTable<T extends Record<string, any>>({
const [activeViewKey, setActiveViewKey] = useState<string | null>(null);
const [activeViewId, setActiveViewId] = useState<string | null>(null);
const [groupByLevels, setGroupByLevels] = useState<GroupByLevelSpec[]>([]);
const useSectionsGroupLayout =
tableGroupLayoutMode === 'sections' &&
const [groupLayoutMode, setGroupLayoutMode] = useState<'inline' | 'sections'>(tableGroupLayoutMode ?? 'inline');
const canUseSections =
!!tableContextKey &&
groupByLevels.length === 1 &&
groupByLevels.length > 0 &&
typeof hookDataProp?.fetchGroupSectionSummaries === 'function' &&
typeof hookDataProp?.refetchForSection === 'function';
const useSectionsGroupLayout = canUseSections && groupLayoutMode === 'sections';
const [sectionSummaries, setSectionSummaries] = useState<
Array<{ value: string | null; label: string; totalCount: number }>
>([]);
@ -1360,6 +1363,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
viewKey: activeViewKey,
groupField: spec.field,
groupDirection: spec.direction || 'asc',
groupByLevels: groupLevelsToApiPayload(groupByLevels),
});
if (!cancelled) setSectionSummaries(Array.isArray(list) ? list : []);
} catch (e) {
@ -2750,6 +2754,27 @@ export function FormGeneratorTable<T extends Record<string, any>>({
onUpdateViewGrouping={(id, levels) => void handleUpdateViewGrouping(id, levels)}
onDeleteView={(id) => void handleDeleteView(id)}
onReloadViews={() => void reloadViews()}
canUseSections={canUseSections}
groupLayoutMode={groupLayoutMode}
onGroupLayoutModeChange={(mode) => {
setGroupLayoutMode(mode);
setCollapsedGroups(new Set());
setCollapsedSectionKeys(new Set());
setCurrentPage(1);
}}
hasGroupBands={!!effectiveGroupLayout && effectiveGroupLayout.bands.length > 0}
onCollapseAll={() => {
if (effectiveGroupLayout) {
setCollapsedGroups(new Set(effectiveGroupLayout.bands.map((b) => b.path.join('///'))));
}
if (useSectionsGroupLayout) {
setCollapsedSectionKeys(new Set(sectionSummaries.map((g) => g.value === null || g.value === undefined ? '__empty__' : String(g.value))));
}
}}
onExpandAll={() => {
setCollapsedGroups(new Set());
setCollapsedSectionKeys(new Set());
}}
/>
)}
@ -3341,13 +3366,23 @@ export function FormGeneratorTable<T extends Record<string, any>>({
)}
<div className={styles.groupSections}>
{sectionSummaries.map((g) => {
const field = groupByLevels[0].field;
const sectionFilter: Record<string, unknown> = {
[field]: g.value === null || g.value === undefined ? null : g.value,
};
const isMultiLevel = groupByLevels.length > 1 && (g as any).filters;
const sectionFilter: Record<string, unknown> = isMultiLevel
? (g as any).filters
: { [groupByLevels[0].field]: g.value === null || g.value === undefined ? null : g.value };
const groupFields = isMultiLevel
? groupByLevels.map((l) => l.field)
: [groupByLevels[0].field];
const sk =
g.value === null || g.value === undefined ? '__empty__' : String(g.value);
const sectionCollapsed = collapsedSectionKeys.has(sk);
const groupFieldSet = new Set(groupFields);
const sectionColumns = (providedColumns ?? []).map((col: any) =>
groupFieldSet.has(col.key) ? { ...col, filterable: false } : col,
);
const sectionInitialFilters = Object.fromEntries(
Object.entries(filters).filter(([k]) => !groupFieldSet.has(k)),
);
return (
<section
key={sk}
@ -3382,9 +3417,9 @@ export function FormGeneratorTable<T extends Record<string, any>>({
</button>
{!sectionCollapsed && (
<FormGeneratorTable<T>
key={`${sk}-r${refreshNonce}-${JSON.stringify(filters)}-${JSON.stringify(sortConfigs)}-${activeViewKey ?? ''}`}
key={`${sk}-r${refreshNonce}-${JSON.stringify(sectionInitialFilters)}-${JSON.stringify(sortConfigs)}-${activeViewKey ?? ''}`}
className={styles.groupSectionTableWrap}
columns={providedColumns}
columns={sectionColumns}
data={[]}
searchable={false}
filterable={filterable}
@ -3415,7 +3450,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
localDataMode
viewKeyForQueries={activeViewKey}
initialSearchTerm={debouncedSearchTerm}
initialFilters={filters}
initialFilters={sectionInitialFilters}
initialSort={sortConfigs}
apiEndpoint={apiEndpoint}
csvExportQueryParams={hookDataProp?.csvExportQueryParams}
@ -3427,13 +3462,13 @@ export function FormGeneratorTable<T extends Record<string, any>>({
if (!hookDataProp?.refetchForSection) {
return { items: [], pagination: null };
}
return hookDataProp.refetchForSection(p, sectionFilter, filters);
return hookDataProp.refetchForSection(p, sectionFilter, sectionInitialFilters);
},
...(hookDataProp?.fetchFilterValues && typeof hookDataProp.fetchFilterValues === 'function'
? {
fetchFilterValues: async (columnKey: string, crossFilters?: Record<string, any>) => {
const merged: Record<string, any> = {
...filters,
...sectionInitialFilters,
...(crossFilters || {}),
...sectionFilter,
};

View file

@ -58,6 +58,13 @@ function _buildChildMap<T>(nodes: TreeNode<T>[]): Map<string | '__root__', TreeN
if (list) list.push(n);
else map.set(key, [n]);
}
for (const [, children] of map) {
children.sort((a, b) => {
if (a.type === 'folder' && b.type !== 'folder') return -1;
if (a.type !== 'folder' && b.type === 'folder') return 1;
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
});
}
return map;
}
@ -409,7 +416,7 @@ export function FormGeneratorTree<T = any>({
className,
}: FormGeneratorTreeProps<T>) {
const { t } = useLanguage();
const { confirm } = useConfirm();
const { confirm, ConfirmDialog } = useConfirm();
const { prompt, PromptDialog } = usePrompt();
const [nodes, setNodes] = useState<TreeNode<T>[]>([]);
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
@ -651,10 +658,14 @@ export function FormGeneratorTree<T = any>({
const _handleCycleScope = useCallback(
async (node: TreeNode<T>) => {
const newScope = _nextScope(node.scope);
await provider.patchScope?.([node.id], newScope);
setNodes((prev) =>
prev.map((n) => (n.id === node.id ? { ...n, scope: newScope } : n)),
);
const isFolder = node.type === 'folder';
await provider.patchScope?.([node.id], newScope, isFolder);
setNodes((prev) => {
if (!isFolder) return prev.map((n) => (n.id === node.id ? { ...n, scope: newScope } : n));
const descendantIds = new Set(_collectDescendantIds(node.id, prev));
descendantIds.add(node.id);
return prev.map((n) => descendantIds.has(n.id) ? { ...n, scope: newScope } : n);
});
},
[provider],
);
@ -663,9 +674,12 @@ export function FormGeneratorTree<T = any>({
async (node: TreeNode<T>) => {
const newValue = !node.neutralize;
await provider.patchNeutralize?.([node.id], newValue);
setNodes((prev) =>
prev.map((n) => (n.id === node.id ? { ...n, neutralize: newValue } : n)),
);
setNodes((prev) => {
if (node.type !== 'folder') return prev.map((n) => (n.id === node.id ? { ...n, neutralize: newValue } : n));
const descendantIds = new Set(_collectDescendantIds(node.id, prev));
descendantIds.add(node.id);
return prev.map((n) => descendantIds.has(n.id) ? { ...n, neutralize: newValue } : n);
});
},
[provider],
);
@ -1018,6 +1032,7 @@ export function FormGeneratorTree<T = any>({
</div>
)}
<PromptDialog />
<ConfirmDialog />
</div>
);
}

View file

@ -153,6 +153,51 @@
white-space: nowrap;
}
.layoutToggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
border: 1px solid var(--color-border, #cbd5e1);
background: var(--color-bg, #fff);
color: var(--color-text, #0f172a);
cursor: pointer;
font-size: 18px;
transition: background 0.15s, border-color 0.15s;
}
.layoutToggle:hover {
background: var(--bg-hover, rgba(15, 23, 42, 0.04));
border-color: var(--color-primary, #64748b);
}
.collapseExpandGroup {
display: inline-flex;
gap: 2px;
}
.collapseBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 6px;
border: 1px solid var(--color-border, #cbd5e1);
background: var(--color-bg, #fff);
color: var(--text-secondary, #94a3b8);
cursor: pointer;
font-size: 16px;
transition: background 0.15s, color 0.15s;
}
.collapseBtn:hover {
background: var(--bg-hover, rgba(15, 23, 42, 0.04));
color: var(--color-text, #0f172a);
}
.viewBlock {
display: flex;
flex-wrap: wrap;

View file

@ -1,5 +1,7 @@
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { FaLayerGroup, FaTrash } from 'react-icons/fa';
import { TbLayoutList, TbLayoutRows } from 'react-icons/tb';
import { FiChevronsDown, FiChevronsUp } from 'react-icons/fi';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './TableViewsBar.module.css';
@ -30,6 +32,12 @@ export interface TableViewsBarProps {
onUpdateViewGrouping: (viewId: string, levels: GroupByLevelSpec[]) => void | Promise<void>;
onDeleteView?: (viewId: string) => void | Promise<void>;
onReloadViews: () => void;
canUseSections?: boolean;
groupLayoutMode?: 'inline' | 'sections';
onGroupLayoutModeChange?: (mode: 'inline' | 'sections') => void;
hasGroupBands?: boolean;
onCollapseAll?: () => void;
onExpandAll?: () => void;
}
function slugify(name: string): string {
@ -74,6 +82,12 @@ export function TableViewsBar({
onUpdateViewGrouping,
onDeleteView,
onReloadViews,
canUseSections,
groupLayoutMode,
onGroupLayoutModeChange,
hasGroupBands,
onCollapseAll,
onExpandAll,
}: TableViewsBarProps) {
const { t } = useLanguage();
const [groupMenuOpen, setGroupMenuOpen] = useState(false);
@ -249,6 +263,41 @@ export function TableViewsBar({
: `${t('Aktiv')}: ${summary}`}
</span>
{canUseSections && groupByLevels.length > 0 && onGroupLayoutModeChange && (
<button
type="button"
className={styles.layoutToggle}
title={groupLayoutMode === 'inline' ? t('Zu Sektionen wechseln') : t('Zu Inline wechseln')}
aria-label={groupLayoutMode === 'inline' ? t('Zu Sektionen wechseln') : t('Zu Inline wechseln')}
onClick={() => onGroupLayoutModeChange(groupLayoutMode === 'inline' ? 'sections' : 'inline')}
>
{groupLayoutMode === 'inline' ? <TbLayoutRows /> : <TbLayoutList />}
</button>
)}
{hasGroupBands && onCollapseAll && onExpandAll && (
<div className={styles.collapseExpandGroup}>
<button
type="button"
className={styles.collapseBtn}
title={t('Alle zuklappen')}
aria-label={t('Alle zuklappen')}
onClick={onCollapseAll}
>
<FiChevronsUp />
</button>
<button
type="button"
className={styles.collapseBtn}
title={t('Alle aufklappen')}
aria-label={t('Alle aufklappen')}
onClick={onExpandAll}
>
<FiChevronsDown />
</button>
</div>
)}
<div className={styles.viewBlock}>
<span className={styles.viewLabel}>{t('Ansicht')}</span>
<select

View file

@ -0,0 +1,116 @@
.badgeContainer {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 9000;
}
.badge {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: var(--primary-color, #F25843);
color: #fff;
border: none;
border-radius: 20px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: transform 0.15s, box-shadow 0.15s;
}
.badge:hover {
transform: scale(1.04);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.pulseIcon {
width: 8px;
height: 8px;
border-radius: 50%;
background: #fff;
animation: pulse 1.5s infinite;
}
.badgeDone {
background: #16a34a;
animation: doneFadeIn 0.25s ease-out;
}
.doneIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
font-weight: 700;
font-size: 12px;
line-height: 1;
}
@keyframes doneFadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}
.badgeText {
white-space: nowrap;
}
.dropdown {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
background: var(--card-bg, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
min-width: 240px;
max-height: 200px;
overflow-y: auto;
}
.dropdownHeader {
padding: 10px 14px 6px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary, #666);
border-bottom: 1px solid var(--border-color, #eee);
}
.jobRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
font-size: 12px;
}
.jobRow:not(:last-child) {
border-bottom: 1px solid var(--border-color, #f0f0f0);
}
.jobLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.jobProgress {
flex-shrink: 0;
margin-left: 8px;
font-weight: 600;
color: var(--primary-color, #F25843);
}

View file

@ -0,0 +1,106 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { useApiRequest } from '../../hooks/useApi';
import { useLanguage } from '../../providers/language/LanguageContext';
import styles from './RagRunningBadge.module.css';
interface _RagJob {
jobId: string;
connectionId: string;
connectionLabel?: string;
jobType: string;
progress: number | null;
progressMessage: string;
}
const _POLL_INTERVAL_ACTIVE_MS = 5_000;
const _POLL_INTERVAL_IDLE_MS = 60_000;
const _DONE_TOAST_MS = 4_000;
export const RagRunningBadge: React.FC = () => {
const { t } = useLanguage();
const { request } = useApiRequest();
const [jobs, setJobs] = useState<_RagJob[]>([]);
const [justFinished, setJustFinished] = useState(false);
const [expanded, setExpanded] = useState(false);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const previousJobCount = useRef(0);
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const _fetchJobs = useCallback(async () => {
try {
const result = await request({ url: '/api/rag/inventory/jobs', method: 'get' });
const list = Array.isArray(result) ? (result as _RagJob[]) : [];
// Detect "all running jobs just completed" → flash a brief success toast
// so the user gets visible confirmation that the work actually finished
// instead of the spinner just silently disappearing.
if (previousJobCount.current > 0 && list.length === 0) {
setJustFinished(true);
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
toastTimerRef.current = setTimeout(() => setJustFinished(false), _DONE_TOAST_MS);
}
previousJobCount.current = list.length;
setJobs(list);
} catch {
setJobs([]);
}
}, [request]);
useEffect(() => {
_fetchJobs();
}, [_fetchJobs]);
useEffect(() => {
if (timerRef.current) clearInterval(timerRef.current);
const interval = jobs.length > 0 ? _POLL_INTERVAL_ACTIVE_MS : _POLL_INTERVAL_IDLE_MS;
timerRef.current = setInterval(_fetchJobs, interval);
return () => { if (timerRef.current) clearInterval(timerRef.current); };
}, [_fetchJobs, jobs.length]);
useEffect(() => {
return () => { if (toastTimerRef.current) clearTimeout(toastTimerRef.current); };
}, []);
if (jobs.length === 0 && !justFinished) return null;
if (jobs.length === 0 && justFinished) {
return (
<div className={styles.badgeContainer}>
<div className={`${styles.badge} ${styles.badgeDone}`} title={t('Sync abgeschlossen')}>
<span className={styles.doneIcon}></span>
<span className={styles.badgeText}>{t('Sync abgeschlossen')}</span>
</div>
</div>
);
}
return (
<div className={styles.badgeContainer}>
<button
className={styles.badge}
onClick={() => setExpanded(prev => !prev)}
title={t('RAG-Indexierung aktiv')}
>
<span className={styles.pulseIcon} />
<span className={styles.badgeText}>
{jobs.length} {jobs.length === 1 ? t('Job') : t('Jobs')}
</span>
</button>
{expanded && (
<div className={styles.dropdown}>
<div className={styles.dropdownHeader}>
{t('Aktive RAG-Jobs')}
</div>
{jobs.map(job => (
<div key={job.jobId} className={styles.jobRow}>
<span className={styles.jobLabel}>{job.connectionLabel || job.jobType}</span>
<span className={styles.jobProgress}>
{job.progressMessage || t('läuft...')}
</span>
</div>
))}
</div>
)}
</div>
);
};

View file

@ -28,6 +28,8 @@ import type { UdbContext } from './UnifiedDataBar';
import api from '../../api';
import { getPageIcon } from '../../config/pageRegistry';
import styles from './SourcesTab.module.css';
import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaLink, FaFolder, FaEnvelope, FaComments, FaCalendarAlt, FaUser, FaCloudUploadAlt } from 'react-icons/fa';
import { SiJira } from 'react-icons/si';
import { useLanguage } from '../../providers/language/LanguageContext';
@ -42,6 +44,7 @@ interface UdbDataSource {
displayPath?: string;
scope: string;
neutralize: boolean;
ragIndexEnabled?: boolean;
}
interface UdbFeatureDataSource {
@ -60,7 +63,7 @@ interface UdbFeatureDataSource {
interface TreeNode {
key: string;
label: string;
icon: string;
icon: React.ReactNode;
type: 'connection' | 'service' | 'folder' | 'file';
expanded: boolean;
loading: boolean;
@ -122,28 +125,28 @@ interface SourcesTabProps {
/* ─── Icons ──────────────────────────────────────────────────────────── */
const _AUTHORITY_ICONS: Record<string, string> = {
msft: '\uD83D\uDFE6',
google: '\uD83D\uDFE9',
clickup: '\uD83D\uDCCB',
infomaniak: '\uD83D\uDFE5',
'local:ftp': '\uD83D\uDD17',
'local:jira': '\uD83D\uDD27',
const _AUTHORITY_ICONS: Record<string, React.ReactNode> = {
msft: <FaMicrosoft style={{ color: '#00a4ef', fontSize: 12 }} />,
google: <FaGoogle style={{ color: '#4285f4', fontSize: 12 }} />,
clickup: <FaTasks style={{ color: '#7b68ee', fontSize: 12 }} />,
infomaniak: <FaCloud style={{ color: '#0098db', fontSize: 12 }} />,
'local:ftp': <FaLink style={{ color: '#795548', fontSize: 12 }} />,
'local:jira': <SiJira style={{ color: '#0052CC', fontSize: 12 }} />,
};
const _SERVICE_ICONS: Record<string, string> = {
sharepoint: '\uD83D\uDCC1',
onedrive: '\u2601\uFE0F',
outlook: '\uD83D\uDCE7',
teams: '\uD83D\uDCAC',
drive: '\uD83D\uDCC2',
gmail: '\uD83D\uDCE8',
files: '\uD83D\uDCC2',
clickup: '\uD83D\uDCCB',
kdrive: '\uD83D\uDCC2',
mail: '\uD83D\uDCE7',
calendar: '\uD83D\uDCC5',
contact: '\uD83D\uDC64',
const _SERVICE_ICONS: Record<string, React.ReactNode> = {
sharepoint: <FaFolder style={{ color: '#0078d4', fontSize: 11 }} />,
onedrive: <FaCloudUploadAlt style={{ color: '#0078d4', fontSize: 11 }} />,
outlook: <FaEnvelope style={{ color: '#0078d4', fontSize: 11 }} />,
teams: <FaComments style={{ color: '#5b5fc7', fontSize: 11 }} />,
drive: <FaFolder style={{ color: '#34a853', fontSize: 11 }} />,
gmail: <FaEnvelope style={{ color: '#ea4335', fontSize: 11 }} />,
files: <FaFolder style={{ color: '#795548', fontSize: 11 }} />,
clickup: <FaTasks style={{ color: '#7b68ee', fontSize: 11 }} />,
kdrive: <FaFolder style={{ color: '#0098FF', fontSize: 11 }} />,
mail: <FaEnvelope style={{ color: '#0098FF', fontSize: 11 }} />,
calendar: <FaCalendarAlt style={{ color: '#0098FF', fontSize: 11 }} />,
contact: <FaUser style={{ color: '#0098FF', fontSize: 11 }} />,
};
/* ─── Source colors & icons ──────────────────────────────────────────── */
@ -333,7 +336,7 @@ async function _loadServices(instanceId: string, connectionId: string): Promise<
return services.map((s: any) => ({
key: `svc-${connectionId}-${s.service}`,
label: s.label || s.service,
icon: _SERVICE_ICONS[s.service] || '\uD83D\uDCC2',
icon: _SERVICE_ICONS[s.service] || <FaFolder style={{ color: '#888', fontSize: 11 }} />,
type: 'service' as const,
expanded: false,
loading: false,
@ -342,7 +345,7 @@ async function _loadServices(instanceId: string, connectionId: string): Promise<
service: s.service,
path: '/',
displayPath: s.label || s.service,
}));
})).sort((a: TreeNode, b: TreeNode) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }));
}
async function _browseService(
@ -374,6 +377,10 @@ async function _browseService(
path: entry.path,
displayPath,
};
}).sort((a: TreeNode, b: TreeNode) => {
if (a.type === 'folder' && b.type !== 'folder') return -1;
if (a.type !== 'folder' && b.type === 'folder') return 1;
return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' });
});
}
@ -495,6 +502,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
scope: d.scope || 'personal',
neutralize: d.neutralize ?? false,
}));
list.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }));
setDataSources(list);
})
.catch(() => { if (mountedRef.current) setDataSources([]); });
@ -518,6 +526,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
neutralizeFields: d.neutralizeFields || undefined,
recordFilter: d.recordFilter || undefined,
}));
list.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }));
setFeatureDataSources(list);
})
.catch(() => { if (mountedRef.current) setFeatureDataSources([]); });
@ -539,14 +548,15 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
.map((c: any) => ({
key: `conn-${c.id}`,
label: c.externalEmail || c.externalUsername || c.authority,
icon: _AUTHORITY_ICONS[c.authority] || '\uD83D\uDD17',
icon: _AUTHORITY_ICONS[c.authority] || <FaLink style={{ color: '#888', fontSize: 12 }} />,
type: 'connection' as const,
expanded: false,
loading: false,
children: null,
connectionId: c.id,
authority: c.authority,
}));
}))
.sort((a: TreeNode, b: TreeNode) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }));
setTree(nodes);
})
.catch(() => { if (mountedRef.current) setTree([]); })
@ -689,6 +699,17 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
}
}, []);
/* ── RAG-Index toggle (personal data source, optimistic) ── */
const _togglePersonalRagIndex = useCallback(async (ds: UdbDataSource) => {
const newValue = !ds.ragIndexEnabled;
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, ragIndexEnabled: newValue } : d));
try {
await api.patch(`/api/datasources/${ds.id}/rag-index`, { ragIndexEnabled: newValue });
} catch {
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, ragIndexEnabled: ds.ragIndexEnabled } : d));
}
}, []);
/* ── Scope change (feature data source, optimistic) ── */
const _cycleFeatureScope = useCallback(async (fds: UdbFeatureDataSource) => {
const newScope = _nextScope(fds.scope);
@ -748,7 +769,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
expanded: false,
loading: false,
tables: null,
})),
})).sort((a: FeatureConnectionNode, b: FeatureConnectionNode) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })),
})));
})
.catch(() => { if (mountedRef.current) setFeatureTree([]); })
@ -786,14 +807,14 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
objectKey: t.objectKey ?? '',
tableName: t.tableName ?? '',
label: t.label ?? '',
fields: t.fields ?? [],
fields: (t.fields ?? []).slice().sort((a: string, b: string) => a.localeCompare(b, undefined, { sensitivity: 'base' })),
isParent: Boolean(t.isParent),
parentTable: t.parentTable ?? null,
parentKey: t.parentKey ?? null,
displayFields: t.displayFields ?? [],
isGroup: Boolean(t.isGroup),
group: t.group ?? null,
}));
})).sort((a: FeatureTableNode, b: FeatureTableNode) => (a.label || a.tableName).localeCompare(b.label || b.tableName, undefined, { sensitivity: 'base' }));
// Default-expand all categorical groups so users immediately see their content.
const defaultExpansions: string[] = tables
@ -900,7 +921,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
displayLabel: r.displayLabel || r.id,
fields: r.fields || {},
tableName: table.tableName,
}));
})).sort((a: ParentRecordNode, b: ParentRecordNode) => a.displayLabel.localeCompare(b.displayLabel, undefined, { sensitivity: 'base' }));
if (mountedRef.current) {
setFeatureRecordsByPath(prev => ({ ...prev, [pathKey]: records }));
}
@ -1018,6 +1039,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
dataSources={dataSources}
onCycleScope={_cyclePersonalScope}
onToggleNeutralize={_togglePersonalNeutralize}
onToggleRagIndex={_togglePersonalRagIndex}
onSendToChat={_sendNodeToChat}
scopeCycleTitle={_scopeCycleTitle}
selectedKeys={selectedKeys}
@ -1105,18 +1127,20 @@ interface _TreeNodeViewProps {
dataSources: UdbDataSource[];
onCycleScope: (ds: UdbDataSource) => void;
onToggleNeutralize: (ds: UdbDataSource) => void;
onToggleRagIndex: (ds: UdbDataSource) => void;
onSendToChat?: (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => void;
scopeCycleTitle: (scope: string) => string;
selectedKeys: Set<string>;
onSelect: (node: TreeNode, e: React.MouseEvent) => void;
inheritedScope?: string;
inheritedNeutralize?: boolean;
inheritedRagIndex?: boolean;
}
const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
node, depth, onToggle, onEnsureDs, isAdded, addingPath,
dataSources, onCycleScope, onToggleNeutralize, onSendToChat, scopeCycleTitle,
selectedKeys, onSelect, inheritedScope, inheritedNeutralize,
dataSources, onCycleScope, onToggleNeutralize, onToggleRagIndex, onSendToChat, scopeCycleTitle,
selectedKeys, onSelect, inheritedScope, inheritedNeutralize, inheritedRagIndex,
}) => {
const { t } = useLanguage();
const [hovered, setHovered] = useState(false);
@ -1128,8 +1152,10 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
const effectiveScope = ds?.scope ?? inheritedScope;
const effectiveNeutralize = ds?.neutralize ?? inheritedNeutralize ?? false;
const effectiveRagIndex = ds?.ragIndexEnabled ?? inheritedRagIndex ?? false;
const childInheritedScope = ds?.scope ?? inheritedScope;
const childInheritedNeutralize = ds?.neutralize ?? inheritedNeutralize;
const childInheritedRagIndex = ds?.ragIndexEnabled ?? inheritedRagIndex;
const _dragPayload = {
connectionId: node.connectionId,
@ -1202,7 +1228,7 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
<span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
{node.loading ? _Spinner() : chevron}
</span>
<span style={{ fontSize: 14, flexShrink: 0 }}>{node.icon}</span>
<span style={{ fontSize: 14, flexShrink: 0, display: 'flex', alignItems: 'center' }}>{node.icon}</span>
<span style={{
flex: 1, minWidth: 0, overflow: 'hidden',
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
@ -1212,11 +1238,24 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
{node.label}
</span>
{/* Stable trio: chat | scope | neutralize (always in this order).
* No "remove from workspace" button here by design: the UDB row only
* exposes the catalog state. Detach from the *current chat* happens
* via the chip "x" in WorkspaceInput; that chip is the single source
* of truth for chat-scoped attachment lifecycle. */}
<button
onClick={async (e) => {
e.stopPropagation();
if (ds) { onToggleRagIndex(ds); return; }
const newId = await onEnsureDs(node);
if (newId) {
try { await api.patch(`/api/datasources/${newId}/rag-index`, { ragIndexEnabled: !effectiveRagIndex }); } catch {}
}
}}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
opacity: (ds?.ragIndexEnabled ?? effectiveRagIndex) ? 1 : 0.35,
}}
title={(ds?.ragIndexEnabled ?? effectiveRagIndex) ? t('RAG-Indexierung an') : t('RAG-Indexierung aus')}
>
{'\uD83E\uDDE0'}
</button>
<button
onClick={e => { e.stopPropagation(); onSendToChat?.(_chatPayload); }}
style={{
@ -1278,12 +1317,14 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
dataSources={dataSources}
onCycleScope={onCycleScope}
onToggleNeutralize={onToggleNeutralize}
onToggleRagIndex={onToggleRagIndex}
onSendToChat={onSendToChat}
scopeCycleTitle={scopeCycleTitle}
selectedKeys={selectedKeys}
onSelect={onSelect}
inheritedScope={childInheritedScope}
inheritedNeutralize={childInheritedNeutralize}
inheritedRagIndex={childInheritedRagIndex}
/>
))}
</div>

View file

@ -19,7 +19,7 @@ import {
FaHome, FaCog, FaBriefcase, FaPlay, FaBuilding, FaUsers, FaUserTag,
FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt,
FaLightbulb, FaRegFileAlt, FaLink, FaComments,
FaListAlt, FaChartLine, FaChartBar, FaFileAlt, FaUserShield, FaDatabase,
FaListAlt, FaChartLine, FaChartBar, FaFileAlt, FaUserShield, FaDatabase, FaMicrophone,
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList,
FaFileContract, FaRobot, FaGlobe, FaClipboardCheck,
@ -53,6 +53,7 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'page.system.billingAdmin': <FaMoneyBillAlt />,
'page.system.statistics': <FaChartBar />,
'page.system.automations': <FaRobot />,
'page.system.ragInventory': <FaDatabase />,
// Billing pages (legacy compat)
'page.billing.dashboard': <FaWallet />,
@ -87,6 +88,8 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'page.admin.database-health': <FaDatabase />,
'page.admin.demoConfig': <FaCubes />,
'page.admin.demo-config': <FaCubes />,
'page.admin.sttBenchmark': <FaMicrophone />,
'page.admin.stt-benchmark': <FaMicrophone />,
'page.admin.mandate-wizard': <FaHatWizard />,
'page.admin.mandateWizard': <FaHatWizard />,
'page.admin.invitation-wizard': <FaEnvelopeOpenText />,
@ -109,6 +112,8 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
// Feature pages - Teams Bot
'page.feature.teamsbot.dashboard': <FaChartLine />,
'page.feature.teamsbot.assistant': <FaHatWizard />,
'page.feature.teamsbot.modules': <FaCubes />,
'page.feature.teamsbot.sessions': <FaVideo />,
'page.feature.teamsbot.settings': <FaCog />,

View file

@ -101,17 +101,15 @@ export function useConnections() {
viewKey?: string | null;
groupField: string;
groupDirection?: 'asc' | 'desc';
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: string }>;
}) => {
const levels = base.groupByLevels?.length
? base.groupByLevels
: [{ field: base.groupField, nullLabel: '—', direction: base.groupDirection || 'asc' }];
const pObj: Record<string, unknown> = {
page: 1,
pageSize: 25,
groupByLevels: [
{
field: base.groupField,
nullLabel: '—',
direction: base.groupDirection || 'asc',
},
],
groupByLevels: levels,
};
if (base.search) (pObj as { search?: string }).search = base.search;
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;
@ -788,7 +786,7 @@ export function useConnections() {
* for backward-compat but new wizard code should call this.
*/
const createConnectionAndAuth = async (
type: 'google' | 'msft' | 'clickup',
type: 'google' | 'msft' | 'clickup' | 'infomaniak',
knowledgeIngestionEnabled: boolean,
knowledgePreferences?: import('../api/connectionApi').KnowledgePreferences | null,
): Promise<void> => {

View file

@ -149,17 +149,15 @@ export function useUserFiles() {
viewKey?: string | null;
groupField: string;
groupDirection?: 'asc' | 'desc';
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: string }>;
}) => {
const levels = base.groupByLevels?.length
? base.groupByLevels
: [{ field: base.groupField, nullLabel: '—', direction: base.groupDirection || 'asc' }];
const pObj: Record<string, unknown> = {
page: 1,
pageSize: 25,
groupByLevels: [
{
field: base.groupField,
nullLabel: '—',
direction: base.groupDirection || 'asc',
},
],
groupByLevels: levels,
};
if (base.search) (pObj as { search?: string }).search = base.search;
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;

View file

@ -98,17 +98,15 @@ export function usePrompts() {
viewKey?: string | null;
groupField: string;
groupDirection?: 'asc' | 'desc';
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: string }>;
}) => {
const levels = base.groupByLevels?.length
? base.groupByLevels
: [{ field: base.groupField, nullLabel: '—', direction: base.groupDirection || 'asc' }];
const pObj: Record<string, unknown> = {
page: 1,
pageSize: 25,
groupByLevels: [
{
field: base.groupField,
nullLabel: '—',
direction: base.groupDirection || 'asc',
},
],
groupByLevels: levels,
};
if (base.search) (pObj as { search?: string }).search = base.search;
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;

View file

@ -21,10 +21,17 @@ export interface VoiceStreamCallbacks {
onError?: (error: unknown) => void;
}
/** Options for the initial `open` message on the generic STT WebSocket (Google streaming). */
export interface SttStreamOpenOptions {
model?: string;
lightweight?: boolean;
singleUtterance?: boolean;
}
export interface VoiceStreamApi {
status: VoiceStreamStatus;
interimText: string;
start: (language?: string) => Promise<void>;
start: (language?: string, sttOpenOptions?: SttStreamOpenOptions) => Promise<void>;
stop: () => void;
}
@ -42,6 +49,7 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
const recorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const languageRef = useRef('de-DE');
const sttOpenOptsRef = useRef<SttStreamOpenOptions | undefined>(undefined);
const stoppingRef = useRef(false);
const reconnectAttemptsRef = useRef(0);
@ -94,11 +102,23 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
stoppingRef.current = false;
}, [_stopRecorder, _closeWs, _releaseDevices, _setStatus]);
const start = useCallback(async (language?: string) => {
const _buildOpenPayload = useCallback(() => {
const o = sttOpenOptsRef.current;
return {
type: 'open' as const,
language: languageRef.current,
model: o?.model ?? 'latest_long',
lightweight: o?.lightweight ?? false,
singleUtterance: o?.singleUtterance ?? false,
};
}, []);
const start = useCallback(async (language?: string, sttOpenOptions?: SttStreamOpenOptions) => {
if (status === 'listening' || status === 'connecting') return;
stoppingRef.current = false;
reconnectAttemptsRef.current = 0;
languageRef.current = language || 'de-DE';
sttOpenOptsRef.current = sttOpenOptions;
_setStatus('connecting');
try {
@ -120,7 +140,7 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
ws.onopen = () => {
if (stoppingRef.current) { ws.close(); return; }
ws.send(JSON.stringify({ type: 'open', language: languageRef.current }));
ws.send(JSON.stringify(_buildOpenPayload()));
const mimeType = _pickMimeType();
const recorder = new MediaRecorder(streamRef.current!, { mimeType });
@ -154,11 +174,15 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
cbRef.current.onFinal?.(msg.text);
} else if (msg.type === 'error') {
cbRef.current.onError?.(new Error(msg.message || msg.code || 'STT error'));
} else if (msg.type === 'end_of_single_utterance') {
if (!stoppingRef.current && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(_buildOpenPayload()));
}
} else if (msg.type === 'reconnect_required') {
if (reconnectAttemptsRef.current < _MAX_RECONNECT_ATTEMPTS && !stoppingRef.current) {
reconnectAttemptsRef.current++;
_closeWs();
start(languageRef.current).catch(() => {});
start(languageRef.current, sttOpenOptsRef.current).catch(() => {});
}
}
} catch { /* ignore parse errors */ }
@ -183,7 +207,7 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
_releaseDevices();
throw err;
}
}, [status, _setStatus, _pickMimeType, _closeWs, _releaseDevices]);
}, [status, _setStatus, _pickMimeType, _closeWs, _releaseDevices, _buildOpenPayload]);
useEffect(() => {
return () => {

View file

@ -14,6 +14,7 @@ import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive'
import { CommcoachKeepAlive } from '../pages/views/commcoach/CommcoachKeepAlive';
import { GraphicalEditorKeepAlive } from '../pages/views/graphicalEditor/GraphicalEditorKeepAlive';
import { AdminLanguagesKeepAlive } from '../pages/admin/AdminLanguagesKeepAlive';
import { RagRunningBadge } from '../components/RagRunningBadge/RagRunningBadge';
import styles from './MainLayout.module.css';
import { useLanguage } from '../providers/language/LanguageContext';
@ -132,6 +133,8 @@ const MainLayoutInner: React.FC = () => {
<Outlet />
</div>
</main>
<RagRunningBadge />
</div>
);
};

View file

@ -35,7 +35,6 @@ import { GraphicalEditorTemplatesPage } from './views/graphicalEditor/GraphicalE
import { WorkspacePage } from './views/workspace/WorkspacePage';
import { WorkspaceEditorPage } from './views/workspace/WorkspaceEditorPage';
import { WorkspaceSettingsPage } from './views/workspace/WorkspaceSettingsPage';
import { WorkspaceRagInsightsPage } from './views/workspace/WorkspaceRagInsightsPage';
// Teamsbot Views
import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
@ -155,7 +154,6 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
workspace: {
dashboard: WorkspacePage,
editor: WorkspaceEditorPage,
'rag-insights': WorkspaceRagInsightsPage,
settings: WorkspaceSettingsPage,
},
teamsbot: {
@ -229,7 +227,7 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
// Workspace dashboard is rendered persistently by WorkspaceKeepAlive at MainLayout level;
// other workspace views (e.g. settings, editor) use the standard FeatureViewPage rendering.
if (featureCode === 'workspace' && view !== 'settings' && view !== 'editor' && view !== 'rag-insights') {
if (featureCode === 'workspace' && view !== 'settings' && view !== 'editor') {
return null;
}

View file

@ -0,0 +1,353 @@
.page {
padding: 24px 32px;
max-width: 1100px;
}
/* ── Page Header ── */
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 24px;
gap: 16px;
flex-wrap: wrap;
}
.headerLeft {
display: flex;
align-items: center;
gap: 14px;
}
.headerIcon {
font-size: 24px;
color: var(--color-primary, #2563eb);
flex-shrink: 0;
}
.pageTitle {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
.pageDesc {
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
margin: 2px 0 0;
}
.headerRight {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.filterGroup {
display: flex;
align-items: center;
gap: 8px;
}
.filterLabel {
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
white-space: nowrap;
}
.scopeSelect {
padding: 7px 12px;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
font-size: 0.875rem;
background: var(--color-surface, #fff);
color: var(--color-text, #111);
cursor: pointer;
min-width: 180px;
}
.scopeSelect:focus {
outline: none;
border-color: var(--color-primary, #2563eb);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.12);
}
.checkboxLabel {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8125rem;
cursor: pointer;
white-space: nowrap;
}
.checkboxLabel input {
cursor: pointer;
}
/* ── Loading / Error ── */
.loading {
padding: 32px;
text-align: center;
color: var(--color-text-muted, #6b7280);
}
.error {
padding: 12px 16px;
border-radius: 6px;
background: #fef2f2;
color: #b91c1c;
font-size: 0.875rem;
margin-bottom: 16px;
}
/* ── Totals ── */
.totals {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
font-size: 0.875rem;
}
.totalLabel {
color: var(--color-text-muted, #6b7280);
}
.totalValue {
font-size: 1.125rem;
}
.totalBytes {
color: var(--color-text-muted, #6b7280);
font-size: 0.8125rem;
}
/* ── Content ── */
.content {
display: flex;
flex-direction: column;
gap: 16px;
}
/* ── Connection Card ── */
.connectionCard {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
padding: 16px;
background: var(--color-surface, #fff);
}
.connectionHeader {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.authority {
font-weight: 600;
font-size: 0.875rem;
text-transform: capitalize;
}
.email {
color: var(--color-text-muted, #6b7280);
font-size: 0.8125rem;
flex: 1;
}
.connChunks {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-primary, #2563eb);
background: var(--color-info-bg, #eff6ff);
padding: 2px 8px;
border-radius: 10px;
}
.consentToggle {
background: none;
border: none;
cursor: pointer;
color: var(--color-primary, #2563eb);
display: flex;
align-items: center;
}
/* ── Consent Warning ── */
.consentWarning {
padding: 8px 12px;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 6px;
margin-bottom: 8px;
font-size: 0.8125rem;
color: #92400e;
}
/* ── Error Banner ── */
.errorBanner {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 6px;
margin-bottom: 8px;
font-size: 0.8125rem;
color: #b91c1c;
}
.successBanner {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 6px;
margin-bottom: 8px;
font-size: 0.8125rem;
color: #166534;
}
.successBanner .duration {
color: #65a30d;
margin-left: 6px;
opacity: 0.85;
}
.reindexBtn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: var(--color-info-bg, #eff6ff);
color: var(--color-primary, #2563eb);
border: 1px solid var(--color-primary-light, #93c5fd);
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
margin-left: auto;
white-space: nowrap;
}
.reindexBtn:hover {
background: #dbeafe;
}
.reindexHint {
display: flex;
padding: 6px 0;
margin-bottom: 4px;
}
/* ── Job Banner ── */
.jobBanner {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--color-info-bg, #eff6ff);
border-radius: 6px;
margin-bottom: 8px;
font-size: 0.8125rem;
}
.spinIcon {
animation: spin 1.5s linear infinite;
color: var(--color-primary, #2563eb);
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.stopBtn {
margin-left: auto;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fecaca;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
}
.stopBtn:hover {
background: #fee2e2;
}
/* ── DataSource List ── */
.dsList {
display: flex;
flex-direction: column;
gap: 0;
}
.dsRow {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 12px;
border-bottom: 1px solid var(--color-border, #f3f4f6);
font-size: 0.8125rem;
}
.dsRow:last-child {
border-bottom: none;
}
.dsActive {
background: rgba(37, 99, 235, 0.03);
}
.dsLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dsType {
color: var(--color-text-muted, #6b7280);
font-size: 0.75rem;
min-width: 90px;
}
.dsChunks {
font-size: 0.75rem;
min-width: 70px;
text-align: right;
}
.dsIndex {
width: 24px;
text-align: center;
}
.dsEmpty {
padding: 12px;
text-align: center;
color: var(--color-text-muted, #9ca3af);
font-size: 0.8125rem;
font-style: italic;
}
/* ── Empty State ── */
.emptyState {
padding: 48px;
text-align: center;
color: var(--color-text-muted, #9ca3af);
font-size: 0.9rem;
}

View file

@ -0,0 +1,316 @@
/**
* RagInventoryPage Global RAG knowledge store management.
*
* Accessible via Start > Nutzung > RAG-Inventar.
* Context selector top-right (same pattern as BillingDataView / Statistiken):
* Dropdown: "Meine Verbindungen" | "Mandant: XY" | "Plattform (alle)"
* Checkbox: "nur meine Daten"
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useLanguage } from '../providers/language/LanguageContext';
import { useApiRequest } from '../hooks/useApi';
import { useUserMandates } from '../hooks/useUserMandates';
import type { RagInventoryDto, RagConnectionDto } from '../api/connectionApi';
import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle, FaCheckCircle } from 'react-icons/fa';
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
import styles from './RagInventoryPage.module.css';
export const RagInventoryPage: React.FC = () => {
const { t } = useLanguage();
const { request } = useApiRequest();
const { fetchMandates } = useUserMandates();
const [mandates, setMandates] = useState<any[]>([]);
const [mandatesLoading, setMandatesLoading] = useState(true);
const [selectedScope, setSelectedScope] = useState<string>('personal');
const [onlyMyData, setOnlyMyData] = useState(false);
const [inventory, setInventory] = useState<RagInventoryDto | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
setMandatesLoading(true);
try {
const data = await fetchMandates();
if (!cancelled) {
const list = Array.isArray(data) ? data : [];
setMandates(list);
if (list.length === 1) setSelectedScope(list[0].id);
}
} catch {}
finally { if (!cancelled) setMandatesLoading(false); }
})();
return () => { cancelled = true; };
}, [fetchMandates]);
const _apiEndpoint = useMemo(() => {
if (selectedScope === 'personal') return '/api/rag/inventory/me';
if (selectedScope === 'platform') return '/api/rag/inventory/platform';
return '/api/rag/inventory/mandate';
}, [selectedScope]);
const _fetchInventory = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params: Record<string, string> = {};
if (selectedScope !== 'personal' && selectedScope !== 'platform') {
params.mandateId = selectedScope;
}
if (onlyMyData) params.onlyMine = 'true';
const data = await request({ url: _apiEndpoint, method: 'get', params });
setInventory(data);
} catch (err: any) {
if (err?.message?.includes('403')) {
setError(t('Keine Berechtigung für diese Sicht.'));
} else {
setError(err?.message || t('Fehler beim Laden'));
}
setInventory(null);
} finally {
setLoading(false);
}
}, [request, _apiEndpoint, selectedScope, onlyMyData, t]);
useEffect(() => {
_fetchInventory();
}, [_fetchInventory]);
const _hasActiveJobs = !!inventory?.connections?.some(c => (c.runningJobs?.length || 0) > 0);
useEffect(() => {
if (pollRef.current) clearInterval(pollRef.current);
// Fast poll (5s) while a sync is in flight so the user gets a snappy
// success/error confirmation; slow poll (60s) at rest to keep the DB
// load low. Visibility check skips polling for backgrounded tabs.
const intervalMs = _hasActiveJobs ? 5000 : 60000;
pollRef.current = setInterval(() => {
if (document.visibilityState === 'visible') _fetchInventory();
}, intervalMs);
return () => { if (pollRef.current) clearInterval(pollRef.current); };
}, [_fetchInventory, _hasActiveJobs]);
const _handleStop = async (connectionId: string) => {
try {
await request({ url: `/api/connections/${connectionId}/knowledge-stop`, method: 'post' });
_fetchInventory();
} catch {}
};
const _handleReindex = async (connectionId: string) => {
try {
await request({ url: `/api/rag/inventory/reindex/${connectionId}`, method: 'post' });
_fetchInventory();
} catch {}
};
const _handleConsentToggle = async (connectionId: string, currentEnabled: boolean) => {
if (!currentEnabled || window.confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?'))) {
try {
await request({
url: `/api/connections/${connectionId}/knowledge-consent`,
method: 'patch',
data: { enabled: !currentEnabled },
});
_fetchInventory();
} catch {}
}
};
const _formatRelative = useCallback((finishedAt: number | null | undefined): string => {
if (!finishedAt) return '';
const nowSec = Date.now() / 1000;
const diff = Math.max(0, nowSec - finishedAt);
if (diff < 45) return t('gerade eben');
if (diff < 3600) return t('vor {n} Min', { n: Math.floor(diff / 60) });
if (diff < 86400) return t('vor {n} Std', { n: Math.floor(diff / 3600) });
return t('vor {n} Tag(en)', { n: Math.floor(diff / 86400) });
}, [t]);
const _formatDuration = useCallback((ms: number | undefined): string => {
if (!ms || ms <= 0) return '';
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
}, []);
const scopeOptions = useMemo(() => {
const opts: { value: string; label: string }[] = [
{ value: 'personal', label: t('Meine Verbindungen') },
];
for (const m of mandates) {
opts.push({ value: m.id, label: t('Mandant: {name}', { name: mandateDisplayLabel(m) }) });
}
opts.push({ value: 'platform', label: t('Plattform (alle)') });
return opts;
}, [mandates, t]);
return (
<div className={styles.page}>
<header className={styles.pageHeader}>
<div className={styles.headerLeft}>
<FaDatabase className={styles.headerIcon} />
<div>
<h1 className={styles.pageTitle}>{t('RAG-Inventar')}</h1>
<p className={styles.pageDesc}>
{t('Übersicht und Steuerung der indexierten Wissensdaten.')}
</p>
</div>
</div>
<div className={styles.headerRight}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>{t('Kontext:')}</label>
<select
className={styles.scopeSelect}
value={selectedScope}
onChange={e => setSelectedScope(e.target.value)}
disabled={mandatesLoading}
>
{scopeOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={onlyMyData}
onChange={e => setOnlyMyData(e.target.checked)}
/>
{t('nur meine Daten')}
</label>
</div>
</header>
{loading && !inventory && <div className={styles.loading}>{t('Laden...')}</div>}
{error && <div className={styles.error}>{error}</div>}
{inventory && (
<div className={styles.content}>
<div className={styles.totals}>
<span className={styles.totalLabel}>{t('Total Chunks')}:</span>
<strong className={styles.totalValue}>{inventory.totals?.chunks ?? 0}</strong>
{inventory.totals?.bytes != null && inventory.totals.bytes > 0 && (
<span className={styles.totalBytes}>{(inventory.totals.bytes / 1024 / 1024).toFixed(1)} MB</span>
)}
</div>
{(inventory.connections || []).map((conn: RagConnectionDto) => (
<div key={conn.id} className={styles.connectionCard}>
<div className={styles.connectionHeader}>
<span className={styles.authority}>{conn.authority}</span>
<span className={styles.email}>{conn.externalEmail}</span>
{conn.totalChunks > 0 && (
<span className={styles.connChunks}>{conn.totalChunks} chunks</span>
)}
<button
className={styles.consentToggle}
onClick={() => _handleConsentToggle(conn.id, conn.knowledgeIngestionEnabled)}
title={conn.knowledgeIngestionEnabled ? t('Wissensdatenbank deaktivieren') : t('Wissensdatenbank aktivieren')}
>
{conn.knowledgeIngestionEnabled ? <FaToggleOn size={20} /> : <FaToggleOff size={20} />}
</button>
</div>
{!conn.knowledgeIngestionEnabled && conn.dataSources.length > 0 && (
<div className={styles.consentWarning}>
{t('Wissensdatenbank deaktiviert — Indexierung pausiert. Toggle aktivieren um Daten zu indexieren.')}
</div>
)}
{/* Status banner: priority is Running > Error-newer-than-Success > Success > Reindex-Hint.
This way a stale error doesn't override a fresh successful resync, and the
spinner is never shown without a real job behind it. */}
{conn.runningJobs.length > 0 ? (
<div className={styles.jobBanner}>
<FaSync className={styles.spinIcon} />
<span>{conn.runningJobs[0].progressMessage || t('Synchronisierung läuft...')}</span>
<button className={styles.stopBtn} onClick={() => _handleStop(conn.id)} title={t('Indexierung stoppen')}>
<FaStop size={12} /> {t('Stop')}
</button>
</div>
) : (() => {
const errAt = conn.lastError?.finishedAt ?? 0;
const okAt = conn.lastSuccess?.finishedAt ?? 0;
const errorIsNewer = !!conn.lastError && errAt > okAt;
if (errorIsNewer) {
return (
<div className={styles.errorBanner}>
<FaExclamationTriangle />
<span>
{t('Letzter Sync fehlgeschlagen')} ({_formatRelative(errAt)}): {conn.lastError?.errorMessage || t('unbekannter Fehler')}
</span>
<button className={styles.reindexBtn} onClick={() => _handleReindex(conn.id)} title={t('Neu indexieren')}>
<FaRedo size={12} /> {t('Neu indexieren')}
</button>
</div>
);
}
if (conn.lastSuccess) {
const s = conn.lastSuccess;
const stats = [
s.indexed > 0 ? t('{n} neu indexiert', { n: s.indexed }) : null,
s.skippedDuplicate > 0 ? t('{n} unverändert', { n: s.skippedDuplicate }) : null,
s.skippedPolicy > 0 ? t('{n} übersprungen', { n: s.skippedPolicy }) : null,
s.failed > 0 ? t('{n} fehler', { n: s.failed }) : null,
].filter(Boolean).join(' · ');
return (
<div className={styles.successBanner}>
<FaCheckCircle />
<span>
{t('Sync erfolgreich')} {_formatRelative(okAt)}
{stats && <> {stats}</>}
{s.durationMs > 0 && <span className={styles.duration}> ({_formatDuration(s.durationMs)})</span>}
</span>
<button className={styles.reindexBtn} onClick={() => _handleReindex(conn.id)} title={t('Erneut indexieren')}>
<FaRedo size={12} /> {t('Erneut indexieren')}
</button>
</div>
);
}
if (conn.dataSources.some(ds => ds.ragIndexEnabled) && conn.knowledgeIngestionEnabled) {
return (
<div className={styles.reindexHint}>
<button className={styles.reindexBtn} onClick={() => _handleReindex(conn.id)} title={t('Indexierung starten')}>
<FaRedo size={12} /> {t('Indexierung starten')}
</button>
</div>
);
}
return null;
})()}
<div className={styles.dsList}>
{conn.dataSources.map(ds => (
<div key={ds.id} className={`${styles.dsRow} ${ds.ragIndexEnabled ? styles.dsActive : ''}`}>
<span className={styles.dsLabel}>{ds.label || ds.path}</span>
<span className={styles.dsType}>{ds.sourceType}</span>
<span className={styles.dsChunks}>{ds.chunkCount} chunks</span>
<span className={styles.dsIndex}>{ds.ragIndexEnabled ? '\uD83E\uDDE0' : '\u2014'}</span>
</div>
))}
{conn.dataSources.length === 0 && (
<div className={styles.dsEmpty}>{t('Keine Datenquellen konfiguriert')}</div>
)}
</div>
</div>
))}
{(inventory.connections || []).length === 0 && (
<div className={styles.emptyState}>{t('Keine Daten für diese Sicht vorhanden.')}</div>
)}
</div>
)}
</div>
);
};
export default RagInventoryPage;

View file

@ -0,0 +1,258 @@
/**
* SttBenchmarkPage Compare STT v1 (latest_long) vs v2 (Chirp 2).
* SysAdmin only. Upload audio, run both engines, compare results.
*/
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { FaMicrophone, FaUpload, FaPlay, FaStop, FaSpinner } from 'react-icons/fa';
import { useLanguage } from '../../providers/language/LanguageContext';
import { useApiRequest } from '../../hooks/useApi';
import styles from '../admin/Admin.module.css';
interface ModelOption { value: string; label: string }
interface BenchmarkResult {
api: string;
model: string;
latencyMs: number;
results: { transcript: string; confidence: number; words: number }[];
resultCount: number;
location?: string;
error?: string;
}
interface BenchmarkResponse {
filename: string;
fileSizeBytes: number;
language: string;
v1: BenchmarkResult | { error: string };
v2: BenchmarkResult | { error: string };
}
interface ModelsResponse {
v1Models: ModelOption[];
v2Models: ModelOption[];
locations: ModelOption[];
languages: ModelOption[];
}
export const SttBenchmarkPage: React.FC = () => {
const { t } = useLanguage();
const { request } = useApiRequest();
const [models, setModels] = useState<ModelsResponse | null>(null);
const [language, setLanguage] = useState('de-DE');
const [v1Model, setV1Model] = useState('latest_long');
const [v2Model, setV2Model] = useState('chirp_2');
const [v2Location, setV2Location] = useState('europe-west4');
const [running, setRunning] = useState(false);
const [result, setResult] = useState<BenchmarkResponse | null>(null);
const [recording, setRecording] = useState(false);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
request({ url: '/api/admin/stt-benchmark/models', method: 'get' })
.then((data: any) => setModels(data))
.catch(() => {});
}, []);
const _startRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
chunksRef.current = [];
recorder.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); };
recorder.onstop = () => {
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
setAudioBlob(blob);
setAudioUrl(URL.createObjectURL(blob));
stream.getTracks().forEach(t => t.stop());
};
mediaRecorderRef.current = recorder;
recorder.start();
setRecording(true);
} catch (err) {
console.error('Microphone access denied', err);
}
}, []);
const _stopRecording = useCallback(() => {
mediaRecorderRef.current?.stop();
setRecording(false);
}, []);
const _handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setAudioBlob(file);
setAudioUrl(URL.createObjectURL(file));
}, []);
const _runBenchmark = useCallback(async () => {
if (!audioBlob) return;
setRunning(true);
setResult(null);
try {
const formData = new FormData();
const filename = audioBlob instanceof File ? audioBlob.name : 'recording.webm';
formData.append('file', audioBlob, filename);
formData.append('language', language);
formData.append('v1Model', v1Model);
formData.append('v2Model', v2Model);
formData.append('v2Location', v2Location);
const resp = await fetch('/api/admin/stt-benchmark/run', {
method: 'POST',
body: formData,
credentials: 'include',
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data: BenchmarkResponse = await resp.json();
setResult(data);
} catch (err: any) {
console.error('Benchmark failed:', err);
} finally {
setRunning(false);
}
}, [audioBlob, language, v1Model, v2Model, v2Location]);
const _renderResult = (label: string, r: BenchmarkResult | { error: string }) => {
if ('error' in r && r.error) {
return (
<div style={{ flex: 1, padding: 16, border: '1px solid #e74c3c', borderRadius: 8, background: '#fdf2f2' }}>
<h3>{label}</h3>
<p style={{ color: '#e74c3c' }}>{r.error}</p>
</div>
);
}
const res = r as BenchmarkResult;
const topTranscript = res.results?.[0]?.transcript || '(no result)';
const topConfidence = res.results?.[0]?.confidence ?? 0;
return (
<div style={{ flex: 1, padding: 16, border: '1px solid #ddd', borderRadius: 8, background: '#fafafa' }}>
<h3 style={{ margin: '0 0 8px' }}>{label}</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginBottom: 12 }}>
<div><strong>{t('Modell')}:</strong> {res.model}</div>
<div><strong>{t('Latenz')}:</strong> {res.latencyMs} ms</div>
<div><strong>{t('Konfidenz')}:</strong> {(topConfidence * 100).toFixed(1)}%</div>
<div><strong>{t('Alternativen')}:</strong> {res.results?.length || 0}</div>
{res.location && <div><strong>{t('Region')}:</strong> {res.location}</div>}
</div>
<div style={{ background: '#fff', padding: 12, borderRadius: 6, border: '1px solid #eee', fontSize: 15 }}>
{topTranscript}
</div>
{res.results?.length > 1 && (
<details style={{ marginTop: 8 }}>
<summary style={{ cursor: 'pointer', fontSize: 13, color: '#666' }}>{t('Weitere Alternativen')}</summary>
{res.results.slice(1).map((alt, i) => (
<div key={i} style={{ marginTop: 4, fontSize: 13, color: '#888' }}>
[{(alt.confidence * 100).toFixed(1)}%] {alt.transcript}
</div>
))}
</details>
)}
</div>
);
};
return (
<div className={styles.adminPage}>
<div className={styles.adminHeader}>
<h1><FaMicrophone style={{ marginRight: 8 }} /> {t('STT Benchmark')}</h1>
<p style={{ color: '#666', margin: '4px 0 0' }}>
{t('Vergleiche Speech-to-Text v1 (latest_long) mit v2 (Chirp 2). Lade eine Audio-Datei hoch oder nimm direkt auf.')}
</p>
</div>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', margin: '20px 0' }}>
<label>
<strong>{t('Sprache')}:</strong>
<select value={language} onChange={e => setLanguage(e.target.value)} style={{ marginLeft: 8 }}>
{(models?.languages || [{ value: 'de-DE', label: 'Deutsch' }]).map(l => (
<option key={l.value} value={l.value}>{l.label}</option>
))}
</select>
</label>
<label>
<strong>v1 {t('Modell')}:</strong>
<select value={v1Model} onChange={e => setV1Model(e.target.value)} style={{ marginLeft: 8 }}>
{(models?.v1Models || [{ value: 'latest_long', label: 'latest_long' }]).map(m => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</label>
<label>
<strong>v2 {t('Modell')}:</strong>
<select value={v2Model} onChange={e => setV2Model(e.target.value)} style={{ marginLeft: 8 }}>
{(models?.v2Models || [{ value: 'chirp_2', label: 'Chirp 2' }]).map(m => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</label>
<label>
<strong>{t('Region')} (v2):</strong>
<select value={v2Location} onChange={e => setV2Location(e.target.value)} style={{ marginLeft: 8 }}>
{(models?.locations || [{ value: 'europe-west4', label: 'Europe West' }]).map(l => (
<option key={l.value} value={l.value}>{l.label}</option>
))}
</select>
</label>
</div>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', margin: '16px 0' }}>
{!recording ? (
<button onClick={_startRecording} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', cursor: 'pointer', background: '#e74c3c', color: '#fff', border: 'none', borderRadius: 6 }}>
<FaMicrophone /> {t('Aufnehmen')}
</button>
) : (
<button onClick={_stopRecording} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', cursor: 'pointer', background: '#333', color: '#fff', border: 'none', borderRadius: 6, animation: 'pulse 1s infinite' }}>
<FaStop /> {t('Stoppen')}
</button>
)}
<button onClick={() => fileInputRef.current?.click()} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', cursor: 'pointer', background: '#3498db', color: '#fff', border: 'none', borderRadius: 6 }}>
<FaUpload /> {t('Datei hochladen')}
</button>
<input ref={fileInputRef} type="file" accept="audio/*" onChange={_handleFileSelect} style={{ display: 'none' }} />
{audioBlob && (
<>
<span style={{ color: '#666', fontSize: 13 }}>
{audioBlob instanceof File ? audioBlob.name : 'recording.webm'} ({(audioBlob.size / 1024).toFixed(0)} KB)
</span>
{audioUrl && <audio src={audioUrl} controls style={{ height: 32 }} />}
</>
)}
</div>
<button
onClick={_runBenchmark}
disabled={!audioBlob || running}
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '10px 24px', cursor: audioBlob && !running ? 'pointer' : 'not-allowed',
background: audioBlob && !running ? '#27ae60' : '#bdc3c7', color: '#fff', border: 'none', borderRadius: 6, fontSize: 15, fontWeight: 600,
}}
>
{running ? <FaSpinner className="fa-spin" /> : <FaPlay />}
{running ? t('Benchmark laeuft...') : t('Benchmark starten')}
</button>
{result && (
<div style={{ marginTop: 24 }}>
<h2>{t('Ergebnis')}</h2>
<p style={{ fontSize: 13, color: '#888' }}>
{result.filename} ({(result.fileSizeBytes / 1024).toFixed(0)} KB) {result.language}
</p>
<div style={{ display: 'flex', gap: 16, marginTop: 12 }}>
{_renderResult(`v1 — ${(result.v1 as any).model || v1Model}`, result.v1)}
{_renderResult(`v2 — ${(result.v2 as any).model || v2Model}`, result.v2)}
</div>
</div>
)}
</div>
);
};
export default SttBenchmarkPage;

View file

@ -19,3 +19,4 @@ export { AdminLogsPage } from './AdminLogsPage';
export { AdminLanguagesPage } from './AdminLanguagesPage';
export { AdminDemoConfigPage } from './AdminDemoConfigPage';
export { AdminDatabaseHealthPage } from './AdminDatabaseHealthPage';
export { SttBenchmarkPage } from './SttBenchmarkPage';

View file

@ -9,8 +9,7 @@ import React, { useState, useMemo, useEffect, useRef } from 'react';
import { useConnections, type Connection } from '../../hooks/useConnections';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaSync, FaLink, FaRedo, FaShieldAlt, FaPlus, FaSpinner, FaTimes, FaSyncAlt, FaCloud } from 'react-icons/fa';
import { getApiBaseUrl } from '../../../config/config';
import { FaSync, FaLink, FaRedo, FaPlus, FaSpinner, FaTimes, FaSyncAlt } from 'react-icons/fa';
import styles from '../admin/Admin.module.css';
import bannerStyles from './ConnectionsPage.module.css';
import { AddConnectionWizard } from '../../components/AddConnectionWizard/AddConnectionWizard';
@ -18,6 +17,7 @@ import type { ConnectorType } from '../../components/AddConnectionWizard/AddConn
import type { KnowledgePreferences } from '../../api/connectionApi';
import { useLanguage } from '../../providers/language/LanguageContext';
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
import { getApiBaseUrl } from '../../../config/config';
const SYNC_BANNER_TTL_MS = 10 * 60 * 1000; // 10 minutes — conservative upper bound for bootstrap
@ -42,8 +42,6 @@ export const ConnectionsPage: React.FC = () => {
deleteConnection,
handleInlineUpdate,
createConnectionAndAuth,
createInfomaniakConnection,
submitInfomaniakToken,
connectWithPopup,
refreshMicrosoftToken,
refreshGoogleToken,
@ -54,7 +52,6 @@ export const ConnectionsPage: React.FC = () => {
const [deletingConnections, setDeletingConnections] = useState<Set<string>>(new Set());
const [refreshingConnections, setRefreshingConnections] = useState<Set<string>>(new Set());
const [reconnectingConnections, setReconnectingConnections] = useState<Set<string>>(new Set());
const [adminConsentPending, setAdminConsentPending] = useState(false);
const [wizardOpen, setWizardOpen] = useState(false);
// Banner shown while knowledge bootstrap is running in the background
const [syncBanner, setSyncBanner] = useState<{
@ -73,15 +70,6 @@ export const ConnectionsPage: React.FC = () => {
setSyncBanner(null);
};
// Infomaniak PAT modal: holds the pending connectionId (created up-front so the
// user only commits if they actually paste a valid token; on cancel we delete it).
const [infomaniakModal, setInfomaniakModal] = useState<{
connectionId: string;
token: string;
submitting: boolean;
error: string | null;
} | null>(null);
// Initial fetch
useEffect(() => {
refetch();
@ -228,13 +216,13 @@ export const ConnectionsPage: React.FC = () => {
const handleWizardConnect = async (
type: ConnectorType,
knowledgeEnabled: boolean,
knowledgePreferences: KnowledgePreferences | null,
knowledgePreferences?: KnowledgePreferences | null,
) => {
try {
await createConnectionAndAuth(type, knowledgeEnabled, knowledgePreferences);
await createConnectionAndAuth(type, knowledgeEnabled, knowledgePreferences ?? null);
refetch();
if (knowledgeEnabled) {
const LABELS: Record<ConnectorType, string> = { google: 'Google', msft: 'Microsoft 365', clickup: 'ClickUp' };
const LABELS: Record<ConnectorType, string> = { google: 'Google', msft: 'Microsoft 365', clickup: 'ClickUp', infomaniak: 'Infomaniak' };
showSyncBanner(LABELS[type] ?? type);
}
} catch (error) {
@ -242,74 +230,9 @@ export const ConnectionsPage: React.FC = () => {
}
};
const handleCreateInfomaniak = async () => {
if (isConnecting || infomaniakModal) return;
try {
const newConnection = await createInfomaniakConnection();
setInfomaniakModal({
connectionId: newConnection.id,
token: '',
submitting: false,
error: null,
});
refetch();
} catch (error) {
console.error('Error creating Infomaniak connection:', error);
}
};
const handleInfomaniakCancel = async () => {
if (!infomaniakModal) return;
const { connectionId, submitting } = infomaniakModal;
if (submitting) return;
setInfomaniakModal(null);
try {
await deleteConnection(connectionId);
refetch();
} catch (error) {
console.error('Error rolling back pending Infomaniak connection:', error);
}
};
const handleInfomaniakSubmit = async () => {
if (!infomaniakModal) return;
const trimmed = infomaniakModal.token.trim();
if (!trimmed) {
setInfomaniakModal({ ...infomaniakModal, error: t('Bitte Personal Access Token einfügen') });
return;
}
setInfomaniakModal({ ...infomaniakModal, submitting: true, error: null });
try {
await submitInfomaniakToken(infomaniakModal.connectionId, trimmed);
setInfomaniakModal(null);
refetch();
} catch (error: any) {
const detail =
error?.response?.data?.detail ||
error?.message ||
t('Token konnte nicht gespeichert werden');
setInfomaniakModal((prev) =>
prev ? { ...prev, submitting: false, error: String(detail) } : prev
);
}
};
// Open Microsoft Admin Consent flow in a popup
const handleAdminConsent = () => {
setAdminConsentPending(true);
const consentUrl = `${getApiBaseUrl()}/api/msft/adminconsent`;
const popup = window.open(consentUrl, 'msft-admin-consent', 'width=600,height=700,scrollbars=yes,resizable=yes');
if (!popup) {
setAdminConsentPending(false);
return;
}
const checkClosed = setInterval(() => {
if (popup.closed) {
clearInterval(checkClosed);
setAdminConsentPending(false);
refetch();
}
}, 1000);
const handleMsftAdminConsent = () => {
const url = `${getApiBaseUrl()}/api/msft/adminconsent`;
window.open(url, 'msft-admin-consent', 'width=560,height=720,scrollbars=yes,resizable=yes');
};
// Form attributes for edit modal
@ -348,14 +271,6 @@ export const ConnectionsPage: React.FC = () => {
</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={handleAdminConsent}
disabled={adminConsentPending}
title={t('Microsoft Admin-Zustimmung für die gesamte Organisation erteilen')}
>
<FaShieldAlt /> {t('Admin-Zustimmung')}
</button>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
@ -364,25 +279,14 @@ export const ConnectionsPage: React.FC = () => {
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
{canCreate && (
<>
<button
type="button"
className={styles.primaryButton}
onClick={() => setWizardOpen(true)}
disabled={isConnecting}
>
<FaPlus /> {t('Verbindung hinzufügen')}
</button>
<button
type="button"
className={styles.secondaryButton}
onClick={handleCreateInfomaniak}
disabled={isConnecting}
title={t('Infomaniak-Konto verbinden (kDrive + Mail)')}
>
<FaCloud /> Infomaniak
</button>
</>
<button
type="button"
className={styles.primaryButton}
onClick={() => setWizardOpen(true)}
disabled={isConnecting}
>
<FaPlus /> {t('Verbindung hinzufügen')}
</button>
)}
</div>
</div>
@ -419,7 +323,7 @@ export const ConnectionsPage: React.FC = () => {
columns={columns}
apiEndpoint="/api/connections/"
tableContextKey="connections"
tableGroupLayoutMode="sections"
tableGroupLayoutMode="inline"
loading={loading}
pagination={true}
pageSize={25}
@ -519,141 +423,11 @@ export const ConnectionsPage: React.FC = () => {
</div>
)}
{/* Infomaniak Personal Access Token Modal */}
{infomaniakModal && (
<div className={styles.modalOverlay}>
<div className={styles.modal} style={{ maxWidth: 640 }}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Infomaniak verbinden')}</h2>
<button
className={styles.modalClose}
onClick={handleInfomaniakCancel}
disabled={infomaniakModal.submitting}
>
</button>
</div>
<div className={styles.modalContent}>
<p style={{ marginTop: 0 }}>
{t(
'Infomaniak nutzt für kDrive und kSuite keine OAuth-Anmeldung, sondern ein persönliches API-Token (PAT). Erstelle das Token einmalig im Infomaniak Manager und füge es unten ein.'
)}
</p>
<ol style={{ paddingLeft: 20 }}>
<li>
{t('Öffne den Infomaniak-Manager:')}{' '}
<a
href="https://manager.infomaniak.com/v3/ng/accounts/token/list"
target="_blank"
rel="noopener noreferrer"
>
manager.infomaniak.com API-Tokens
</a>
</li>
<li>
{t('Klicke auf')} <code>{t('Token erstellen')}</code>{' '}
{t('und vergib einen aussagekräftigen Namen, z. B.')}{' '}
<code>PowerOn</code>.{' '}
{t('Application bleibt auf')} <code>Default application</code>.
</li>
<li>
{t('Suche im Scope-Feld nach')}{' '}
<strong>{t('allen vier')}</strong>{' '}
{t('Berechtigungen und kreuze sie an:')}
<ul style={{ marginTop: 6, marginBottom: 6, paddingLeft: 20 }}>
<li>
<code>drive</code> {t('kDrive (Pflicht, heute aktiv)')}
</li>
<li>
<code>workspace:calendar</code> {' '}
{t('Kalender (Pflicht, heute aktiv)')}
</li>
<li>
<code>workspace:contact</code> {' '}
{t('Kontakte (heute aktiv)')}
</li>
<li>
<code>workspace:mail</code> {' '}
{t('Mail (in Vorbereitung, Scope schon mitnehmen)')}
</li>
</ul>
<em>
{t(
'Nicht "All" auswählen und nicht user_info / accounts — wird nicht benötigt.'
)}
</em>
</li>
<li>
{t(
'Kopiere das Token sofort (Infomaniak zeigt es nur einmal) und füge es hier ein:'
)}
</li>
</ol>
<input
type="password"
value={infomaniakModal.token}
onChange={(e) =>
setInfomaniakModal((prev) =>
prev ? { ...prev, token: e.target.value, error: null } : prev
)
}
placeholder={t('Personal Access Token einfügen')}
disabled={infomaniakModal.submitting}
autoFocus
style={{
width: '100%',
padding: '8px 10px',
fontFamily: 'monospace',
fontSize: 13,
border: '1px solid var(--border, #ccc)',
borderRadius: 4,
marginBottom: 12,
}}
/>
{infomaniakModal.error && (
<div className={styles.errorMessage} style={{ marginBottom: 12 }}>
{infomaniakModal.error}
</div>
)}
<p style={{ fontSize: 12, color: 'var(--text-secondary, #666)' }}>
{t(
'Das Token wird verschlüsselt gespeichert und nur für API-Aufrufe an Infomaniak verwendet.'
)}
</p>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: 8,
marginTop: 16,
}}
>
<button
type="button"
className={styles.secondaryButton}
onClick={handleInfomaniakCancel}
disabled={infomaniakModal.submitting}
>
{t('Abbrechen')}
</button>
<button
type="button"
className={styles.primaryButton}
onClick={handleInfomaniakSubmit}
disabled={infomaniakModal.submitting || !infomaniakModal.token.trim()}
>
{infomaniakModal.submitting ? t('Prüfen…') : t('Verbinden')}
</button>
</div>
</div>
</div>
</div>
)}
<AddConnectionWizard
open={wizardOpen}
onClose={() => setWizardOpen(false)}
onConnect={handleWizardConnect}
onMsftAdminConsent={handleMsftAdminConsent}
isConnecting={isConnecting}
/>
</div>

View file

@ -448,7 +448,7 @@ export const FilesPage: React.FC = () => {
columns={columns}
apiEndpoint="/api/files/list"
tableContextKey="files/list"
tableGroupLayoutMode="sections"
tableGroupLayoutMode="inline"
loading={tableLoading}
pagination={true}
pageSize={25}

View file

@ -209,7 +209,7 @@ export const PromptsPage: React.FC = () => {
columns={columns}
apiEndpoint="/api/prompts"
tableContextKey="prompts"
tableGroupLayoutMode="sections"
tableGroupLayoutMode="inline"
loading={loading}
pagination={true}
pageSize={25}

View file

@ -527,17 +527,15 @@ export const BillingDataView: React.FC = () => {
viewKey?: string | null;
groupField: string;
groupDirection?: 'asc' | 'desc';
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: string }>;
}) => {
const levels = base.groupByLevels?.length
? base.groupByLevels
: [{ field: base.groupField, nullLabel: '—', direction: base.groupDirection || 'asc' }];
const pObj: Record<string, unknown> = {
page: 1,
pageSize: 25,
groupByLevels: [
{
field: base.groupField,
nullLabel: '—',
direction: base.groupDirection || 'asc',
},
],
groupByLevels: levels,
};
if (base.search) (pObj as { search?: string }).search = base.search;
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;
@ -837,7 +835,7 @@ export const BillingDataView: React.FC = () => {
columns={columns}
apiEndpoint="/api/billing/view/users/transactions"
tableContextKey="billing/view/users/transactions"
tableGroupLayoutMode="sections"
tableGroupLayoutMode="inline"
loading={transactionsLoading}
pagination={true}
pageSize={25}

View file

@ -1,6 +1,13 @@
/* CommCoach Shared Styles — Assistant, Modules, Session views */
.assistantContainer,
.assistantContainer {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
}
.modulesContainer {
padding: 1.5rem;
display: flex;
@ -14,6 +21,14 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.wizardHeaderRight {
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
}
.stepIndicator {
@ -33,7 +48,6 @@
}
.wizardContent {
flex: 1;
display: flex;
flex-direction: column;
}
@ -98,9 +112,8 @@
.wizardActions {
display: flex;
gap: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color, #e0e0e0);
gap: 0.5rem;
align-items: center;
}
.wizardHint {

View file

@ -75,10 +75,24 @@ export const CommcoachAssistantView: React.FC = () => {
<div className={styles.assistantContainer}>
<div className={styles.wizardHeader}>
<h2>{t('Neues Modul erstellen')}</h2>
<div className={styles.stepIndicator}>
{STEPS.map((s, i) => (
<div key={s} className={`${styles.stepDot} ${i <= stepIdx ? styles.stepActive : ''}`} />
))}
<div className={styles.wizardHeaderRight}>
<div className={styles.stepIndicator}>
{STEPS.map((s, i) => (
<div key={s} className={`${styles.stepDot} ${i <= stepIdx ? styles.stepActive : ''}`} />
))}
</div>
<div className={styles.wizardActions}>
{stepIdx > 0 && (
<button className={styles.btnSecondary} onClick={_handleBack}>{t('Zurück')}</button>
)}
{step !== 'confirm' ? (
<button className={styles.btnPrimary} onClick={_handleNext}>{t('Weiter')}</button>
) : (
<button className={styles.btnPrimary} onClick={_handleCreate} disabled={loading}>
{loading ? t('Erstelle...') : t('Modul erstellen & erste Session starten')}
</button>
)}
</div>
</div>
</div>
@ -156,19 +170,6 @@ export const CommcoachAssistantView: React.FC = () => {
)}
</div>
<div className={styles.wizardActions}>
{stepIdx > 0 && (
<button className={styles.btnSecondary} onClick={_handleBack}>{t('Zurück')}</button>
)}
<div style={{ flex: 1 }} />
{step !== 'confirm' ? (
<button className={styles.btnPrimary} onClick={_handleNext}>{t('Weiter')}</button>
) : (
<button className={styles.btnPrimary} onClick={_handleCreate} disabled={loading}>
{loading ? t('Erstelle...') : t('Modul erstellen & erste Session starten')}
</button>
)}
</div>
</div>
);
};

View file

@ -10,7 +10,7 @@
*/
import { useState, useRef, useCallback, useEffect } from 'react';
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
import { useVoiceStream, type SttStreamOpenOptions } from '../../../hooks/useSpeechAudioCapture';
import api from '../../../api';
export type VoiceState = 'idle' | 'listening' | 'botSpeaking' | 'interrupted';
@ -35,6 +35,13 @@ export interface VoiceControllerCallbacks {
const _DEFAULT_STT_LANGUAGE = 'de-DE';
/** CommCoach: faster streaming STT profile + single-utterance endpointing (client re-opens stream). */
const _commcoachSttOpen: SttStreamOpenOptions = {
model: 'latest_short',
lightweight: true,
singleUtterance: true,
};
export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceControllerApi {
const [state, setState] = useState<VoiceState>('idle');
const [muted, setMuted] = useState(false);
@ -86,7 +93,7 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
});
const _startStream = useCallback(() => {
return voiceStream.start(sttLanguageRef.current);
return voiceStream.start(sttLanguageRef.current, _commcoachSttOpen);
}, [voiceStream]);
const activate = useCallback(async () => {

View file

@ -413,15 +413,23 @@
flex-direction: column;
gap: 1rem;
padding: 1rem;
height: 100%;
}
.sessionSwitcherSelect {
width: 100%;
max-width: 420px;
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
border: 1px solid var(--border-color, #ccc);
border-radius: 8px;
background: var(--bg-primary, #fff);
color: var(--text-primary, #333);
}
/* ----- Session Layout (UDB Sidebar + Main) ------------------------------- */
.sessionLayout {
display: flex;
flex: 1;
min-height: 0;
gap: 1rem;
}
@ -430,7 +438,6 @@
flex-direction: column;
flex: 1;
min-width: 0;
min-height: 0;
gap: 1rem;
}
@ -820,8 +827,6 @@
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
flex: 1;
min-height: 0;
}
/* Transcript Panel */
@ -833,6 +838,8 @@
display: flex;
flex-direction: column;
overflow: hidden;
height: 50vh;
min-height: 250px;
}
.panelTitle {
@ -914,6 +921,68 @@
color: var(--text-primary, #333);
}
.responseText h1,
.responseText h2,
.responseText h3 {
margin: 0.6em 0 0.3em;
font-size: 1em;
font-weight: 600;
}
.responseText p {
margin: 0.3em 0;
}
.responseText ul,
.responseText ol {
margin: 0.3em 0;
padding-left: 1.4em;
}
.responseText code {
background: var(--bg-tertiary, #f0f0f0);
padding: 0.1em 0.3em;
border-radius: 3px;
font-size: 0.85em;
}
.responseText pre {
background: var(--bg-tertiary, #f0f0f0);
padding: 0.6em;
border-radius: 4px;
overflow-x: auto;
margin: 0.4em 0;
}
.responseText pre code {
background: none;
padding: 0;
}
.responseText table {
border-collapse: collapse;
margin: 0.4em 0;
font-size: 0.85em;
}
.responseText th,
.responseText td {
border: 1px solid var(--border-color, #ddd);
padding: 0.3em 0.6em;
}
.responseText th {
background: var(--bg-secondary, #f5f5f5);
font-weight: 600;
}
.responseText blockquote {
border-left: 3px solid var(--border-color, #ddd);
margin: 0.4em 0;
padding: 0.2em 0.8em;
color: var(--text-secondary, #666);
}
.responseReasoning {
margin-top: 0.5rem;
font-size: 0.8rem;
@ -941,9 +1010,18 @@
font-size: 0.9rem;
line-height: 1.6;
color: var(--text-primary, #333);
white-space: pre-wrap;
}
.summaryText p { margin: 0.3em 0; }
.summaryText ul, .summaryText ol { margin: 0.3em 0; padding-left: 1.4em; }
.summaryText h1, .summaryText h2, .summaryText h3 { margin: 0.6em 0 0.3em; font-size: 1em; font-weight: 600; }
.summaryText code { background: var(--bg-tertiary, #f0f0f0); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.85em; }
.summaryText pre { background: var(--bg-tertiary, #f0f0f0); padding: 0.6em; border-radius: 4px; overflow-x: auto; }
.summaryText pre code { background: none; padding: 0; }
.summaryText table { border-collapse: collapse; margin: 0.4em 0; font-size: 0.85em; }
.summaryText th, .summaryText td { border: 1px solid var(--border-color, #ddd); padding: 0.3em 0.6em; }
.summaryText th { background: var(--bg-secondary, #f5f5f5); font-weight: 600; }
/* ============================================================================
Settings View
============================================================================ */
@ -1347,6 +1425,36 @@
animation: agentPulse 1s ease-in-out infinite;
}
.agentProgressLog {
padding: 0.5rem 0;
max-height: 200px;
overflow-y: auto;
font-size: 0.8rem;
line-height: 1.4;
}
.agentProgressEntry {
display: flex;
gap: 0.5rem;
padding: 0.15rem 0;
border-bottom: 1px solid var(--border-color, #eee);
}
.agentProgressEntry:last-child {
border-bottom: none;
}
.agentProgressTime {
color: var(--text-tertiary, #999);
flex-shrink: 0;
font-size: 0.75rem;
}
.agentProgressText {
color: var(--text-secondary, #666);
word-break: break-word;
}
.statsCards {
display: flex;
gap: 1rem;
@ -1376,7 +1484,14 @@
margin-top: 0.25rem;
}
.assistantContainer,
.assistantContainer {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
}
.modulesContainer {
padding: 1.5rem;
display: flex;
@ -1390,6 +1505,14 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.wizardHeaderRight {
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
}
.stepIndicator {
@ -1409,7 +1532,6 @@
}
.wizardContent {
flex: 1;
display: flex;
flex-direction: column;
}
@ -1442,9 +1564,8 @@
.wizardActions {
display: flex;
gap: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color, #e0e0e0);
gap: 0.5rem;
align-items: center;
}
.moduleChoice {
@ -1478,6 +1599,10 @@
border-color: var(--primary-color, #4A90D9);
}
.moduleRowFocused {
box-shadow: 0 0 0 2px rgba(74, 144, 217, 0.45);
}
.moduleRow {
display: flex;
align-items: center;
@ -1519,16 +1644,49 @@
border-top: 1px solid var(--border-color, #e0e0e0);
}
.sessionRow {
display: flex;
gap: 1rem;
padding: 0.4rem 0;
cursor: pointer;
font-size: 0.9rem;
.sessionTable {
border-collapse: collapse;
font-size: 0.85rem;
}
.sessionRow:hover {
color: var(--primary-color, #4A90D9);
.sessionTable th {
text-align: left;
padding: 0.35rem 0.5rem;
font-weight: 600;
font-size: 0.75rem;
color: var(--text-secondary, #666);
border-bottom: 1px solid var(--border-color, #ddd);
}
.sessionTableRow {
cursor: pointer;
}
.sessionTableRow td {
padding: 0.35rem 0.5rem;
border-bottom: 1px solid var(--border-color, #eee);
}
.sessionTableRow:hover td {
background: rgba(74, 144, 217, 0.05);
}
.sessionDeleteBtn {
background: none;
border: none;
color: #b91c1c;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
padding: 0.1rem 0.35rem;
border-radius: 3px;
line-height: 1;
opacity: 0.5;
}
.sessionDeleteBtn:hover {
opacity: 1;
background: rgba(185, 28, 28, 0.1);
}
.sessionStatus {
@ -1558,7 +1716,7 @@
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 12px;
padding: 1.5rem;
max-width: 400px;
max-width: 600px;
width: 90%;
display: flex;
flex-direction: column;
@ -1654,3 +1812,222 @@
padding: 2rem;
color: var(--text-secondary, #666);
}
/* --- TeamsBot Dashboard (Greenfield IA) --- */
.tbDash {
display: flex;
flex-direction: column;
gap: 1.75rem;
padding: 1.25rem 1.5rem 2rem;
max-width: 1100px;
margin: 0 auto;
}
.tbDashHero {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 1.25rem;
padding: 1.5rem 1.75rem;
border-radius: 12px;
background: linear-gradient(135deg, rgba(74, 144, 217, 0.12) 0%, var(--surface-color, #fff) 48%);
border: 1px solid var(--border-color, #e6e6e6);
}
.tbDashTitle {
margin: 0 0 0.35rem 0;
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary, #1a1a1a);
}
.tbDashSubtitle {
margin: 0;
max-width: 520px;
font-size: 0.95rem;
line-height: 1.45;
color: var(--text-secondary, #555);
}
.tbDashQuickActions {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-items: center;
}
.tbDashBtnPrimary {
padding: 0.65rem 1.35rem;
border-radius: 8px;
border: none;
background: var(--primary-color, #4A90D9);
color: #fff;
font-weight: 600;
font-size: 0.95rem;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.tbDashBtnPrimary:hover {
background: var(--primary-hover, #3A7BC8);
}
.tbDashBtnSecondary {
padding: 0.65rem 1.1rem;
border-radius: 8px;
border: 1px solid var(--border-color, #d0d0d0);
background: var(--surface-color, #fff);
color: var(--text-primary, #333);
font-weight: 500;
font-size: 0.9rem;
cursor: pointer;
}
.tbDashBtnSecondary:hover {
border-color: var(--primary-color, #4A90D9);
color: var(--primary-color, #4A90D9);
}
.tbDashKpiGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 1rem;
}
.tbDashKpiCard {
padding: 1.1rem 1.25rem;
border-radius: 10px;
border: 1px solid var(--border-color, #e8e8e8);
background: var(--surface-color, #fff);
}
.tbDashKpiValue {
font-size: 1.75rem;
font-weight: 700;
color: var(--primary-color, #4A90D9);
line-height: 1.1;
}
.tbDashKpiLabel {
margin-top: 0.35rem;
font-size: 0.82rem;
font-weight: 600;
color: var(--text-secondary, #666);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.tbDashKpiHint {
margin-top: 0.4rem;
font-size: 0.8rem;
color: var(--text-tertiary, #888);
}
.tbDashSection {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.tbDashSectionHead {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.tbDashSectionTitle {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
color: var(--text-primary, #222);
}
.tbDashLinkBtn {
padding: 0.35rem 0.75rem;
border: none;
background: transparent;
color: var(--primary-color, #4A90D9);
font-size: 0.88rem;
font-weight: 500;
cursor: pointer;
text-decoration: underline;
}
.tbDashModuleGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
}
.tbDashModuleCard {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.35rem;
padding: 1rem 1.1rem;
border-radius: 10px;
border: 1px solid var(--border-color, #e6e6e6);
background: var(--surface-color, #fafafa);
cursor: pointer;
text-align: left;
transition: border-color 0.15s, box-shadow 0.15s;
}
.tbDashModuleCard:hover {
border-color: var(--primary-color, #4A90D9);
box-shadow: 0 2px 8px rgba(74, 144, 217, 0.12);
}
.tbDashModuleTitle {
font-weight: 600;
font-size: 0.95rem;
color: var(--text-primary, #222);
}
.tbDashModuleCount {
font-size: 0.82rem;
color: var(--text-secondary, #666);
}
.tbDashSessionList {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.tbDashSessionRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem;
padding: 0.85rem 1rem;
border-radius: 10px;
border: 1px solid var(--border-color, #eaeaea);
background: var(--surface-color, #fff);
}
.tbDashSessionMain {
display: flex;
align-items: center;
gap: 0.6rem;
flex: 1 1 200px;
}
.tbDashSessionMeta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
font-size: 0.82rem;
color: var(--text-secondary, #666);
flex: 2 1 220px;
}
.tbDashSessionActions {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin-left: auto;
}

View file

@ -3,10 +3,12 @@
*
* Wizard: Select/create module Meeting link Bot selection "Start bot"
*/
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import * as teamsbotApi from '../../../api/teamsbotApi';
import type { MeetingModule, TeamsbotJoinMode, UserAccountStatus } from '../../../api/teamsbotApi';
import { getUserDataCache } from '../../../utils/userCache';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './Teamsbot.module.css';
@ -18,16 +20,26 @@ export const TeamsbotAssistantView: React.FC = () => {
const { instance, mandateId } = useCurrentInstance();
const instanceId = instance?.id || '';
const navigate = useNavigate();
const cachedUser = getUserDataCache();
const isSysAdmin = cachedUser?.isSysAdmin === true;
const [searchParams] = useSearchParams();
const preselectedModuleId = searchParams.get('moduleId');
const [step, setStep] = useState<WizardStep>(preselectedModuleId ? 'meeting' : 'module');
const [modules, setModules] = useState<any[]>([]);
const [modules, setModules] = useState<MeetingModule[]>([]);
const [moduleFilter, setModuleFilter] = useState('');
const [selectedModuleId, setSelectedModuleId] = useState<string | null>(preselectedModuleId);
const [newModuleTitle, setNewModuleTitle] = useState('');
const [createNewModule, setCreateNewModule] = useState(false);
const [meetingLink, setMeetingLink] = useState('');
const [botName, setBotName] = useState('AI Assistant');
const [joinMode, setJoinMode] = useState<TeamsbotJoinMode>('anonymous');
const [sessionContext, setSessionContext] = useState('');
const [userAccount, setUserAccount] = useState<UserAccountStatus | null>(null);
const [showCredentialForm, setShowCredentialForm] = useState(false);
const [credEmail, setCredEmail] = useState('');
const [credPassword, setCredPassword] = useState('');
const [savingCredentials, setSavingCredentials] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -45,6 +57,33 @@ export const TeamsbotAssistantView: React.FC = () => {
useEffect(() => { _loadModules(); }, [_loadModules]);
useEffect(() => {
if (joinMode === 'userAccount' && instanceId) {
teamsbotApi.getUserAccount(instanceId).then(setUserAccount).catch(() => setUserAccount(null));
}
}, [joinMode, instanceId]);
const filteredModules = useMemo(() => {
const q = moduleFilter.trim().toLowerCase();
if (!q) return modules;
return modules.filter(m => m.title.toLowerCase().includes(q));
}, [modules, moduleFilter]);
const modulePrefillKeyRef = useRef<string>('');
useEffect(() => {
if (!selectedModuleId || createNewModule) {
modulePrefillKeyRef.current = '';
return;
}
const mod = modules.find(m => m.id === selectedModuleId);
if (!mod) return;
const key = `${selectedModuleId}:${mod.defaultMeetingLink ?? ''}:${mod.defaultBotName ?? ''}`;
if (modulePrefillKeyRef.current === key) return;
modulePrefillKeyRef.current = key;
if (mod.defaultMeetingLink) setMeetingLink(mod.defaultMeetingLink);
if (mod.defaultBotName) setBotName(mod.defaultBotName);
}, [selectedModuleId, createNewModule, modules]);
const _handleNext = () => {
const nextIdx = stepIdx + 1;
if (nextIdx < STEPS.length) setStep(STEPS[nextIdx]);
@ -60,6 +99,27 @@ export const TeamsbotAssistantView: React.FC = () => {
setError(t('Meeting-Link erforderlich'));
return;
}
if (joinMode === 'userAccount' && !userAccount?.hasSavedCredentials && !credEmail) {
setShowCredentialForm(true);
setError(t('Bitte Microsoft-Zugangsdaten eingeben oder speichern.'));
return;
}
const needsSave = joinMode === 'userAccount' && !userAccount?.hasSavedCredentials && credEmail && credPassword;
const needsUpdate = joinMode === 'userAccount' && showCredentialForm && credEmail && credPassword;
if (needsSave || needsUpdate) {
try {
setSavingCredentials(true);
await teamsbotApi.saveUserAccount(instanceId, credEmail, credPassword);
setUserAccount({ hasSavedCredentials: true, email: credEmail });
setShowCredentialForm(false);
} catch (err: any) {
setError(err?.message || t('Fehler beim Speichern der Zugangsdaten'));
setSavingCredentials(false);
return;
} finally {
setSavingCredentials(false);
}
}
setLoading(true);
setError(null);
try {
@ -73,7 +133,9 @@ export const TeamsbotAssistantView: React.FC = () => {
meetingLink: meetingLink.trim(),
botName,
moduleId: moduleId || undefined,
} as any);
joinMode,
sessionContext: sessionContext.trim() || undefined,
});
navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/sessions?sessionId=${result.session.id}`);
} catch (err: any) {
@ -87,10 +149,32 @@ export const TeamsbotAssistantView: React.FC = () => {
<div className={styles.assistantContainer}>
<div className={styles.wizardHeader}>
<h2>{t('Neues Meeting starten')}</h2>
<div className={styles.stepIndicator}>
{STEPS.map((s, i) => (
<div key={s} className={`${styles.stepDot} ${i <= stepIdx ? styles.stepActive : ''}`} />
))}
<div className={styles.wizardHeaderRight}>
<div className={styles.stepIndicator}>
{STEPS.map((s, i) => (
<div key={s} className={`${styles.stepDot} ${i <= stepIdx ? styles.stepActive : ''}`} />
))}
</div>
<div className={styles.wizardActions}>
{stepIdx > 0 && (
<button className={styles.btnSecondary} onClick={_handleBack}>{t('Zurück')}</button>
)}
{step !== 'confirm' ? (
<button className={styles.btnPrimary} onClick={_handleNext}>{t('Weiter')}</button>
) : (
<button
className={styles.btnPrimary}
onClick={_handleStart}
disabled={
loading
|| savingCredentials
|| (joinMode === 'userAccount' && showCredentialForm && (!credEmail || !credPassword))
}
>
{loading ? t('Starte...') : t('Bot starten')}
</button>
)}
</div>
</div>
</div>
@ -106,16 +190,27 @@ export const TeamsbotAssistantView: React.FC = () => {
{t('Bestehendes Modul')}
</label>
{!createNewModule && (
<select
value={selectedModuleId || ''}
onChange={e => setSelectedModuleId(e.target.value || null)}
className={styles.wizardSelect}
>
<option value="">{t('Kein Modul (Adhoc)')}</option>
{modules.map(m => (
<option key={m.id} value={m.id}>{m.title}</option>
))}
</select>
<>
<input
type="search"
className={styles.wizardInput}
placeholder={t('Modul suchen…')}
value={moduleFilter}
onChange={e => setModuleFilter(e.target.value)}
aria-label={t('Modul suchen')}
/>
<select
value={selectedModuleId || ''}
onChange={e => setSelectedModuleId(e.target.value || null)}
className={styles.wizardSelect}
size={Math.min(12, Math.max(4, filteredModules.length + 1))}
>
<option value="">{t('Kein Modul (Adhoc)')}</option>
{filteredModules.map(m => (
<option key={m.id} value={m.id}>{m.title}</option>
))}
</select>
</>
)}
<label>
<input type="radio" checked={createNewModule} onChange={() => setCreateNewModule(true)} />
@ -136,7 +231,7 @@ export const TeamsbotAssistantView: React.FC = () => {
{step === 'meeting' && (
<div className={styles.wizardStep}>
<h3>{t('Meeting-Link')}</h3>
<h3>{t('Meeting-Link und Beitritt')}</h3>
<input
type="text"
className={styles.wizardInput}
@ -145,6 +240,87 @@ export const TeamsbotAssistantView: React.FC = () => {
onChange={e => setMeetingLink(e.target.value)}
autoFocus
/>
<label className={styles.label} style={{ marginTop: '1rem' }}>{t('Join-Modus')}</label>
<select
className={styles.wizardSelect}
value={joinMode}
onChange={e => setJoinMode(e.target.value as TeamsbotJoinMode)}
>
{isSysAdmin && <option value="systemBot">{t('Systembot authentifiziert')}</option>}
<option value="anonymous">{t('Anonymer Gast')}</option>
<option value="userAccount">{t('Mein Account')}</option>
</select>
{joinMode === 'userAccount' && (
<div className={styles.credentialsCard} style={{ marginTop: '0.75rem' }}>
{userAccount?.hasSavedCredentials && !showCredentialForm ? (
<div className={styles.credentialsInfo}>
<span>
{t('Gespeichert:')} <span className={styles.credentialsEmail}>{userAccount.email}</span>
</span>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
type="button"
className={styles.viewButton}
onClick={() => { setShowCredentialForm(true); setCredEmail(userAccount.email || ''); }}
>
{t('Ändern')}
</button>
<button
type="button"
className={styles.deleteButton}
onClick={async () => {
try {
await teamsbotApi.deleteUserAccount(instanceId);
setUserAccount({ hasSavedCredentials: false });
setCredEmail('');
setCredPassword('');
} catch { /* ignore */ }
}}
>
{t('Entfernen')}
</button>
</div>
</div>
) : (
<>
<div className={styles.formGroup}>
<label className={styles.label}>{t('Microsoft E-Mail')}</label>
<input
type="email"
className={styles.input}
value={credEmail}
onChange={e => setCredEmail(e.target.value)}
disabled={savingCredentials}
/>
</div>
<div className={styles.formGroup}>
<label className={styles.label}>{t('Passwort')}</label>
<input
type="password"
className={styles.input}
value={credPassword}
onChange={e => setCredPassword(e.target.value)}
disabled={savingCredentials}
/>
</div>
<span className={styles.hint}>{t('Zugangsdaten werden verschlüsselt gespeichert.')}</span>
{userAccount?.hasSavedCredentials && (
<button type="button" className={styles.viewButton} style={{ marginTop: '0.5rem' }} onClick={() => setShowCredentialForm(false)}>
{t('Abbrechen')}
</button>
)}
</>
)}
</div>
)}
<label className={styles.label} style={{ marginTop: '1rem' }}>{t('Sitzungskontext (optional)')}</label>
<textarea
className={styles.wizardTextarea}
placeholder={t('Agenda, Hintergrund …')}
value={sessionContext}
onChange={e => setSessionContext(e.target.value)}
rows={3}
/>
</div>
)}
@ -167,24 +343,13 @@ export const TeamsbotAssistantView: React.FC = () => {
<div><strong>{t('Modul')}:</strong> {createNewModule ? newModuleTitle : (modules.find(m => m.id === selectedModuleId)?.title || t('Adhoc'))}</div>
<div><strong>{t('Meeting')}:</strong> {meetingLink}</div>
<div><strong>{t('Bot')}:</strong> {botName}</div>
<div><strong>{t('Join-Modus')}:</strong> {joinMode}</div>
{sessionContext.trim() ? <div><strong>{t('Kontext')}:</strong> {sessionContext.trim().slice(0, 120)}{sessionContext.length > 120 ? '…' : ''}</div> : null}
</div>
</div>
)}
</div>
<div className={styles.wizardActions}>
{stepIdx > 0 && (
<button className={styles.btnSecondary} onClick={_handleBack}>{t('Zurück')}</button>
)}
<div style={{ flex: 1 }} />
{step !== 'confirm' ? (
<button className={styles.btnPrimary} onClick={_handleNext}>{t('Weiter')}</button>
) : (
<button className={styles.btnPrimary} onClick={_handleStart} disabled={loading}>
{loading ? t('Starte...') : t('Bot starten')}
</button>
)}
</div>
</div>
);
};

View file

@ -1,230 +1,113 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import * as teamsbotApi from '../../../api/teamsbotApi';
import type { TeamsbotSession, StartSessionRequest, TeamsbotJoinMode, UserAccountStatus, MfaChallengeEvent } from '../../../api/teamsbotApi';
import { getUserDataCache } from '../../../utils/userCache';
import type { TeamsbotSession, MeetingModule } from '../../../api/teamsbotApi';
import styles from './Teamsbot.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
/**
* TeamsbotDashboardView - Overview of all Teams Bot sessions.
* Allows starting new sessions and viewing active/past sessions.
* Supports "Mein Account" login with saved credentials and MFA relay.
* TeamsBot Dashboard IA: KPIs, Modul-Aggregate, Quick-Actions.
* Neues Meeting: Assistent (Wizard). Sessions sind via Module erreichbar.
*/
export const TeamsbotDashboardView: React.FC = () => {
const { t } = useLanguage();
const { t } = useLanguage();
const { instance, mandateId, featureCode } = useCurrentInstance();
const instanceId = instance?.id || '';
const navigate = useNavigate();
const cachedUser = getUserDataCache();
const _isSysAdmin = cachedUser?.isSysAdmin === true;
const [sessions, setSessions] = useState<TeamsbotSession[]>([]);
const [loading, setLoading] = useState(true);
const [modules, setModules] = useState<MeetingModule[]>([]);
const [error, setError] = useState<string | null>(null);
// New session form
const [meetingLink, setMeetingLink] = useState('');
const [botName, setBotName] = useState('');
const [joinMode, setJoinMode] = useState<TeamsbotJoinMode>('anonymous');
const [sessionContext, setSessionContext] = useState('');
const [isStarting, setIsStarting] = useState(false);
const dashboardEsRef = useRef<EventSource | null>(null);
const dashboardReconnectRef = useRef<number | null>(null);
// User Account (Mein Account) state
const [userAccount, setUserAccount] = useState<UserAccountStatus | null>(null);
const [showCredentialForm, setShowCredentialForm] = useState(false);
const [credEmail, setCredEmail] = useState('');
const [credPassword, setCredPassword] = useState('');
const [savingCredentials, setSavingCredentials] = useState(false);
// MFA state
const [mfaChallenge, setMfaChallenge] = useState<MfaChallengeEvent | null>(null);
const [mfaCode, setMfaCode] = useState('');
const [mfaSessionId, setMfaSessionId] = useState<string | null>(null);
const [mfaWaitingPush, setMfaWaitingPush] = useState(false);
const sseRef = useRef<EventSource | null>(null);
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 || t('Fehler beim Laden der Sitzungen'));
} finally {
setLoading(false);
}
}, [instanceId, t]);
useEffect(() => {
_loadSessions();
}, [_loadSessions]);
// Load user account status when joinMode changes to userAccount
useEffect(() => {
if (joinMode === 'userAccount' && instanceId) {
teamsbotApi.getUserAccount(instanceId).then(setUserAccount).catch(() => setUserAccount(null));
}
}, [joinMode, instanceId]);
// Adaptive polling: 3s with active sessions, 30s otherwise
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
const hasActiveSessions = sessions.some(s => ['pending', 'joining', 'active'].includes(s.status));
const interval = hasActiveSessions ? 3000 : 30000;
if (instanceId) {
pollRef.current = setInterval(() => {
teamsbotApi.listSessions(instanceId).then(r => setSessions(r.sessions || [])).catch(() => {});
}, interval);
}
return () => { if (pollRef.current) clearInterval(pollRef.current); };
}, [sessions, instanceId]);
// Cleanup SSE on unmount
useEffect(() => {
return () => { sseRef.current?.close(); };
const applyDashboardPayload = useCallback((nextSessions: TeamsbotSession[], nextModules: MeetingModule[]) => {
setSessions(nextSessions);
setModules(nextModules);
}, []);
const _startMfaListener = useCallback((sessionId: string) => {
sseRef.current?.close();
const es = teamsbotApi.createSessionStream(instanceId, sessionId);
sseRef.current = es;
setMfaSessionId(sessionId);
useEffect(() => {
if (!instanceId) return;
let cancelled = false;
es.onmessage = (event) => {
try {
const parsed = JSON.parse(event.data);
if (parsed.type === 'mfaChallenge') {
const data = parsed.data as MfaChallengeEvent;
if (data.mfaType === 'timeout') {
setMfaChallenge(null);
setMfaWaitingPush(false);
setError(t('MFA-Zeitlimit überschritten, bitte erneut versuchen'));
} else {
setMfaChallenge(data);
setMfaCode('');
setMfaWaitingPush(data.mfaType === 'pushApproval' || data.mfaType === 'numberMatch');
}
} else if (parsed.type === 'mfaResolved') {
setMfaChallenge(null);
setMfaWaitingPush(false);
setMfaSessionId(null);
es.close();
sseRef.current = null;
_loadSessions();
}
} catch { /* ignore parse errors */ }
};
}, [instanceId, _loadSessions, t]);
const _handleStartSession = async () => {
if (!meetingLink.trim()) return;
// For userAccount: need credentials (saved or entered now)
if (joinMode === 'userAccount' && !userAccount?.hasSavedCredentials && !credEmail) {
setShowCredentialForm(true);
return;
}
// userAccount with new/unsaved credentials: save to DB before starting
const needsSave = joinMode === 'userAccount' && !userAccount?.hasSavedCredentials && credEmail && credPassword;
const needsUpdate = joinMode === 'userAccount' && showCredentialForm && credEmail && credPassword;
if (needsSave || needsUpdate) {
try {
setSavingCredentials(true);
await teamsbotApi.saveUserAccount(instanceId, credEmail, credPassword);
setUserAccount({ hasSavedCredentials: true, email: credEmail });
} catch (err: any) {
setError(err.message || t('Fehler beim Speichern der Zugangsdaten'));
setSavingCredentials(false);
return;
} finally {
setSavingCredentials(false);
const clearReconnect = () => {
if (dashboardReconnectRef.current) {
window.clearTimeout(dashboardReconnectRef.current);
dashboardReconnectRef.current = null;
}
setShowCredentialForm(false);
}
};
setIsStarting(true);
setError(null);
const connect = () => {
if (cancelled) return;
dashboardEsRef.current?.close();
const es = teamsbotApi.createDashboardStream(instanceId);
dashboardEsRef.current = es;
try {
const request: StartSessionRequest = {
meetingLink: meetingLink.trim(),
botName: botName.trim() || undefined,
joinMode: joinMode,
sessionContext: sessionContext.trim() || undefined,
es.onmessage = (event) => {
try {
const msg = JSON.parse(event.data) as {
type?: string;
sessions?: TeamsbotSession[];
modules?: MeetingModule[];
};
if (msg.type === 'dashboardState' && Array.isArray(msg.sessions) && Array.isArray(msg.modules)) {
applyDashboardPayload(msg.sessions, msg.modules);
setError(null);
}
} catch {
/* ignore malformed SSE */
}
};
const result = await teamsbotApi.startSession(instanceId, request);
const newSessionId = result.session?.id;
setMeetingLink('');
setBotName('');
es.onerror = () => {
es.close();
dashboardEsRef.current = null;
if (cancelled) return;
clearReconnect();
dashboardReconnectRef.current = window.setTimeout(connect, 2500);
};
};
// Start SSE listener for MFA events if userAccount mode
if (joinMode === 'userAccount' && newSessionId) {
_startMfaListener(newSessionId);
}
connect();
return () => {
cancelled = true;
clearReconnect();
dashboardEsRef.current?.close();
dashboardEsRef.current = null;
};
}, [instanceId, applyDashboardPayload]);
await _loadSessions();
} catch (err: any) {
setError(err.message || t('Fehler beim Starten der Sitzung'));
} finally {
setIsStarting(false);
}
};
const activeSessions = useMemo(
() => sessions.filter((s) => ['pending', 'joining', 'active'].includes(s.status)),
[sessions],
);
const moduleTitleById = useMemo(() => {
const m = new Map<string, string>();
modules.forEach((mod) => m.set(mod.id, mod.title));
return m;
}, [modules]);
const _handleSubmitMfaCode = async () => {
if (!mfaSessionId || !instanceId) return;
const needsCode = mfaChallenge?.mfaType === 'totpCode' || mfaChallenge?.mfaType === 'smsCode';
try {
await teamsbotApi.submitMfaCode(
instanceId,
mfaSessionId,
needsCode ? mfaCode : '',
needsCode ? 'code' : 'confirmed',
);
if (!needsCode) {
setMfaWaitingPush(true);
}
} catch (err: any) {
setError(err.message || t('Fehler beim Senden des MFA-Codes'));
}
};
const topModules = useMemo(() => {
const counts = new Map<string, number>();
sessions.forEach((s) => {
const mid = s.moduleId || '_adhoc';
counts.set(mid, (counts.get(mid) || 0) + 1);
});
const rows = Array.from(counts.entries())
.map(([moduleId, sessionCount]) => ({
moduleId,
sessionCount,
title: moduleId === '_adhoc' ? t('Adhoc / ohne Modul') : (moduleTitleById.get(moduleId) || t('Unbekanntes Modul')),
}))
.sort((a, b) => b.sessionCount - a.sessionCount)
.slice(0, 6);
return rows;
}, [sessions, moduleTitleById, t]);
const _handleDeleteUserAccount = async () => {
try {
await teamsbotApi.deleteUserAccount(instanceId);
setUserAccount({ hasSavedCredentials: false });
setCredEmail('');
setCredPassword('');
} catch (err: any) {
setError(err.message || t('Fehler beim Löschen der Zugangsdaten'));
}
};
const _handleStopSession = async (sessionId: string) => {
try {
await teamsbotApi.stopSession(instanceId, sessionId);
await _loadSessions();
} catch (err: any) {
setError(err.message || t('Fehler beim Stoppen der Sitzung'));
}
};
const _handleDeleteSession = async (sessionId: string) => {
try {
await teamsbotApi.deleteSession(instanceId, sessionId);
await _loadSessions();
} catch (err: any) {
setError(err.message || t('Fehler beim Löschen der Sitzung'));
}
};
const totalSegments = useMemo(() => sessions.reduce((acc, s) => acc + (s.transcriptSegmentCount || 0), 0), [sessions]);
const totalResponses = useMemo(() => sessions.reduce((acc, s) => acc + (s.botResponseCount || 0), 0), [sessions]);
const _getStatusBadgeClass = (status: string) => {
switch (status) {
@ -240,270 +123,156 @@ export const TeamsbotDashboardView: React.FC = () => {
const _getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
pending: 'Wartend',
joining: 'Beitritt...',
active: 'Aktiv',
leaving: 'Verlassen...',
ended: 'Beendet',
error: 'Fehler',
pending: t('Wartend'),
joining: t('Beitritt…'),
active: t('Aktiv'),
leaving: t('Verlassen…'),
ended: t('Beendet'),
error: t('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));
const _sessionPath = (sessId: string) =>
`/mandates/${mandateId}/${featureCode}/${instanceId}/sessions?sessionId=${sessId}`;
const _needsCodeInput = mfaChallenge?.mfaType === 'totpCode' || mfaChallenge?.mfaType === 'smsCode';
const _refreshLists = useCallback(async () => {
if (!instanceId) return;
try {
const [r, m] = await Promise.all([
teamsbotApi.listSessions(instanceId, true),
teamsbotApi.listModules(instanceId),
]);
setSessions(r.sessions || []);
setModules(m || []);
} catch { /* ignore */ }
}, [instanceId]);
const _handleStopSession = async (sid: string) => {
try {
await teamsbotApi.stopSession(instanceId, sid);
await _refreshLists();
} catch (err: any) {
setError(err.message || t('Fehler beim Stoppen'));
}
};
return (
<div className={styles.dashboardContainer}>
{/* MFA Challenge Dialog */}
{mfaChallenge && (
<div className={styles.mfaOverlay}>
<div className={styles.mfaDialog}>
<div className={styles.mfaTitle}>Multi-Faktor-Authentifizierung</div>
{mfaChallenge.displayNumber && (
<div className={styles.mfaNumber}>{mfaChallenge.displayNumber}</div>
)}
<div className={styles.mfaPrompt}>{mfaChallenge.prompt}</div>
{_needsCodeInput ? (
<>
<input
type="text"
className={styles.mfaCodeInput}
placeholder={t('Code eingeben')}
value={mfaCode}
onChange={(e) => setMfaCode(e.target.value)}
autoFocus
onKeyDown={(e) => e.key === 'Enter' && _handleSubmitMfaCode()}
/>
<button className={styles.startButton} onClick={_handleSubmitMfaCode} disabled={!mfaCode.trim()}>
Bestaetigen
</button>
</>
) : mfaWaitingPush ? (
<>
<div className={styles.mfaSpinner} />
<p style={{ fontSize: '0.85rem', color: '#888' }}>
Warte auf Bestaetigung in der Authenticator App...
</p>
</>
) : (
<button className={styles.startButton} onClick={_handleSubmitMfaCode}>
Ich habe bestaetigt
</button>
)}
</div>
<div className={styles.tbDash}>
<header className={styles.tbDashHero}>
<div className={styles.tbDashHeroText}>
<h1 className={styles.tbDashTitle}>{t('Teams Bot')}</h1>
<p className={styles.tbDashSubtitle}>
{t('Dashboard mit Übersicht, Modulen und Live-Sitzung — neues Meeting über den Assistenten starten.')}
</p>
</div>
)}
{/* Start New Session Card */}
<div className={styles.startSessionCard}>
<h3 className={styles.cardTitle}>{t('Neue Botsitzung 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}>{t('Teams-Meetinglink')}</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}>Join-Modus</label>
<select
className={styles.select || styles.input}
value={joinMode}
onChange={(e) => setJoinMode(e.target.value as TeamsbotJoinMode)}
disabled={isStarting}
<div className={styles.tbDashQuickActions}>
<button
type="button"
className={styles.tbDashBtnPrimary}
onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/assistant`)}
>
{_isSysAdmin && <option value="systemBot">{t('Systembot authentifiziert')}</option>}
<option value="anonymous">{t('Anonymer Gast')}</option>
<option value="userAccount">{t('Mein Account')}</option>
</select>
{t('Neues Meeting')}
</button>
<button
type="button"
className={styles.tbDashBtnSecondary}
onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/modules`)}
>
{t('Module')}
</button>
<button
type="button"
className={styles.tbDashBtnSecondary}
onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/sessions`)}
>
{t('Live-Session')}
</button>
</div>
{/* User Account: saved credentials info or credential form */}
{joinMode === 'userAccount' && (
<div className={styles.credentialsCard}>
{userAccount?.hasSavedCredentials && !showCredentialForm ? (
<div className={styles.credentialsInfo}>
<span>
Gespeichert: <span className={styles.credentialsEmail}>{userAccount.email}</span>
</span>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
className={styles.viewButton}
onClick={() => { setShowCredentialForm(true); setCredEmail(userAccount.email || ''); }}
>
Aendern
</button>
<button className={styles.deleteButton} onClick={_handleDeleteUserAccount}>
Entfernen
</button>
</div>
</div>
) : (
<>
<div className={styles.formGroup}>
<label className={styles.label}>{t('Microsoft E-Mail')}</label>
<input
type="email"
className={styles.input}
placeholder="name@example.com"
value={credEmail}
onChange={(e) => setCredEmail(e.target.value)}
disabled={savingCredentials}
/>
</div>
<div className={styles.formGroup}>
<label className={styles.label}>{t('Passwort')}</label>
<input
type="password"
className={styles.input}
placeholder="Microsoft-Passwort"
value={credPassword}
onChange={(e) => setCredPassword(e.target.value)}
disabled={savingCredentials}
/>
</div>
<span style={{ fontSize: '12px', color: '#888', marginTop: '4px', display: 'block' }}>
Zugangsdaten werden verschluesselt gespeichert.
</span>
{userAccount?.hasSavedCredentials && (
<button
className={styles.viewButton}
style={{ marginTop: '0.5rem' }}
onClick={() => setShowCredentialForm(false)}
>
Abbrechen
</button>
)}
</>
)}
</div>
)}
<div className={styles.formGroup}>
<label className={styles.label}>{t('Botname (optional)')}</label>
<input
type="text"
className={styles.input}
placeholder={t('KI-Assistent')}
value={botName}
onChange={(e) => setBotName(e.target.value)}
disabled={isStarting}
/>
</div>
<div className={styles.formGroup}>
<label className={styles.label}>{t('Sitzungskontext (optional)')}</label>
<textarea
className={styles.textarea || styles.input}
placeholder={t('Agenda, Hintergrundinfos, Dokumente oder …')}
value={sessionContext}
onChange={(e) => setSessionContext(e.target.value)}
disabled={isStarting}
rows={4}
style={{ resize: 'vertical', minHeight: '80px' }}
/>
<span style={{ fontSize: '12px', color: '#888' }}>
Kontext den der Bot waehrend der Sitzung nutzen kann (z.B. Meeting-Agenda, Projektinfos).
{sessionContext.length > 0 && ` (${sessionContext.length} Zeichen)`}
</span>
</div>
<button
className={styles.startButton}
onClick={_handleStartSession}
disabled={isStarting || !meetingLink.trim() || (joinMode === 'userAccount' && showCredentialForm && (!credEmail || !credPassword))}
>
{isStarting ? t('Wird gestartet') : t('Bot ins Meeting senden')}
</button>
</div>
</header>
{error && <div className={styles.errorBanner}>{error}</div>}
{/* Active Sessions */}
<section className={styles.tbDashKpiGrid} aria-label={t('Kennzahlen')}>
<div className={styles.tbDashKpiCard}>
<div className={styles.tbDashKpiValue}>{modules.length}</div>
<div className={styles.tbDashKpiLabel}>{t('Meeting-Module')}</div>
</div>
<div className={styles.tbDashKpiCard}>
<div className={styles.tbDashKpiValue}>{activeSessions.length}</div>
<div className={styles.tbDashKpiLabel}>{t('Aktive Sitzungen')}</div>
</div>
<div className={styles.tbDashKpiCard}>
<div className={styles.tbDashKpiValue}>{sessions.length}</div>
<div className={styles.tbDashKpiLabel}>{t('Sitzungen gesamt')}</div>
</div>
<div className={styles.tbDashKpiCard}>
<div className={styles.tbDashKpiValue}>{totalSegments}</div>
<div className={styles.tbDashKpiLabel}>{t('Transkript-Segmente')}</div>
<div className={styles.tbDashKpiHint}>{totalResponses} {t('Bot-Antworten')}</div>
</div>
</section>
<section className={styles.tbDashSection}>
<h2 className={styles.tbDashSectionTitle}>{t('Module nach Aktivität')}</h2>
{topModules.length === 0 ? (
<p className={styles.emptyState}>{t('Noch keine Sitzungen — starte ein Meeting im Assistenten.')}</p>
) : (
<div className={styles.tbDashModuleGrid}>
{topModules.map((row) => (
<button
key={row.moduleId}
type="button"
className={styles.tbDashModuleCard}
onClick={() => navigate(
row.moduleId === '_adhoc'
? `/mandates/${mandateId}/${featureCode}/${instanceId}/modules`
: `/mandates/${mandateId}/${featureCode}/${instanceId}/modules?moduleId=${encodeURIComponent(row.moduleId)}`,
)}
>
<span className={styles.tbDashModuleTitle}>{row.title}</span>
<span className={styles.tbDashModuleCount}>{row.sessionCount} {t('Sitzungen')}</span>
</button>
))}
</div>
)}
</section>
{activeSessions.length > 0 && (
<div className={styles.sectionContainer}>
<h3 className={styles.sectionTitle}>{t('Aktive Sitzungen')}</h3>
<div className={styles.sessionList}>
<section className={styles.tbDashSection}>
<h2 className={styles.tbDashSectionTitle}>{t('Aktive Sitzungen')}</h2>
<div className={styles.tbDashSessionList}>
{activeSessions.map((session) => (
<div key={session.id} className={styles.sessionCard}>
<div className={styles.sessionHeader}>
<div key={session.id} className={styles.tbDashSessionRow}>
<div className={styles.tbDashSessionMain}>
<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 className={styles.tbDashSessionMeta}>
{session.moduleId && (
<span>{moduleTitleById.get(session.moduleId) || session.moduleId}</span>
)}
<span>{session.transcriptSegmentCount} {t('Segmente')}</span>
<span>{session.botResponseCount} {t('Antworten')}</span>
</div>
<div className={styles.sessionActions}>
<button className={styles.viewButton} onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/sessions?sessionId=${session.id}`)}>{t('Live ansehen')}</button>
{session.status === 'active' && (
<button className={styles.stopButton} onClick={() => _handleStopSession(session.id)}>
Stoppen
<div className={styles.tbDashSessionActions}>
<button type="button" className={styles.viewButton} onClick={() => navigate(_sessionPath(session.id))}>
{t('Live ansehen')}
</button>
{!['ended', 'error', 'leaving'].includes(session.status) && (
<button type="button" className={styles.stopButton} onClick={() => _handleStopSession(session.id)}>
{t('Stoppen')}
</button>
)}
</div>
</div>
))}
</div>
</div>
</section>
)}
{/* 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}>{t('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}>
<button className={styles.viewButton} onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/sessions?sessionId=${session.id}`)}>Details</button>
<button className={styles.deleteButton} onClick={() => _handleDeleteSession(session.id)}>
Loeschen
</button>
</div>
</div>
))}
</div>
</div>
</div>
);
};

View file

@ -3,11 +3,14 @@
*
* CRUD list of MeetingModules with expandable session lists per module.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import * as teamsbotApi from '../../../api/teamsbotApi';
import type { MeetingModule, TeamsbotSession, MediaFileInfo } from '../../../api/teamsbotApi';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { useFileContext } from '../../../contexts/FileContext';
import { FaSpinner } from 'react-icons/fa';
import styles from './Teamsbot.module.css';
const SERIES_TYPE_LABELS: Record<string, string> = {
@ -29,13 +32,62 @@ export const TeamsbotModulesView: React.FC = () => {
const { instance, mandateId } = useCurrentInstance();
const instanceId = instance?.id || '';
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const focusModuleId = searchParams.get('moduleId') || '';
const moduleRowRefs = useRef<Record<string, HTMLDivElement | null>>({});
const [modules, setModules] = useState<any[]>([]);
const [modules, setModules] = useState<MeetingModule[]>([]);
const [loading, setLoading] = useState(true);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [moduleSessions, setModuleSessions] = useState<Record<string, any[]>>({});
const [moduleSessions, setModuleSessions] = useState<Record<string, TeamsbotSession[]>>({});
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [editingModule, setEditingModule] = useState<any | null>(null);
const [editingModule, setEditingModule] = useState<MeetingModule | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [createTitle, setCreateTitle] = useState('');
const [createSeriesType, setCreateSeriesType] = useState<string>('adhoc');
const [createDefaultLink, setCreateDefaultLink] = useState('');
const [createDefaultBotName, setCreateDefaultBotName] = useState('');
const [createGoals, setCreateGoals] = useState('');
const [createDefaultAvatarFileId, setCreateDefaultAvatarFileId] = useState('');
const [createSaving, setCreateSaving] = useState(false);
const [mediaFiles, setMediaFiles] = useState<MediaFileInfo[]>([]);
const fileCtx = useFileContext();
const avatarInputRef = useRef<HTMLInputElement | null>(null);
const [avatarUploading, setAvatarUploading] = useState(false);
const [avatarTarget, setAvatarTarget] = useState<'create' | 'edit'>('create');
const _refreshMediaFiles = useCallback(async () => {
const result = await teamsbotApi.listMediaFiles().catch(() => [] as MediaFileInfo[]);
setMediaFiles(result);
}, []);
const _handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !fileCtx?.handleFileUpload) return;
setAvatarUploading(true);
try {
const result = await fileCtx.handleFileUpload(file);
if (result?.success) {
const data: any = (result.fileData as any)?.file || result.fileData;
const id = data?.id || (result.fileData as any)?.id;
if (id) {
if (avatarTarget === 'create') {
setCreateDefaultAvatarFileId(id);
} else if (editingModule) {
setEditingModule({ ...editingModule, defaultAvatarFileId: id });
}
const refreshed = await teamsbotApi.listMediaFiles().catch(() => [] as MediaFileInfo[]);
setMediaFiles(refreshed);
}
}
} catch {
// upload error handled by FileContext
} finally {
setAvatarUploading(false);
if (avatarInputRef.current) avatarInputRef.current.value = '';
}
};
const _loadModules = useCallback(async () => {
if (!instanceId) return;
@ -52,6 +104,10 @@ export const TeamsbotModulesView: React.FC = () => {
useEffect(() => { _loadModules(); }, [_loadModules]);
useEffect(() => {
teamsbotApi.listMediaFiles().then(setMediaFiles).catch(() => {});
}, []);
const _loadModuleSessions = useCallback(async (moduleId: string) => {
if (!instanceId) return;
try {
@ -62,6 +118,20 @@ export const TeamsbotModulesView: React.FC = () => {
}
}, [instanceId]);
useEffect(() => {
if (!focusModuleId || modules.length === 0) return;
if (!modules.some((m) => m.id === focusModuleId)) return;
setExpandedId(focusModuleId);
_loadModuleSessions(focusModuleId);
}, [focusModuleId, modules, _loadModuleSessions]);
useEffect(() => {
if (!focusModuleId || expandedId !== focusModuleId) return;
requestAnimationFrame(() => {
moduleRowRefs.current[focusModuleId]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
}, [focusModuleId, expandedId]);
const _toggleExpand = (moduleId: string) => {
if (expandedId === moduleId) {
setExpandedId(null);
@ -81,7 +151,7 @@ export const TeamsbotModulesView: React.FC = () => {
}
};
const _handleUpdate = async (moduleId: string, updates: any) => {
const _handleUpdate = async (moduleId: string, updates: Partial<MeetingModule>) => {
try {
await teamsbotApi.updateModule(instanceId, moduleId, updates);
setEditingModule(null);
@ -91,27 +161,96 @@ export const TeamsbotModulesView: React.FC = () => {
}
};
const _handleCreateModule = async () => {
if (!createTitle.trim()) return;
setCreateSaving(true);
try {
await teamsbotApi.createModule(instanceId, {
title: createTitle.trim(),
seriesType: createSeriesType,
defaultMeetingLink: createDefaultLink.trim() || undefined,
defaultBotName: createDefaultBotName.trim() || undefined,
defaultAvatarFileId: createDefaultAvatarFileId || undefined,
goals: createGoals.trim() || undefined,
});
setCreateOpen(false);
setCreateTitle('');
setCreateSeriesType('adhoc');
setCreateDefaultLink('');
setCreateDefaultBotName('');
setCreateDefaultAvatarFileId('');
setCreateGoals('');
_loadModules();
} catch (err) {
console.error('Create module failed:', err);
} finally {
setCreateSaving(false);
}
};
const _formatSessionDateTime = (ts?: string | number): string => {
if (ts == null) return '-';
const ms = typeof ts === 'number' ? ts * 1000 : Date.parse(String(ts));
if (Number.isNaN(ms)) return '-';
const d = new Date(ms);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
const _calcDurationMin = (startedAt?: string | number, endedAt?: string | number): string => {
if (!startedAt) return '-';
const startMs = typeof startedAt === 'number' ? startedAt * 1000 : Date.parse(String(startedAt));
if (Number.isNaN(startMs)) return '-';
const endMs = endedAt
? (typeof endedAt === 'number' ? endedAt * 1000 : Date.parse(String(endedAt)))
: Date.now();
if (Number.isNaN(endMs)) return '-';
const mins = Math.round((endMs - startMs) / 60000);
return `${mins} min`;
};
const _sortedSessions = (sessions: TeamsbotSession[]): TeamsbotSession[] =>
[...sessions].sort((a, b) => {
const ta = a.startedAt ? (typeof a.startedAt === 'number' ? a.startedAt * 1000 : Date.parse(String(a.startedAt))) : 0;
const tb = b.startedAt ? (typeof b.startedAt === 'number' ? b.startedAt * 1000 : Date.parse(String(b.startedAt))) : 0;
return tb - ta;
});
return (
<div className={styles.modulesContainer}>
<div className={styles.modulesHeader}>
<h2>{t('Meeting-Module')}</h2>
<button
className={styles.btnPrimary}
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant`)}
>
{t('Neues Modul')}
</button>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
<button
type="button"
className={styles.btnSecondary}
onClick={() => setCreateOpen(true)}
>
{t('Modul anlegen')}
</button>
<button
type="button"
className={styles.btnPrimary}
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant`)}
>
{t('Meeting starten')}
</button>
</div>
</div>
{loading && <div className={styles.loading}>{t('Laden...')}</div>}
<div className={styles.modulesList}>
{modules.map(mod => (
<div key={mod.id} className={`${styles.moduleCard} ${expandedId === mod.id ? styles.moduleExpanded : ''}`}>
<div
key={mod.id}
ref={(el) => { moduleRowRefs.current[mod.id] = el; }}
className={`${styles.moduleCard} ${expandedId === mod.id ? styles.moduleExpanded : ''} ${focusModuleId === mod.id ? styles.moduleRowFocused : ''}`}
>
<div className={styles.moduleRow} onClick={() => _toggleExpand(mod.id)}>
<span className={styles.moduleType}>{t(SERIES_TYPE_LABELS[mod.seriesType] || mod.seriesType)}</span>
<span className={styles.moduleTitle}>{mod.title}</span>
<span className={styles.moduleStatus}>{t(STATUS_LABELS[mod.status] || mod.status)}</span>
<span className={styles.moduleStatus}>{t(STATUS_LABELS[mod.status] || mod.status || 'Aktiv')}</span>
<div className={styles.moduleActions}>
<button className={styles.btnPrimary} style={{ padding: '0.3rem 0.7rem', fontSize: '0.8rem' }} onClick={e => {
e.stopPropagation();
@ -127,17 +266,50 @@ export const TeamsbotModulesView: React.FC = () => {
{(moduleSessions[mod.id] || []).length === 0 ? (
<p className={styles.noSessions}>{t('Keine Sitzungen')}</p>
) : (
(moduleSessions[mod.id] || []).map((sess: any) => (
<div
key={sess.id}
className={styles.sessionRow}
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/sessions?sessionId=${sess.id}`)}
>
<span>{sess.botName || 'Bot'}</span>
<span className={styles.sessionStatus}>{sess.status}</span>
<span>{sess.startedAt ? new Date(sess.startedAt * 1000).toLocaleDateString() : '-'}</span>
</div>
))
<table className={styles.sessionTable}>
<thead>
<tr>
<th style={{ width: 32 }}></th>
<th>{t('Datum')}</th>
<th>{t('Dauer')}</th>
<th>{t('Status')}</th>
</tr>
</thead>
<tbody>
{_sortedSessions(moduleSessions[mod.id] || []).map((sess) => (
<tr
key={sess.id}
className={styles.sessionTableRow}
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/sessions?sessionId=${sess.id}`)}
>
<td>
<button
type="button"
className={styles.sessionDeleteBtn}
title={t('Sitzung loeschen')}
onClick={async (e) => {
e.stopPropagation();
try {
await teamsbotApi.deleteSession(instanceId, sess.id);
setModuleSessions((prev) => ({
...prev,
[mod.id]: (prev[mod.id] || []).filter((s) => s.id !== sess.id),
}));
} catch (err) {
console.error('Delete session failed:', err);
}
}}
>
x
</button>
</td>
<td>{_formatSessionDateTime(sess.startedAt)}</td>
<td>{_calcDurationMin(sess.startedAt, sess.endedAt)}</td>
<td><span className={styles.sessionStatus}>{sess.status}</span></td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
@ -145,6 +317,85 @@ export const TeamsbotModulesView: React.FC = () => {
))}
</div>
{createOpen && (
<div className={styles.confirmOverlay}>
<div className={styles.editDialog}>
<h3>{t('Modul anlegen')}</h3>
<label className={styles.label}>{t('Titel')}</label>
<input
type="text"
className={styles.wizardInput}
value={createTitle}
onChange={e => setCreateTitle(e.target.value)}
placeholder={t('z.B. Weekly Standup')}
autoFocus
/>
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Serientyp')}</label>
<select
className={styles.wizardSelect}
value={createSeriesType}
onChange={e => setCreateSeriesType(e.target.value)}
>
{Object.entries(SERIES_TYPE_LABELS).map(([code, lab]) => (
<option key={code} value={code}>{lab}</option>
))}
</select>
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Standard-Meeting-Link')}</label>
<input
type="text"
className={styles.wizardInput}
value={createDefaultLink}
onChange={e => setCreateDefaultLink(e.target.value)}
placeholder="https://teams.microsoft.com/..."
/>
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Standard-Bot-Name')}</label>
<input
type="text"
className={styles.wizardInput}
value={createDefaultBotName}
onChange={e => setCreateDefaultBotName(e.target.value)}
/>
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Avatar-Bild / Video')}</label>
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
<select
className={styles.wizardSelect}
style={{ flex: 1 }}
value={createDefaultAvatarFileId}
onChange={e => setCreateDefaultAvatarFileId(e.target.value)}
onFocus={_refreshMediaFiles}
>
<option value="">{t('Standard (aus Bot-Einstellungen)')}</option>
{mediaFiles.map(f => (
<option key={f.id} value={f.id}>{f.fileName} ({f.mimeType})</option>
))}
</select>
<button type="button" className={styles.btnSmall} disabled={avatarUploading} onClick={() => { setAvatarTarget('create'); avatarInputRef.current?.click(); }} style={{ whiteSpace: 'nowrap' }}>
{avatarUploading && avatarTarget === 'create' ? <FaSpinner className={styles.spinner} /> : null}
{t('Hochladen')}
</button>
</div>
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Ziele')}</label>
<textarea
className={styles.wizardTextarea}
rows={2}
value={createGoals}
onChange={e => setCreateGoals(e.target.value)}
/>
<div className={styles.confirmActions}>
<button type="button" className={styles.btnSecondary} onClick={() => setCreateOpen(false)}>{t('Abbrechen')}</button>
<button
type="button"
className={styles.btnPrimary}
onClick={_handleCreateModule}
disabled={!createTitle.trim() || createSaving}
>
{createSaving ? t('Speichern…') : t('Anlegen')}
</button>
</div>
</div>
</div>
)}
{deleteConfirm && (
<div className={styles.confirmOverlay}>
<div className={styles.confirmDialog}>
@ -167,6 +418,41 @@ export const TeamsbotModulesView: React.FC = () => {
className={styles.wizardInput}
onBlur={e => setEditingModule({ ...editingModule, title: e.target.value })}
/>
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Standard-Meeting-Link')}</label>
<input
type="text"
defaultValue={editingModule.defaultMeetingLink || ''}
className={styles.wizardInput}
placeholder="https://teams.microsoft.com/l/meetup-join/..."
onBlur={e => setEditingModule({ ...editingModule, defaultMeetingLink: e.target.value })}
/>
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Standard-Bot-Name')}</label>
<input
type="text"
defaultValue={editingModule.defaultBotName || ''}
className={styles.wizardInput}
placeholder={t('z.B. AI Assistant')}
onBlur={e => setEditingModule({ ...editingModule, defaultBotName: e.target.value })}
/>
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Avatar-Bild / Video')}</label>
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
<select
className={styles.wizardSelect}
style={{ flex: 1 }}
value={editingModule.defaultAvatarFileId || ''}
onChange={e => setEditingModule({ ...editingModule, defaultAvatarFileId: e.target.value || undefined })}
onFocus={_refreshMediaFiles}
>
<option value="">{t('Standard (aus Bot-Einstellungen)')}</option>
{mediaFiles.map(f => (
<option key={f.id} value={f.id}>{f.fileName} ({f.mimeType})</option>
))}
</select>
<button type="button" className={styles.btnSmall} disabled={avatarUploading} onClick={() => { setAvatarTarget('edit'); avatarInputRef.current?.click(); }} style={{ whiteSpace: 'nowrap' }}>
{avatarUploading && avatarTarget === 'edit' ? <FaSpinner className={styles.spinner} /> : null}
{t('Hochladen')}
</button>
</div>
<textarea
defaultValue={editingModule.goals || ''}
className={styles.wizardTextarea}
@ -179,11 +465,22 @@ export const TeamsbotModulesView: React.FC = () => {
<button className={styles.btnPrimary} onClick={() => _handleUpdate(editingModule.id, {
title: editingModule.title,
goals: editingModule.goals,
defaultMeetingLink: (editingModule.defaultMeetingLink || '').trim(),
defaultBotName: (editingModule.defaultBotName || '').trim(),
defaultAvatarFileId: (editingModule.defaultAvatarFileId || '').trim() || undefined,
})}>{t('Speichern')}</button>
</div>
</div>
</div>
)}
<input
ref={avatarInputRef}
type="file"
accept="image/*,video/*"
style={{ display: 'none' }}
onChange={_handleAvatarUpload}
/>
</div>
);
};

View file

@ -10,6 +10,7 @@ import type {
ScreenshotInfo,
DirectorPrompt,
DirectorPromptMode,
MfaChallengeEvent,
} from '../../../api/teamsbotApi';
import {
DIRECTOR_PROMPT_TEXT_LIMIT,
@ -22,6 +23,8 @@ import { useFileContext } from '../../../contexts/FileContext';
import styles from './Teamsbot.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
/**
* TeamsbotSessionView - Live session view with real-time transcript and bot responses.
@ -50,6 +53,7 @@ export const TeamsbotSessionView: React.FC = () => {
const [screenshots, setScreenshots] = useState<ScreenshotInfo[]>([]);
const [screenshotsLoading, setScreenshotsLoading] = useState(false);
const [screenshotsLoaded, setScreenshotsLoaded] = useState(false);
const [screenshotsExpanded, setScreenshotsExpanded] = useState(false);
const [ttsStatusEvents, setTtsStatusEvents] = useState<Array<{
status: string;
message?: string;
@ -59,6 +63,8 @@ export const TeamsbotSessionView: React.FC = () => {
const [agentStatus, setAgentStatus] = useState<{ toolName?: string; status?: string; reason?: string } | null>(null);
const agentStatusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [agentProgressLog, setAgentProgressLog] = useState<Array<{ id: number; text: string; ts: string }>>([]);
const agentProgressIdRef = useRef(0);
const [sessionStats, setSessionStats] = useState<any>(null);
const [reconnectTick, setReconnectTick] = useState(0);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -80,6 +86,10 @@ export const TeamsbotSessionView: React.FC = () => {
// the gateway. Director prompts can only be processed once botConnected=true.)
const [botConnected, setBotConnected] = useState(false);
const [mfaChallenge, setMfaChallenge] = useState<MfaChallengeEvent | null>(null);
const [mfaCode, setMfaCode] = useState('');
const [mfaWaitingPush, setMfaWaitingPush] = useState(false);
// UDB Sidebar state
const [udbCollapsed, setUdbCollapsed] = useState(false);
const [udbTab, setUdbTab] = useState<UdbTab>('files');
@ -305,6 +315,22 @@ export const TeamsbotSessionView: React.FC = () => {
setAgentStatus({ toolName: data.toolName, status: data.status, reason: data.reason });
if (agentStatusTimerRef.current) clearTimeout(agentStatusTimerRef.current);
agentStatusTimerRef.current = setTimeout(() => setAgentStatus(null), 15000);
} else if (data.status === 'toolCall') {
const label = data.displayLabel || data.toolName || '';
setAgentStatus({ toolName: label, status: 'running' });
if (agentStatusTimerRef.current) clearTimeout(agentStatusTimerRef.current);
agentStatusTimerRef.current = setTimeout(() => setAgentStatus(null), 15000);
agentProgressIdRef.current += 1;
const entry = { id: agentProgressIdRef.current, text: `${label}`, ts: new Date().toLocaleTimeString() };
setAgentProgressLog((prev) => [...prev.slice(-19), entry]);
} else if (data.status === 'toolResult') {
const summary = data.summary ? `: ${data.summary.substring(0, 120)}` : '';
agentProgressIdRef.current += 1;
const entry = { id: agentProgressIdRef.current, text: `${data.toolName || ''}${summary}`, ts: new Date().toLocaleTimeString() };
setAgentProgressLog((prev) => [...prev.slice(-19), entry]);
} else if (data.status === 'completed') {
if (agentStatusTimerRef.current) clearTimeout(agentStatusTimerRef.current);
setAgentStatus(null);
} else {
if (agentStatusTimerRef.current) clearTimeout(agentStatusTimerRef.current);
agentStatusTimerRef.current = setTimeout(() => setAgentStatus(null), 2000);
@ -325,6 +351,31 @@ export const TeamsbotSessionView: React.FC = () => {
case 'ping':
break;
case 'mfaChallenge': {
const data = sseEvent.data as MfaChallengeEvent;
if (data.mfaType === 'timeout') {
setMfaChallenge(null);
setMfaWaitingPush(false);
setError(t('MFA-Zeitlimit überschritten, bitte erneut versuchen'));
} else {
setMfaChallenge(data);
setMfaCode('');
setMfaWaitingPush(data.mfaType === 'pushApproval' || data.mfaType === 'numberMatch');
}
break;
}
case 'mfaResolved': {
setMfaChallenge(null);
setMfaWaitingPush(false);
teamsbotApi.getSession(instanceId, sessionId).then((result) => {
setSession(result.session);
if (result.transcripts) setTranscripts(result.transcripts);
if (result.botResponses) setBotResponses(result.botResponses);
}).catch(() => {});
break;
}
}
} catch (err) {
_dlog('SSE-ERR', String(err));
@ -358,8 +409,35 @@ export const TeamsbotSessionView: React.FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [instanceId, sessionId, reconnectTick]);
// Polling fallback: refresh session data every 5s when SSE is not connected.
// Uses isActive (boolean) instead of session object to prevent interval resets.
// Keep session switcher labels in sync when the selected session updates (SSE, poll, etc.).
useEffect(() => {
if (!session?.id) return;
setAllSessions((prev) => {
const idx = prev.findIndex((s) => s.id === session.id);
if (idx < 0) return prev;
const row = prev[idx];
if (
row.status === session.status
&& row.botName === session.botName
&& row.startedAt === session.startedAt
&& row.endedAt === session.endedAt
) {
return prev;
}
const next = [...prev];
next[idx] = {
...next[idx],
status: session.status,
botName: session.botName,
startedAt: session.startedAt,
endedAt: session.endedAt,
};
return next;
});
}, [session?.id, session?.status, session?.botName, session?.startedAt, session?.endedAt]);
// Polling: while joining/pending, poll even if SSE is connected (status may not arrive on stream).
// For active-only, poll only when SSE is down (previous behavior).
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isActive = useMemo(() => session && ['pending', 'joining', 'active'].includes(session.status), [session?.status]);
const isLiveRef = useRef(isLive);
@ -367,22 +445,24 @@ export const TeamsbotSessionView: React.FC = () => {
useEffect(() => {
if (!instanceId || !sessionId) return;
if (!isActive) return;
const intervalMs = session?.status === 'active' ? 5000 : 2500;
pollRef.current = setInterval(async () => {
if (isLiveRef.current) return;
const st = sessionStatusRef.current;
const pollWhileLive = st === 'pending' || st === 'joining';
if (!pollWhileLive && isLiveRef.current) return;
try {
const result = await teamsbotApi.getSession(instanceId, sessionId);
setSession(result.session);
if (result.transcripts) setTranscripts(result.transcripts);
if (result.botResponses) setBotResponses(result.botResponses);
// If session became active and SSE is dead, trigger reconnect
const newStatus = result.session?.status;
if (newStatus && ['active', 'joining', 'pending'].includes(newStatus) && !eventSourceRef.current) {
setReconnectTick(v => v + 1);
setReconnectTick((v) => v + 1);
}
} catch {}
}, 5000);
} catch { /* ignore */ }
}, intervalMs);
return () => { if (pollRef.current) clearInterval(pollRef.current); };
}, [isActive, instanceId, sessionId]);
}, [isActive, instanceId, sessionId, session?.status]);
// Auto-scroll transcript
useEffect(() => {
@ -398,6 +478,25 @@ export const TeamsbotSessionView: React.FC = () => {
}
};
const _mfaNeedsCodeInput = mfaChallenge?.mfaType === 'totpCode' || mfaChallenge?.mfaType === 'smsCode';
const _handleSubmitMfaCode = async () => {
if (!instanceId || !sessionId) return;
try {
await teamsbotApi.submitMfaCode(
instanceId,
sessionId,
_mfaNeedsCodeInput ? mfaCode : '',
_mfaNeedsCodeInput ? 'code' : 'confirmed',
);
if (!_mfaNeedsCodeInput) {
setMfaWaitingPush(true);
}
} catch (err: any) {
setError(err.message || t('Fehler beim Senden des MFA-Codes'));
}
};
const _formatTime = (timestamp: string) => {
try {
const dt = new Date(timestamp);
@ -632,13 +731,16 @@ export const TeamsbotSessionView: React.FC = () => {
<p style={{ color: 'var(--text-secondary, #666)', textAlign: 'center', maxWidth: 400 }}>
{t('Starte ein neues Meeting ueber den Assistenten oder die Module-Seite.')}
</p>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', justifyContent: 'center' }}>
<button className={styles.btnPrimary}
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant`)}
>{t('Zum Assistenten')}</button>
<button className={styles.btnSecondary}
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/modules`)}
>{t('Zu den Modulen')}</button>
<button className={styles.btnSecondary}
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/dashboard`)}
>{t('Zum Dashboard')}</button>
</div>
</div>
);
@ -650,39 +752,88 @@ export const TeamsbotSessionView: React.FC = () => {
return (
<div className={styles.sessionContainer}>
{/* Session Switcher (if multiple sessions exist) */}
{allSessions.length > 1 && (
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>
{allSessions.map((s) => (
<button
key={s.id}
onClick={() => _switchSession(s.id)}
style={{
padding: '6px 12px',
borderRadius: '6px',
border: s.id === sessionId ? '2px solid #4A90D9' : '1px solid #ddd',
background: s.id === sessionId ? '#EBF3FC' : '#fff',
cursor: 'pointer',
fontSize: '13px',
fontWeight: s.id === sessionId ? 600 : 400,
}}
>
{s.botName}
{['active', 'joining', 'pending'].includes(s.status) && ' (aktiv)'}
{s.status === 'ended' && ' (beendet)'}
</button>
))}
{mfaChallenge && (
<div className={styles.mfaOverlay}>
<div className={styles.mfaDialog}>
<div className={styles.mfaTitle}>{t('Multi-Faktor-Authentifizierung')}</div>
{mfaChallenge.displayNumber && (
<div className={styles.mfaNumber}>{mfaChallenge.displayNumber}</div>
)}
<div className={styles.mfaPrompt}>{mfaChallenge.prompt}</div>
{_mfaNeedsCodeInput ? (
<>
<input
type="text"
className={styles.mfaCodeInput}
placeholder={t('Code eingeben')}
value={mfaCode}
onChange={(e) => setMfaCode(e.target.value)}
autoFocus
onKeyDown={(e) => e.key === 'Enter' && _handleSubmitMfaCode()}
/>
<button type="button" className={styles.startButton} onClick={_handleSubmitMfaCode} disabled={!mfaCode.trim()}>
{t('Bestätigen')}
</button>
</>
) : mfaWaitingPush ? (
<>
<div className={styles.mfaSpinner} />
<p style={{ fontSize: '0.85rem', color: '#888' }}>
{t('Warte auf Bestätigung in der Authenticator-App …')}
</p>
</>
) : (
<button type="button" className={styles.startButton} onClick={_handleSubmitMfaCode}>
{t('Ich habe bestätigt')}
</button>
)}
</div>
</div>
)}
{/* Agent Status Bubble (F-fix-2) */}
{/* Session Switcher */}
{allSessions.length > 1 && (
<div style={{ marginBottom: '12px' }}>
<select
value={sessionId}
onChange={(e) => _switchSession(e.target.value)}
className={styles.sessionSwitcherSelect}
>
{[...allSessions]
.sort((a, b) => {
const ta = a.startedAt ? new Date(a.startedAt).getTime() : 0;
const tb = b.startedAt ? new Date(b.startedAt).getTime() : 0;
return tb - ta;
})
.map((s) => {
const dt = s.startedAt ? new Date(s.startedAt) : null;
const ts = dt && !Number.isNaN(dt.getTime())
? `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')} ${String(dt.getHours()).padStart(2, '0')}:${String(dt.getMinutes()).padStart(2, '0')}`
: '';
const statusTag = ['active', 'joining', 'pending'].includes(s.status)
? ` (${t('aktiv')})`
: s.status === 'ended'
? ` (${t('beendet')})`
: s.status === 'error'
? ` (${t('Fehler')})`
: '';
return (
<option key={s.id} value={s.id}>
{ts ? `${ts}` : ''}{s.botName}{statusTag}
</option>
);
})}
</select>
</div>
)}
{/* Agent Status Bubble + Progress Log */}
{agentStatus && (
<div className={styles.agentStatusBubble}>
<span className={styles.agentStatusDot} />
<span>{t('Agent denkt nach')}{agentStatus.toolName ? `: ${agentStatus.toolName}` : '...'}</span>
</div>
)}
{/* Stats Cards (F-fix-3) */}
{sessionStats && (
<div className={styles.statsCards}>
@ -717,7 +868,7 @@ export const TeamsbotSessionView: React.FC = () => {
{isLive && <span className={styles.liveBadge}>LIVE</span>}
</div>
<div className={styles.sessionControls}>
{['active', 'joining', 'pending'].includes(session.status) && (
{!['ended', 'error', 'leaving'].includes(session.status) && (
<button className={styles.stopButton} onClick={_handleStop}>{t('Sitzung beenden')}</button>
)}
</div>
@ -778,8 +929,12 @@ export const TeamsbotSessionView: React.FC = () => {
{/* Main column */}
<div className={styles.sessionMain}>
{/* Director Prompt Panel (private operator instructions) */}
{['active', 'joining', 'pending'].includes(session.status) && (
{/* Director Prompt Panel (private operator instructions).
Blacklist statt Whitelist: zeigen ausser bei terminal-Status,
damit das Panel nicht waehrend kurzer SSE-Race-Conditions
(Status briefly leer/unbekannt direkt nach Mount) verschwindet
und erst nach Reload wieder auftaucht. */}
{!['ended', 'error', 'leaving'].includes(session.status) && (
<div
className={`${styles.directorPanel} ${directorDragOver ? styles.directorPanelDragOver : ''}`}
onDragEnter={_onDirectorDragEnter}
@ -957,7 +1112,8 @@ export const TeamsbotSessionView: React.FC = () => {
<div className={styles.directorHistoryText}>{p.text}</div>
{p.responseText && (
<div className={styles.directorHistoryText} style={{ opacity: 0.85 }}>
<em>{t('Antwort')}:</em> {p.responseText}
<em>{t('Antwort')}:</em>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{p.responseText}</ReactMarkdown>
</div>
)}
{p.statusMessage && p.status === 'failed' && (
@ -1009,7 +1165,9 @@ export const TeamsbotSessionView: React.FC = () => {
<span className={styles.responseIntent}>{r.detectedIntent}</span>
<span className={styles.responseTime}>{_formatTime(r.timestamp || '')}</span>
</div>
<div className={styles.responseText}>{r.responseText}</div>
<div className={styles.responseText}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{r.responseText || ''}</ReactMarkdown>
</div>
{r.reasoning && (
<div className={styles.responseReasoning}>
<em>{t('Begründung: {text}', { text: r.reasoning })}</em>
@ -1035,17 +1193,40 @@ export const TeamsbotSessionView: React.FC = () => {
{session.summary && (
<div className={styles.summaryCard}>
<h4 className={styles.panelTitle}>{t('Meeting-Zusammenfassung')}</h4>
<div className={styles.summaryText}>{session.summary}</div>
<div className={styles.summaryText}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{session.summary || ''}</ReactMarkdown>
</div>
</div>
)}
{/* TTS Delivery Debug */}
<div className={styles.summaryCard}>
<h4 className={styles.panelTitle}>{t('TTS-Lieferstatus')}</h4>
{/* Agent Progress Log (collapsed by default) */}
<details className={styles.summaryCard}>
<summary className={styles.panelTitle} style={{ cursor: 'pointer' }}>
{t('Agent-Fortschritt')} ({agentProgressLog.length})
</summary>
{agentProgressLog.length === 0 ? (
<div className={styles.emptyState}>{t('Noch keine Agent-Aktivitaet')}</div>
) : (
<div className={styles.agentProgressLog}>
{agentProgressLog.map((entry) => (
<div key={entry.id} className={styles.agentProgressEntry}>
<span className={styles.agentProgressTime}>{entry.ts}</span>
<span className={styles.agentProgressText}>{entry.text}</span>
</div>
))}
</div>
)}
</details>
{/* TTS Delivery Debug (collapsed by default) */}
<details className={styles.summaryCard}>
<summary className={styles.panelTitle} style={{ cursor: 'pointer' }}>
{t('TTS-Lieferstatus')} ({ttsStatusEvents.length})
</summary>
{ttsStatusEvents.length === 0 ? (
<div className={styles.emptyState}>{t('Noch keine TTS-Events')}</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', padding: '0.5rem 0' }}>
{ttsStatusEvents.slice(-10).reverse().map((ev, idx) => (
<div key={`${ev.timestamp}-${idx}`} className={styles.responseMeta}>
<span>{_formatTime(ev.timestamp)}</span>
@ -1056,7 +1237,7 @@ export const TeamsbotSessionView: React.FC = () => {
))}
</div>
)}
</div>
</details>
{/* Debug Log (SSE/Transcript/Chat) */}
<div style={{ position: 'fixed', bottom: 0, right: 0, zIndex: 9999 }}>
@ -1073,62 +1254,77 @@ export const TeamsbotSessionView: React.FC = () => {
)}
</div>
{/* Debug Screenshots (SysAdmin only) */}
{/* Debug Screenshots (SysAdmin only, collapsible) */}
{_isSysAdmin && (
<div className={styles.summaryCard}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<h4 className={styles.panelTitle} style={{ margin: 0 }}>{t('Debug-Screenshots')}</h4>
<button
className={styles.viewButton}
onClick={async () => {
setScreenshotsLoading(true);
try {
const result = await teamsbotApi.listScreenshots(instanceId, session.id);
setScreenshots(result.screenshots || []);
setScreenshotsLoaded(true);
} catch (err: any) {
setScreenshots([]);
setScreenshotsLoaded(true);
} finally {
setScreenshotsLoading(false);
}
}}
disabled={screenshotsLoading}
>
{screenshotsLoading ? t('Laden…') : screenshotsLoaded ? t('Aktualisieren') : t('Screenshots laden')}
</button>
</div>
{screenshotsLoaded && screenshots.length === 0 && (
<div className={styles.emptyState}>{t('Keine Screenshots für diese Sitzung')}</div>
)}
{screenshots.length > 0 && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '12px' }}>
{screenshots.map((s) => {
const imgUrl = teamsbotApi.getScreenshotUrl(instanceId, s.name);
return (
<a
key={s.name}
href={imgUrl}
target="_blank"
rel="noopener noreferrer"
style={{ display: 'block', textDecoration: 'none', color: 'inherit', border: '1px solid #333', borderRadius: '8px', overflow: 'hidden', background: '#1a1a2e' }}
>
<img
src={imgUrl}
alt={s.step}
style={{ width: '100%', height: '140px', objectFit: 'cover', display: 'block' }}
loading="lazy"
/>
<div style={{ padding: '8px', fontSize: '12px' }}>
<div style={{ fontWeight: 600, marginBottom: '2px' }}>{s.step}</div>
<div style={{ color: '#888' }}>
{new Date(s.timestamp).toLocaleTimeString('de-CH')} {(s.sizeBytes / 1024).toFixed(0)} KB
</div>
</div>
</a>
);
})}
<div
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer' }}
onClick={() => setScreenshotsExpanded((v) => !v)}
>
<h4 className={styles.panelTitle} style={{ margin: 0 }}>
{screenshotsExpanded ? '\u25BC' : '\u25B6'} {t('Debug-Screenshots')}
{screenshotsLoaded && screenshots.length > 0 && ` (${screenshots.length})`}
</h4>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
{screenshotsExpanded && (
<button
className={styles.viewButton}
onClick={async (e) => {
e.stopPropagation();
setScreenshotsLoading(true);
try {
const result = await teamsbotApi.listScreenshots(instanceId, session.id);
setScreenshots(result.screenshots || []);
setScreenshotsLoaded(true);
} catch (err: any) {
setScreenshots([]);
setScreenshotsLoaded(true);
} finally {
setScreenshotsLoading(false);
}
}}
disabled={screenshotsLoading}
>
{screenshotsLoading ? t('Laden…') : screenshotsLoaded ? t('Aktualisieren') : t('Screenshots laden')}
</button>
)}
</div>
</div>
{screenshotsExpanded && (
<>
{screenshotsLoaded && screenshots.length === 0 && (
<div className={styles.emptyState} style={{ marginTop: '12px' }}>{t('Keine Screenshots für diese Sitzung')}</div>
)}
{screenshots.length > 0 && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '12px', marginTop: '12px' }}>
{screenshots.map((s) => {
const imgUrl = teamsbotApi.getScreenshotUrl(instanceId, s.name);
return (
<a
key={s.name}
href={imgUrl}
target="_blank"
rel="noopener noreferrer"
style={{ display: 'block', textDecoration: 'none', color: 'inherit', border: '1px solid #333', borderRadius: '8px', overflow: 'hidden', background: '#1a1a2e' }}
>
<img
src={imgUrl}
alt={s.step}
style={{ width: '100%', height: '140px', objectFit: 'cover', display: 'block' }}
loading="lazy"
/>
<div style={{ padding: '8px', fontSize: '12px' }}>
<div style={{ fontWeight: 600, marginBottom: '2px' }}>{s.step}</div>
<div style={{ color: '#888' }}>
{new Date(s.timestamp).toLocaleTimeString('de-CH')} {(s.sizeBytes / 1024).toFixed(0)} KB
</div>
</div>
</a>
);
})}
</div>
)}
</>
)}
</div>
)}

View file

@ -1,11 +1,12 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import * as teamsbotApi from '../../../api/teamsbotApi';
import type { TeamsbotConfig, ConfigUpdateRequest, VoiceOption, SystemBot } from '../../../api/teamsbotApi';
import type { TeamsbotConfig, ConfigUpdateRequest, VoiceOption, SystemBot, MediaFileInfo } from '../../../api/teamsbotApi';
import type { VoiceLanguage } from '../../../api/voiceCatalogApi';
import { FaPlay, FaSpinner, FaTrash } from 'react-icons/fa';
import styles from './Teamsbot.module.css';
import { getUserDataCache } from '../../../utils/userCache';
import { useFileContext } from '../../../contexts/FileContext';
import { useLanguage } from '../../../providers/language/LanguageContext';
@ -45,16 +46,24 @@ export const TeamsbotSettingsView: React.FC = () => {
const [voices, setVoices] = useState<VoiceOption[]>([]);
const [loadingVoices, setLoadingVoices] = useState(false);
// Media files for avatar picker
const [mediaFiles, setMediaFiles] = useState<MediaFileInfo[]>([]);
const fileCtx = useFileContext();
const avatarInputRef = useRef<HTMLInputElement | null>(null);
const [avatarUploading, setAvatarUploading] = useState(false);
const _loadConfig = useCallback(async () => {
if (!instanceId) return;
try {
setLoading(true);
// Load per-user settings (merged with instance defaults)
const [settingsResult, languagesResult] = await Promise.all([
const [settingsResult, languagesResult, mediaResult] = await Promise.all([
teamsbotApi.getUserSettings(instanceId),
teamsbotApi.fetchLanguages(),
teamsbotApi.listMediaFiles().catch(() => [] as MediaFileInfo[]),
]);
setMediaFiles(mediaResult);
const effectiveConfig = settingsResult.effectiveConfig;
setConfig(effectiveConfig);
setFormData(effectiveConfig);
@ -117,6 +126,35 @@ export const TeamsbotSettingsView: React.FC = () => {
}
};
const _refreshMediaFiles = useCallback(async () => {
const result = await teamsbotApi.listMediaFiles().catch(() => [] as MediaFileInfo[]);
setMediaFiles(result);
}, []);
const _handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !fileCtx?.handleFileUpload) return;
setAvatarUploading(true);
try {
const result = await fileCtx.handleFileUpload(file);
if (result?.success) {
const data: any = (result.fileData as any)?.file || result.fileData;
const id = data?.id || (result.fileData as any)?.id;
if (id) {
_updateField('avatarFileId', id);
const refreshed = await teamsbotApi.listMediaFiles().catch(() => [] as MediaFileInfo[]);
setMediaFiles(refreshed);
}
}
} catch (err: any) {
setError(err.message || t('Upload fehlgeschlagen'));
setTimeout(() => setError(null), 3000);
} finally {
setAvatarUploading(false);
if (avatarInputRef.current) avatarInputRef.current.value = '';
}
};
const _handleTestVoice = async () => {
if (!instanceId) return;
setTestingVoice(true);
@ -210,6 +248,48 @@ export const TeamsbotSettingsView: React.FC = () => {
Default-Name fuer den Bot im Meeting. Falls keiner angegeben, wird der Name des System-Bots verwendet (z.B. "Nyla Larsson").
</span>
</div>
<div className={styles.formGroup}>
<label className={styles.label}>{t('Avatar-Bild / Video')}</label>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<select
className={styles.select}
style={{ flex: 1 }}
value={formData.avatarFileId || ''}
onChange={(e) => _updateField('avatarFileId', e.target.value || undefined)}
onFocus={_refreshMediaFiles}
>
<option value="">{t('Standard (statische Farbfläche)')}</option>
{mediaFiles.map(f => (
<option key={f.id} value={f.id}>
{f.fileName} ({f.mimeType})
</option>
))}
</select>
<input
ref={avatarInputRef}
type="file"
accept="image/*,video/*"
style={{ display: 'none' }}
onChange={_handleAvatarUpload}
/>
<button
type="button"
className={styles.testButton || styles.saveButton}
onClick={() => avatarInputRef.current?.click()}
disabled={avatarUploading}
style={{ minWidth: '44px', padding: '8px 12px', whiteSpace: 'nowrap' }}
>
{avatarUploading ? <FaSpinner className={styles.spinner} /> : null}
{avatarUploading ? t('Laden...') : t('Hochladen')}
</button>
</div>
<span className={styles.hint}>
{mediaFiles.length === 0
? t('Noch keine Bild-/Video-Dateien vorhanden. Lade ein Bild oder Video hoch.')
: t('Bild oder Video, das als Bot-Video im Meeting angezeigt wird.')}
</span>
</div>
</div>
{/* AI Behavior */}

View file

@ -1,107 +0,0 @@
.wrap {
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 1200px;
}
.disclaimer {
font-size: 0.85rem;
line-height: 1.45;
color: var(--text-secondary, #666);
padding: 0.75rem 1rem;
background: var(--bg-secondary, #f5f5f5);
border-radius: 8px;
border: 1px solid var(--border-color, #e8e8e8);
}
.kpiGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.75rem;
}
.kpiCard {
padding: 1rem;
border-radius: 8px;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #e0e0e0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.kpiValue {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary, #1a1a1a);
margin: 0 0 0.25rem;
}
.kpiLabel {
font-size: 0.8rem;
color: var(--text-secondary, #666);
margin: 0;
line-height: 1.3;
}
.chartBlock {
padding: 1rem;
border-radius: 8px;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #e0e0e0);
min-height: 280px;
}
.chartTitle {
font-size: 0.95rem;
font-weight: 600;
margin: 0 0 0.75rem;
color: var(--text-primary, #1a1a1a);
}
.row2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 900px) {
.row2 {
grid-template-columns: 1fr;
}
}
.meta {
font-size: 0.75rem;
color: var(--text-secondary, #888);
margin-top: 0.5rem;
}
.error {
color: #c62828;
padding: 1rem;
}
.recentTable {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.recentTable th {
text-align: left;
font-weight: 600;
color: var(--text-secondary, #666);
padding: 0.5rem 0.75rem;
border-bottom: 2px solid var(--border-color, #e0e0e0);
white-space: nowrap;
}
.recentTable td {
padding: 0.45rem 0.75rem;
border-bottom: 1px solid var(--border-color, #f0f0f0);
color: var(--text-primary, #1a1a1a);
}
.recentTable tbody tr:hover {
background: var(--bg-secondary, #fafafa);
}

View file

@ -1,352 +0,0 @@
/**
* WorkspaceRagInsightsPage Aggregierte, nicht personenbezogene Kennzahlen zum
* Knowledge Store / RAG dieser Workspace-Instanz (Präsentationen, Monitoring).
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
ResponsiveContainer,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
BarChart,
Bar,
PieChart,
Pie,
Cell,
} from 'recharts';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi';
import styles from './WorkspaceRagInsightsPage.module.css';
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
import { useLanguage } from '../../../providers/language/LanguageContext';
function _mimeLabel(key: string, t: (k: string) => string): string {
switch (key) {
case 'pdf': return t('PDF');
case 'office_doc': return t('Office (Text)');
case 'office_sheet': return t('Office (Tabellen)');
case 'office_slides': return t('Office (Folien)');
case 'text': return t('Text');
case 'image': return t('Bild');
case 'html': return t('HTML');
case 'other': return t('Sonstige');
default: return key;
}
}
const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828'];
function _formatTimestamp(ts: number | null | undefined): string {
if (ts == null || ts <= 0) return '';
try {
const d = new Date(ts * 1000);
return d.toLocaleString('de-CH', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
} catch {
return '';
}
}
function _shortMime(mime: string): string {
const m = (mime || '').toLowerCase();
if (m.includes('pdf')) return 'PDF';
if (m.includes('wordprocessing') || m.includes('msword')) return 'Word';
if (m.includes('spreadsheet') || m.includes('excel')) return 'Excel';
if (m.includes('presentation') || m.includes('powerpoint')) return 'PowerPoint';
if (m.startsWith('text/')) return 'Text';
if (m.startsWith('image/')) return 'Bild';
if (m.includes('html')) return 'HTML';
return mime || '';
}
const _STATUS_COLORS: Record<string, string> = {
indexed: '#2e7d32',
extracted: '#1565c0',
embedding: '#6a1b9a',
pending: '#e65100',
failed: '#c62828',
};
interface RagKpis {
indexedDocuments: number;
indexedBytesTotal: number;
contributorUsers: number;
contentChunks: number;
chunksWithEmbedding: number;
embeddingCoveragePercent: number;
workflowEntities: number;
}
interface RecentlyIndexedDoc {
fileName: string;
mimeType: string;
status: string;
extractedAt: number | null;
totalSize: number;
}
interface RagStatsResponse {
error?: string;
scope?: {
featureInstanceId?: string;
mandateScopedShared?: boolean;
workspaceFileIdsResolved?: number;
};
kpis?: RagKpis;
indexedDocumentsByStatus?: Record<string, number>;
documentsByMimeCategory?: Record<string, number>;
chunksByContentType?: Record<string, number>;
timelineIndexedDocuments?: Array<{ date: string; indexedDocuments: number }>;
recentlyIndexedDocuments?: RecentlyIndexedDoc[];
generatedAtUtc?: string;
}
export const WorkspaceRagInsightsPage: React.FC = () => {
const { t } = useLanguage();
const instanceId = useInstanceId();
const { request } = useApiRequest();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [stats, setStats] = useState<RagStatsResponse | null>(null);
const load = useCallback(async () => {
if (!instanceId) return;
setLoading(true);
setError(null);
try {
const data = (await request({
url: `/api/workspace/${instanceId}/rag-statistics`,
method: 'get',
})) as RagStatsResponse;
if (data?.error) {
setError(String(data.error));
setStats(null);
} else {
setStats(data ?? null);
}
} catch (e) {
setError(e instanceof Error ? e.message : t('Laden fehlgeschlagen'));
setStats(null);
} finally {
setLoading(false);
}
}, [instanceId, request, t]);
useEffect(() => {
void load();
}, [load]);
if (!instanceId) {
return (
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
{t('Keine Workspace-Instanz ausgewählt.')}
</div>
);
}
if (loading) {
return <div className={styles.wrap} style={{ padding: 24 }}>{t('Lade Kennzahlen')}</div>;
}
if (error) {
return <div className={styles.error}>{error}</div>;
}
const kpis = stats?.kpis;
const timeline = stats?.timelineIndexedDocuments ?? [];
const mimeRows = Object.entries(stats?.documentsByMimeCategory ?? {}).map(([key, value]) => ({
name: _mimeLabel(key, t),
value,
}));
const statusRows = Object.entries(stats?.indexedDocumentsByStatus ?? {}).map(([name, value]) => ({
name,
value,
}));
const chunkTypeRows = Object.entries(stats?.chunksByContentType ?? {}).map(([name, value]) => ({
name,
value,
}));
return (
<div className={styles.wrap}>
<p className={styles.disclaimer}>
{t(
'Dargestellt sind ausschliesslich aggregierte technische Masszahlen dieser Instanz (Anzahl Dokumente, Fragmente, Speicherumfang, Verteilungen). Es werden keine Inhalte, Dateinamen oder personenbezogene Angaben ausgewiesen. Geeignet für interne Berichte und Präsentationen.',
)}
</p>
{stats?.scope?.workspaceFileIdsResolved !== undefined && (
<p className={styles.meta} style={{ marginTop: 0 }}>
{t(
'Zuordnung Knowledge ↔ Dateien: {workspaceFileIdsResolved} Datei-ID(s) mit dieser Feature-Instanz in der Dateiverwaltung. Neu indexierte Uploads erhalten die Instanz automatisch; ältere Einträge ohne Zuordnung erscheinen erst nach erneuter Indexierung.',
{ workspaceFileIdsResolved: stats.scope.workspaceFileIdsResolved },
)}
</p>
)}
{kpis && (
<div className={styles.kpiGrid}>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{kpis.indexedDocuments}</p>
<p className={styles.kpiLabel}>{t('Indexierte Dokumente')}</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{formatBinaryDataSizeBytes(kpis.indexedBytesTotal)}</p>
<p className={styles.kpiLabel}>{t('Indexiertes Datenvolumen (geschätzt)')}</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{kpis.contentChunks}</p>
<p className={styles.kpiLabel}>{t('Inhaltsfragmente (Chunks)')}</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>
{kpis.embeddingCoveragePercent}%
</p>
<p className={styles.kpiLabel}>{t('Anteil Fragmente mit Embedding')}</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{kpis.contributorUsers}</p>
<p className={styles.kpiLabel}>{t('Beitragende Benutzeranzahl')}</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{kpis.workflowEntities}</p>
<p className={styles.kpiLabel}>{t('Workflowentitäten-Cache')}</p>
</div>
</div>
)}
{(stats?.recentlyIndexedDocuments ?? []).length > 0 && (
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('Zuletzt indexierte Dokumente')}</h3>
<div style={{ overflowX: 'auto' }}>
<table className={styles.recentTable}>
<thead>
<tr>
<th>{t('Dateiname')}</th>
<th>{t('Format')}</th>
<th>{t('Grösse')}</th>
<th>{t('Status')}</th>
<th>{t('Indexiert am')}</th>
</tr>
</thead>
<tbody>
{(stats?.recentlyIndexedDocuments ?? []).map((doc, i) => (
<tr key={i}>
<td title={doc.fileName} style={{ maxWidth: 280, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{doc.fileName || ''}
</td>
<td>{_shortMime(doc.mimeType)}</td>
<td style={{ whiteSpace: 'nowrap' }}>{formatBinaryDataSizeBytes(doc.totalSize)}</td>
<td>
<span style={{
color: _STATUS_COLORS[doc.status] ?? '#666',
fontWeight: 500,
}}>
{doc.status}
</span>
</td>
<td style={{ whiteSpace: 'nowrap' }}>{_formatTimestamp(doc.extractedAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('Neu indexierte Dokumente pro Tag')}</h3>
{timeline.length === 0 ? (
<p className={styles.meta}>{t('Keine Zeitreihendaten für den gewählten')}</p>
) : (
<ResponsiveContainer width="100%" height={240}>
<LineChart data={timeline}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
<Tooltip />
<Line type="monotone" dataKey="indexedDocuments" name={t('Dokumente')} stroke="#1976d2" dot={false} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
)}
</div>
<div className={styles.row2}>
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('Dokumente nach Formatkategorie')}</h3>
{mimeRows.length === 0 ? (
<p className={styles.meta}>{t('Keine Daten')}</p>
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={mimeRows} layout="vertical" margin={{ left: 8, right: 16 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" allowDecimals={false} />
<YAxis type="category" dataKey="name" width={120} tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="value" name={t('Anzahl')} fill="#00897b" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('Index-Status')}</h3>
{statusRows.length === 0 ? (
<p className={styles.meta}>{t('Keine Daten')}</p>
) : (
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie
data={statusRows}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={88}
label={({ name, percent }) =>
`${name ?? ''} ${percent != null ? (percent * 100).toFixed(0) : '0'}%`}
>
{statusRows.map((_, i) => (
<Cell key={`st-${i}`} fill={CHART_COLORS[i % CHART_COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
)}
</div>
</div>
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('Fragmente nach Inhaltstyp')}</h3>
{chunkTypeRows.length === 0 ? (
<p className={styles.meta}>{t('Keine Chunkdaten')}</p>
) : (
<ResponsiveContainer width="100%" height={240}>
<BarChart data={chunkTypeRows}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="value" name={t('Fragmente')} fill="#6a1b9a" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
{stats?.generatedAtUtc && (
<p className={styles.meta}>
{t('Stand (UTC):')} {stats.generatedAtUtc}
</p>
)}
</div>
);
};

View file

@ -246,7 +246,7 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
label: 'Teams Bot',
icon: 'headset_mic',
views: [
{ code: 'dashboard', label: 'Übersicht', path: 'dashboard' },
{ code: 'dashboard', label: 'Dashboard', path: 'dashboard' },
{ code: 'assistant', label: 'Assistent', path: 'assistant' },
{ code: 'modules', label: 'Module', path: 'modules' },
{ code: 'sessions', label: 'Live-Session', path: 'sessions' },
@ -295,7 +295,6 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
views: [
{ code: 'dashboard', label: 'Dashboard', path: 'dashboard' },
{ code: 'editor', label: 'Editor', path: 'editor' },
{ code: 'rag-insights', label: 'Wissens-Insights', path: 'rag-insights' },
{ code: 'settings', label: 'Einstellungen', path: 'settings' },
]
},