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: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v6
with: with:
node-version: '20' node-version: '20'
cache: 'npm' cache: 'npm'

View file

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

View file

@ -185,7 +185,10 @@
</div> </div>
<div class="footer"> <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>
</div> </div>
</body> </body>

View file

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

View file

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

View file

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

View file

@ -6,17 +6,11 @@ import { ApiRequestOptions } from '../hooks/useApi';
export interface KnowledgePreferences { export interface KnowledgePreferences {
schemaVersion?: number; schemaVersion?: number;
neutralizeBeforeEmbed?: boolean;
mailContentDepth?: 'metadata' | 'snippet' | 'full'; mailContentDepth?: 'metadata' | 'snippet' | 'full';
mailIndexAttachments?: boolean; mailIndexAttachments?: boolean;
filesIndexBinaries?: boolean; filesIndexBinaries?: boolean;
mimeAllowlist?: string[];
clickupScope?: 'titles' | 'title_description' | 'with_comments'; clickupScope?: 'titles' | 'title_description' | 'with_comments';
clickupIndexAttachments?: boolean; clickupIndexAttachments?: boolean;
surfaceToggles?: {
google?: { gmail?: boolean; drive?: boolean };
msft?: { sharepoint?: boolean; outlook?: boolean };
};
maxAgeDays?: number; 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; triggerCooldownSeconds: number;
contextWindowSegments: number; contextWindowSegments: number;
debugMode?: boolean; debugMode?: boolean;
avatarFileId?: string;
} }
export interface TeamsbotSessionStats { export interface TeamsbotSessionStats {
@ -84,6 +85,7 @@ export interface TeamsbotSessionStats {
export interface StartSessionRequest { export interface StartSessionRequest {
meetingLink: string; meetingLink: string;
botName?: string; botName?: string;
moduleId?: string;
connectionId?: string; connectionId?: string;
joinMode?: TeamsbotJoinMode; joinMode?: TeamsbotJoinMode;
sessionContext?: string; sessionContext?: string;
@ -102,6 +104,7 @@ export interface ConfigUpdateRequest {
triggerCooldownSeconds?: number; triggerCooldownSeconds?: number;
contextWindowSegments?: number; contextWindowSegments?: number;
debugMode?: boolean; debugMode?: boolean;
avatarFileId?: string;
} }
// Voice option type re-exported from the central voice catalog API // 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 }); 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) // Debug Screenshots (SysAdmin only)
// ========================================================================= // =========================================================================
@ -592,6 +602,9 @@ export interface MeetingModule {
defaultDirectorPrompts?: string; defaultDirectorPrompts?: string;
goals?: string; goals?: string;
kpiTargets?: string; kpiTargets?: string;
defaultMeetingLink?: string;
defaultBotName?: string;
defaultAvatarFileId?: string;
status: string; status: string;
} }
@ -602,6 +615,7 @@ export async function listModules(instanceId: string): Promise<MeetingModule[]>
export async function createModule(instanceId: string, body: { export async function createModule(instanceId: string, body: {
title: string; seriesType?: string; defaultBotId?: string; goals?: string; kpiTargets?: string; title: string; seriesType?: string; defaultBotId?: string; goals?: string; kpiTargets?: string;
defaultMeetingLink?: string; defaultBotName?: string; defaultAvatarFileId?: string;
}): Promise<MeetingModule> { }): Promise<MeetingModule> {
const response = await api.post(`/api/teamsbot/${instanceId}/modules`, body); const response = await api.post(`/api/teamsbot/${instanceId}/modules`, body);
return response.data?.module; 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> { export async function deleteModule(instanceId: string, moduleId: string): Promise<void> {
await api.delete(`/api/teamsbot/${instanceId}/modules/${moduleId}`); 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) */ /* Connector grid (Step 0) */
.connectorGrid { .connectorGrid {
display: flex; display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 1rem; gap: 1rem;
flex-wrap: wrap;
} }
.connectorCard { .connectorCard {
flex: 1 1 140px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -447,6 +446,22 @@
cursor: not-allowed; 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 */ /* Dark theme */
:global(.dark-theme) .connectorCard { :global(.dark-theme) .connectorCard {
background: var(--surface-color); background: var(--surface-color);

View file

@ -1,153 +1,52 @@
/** /**
* AddConnectionWizard * AddConnectionWizard
* *
* Multi-step modal for adding a new connector with optional knowledge * Streamlined multi-step modal for adding a new connector.
* ingestion consent and per-connection preferences (§2.6). * Steps are connector-type-aware:
* * Base: Connector Consent Connect
* Steps: * Microsoft: Connector Consent Admin Consent (optional) Connect
* 0 Connector wählen * Infomaniak: Connector Consent PAT Input (done)
* 1 Consent (Wissensdatenbank Ja/Nein)
* 2 Präferenzen (nur wenn Ja)
* 3 Zusammenfassung + OAuth starten
*/ */
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Modal } from '../UiComponents/Modal/Modal'; import { Modal } from '../UiComponents/Modal/Modal';
import { FaGoogle, FaMicrosoft, FaTasks, FaDatabase, FaShieldAlt, FaCheck, FaArrowRight, FaInfoCircle } from 'react-icons/fa'; import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaCheck, FaArrowRight, FaShieldAlt } from 'react-icons/fa';
import type { KnowledgePreferences } from '../../api/connectionApi';
import styles from './AddConnectionWizard.module.css'; import styles from './AddConnectionWizard.module.css';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export type ConnectorType = 'google' | 'msft' | 'clickup'; export type ConnectorType = 'google' | 'msft' | 'clickup' | 'infomaniak';
type StepId = 'connector' | 'consent' | 'msftAdminConsent' | 'infomaniakPat' | 'connect';
interface WizardState { interface WizardState {
step: 0 | 1 | 2 | 3; currentStep: StepId;
connector: ConnectorType | null; connector: ConnectorType | null;
knowledgeEnabled: boolean; 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> = { const CONNECTOR_LABELS: Record<ConnectorType, string> = {
google: 'Google', google: 'Google',
msft: 'Microsoft 365', msft: 'Microsoft 365',
clickup: 'ClickUp', clickup: 'ClickUp',
infomaniak: 'Infomaniak',
}; };
const CONNECTOR_ICONS: Record<ConnectorType, React.ReactNode> = { const CONNECTOR_ICONS: Record<ConnectorType, React.ReactNode> = {
google: <FaGoogle style={{ color: '#4285f4' }} />, google: <FaGoogle style={{ color: '#4285f4' }} />,
msft: <FaMicrosoft style={{ color: '#00a4ef' }} />, msft: <FaMicrosoft style={{ color: '#00a4ef' }} />,
clickup: <FaTasks style={{ color: '#7b68ee' }} />, clickup: <FaTasks style={{ color: '#7b68ee' }} />,
infomaniak: <FaCloud style={{ color: '#0098db' }} />,
}; };
// --------------------------------------------------------------------------- function _getSteps(connector: ConnectorType | null): StepId[] {
// Cost estimate helper if (connector === 'msft') return ['connector', 'consent', 'msftAdminConsent', 'connect'];
// --------------------------------------------------------------------------- if (connector === 'infomaniak') return ['connector', 'consent', 'infomaniakPat'];
return ['connector', 'consent', 'connect'];
/**
* 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).',
};
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -157,11 +56,9 @@ function computeCostEstimate(
interface AddConnectionWizardProps { interface AddConnectionWizardProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onConnect: ( onConnect: (type: ConnectorType, knowledgeEnabled: boolean) => Promise<void>;
type: ConnectorType, onInfomaniakConnect?: (token: string, knowledgeEnabled: boolean) => Promise<void>;
knowledgeEnabled: boolean, onMsftAdminConsent?: () => void;
prefs: KnowledgePreferences | null,
) => Promise<void>;
isConnecting?: boolean; isConnecting?: boolean;
} }
@ -173,84 +70,91 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
open, open,
onClose, onClose,
onConnect, onConnect,
onInfomaniakConnect,
onMsftAdminConsent,
isConnecting = false, isConnecting = false,
}) => { }) => {
const [state, setState] = useState<WizardState>({ const [state, setState] = useState<WizardState>({
step: 0, currentStep: 'connector',
connector: null, connector: null,
knowledgeEnabled: false, knowledgeEnabled: false,
prefs: { ...DEFAULT_PREFS }, infomaniakToken: '',
adminConsentDone: false,
}); });
const reset = () => const reset = () =>
setState({ step: 0, connector: null, knowledgeEnabled: false, prefs: { ...DEFAULT_PREFS } }); setState({ currentStep: 'connector', connector: null, knowledgeEnabled: false, infomaniakToken: '', adminConsentDone: false });
const handleClose = () => { const handleClose = () => { reset(); onClose(); };
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 goBack = () => {
const setConnector = (connector: ConnectorType) => const prevIdx = stepIndex - 1;
setState(s => ({ ...s, connector, step: 1 })); if (prevIdx >= 0) {
const setKnowledgeEnabled = (v: boolean) => setState(s => ({ ...s, currentStep: steps[prevIdx] }));
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 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; if (!state.connector) return;
await onConnect( if (state.connector === 'infomaniak' && onInfomaniakConnect) {
state.connector, await onInfomaniakConnect(state.infomaniakToken, state.knowledgeEnabled);
state.knowledgeEnabled, } else {
state.knowledgeEnabled ? state.prefs : null, await onConnect(state.connector, state.knowledgeEnabled);
); }
reset(); reset();
onClose(); onClose();
}; };
const visibleSteps = state.knowledgeEnabled
? [0, 1, 2, 3]
: [0, 1, 3];
return ( return (
<Modal <Modal open={open} onClose={handleClose} title="Verbindung hinzufügen" size="md" closeOnEscape>
open={open}
onClose={handleClose}
title="Verbindung hinzufügen"
size="md"
closeOnEscape
>
{/* Stepper */} {/* Stepper */}
<div className={styles.stepper}> <div className={styles.stepper}>
{[0, 1, 2, 3].map(i => ( {steps.map((s, i) => (
<div <div
key={i} key={s}
className={[ className={[
styles.stepDot, styles.stepDot,
state.step === i ? styles.stepDotActive : '', stepIndex === i ? styles.stepDotActive : '',
state.step > i ? styles.stepDotDone : '', stepIndex > i ? styles.stepDotDone : '',
!visibleSteps.includes(i) ? styles.stepDotHidden : '',
].join(' ')} ].join(' ')}
> >
{state.step > i ? <FaCheck size={10} /> : i + 1} {stepIndex > i ? <FaCheck size={10} /> : i + 1}
</div> </div>
))} ))}
</div> </div>
<div className={styles.body}> <div className={styles.body}>
{/* ---- Step 0: Connector ---- */} {/* ---- Step: Connector ---- */}
{state.step === 0 && ( {state.currentStep === 'connector' && (
<div className={styles.stepContent}> <div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Anbieter wählen</h3> <h3 className={styles.stepTitle}>Anbieter wählen</h3>
<p className={styles.stepHint}>Welchen Dienst möchtest du verbinden?</p> <p className={styles.stepHint}>Welchen Dienst möchtest du verbinden?</p>
<div className={styles.connectorGrid}> <div className={styles.connectorGrid}>
{(['google', 'msft', 'clickup'] as ConnectorType[]).map(type => ( {(['google', 'msft', 'clickup', 'infomaniak'] as ConnectorType[]).map(type => (
<button <button
key={type} key={type}
type="button" type="button"
className={styles.connectorCard} className={styles.connectorCard}
onClick={() => setConnector(type)} onClick={() => selectConnector(type)}
> >
<span className={styles.connectorIcon}>{CONNECTOR_ICONS[type]}</span> <span className={styles.connectorIcon}>{CONNECTOR_ICONS[type]}</span>
<span className={styles.connectorLabel}>{CONNECTOR_LABELS[type]}</span> <span className={styles.connectorLabel}>{CONNECTOR_LABELS[type]}</span>
@ -260,151 +164,103 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
</div> </div>
)} )}
{/* ---- Step 1: Consent ---- */} {/* ---- Step: Consent ---- */}
{state.step === 1 && ( {state.currentStep === 'consent' && (
<div className={styles.stepContent}> <div className={styles.stepContent}>
<div className={styles.consentIcon}><FaDatabase size={32} /></div>
<h3 className={styles.stepTitle}>Wissensdatenbank</h3> <h3 className={styles.stepTitle}>Wissensdatenbank</h3>
<p className={styles.stepBody}> <p className={styles.stepBody}>
Möchtest du Inhalte aus dieser Verbindung in deine persönliche Möchtest du Inhalte aus dieser Verbindung in deine persönliche
Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen
aus{' '} aus {state.connector ? CONNECTOR_LABELS[state.connector] : 'diesem Dienst'} zurückgreifen kann?
{state.connector ? CONNECTOR_LABELS[state.connector] : 'diesem Dienst'}{' '}
zurückgreifen kann?
</p> </p>
<p className={styles.stepHint}> <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> </p>
<div className={styles.consentButtons}> <div className={styles.consentButtons}>
<button <button
type="button" type="button"
className={styles.consentButtonYes} className={styles.consentButtonYes}
onClick={() => setKnowledgeEnabled(true)} onClick={() => { onMsftAdminConsent?.(); setState(s => ({ ...s, adminConsentDone: true })); goNext(); }}
> >
<FaCheck /> Ja, aufnehmen <FaShieldAlt /> Admin-Zustimmung erteilen
</button> </button>
<button <button type="button" className={styles.consentButtonNo} onClick={goNext}>
type="button" Überspringen
className={styles.consentButtonNo}
onClick={() => setKnowledgeEnabled(false)}
>
Nein, überspringen
</button> </button>
</div> </div>
<div className={styles.stepNavLeft}> <div className={styles.stepNavLeft}>
<button type="button" className={styles.navBack} onClick={() => setStep(0)}> <button type="button" className={styles.navBack} onClick={goBack}>Zurück</button>
Zurück
</button>
</div> </div>
</div> </div>
)} )}
{/* ---- Step 2: Preferences ---- */} {/* ---- Step: Infomaniak PAT ---- */}
{state.step === 2 && ( {state.currentStep === 'infomaniakPat' && (
<div className={styles.stepContent}> <div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Einstellungen</h3> <h3 className={styles.stepTitle}>Infomaniak Personal Access Token</h3>
<p className={styles.stepHint}> <p className={styles.stepBody}>
Steuere, welche Inhalte und in welcher Form sie indexiert werden. Erstelle einen Personal Access Token in deinem Infomaniak-Konto und füge ihn hier ein.
</p> </p>
<input
<div className={styles.prefGroup}> type="password"
<label className={styles.prefLabel}> placeholder="pat_..."
<FaShieldAlt className={styles.prefIcon} /> value={state.infomaniakToken}
Anonymisierung vor dem Indexieren onChange={e => setState(s => ({ ...s, infomaniakToken: e.target.value }))}
<input className={styles.patInput}
type="checkbox" autoFocus
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>
<div className={styles.stepNav}> <div className={styles.stepNav}>
<button type="button" className={styles.navBack} onClick={() => setStep(1)}> <button type="button" className={styles.navBack} onClick={goBack}>Zurück</button>
Zurück <button
</button> type="button"
<button type="button" className={styles.navNext} onClick={() => setStep(3)}> className={styles.navConnect}
Weiter <FaArrowRight size={12} /> onClick={handleFinalConnect}
disabled={isConnecting || !state.infomaniakToken.trim()}
>
{isConnecting ? 'Verbinden…' : 'Verbinden'}
{!isConnecting && <FaArrowRight size={12} />}
</button> </button>
</div> </div>
</div> </div>
)} )}
{/* ---- Step 3: Summary ---- */} {/* ---- Step: Connect ---- */}
{state.step === 3 && ( {state.currentStep === 'connect' && (
<div className={styles.stepContent}> <div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Zusammenfassung</h3> <h3 className={styles.stepTitle}>Verbindung herstellen</h3>
<div className={styles.summary}> <div className={styles.summary}>
<div className={styles.summaryRow}> <div className={styles.summaryRow}>
<span className={styles.summaryKey}>Anbieter</span> <span className={styles.summaryKey}>Anbieter</span>
<span className={styles.summaryVal}> <span className={styles.summaryVal}>
{CONNECTOR_ICONS[state.connector!]}&nbsp; {state.connector && CONNECTOR_ICONS[state.connector]}&nbsp;
{state.connector ? CONNECTOR_LABELS[state.connector] : '—'} {state.connector ? CONNECTOR_LABELS[state.connector] : '—'}
</span> </span>
</div> </div>
@ -414,96 +270,13 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
{state.knowledgeEnabled ? '✓ Aktiv' : '✗ Nicht aktiv'} {state.knowledgeEnabled ? '✓ Aktiv' : '✗ Nicht aktiv'}
</span> </span>
</div> </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> </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}> <div className={styles.stepNav}>
<button <button type="button" className={styles.navBack} onClick={goBack}>Zurück</button>
type="button"
className={styles.navBack}
onClick={() => setStep(state.knowledgeEnabled ? 2 : 1)}
>
Zurück
</button>
<button <button
type="button" type="button"
className={styles.navConnect} className={styles.navConnect}
onClick={handleConnect} onClick={handleFinalConnect}
disabled={isConnecting} disabled={isConnecting}
> >
{isConnecting ? 'Verbinden…' : `Mit ${state.connector ? CONNECTOR_LABELS[state.connector] : '…'} verbinden`} {isConnecting ? 'Verbinden…' : `Mit ${state.connector ? CONNECTOR_LABELS[state.connector] : '…'} verbinden`}

View file

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

View file

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

View file

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

View file

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

View file

@ -153,6 +153,51 @@
white-space: nowrap; 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 { .viewBlock {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View file

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

View file

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

View file

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

View file

@ -149,17 +149,15 @@ export function useUserFiles() {
viewKey?: string | null; viewKey?: string | null;
groupField: string; groupField: string;
groupDirection?: 'asc' | 'desc'; 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> = { const pObj: Record<string, unknown> = {
page: 1, page: 1,
pageSize: 25, pageSize: 25,
groupByLevels: [ groupByLevels: levels,
{
field: base.groupField,
nullLabel: '—',
direction: base.groupDirection || 'asc',
},
],
}; };
if (base.search) (pObj as { search?: string }).search = base.search; 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; 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; viewKey?: string | null;
groupField: string; groupField: string;
groupDirection?: 'asc' | 'desc'; 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> = { const pObj: Record<string, unknown> = {
page: 1, page: 1,
pageSize: 25, pageSize: 25,
groupByLevels: [ groupByLevels: levels,
{
field: base.groupField,
nullLabel: '—',
direction: base.groupDirection || 'asc',
},
],
}; };
if (base.search) (pObj as { search?: string }).search = base.search; 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; 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; 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 { export interface VoiceStreamApi {
status: VoiceStreamStatus; status: VoiceStreamStatus;
interimText: string; interimText: string;
start: (language?: string) => Promise<void>; start: (language?: string, sttOpenOptions?: SttStreamOpenOptions) => Promise<void>;
stop: () => void; stop: () => void;
} }
@ -42,6 +49,7 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
const recorderRef = useRef<MediaRecorder | null>(null); const recorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null); const streamRef = useRef<MediaStream | null>(null);
const languageRef = useRef('de-DE'); const languageRef = useRef('de-DE');
const sttOpenOptsRef = useRef<SttStreamOpenOptions | undefined>(undefined);
const stoppingRef = useRef(false); const stoppingRef = useRef(false);
const reconnectAttemptsRef = useRef(0); const reconnectAttemptsRef = useRef(0);
@ -94,11 +102,23 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
stoppingRef.current = false; stoppingRef.current = false;
}, [_stopRecorder, _closeWs, _releaseDevices, _setStatus]); }, [_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; if (status === 'listening' || status === 'connecting') return;
stoppingRef.current = false; stoppingRef.current = false;
reconnectAttemptsRef.current = 0; reconnectAttemptsRef.current = 0;
languageRef.current = language || 'de-DE'; languageRef.current = language || 'de-DE';
sttOpenOptsRef.current = sttOpenOptions;
_setStatus('connecting'); _setStatus('connecting');
try { try {
@ -120,7 +140,7 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
ws.onopen = () => { ws.onopen = () => {
if (stoppingRef.current) { ws.close(); return; } if (stoppingRef.current) { ws.close(); return; }
ws.send(JSON.stringify({ type: 'open', language: languageRef.current })); ws.send(JSON.stringify(_buildOpenPayload()));
const mimeType = _pickMimeType(); const mimeType = _pickMimeType();
const recorder = new MediaRecorder(streamRef.current!, { mimeType }); const recorder = new MediaRecorder(streamRef.current!, { mimeType });
@ -154,11 +174,15 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
cbRef.current.onFinal?.(msg.text); cbRef.current.onFinal?.(msg.text);
} else if (msg.type === 'error') { } else if (msg.type === 'error') {
cbRef.current.onError?.(new Error(msg.message || msg.code || 'STT 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') { } else if (msg.type === 'reconnect_required') {
if (reconnectAttemptsRef.current < _MAX_RECONNECT_ATTEMPTS && !stoppingRef.current) { if (reconnectAttemptsRef.current < _MAX_RECONNECT_ATTEMPTS && !stoppingRef.current) {
reconnectAttemptsRef.current++; reconnectAttemptsRef.current++;
_closeWs(); _closeWs();
start(languageRef.current).catch(() => {}); start(languageRef.current, sttOpenOptsRef.current).catch(() => {});
} }
} }
} catch { /* ignore parse errors */ } } catch { /* ignore parse errors */ }
@ -183,7 +207,7 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
_releaseDevices(); _releaseDevices();
throw err; throw err;
} }
}, [status, _setStatus, _pickMimeType, _closeWs, _releaseDevices]); }, [status, _setStatus, _pickMimeType, _closeWs, _releaseDevices, _buildOpenPayload]);
useEffect(() => { useEffect(() => {
return () => { return () => {

View file

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

View file

@ -35,7 +35,6 @@ import { GraphicalEditorTemplatesPage } from './views/graphicalEditor/GraphicalE
import { WorkspacePage } from './views/workspace/WorkspacePage'; import { WorkspacePage } from './views/workspace/WorkspacePage';
import { WorkspaceEditorPage } from './views/workspace/WorkspaceEditorPage'; import { WorkspaceEditorPage } from './views/workspace/WorkspaceEditorPage';
import { WorkspaceSettingsPage } from './views/workspace/WorkspaceSettingsPage'; import { WorkspaceSettingsPage } from './views/workspace/WorkspaceSettingsPage';
import { WorkspaceRagInsightsPage } from './views/workspace/WorkspaceRagInsightsPage';
// Teamsbot Views // Teamsbot Views
import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView'; import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
@ -155,7 +154,6 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
workspace: { workspace: {
dashboard: WorkspacePage, dashboard: WorkspacePage,
editor: WorkspaceEditorPage, editor: WorkspaceEditorPage,
'rag-insights': WorkspaceRagInsightsPage,
settings: WorkspaceSettingsPage, settings: WorkspaceSettingsPage,
}, },
teamsbot: { teamsbot: {
@ -229,7 +227,7 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
// Workspace dashboard is rendered persistently by WorkspaceKeepAlive at MainLayout level; // Workspace dashboard is rendered persistently by WorkspaceKeepAlive at MainLayout level;
// other workspace views (e.g. settings, editor) use the standard FeatureViewPage rendering. // 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; 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 { AdminLanguagesPage } from './AdminLanguagesPage';
export { AdminDemoConfigPage } from './AdminDemoConfigPage'; export { AdminDemoConfigPage } from './AdminDemoConfigPage';
export { AdminDatabaseHealthPage } from './AdminDatabaseHealthPage'; 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 { useConnections, type Connection } from '../../hooks/useConnections';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaSync, FaLink, FaRedo, FaShieldAlt, FaPlus, FaSpinner, FaTimes, FaSyncAlt, FaCloud } from 'react-icons/fa'; import { FaSync, FaLink, FaRedo, FaPlus, FaSpinner, FaTimes, FaSyncAlt } from 'react-icons/fa';
import { getApiBaseUrl } from '../../../config/config';
import styles from '../admin/Admin.module.css'; import styles from '../admin/Admin.module.css';
import bannerStyles from './ConnectionsPage.module.css'; import bannerStyles from './ConnectionsPage.module.css';
import { AddConnectionWizard } from '../../components/AddConnectionWizard/AddConnectionWizard'; import { AddConnectionWizard } from '../../components/AddConnectionWizard/AddConnectionWizard';
@ -18,6 +17,7 @@ import type { ConnectorType } from '../../components/AddConnectionWizard/AddConn
import type { KnowledgePreferences } from '../../api/connectionApi'; import type { KnowledgePreferences } from '../../api/connectionApi';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { resolveColumnTypes } from '../../utils/columnTypeResolver'; 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 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, deleteConnection,
handleInlineUpdate, handleInlineUpdate,
createConnectionAndAuth, createConnectionAndAuth,
createInfomaniakConnection,
submitInfomaniakToken,
connectWithPopup, connectWithPopup,
refreshMicrosoftToken, refreshMicrosoftToken,
refreshGoogleToken, refreshGoogleToken,
@ -54,7 +52,6 @@ export const ConnectionsPage: React.FC = () => {
const [deletingConnections, setDeletingConnections] = useState<Set<string>>(new Set()); const [deletingConnections, setDeletingConnections] = useState<Set<string>>(new Set());
const [refreshingConnections, setRefreshingConnections] = useState<Set<string>>(new Set()); const [refreshingConnections, setRefreshingConnections] = useState<Set<string>>(new Set());
const [reconnectingConnections, setReconnectingConnections] = useState<Set<string>>(new Set()); const [reconnectingConnections, setReconnectingConnections] = useState<Set<string>>(new Set());
const [adminConsentPending, setAdminConsentPending] = useState(false);
const [wizardOpen, setWizardOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false);
// Banner shown while knowledge bootstrap is running in the background // Banner shown while knowledge bootstrap is running in the background
const [syncBanner, setSyncBanner] = useState<{ const [syncBanner, setSyncBanner] = useState<{
@ -73,15 +70,6 @@ export const ConnectionsPage: React.FC = () => {
setSyncBanner(null); 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 // Initial fetch
useEffect(() => { useEffect(() => {
refetch(); refetch();
@ -228,13 +216,13 @@ export const ConnectionsPage: React.FC = () => {
const handleWizardConnect = async ( const handleWizardConnect = async (
type: ConnectorType, type: ConnectorType,
knowledgeEnabled: boolean, knowledgeEnabled: boolean,
knowledgePreferences: KnowledgePreferences | null, knowledgePreferences?: KnowledgePreferences | null,
) => { ) => {
try { try {
await createConnectionAndAuth(type, knowledgeEnabled, knowledgePreferences); await createConnectionAndAuth(type, knowledgeEnabled, knowledgePreferences ?? null);
refetch(); refetch();
if (knowledgeEnabled) { 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); showSyncBanner(LABELS[type] ?? type);
} }
} catch (error) { } catch (error) {
@ -242,74 +230,9 @@ export const ConnectionsPage: React.FC = () => {
} }
}; };
const handleCreateInfomaniak = async () => { const handleMsftAdminConsent = () => {
if (isConnecting || infomaniakModal) return; const url = `${getApiBaseUrl()}/api/msft/adminconsent`;
try { window.open(url, 'msft-admin-consent', 'width=560,height=720,scrollbars=yes,resizable=yes');
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);
}; };
// Form attributes for edit modal // Form attributes for edit modal
@ -348,14 +271,6 @@ export const ConnectionsPage: React.FC = () => {
</p> </p>
</div> </div>
<div className={styles.headerActions}> <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 <button
className={styles.secondaryButton} className={styles.secondaryButton}
onClick={() => refetch()} onClick={() => refetch()}
@ -364,25 +279,14 @@ export const ConnectionsPage: React.FC = () => {
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')} <FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button> </button>
{canCreate && ( {canCreate && (
<> <button
<button type="button"
type="button" className={styles.primaryButton}
className={styles.primaryButton} onClick={() => setWizardOpen(true)}
onClick={() => setWizardOpen(true)} disabled={isConnecting}
disabled={isConnecting} >
> <FaPlus /> {t('Verbindung hinzufügen')}
<FaPlus /> {t('Verbindung hinzufügen')} </button>
</button>
<button
type="button"
className={styles.secondaryButton}
onClick={handleCreateInfomaniak}
disabled={isConnecting}
title={t('Infomaniak-Konto verbinden (kDrive + Mail)')}
>
<FaCloud /> Infomaniak
</button>
</>
)} )}
</div> </div>
</div> </div>
@ -419,7 +323,7 @@ export const ConnectionsPage: React.FC = () => {
columns={columns} columns={columns}
apiEndpoint="/api/connections/" apiEndpoint="/api/connections/"
tableContextKey="connections" tableContextKey="connections"
tableGroupLayoutMode="sections" tableGroupLayoutMode="inline"
loading={loading} loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}
@ -519,141 +423,11 @@ export const ConnectionsPage: React.FC = () => {
</div> </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 <AddConnectionWizard
open={wizardOpen} open={wizardOpen}
onClose={() => setWizardOpen(false)} onClose={() => setWizardOpen(false)}
onConnect={handleWizardConnect} onConnect={handleWizardConnect}
onMsftAdminConsent={handleMsftAdminConsent}
isConnecting={isConnecting} isConnecting={isConnecting}
/> />
</div> </div>

View file

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

View file

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

View file

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

View file

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

View file

@ -75,10 +75,24 @@ export const CommcoachAssistantView: React.FC = () => {
<div className={styles.assistantContainer}> <div className={styles.assistantContainer}>
<div className={styles.wizardHeader}> <div className={styles.wizardHeader}>
<h2>{t('Neues Modul erstellen')}</h2> <h2>{t('Neues Modul erstellen')}</h2>
<div className={styles.stepIndicator}> <div className={styles.wizardHeaderRight}>
{STEPS.map((s, i) => ( <div className={styles.stepIndicator}>
<div key={s} className={`${styles.stepDot} ${i <= stepIdx ? styles.stepActive : ''}`} /> {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>
</div> </div>
@ -156,19 +170,6 @@ export const CommcoachAssistantView: React.FC = () => {
)} )}
</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={_handleCreate} disabled={loading}>
{loading ? t('Erstelle...') : t('Modul erstellen & erste Session starten')}
</button>
)}
</div>
</div> </div>
); );
}; };

View file

@ -10,7 +10,7 @@
*/ */
import { useState, useRef, useCallback, useEffect } from 'react'; import { useState, useRef, useCallback, useEffect } from 'react';
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture'; import { useVoiceStream, type SttStreamOpenOptions } from '../../../hooks/useSpeechAudioCapture';
import api from '../../../api'; import api from '../../../api';
export type VoiceState = 'idle' | 'listening' | 'botSpeaking' | 'interrupted'; export type VoiceState = 'idle' | 'listening' | 'botSpeaking' | 'interrupted';
@ -35,6 +35,13 @@ export interface VoiceControllerCallbacks {
const _DEFAULT_STT_LANGUAGE = 'de-DE'; 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 { export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceControllerApi {
const [state, setState] = useState<VoiceState>('idle'); const [state, setState] = useState<VoiceState>('idle');
const [muted, setMuted] = useState(false); const [muted, setMuted] = useState(false);
@ -86,7 +93,7 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
}); });
const _startStream = useCallback(() => { const _startStream = useCallback(() => {
return voiceStream.start(sttLanguageRef.current); return voiceStream.start(sttLanguageRef.current, _commcoachSttOpen);
}, [voiceStream]); }, [voiceStream]);
const activate = useCallback(async () => { const activate = useCallback(async () => {

View file

@ -413,15 +413,23 @@
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
padding: 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) ------------------------------- */ /* ----- Session Layout (UDB Sidebar + Main) ------------------------------- */
.sessionLayout { .sessionLayout {
display: flex; display: flex;
flex: 1;
min-height: 0;
gap: 1rem; gap: 1rem;
} }
@ -430,7 +438,6 @@
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
min-height: 0;
gap: 1rem; gap: 1rem;
} }
@ -820,8 +827,6 @@
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 1rem; gap: 1rem;
flex: 1;
min-height: 0;
} }
/* Transcript Panel */ /* Transcript Panel */
@ -833,6 +838,8 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
height: 50vh;
min-height: 250px;
} }
.panelTitle { .panelTitle {
@ -914,6 +921,68 @@
color: var(--text-primary, #333); 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 { .responseReasoning {
margin-top: 0.5rem; margin-top: 0.5rem;
font-size: 0.8rem; font-size: 0.8rem;
@ -941,9 +1010,18 @@
font-size: 0.9rem; font-size: 0.9rem;
line-height: 1.6; line-height: 1.6;
color: var(--text-primary, #333); 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 Settings View
============================================================================ */ ============================================================================ */
@ -1347,6 +1425,36 @@
animation: agentPulse 1s ease-in-out infinite; 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 { .statsCards {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
@ -1376,7 +1484,14 @@
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.assistantContainer, .assistantContainer {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
}
.modulesContainer { .modulesContainer {
padding: 1.5rem; padding: 1.5rem;
display: flex; display: flex;
@ -1390,6 +1505,14 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem;
}
.wizardHeaderRight {
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
} }
.stepIndicator { .stepIndicator {
@ -1409,7 +1532,6 @@
} }
.wizardContent { .wizardContent {
flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@ -1442,9 +1564,8 @@
.wizardActions { .wizardActions {
display: flex; display: flex;
gap: 1rem; gap: 0.5rem;
padding-top: 1rem; align-items: center;
border-top: 1px solid var(--border-color, #e0e0e0);
} }
.moduleChoice { .moduleChoice {
@ -1478,6 +1599,10 @@
border-color: var(--primary-color, #4A90D9); border-color: var(--primary-color, #4A90D9);
} }
.moduleRowFocused {
box-shadow: 0 0 0 2px rgba(74, 144, 217, 0.45);
}
.moduleRow { .moduleRow {
display: flex; display: flex;
align-items: center; align-items: center;
@ -1519,16 +1644,49 @@
border-top: 1px solid var(--border-color, #e0e0e0); border-top: 1px solid var(--border-color, #e0e0e0);
} }
.sessionRow { .sessionTable {
display: flex; border-collapse: collapse;
gap: 1rem; font-size: 0.85rem;
padding: 0.4rem 0;
cursor: pointer;
font-size: 0.9rem;
} }
.sessionRow:hover { .sessionTable th {
color: var(--primary-color, #4A90D9); 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 { .sessionStatus {
@ -1558,7 +1716,7 @@
border: 1px solid var(--border-color, #e0e0e0); border: 1px solid var(--border-color, #e0e0e0);
border-radius: 12px; border-radius: 12px;
padding: 1.5rem; padding: 1.5rem;
max-width: 400px; max-width: 600px;
width: 90%; width: 90%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -1654,3 +1812,222 @@
padding: 2rem; padding: 2rem;
color: var(--text-secondary, #666); 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" * 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 { useNavigate, useSearchParams } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import * as teamsbotApi from '../../../api/teamsbotApi'; 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 { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './Teamsbot.module.css'; import styles from './Teamsbot.module.css';
@ -18,16 +20,26 @@ export const TeamsbotAssistantView: React.FC = () => {
const { instance, mandateId } = useCurrentInstance(); const { instance, mandateId } = useCurrentInstance();
const instanceId = instance?.id || ''; const instanceId = instance?.id || '';
const navigate = useNavigate(); const navigate = useNavigate();
const cachedUser = getUserDataCache();
const isSysAdmin = cachedUser?.isSysAdmin === true;
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const preselectedModuleId = searchParams.get('moduleId'); const preselectedModuleId = searchParams.get('moduleId');
const [step, setStep] = useState<WizardStep>(preselectedModuleId ? 'meeting' : 'module'); 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 [selectedModuleId, setSelectedModuleId] = useState<string | null>(preselectedModuleId);
const [newModuleTitle, setNewModuleTitle] = useState(''); const [newModuleTitle, setNewModuleTitle] = useState('');
const [createNewModule, setCreateNewModule] = useState(false); const [createNewModule, setCreateNewModule] = useState(false);
const [meetingLink, setMeetingLink] = useState(''); const [meetingLink, setMeetingLink] = useState('');
const [botName, setBotName] = useState('AI Assistant'); 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 [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -45,6 +57,33 @@ export const TeamsbotAssistantView: React.FC = () => {
useEffect(() => { _loadModules(); }, [_loadModules]); 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 _handleNext = () => {
const nextIdx = stepIdx + 1; const nextIdx = stepIdx + 1;
if (nextIdx < STEPS.length) setStep(STEPS[nextIdx]); if (nextIdx < STEPS.length) setStep(STEPS[nextIdx]);
@ -60,6 +99,27 @@ export const TeamsbotAssistantView: React.FC = () => {
setError(t('Meeting-Link erforderlich')); setError(t('Meeting-Link erforderlich'));
return; 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); setLoading(true);
setError(null); setError(null);
try { try {
@ -73,7 +133,9 @@ export const TeamsbotAssistantView: React.FC = () => {
meetingLink: meetingLink.trim(), meetingLink: meetingLink.trim(),
botName, botName,
moduleId: moduleId || undefined, moduleId: moduleId || undefined,
} as any); joinMode,
sessionContext: sessionContext.trim() || undefined,
});
navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/sessions?sessionId=${result.session.id}`); navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/sessions?sessionId=${result.session.id}`);
} catch (err: any) { } catch (err: any) {
@ -87,10 +149,32 @@ export const TeamsbotAssistantView: React.FC = () => {
<div className={styles.assistantContainer}> <div className={styles.assistantContainer}>
<div className={styles.wizardHeader}> <div className={styles.wizardHeader}>
<h2>{t('Neues Meeting starten')}</h2> <h2>{t('Neues Meeting starten')}</h2>
<div className={styles.stepIndicator}> <div className={styles.wizardHeaderRight}>
{STEPS.map((s, i) => ( <div className={styles.stepIndicator}>
<div key={s} className={`${styles.stepDot} ${i <= stepIdx ? styles.stepActive : ''}`} /> {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>
</div> </div>
@ -106,16 +190,27 @@ export const TeamsbotAssistantView: React.FC = () => {
{t('Bestehendes Modul')} {t('Bestehendes Modul')}
</label> </label>
{!createNewModule && ( {!createNewModule && (
<select <>
value={selectedModuleId || ''} <input
onChange={e => setSelectedModuleId(e.target.value || null)} type="search"
className={styles.wizardSelect} className={styles.wizardInput}
> placeholder={t('Modul suchen…')}
<option value="">{t('Kein Modul (Adhoc)')}</option> value={moduleFilter}
{modules.map(m => ( onChange={e => setModuleFilter(e.target.value)}
<option key={m.id} value={m.id}>{m.title}</option> aria-label={t('Modul suchen')}
))} />
</select> <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> <label>
<input type="radio" checked={createNewModule} onChange={() => setCreateNewModule(true)} /> <input type="radio" checked={createNewModule} onChange={() => setCreateNewModule(true)} />
@ -136,7 +231,7 @@ export const TeamsbotAssistantView: React.FC = () => {
{step === 'meeting' && ( {step === 'meeting' && (
<div className={styles.wizardStep}> <div className={styles.wizardStep}>
<h3>{t('Meeting-Link')}</h3> <h3>{t('Meeting-Link und Beitritt')}</h3>
<input <input
type="text" type="text"
className={styles.wizardInput} className={styles.wizardInput}
@ -145,6 +240,87 @@ export const TeamsbotAssistantView: React.FC = () => {
onChange={e => setMeetingLink(e.target.value)} onChange={e => setMeetingLink(e.target.value)}
autoFocus 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> </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('Modul')}:</strong> {createNewModule ? newModuleTitle : (modules.find(m => m.id === selectedModuleId)?.title || t('Adhoc'))}</div>
<div><strong>{t('Meeting')}:</strong> {meetingLink}</div> <div><strong>{t('Meeting')}:</strong> {meetingLink}</div>
<div><strong>{t('Bot')}:</strong> {botName}</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>
)} )}
</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> </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 { useNavigate } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import * as teamsbotApi from '../../../api/teamsbotApi'; import * as teamsbotApi from '../../../api/teamsbotApi';
import type { TeamsbotSession, StartSessionRequest, TeamsbotJoinMode, UserAccountStatus, MfaChallengeEvent } from '../../../api/teamsbotApi'; import type { TeamsbotSession, MeetingModule } from '../../../api/teamsbotApi';
import { getUserDataCache } from '../../../utils/userCache';
import styles from './Teamsbot.module.css'; import styles from './Teamsbot.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
/** /**
* TeamsbotDashboardView - Overview of all Teams Bot sessions. * TeamsBot Dashboard IA: KPIs, Modul-Aggregate, Quick-Actions.
* Allows starting new sessions and viewing active/past sessions. * Neues Meeting: Assistent (Wizard). Sessions sind via Module erreichbar.
* Supports "Mein Account" login with saved credentials and MFA relay.
*/ */
export const TeamsbotDashboardView: React.FC = () => { export const TeamsbotDashboardView: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const { instance, mandateId, featureCode } = useCurrentInstance(); const { instance, mandateId, featureCode } = useCurrentInstance();
const instanceId = instance?.id || ''; const instanceId = instance?.id || '';
const navigate = useNavigate(); const navigate = useNavigate();
const cachedUser = getUserDataCache();
const _isSysAdmin = cachedUser?.isSysAdmin === true;
const [sessions, setSessions] = useState<TeamsbotSession[]>([]); const [sessions, setSessions] = useState<TeamsbotSession[]>([]);
const [loading, setLoading] = useState(true); const [modules, setModules] = useState<MeetingModule[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// New session form const dashboardEsRef = useRef<EventSource | null>(null);
const [meetingLink, setMeetingLink] = useState(''); const dashboardReconnectRef = useRef<number | null>(null);
const [botName, setBotName] = useState('');
const [joinMode, setJoinMode] = useState<TeamsbotJoinMode>('anonymous');
const [sessionContext, setSessionContext] = useState('');
const [isStarting, setIsStarting] = useState(false);
// User Account (Mein Account) state const applyDashboardPayload = useCallback((nextSessions: TeamsbotSession[], nextModules: MeetingModule[]) => {
const [userAccount, setUserAccount] = useState<UserAccountStatus | null>(null); setSessions(nextSessions);
const [showCredentialForm, setShowCredentialForm] = useState(false); setModules(nextModules);
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 _startMfaListener = useCallback((sessionId: string) => { useEffect(() => {
sseRef.current?.close(); if (!instanceId) return;
const es = teamsbotApi.createSessionStream(instanceId, sessionId); let cancelled = false;
sseRef.current = es;
setMfaSessionId(sessionId);
es.onmessage = (event) => { const clearReconnect = () => {
try { if (dashboardReconnectRef.current) {
const parsed = JSON.parse(event.data); window.clearTimeout(dashboardReconnectRef.current);
if (parsed.type === 'mfaChallenge') { dashboardReconnectRef.current = null;
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);
} }
setShowCredentialForm(false); };
}
setIsStarting(true); const connect = () => {
setError(null); if (cancelled) return;
dashboardEsRef.current?.close();
const es = teamsbotApi.createDashboardStream(instanceId);
dashboardEsRef.current = es;
try { es.onmessage = (event) => {
const request: StartSessionRequest = { try {
meetingLink: meetingLink.trim(), const msg = JSON.parse(event.data) as {
botName: botName.trim() || undefined, type?: string;
joinMode: joinMode, sessions?: TeamsbotSession[];
sessionContext: sessionContext.trim() || undefined, 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); es.onerror = () => {
const newSessionId = result.session?.id; es.close();
setMeetingLink(''); dashboardEsRef.current = null;
setBotName(''); if (cancelled) return;
clearReconnect();
dashboardReconnectRef.current = window.setTimeout(connect, 2500);
};
};
// Start SSE listener for MFA events if userAccount mode connect();
if (joinMode === 'userAccount' && newSessionId) { return () => {
_startMfaListener(newSessionId); cancelled = true;
} clearReconnect();
dashboardEsRef.current?.close();
dashboardEsRef.current = null;
};
}, [instanceId, applyDashboardPayload]);
await _loadSessions(); const activeSessions = useMemo(
} catch (err: any) { () => sessions.filter((s) => ['pending', 'joining', 'active'].includes(s.status)),
setError(err.message || t('Fehler beim Starten der Sitzung')); [sessions],
} finally { );
setIsStarting(false); const moduleTitleById = useMemo(() => {
} const m = new Map<string, string>();
}; modules.forEach((mod) => m.set(mod.id, mod.title));
return m;
}, [modules]);
const _handleSubmitMfaCode = async () => { const topModules = useMemo(() => {
if (!mfaSessionId || !instanceId) return; const counts = new Map<string, number>();
const needsCode = mfaChallenge?.mfaType === 'totpCode' || mfaChallenge?.mfaType === 'smsCode'; sessions.forEach((s) => {
try { const mid = s.moduleId || '_adhoc';
await teamsbotApi.submitMfaCode( counts.set(mid, (counts.get(mid) || 0) + 1);
instanceId, });
mfaSessionId, const rows = Array.from(counts.entries())
needsCode ? mfaCode : '', .map(([moduleId, sessionCount]) => ({
needsCode ? 'code' : 'confirmed', moduleId,
); sessionCount,
if (!needsCode) { title: moduleId === '_adhoc' ? t('Adhoc / ohne Modul') : (moduleTitleById.get(moduleId) || t('Unbekanntes Modul')),
setMfaWaitingPush(true); }))
} .sort((a, b) => b.sessionCount - a.sessionCount)
} catch (err: any) { .slice(0, 6);
setError(err.message || t('Fehler beim Senden des MFA-Codes')); return rows;
} }, [sessions, moduleTitleById, t]);
};
const _handleDeleteUserAccount = async () => { const totalSegments = useMemo(() => sessions.reduce((acc, s) => acc + (s.transcriptSegmentCount || 0), 0), [sessions]);
try { const totalResponses = useMemo(() => sessions.reduce((acc, s) => acc + (s.botResponseCount || 0), 0), [sessions]);
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 _getStatusBadgeClass = (status: string) => { const _getStatusBadgeClass = (status: string) => {
switch (status) { switch (status) {
@ -240,270 +123,156 @@ export const TeamsbotDashboardView: React.FC = () => {
const _getStatusLabel = (status: string) => { const _getStatusLabel = (status: string) => {
const labels: Record<string, string> = { const labels: Record<string, string> = {
pending: 'Wartend', pending: t('Wartend'),
joining: 'Beitritt...', joining: t('Beitritt…'),
active: 'Aktiv', active: t('Aktiv'),
leaving: 'Verlassen...', leaving: t('Verlassen…'),
ended: 'Beendet', ended: t('Beendet'),
error: 'Fehler', error: t('Fehler'),
}; };
return labels[status] || status; return labels[status] || status;
}; };
const activeSessions = sessions.filter(s => ['pending', 'joining', 'active'].includes(s.status)); const _sessionPath = (sessId: string) =>
const pastSessions = sessions.filter(s => ['ended', 'error', 'leaving'].includes(s.status)); `/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 ( return (
<div className={styles.dashboardContainer}> <div className={styles.tbDash}>
{/* MFA Challenge Dialog */} <header className={styles.tbDashHero}>
{mfaChallenge && ( <div className={styles.tbDashHeroText}>
<div className={styles.mfaOverlay}> <h1 className={styles.tbDashTitle}>{t('Teams Bot')}</h1>
<div className={styles.mfaDialog}> <p className={styles.tbDashSubtitle}>
<div className={styles.mfaTitle}>Multi-Faktor-Authentifizierung</div> {t('Dashboard mit Übersicht, Modulen und Live-Sitzung — neues Meeting über den Assistenten starten.')}
</p>
{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> </div>
)} <div className={styles.tbDashQuickActions}>
<button
{/* Start New Session Card */} type="button"
<div className={styles.startSessionCard}> className={styles.tbDashBtnPrimary}
<h3 className={styles.cardTitle}>{t('Neue Botsitzung starten')}</h3> onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/assistant`)}
<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}
> >
{_isSysAdmin && <option value="systemBot">{t('Systembot authentifiziert')}</option>} {t('Neues Meeting')}
<option value="anonymous">{t('Anonymer Gast')}</option> </button>
<option value="userAccount">{t('Mein Account')}</option> <button
</select> 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> </div>
</header>
{/* 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>
{error && <div className={styles.errorBanner}>{error}</div>} {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 && ( {activeSessions.length > 0 && (
<div className={styles.sectionContainer}> <section className={styles.tbDashSection}>
<h3 className={styles.sectionTitle}>{t('Aktive Sitzungen')}</h3> <h2 className={styles.tbDashSectionTitle}>{t('Aktive Sitzungen')}</h2>
<div className={styles.sessionList}> <div className={styles.tbDashSessionList}>
{activeSessions.map((session) => ( {activeSessions.map((session) => (
<div key={session.id} className={styles.sessionCard}> <div key={session.id} className={styles.tbDashSessionRow}>
<div className={styles.sessionHeader}> <div className={styles.tbDashSessionMain}>
<span className={styles.sessionBotName}>{session.botName}</span> <span className={styles.sessionBotName}>{session.botName}</span>
<span className={`${styles.statusBadge} ${_getStatusBadgeClass(session.status)}`}> <span className={`${styles.statusBadge} ${_getStatusBadgeClass(session.status)}`}>
{_getStatusLabel(session.status)} {_getStatusLabel(session.status)}
</span> </span>
</div> </div>
<div className={styles.sessionMeta}> <div className={styles.tbDashSessionMeta}>
<span>{session.transcriptSegmentCount} Segmente</span> {session.moduleId && (
<span>{session.botResponseCount} Antworten</span> <span>{moduleTitleById.get(session.moduleId) || session.moduleId}</span>
{session.startedAt && <span>Seit: {new Date(session.startedAt).toLocaleTimeString('de-CH')}</span>} )}
<span>{session.transcriptSegmentCount} {t('Segmente')}</span>
<span>{session.botResponseCount} {t('Antworten')}</span>
</div> </div>
<div className={styles.sessionActions}> <div className={styles.tbDashSessionActions}>
<button className={styles.viewButton} onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/sessions?sessionId=${session.id}`)}>{t('Live ansehen')}</button> <button type="button" className={styles.viewButton} onClick={() => navigate(_sessionPath(session.id))}>
{session.status === 'active' && ( {t('Live ansehen')}
<button className={styles.stopButton} onClick={() => _handleStopSession(session.id)}> </button>
Stoppen {!['ended', 'error', 'leaving'].includes(session.status) && (
<button type="button" className={styles.stopButton} onClick={() => _handleStopSession(session.id)}>
{t('Stoppen')}
</button> </button>
)} )}
</div> </div>
</div> </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> </div>
); );
}; };

View file

@ -3,11 +3,14 @@
* *
* CRUD list of MeetingModules with expandable session lists per module. * CRUD list of MeetingModules with expandable session lists per module.
*/ */
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import * as teamsbotApi from '../../../api/teamsbotApi'; import * as teamsbotApi from '../../../api/teamsbotApi';
import type { MeetingModule, TeamsbotSession, MediaFileInfo } from '../../../api/teamsbotApi';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { useFileContext } from '../../../contexts/FileContext';
import { FaSpinner } from 'react-icons/fa';
import styles from './Teamsbot.module.css'; import styles from './Teamsbot.module.css';
const SERIES_TYPE_LABELS: Record<string, string> = { const SERIES_TYPE_LABELS: Record<string, string> = {
@ -29,13 +32,62 @@ export const TeamsbotModulesView: React.FC = () => {
const { instance, mandateId } = useCurrentInstance(); const { instance, mandateId } = useCurrentInstance();
const instanceId = instance?.id || ''; const instanceId = instance?.id || '';
const navigate = useNavigate(); 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 [loading, setLoading] = useState(true);
const [expandedId, setExpandedId] = useState<string | null>(null); 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 [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 () => { const _loadModules = useCallback(async () => {
if (!instanceId) return; if (!instanceId) return;
@ -52,6 +104,10 @@ export const TeamsbotModulesView: React.FC = () => {
useEffect(() => { _loadModules(); }, [_loadModules]); useEffect(() => { _loadModules(); }, [_loadModules]);
useEffect(() => {
teamsbotApi.listMediaFiles().then(setMediaFiles).catch(() => {});
}, []);
const _loadModuleSessions = useCallback(async (moduleId: string) => { const _loadModuleSessions = useCallback(async (moduleId: string) => {
if (!instanceId) return; if (!instanceId) return;
try { try {
@ -62,6 +118,20 @@ export const TeamsbotModulesView: React.FC = () => {
} }
}, [instanceId]); }, [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) => { const _toggleExpand = (moduleId: string) => {
if (expandedId === moduleId) { if (expandedId === moduleId) {
setExpandedId(null); 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 { try {
await teamsbotApi.updateModule(instanceId, moduleId, updates); await teamsbotApi.updateModule(instanceId, moduleId, updates);
setEditingModule(null); 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 ( return (
<div className={styles.modulesContainer}> <div className={styles.modulesContainer}>
<div className={styles.modulesHeader}> <div className={styles.modulesHeader}>
<h2>{t('Meeting-Module')}</h2> <h2>{t('Meeting-Module')}</h2>
<button <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
className={styles.btnPrimary} <button
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant`)} type="button"
> className={styles.btnSecondary}
{t('Neues Modul')} onClick={() => setCreateOpen(true)}
</button> >
{t('Modul anlegen')}
</button>
<button
type="button"
className={styles.btnPrimary}
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant`)}
>
{t('Meeting starten')}
</button>
</div>
</div> </div>
{loading && <div className={styles.loading}>{t('Laden...')}</div>} {loading && <div className={styles.loading}>{t('Laden...')}</div>}
<div className={styles.modulesList}> <div className={styles.modulesList}>
{modules.map(mod => ( {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)}> <div className={styles.moduleRow} onClick={() => _toggleExpand(mod.id)}>
<span className={styles.moduleType}>{t(SERIES_TYPE_LABELS[mod.seriesType] || mod.seriesType)}</span> <span className={styles.moduleType}>{t(SERIES_TYPE_LABELS[mod.seriesType] || mod.seriesType)}</span>
<span className={styles.moduleTitle}>{mod.title}</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}> <div className={styles.moduleActions}>
<button className={styles.btnPrimary} style={{ padding: '0.3rem 0.7rem', fontSize: '0.8rem' }} onClick={e => { <button className={styles.btnPrimary} style={{ padding: '0.3rem 0.7rem', fontSize: '0.8rem' }} onClick={e => {
e.stopPropagation(); e.stopPropagation();
@ -127,17 +266,50 @@ export const TeamsbotModulesView: React.FC = () => {
{(moduleSessions[mod.id] || []).length === 0 ? ( {(moduleSessions[mod.id] || []).length === 0 ? (
<p className={styles.noSessions}>{t('Keine Sitzungen')}</p> <p className={styles.noSessions}>{t('Keine Sitzungen')}</p>
) : ( ) : (
(moduleSessions[mod.id] || []).map((sess: any) => ( <table className={styles.sessionTable}>
<div <thead>
key={sess.id} <tr>
className={styles.sessionRow} <th style={{ width: 32 }}></th>
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/sessions?sessionId=${sess.id}`)} <th>{t('Datum')}</th>
> <th>{t('Dauer')}</th>
<span>{sess.botName || 'Bot'}</span> <th>{t('Status')}</th>
<span className={styles.sessionStatus}>{sess.status}</span> </tr>
<span>{sess.startedAt ? new Date(sess.startedAt * 1000).toLocaleDateString() : '-'}</span> </thead>
</div> <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> </div>
)} )}
@ -145,6 +317,85 @@ export const TeamsbotModulesView: React.FC = () => {
))} ))}
</div> </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 && ( {deleteConfirm && (
<div className={styles.confirmOverlay}> <div className={styles.confirmOverlay}>
<div className={styles.confirmDialog}> <div className={styles.confirmDialog}>
@ -167,6 +418,41 @@ export const TeamsbotModulesView: React.FC = () => {
className={styles.wizardInput} className={styles.wizardInput}
onBlur={e => setEditingModule({ ...editingModule, title: e.target.value })} 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 <textarea
defaultValue={editingModule.goals || ''} defaultValue={editingModule.goals || ''}
className={styles.wizardTextarea} className={styles.wizardTextarea}
@ -179,11 +465,22 @@ export const TeamsbotModulesView: React.FC = () => {
<button className={styles.btnPrimary} onClick={() => _handleUpdate(editingModule.id, { <button className={styles.btnPrimary} onClick={() => _handleUpdate(editingModule.id, {
title: editingModule.title, title: editingModule.title,
goals: editingModule.goals, goals: editingModule.goals,
defaultMeetingLink: (editingModule.defaultMeetingLink || '').trim(),
defaultBotName: (editingModule.defaultBotName || '').trim(),
defaultAvatarFileId: (editingModule.defaultAvatarFileId || '').trim() || undefined,
})}>{t('Speichern')}</button> })}>{t('Speichern')}</button>
</div> </div>
</div> </div>
</div> </div>
)} )}
<input
ref={avatarInputRef}
type="file"
accept="image/*,video/*"
style={{ display: 'none' }}
onChange={_handleAvatarUpload}
/>
</div> </div>
); );
}; };

View file

@ -10,6 +10,7 @@ import type {
ScreenshotInfo, ScreenshotInfo,
DirectorPrompt, DirectorPrompt,
DirectorPromptMode, DirectorPromptMode,
MfaChallengeEvent,
} from '../../../api/teamsbotApi'; } from '../../../api/teamsbotApi';
import { import {
DIRECTOR_PROMPT_TEXT_LIMIT, DIRECTOR_PROMPT_TEXT_LIMIT,
@ -22,6 +23,8 @@ import { useFileContext } from '../../../contexts/FileContext';
import styles from './Teamsbot.module.css'; import styles from './Teamsbot.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; 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. * 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 [screenshots, setScreenshots] = useState<ScreenshotInfo[]>([]);
const [screenshotsLoading, setScreenshotsLoading] = useState(false); const [screenshotsLoading, setScreenshotsLoading] = useState(false);
const [screenshotsLoaded, setScreenshotsLoaded] = useState(false); const [screenshotsLoaded, setScreenshotsLoaded] = useState(false);
const [screenshotsExpanded, setScreenshotsExpanded] = useState(false);
const [ttsStatusEvents, setTtsStatusEvents] = useState<Array<{ const [ttsStatusEvents, setTtsStatusEvents] = useState<Array<{
status: string; status: string;
message?: 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 [agentStatus, setAgentStatus] = useState<{ toolName?: string; status?: string; reason?: string } | null>(null);
const agentStatusTimerRef = useRef<ReturnType<typeof setTimeout> | 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 [sessionStats, setSessionStats] = useState<any>(null);
const [reconnectTick, setReconnectTick] = useState(0); const [reconnectTick, setReconnectTick] = useState(0);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); 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.) // the gateway. Director prompts can only be processed once botConnected=true.)
const [botConnected, setBotConnected] = useState(false); const [botConnected, setBotConnected] = useState(false);
const [mfaChallenge, setMfaChallenge] = useState<MfaChallengeEvent | null>(null);
const [mfaCode, setMfaCode] = useState('');
const [mfaWaitingPush, setMfaWaitingPush] = useState(false);
// UDB Sidebar state // UDB Sidebar state
const [udbCollapsed, setUdbCollapsed] = useState(false); const [udbCollapsed, setUdbCollapsed] = useState(false);
const [udbTab, setUdbTab] = useState<UdbTab>('files'); 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 }); setAgentStatus({ toolName: data.toolName, status: data.status, reason: data.reason });
if (agentStatusTimerRef.current) clearTimeout(agentStatusTimerRef.current); if (agentStatusTimerRef.current) clearTimeout(agentStatusTimerRef.current);
agentStatusTimerRef.current = setTimeout(() => setAgentStatus(null), 15000); 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 { } else {
if (agentStatusTimerRef.current) clearTimeout(agentStatusTimerRef.current); if (agentStatusTimerRef.current) clearTimeout(agentStatusTimerRef.current);
agentStatusTimerRef.current = setTimeout(() => setAgentStatus(null), 2000); agentStatusTimerRef.current = setTimeout(() => setAgentStatus(null), 2000);
@ -325,6 +351,31 @@ export const TeamsbotSessionView: React.FC = () => {
case 'ping': case 'ping':
break; 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) { } catch (err) {
_dlog('SSE-ERR', String(err)); _dlog('SSE-ERR', String(err));
@ -358,8 +409,35 @@ export const TeamsbotSessionView: React.FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [instanceId, sessionId, reconnectTick]); }, [instanceId, sessionId, reconnectTick]);
// Polling fallback: refresh session data every 5s when SSE is not connected. // Keep session switcher labels in sync when the selected session updates (SSE, poll, etc.).
// Uses isActive (boolean) instead of session object to prevent interval resets. 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 pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isActive = useMemo(() => session && ['pending', 'joining', 'active'].includes(session.status), [session?.status]); const isActive = useMemo(() => session && ['pending', 'joining', 'active'].includes(session.status), [session?.status]);
const isLiveRef = useRef(isLive); const isLiveRef = useRef(isLive);
@ -367,22 +445,24 @@ export const TeamsbotSessionView: React.FC = () => {
useEffect(() => { useEffect(() => {
if (!instanceId || !sessionId) return; if (!instanceId || !sessionId) return;
if (!isActive) return; if (!isActive) return;
const intervalMs = session?.status === 'active' ? 5000 : 2500;
pollRef.current = setInterval(async () => { pollRef.current = setInterval(async () => {
if (isLiveRef.current) return; const st = sessionStatusRef.current;
const pollWhileLive = st === 'pending' || st === 'joining';
if (!pollWhileLive && isLiveRef.current) return;
try { try {
const result = await teamsbotApi.getSession(instanceId, sessionId); const result = await teamsbotApi.getSession(instanceId, sessionId);
setSession(result.session); setSession(result.session);
if (result.transcripts) setTranscripts(result.transcripts); if (result.transcripts) setTranscripts(result.transcripts);
if (result.botResponses) setBotResponses(result.botResponses); if (result.botResponses) setBotResponses(result.botResponses);
// If session became active and SSE is dead, trigger reconnect
const newStatus = result.session?.status; const newStatus = result.session?.status;
if (newStatus && ['active', 'joining', 'pending'].includes(newStatus) && !eventSourceRef.current) { if (newStatus && ['active', 'joining', 'pending'].includes(newStatus) && !eventSourceRef.current) {
setReconnectTick(v => v + 1); setReconnectTick((v) => v + 1);
} }
} catch {} } catch { /* ignore */ }
}, 5000); }, intervalMs);
return () => { if (pollRef.current) clearInterval(pollRef.current); }; return () => { if (pollRef.current) clearInterval(pollRef.current); };
}, [isActive, instanceId, sessionId]); }, [isActive, instanceId, sessionId, session?.status]);
// Auto-scroll transcript // Auto-scroll transcript
useEffect(() => { 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) => { const _formatTime = (timestamp: string) => {
try { try {
const dt = new Date(timestamp); 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 }}> <p style={{ color: 'var(--text-secondary, #666)', textAlign: 'center', maxWidth: 400 }}>
{t('Starte ein neues Meeting ueber den Assistenten oder die Module-Seite.')} {t('Starte ein neues Meeting ueber den Assistenten oder die Module-Seite.')}
</p> </p>
<div style={{ display: 'flex', gap: '0.75rem' }}> <div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', justifyContent: 'center' }}>
<button className={styles.btnPrimary} <button className={styles.btnPrimary}
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant`)} onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant`)}
>{t('Zum Assistenten')}</button> >{t('Zum Assistenten')}</button>
<button className={styles.btnSecondary} <button className={styles.btnSecondary}
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/modules`)} onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/modules`)}
>{t('Zu den Modulen')}</button> >{t('Zu den Modulen')}</button>
<button className={styles.btnSecondary}
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/dashboard`)}
>{t('Zum Dashboard')}</button>
</div> </div>
</div> </div>
); );
@ -650,39 +752,88 @@ export const TeamsbotSessionView: React.FC = () => {
return ( return (
<div className={styles.sessionContainer}> <div className={styles.sessionContainer}>
{/* Session Switcher (if multiple sessions exist) */} {mfaChallenge && (
{allSessions.length > 1 && ( <div className={styles.mfaOverlay}>
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}> <div className={styles.mfaDialog}>
{allSessions.map((s) => ( <div className={styles.mfaTitle}>{t('Multi-Faktor-Authentifizierung')}</div>
<button {mfaChallenge.displayNumber && (
key={s.id} <div className={styles.mfaNumber}>{mfaChallenge.displayNumber}</div>
onClick={() => _switchSession(s.id)} )}
style={{ <div className={styles.mfaPrompt}>{mfaChallenge.prompt}</div>
padding: '6px 12px', {_mfaNeedsCodeInput ? (
borderRadius: '6px', <>
border: s.id === sessionId ? '2px solid #4A90D9' : '1px solid #ddd', <input
background: s.id === sessionId ? '#EBF3FC' : '#fff', type="text"
cursor: 'pointer', className={styles.mfaCodeInput}
fontSize: '13px', placeholder={t('Code eingeben')}
fontWeight: s.id === sessionId ? 600 : 400, value={mfaCode}
}} onChange={(e) => setMfaCode(e.target.value)}
> autoFocus
{s.botName} onKeyDown={(e) => e.key === 'Enter' && _handleSubmitMfaCode()}
{['active', 'joining', 'pending'].includes(s.status) && ' (aktiv)'} />
{s.status === 'ended' && ' (beendet)'} <button type="button" className={styles.startButton} onClick={_handleSubmitMfaCode} disabled={!mfaCode.trim()}>
</button> {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> </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 && ( {agentStatus && (
<div className={styles.agentStatusBubble}> <div className={styles.agentStatusBubble}>
<span className={styles.agentStatusDot} /> <span className={styles.agentStatusDot} />
<span>{t('Agent denkt nach')}{agentStatus.toolName ? `: ${agentStatus.toolName}` : '...'}</span> <span>{t('Agent denkt nach')}{agentStatus.toolName ? `: ${agentStatus.toolName}` : '...'}</span>
</div> </div>
)} )}
{/* Stats Cards (F-fix-3) */} {/* Stats Cards (F-fix-3) */}
{sessionStats && ( {sessionStats && (
<div className={styles.statsCards}> <div className={styles.statsCards}>
@ -717,7 +868,7 @@ export const TeamsbotSessionView: React.FC = () => {
{isLive && <span className={styles.liveBadge}>LIVE</span>} {isLive && <span className={styles.liveBadge}>LIVE</span>}
</div> </div>
<div className={styles.sessionControls}> <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> <button className={styles.stopButton} onClick={_handleStop}>{t('Sitzung beenden')}</button>
)} )}
</div> </div>
@ -778,8 +929,12 @@ export const TeamsbotSessionView: React.FC = () => {
{/* Main column */} {/* Main column */}
<div className={styles.sessionMain}> <div className={styles.sessionMain}>
{/* Director Prompt Panel (private operator instructions) */} {/* Director Prompt Panel (private operator instructions).
{['active', 'joining', 'pending'].includes(session.status) && ( 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 <div
className={`${styles.directorPanel} ${directorDragOver ? styles.directorPanelDragOver : ''}`} className={`${styles.directorPanel} ${directorDragOver ? styles.directorPanelDragOver : ''}`}
onDragEnter={_onDirectorDragEnter} onDragEnter={_onDirectorDragEnter}
@ -957,7 +1112,8 @@ export const TeamsbotSessionView: React.FC = () => {
<div className={styles.directorHistoryText}>{p.text}</div> <div className={styles.directorHistoryText}>{p.text}</div>
{p.responseText && ( {p.responseText && (
<div className={styles.directorHistoryText} style={{ opacity: 0.85 }}> <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> </div>
)} )}
{p.statusMessage && p.status === 'failed' && ( {p.statusMessage && p.status === 'failed' && (
@ -1009,7 +1165,9 @@ export const TeamsbotSessionView: React.FC = () => {
<span className={styles.responseIntent}>{r.detectedIntent}</span> <span className={styles.responseIntent}>{r.detectedIntent}</span>
<span className={styles.responseTime}>{_formatTime(r.timestamp || '')}</span> <span className={styles.responseTime}>{_formatTime(r.timestamp || '')}</span>
</div> </div>
<div className={styles.responseText}>{r.responseText}</div> <div className={styles.responseText}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{r.responseText || ''}</ReactMarkdown>
</div>
{r.reasoning && ( {r.reasoning && (
<div className={styles.responseReasoning}> <div className={styles.responseReasoning}>
<em>{t('Begründung: {text}', { text: r.reasoning })}</em> <em>{t('Begründung: {text}', { text: r.reasoning })}</em>
@ -1035,17 +1193,40 @@ export const TeamsbotSessionView: React.FC = () => {
{session.summary && ( {session.summary && (
<div className={styles.summaryCard}> <div className={styles.summaryCard}>
<h4 className={styles.panelTitle}>{t('Meeting-Zusammenfassung')}</h4> <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> </div>
)} )}
{/* TTS Delivery Debug */} {/* Agent Progress Log (collapsed by default) */}
<div className={styles.summaryCard}> <details className={styles.summaryCard}>
<h4 className={styles.panelTitle}>{t('TTS-Lieferstatus')}</h4> <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 ? ( {ttsStatusEvents.length === 0 ? (
<div className={styles.emptyState}>{t('Noch keine TTS-Events')}</div> <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) => ( {ttsStatusEvents.slice(-10).reverse().map((ev, idx) => (
<div key={`${ev.timestamp}-${idx}`} className={styles.responseMeta}> <div key={`${ev.timestamp}-${idx}`} className={styles.responseMeta}>
<span>{_formatTime(ev.timestamp)}</span> <span>{_formatTime(ev.timestamp)}</span>
@ -1056,7 +1237,7 @@ export const TeamsbotSessionView: React.FC = () => {
))} ))}
</div> </div>
)} )}
</div> </details>
{/* Debug Log (SSE/Transcript/Chat) */} {/* Debug Log (SSE/Transcript/Chat) */}
<div style={{ position: 'fixed', bottom: 0, right: 0, zIndex: 9999 }}> <div style={{ position: 'fixed', bottom: 0, right: 0, zIndex: 9999 }}>
@ -1073,62 +1254,77 @@ export const TeamsbotSessionView: React.FC = () => {
)} )}
</div> </div>
{/* Debug Screenshots (SysAdmin only) */} {/* Debug Screenshots (SysAdmin only, collapsible) */}
{_isSysAdmin && ( {_isSysAdmin && (
<div className={styles.summaryCard}> <div className={styles.summaryCard}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}> <div
<h4 className={styles.panelTitle} style={{ margin: 0 }}>{t('Debug-Screenshots')}</h4> style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer' }}
<button onClick={() => setScreenshotsExpanded((v) => !v)}
className={styles.viewButton} >
onClick={async () => { <h4 className={styles.panelTitle} style={{ margin: 0 }}>
setScreenshotsLoading(true); {screenshotsExpanded ? '\u25BC' : '\u25B6'} {t('Debug-Screenshots')}
try { {screenshotsLoaded && screenshots.length > 0 && ` (${screenshots.length})`}
const result = await teamsbotApi.listScreenshots(instanceId, session.id); </h4>
setScreenshots(result.screenshots || []); <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
setScreenshotsLoaded(true); {screenshotsExpanded && (
} catch (err: any) { <button
setScreenshots([]); className={styles.viewButton}
setScreenshotsLoaded(true); onClick={async (e) => {
} finally { e.stopPropagation();
setScreenshotsLoading(false); setScreenshotsLoading(true);
} try {
}} const result = await teamsbotApi.listScreenshots(instanceId, session.id);
disabled={screenshotsLoading} setScreenshots(result.screenshots || []);
> setScreenshotsLoaded(true);
{screenshotsLoading ? t('Laden…') : screenshotsLoaded ? t('Aktualisieren') : t('Screenshots laden')} } catch (err: any) {
</button> setScreenshots([]);
</div> setScreenshotsLoaded(true);
{screenshotsLoaded && screenshots.length === 0 && ( } finally {
<div className={styles.emptyState}>{t('Keine Screenshots für diese Sitzung')}</div> setScreenshotsLoading(false);
)} }
{screenshots.length > 0 && ( }}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '12px' }}> disabled={screenshotsLoading}
{screenshots.map((s) => { >
const imgUrl = teamsbotApi.getScreenshotUrl(instanceId, s.name); {screenshotsLoading ? t('Laden…') : screenshotsLoaded ? t('Aktualisieren') : t('Screenshots laden')}
return ( </button>
<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>
</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> </div>
)} )}

View file

@ -1,11 +1,12 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import * as teamsbotApi from '../../../api/teamsbotApi'; 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 type { VoiceLanguage } from '../../../api/voiceCatalogApi';
import { FaPlay, FaSpinner, FaTrash } from 'react-icons/fa'; import { FaPlay, FaSpinner, FaTrash } from 'react-icons/fa';
import styles from './Teamsbot.module.css'; import styles from './Teamsbot.module.css';
import { getUserDataCache } from '../../../utils/userCache'; import { getUserDataCache } from '../../../utils/userCache';
import { useFileContext } from '../../../contexts/FileContext';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
@ -45,16 +46,24 @@ export const TeamsbotSettingsView: React.FC = () => {
const [voices, setVoices] = useState<VoiceOption[]>([]); const [voices, setVoices] = useState<VoiceOption[]>([]);
const [loadingVoices, setLoadingVoices] = useState(false); 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 () => { const _loadConfig = useCallback(async () => {
if (!instanceId) return; if (!instanceId) return;
try { try {
setLoading(true); setLoading(true);
// Load per-user settings (merged with instance defaults) // 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.getUserSettings(instanceId),
teamsbotApi.fetchLanguages(), teamsbotApi.fetchLanguages(),
teamsbotApi.listMediaFiles().catch(() => [] as MediaFileInfo[]),
]); ]);
setMediaFiles(mediaResult);
const effectiveConfig = settingsResult.effectiveConfig; const effectiveConfig = settingsResult.effectiveConfig;
setConfig(effectiveConfig); setConfig(effectiveConfig);
setFormData(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 () => { const _handleTestVoice = async () => {
if (!instanceId) return; if (!instanceId) return;
setTestingVoice(true); 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"). Default-Name fuer den Bot im Meeting. Falls keiner angegeben, wird der Name des System-Bots verwendet (z.B. "Nyla Larsson").
</span> </span>
</div> </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> </div>
{/* AI Behavior */} {/* 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', label: 'Teams Bot',
icon: 'headset_mic', icon: 'headset_mic',
views: [ views: [
{ code: 'dashboard', label: 'Übersicht', path: 'dashboard' }, { code: 'dashboard', label: 'Dashboard', path: 'dashboard' },
{ code: 'assistant', label: 'Assistent', path: 'assistant' }, { code: 'assistant', label: 'Assistent', path: 'assistant' },
{ code: 'modules', label: 'Module', path: 'modules' }, { code: 'modules', label: 'Module', path: 'modules' },
{ code: 'sessions', label: 'Live-Session', path: 'sessions' }, { code: 'sessions', label: 'Live-Session', path: 'sessions' },
@ -295,7 +295,6 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
views: [ views: [
{ code: 'dashboard', label: 'Dashboard', path: 'dashboard' }, { code: 'dashboard', label: 'Dashboard', path: 'dashboard' },
{ code: 'editor', label: 'Editor', path: 'editor' }, { code: 'editor', label: 'Editor', path: 'editor' },
{ code: 'rag-insights', label: 'Wissens-Insights', path: 'rag-insights' },
{ code: 'settings', label: 'Einstellungen', path: 'settings' }, { code: 'settings', label: 'Einstellungen', path: 'settings' },
] ]
}, },