Merge pull request #82 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
This commit is contained in:
commit
629333f910
42 changed files with 2924 additions and 1806 deletions
|
|
@ -185,7 +185,10 @@
|
|||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>© 2025 PowerOn AI Platform. All rights reserved.</p>
|
||||
<p class="legal-meta" style="margin-bottom: 0.75rem; color: #64748b; font-size: 0.85rem;">Company & legal details · 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;">© 2026 PowerOn. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@
|
|||
</div>
|
||||
|
||||
<div class="last-updated">
|
||||
<strong>Last Updated:</strong> August 2025
|
||||
<strong>Last Updated:</strong> May 2026
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
|
|
@ -272,8 +272,13 @@
|
|||
<h2>Contact Us</h2>
|
||||
<p>If you have any questions about this Privacy Policy or our data practices, please contact us:</p>
|
||||
<div class="highlight-box">
|
||||
<p><strong>Email:</strong> privacy@poweron-ai.com</p>
|
||||
<p><strong>Address:</strong> PowerOn AI Platform, Privacy Team</p>
|
||||
<p><strong>Email:</strong> <a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
|
||||
<p><strong>Address:</strong><br>
|
||||
PowerOn AG<br>
|
||||
Birmensdorferstrasse 94<br>
|
||||
CH-8003 Zürich<br>
|
||||
Switzerland
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -283,7 +288,7 @@
|
|||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>© 2025 PowerOn AI Platform. All rights reserved.</p>
|
||||
<p>© 2026 PowerOn. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@
|
|||
</div>
|
||||
|
||||
<div class="last-updated">
|
||||
<strong>Last Updated:</strong> August 2025
|
||||
<strong>Last Updated:</strong> May 2026
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
|
|
@ -315,8 +315,13 @@
|
|||
<h2>Contact Information</h2>
|
||||
<p>If you have any questions about these Terms of Service, please contact us:</p>
|
||||
<div class="highlight-box">
|
||||
<p><strong>Email:</strong> legal@poweron-ai.com</p>
|
||||
<p><strong>Address:</strong> PowerOn AI Platform, Legal Department</p>
|
||||
<p><strong>Email:</strong> <a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
|
||||
<p><strong>Address:</strong><br>
|
||||
PowerOn AG<br>
|
||||
Birmensdorferstrasse 94<br>
|
||||
CH-8003 Zürich<br>
|
||||
Switzerland
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -326,7 +331,7 @@
|
|||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>© 2025 PowerOn AI Platform. All rights reserved.</p>
|
||||
<p>© 2026 PowerOn. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin
|
|||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
||||
import { AutomationsDashboardPage } from './pages/AutomationsDashboardPage';
|
||||
import { RagInventoryPage } from './pages/RagInventoryPage';
|
||||
import { ComplianceAuditPage } from './pages/ComplianceAuditPage';
|
||||
function App() {
|
||||
// Load saved theme preference and set app name on app mount
|
||||
|
|
@ -127,6 +128,11 @@ function App() {
|
|||
{/* ============================================== */}
|
||||
<Route path="automations" element={<AutomationsDashboardPage />} />
|
||||
|
||||
{/* ============================================== */}
|
||||
{/* RAG INVENTORY */}
|
||||
{/* ============================================== */}
|
||||
<Route path="rag-inventory" element={<RagInventoryPage />} />
|
||||
|
||||
{/* Legacy top-level routes – redirect to dashboard (migrated to feature-instance routes) */}
|
||||
<Route path="chatbot" element={<Navigate to="/" replace />} />
|
||||
<Route path="pek" element={<Navigate to="/" replace />} />
|
||||
|
|
|
|||
|
|
@ -6,17 +6,11 @@ import { ApiRequestOptions } from '../hooks/useApi';
|
|||
|
||||
export interface KnowledgePreferences {
|
||||
schemaVersion?: number;
|
||||
neutralizeBeforeEmbed?: boolean;
|
||||
mailContentDepth?: 'metadata' | 'snippet' | 'full';
|
||||
mailIndexAttachments?: boolean;
|
||||
filesIndexBinaries?: boolean;
|
||||
mimeAllowlist?: string[];
|
||||
clickupScope?: 'titles' | 'title_description' | 'with_comments';
|
||||
clickupIndexAttachments?: boolean;
|
||||
surfaceToggles?: {
|
||||
google?: { gmail?: boolean; drive?: boolean };
|
||||
msft?: { sharepoint?: boolean; outlook?: boolean };
|
||||
};
|
||||
maxAgeDays?: number;
|
||||
}
|
||||
|
||||
|
|
@ -292,3 +286,110 @@ 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 } | 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' });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ export interface TeamsbotConfig {
|
|||
triggerCooldownSeconds: number;
|
||||
contextWindowSegments: number;
|
||||
debugMode?: boolean;
|
||||
avatarFileId?: string;
|
||||
}
|
||||
|
||||
export interface TeamsbotSessionStats {
|
||||
|
|
@ -84,6 +85,7 @@ export interface TeamsbotSessionStats {
|
|||
export interface StartSessionRequest {
|
||||
meetingLink: string;
|
||||
botName?: string;
|
||||
moduleId?: string;
|
||||
connectionId?: string;
|
||||
joinMode?: TeamsbotJoinMode;
|
||||
sessionContext?: string;
|
||||
|
|
@ -102,6 +104,7 @@ export interface ConfigUpdateRequest {
|
|||
triggerCooldownSeconds?: number;
|
||||
contextWindowSegments?: number;
|
||||
debugMode?: boolean;
|
||||
avatarFileId?: string;
|
||||
}
|
||||
|
||||
// Voice option type re-exported from the central voice catalog API
|
||||
|
|
@ -462,6 +465,13 @@ export function createSessionStream(instanceId: string, sessionId: string): Even
|
|||
return new EventSource(url, { withCredentials: true });
|
||||
}
|
||||
|
||||
/** SSE dashboard stream: periodic { type: 'dashboardState', sessions, modules } */
|
||||
export function createDashboardStream(instanceId: string): EventSource {
|
||||
const baseUrl = api.defaults.baseURL || '';
|
||||
const url = `${baseUrl}/api/teamsbot/${instanceId}/dashboard/stream`;
|
||||
return new EventSource(url, { withCredentials: true });
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Debug Screenshots (SysAdmin only)
|
||||
// =========================================================================
|
||||
|
|
@ -592,6 +602,9 @@ export interface MeetingModule {
|
|||
defaultDirectorPrompts?: string;
|
||||
goals?: string;
|
||||
kpiTargets?: string;
|
||||
defaultMeetingLink?: string;
|
||||
defaultBotName?: string;
|
||||
defaultAvatarFileId?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
|
|
@ -602,6 +615,7 @@ export async function listModules(instanceId: string): Promise<MeetingModule[]>
|
|||
|
||||
export async function createModule(instanceId: string, body: {
|
||||
title: string; seriesType?: string; defaultBotId?: string; goals?: string; kpiTargets?: string;
|
||||
defaultMeetingLink?: string; defaultBotName?: string; defaultAvatarFileId?: string;
|
||||
}): Promise<MeetingModule> {
|
||||
const response = await api.post(`/api/teamsbot/${instanceId}/modules`, body);
|
||||
return response.data?.module;
|
||||
|
|
@ -620,3 +634,31 @@ export async function updateModule(instanceId: string, moduleId: string, body: P
|
|||
export async function deleteModule(instanceId: string, moduleId: string): Promise<void> {
|
||||
await api.delete(`/api/teamsbot/${instanceId}/modules/${moduleId}`);
|
||||
}
|
||||
|
||||
export interface MediaFileInfo {
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export async function listMediaFiles(): Promise<MediaFileInfo[]> {
|
||||
const response = await api.get('/api/files/list', {
|
||||
params: { pagination: JSON.stringify({ pageSize: 500 }) },
|
||||
});
|
||||
const data = response.data;
|
||||
let items: any[];
|
||||
if (Array.isArray(data)) {
|
||||
items = data;
|
||||
} else if (Array.isArray(data?.items)) {
|
||||
items = data.items;
|
||||
} else {
|
||||
console.warn('[listMediaFiles] unexpected response shape:', Object.keys(data || {}));
|
||||
items = [];
|
||||
}
|
||||
const filtered = items.filter((f: any) => {
|
||||
const mime = (f.mimeType || '').toLowerCase();
|
||||
return mime.startsWith('image/') || mime.startsWith('video/');
|
||||
});
|
||||
console.log(`[listMediaFiles] ${items.length} total files, ${filtered.length} media files`);
|
||||
return filtered.map((f: any) => ({ id: f.id, fileName: f.fileName, mimeType: f.mimeType }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,13 +73,12 @@
|
|||
|
||||
/* Connector grid (Step 0) */
|
||||
.connectorGrid {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.connectorCard {
|
||||
flex: 1 1 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
|
@ -447,6 +446,22 @@
|
|||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.patInput {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-family: monospace;
|
||||
margin: 12px 0 16px;
|
||||
}
|
||||
|
||||
.patInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary, #2563eb);
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
:global(.dark-theme) .connectorCard {
|
||||
background: var(--surface-color);
|
||||
|
|
|
|||
|
|
@ -1,153 +1,52 @@
|
|||
/**
|
||||
* AddConnectionWizard
|
||||
*
|
||||
* Multi-step modal for adding a new connector with optional knowledge
|
||||
* ingestion consent and per-connection preferences (§2.6).
|
||||
*
|
||||
* Steps:
|
||||
* 0 — Connector wählen
|
||||
* 1 — Consent (Wissensdatenbank Ja/Nein)
|
||||
* 2 — Präferenzen (nur wenn Ja)
|
||||
* 3 — Zusammenfassung + OAuth starten
|
||||
* Streamlined multi-step modal for adding a new connector.
|
||||
* Steps are connector-type-aware:
|
||||
* Base: Connector → Consent → Connect
|
||||
* Microsoft: Connector → Consent → Admin Consent (optional) → Connect
|
||||
* Infomaniak: Connector → Consent → PAT Input → (done)
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Modal } from '../UiComponents/Modal/Modal';
|
||||
import { FaGoogle, FaMicrosoft, FaTasks, FaDatabase, FaShieldAlt, FaCheck, FaArrowRight, FaInfoCircle } from 'react-icons/fa';
|
||||
import type { KnowledgePreferences } from '../../api/connectionApi';
|
||||
import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaCheck, FaArrowRight, FaShieldAlt } from 'react-icons/fa';
|
||||
import styles from './AddConnectionWizard.module.css';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ConnectorType = 'google' | 'msft' | 'clickup';
|
||||
export type ConnectorType = 'google' | 'msft' | 'clickup' | 'infomaniak';
|
||||
|
||||
type StepId = 'connector' | 'consent' | 'msftAdminConsent' | 'infomaniakPat' | 'connect';
|
||||
|
||||
interface WizardState {
|
||||
step: 0 | 1 | 2 | 3;
|
||||
currentStep: StepId;
|
||||
connector: ConnectorType | null;
|
||||
knowledgeEnabled: boolean;
|
||||
prefs: KnowledgePreferences;
|
||||
infomaniakToken: string;
|
||||
adminConsentDone: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PREFS: KnowledgePreferences = {
|
||||
schemaVersion: 1,
|
||||
neutralizeBeforeEmbed: false,
|
||||
mailContentDepth: 'full',
|
||||
mailIndexAttachments: false,
|
||||
filesIndexBinaries: true,
|
||||
clickupScope: 'title_description',
|
||||
clickupIndexAttachments: false,
|
||||
maxAgeDays: 90,
|
||||
};
|
||||
|
||||
const CONNECTOR_LABELS: Record<ConnectorType, string> = {
|
||||
google: 'Google',
|
||||
msft: 'Microsoft 365',
|
||||
clickup: 'ClickUp',
|
||||
infomaniak: 'Infomaniak',
|
||||
};
|
||||
|
||||
const CONNECTOR_ICONS: Record<ConnectorType, React.ReactNode> = {
|
||||
google: <FaGoogle style={{ color: '#4285f4' }} />,
|
||||
msft: <FaMicrosoft style={{ color: '#00a4ef' }} />,
|
||||
clickup: <FaTasks style={{ color: '#7b68ee' }} />,
|
||||
infomaniak: <FaCloud style={{ color: '#0098db' }} />,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cost estimate helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns a cost estimate broken into two lines:
|
||||
*
|
||||
* 1. Embedding (OpenAI text-embedding-3-small, $0.02 / 1M tokens) — always tiny.
|
||||
* 2. Neutralization (Private LLM / qwen2.5 on-premise, CHF 0.01 per LLM call)
|
||||
* — this is the DOMINANT cost when enabled. One call per email/task for
|
||||
* short content; several calls for long threads or files.
|
||||
*
|
||||
* Numbers are conservative ranges. Subsequent syncs are cheaper because
|
||||
* unchanged content is deduplicated before any LLM/embedding call.
|
||||
*/
|
||||
function computeCostEstimate(
|
||||
connector: ConnectorType | null,
|
||||
prefs: KnowledgePreferences,
|
||||
): {
|
||||
embeddingLow: string;
|
||||
embeddingHigh: string;
|
||||
neutralizationLow: string | null;
|
||||
neutralizationHigh: string | null;
|
||||
note: string;
|
||||
} | null {
|
||||
if (!connector) return null;
|
||||
|
||||
// ---- Embedding (OpenAI, USD) ----
|
||||
const EMBED_USD_PER_M = 0.02;
|
||||
const tokensPerMail: Record<string, number> = { metadata: 30, snippet: 120, full: 500 };
|
||||
const depth = prefs.mailContentDepth ?? 'full';
|
||||
const maxAge = prefs.maxAgeDays ?? 90;
|
||||
const mailCount = Math.min(500, Math.round((maxAge / 90) * 500));
|
||||
const taskCount = Math.min(500, Math.round((maxAge / 90) * 300));
|
||||
|
||||
let embedLowTokens = 0;
|
||||
let embedHighTokens = 0;
|
||||
|
||||
if (connector === 'google' || connector === 'msft') {
|
||||
const mailTokens = mailCount * tokensPerMail[depth];
|
||||
embedLowTokens += mailTokens * 0.6;
|
||||
embedHighTokens += mailTokens * 1.5 + 500_000; // Drive/SharePoint
|
||||
if (prefs.mailIndexAttachments) embedHighTokens += 200_000;
|
||||
} else if (connector === 'clickup') {
|
||||
const scope = prefs.clickupScope ?? 'title_description';
|
||||
const tpt = scope === 'titles' ? 30 : scope === 'title_description' ? 200 : 400;
|
||||
embedLowTokens += taskCount * tpt * 0.6;
|
||||
embedHighTokens += taskCount * tpt * 1.5;
|
||||
}
|
||||
|
||||
const fmtUsd = (tokens: number) => {
|
||||
const usd = (tokens / 1_000_000) * EMBED_USD_PER_M;
|
||||
if (usd < 0.001) return '< 0.01 $';
|
||||
if (usd < 0.10) return `~${usd.toFixed(3)} $`;
|
||||
return `~${usd.toFixed(2)} $`;
|
||||
};
|
||||
|
||||
// ---- Neutralization (Private LLM, CHF 0.01/call) ----
|
||||
// Each item (email / task / file) = 1 LLM call for short content,
|
||||
// 2-4 for long threads/documents.
|
||||
const NEUT_CHF_PER_CALL = 0.01;
|
||||
let neutLow: string | null = null;
|
||||
let neutHigh: string | null = null;
|
||||
|
||||
if (prefs.neutralizeBeforeEmbed) {
|
||||
let lowCalls = 0;
|
||||
let highCalls = 0;
|
||||
|
||||
if (connector === 'google' || connector === 'msft') {
|
||||
lowCalls += mailCount * 1; // 1 call / short email
|
||||
highCalls += mailCount * 3; // up to 3 calls / long thread
|
||||
lowCalls += 20; // Drive/SharePoint files (low)
|
||||
highCalls += 200; // Drive/SharePoint files (high, large PDFs)
|
||||
} else if (connector === 'clickup') {
|
||||
lowCalls += taskCount * 1;
|
||||
highCalls += taskCount * 2;
|
||||
}
|
||||
|
||||
const fmtChf = (calls: number) => {
|
||||
const chf = calls * NEUT_CHF_PER_CALL;
|
||||
if (chf < 0.01) return '< 0.01 CHF';
|
||||
return `~${chf.toFixed(2)} CHF`;
|
||||
};
|
||||
|
||||
neutLow = fmtChf(lowCalls);
|
||||
neutHigh = fmtChf(highCalls);
|
||||
}
|
||||
|
||||
return {
|
||||
embeddingLow: fmtUsd(embedLowTokens),
|
||||
embeddingHigh: fmtUsd(embedHighTokens),
|
||||
neutralizationLow: neutLow,
|
||||
neutralizationHigh: neutHigh,
|
||||
note: 'Einmalig beim ersten Sync. Folge-Syncs kosten weniger (nur neue Inhalte).',
|
||||
};
|
||||
function _getSteps(connector: ConnectorType | null): StepId[] {
|
||||
if (connector === 'msft') return ['connector', 'consent', 'msftAdminConsent', 'connect'];
|
||||
if (connector === 'infomaniak') return ['connector', 'consent', 'infomaniakPat'];
|
||||
return ['connector', 'consent', 'connect'];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -157,11 +56,9 @@ function computeCostEstimate(
|
|||
interface AddConnectionWizardProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConnect: (
|
||||
type: ConnectorType,
|
||||
knowledgeEnabled: boolean,
|
||||
prefs: KnowledgePreferences | null,
|
||||
) => Promise<void>;
|
||||
onConnect: (type: ConnectorType, knowledgeEnabled: boolean) => Promise<void>;
|
||||
onInfomaniakConnect?: (token: string, knowledgeEnabled: boolean) => Promise<void>;
|
||||
onMsftAdminConsent?: () => void;
|
||||
isConnecting?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -173,84 +70,91 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
|
|||
open,
|
||||
onClose,
|
||||
onConnect,
|
||||
onInfomaniakConnect,
|
||||
onMsftAdminConsent,
|
||||
isConnecting = false,
|
||||
}) => {
|
||||
const [state, setState] = useState<WizardState>({
|
||||
step: 0,
|
||||
currentStep: 'connector',
|
||||
connector: null,
|
||||
knowledgeEnabled: false,
|
||||
prefs: { ...DEFAULT_PREFS },
|
||||
infomaniakToken: '',
|
||||
adminConsentDone: false,
|
||||
});
|
||||
|
||||
const reset = () =>
|
||||
setState({ step: 0, connector: null, knowledgeEnabled: false, prefs: { ...DEFAULT_PREFS } });
|
||||
setState({ currentStep: 'connector', connector: null, knowledgeEnabled: false, infomaniakToken: '', adminConsentDone: false });
|
||||
|
||||
const handleClose = () => {
|
||||
reset();
|
||||
onClose();
|
||||
const handleClose = () => { reset(); onClose(); };
|
||||
|
||||
const steps = _getSteps(state.connector);
|
||||
const stepIndex = steps.indexOf(state.currentStep);
|
||||
|
||||
const goNext = () => {
|
||||
const nextIdx = stepIndex + 1;
|
||||
if (nextIdx < steps.length) {
|
||||
setState(s => ({ ...s, currentStep: steps[nextIdx] }));
|
||||
}
|
||||
};
|
||||
|
||||
const setStep = (step: WizardState['step']) => setState(s => ({ ...s, step }));
|
||||
const setConnector = (connector: ConnectorType) =>
|
||||
setState(s => ({ ...s, connector, step: 1 }));
|
||||
const setKnowledgeEnabled = (v: boolean) =>
|
||||
setState(s => ({ ...s, knowledgeEnabled: v, step: v ? 2 : 3 }));
|
||||
const updatePref = <K extends keyof KnowledgePreferences>(key: K, value: KnowledgePreferences[K]) =>
|
||||
setState(s => ({ ...s, prefs: { ...s.prefs, [key]: value } }));
|
||||
const goBack = () => {
|
||||
const prevIdx = stepIndex - 1;
|
||||
if (prevIdx >= 0) {
|
||||
setState(s => ({ ...s, currentStep: steps[prevIdx] }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = async () => {
|
||||
const selectConnector = (c: ConnectorType) => {
|
||||
setState(s => ({ ...s, connector: c, currentStep: 'consent' }));
|
||||
};
|
||||
|
||||
const setConsent = (enabled: boolean) => {
|
||||
setState(s => ({ ...s, knowledgeEnabled: enabled }));
|
||||
goNext();
|
||||
};
|
||||
|
||||
const handleFinalConnect = async () => {
|
||||
if (!state.connector) return;
|
||||
await onConnect(
|
||||
state.connector,
|
||||
state.knowledgeEnabled,
|
||||
state.knowledgeEnabled ? state.prefs : null,
|
||||
);
|
||||
if (state.connector === 'infomaniak' && onInfomaniakConnect) {
|
||||
await onInfomaniakConnect(state.infomaniakToken, state.knowledgeEnabled);
|
||||
} else {
|
||||
await onConnect(state.connector, state.knowledgeEnabled);
|
||||
}
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const visibleSteps = state.knowledgeEnabled
|
||||
? [0, 1, 2, 3]
|
||||
: [0, 1, 3];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title="Verbindung hinzufügen"
|
||||
size="md"
|
||||
closeOnEscape
|
||||
>
|
||||
<Modal open={open} onClose={handleClose} title="Verbindung hinzufügen" size="md" closeOnEscape>
|
||||
{/* Stepper */}
|
||||
<div className={styles.stepper}>
|
||||
{[0, 1, 2, 3].map(i => (
|
||||
{steps.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
key={s}
|
||||
className={[
|
||||
styles.stepDot,
|
||||
state.step === i ? styles.stepDotActive : '',
|
||||
state.step > i ? styles.stepDotDone : '',
|
||||
!visibleSteps.includes(i) ? styles.stepDotHidden : '',
|
||||
stepIndex === i ? styles.stepDotActive : '',
|
||||
stepIndex > i ? styles.stepDotDone : '',
|
||||
].join(' ')}
|
||||
>
|
||||
{state.step > i ? <FaCheck size={10} /> : i + 1}
|
||||
{stepIndex > i ? <FaCheck size={10} /> : i + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
{/* ---- Step 0: Connector ---- */}
|
||||
{state.step === 0 && (
|
||||
{/* ---- Step: Connector ---- */}
|
||||
{state.currentStep === 'connector' && (
|
||||
<div className={styles.stepContent}>
|
||||
<h3 className={styles.stepTitle}>Anbieter wählen</h3>
|
||||
<p className={styles.stepHint}>Welchen Dienst möchtest du verbinden?</p>
|
||||
<div className={styles.connectorGrid}>
|
||||
{(['google', 'msft', 'clickup'] as ConnectorType[]).map(type => (
|
||||
{(['google', 'msft', 'clickup', 'infomaniak'] as ConnectorType[]).map(type => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
className={styles.connectorCard}
|
||||
onClick={() => setConnector(type)}
|
||||
onClick={() => selectConnector(type)}
|
||||
>
|
||||
<span className={styles.connectorIcon}>{CONNECTOR_ICONS[type]}</span>
|
||||
<span className={styles.connectorLabel}>{CONNECTOR_LABELS[type]}</span>
|
||||
|
|
@ -260,151 +164,103 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- Step 1: Consent ---- */}
|
||||
{state.step === 1 && (
|
||||
{/* ---- Step: Consent ---- */}
|
||||
{state.currentStep === 'consent' && (
|
||||
<div className={styles.stepContent}>
|
||||
<div className={styles.consentIcon}><FaDatabase size={32} /></div>
|
||||
<h3 className={styles.stepTitle}>Wissensdatenbank</h3>
|
||||
<p className={styles.stepBody}>
|
||||
Möchtest du Inhalte aus dieser Verbindung in deine persönliche
|
||||
Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen
|
||||
aus{' '}
|
||||
{state.connector ? CONNECTOR_LABELS[state.connector] : 'diesem Dienst'}{' '}
|
||||
zurückgreifen kann?
|
||||
aus {state.connector ? CONNECTOR_LABELS[state.connector] : 'diesem Dienst'} zurückgreifen kann?
|
||||
</p>
|
||||
<p className={styles.stepHint}>
|
||||
Du kannst diese Einstellung später in den Verbindungsdetails ändern.
|
||||
Du kannst dies später jederzeit in der UDB pro Datenquelle steuern.
|
||||
</p>
|
||||
<div className={styles.consentButtons}>
|
||||
<button type="button" className={styles.consentButtonYes} onClick={() => setConsent(true)}>
|
||||
<FaCheck /> Ja, aktivieren
|
||||
</button>
|
||||
<button type="button" className={styles.consentButtonNo} onClick={() => setConsent(false)}>
|
||||
Nein, überspringen
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.stepNavLeft}>
|
||||
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- Step: MSFT Admin Consent ---- */}
|
||||
{state.currentStep === 'msftAdminConsent' && (
|
||||
<div className={styles.stepContent}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||
<FaShieldAlt size={32} style={{ color: '#00a4ef' }} />
|
||||
</div>
|
||||
<h3 className={styles.stepTitle}>Organisations-Zustimmung (optional)</h3>
|
||||
<p className={styles.stepBody}>
|
||||
Falls du Mandant-Administrator bist, kannst du jetzt für deine ganze Organisation zustimmen.
|
||||
So müssen andere Benutzer nicht einzeln bestätigen.
|
||||
</p>
|
||||
<p className={styles.stepHint}>
|
||||
Wenn du kein Admin bist oder dies später tun möchtest, überspringe diesen Schritt.
|
||||
</p>
|
||||
<div className={styles.consentButtons}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.consentButtonYes}
|
||||
onClick={() => setKnowledgeEnabled(true)}
|
||||
onClick={() => { onMsftAdminConsent?.(); setState(s => ({ ...s, adminConsentDone: true })); goNext(); }}
|
||||
>
|
||||
<FaCheck /> Ja, aufnehmen
|
||||
<FaShieldAlt /> Admin-Zustimmung erteilen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.consentButtonNo}
|
||||
onClick={() => setKnowledgeEnabled(false)}
|
||||
>
|
||||
Nein, überspringen
|
||||
<button type="button" className={styles.consentButtonNo} onClick={goNext}>
|
||||
Überspringen
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.stepNavLeft}>
|
||||
<button type="button" className={styles.navBack} onClick={() => setStep(0)}>
|
||||
Zurück
|
||||
</button>
|
||||
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- Step 2: Preferences ---- */}
|
||||
{state.step === 2 && (
|
||||
{/* ---- Step: Infomaniak PAT ---- */}
|
||||
{state.currentStep === 'infomaniakPat' && (
|
||||
<div className={styles.stepContent}>
|
||||
<h3 className={styles.stepTitle}>Einstellungen</h3>
|
||||
<p className={styles.stepHint}>
|
||||
Steuere, welche Inhalte und in welcher Form sie indexiert werden.
|
||||
<h3 className={styles.stepTitle}>Infomaniak Personal Access Token</h3>
|
||||
<p className={styles.stepBody}>
|
||||
Erstelle einen Personal Access Token in deinem Infomaniak-Konto und füge ihn hier ein.
|
||||
</p>
|
||||
|
||||
<div className={styles.prefGroup}>
|
||||
<label className={styles.prefLabel}>
|
||||
<FaShieldAlt className={styles.prefIcon} />
|
||||
Anonymisierung vor dem Indexieren
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!state.prefs.neutralizeBeforeEmbed}
|
||||
onChange={e => updatePref('neutralizeBeforeEmbed', e.target.checked)}
|
||||
className={styles.prefCheck}
|
||||
/>
|
||||
</label>
|
||||
<p className={styles.prefHint}>
|
||||
Persönliche Daten (Namen, E-Mail-Adressen) werden vor dem Speichern ersetzt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(state.connector === 'google' || state.connector === 'msft') && (
|
||||
<>
|
||||
<div className={styles.prefGroup}>
|
||||
<label className={styles.prefLabelRow}>
|
||||
E-Mail-Inhalt
|
||||
<select
|
||||
value={state.prefs.mailContentDepth ?? 'full'}
|
||||
onChange={e => updatePref('mailContentDepth', e.target.value as any)}
|
||||
className={styles.prefSelect}
|
||||
>
|
||||
<option value="metadata">Nur Metadaten (Betreff, Absender, Datum)</option>
|
||||
<option value="snippet">Vorschautext (ca. 250 Zeichen)</option>
|
||||
<option value="full">Vollständiger Text</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className={styles.prefGroup}>
|
||||
<label className={styles.prefLabel}>
|
||||
E-Mail-Anhänge indexieren
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!state.prefs.mailIndexAttachments}
|
||||
onChange={e => updatePref('mailIndexAttachments', e.target.checked)}
|
||||
className={styles.prefCheck}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.connector === 'clickup' && (
|
||||
<div className={styles.prefGroup}>
|
||||
<label className={styles.prefLabelRow}>
|
||||
Aufgaben-Inhalt
|
||||
<select
|
||||
value={state.prefs.clickupScope ?? 'title_description'}
|
||||
onChange={e => updatePref('clickupScope', e.target.value as any)}
|
||||
className={styles.prefSelect}
|
||||
>
|
||||
<option value="titles">Nur Aufgabentitel</option>
|
||||
<option value="title_description">Titel + Beschreibung</option>
|
||||
<option value="with_comments">Titel + Beschreibung + Kommentare</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.prefGroup}>
|
||||
<label className={styles.prefLabelRow}>
|
||||
Zeitfenster (Tage)
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={3650}
|
||||
value={state.prefs.maxAgeDays ?? 90}
|
||||
onChange={e => updatePref('maxAgeDays', parseInt(e.target.value, 10) || 0)}
|
||||
className={styles.prefNumber}
|
||||
/>
|
||||
</label>
|
||||
<p className={styles.prefHint}>0 = kein Limit</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="password"
|
||||
placeholder="pat_..."
|
||||
value={state.infomaniakToken}
|
||||
onChange={e => setState(s => ({ ...s, infomaniakToken: e.target.value }))}
|
||||
className={styles.patInput}
|
||||
autoFocus
|
||||
/>
|
||||
<div className={styles.stepNav}>
|
||||
<button type="button" className={styles.navBack} onClick={() => setStep(1)}>
|
||||
Zurück
|
||||
</button>
|
||||
<button type="button" className={styles.navNext} onClick={() => setStep(3)}>
|
||||
Weiter <FaArrowRight size={12} />
|
||||
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navConnect}
|
||||
onClick={handleFinalConnect}
|
||||
disabled={isConnecting || !state.infomaniakToken.trim()}
|
||||
>
|
||||
{isConnecting ? 'Verbinden…' : 'Verbinden'}
|
||||
{!isConnecting && <FaArrowRight size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- Step 3: Summary ---- */}
|
||||
{state.step === 3 && (
|
||||
{/* ---- Step: Connect ---- */}
|
||||
{state.currentStep === 'connect' && (
|
||||
<div className={styles.stepContent}>
|
||||
<h3 className={styles.stepTitle}>Zusammenfassung</h3>
|
||||
<h3 className={styles.stepTitle}>Verbindung herstellen</h3>
|
||||
<div className={styles.summary}>
|
||||
<div className={styles.summaryRow}>
|
||||
<span className={styles.summaryKey}>Anbieter</span>
|
||||
<span className={styles.summaryVal}>
|
||||
{CONNECTOR_ICONS[state.connector!]}
|
||||
{state.connector && CONNECTOR_ICONS[state.connector]}
|
||||
{state.connector ? CONNECTOR_LABELS[state.connector] : '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -414,96 +270,13 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
|
|||
{state.knowledgeEnabled ? '✓ Aktiv' : '✗ Nicht aktiv'}
|
||||
</span>
|
||||
</div>
|
||||
{state.knowledgeEnabled && (
|
||||
<>
|
||||
<div className={styles.summaryRow}>
|
||||
<span className={styles.summaryKey}>Anonymisierung</span>
|
||||
<span className={styles.summaryVal}>
|
||||
{state.prefs.neutralizeBeforeEmbed ? 'Ja' : 'Nein'}
|
||||
</span>
|
||||
</div>
|
||||
{(state.connector === 'google' || state.connector === 'msft') && (
|
||||
<div className={styles.summaryRow}>
|
||||
<span className={styles.summaryKey}>E-Mail-Tiefe</span>
|
||||
<span className={styles.summaryVal}>
|
||||
{{ metadata: 'Nur Metadaten', snippet: 'Vorschautext', full: 'Volltext' }[
|
||||
state.prefs.mailContentDepth ?? 'full'
|
||||
] ?? state.prefs.mailContentDepth}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{state.connector === 'clickup' && (
|
||||
<div className={styles.summaryRow}>
|
||||
<span className={styles.summaryKey}>Aufgaben-Inhalt</span>
|
||||
<span className={styles.summaryVal}>
|
||||
{{
|
||||
titles: 'Nur Titel',
|
||||
title_description: 'Titel + Beschreibung',
|
||||
with_comments: 'Titel + Beschreibung + Kommentare',
|
||||
}[state.prefs.clickupScope ?? 'title_description'] ?? state.prefs.clickupScope}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.summaryRow}>
|
||||
<span className={styles.summaryKey}>Zeitfenster</span>
|
||||
<span className={styles.summaryVal}>
|
||||
{state.prefs.maxAgeDays ? `${state.prefs.maxAgeDays} Tage` : 'Unbegrenzt'}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cost estimate — only shown when knowledge ingestion is enabled */}
|
||||
{state.knowledgeEnabled && (() => {
|
||||
const est = computeCostEstimate(state.connector, state.prefs);
|
||||
if (!est) return null;
|
||||
return (
|
||||
<div className={styles.costHint}>
|
||||
<FaInfoCircle className={styles.costHintIcon} />
|
||||
<div>
|
||||
<span className={styles.costHintTitle}>Geschätzte Kosten (erster Sync)</span>
|
||||
<table className={styles.costTable}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className={styles.costLabel}>Embedding</td>
|
||||
<td className={styles.costVal}>
|
||||
{est.embeddingLow} – {est.embeddingHigh}
|
||||
</td>
|
||||
</tr>
|
||||
{est.neutralizationLow && (
|
||||
<tr className={styles.costRowNeut}>
|
||||
<td className={styles.costLabel}>Anonymisierung (Private LLM)</td>
|
||||
<td className={styles.costVal}>
|
||||
{est.neutralizationLow} – {est.neutralizationHigh}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{est.neutralizationLow && (
|
||||
<span className={styles.costHintWarn}>
|
||||
⚠ Anonymisierung ist der Hauptkostentreiber (CHF 0.01 pro LLM-Aufruf, on-premise).
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.costHintNote}>{est.note}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className={styles.stepNav}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navBack}
|
||||
onClick={() => setStep(state.knowledgeEnabled ? 2 : 1)}
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navConnect}
|
||||
onClick={handleConnect}
|
||||
onClick={handleFinalConnect}
|
||||
disabled={isConnecting}
|
||||
>
|
||||
{isConnecting ? 'Verbinden…' : `Mit ${state.connector ? CONNECTOR_LABELS[state.connector] : '…'} verbinden`}
|
||||
|
|
|
|||
|
|
@ -254,35 +254,30 @@
|
|||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
background: var(--bg-primary, #fff);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Toolbar: context (load + name) is fluid with ellipsis; actions stay right-aligned. */
|
||||
/* Toolbar: context (load + name) is fluid with ellipsis; actions wrap below on narrow viewports. */
|
||||
.canvasHeaderRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 0.75rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.canvasHeaderRow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.canvasHeaderContext {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* Closed <select> width must not follow the longest option label. */
|
||||
.canvasHeaderWorkflowSelect {
|
||||
flex: 0 0 auto;
|
||||
width: 12.5rem;
|
||||
flex: 0 1 12.5rem;
|
||||
min-width: 8rem;
|
||||
max-width: 100%;
|
||||
padding: 0.4rem 0.5rem;
|
||||
min-height: 2rem;
|
||||
|
|
@ -347,6 +342,7 @@
|
|||
background: var(--bg-secondary, #f8f9fa);
|
||||
flex: 0 1 auto;
|
||||
max-width: 100%;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* .retryButton sets margin-top for legacy error stacks — not wanted in the toolbar. */
|
||||
|
|
@ -366,6 +362,8 @@
|
|||
@media (max-width: 900px) {
|
||||
.canvasHeaderActionPanel {
|
||||
justify-content: flex-start;
|
||||
margin-left: 0;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -101,9 +101,8 @@
|
|||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
/* Share remaining viewport among expanded groups; scroll when many groups */
|
||||
flex: 1 1 280px;
|
||||
min-height: 0;
|
||||
flex: 1 1 400px;
|
||||
min-height: 350px;
|
||||
}
|
||||
|
||||
.groupSectionCollapsed {
|
||||
|
|
|
|||
|
|
@ -681,7 +681,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
resizable = true,
|
||||
pagination = true,
|
||||
pageSize = 10,
|
||||
pageSizeOptions = [10, 25, 50, 100, 500],
|
||||
pageSizeOptions = [10, 25, 50, 100, 500, 1000, 2000, 10000],
|
||||
showPageSizeSelector = true,
|
||||
onRowClick,
|
||||
onRowSelect,
|
||||
|
|
@ -740,13 +740,16 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
const [activeViewKey, setActiveViewKey] = useState<string | null>(null);
|
||||
const [activeViewId, setActiveViewId] = useState<string | null>(null);
|
||||
const [groupByLevels, setGroupByLevels] = useState<GroupByLevelSpec[]>([]);
|
||||
const useSectionsGroupLayout =
|
||||
tableGroupLayoutMode === 'sections' &&
|
||||
const [groupLayoutMode, setGroupLayoutMode] = useState<'inline' | 'sections'>(tableGroupLayoutMode ?? 'inline');
|
||||
|
||||
const canUseSections =
|
||||
!!tableContextKey &&
|
||||
groupByLevels.length === 1 &&
|
||||
groupByLevels.length > 0 &&
|
||||
typeof hookDataProp?.fetchGroupSectionSummaries === 'function' &&
|
||||
typeof hookDataProp?.refetchForSection === 'function';
|
||||
|
||||
const useSectionsGroupLayout = canUseSections && groupLayoutMode === 'sections';
|
||||
|
||||
const [sectionSummaries, setSectionSummaries] = useState<
|
||||
Array<{ value: string | null; label: string; totalCount: number }>
|
||||
>([]);
|
||||
|
|
@ -1360,6 +1363,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
viewKey: activeViewKey,
|
||||
groupField: spec.field,
|
||||
groupDirection: spec.direction || 'asc',
|
||||
groupByLevels: groupLevelsToApiPayload(groupByLevels),
|
||||
});
|
||||
if (!cancelled) setSectionSummaries(Array.isArray(list) ? list : []);
|
||||
} catch (e) {
|
||||
|
|
@ -2750,6 +2754,27 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
onUpdateViewGrouping={(id, levels) => void handleUpdateViewGrouping(id, levels)}
|
||||
onDeleteView={(id) => void handleDeleteView(id)}
|
||||
onReloadViews={() => void reloadViews()}
|
||||
canUseSections={canUseSections}
|
||||
groupLayoutMode={groupLayoutMode}
|
||||
onGroupLayoutModeChange={(mode) => {
|
||||
setGroupLayoutMode(mode);
|
||||
setCollapsedGroups(new Set());
|
||||
setCollapsedSectionKeys(new Set());
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
hasGroupBands={!!effectiveGroupLayout && effectiveGroupLayout.bands.length > 0}
|
||||
onCollapseAll={() => {
|
||||
if (effectiveGroupLayout) {
|
||||
setCollapsedGroups(new Set(effectiveGroupLayout.bands.map((b) => b.path.join('///'))));
|
||||
}
|
||||
if (useSectionsGroupLayout) {
|
||||
setCollapsedSectionKeys(new Set(sectionSummaries.map((g) => g.value === null || g.value === undefined ? '__empty__' : String(g.value))));
|
||||
}
|
||||
}}
|
||||
onExpandAll={() => {
|
||||
setCollapsedGroups(new Set());
|
||||
setCollapsedSectionKeys(new Set());
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -3341,13 +3366,23 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
)}
|
||||
<div className={styles.groupSections}>
|
||||
{sectionSummaries.map((g) => {
|
||||
const field = groupByLevels[0].field;
|
||||
const sectionFilter: Record<string, unknown> = {
|
||||
[field]: g.value === null || g.value === undefined ? null : g.value,
|
||||
};
|
||||
const isMultiLevel = groupByLevels.length > 1 && (g as any).filters;
|
||||
const sectionFilter: Record<string, unknown> = isMultiLevel
|
||||
? (g as any).filters
|
||||
: { [groupByLevels[0].field]: g.value === null || g.value === undefined ? null : g.value };
|
||||
const groupFields = isMultiLevel
|
||||
? groupByLevels.map((l) => l.field)
|
||||
: [groupByLevels[0].field];
|
||||
const sk =
|
||||
g.value === null || g.value === undefined ? '__empty__' : String(g.value);
|
||||
const sectionCollapsed = collapsedSectionKeys.has(sk);
|
||||
const groupFieldSet = new Set(groupFields);
|
||||
const sectionColumns = (providedColumns ?? []).map((col: any) =>
|
||||
groupFieldSet.has(col.key) ? { ...col, filterable: false } : col,
|
||||
);
|
||||
const sectionInitialFilters = Object.fromEntries(
|
||||
Object.entries(filters).filter(([k]) => !groupFieldSet.has(k)),
|
||||
);
|
||||
return (
|
||||
<section
|
||||
key={sk}
|
||||
|
|
@ -3382,9 +3417,9 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
</button>
|
||||
{!sectionCollapsed && (
|
||||
<FormGeneratorTable<T>
|
||||
key={`${sk}-r${refreshNonce}-${JSON.stringify(filters)}-${JSON.stringify(sortConfigs)}-${activeViewKey ?? ''}`}
|
||||
key={`${sk}-r${refreshNonce}-${JSON.stringify(sectionInitialFilters)}-${JSON.stringify(sortConfigs)}-${activeViewKey ?? ''}`}
|
||||
className={styles.groupSectionTableWrap}
|
||||
columns={providedColumns}
|
||||
columns={sectionColumns}
|
||||
data={[]}
|
||||
searchable={false}
|
||||
filterable={filterable}
|
||||
|
|
@ -3415,7 +3450,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
localDataMode
|
||||
viewKeyForQueries={activeViewKey}
|
||||
initialSearchTerm={debouncedSearchTerm}
|
||||
initialFilters={filters}
|
||||
initialFilters={sectionInitialFilters}
|
||||
initialSort={sortConfigs}
|
||||
apiEndpoint={apiEndpoint}
|
||||
csvExportQueryParams={hookDataProp?.csvExportQueryParams}
|
||||
|
|
@ -3427,13 +3462,13 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
if (!hookDataProp?.refetchForSection) {
|
||||
return { items: [], pagination: null };
|
||||
}
|
||||
return hookDataProp.refetchForSection(p, sectionFilter, filters);
|
||||
return hookDataProp.refetchForSection(p, sectionFilter, sectionInitialFilters);
|
||||
},
|
||||
...(hookDataProp?.fetchFilterValues && typeof hookDataProp.fetchFilterValues === 'function'
|
||||
? {
|
||||
fetchFilterValues: async (columnKey: string, crossFilters?: Record<string, any>) => {
|
||||
const merged: Record<string, any> = {
|
||||
...filters,
|
||||
...sectionInitialFilters,
|
||||
...(crossFilters || {}),
|
||||
...sectionFilter,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -58,6 +58,13 @@ function _buildChildMap<T>(nodes: TreeNode<T>[]): Map<string | '__root__', TreeN
|
|||
if (list) list.push(n);
|
||||
else map.set(key, [n]);
|
||||
}
|
||||
for (const [, children] of map) {
|
||||
children.sort((a, b) => {
|
||||
if (a.type === 'folder' && b.type !== 'folder') return -1;
|
||||
if (a.type !== 'folder' && b.type === 'folder') return 1;
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
|
|
@ -409,7 +416,7 @@ export function FormGeneratorTree<T = any>({
|
|||
className,
|
||||
}: FormGeneratorTreeProps<T>) {
|
||||
const { t } = useLanguage();
|
||||
const { confirm } = useConfirm();
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
const { prompt, PromptDialog } = usePrompt();
|
||||
const [nodes, setNodes] = useState<TreeNode<T>[]>([]);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
|
|
@ -651,10 +658,14 @@ export function FormGeneratorTree<T = any>({
|
|||
const _handleCycleScope = useCallback(
|
||||
async (node: TreeNode<T>) => {
|
||||
const newScope = _nextScope(node.scope);
|
||||
await provider.patchScope?.([node.id], newScope);
|
||||
setNodes((prev) =>
|
||||
prev.map((n) => (n.id === node.id ? { ...n, scope: newScope } : n)),
|
||||
);
|
||||
const isFolder = node.type === 'folder';
|
||||
await provider.patchScope?.([node.id], newScope, isFolder);
|
||||
setNodes((prev) => {
|
||||
if (!isFolder) return prev.map((n) => (n.id === node.id ? { ...n, scope: newScope } : n));
|
||||
const descendantIds = new Set(_collectDescendantIds(node.id, prev));
|
||||
descendantIds.add(node.id);
|
||||
return prev.map((n) => descendantIds.has(n.id) ? { ...n, scope: newScope } : n);
|
||||
});
|
||||
},
|
||||
[provider],
|
||||
);
|
||||
|
|
@ -663,9 +674,12 @@ export function FormGeneratorTree<T = any>({
|
|||
async (node: TreeNode<T>) => {
|
||||
const newValue = !node.neutralize;
|
||||
await provider.patchNeutralize?.([node.id], newValue);
|
||||
setNodes((prev) =>
|
||||
prev.map((n) => (n.id === node.id ? { ...n, neutralize: newValue } : n)),
|
||||
);
|
||||
setNodes((prev) => {
|
||||
if (node.type !== 'folder') return prev.map((n) => (n.id === node.id ? { ...n, neutralize: newValue } : n));
|
||||
const descendantIds = new Set(_collectDescendantIds(node.id, prev));
|
||||
descendantIds.add(node.id);
|
||||
return prev.map((n) => descendantIds.has(n.id) ? { ...n, neutralize: newValue } : n);
|
||||
});
|
||||
},
|
||||
[provider],
|
||||
);
|
||||
|
|
@ -1018,6 +1032,7 @@ export function FormGeneratorTree<T = any>({
|
|||
</div>
|
||||
)}
|
||||
<PromptDialog />
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,6 +153,51 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.layoutToggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border, #cbd5e1);
|
||||
background: var(--color-bg, #fff);
|
||||
color: var(--color-text, #0f172a);
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.layoutToggle:hover {
|
||||
background: var(--bg-hover, rgba(15, 23, 42, 0.04));
|
||||
border-color: var(--color-primary, #64748b);
|
||||
}
|
||||
|
||||
.collapseExpandGroup {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.collapseBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border, #cbd5e1);
|
||||
background: var(--color-bg, #fff);
|
||||
color: var(--text-secondary, #94a3b8);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.collapseBtn:hover {
|
||||
background: var(--bg-hover, rgba(15, 23, 42, 0.04));
|
||||
color: var(--color-text, #0f172a);
|
||||
}
|
||||
|
||||
.viewBlock {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||
import { FaLayerGroup, FaTrash } from 'react-icons/fa';
|
||||
import { TbLayoutList, TbLayoutRows } from 'react-icons/tb';
|
||||
import { FiChevronsDown, FiChevronsUp } from 'react-icons/fi';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from './TableViewsBar.module.css';
|
||||
|
||||
|
|
@ -30,6 +32,12 @@ export interface TableViewsBarProps {
|
|||
onUpdateViewGrouping: (viewId: string, levels: GroupByLevelSpec[]) => void | Promise<void>;
|
||||
onDeleteView?: (viewId: string) => void | Promise<void>;
|
||||
onReloadViews: () => void;
|
||||
canUseSections?: boolean;
|
||||
groupLayoutMode?: 'inline' | 'sections';
|
||||
onGroupLayoutModeChange?: (mode: 'inline' | 'sections') => void;
|
||||
hasGroupBands?: boolean;
|
||||
onCollapseAll?: () => void;
|
||||
onExpandAll?: () => void;
|
||||
}
|
||||
|
||||
function slugify(name: string): string {
|
||||
|
|
@ -74,6 +82,12 @@ export function TableViewsBar({
|
|||
onUpdateViewGrouping,
|
||||
onDeleteView,
|
||||
onReloadViews,
|
||||
canUseSections,
|
||||
groupLayoutMode,
|
||||
onGroupLayoutModeChange,
|
||||
hasGroupBands,
|
||||
onCollapseAll,
|
||||
onExpandAll,
|
||||
}: TableViewsBarProps) {
|
||||
const { t } = useLanguage();
|
||||
const [groupMenuOpen, setGroupMenuOpen] = useState(false);
|
||||
|
|
@ -249,6 +263,41 @@ export function TableViewsBar({
|
|||
: `${t('Aktiv')}: ${summary}`}
|
||||
</span>
|
||||
|
||||
{canUseSections && groupByLevels.length > 0 && onGroupLayoutModeChange && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.layoutToggle}
|
||||
title={groupLayoutMode === 'inline' ? t('Zu Sektionen wechseln') : t('Zu Inline wechseln')}
|
||||
aria-label={groupLayoutMode === 'inline' ? t('Zu Sektionen wechseln') : t('Zu Inline wechseln')}
|
||||
onClick={() => onGroupLayoutModeChange(groupLayoutMode === 'inline' ? 'sections' : 'inline')}
|
||||
>
|
||||
{groupLayoutMode === 'inline' ? <TbLayoutRows /> : <TbLayoutList />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasGroupBands && onCollapseAll && onExpandAll && (
|
||||
<div className={styles.collapseExpandGroup}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.collapseBtn}
|
||||
title={t('Alle zuklappen')}
|
||||
aria-label={t('Alle zuklappen')}
|
||||
onClick={onCollapseAll}
|
||||
>
|
||||
<FiChevronsUp />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.collapseBtn}
|
||||
title={t('Alle aufklappen')}
|
||||
aria-label={t('Alle aufklappen')}
|
||||
onClick={onExpandAll}
|
||||
>
|
||||
<FiChevronsDown />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.viewBlock}>
|
||||
<span className={styles.viewLabel}>{t('Ansicht')}</span>
|
||||
<select
|
||||
|
|
|
|||
95
src/components/RagRunningBadge/RagRunningBadge.module.css
Normal file
95
src/components/RagRunningBadge/RagRunningBadge.module.css
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
.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;
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
71
src/components/RagRunningBadge/RagRunningBadge.tsx
Normal file
71
src/components/RagRunningBadge/RagRunningBadge.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
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_MS = 60_000;
|
||||
|
||||
export const RagRunningBadge: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { request } = useApiRequest();
|
||||
const [jobs, setJobs] = useState<_RagJob[]>([]);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const _fetchJobs = useCallback(async () => {
|
||||
try {
|
||||
const result = await request({ url: '/api/rag/inventory/jobs', method: 'get' });
|
||||
setJobs(Array.isArray(result) ? result : []);
|
||||
} catch {
|
||||
setJobs([]);
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
useEffect(() => {
|
||||
_fetchJobs();
|
||||
timerRef.current = setInterval(_fetchJobs, _POLL_INTERVAL_MS);
|
||||
return () => { if (timerRef.current) clearInterval(timerRef.current); };
|
||||
}, [_fetchJobs]);
|
||||
|
||||
if (jobs.length === 0) return null;
|
||||
|
||||
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.progress != null ? `${Math.round(job.progress)}%` : '...'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -42,6 +42,7 @@ interface UdbDataSource {
|
|||
displayPath?: string;
|
||||
scope: string;
|
||||
neutralize: boolean;
|
||||
ragIndexEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface UdbFeatureDataSource {
|
||||
|
|
@ -342,7 +343,7 @@ async function _loadServices(instanceId: string, connectionId: string): Promise<
|
|||
service: s.service,
|
||||
path: '/',
|
||||
displayPath: s.label || s.service,
|
||||
}));
|
||||
})).sort((a: TreeNode, b: TreeNode) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }));
|
||||
}
|
||||
|
||||
async function _browseService(
|
||||
|
|
@ -374,6 +375,10 @@ async function _browseService(
|
|||
path: entry.path,
|
||||
displayPath,
|
||||
};
|
||||
}).sort((a: TreeNode, b: TreeNode) => {
|
||||
if (a.type === 'folder' && b.type !== 'folder') return -1;
|
||||
if (a.type !== 'folder' && b.type === 'folder') return 1;
|
||||
return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' });
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -495,6 +500,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
|
|||
scope: d.scope || 'personal',
|
||||
neutralize: d.neutralize ?? false,
|
||||
}));
|
||||
list.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }));
|
||||
setDataSources(list);
|
||||
})
|
||||
.catch(() => { if (mountedRef.current) setDataSources([]); });
|
||||
|
|
@ -518,6 +524,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
|
|||
neutralizeFields: d.neutralizeFields || undefined,
|
||||
recordFilter: d.recordFilter || undefined,
|
||||
}));
|
||||
list.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }));
|
||||
setFeatureDataSources(list);
|
||||
})
|
||||
.catch(() => { if (mountedRef.current) setFeatureDataSources([]); });
|
||||
|
|
@ -546,7 +553,8 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
|
|||
children: null,
|
||||
connectionId: c.id,
|
||||
authority: c.authority,
|
||||
}));
|
||||
}))
|
||||
.sort((a: TreeNode, b: TreeNode) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }));
|
||||
setTree(nodes);
|
||||
})
|
||||
.catch(() => { if (mountedRef.current) setTree([]); })
|
||||
|
|
@ -689,6 +697,17 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
|
|||
}
|
||||
}, []);
|
||||
|
||||
/* ── RAG-Index toggle (personal data source, optimistic) ── */
|
||||
const _togglePersonalRagIndex = useCallback(async (ds: UdbDataSource) => {
|
||||
const newValue = !ds.ragIndexEnabled;
|
||||
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, ragIndexEnabled: newValue } : d));
|
||||
try {
|
||||
await api.patch(`/api/datasources/${ds.id}/rag-index`, { ragIndexEnabled: newValue });
|
||||
} catch {
|
||||
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, ragIndexEnabled: ds.ragIndexEnabled } : d));
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* ── Scope change (feature data source, optimistic) ── */
|
||||
const _cycleFeatureScope = useCallback(async (fds: UdbFeatureDataSource) => {
|
||||
const newScope = _nextScope(fds.scope);
|
||||
|
|
@ -748,7 +767,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
|
|||
expanded: false,
|
||||
loading: false,
|
||||
tables: null,
|
||||
})),
|
||||
})).sort((a: FeatureConnectionNode, b: FeatureConnectionNode) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })),
|
||||
})));
|
||||
})
|
||||
.catch(() => { if (mountedRef.current) setFeatureTree([]); })
|
||||
|
|
@ -786,14 +805,14 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
|
|||
objectKey: t.objectKey ?? '',
|
||||
tableName: t.tableName ?? '',
|
||||
label: t.label ?? '',
|
||||
fields: t.fields ?? [],
|
||||
fields: (t.fields ?? []).slice().sort((a: string, b: string) => a.localeCompare(b, undefined, { sensitivity: 'base' })),
|
||||
isParent: Boolean(t.isParent),
|
||||
parentTable: t.parentTable ?? null,
|
||||
parentKey: t.parentKey ?? null,
|
||||
displayFields: t.displayFields ?? [],
|
||||
isGroup: Boolean(t.isGroup),
|
||||
group: t.group ?? null,
|
||||
}));
|
||||
})).sort((a: FeatureTableNode, b: FeatureTableNode) => (a.label || a.tableName).localeCompare(b.label || b.tableName, undefined, { sensitivity: 'base' }));
|
||||
|
||||
// Default-expand all categorical groups so users immediately see their content.
|
||||
const defaultExpansions: string[] = tables
|
||||
|
|
@ -900,7 +919,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
|
|||
displayLabel: r.displayLabel || r.id,
|
||||
fields: r.fields || {},
|
||||
tableName: table.tableName,
|
||||
}));
|
||||
})).sort((a: ParentRecordNode, b: ParentRecordNode) => a.displayLabel.localeCompare(b.displayLabel, undefined, { sensitivity: 'base' }));
|
||||
if (mountedRef.current) {
|
||||
setFeatureRecordsByPath(prev => ({ ...prev, [pathKey]: records }));
|
||||
}
|
||||
|
|
@ -1018,6 +1037,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
|
|||
dataSources={dataSources}
|
||||
onCycleScope={_cyclePersonalScope}
|
||||
onToggleNeutralize={_togglePersonalNeutralize}
|
||||
onToggleRagIndex={_togglePersonalRagIndex}
|
||||
onSendToChat={_sendNodeToChat}
|
||||
scopeCycleTitle={_scopeCycleTitle}
|
||||
selectedKeys={selectedKeys}
|
||||
|
|
@ -1105,18 +1125,20 @@ interface _TreeNodeViewProps {
|
|||
dataSources: UdbDataSource[];
|
||||
onCycleScope: (ds: UdbDataSource) => void;
|
||||
onToggleNeutralize: (ds: UdbDataSource) => void;
|
||||
onToggleRagIndex: (ds: UdbDataSource) => void;
|
||||
onSendToChat?: (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => void;
|
||||
scopeCycleTitle: (scope: string) => string;
|
||||
selectedKeys: Set<string>;
|
||||
onSelect: (node: TreeNode, e: React.MouseEvent) => void;
|
||||
inheritedScope?: string;
|
||||
inheritedNeutralize?: boolean;
|
||||
inheritedRagIndex?: boolean;
|
||||
}
|
||||
|
||||
const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
||||
node, depth, onToggle, onEnsureDs, isAdded, addingPath,
|
||||
dataSources, onCycleScope, onToggleNeutralize, onSendToChat, scopeCycleTitle,
|
||||
selectedKeys, onSelect, inheritedScope, inheritedNeutralize,
|
||||
dataSources, onCycleScope, onToggleNeutralize, onToggleRagIndex, onSendToChat, scopeCycleTitle,
|
||||
selectedKeys, onSelect, inheritedScope, inheritedNeutralize, inheritedRagIndex,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
|
@ -1128,8 +1150,10 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
|||
|
||||
const effectiveScope = ds?.scope ?? inheritedScope;
|
||||
const effectiveNeutralize = ds?.neutralize ?? inheritedNeutralize ?? false;
|
||||
const effectiveRagIndex = ds?.ragIndexEnabled ?? inheritedRagIndex ?? false;
|
||||
const childInheritedScope = ds?.scope ?? inheritedScope;
|
||||
const childInheritedNeutralize = ds?.neutralize ?? inheritedNeutralize;
|
||||
const childInheritedRagIndex = ds?.ragIndexEnabled ?? inheritedRagIndex;
|
||||
|
||||
const _dragPayload = {
|
||||
connectionId: node.connectionId,
|
||||
|
|
@ -1212,11 +1236,24 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
|||
{node.label}
|
||||
</span>
|
||||
|
||||
{/* ── Stable trio: chat | scope | neutralize (always in this order).
|
||||
* No "remove from workspace" button here by design: the UDB row only
|
||||
* exposes the catalog state. Detach from the *current chat* happens
|
||||
* via the chip "x" in WorkspaceInput; that chip is the single source
|
||||
* of truth for chat-scoped attachment lifecycle. */}
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (ds) { onToggleRagIndex(ds); return; }
|
||||
const newId = await onEnsureDs(node);
|
||||
if (newId) {
|
||||
try { await api.patch(`/api/datasources/${newId}/rag-index`, { ragIndexEnabled: !effectiveRagIndex }); } catch {}
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
|
||||
opacity: (ds?.ragIndexEnabled ?? effectiveRagIndex) ? 1 : 0.35,
|
||||
}}
|
||||
title={(ds?.ragIndexEnabled ?? effectiveRagIndex) ? t('RAG-Indexierung an') : t('RAG-Indexierung aus')}
|
||||
>
|
||||
{'\uD83E\uDDE0'}
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onSendToChat?.(_chatPayload); }}
|
||||
style={{
|
||||
|
|
@ -1278,12 +1315,14 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
|||
dataSources={dataSources}
|
||||
onCycleScope={onCycleScope}
|
||||
onToggleNeutralize={onToggleNeutralize}
|
||||
onToggleRagIndex={onToggleRagIndex}
|
||||
onSendToChat={onSendToChat}
|
||||
scopeCycleTitle={scopeCycleTitle}
|
||||
selectedKeys={selectedKeys}
|
||||
onSelect={onSelect}
|
||||
inheritedScope={childInheritedScope}
|
||||
inheritedNeutralize={childInheritedNeutralize}
|
||||
inheritedRagIndex={childInheritedRagIndex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
|||
'page.system.billingAdmin': <FaMoneyBillAlt />,
|
||||
'page.system.statistics': <FaChartBar />,
|
||||
'page.system.automations': <FaRobot />,
|
||||
'page.system.ragInventory': <FaDatabase />,
|
||||
|
||||
// Billing pages (legacy compat)
|
||||
'page.billing.dashboard': <FaWallet />,
|
||||
|
|
@ -109,6 +110,8 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
|||
|
||||
// Feature pages - Teams Bot
|
||||
'page.feature.teamsbot.dashboard': <FaChartLine />,
|
||||
'page.feature.teamsbot.assistant': <FaHatWizard />,
|
||||
'page.feature.teamsbot.modules': <FaCubes />,
|
||||
'page.feature.teamsbot.sessions': <FaVideo />,
|
||||
'page.feature.teamsbot.settings': <FaCog />,
|
||||
|
||||
|
|
|
|||
|
|
@ -101,17 +101,15 @@ export function useConnections() {
|
|||
viewKey?: string | null;
|
||||
groupField: string;
|
||||
groupDirection?: 'asc' | 'desc';
|
||||
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: string }>;
|
||||
}) => {
|
||||
const levels = base.groupByLevels?.length
|
||||
? base.groupByLevels
|
||||
: [{ field: base.groupField, nullLabel: '—', direction: base.groupDirection || 'asc' }];
|
||||
const pObj: Record<string, unknown> = {
|
||||
page: 1,
|
||||
pageSize: 25,
|
||||
groupByLevels: [
|
||||
{
|
||||
field: base.groupField,
|
||||
nullLabel: '—',
|
||||
direction: base.groupDirection || 'asc',
|
||||
},
|
||||
],
|
||||
groupByLevels: levels,
|
||||
};
|
||||
if (base.search) (pObj as { search?: string }).search = base.search;
|
||||
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;
|
||||
|
|
@ -788,7 +786,7 @@ export function useConnections() {
|
|||
* for backward-compat but new wizard code should call this.
|
||||
*/
|
||||
const createConnectionAndAuth = async (
|
||||
type: 'google' | 'msft' | 'clickup',
|
||||
type: 'google' | 'msft' | 'clickup' | 'infomaniak',
|
||||
knowledgeIngestionEnabled: boolean,
|
||||
knowledgePreferences?: import('../api/connectionApi').KnowledgePreferences | null,
|
||||
): Promise<void> => {
|
||||
|
|
|
|||
|
|
@ -149,17 +149,15 @@ export function useUserFiles() {
|
|||
viewKey?: string | null;
|
||||
groupField: string;
|
||||
groupDirection?: 'asc' | 'desc';
|
||||
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: string }>;
|
||||
}) => {
|
||||
const levels = base.groupByLevels?.length
|
||||
? base.groupByLevels
|
||||
: [{ field: base.groupField, nullLabel: '—', direction: base.groupDirection || 'asc' }];
|
||||
const pObj: Record<string, unknown> = {
|
||||
page: 1,
|
||||
pageSize: 25,
|
||||
groupByLevels: [
|
||||
{
|
||||
field: base.groupField,
|
||||
nullLabel: '—',
|
||||
direction: base.groupDirection || 'asc',
|
||||
},
|
||||
],
|
||||
groupByLevels: levels,
|
||||
};
|
||||
if (base.search) (pObj as { search?: string }).search = base.search;
|
||||
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;
|
||||
|
|
|
|||
|
|
@ -98,17 +98,15 @@ export function usePrompts() {
|
|||
viewKey?: string | null;
|
||||
groupField: string;
|
||||
groupDirection?: 'asc' | 'desc';
|
||||
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: string }>;
|
||||
}) => {
|
||||
const levels = base.groupByLevels?.length
|
||||
? base.groupByLevels
|
||||
: [{ field: base.groupField, nullLabel: '—', direction: base.groupDirection || 'asc' }];
|
||||
const pObj: Record<string, unknown> = {
|
||||
page: 1,
|
||||
pageSize: 25,
|
||||
groupByLevels: [
|
||||
{
|
||||
field: base.groupField,
|
||||
nullLabel: '—',
|
||||
direction: base.groupDirection || 'asc',
|
||||
},
|
||||
],
|
||||
groupByLevels: levels,
|
||||
};
|
||||
if (base.search) (pObj as { search?: string }).search = base.search;
|
||||
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;
|
||||
|
|
|
|||
|
|
@ -21,10 +21,17 @@ export interface VoiceStreamCallbacks {
|
|||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
/** Options for the initial `open` message on the generic STT WebSocket (Google streaming). */
|
||||
export interface SttStreamOpenOptions {
|
||||
model?: string;
|
||||
lightweight?: boolean;
|
||||
singleUtterance?: boolean;
|
||||
}
|
||||
|
||||
export interface VoiceStreamApi {
|
||||
status: VoiceStreamStatus;
|
||||
interimText: string;
|
||||
start: (language?: string) => Promise<void>;
|
||||
start: (language?: string, sttOpenOptions?: SttStreamOpenOptions) => Promise<void>;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -42,6 +49,7 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
|
|||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const languageRef = useRef('de-DE');
|
||||
const sttOpenOptsRef = useRef<SttStreamOpenOptions | undefined>(undefined);
|
||||
const stoppingRef = useRef(false);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
|
||||
|
|
@ -94,11 +102,23 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
|
|||
stoppingRef.current = false;
|
||||
}, [_stopRecorder, _closeWs, _releaseDevices, _setStatus]);
|
||||
|
||||
const start = useCallback(async (language?: string) => {
|
||||
const _buildOpenPayload = useCallback(() => {
|
||||
const o = sttOpenOptsRef.current;
|
||||
return {
|
||||
type: 'open' as const,
|
||||
language: languageRef.current,
|
||||
model: o?.model ?? 'latest_long',
|
||||
lightweight: o?.lightweight ?? false,
|
||||
singleUtterance: o?.singleUtterance ?? false,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const start = useCallback(async (language?: string, sttOpenOptions?: SttStreamOpenOptions) => {
|
||||
if (status === 'listening' || status === 'connecting') return;
|
||||
stoppingRef.current = false;
|
||||
reconnectAttemptsRef.current = 0;
|
||||
languageRef.current = language || 'de-DE';
|
||||
sttOpenOptsRef.current = sttOpenOptions;
|
||||
_setStatus('connecting');
|
||||
|
||||
try {
|
||||
|
|
@ -120,7 +140,7 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
|
|||
|
||||
ws.onopen = () => {
|
||||
if (stoppingRef.current) { ws.close(); return; }
|
||||
ws.send(JSON.stringify({ type: 'open', language: languageRef.current }));
|
||||
ws.send(JSON.stringify(_buildOpenPayload()));
|
||||
|
||||
const mimeType = _pickMimeType();
|
||||
const recorder = new MediaRecorder(streamRef.current!, { mimeType });
|
||||
|
|
@ -154,11 +174,15 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
|
|||
cbRef.current.onFinal?.(msg.text);
|
||||
} else if (msg.type === 'error') {
|
||||
cbRef.current.onError?.(new Error(msg.message || msg.code || 'STT error'));
|
||||
} else if (msg.type === 'end_of_single_utterance') {
|
||||
if (!stoppingRef.current && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(_buildOpenPayload()));
|
||||
}
|
||||
} else if (msg.type === 'reconnect_required') {
|
||||
if (reconnectAttemptsRef.current < _MAX_RECONNECT_ATTEMPTS && !stoppingRef.current) {
|
||||
reconnectAttemptsRef.current++;
|
||||
_closeWs();
|
||||
start(languageRef.current).catch(() => {});
|
||||
start(languageRef.current, sttOpenOptsRef.current).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
|
|
@ -183,7 +207,7 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi
|
|||
_releaseDevices();
|
||||
throw err;
|
||||
}
|
||||
}, [status, _setStatus, _pickMimeType, _closeWs, _releaseDevices]);
|
||||
}, [status, _setStatus, _pickMimeType, _closeWs, _releaseDevices, _buildOpenPayload]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive'
|
|||
import { CommcoachKeepAlive } from '../pages/views/commcoach/CommcoachKeepAlive';
|
||||
import { GraphicalEditorKeepAlive } from '../pages/views/graphicalEditor/GraphicalEditorKeepAlive';
|
||||
import { AdminLanguagesKeepAlive } from '../pages/admin/AdminLanguagesKeepAlive';
|
||||
import { RagRunningBadge } from '../components/RagRunningBadge/RagRunningBadge';
|
||||
import styles from './MainLayout.module.css';
|
||||
|
||||
import { useLanguage } from '../providers/language/LanguageContext';
|
||||
|
|
@ -132,6 +133,8 @@ const MainLayoutInner: React.FC = () => {
|
|||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<RagRunningBadge />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ import { GraphicalEditorTemplatesPage } from './views/graphicalEditor/GraphicalE
|
|||
import { WorkspacePage } from './views/workspace/WorkspacePage';
|
||||
import { WorkspaceEditorPage } from './views/workspace/WorkspaceEditorPage';
|
||||
import { WorkspaceSettingsPage } from './views/workspace/WorkspaceSettingsPage';
|
||||
import { WorkspaceRagInsightsPage } from './views/workspace/WorkspaceRagInsightsPage';
|
||||
|
||||
// Teamsbot Views
|
||||
import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
|
||||
|
|
@ -155,7 +154,6 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
|||
workspace: {
|
||||
dashboard: WorkspacePage,
|
||||
editor: WorkspaceEditorPage,
|
||||
'rag-insights': WorkspaceRagInsightsPage,
|
||||
settings: WorkspaceSettingsPage,
|
||||
},
|
||||
teamsbot: {
|
||||
|
|
|
|||
334
src/pages/RagInventoryPage.module.css
Normal file
334
src/pages/RagInventoryPage.module.css
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
255
src/pages/RagInventoryPage.tsx
Normal file
255
src/pages/RagInventoryPage.tsx
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
/**
|
||||
* 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 } 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]);
|
||||
|
||||
useEffect(() => {
|
||||
pollRef.current = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') _fetchInventory();
|
||||
}, 60000);
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
||||
}, [_fetchInventory]);
|
||||
|
||||
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 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>
|
||||
)}
|
||||
|
||||
{conn.lastError && conn.runningJobs.length === 0 && (
|
||||
<div className={styles.errorBanner}>
|
||||
<FaExclamationTriangle />
|
||||
<span>{t('Letzter Job fehlgeschlagen')}: {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>
|
||||
)}
|
||||
|
||||
{conn.runningJobs.length > 0 && (
|
||||
<div className={styles.jobBanner}>
|
||||
<FaSync className={styles.spinIcon} />
|
||||
<span>{conn.runningJobs[0].progressMessage || `${Math.round(conn.runningJobs[0].progress)}%`}</span>
|
||||
<button className={styles.stopBtn} onClick={() => _handleStop(conn.id)} title={t('Indexierung stoppen')}>
|
||||
<FaStop size={12} /> {t('Stop')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!conn.lastError && conn.runningJobs.length === 0 && conn.dataSources.some(ds => ds.ragIndexEnabled) && conn.totalChunks === 0 && conn.knowledgeIngestionEnabled && (
|
||||
<div className={styles.reindexHint}>
|
||||
<button className={styles.reindexBtn} onClick={() => _handleReindex(conn.id)} title={t('Indexierung starten')}>
|
||||
<FaRedo size={12} /> {t('Indexierung starten')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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;
|
||||
|
|
@ -9,8 +9,7 @@ import React, { useState, useMemo, useEffect, useRef } from 'react';
|
|||
import { useConnections, type Connection } from '../../hooks/useConnections';
|
||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaSync, FaLink, FaRedo, FaShieldAlt, FaPlus, FaSpinner, FaTimes, FaSyncAlt, FaCloud } from 'react-icons/fa';
|
||||
import { getApiBaseUrl } from '../../../config/config';
|
||||
import { FaSync, FaLink, FaRedo, FaPlus, FaSpinner, FaTimes, FaSyncAlt } from 'react-icons/fa';
|
||||
import styles from '../admin/Admin.module.css';
|
||||
import bannerStyles from './ConnectionsPage.module.css';
|
||||
import { AddConnectionWizard } from '../../components/AddConnectionWizard/AddConnectionWizard';
|
||||
|
|
@ -42,8 +41,6 @@ export const ConnectionsPage: React.FC = () => {
|
|||
deleteConnection,
|
||||
handleInlineUpdate,
|
||||
createConnectionAndAuth,
|
||||
createInfomaniakConnection,
|
||||
submitInfomaniakToken,
|
||||
connectWithPopup,
|
||||
refreshMicrosoftToken,
|
||||
refreshGoogleToken,
|
||||
|
|
@ -54,7 +51,6 @@ export const ConnectionsPage: React.FC = () => {
|
|||
const [deletingConnections, setDeletingConnections] = useState<Set<string>>(new Set());
|
||||
const [refreshingConnections, setRefreshingConnections] = useState<Set<string>>(new Set());
|
||||
const [reconnectingConnections, setReconnectingConnections] = useState<Set<string>>(new Set());
|
||||
const [adminConsentPending, setAdminConsentPending] = useState(false);
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
// Banner shown while knowledge bootstrap is running in the background
|
||||
const [syncBanner, setSyncBanner] = useState<{
|
||||
|
|
@ -73,15 +69,6 @@ export const ConnectionsPage: React.FC = () => {
|
|||
setSyncBanner(null);
|
||||
};
|
||||
|
||||
// Infomaniak PAT modal: holds the pending connectionId (created up-front so the
|
||||
// user only commits if they actually paste a valid token; on cancel we delete it).
|
||||
const [infomaniakModal, setInfomaniakModal] = useState<{
|
||||
connectionId: string;
|
||||
token: string;
|
||||
submitting: boolean;
|
||||
error: string | null;
|
||||
} | null>(null);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
|
|
@ -228,13 +215,13 @@ export const ConnectionsPage: React.FC = () => {
|
|||
const handleWizardConnect = async (
|
||||
type: ConnectorType,
|
||||
knowledgeEnabled: boolean,
|
||||
knowledgePreferences: KnowledgePreferences | null,
|
||||
knowledgePreferences?: KnowledgePreferences | null,
|
||||
) => {
|
||||
try {
|
||||
await createConnectionAndAuth(type, knowledgeEnabled, knowledgePreferences);
|
||||
await createConnectionAndAuth(type, knowledgeEnabled, knowledgePreferences ?? null);
|
||||
refetch();
|
||||
if (knowledgeEnabled) {
|
||||
const LABELS: Record<ConnectorType, string> = { google: 'Google', msft: 'Microsoft 365', clickup: 'ClickUp' };
|
||||
const LABELS: Record<ConnectorType, string> = { google: 'Google', msft: 'Microsoft 365', clickup: 'ClickUp', infomaniak: 'Infomaniak' };
|
||||
showSyncBanner(LABELS[type] ?? type);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -242,76 +229,6 @@ export const ConnectionsPage: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleCreateInfomaniak = async () => {
|
||||
if (isConnecting || infomaniakModal) return;
|
||||
try {
|
||||
const newConnection = await createInfomaniakConnection();
|
||||
setInfomaniakModal({
|
||||
connectionId: newConnection.id,
|
||||
token: '',
|
||||
submitting: false,
|
||||
error: null,
|
||||
});
|
||||
refetch();
|
||||
} catch (error) {
|
||||
console.error('Error creating Infomaniak connection:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInfomaniakCancel = async () => {
|
||||
if (!infomaniakModal) return;
|
||||
const { connectionId, submitting } = infomaniakModal;
|
||||
if (submitting) return;
|
||||
setInfomaniakModal(null);
|
||||
try {
|
||||
await deleteConnection(connectionId);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
console.error('Error rolling back pending Infomaniak connection:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInfomaniakSubmit = async () => {
|
||||
if (!infomaniakModal) return;
|
||||
const trimmed = infomaniakModal.token.trim();
|
||||
if (!trimmed) {
|
||||
setInfomaniakModal({ ...infomaniakModal, error: t('Bitte Personal Access Token einfügen') });
|
||||
return;
|
||||
}
|
||||
setInfomaniakModal({ ...infomaniakModal, submitting: true, error: null });
|
||||
try {
|
||||
await submitInfomaniakToken(infomaniakModal.connectionId, trimmed);
|
||||
setInfomaniakModal(null);
|
||||
refetch();
|
||||
} catch (error: any) {
|
||||
const detail =
|
||||
error?.response?.data?.detail ||
|
||||
error?.message ||
|
||||
t('Token konnte nicht gespeichert werden');
|
||||
setInfomaniakModal((prev) =>
|
||||
prev ? { ...prev, submitting: false, error: String(detail) } : prev
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Open Microsoft Admin Consent flow in a popup
|
||||
const handleAdminConsent = () => {
|
||||
setAdminConsentPending(true);
|
||||
const consentUrl = `${getApiBaseUrl()}/api/msft/adminconsent`;
|
||||
const popup = window.open(consentUrl, 'msft-admin-consent', 'width=600,height=700,scrollbars=yes,resizable=yes');
|
||||
if (!popup) {
|
||||
setAdminConsentPending(false);
|
||||
return;
|
||||
}
|
||||
const checkClosed = setInterval(() => {
|
||||
if (popup.closed) {
|
||||
clearInterval(checkClosed);
|
||||
setAdminConsentPending(false);
|
||||
refetch();
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// Form attributes for edit modal
|
||||
const formAttributes = useMemo(() => {
|
||||
const excludedFields = [
|
||||
|
|
@ -348,14 +265,6 @@ export const ConnectionsPage: React.FC = () => {
|
|||
</p>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={handleAdminConsent}
|
||||
disabled={adminConsentPending}
|
||||
title={t('Microsoft Admin-Zustimmung für die gesamte Organisation erteilen')}
|
||||
>
|
||||
<FaShieldAlt /> {t('Admin-Zustimmung')}
|
||||
</button>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => refetch()}
|
||||
|
|
@ -364,25 +273,14 @@ export const ConnectionsPage: React.FC = () => {
|
|||
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
||||
</button>
|
||||
{canCreate && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryButton}
|
||||
onClick={() => setWizardOpen(true)}
|
||||
disabled={isConnecting}
|
||||
>
|
||||
<FaPlus /> {t('Verbindung hinzufügen')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryButton}
|
||||
onClick={handleCreateInfomaniak}
|
||||
disabled={isConnecting}
|
||||
title={t('Infomaniak-Konto verbinden (kDrive + Mail)')}
|
||||
>
|
||||
<FaCloud /> Infomaniak
|
||||
</button>
|
||||
</>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryButton}
|
||||
onClick={() => setWizardOpen(true)}
|
||||
disabled={isConnecting}
|
||||
>
|
||||
<FaPlus /> {t('Verbindung hinzufügen')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -419,7 +317,7 @@ export const ConnectionsPage: React.FC = () => {
|
|||
columns={columns}
|
||||
apiEndpoint="/api/connections/"
|
||||
tableContextKey="connections"
|
||||
tableGroupLayoutMode="sections"
|
||||
tableGroupLayoutMode="inline"
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
pageSize={25}
|
||||
|
|
@ -519,137 +417,6 @@ export const ConnectionsPage: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Infomaniak Personal Access Token Modal */}
|
||||
{infomaniakModal && (
|
||||
<div className={styles.modalOverlay}>
|
||||
<div className={styles.modal} style={{ maxWidth: 640 }}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h2 className={styles.modalTitle}>{t('Infomaniak verbinden')}</h2>
|
||||
<button
|
||||
className={styles.modalClose}
|
||||
onClick={handleInfomaniakCancel}
|
||||
disabled={infomaniakModal.submitting}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
<p style={{ marginTop: 0 }}>
|
||||
{t(
|
||||
'Infomaniak nutzt für kDrive und kSuite keine OAuth-Anmeldung, sondern ein persönliches API-Token (PAT). Erstelle das Token einmalig im Infomaniak Manager und füge es unten ein.'
|
||||
)}
|
||||
</p>
|
||||
<ol style={{ paddingLeft: 20 }}>
|
||||
<li>
|
||||
{t('Öffne den Infomaniak-Manager:')}{' '}
|
||||
<a
|
||||
href="https://manager.infomaniak.com/v3/ng/accounts/token/list"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
manager.infomaniak.com – API-Tokens
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
{t('Klicke auf')} <code>{t('Token erstellen')}</code>{' '}
|
||||
{t('und vergib einen aussagekräftigen Namen, z. B.')}{' '}
|
||||
<code>PowerOn</code>.{' '}
|
||||
{t('Application bleibt auf')} <code>Default application</code>.
|
||||
</li>
|
||||
<li>
|
||||
{t('Suche im Scope-Feld nach')}{' '}
|
||||
<strong>{t('allen vier')}</strong>{' '}
|
||||
{t('Berechtigungen und kreuze sie an:')}
|
||||
<ul style={{ marginTop: 6, marginBottom: 6, paddingLeft: 20 }}>
|
||||
<li>
|
||||
<code>drive</code> — {t('kDrive (Pflicht, heute aktiv)')}
|
||||
</li>
|
||||
<li>
|
||||
<code>workspace:calendar</code> —{' '}
|
||||
{t('Kalender (Pflicht, heute aktiv)')}
|
||||
</li>
|
||||
<li>
|
||||
<code>workspace:contact</code> —{' '}
|
||||
{t('Kontakte (heute aktiv)')}
|
||||
</li>
|
||||
<li>
|
||||
<code>workspace:mail</code> —{' '}
|
||||
{t('Mail (in Vorbereitung, Scope schon mitnehmen)')}
|
||||
</li>
|
||||
</ul>
|
||||
<em>
|
||||
{t(
|
||||
'Nicht "All" auswählen und nicht user_info / accounts — wird nicht benötigt.'
|
||||
)}
|
||||
</em>
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
'Kopiere das Token sofort (Infomaniak zeigt es nur einmal) und füge es hier ein:'
|
||||
)}
|
||||
</li>
|
||||
</ol>
|
||||
<input
|
||||
type="password"
|
||||
value={infomaniakModal.token}
|
||||
onChange={(e) =>
|
||||
setInfomaniakModal((prev) =>
|
||||
prev ? { ...prev, token: e.target.value, error: null } : prev
|
||||
)
|
||||
}
|
||||
placeholder={t('Personal Access Token einfügen')}
|
||||
disabled={infomaniakModal.submitting}
|
||||
autoFocus
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
border: '1px solid var(--border, #ccc)',
|
||||
borderRadius: 4,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
/>
|
||||
{infomaniakModal.error && (
|
||||
<div className={styles.errorMessage} style={{ marginBottom: 12 }}>
|
||||
{infomaniakModal.error}
|
||||
</div>
|
||||
)}
|
||||
<p style={{ fontSize: 12, color: 'var(--text-secondary, #666)' }}>
|
||||
{t(
|
||||
'Das Token wird verschlüsselt gespeichert und nur für API-Aufrufe an Infomaniak verwendet.'
|
||||
)}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 8,
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryButton}
|
||||
onClick={handleInfomaniakCancel}
|
||||
disabled={infomaniakModal.submitting}
|
||||
>
|
||||
{t('Abbrechen')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryButton}
|
||||
onClick={handleInfomaniakSubmit}
|
||||
disabled={infomaniakModal.submitting || !infomaniakModal.token.trim()}
|
||||
>
|
||||
{infomaniakModal.submitting ? t('Prüfen…') : t('Verbinden')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AddConnectionWizard
|
||||
open={wizardOpen}
|
||||
onClose={() => setWizardOpen(false)}
|
||||
|
|
|
|||
|
|
@ -448,7 +448,7 @@ export const FilesPage: React.FC = () => {
|
|||
columns={columns}
|
||||
apiEndpoint="/api/files/list"
|
||||
tableContextKey="files/list"
|
||||
tableGroupLayoutMode="sections"
|
||||
tableGroupLayoutMode="inline"
|
||||
loading={tableLoading}
|
||||
pagination={true}
|
||||
pageSize={25}
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ export const PromptsPage: React.FC = () => {
|
|||
columns={columns}
|
||||
apiEndpoint="/api/prompts"
|
||||
tableContextKey="prompts"
|
||||
tableGroupLayoutMode="sections"
|
||||
tableGroupLayoutMode="inline"
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
pageSize={25}
|
||||
|
|
|
|||
|
|
@ -527,17 +527,15 @@ export const BillingDataView: React.FC = () => {
|
|||
viewKey?: string | null;
|
||||
groupField: string;
|
||||
groupDirection?: 'asc' | 'desc';
|
||||
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: string }>;
|
||||
}) => {
|
||||
const levels = base.groupByLevels?.length
|
||||
? base.groupByLevels
|
||||
: [{ field: base.groupField, nullLabel: '—', direction: base.groupDirection || 'asc' }];
|
||||
const pObj: Record<string, unknown> = {
|
||||
page: 1,
|
||||
pageSize: 25,
|
||||
groupByLevels: [
|
||||
{
|
||||
field: base.groupField,
|
||||
nullLabel: '—',
|
||||
direction: base.groupDirection || 'asc',
|
||||
},
|
||||
],
|
||||
groupByLevels: levels,
|
||||
};
|
||||
if (base.search) (pObj as { search?: string }).search = base.search;
|
||||
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;
|
||||
|
|
@ -837,7 +835,7 @@ export const BillingDataView: React.FC = () => {
|
|||
columns={columns}
|
||||
apiEndpoint="/api/billing/view/users/transactions"
|
||||
tableContextKey="billing/view/users/transactions"
|
||||
tableGroupLayoutMode="sections"
|
||||
tableGroupLayoutMode="inline"
|
||||
loading={transactionsLoading}
|
||||
pagination={true}
|
||||
pageSize={25}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
/* CommCoach Shared Styles — Assistant, Modules, Session views */
|
||||
|
||||
.assistantContainer,
|
||||
.assistantContainer {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modulesContainer {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
|
|
@ -14,6 +21,14 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.wizardHeaderRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stepIndicator {
|
||||
|
|
@ -33,7 +48,6 @@
|
|||
}
|
||||
|
||||
.wizardContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
@ -98,9 +112,8 @@
|
|||
|
||||
.wizardActions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wizardHint {
|
||||
|
|
|
|||
|
|
@ -75,10 +75,24 @@ export const CommcoachAssistantView: React.FC = () => {
|
|||
<div className={styles.assistantContainer}>
|
||||
<div className={styles.wizardHeader}>
|
||||
<h2>{t('Neues Modul erstellen')}</h2>
|
||||
<div className={styles.stepIndicator}>
|
||||
{STEPS.map((s, i) => (
|
||||
<div key={s} className={`${styles.stepDot} ${i <= stepIdx ? styles.stepActive : ''}`} />
|
||||
))}
|
||||
<div className={styles.wizardHeaderRight}>
|
||||
<div className={styles.stepIndicator}>
|
||||
{STEPS.map((s, i) => (
|
||||
<div key={s} className={`${styles.stepDot} ${i <= stepIdx ? styles.stepActive : ''}`} />
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.wizardActions}>
|
||||
{stepIdx > 0 && (
|
||||
<button className={styles.btnSecondary} onClick={_handleBack}>{t('Zurück')}</button>
|
||||
)}
|
||||
{step !== 'confirm' ? (
|
||||
<button className={styles.btnPrimary} onClick={_handleNext}>{t('Weiter')}</button>
|
||||
) : (
|
||||
<button className={styles.btnPrimary} onClick={_handleCreate} disabled={loading}>
|
||||
{loading ? t('Erstelle...') : t('Modul erstellen & erste Session starten')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -156,19 +170,6 @@ export const CommcoachAssistantView: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.wizardActions}>
|
||||
{stepIdx > 0 && (
|
||||
<button className={styles.btnSecondary} onClick={_handleBack}>{t('Zurück')}</button>
|
||||
)}
|
||||
<div style={{ flex: 1 }} />
|
||||
{step !== 'confirm' ? (
|
||||
<button className={styles.btnPrimary} onClick={_handleNext}>{t('Weiter')}</button>
|
||||
) : (
|
||||
<button className={styles.btnPrimary} onClick={_handleCreate} disabled={loading}>
|
||||
{loading ? t('Erstelle...') : t('Modul erstellen & erste Session starten')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
*/
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
|
||||
import { useVoiceStream, type SttStreamOpenOptions } from '../../../hooks/useSpeechAudioCapture';
|
||||
import api from '../../../api';
|
||||
|
||||
export type VoiceState = 'idle' | 'listening' | 'botSpeaking' | 'interrupted';
|
||||
|
|
@ -35,6 +35,13 @@ export interface VoiceControllerCallbacks {
|
|||
|
||||
const _DEFAULT_STT_LANGUAGE = 'de-DE';
|
||||
|
||||
/** CommCoach: faster streaming STT profile + single-utterance endpointing (client re-opens stream). */
|
||||
const _commcoachSttOpen: SttStreamOpenOptions = {
|
||||
model: 'latest_short',
|
||||
lightweight: true,
|
||||
singleUtterance: true,
|
||||
};
|
||||
|
||||
export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceControllerApi {
|
||||
const [state, setState] = useState<VoiceState>('idle');
|
||||
const [muted, setMuted] = useState(false);
|
||||
|
|
@ -86,7 +93,7 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
|
|||
});
|
||||
|
||||
const _startStream = useCallback(() => {
|
||||
return voiceStream.start(sttLanguageRef.current);
|
||||
return voiceStream.start(sttLanguageRef.current, _commcoachSttOpen);
|
||||
}, [voiceStream]);
|
||||
|
||||
const activate = useCallback(async () => {
|
||||
|
|
|
|||
|
|
@ -413,15 +413,23 @@
|
|||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sessionSwitcherSelect {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid var(--border-color, #ccc);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary, #fff);
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
/* ----- Session Layout (UDB Sidebar + Main) ------------------------------- */
|
||||
|
||||
.sessionLayout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
|
|
@ -430,7 +438,6 @@
|
|||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
|
|
@ -820,8 +827,6 @@
|
|||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Transcript Panel */
|
||||
|
|
@ -833,6 +838,8 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: 50vh;
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
.panelTitle {
|
||||
|
|
@ -914,6 +921,68 @@
|
|||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.responseText h1,
|
||||
.responseText h2,
|
||||
.responseText h3 {
|
||||
margin: 0.6em 0 0.3em;
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.responseText p {
|
||||
margin: 0.3em 0;
|
||||
}
|
||||
|
||||
.responseText ul,
|
||||
.responseText ol {
|
||||
margin: 0.3em 0;
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
|
||||
.responseText code {
|
||||
background: var(--bg-tertiary, #f0f0f0);
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.responseText pre {
|
||||
background: var(--bg-tertiary, #f0f0f0);
|
||||
padding: 0.6em;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 0.4em 0;
|
||||
}
|
||||
|
||||
.responseText pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.responseText table {
|
||||
border-collapse: collapse;
|
||||
margin: 0.4em 0;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.responseText th,
|
||||
.responseText td {
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
padding: 0.3em 0.6em;
|
||||
}
|
||||
|
||||
.responseText th {
|
||||
background: var(--bg-secondary, #f5f5f5);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.responseText blockquote {
|
||||
border-left: 3px solid var(--border-color, #ddd);
|
||||
margin: 0.4em 0;
|
||||
padding: 0.2em 0.8em;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.responseReasoning {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
|
|
@ -941,9 +1010,18 @@
|
|||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary, #333);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.summaryText p { margin: 0.3em 0; }
|
||||
.summaryText ul, .summaryText ol { margin: 0.3em 0; padding-left: 1.4em; }
|
||||
.summaryText h1, .summaryText h2, .summaryText h3 { margin: 0.6em 0 0.3em; font-size: 1em; font-weight: 600; }
|
||||
.summaryText code { background: var(--bg-tertiary, #f0f0f0); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.85em; }
|
||||
.summaryText pre { background: var(--bg-tertiary, #f0f0f0); padding: 0.6em; border-radius: 4px; overflow-x: auto; }
|
||||
.summaryText pre code { background: none; padding: 0; }
|
||||
.summaryText table { border-collapse: collapse; margin: 0.4em 0; font-size: 0.85em; }
|
||||
.summaryText th, .summaryText td { border: 1px solid var(--border-color, #ddd); padding: 0.3em 0.6em; }
|
||||
.summaryText th { background: var(--bg-secondary, #f5f5f5); font-weight: 600; }
|
||||
|
||||
/* ============================================================================
|
||||
Settings View
|
||||
============================================================================ */
|
||||
|
|
@ -1347,6 +1425,36 @@
|
|||
animation: agentPulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.agentProgressLog {
|
||||
padding: 0.5rem 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.agentProgressEntry {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.15rem 0;
|
||||
border-bottom: 1px solid var(--border-color, #eee);
|
||||
}
|
||||
|
||||
.agentProgressEntry:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.agentProgressTime {
|
||||
color: var(--text-tertiary, #999);
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.agentProgressText {
|
||||
color: var(--text-secondary, #666);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.statsCards {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
|
|
@ -1376,7 +1484,14 @@
|
|||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.assistantContainer,
|
||||
.assistantContainer {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modulesContainer {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
|
|
@ -1390,6 +1505,14 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.wizardHeaderRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stepIndicator {
|
||||
|
|
@ -1409,7 +1532,6 @@
|
|||
}
|
||||
|
||||
.wizardContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
@ -1442,9 +1564,8 @@
|
|||
|
||||
.wizardActions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.moduleChoice {
|
||||
|
|
@ -1478,6 +1599,10 @@
|
|||
border-color: var(--primary-color, #4A90D9);
|
||||
}
|
||||
|
||||
.moduleRowFocused {
|
||||
box-shadow: 0 0 0 2px rgba(74, 144, 217, 0.45);
|
||||
}
|
||||
|
||||
.moduleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -1519,16 +1644,49 @@
|
|||
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.sessionRow {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0.4rem 0;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
.sessionTable {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.sessionRow:hover {
|
||||
color: var(--primary-color, #4A90D9);
|
||||
.sessionTable th {
|
||||
text-align: left;
|
||||
padding: 0.35rem 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
border-bottom: 1px solid var(--border-color, #ddd);
|
||||
}
|
||||
|
||||
.sessionTableRow {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sessionTableRow td {
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #eee);
|
||||
}
|
||||
|
||||
.sessionTableRow:hover td {
|
||||
background: rgba(74, 144, 217, 0.05);
|
||||
}
|
||||
|
||||
.sessionDeleteBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #b91c1c;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
line-height: 1;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.sessionDeleteBtn:hover {
|
||||
opacity: 1;
|
||||
background: rgba(185, 28, 28, 0.1);
|
||||
}
|
||||
|
||||
.sessionStatus {
|
||||
|
|
@ -1558,7 +1716,7 @@
|
|||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
max-width: 400px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -1654,3 +1812,222 @@
|
|||
padding: 2rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
/* --- TeamsBot Dashboard (Greenfield IA) --- */
|
||||
.tbDash {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
padding: 1.25rem 1.5rem 2rem;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tbDashHero {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1.25rem;
|
||||
padding: 1.5rem 1.75rem;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, rgba(74, 144, 217, 0.12) 0%, var(--surface-color, #fff) 48%);
|
||||
border: 1px solid var(--border-color, #e6e6e6);
|
||||
}
|
||||
|
||||
.tbDashTitle {
|
||||
margin: 0 0 0.35rem 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
}
|
||||
|
||||
.tbDashSubtitle {
|
||||
margin: 0;
|
||||
max-width: 520px;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text-secondary, #555);
|
||||
}
|
||||
|
||||
.tbDashQuickActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tbDashBtnPrimary {
|
||||
padding: 0.65rem 1.35rem;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--primary-color, #4A90D9);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.tbDashBtnPrimary:hover {
|
||||
background: var(--primary-hover, #3A7BC8);
|
||||
}
|
||||
|
||||
.tbDashBtnSecondary {
|
||||
padding: 0.65rem 1.1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color, #d0d0d0);
|
||||
background: var(--surface-color, #fff);
|
||||
color: var(--text-primary, #333);
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tbDashBtnSecondary:hover {
|
||||
border-color: var(--primary-color, #4A90D9);
|
||||
color: var(--primary-color, #4A90D9);
|
||||
}
|
||||
|
||||
.tbDashKpiGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tbDashKpiCard {
|
||||
padding: 1.1rem 1.25rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color, #e8e8e8);
|
||||
background: var(--surface-color, #fff);
|
||||
}
|
||||
|
||||
.tbDashKpiValue {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color, #4A90D9);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.tbDashKpiLabel {
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #666);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.tbDashKpiHint {
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-tertiary, #888);
|
||||
}
|
||||
|
||||
.tbDashSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.tbDashSectionHead {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tbDashSectionTitle {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #222);
|
||||
}
|
||||
|
||||
.tbDashLinkBtn {
|
||||
padding: 0.35rem 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--primary-color, #4A90D9);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.tbDashModuleGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tbDashModuleCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color, #e6e6e6);
|
||||
background: var(--surface-color, #fafafa);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.tbDashModuleCard:hover {
|
||||
border-color: var(--primary-color, #4A90D9);
|
||||
box-shadow: 0 2px 8px rgba(74, 144, 217, 0.12);
|
||||
}
|
||||
|
||||
.tbDashModuleTitle {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary, #222);
|
||||
}
|
||||
|
||||
.tbDashModuleCount {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.tbDashSessionList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.tbDashSessionRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color, #eaeaea);
|
||||
background: var(--surface-color, #fff);
|
||||
}
|
||||
|
||||
.tbDashSessionMain {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex: 1 1 200px;
|
||||
}
|
||||
|
||||
.tbDashSessionMeta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary, #666);
|
||||
flex: 2 1 220px;
|
||||
}
|
||||
|
||||
.tbDashSessionActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@
|
|||
*
|
||||
* Wizard: Select/create module → Meeting link → Bot selection → "Start bot"
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
import * as teamsbotApi from '../../../api/teamsbotApi';
|
||||
import type { MeetingModule, TeamsbotJoinMode, UserAccountStatus } from '../../../api/teamsbotApi';
|
||||
import { getUserDataCache } from '../../../utils/userCache';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from './Teamsbot.module.css';
|
||||
|
||||
|
|
@ -18,16 +20,26 @@ export const TeamsbotAssistantView: React.FC = () => {
|
|||
const { instance, mandateId } = useCurrentInstance();
|
||||
const instanceId = instance?.id || '';
|
||||
const navigate = useNavigate();
|
||||
const cachedUser = getUserDataCache();
|
||||
const isSysAdmin = cachedUser?.isSysAdmin === true;
|
||||
const [searchParams] = useSearchParams();
|
||||
const preselectedModuleId = searchParams.get('moduleId');
|
||||
|
||||
const [step, setStep] = useState<WizardStep>(preselectedModuleId ? 'meeting' : 'module');
|
||||
const [modules, setModules] = useState<any[]>([]);
|
||||
const [modules, setModules] = useState<MeetingModule[]>([]);
|
||||
const [moduleFilter, setModuleFilter] = useState('');
|
||||
const [selectedModuleId, setSelectedModuleId] = useState<string | null>(preselectedModuleId);
|
||||
const [newModuleTitle, setNewModuleTitle] = useState('');
|
||||
const [createNewModule, setCreateNewModule] = useState(false);
|
||||
const [meetingLink, setMeetingLink] = useState('');
|
||||
const [botName, setBotName] = useState('AI Assistant');
|
||||
const [joinMode, setJoinMode] = useState<TeamsbotJoinMode>('anonymous');
|
||||
const [sessionContext, setSessionContext] = useState('');
|
||||
const [userAccount, setUserAccount] = useState<UserAccountStatus | null>(null);
|
||||
const [showCredentialForm, setShowCredentialForm] = useState(false);
|
||||
const [credEmail, setCredEmail] = useState('');
|
||||
const [credPassword, setCredPassword] = useState('');
|
||||
const [savingCredentials, setSavingCredentials] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -45,6 +57,33 @@ export const TeamsbotAssistantView: React.FC = () => {
|
|||
|
||||
useEffect(() => { _loadModules(); }, [_loadModules]);
|
||||
|
||||
useEffect(() => {
|
||||
if (joinMode === 'userAccount' && instanceId) {
|
||||
teamsbotApi.getUserAccount(instanceId).then(setUserAccount).catch(() => setUserAccount(null));
|
||||
}
|
||||
}, [joinMode, instanceId]);
|
||||
|
||||
const filteredModules = useMemo(() => {
|
||||
const q = moduleFilter.trim().toLowerCase();
|
||||
if (!q) return modules;
|
||||
return modules.filter(m => m.title.toLowerCase().includes(q));
|
||||
}, [modules, moduleFilter]);
|
||||
|
||||
const modulePrefillKeyRef = useRef<string>('');
|
||||
useEffect(() => {
|
||||
if (!selectedModuleId || createNewModule) {
|
||||
modulePrefillKeyRef.current = '';
|
||||
return;
|
||||
}
|
||||
const mod = modules.find(m => m.id === selectedModuleId);
|
||||
if (!mod) return;
|
||||
const key = `${selectedModuleId}:${mod.defaultMeetingLink ?? ''}:${mod.defaultBotName ?? ''}`;
|
||||
if (modulePrefillKeyRef.current === key) return;
|
||||
modulePrefillKeyRef.current = key;
|
||||
if (mod.defaultMeetingLink) setMeetingLink(mod.defaultMeetingLink);
|
||||
if (mod.defaultBotName) setBotName(mod.defaultBotName);
|
||||
}, [selectedModuleId, createNewModule, modules]);
|
||||
|
||||
const _handleNext = () => {
|
||||
const nextIdx = stepIdx + 1;
|
||||
if (nextIdx < STEPS.length) setStep(STEPS[nextIdx]);
|
||||
|
|
@ -60,6 +99,27 @@ export const TeamsbotAssistantView: React.FC = () => {
|
|||
setError(t('Meeting-Link erforderlich'));
|
||||
return;
|
||||
}
|
||||
if (joinMode === 'userAccount' && !userAccount?.hasSavedCredentials && !credEmail) {
|
||||
setShowCredentialForm(true);
|
||||
setError(t('Bitte Microsoft-Zugangsdaten eingeben oder speichern.'));
|
||||
return;
|
||||
}
|
||||
const needsSave = joinMode === 'userAccount' && !userAccount?.hasSavedCredentials && credEmail && credPassword;
|
||||
const needsUpdate = joinMode === 'userAccount' && showCredentialForm && credEmail && credPassword;
|
||||
if (needsSave || needsUpdate) {
|
||||
try {
|
||||
setSavingCredentials(true);
|
||||
await teamsbotApi.saveUserAccount(instanceId, credEmail, credPassword);
|
||||
setUserAccount({ hasSavedCredentials: true, email: credEmail });
|
||||
setShowCredentialForm(false);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || t('Fehler beim Speichern der Zugangsdaten'));
|
||||
setSavingCredentials(false);
|
||||
return;
|
||||
} finally {
|
||||
setSavingCredentials(false);
|
||||
}
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
|
|
@ -73,7 +133,9 @@ export const TeamsbotAssistantView: React.FC = () => {
|
|||
meetingLink: meetingLink.trim(),
|
||||
botName,
|
||||
moduleId: moduleId || undefined,
|
||||
} as any);
|
||||
joinMode,
|
||||
sessionContext: sessionContext.trim() || undefined,
|
||||
});
|
||||
|
||||
navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/sessions?sessionId=${result.session.id}`);
|
||||
} catch (err: any) {
|
||||
|
|
@ -87,10 +149,32 @@ export const TeamsbotAssistantView: React.FC = () => {
|
|||
<div className={styles.assistantContainer}>
|
||||
<div className={styles.wizardHeader}>
|
||||
<h2>{t('Neues Meeting starten')}</h2>
|
||||
<div className={styles.stepIndicator}>
|
||||
{STEPS.map((s, i) => (
|
||||
<div key={s} className={`${styles.stepDot} ${i <= stepIdx ? styles.stepActive : ''}`} />
|
||||
))}
|
||||
<div className={styles.wizardHeaderRight}>
|
||||
<div className={styles.stepIndicator}>
|
||||
{STEPS.map((s, i) => (
|
||||
<div key={s} className={`${styles.stepDot} ${i <= stepIdx ? styles.stepActive : ''}`} />
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.wizardActions}>
|
||||
{stepIdx > 0 && (
|
||||
<button className={styles.btnSecondary} onClick={_handleBack}>{t('Zurück')}</button>
|
||||
)}
|
||||
{step !== 'confirm' ? (
|
||||
<button className={styles.btnPrimary} onClick={_handleNext}>{t('Weiter')}</button>
|
||||
) : (
|
||||
<button
|
||||
className={styles.btnPrimary}
|
||||
onClick={_handleStart}
|
||||
disabled={
|
||||
loading
|
||||
|| savingCredentials
|
||||
|| (joinMode === 'userAccount' && showCredentialForm && (!credEmail || !credPassword))
|
||||
}
|
||||
>
|
||||
{loading ? t('Starte...') : t('Bot starten')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -106,16 +190,27 @@ export const TeamsbotAssistantView: React.FC = () => {
|
|||
{t('Bestehendes Modul')}
|
||||
</label>
|
||||
{!createNewModule && (
|
||||
<select
|
||||
value={selectedModuleId || ''}
|
||||
onChange={e => setSelectedModuleId(e.target.value || null)}
|
||||
className={styles.wizardSelect}
|
||||
>
|
||||
<option value="">{t('Kein Modul (Adhoc)')}</option>
|
||||
{modules.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<>
|
||||
<input
|
||||
type="search"
|
||||
className={styles.wizardInput}
|
||||
placeholder={t('Modul suchen…')}
|
||||
value={moduleFilter}
|
||||
onChange={e => setModuleFilter(e.target.value)}
|
||||
aria-label={t('Modul suchen')}
|
||||
/>
|
||||
<select
|
||||
value={selectedModuleId || ''}
|
||||
onChange={e => setSelectedModuleId(e.target.value || null)}
|
||||
className={styles.wizardSelect}
|
||||
size={Math.min(12, Math.max(4, filteredModules.length + 1))}
|
||||
>
|
||||
<option value="">{t('Kein Modul (Adhoc)')}</option>
|
||||
{filteredModules.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
<label>
|
||||
<input type="radio" checked={createNewModule} onChange={() => setCreateNewModule(true)} />
|
||||
|
|
@ -136,7 +231,7 @@ export const TeamsbotAssistantView: React.FC = () => {
|
|||
|
||||
{step === 'meeting' && (
|
||||
<div className={styles.wizardStep}>
|
||||
<h3>{t('Meeting-Link')}</h3>
|
||||
<h3>{t('Meeting-Link und Beitritt')}</h3>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.wizardInput}
|
||||
|
|
@ -145,6 +240,87 @@ export const TeamsbotAssistantView: React.FC = () => {
|
|||
onChange={e => setMeetingLink(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<label className={styles.label} style={{ marginTop: '1rem' }}>{t('Join-Modus')}</label>
|
||||
<select
|
||||
className={styles.wizardSelect}
|
||||
value={joinMode}
|
||||
onChange={e => setJoinMode(e.target.value as TeamsbotJoinMode)}
|
||||
>
|
||||
{isSysAdmin && <option value="systemBot">{t('Systembot authentifiziert')}</option>}
|
||||
<option value="anonymous">{t('Anonymer Gast')}</option>
|
||||
<option value="userAccount">{t('Mein Account')}</option>
|
||||
</select>
|
||||
{joinMode === 'userAccount' && (
|
||||
<div className={styles.credentialsCard} style={{ marginTop: '0.75rem' }}>
|
||||
{userAccount?.hasSavedCredentials && !showCredentialForm ? (
|
||||
<div className={styles.credentialsInfo}>
|
||||
<span>
|
||||
{t('Gespeichert:')} <span className={styles.credentialsEmail}>{userAccount.email}</span>
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.viewButton}
|
||||
onClick={() => { setShowCredentialForm(true); setCredEmail(userAccount.email || ''); }}
|
||||
>
|
||||
{t('Ändern')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.deleteButton}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await teamsbotApi.deleteUserAccount(instanceId);
|
||||
setUserAccount({ hasSavedCredentials: false });
|
||||
setCredEmail('');
|
||||
setCredPassword('');
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
>
|
||||
{t('Entfernen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.label}>{t('Microsoft E-Mail')}</label>
|
||||
<input
|
||||
type="email"
|
||||
className={styles.input}
|
||||
value={credEmail}
|
||||
onChange={e => setCredEmail(e.target.value)}
|
||||
disabled={savingCredentials}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.label}>{t('Passwort')}</label>
|
||||
<input
|
||||
type="password"
|
||||
className={styles.input}
|
||||
value={credPassword}
|
||||
onChange={e => setCredPassword(e.target.value)}
|
||||
disabled={savingCredentials}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.hint}>{t('Zugangsdaten werden verschlüsselt gespeichert.')}</span>
|
||||
{userAccount?.hasSavedCredentials && (
|
||||
<button type="button" className={styles.viewButton} style={{ marginTop: '0.5rem' }} onClick={() => setShowCredentialForm(false)}>
|
||||
{t('Abbrechen')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<label className={styles.label} style={{ marginTop: '1rem' }}>{t('Sitzungskontext (optional)')}</label>
|
||||
<textarea
|
||||
className={styles.wizardTextarea}
|
||||
placeholder={t('Agenda, Hintergrund …')}
|
||||
value={sessionContext}
|
||||
onChange={e => setSessionContext(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -167,24 +343,13 @@ export const TeamsbotAssistantView: React.FC = () => {
|
|||
<div><strong>{t('Modul')}:</strong> {createNewModule ? newModuleTitle : (modules.find(m => m.id === selectedModuleId)?.title || t('Adhoc'))}</div>
|
||||
<div><strong>{t('Meeting')}:</strong> {meetingLink}</div>
|
||||
<div><strong>{t('Bot')}:</strong> {botName}</div>
|
||||
<div><strong>{t('Join-Modus')}:</strong> {joinMode}</div>
|
||||
{sessionContext.trim() ? <div><strong>{t('Kontext')}:</strong> {sessionContext.trim().slice(0, 120)}{sessionContext.length > 120 ? '…' : ''}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.wizardActions}>
|
||||
{stepIdx > 0 && (
|
||||
<button className={styles.btnSecondary} onClick={_handleBack}>{t('Zurück')}</button>
|
||||
)}
|
||||
<div style={{ flex: 1 }} />
|
||||
{step !== 'confirm' ? (
|
||||
<button className={styles.btnPrimary} onClick={_handleNext}>{t('Weiter')}</button>
|
||||
) : (
|
||||
<button className={styles.btnPrimary} onClick={_handleStart} disabled={loading}>
|
||||
{loading ? t('Starte...') : t('Bot starten')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,230 +1,113 @@
|
|||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
import * as teamsbotApi from '../../../api/teamsbotApi';
|
||||
import type { TeamsbotSession, StartSessionRequest, TeamsbotJoinMode, UserAccountStatus, MfaChallengeEvent } from '../../../api/teamsbotApi';
|
||||
import { getUserDataCache } from '../../../utils/userCache';
|
||||
import type { TeamsbotSession, MeetingModule } from '../../../api/teamsbotApi';
|
||||
import styles from './Teamsbot.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
/**
|
||||
* TeamsbotDashboardView - Overview of all Teams Bot sessions.
|
||||
* Allows starting new sessions and viewing active/past sessions.
|
||||
* Supports "Mein Account" login with saved credentials and MFA relay.
|
||||
* TeamsBot Dashboard — IA: KPIs, Modul-Aggregate, Quick-Actions.
|
||||
* Neues Meeting: Assistent (Wizard). Sessions sind via Module erreichbar.
|
||||
*/
|
||||
export const TeamsbotDashboardView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
const { t } = useLanguage();
|
||||
const { instance, mandateId, featureCode } = useCurrentInstance();
|
||||
const instanceId = instance?.id || '';
|
||||
const navigate = useNavigate();
|
||||
|
||||
const cachedUser = getUserDataCache();
|
||||
const _isSysAdmin = cachedUser?.isSysAdmin === true;
|
||||
|
||||
const [sessions, setSessions] = useState<TeamsbotSession[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modules, setModules] = useState<MeetingModule[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// New session form
|
||||
const [meetingLink, setMeetingLink] = useState('');
|
||||
const [botName, setBotName] = useState('');
|
||||
const [joinMode, setJoinMode] = useState<TeamsbotJoinMode>('anonymous');
|
||||
const [sessionContext, setSessionContext] = useState('');
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const dashboardEsRef = useRef<EventSource | null>(null);
|
||||
const dashboardReconnectRef = useRef<number | null>(null);
|
||||
|
||||
// User Account (Mein Account) state
|
||||
const [userAccount, setUserAccount] = useState<UserAccountStatus | null>(null);
|
||||
const [showCredentialForm, setShowCredentialForm] = useState(false);
|
||||
const [credEmail, setCredEmail] = useState('');
|
||||
const [credPassword, setCredPassword] = useState('');
|
||||
const [savingCredentials, setSavingCredentials] = useState(false);
|
||||
|
||||
// MFA state
|
||||
const [mfaChallenge, setMfaChallenge] = useState<MfaChallengeEvent | null>(null);
|
||||
const [mfaCode, setMfaCode] = useState('');
|
||||
const [mfaSessionId, setMfaSessionId] = useState<string | null>(null);
|
||||
const [mfaWaitingPush, setMfaWaitingPush] = useState(false);
|
||||
const sseRef = useRef<EventSource | null>(null);
|
||||
|
||||
const _loadSessions = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await teamsbotApi.listSessions(instanceId);
|
||||
setSessions(result.sessions || []);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('Fehler beim Laden der Sitzungen'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [instanceId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
_loadSessions();
|
||||
}, [_loadSessions]);
|
||||
|
||||
// Load user account status when joinMode changes to userAccount
|
||||
useEffect(() => {
|
||||
if (joinMode === 'userAccount' && instanceId) {
|
||||
teamsbotApi.getUserAccount(instanceId).then(setUserAccount).catch(() => setUserAccount(null));
|
||||
}
|
||||
}, [joinMode, instanceId]);
|
||||
|
||||
// Adaptive polling: 3s with active sessions, 30s otherwise
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
useEffect(() => {
|
||||
const hasActiveSessions = sessions.some(s => ['pending', 'joining', 'active'].includes(s.status));
|
||||
const interval = hasActiveSessions ? 3000 : 30000;
|
||||
if (instanceId) {
|
||||
pollRef.current = setInterval(() => {
|
||||
teamsbotApi.listSessions(instanceId).then(r => setSessions(r.sessions || [])).catch(() => {});
|
||||
}, interval);
|
||||
}
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
||||
}, [sessions, instanceId]);
|
||||
|
||||
// Cleanup SSE on unmount
|
||||
useEffect(() => {
|
||||
return () => { sseRef.current?.close(); };
|
||||
const applyDashboardPayload = useCallback((nextSessions: TeamsbotSession[], nextModules: MeetingModule[]) => {
|
||||
setSessions(nextSessions);
|
||||
setModules(nextModules);
|
||||
}, []);
|
||||
|
||||
const _startMfaListener = useCallback((sessionId: string) => {
|
||||
sseRef.current?.close();
|
||||
const es = teamsbotApi.createSessionStream(instanceId, sessionId);
|
||||
sseRef.current = es;
|
||||
setMfaSessionId(sessionId);
|
||||
useEffect(() => {
|
||||
if (!instanceId) return;
|
||||
let cancelled = false;
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const parsed = JSON.parse(event.data);
|
||||
if (parsed.type === 'mfaChallenge') {
|
||||
const data = parsed.data as MfaChallengeEvent;
|
||||
if (data.mfaType === 'timeout') {
|
||||
setMfaChallenge(null);
|
||||
setMfaWaitingPush(false);
|
||||
setError(t('MFA-Zeitlimit überschritten, bitte erneut versuchen'));
|
||||
} else {
|
||||
setMfaChallenge(data);
|
||||
setMfaCode('');
|
||||
setMfaWaitingPush(data.mfaType === 'pushApproval' || data.mfaType === 'numberMatch');
|
||||
}
|
||||
} else if (parsed.type === 'mfaResolved') {
|
||||
setMfaChallenge(null);
|
||||
setMfaWaitingPush(false);
|
||||
setMfaSessionId(null);
|
||||
es.close();
|
||||
sseRef.current = null;
|
||||
_loadSessions();
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
};
|
||||
}, [instanceId, _loadSessions, t]);
|
||||
|
||||
const _handleStartSession = async () => {
|
||||
if (!meetingLink.trim()) return;
|
||||
|
||||
// For userAccount: need credentials (saved or entered now)
|
||||
if (joinMode === 'userAccount' && !userAccount?.hasSavedCredentials && !credEmail) {
|
||||
setShowCredentialForm(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// userAccount with new/unsaved credentials: save to DB before starting
|
||||
const needsSave = joinMode === 'userAccount' && !userAccount?.hasSavedCredentials && credEmail && credPassword;
|
||||
const needsUpdate = joinMode === 'userAccount' && showCredentialForm && credEmail && credPassword;
|
||||
if (needsSave || needsUpdate) {
|
||||
try {
|
||||
setSavingCredentials(true);
|
||||
await teamsbotApi.saveUserAccount(instanceId, credEmail, credPassword);
|
||||
setUserAccount({ hasSavedCredentials: true, email: credEmail });
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('Fehler beim Speichern der Zugangsdaten'));
|
||||
setSavingCredentials(false);
|
||||
return;
|
||||
} finally {
|
||||
setSavingCredentials(false);
|
||||
const clearReconnect = () => {
|
||||
if (dashboardReconnectRef.current) {
|
||||
window.clearTimeout(dashboardReconnectRef.current);
|
||||
dashboardReconnectRef.current = null;
|
||||
}
|
||||
setShowCredentialForm(false);
|
||||
}
|
||||
};
|
||||
|
||||
setIsStarting(true);
|
||||
setError(null);
|
||||
const connect = () => {
|
||||
if (cancelled) return;
|
||||
dashboardEsRef.current?.close();
|
||||
const es = teamsbotApi.createDashboardStream(instanceId);
|
||||
dashboardEsRef.current = es;
|
||||
|
||||
try {
|
||||
const request: StartSessionRequest = {
|
||||
meetingLink: meetingLink.trim(),
|
||||
botName: botName.trim() || undefined,
|
||||
joinMode: joinMode,
|
||||
sessionContext: sessionContext.trim() || undefined,
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as {
|
||||
type?: string;
|
||||
sessions?: TeamsbotSession[];
|
||||
modules?: MeetingModule[];
|
||||
};
|
||||
if (msg.type === 'dashboardState' && Array.isArray(msg.sessions) && Array.isArray(msg.modules)) {
|
||||
applyDashboardPayload(msg.sessions, msg.modules);
|
||||
setError(null);
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed SSE */
|
||||
}
|
||||
};
|
||||
|
||||
const result = await teamsbotApi.startSession(instanceId, request);
|
||||
const newSessionId = result.session?.id;
|
||||
setMeetingLink('');
|
||||
setBotName('');
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
dashboardEsRef.current = null;
|
||||
if (cancelled) return;
|
||||
clearReconnect();
|
||||
dashboardReconnectRef.current = window.setTimeout(connect, 2500);
|
||||
};
|
||||
};
|
||||
|
||||
// Start SSE listener for MFA events if userAccount mode
|
||||
if (joinMode === 'userAccount' && newSessionId) {
|
||||
_startMfaListener(newSessionId);
|
||||
}
|
||||
connect();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearReconnect();
|
||||
dashboardEsRef.current?.close();
|
||||
dashboardEsRef.current = null;
|
||||
};
|
||||
}, [instanceId, applyDashboardPayload]);
|
||||
|
||||
await _loadSessions();
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('Fehler beim Starten der Sitzung'));
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
}
|
||||
};
|
||||
const activeSessions = useMemo(
|
||||
() => sessions.filter((s) => ['pending', 'joining', 'active'].includes(s.status)),
|
||||
[sessions],
|
||||
);
|
||||
const moduleTitleById = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
modules.forEach((mod) => m.set(mod.id, mod.title));
|
||||
return m;
|
||||
}, [modules]);
|
||||
|
||||
const _handleSubmitMfaCode = async () => {
|
||||
if (!mfaSessionId || !instanceId) return;
|
||||
const needsCode = mfaChallenge?.mfaType === 'totpCode' || mfaChallenge?.mfaType === 'smsCode';
|
||||
try {
|
||||
await teamsbotApi.submitMfaCode(
|
||||
instanceId,
|
||||
mfaSessionId,
|
||||
needsCode ? mfaCode : '',
|
||||
needsCode ? 'code' : 'confirmed',
|
||||
);
|
||||
if (!needsCode) {
|
||||
setMfaWaitingPush(true);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('Fehler beim Senden des MFA-Codes'));
|
||||
}
|
||||
};
|
||||
const topModules = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
sessions.forEach((s) => {
|
||||
const mid = s.moduleId || '_adhoc';
|
||||
counts.set(mid, (counts.get(mid) || 0) + 1);
|
||||
});
|
||||
const rows = Array.from(counts.entries())
|
||||
.map(([moduleId, sessionCount]) => ({
|
||||
moduleId,
|
||||
sessionCount,
|
||||
title: moduleId === '_adhoc' ? t('Adhoc / ohne Modul') : (moduleTitleById.get(moduleId) || t('Unbekanntes Modul')),
|
||||
}))
|
||||
.sort((a, b) => b.sessionCount - a.sessionCount)
|
||||
.slice(0, 6);
|
||||
return rows;
|
||||
}, [sessions, moduleTitleById, t]);
|
||||
|
||||
const _handleDeleteUserAccount = async () => {
|
||||
try {
|
||||
await teamsbotApi.deleteUserAccount(instanceId);
|
||||
setUserAccount({ hasSavedCredentials: false });
|
||||
setCredEmail('');
|
||||
setCredPassword('');
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('Fehler beim Löschen der Zugangsdaten'));
|
||||
}
|
||||
};
|
||||
|
||||
const _handleStopSession = async (sessionId: string) => {
|
||||
try {
|
||||
await teamsbotApi.stopSession(instanceId, sessionId);
|
||||
await _loadSessions();
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('Fehler beim Stoppen der Sitzung'));
|
||||
}
|
||||
};
|
||||
|
||||
const _handleDeleteSession = async (sessionId: string) => {
|
||||
try {
|
||||
await teamsbotApi.deleteSession(instanceId, sessionId);
|
||||
await _loadSessions();
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('Fehler beim Löschen der Sitzung'));
|
||||
}
|
||||
};
|
||||
const totalSegments = useMemo(() => sessions.reduce((acc, s) => acc + (s.transcriptSegmentCount || 0), 0), [sessions]);
|
||||
const totalResponses = useMemo(() => sessions.reduce((acc, s) => acc + (s.botResponseCount || 0), 0), [sessions]);
|
||||
|
||||
const _getStatusBadgeClass = (status: string) => {
|
||||
switch (status) {
|
||||
|
|
@ -240,270 +123,156 @@ export const TeamsbotDashboardView: React.FC = () => {
|
|||
|
||||
const _getStatusLabel = (status: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
pending: 'Wartend',
|
||||
joining: 'Beitritt...',
|
||||
active: 'Aktiv',
|
||||
leaving: 'Verlassen...',
|
||||
ended: 'Beendet',
|
||||
error: 'Fehler',
|
||||
pending: t('Wartend'),
|
||||
joining: t('Beitritt…'),
|
||||
active: t('Aktiv'),
|
||||
leaving: t('Verlassen…'),
|
||||
ended: t('Beendet'),
|
||||
error: t('Fehler'),
|
||||
};
|
||||
return labels[status] || status;
|
||||
};
|
||||
|
||||
const activeSessions = sessions.filter(s => ['pending', 'joining', 'active'].includes(s.status));
|
||||
const pastSessions = sessions.filter(s => ['ended', 'error', 'leaving'].includes(s.status));
|
||||
const _sessionPath = (sessId: string) =>
|
||||
`/mandates/${mandateId}/${featureCode}/${instanceId}/sessions?sessionId=${sessId}`;
|
||||
|
||||
const _needsCodeInput = mfaChallenge?.mfaType === 'totpCode' || mfaChallenge?.mfaType === 'smsCode';
|
||||
const _refreshLists = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
try {
|
||||
const [r, m] = await Promise.all([
|
||||
teamsbotApi.listSessions(instanceId, true),
|
||||
teamsbotApi.listModules(instanceId),
|
||||
]);
|
||||
setSessions(r.sessions || []);
|
||||
setModules(m || []);
|
||||
} catch { /* ignore */ }
|
||||
}, [instanceId]);
|
||||
|
||||
const _handleStopSession = async (sid: string) => {
|
||||
try {
|
||||
await teamsbotApi.stopSession(instanceId, sid);
|
||||
await _refreshLists();
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('Fehler beim Stoppen'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardContainer}>
|
||||
{/* MFA Challenge Dialog */}
|
||||
{mfaChallenge && (
|
||||
<div className={styles.mfaOverlay}>
|
||||
<div className={styles.mfaDialog}>
|
||||
<div className={styles.mfaTitle}>Multi-Faktor-Authentifizierung</div>
|
||||
|
||||
{mfaChallenge.displayNumber && (
|
||||
<div className={styles.mfaNumber}>{mfaChallenge.displayNumber}</div>
|
||||
)}
|
||||
|
||||
<div className={styles.mfaPrompt}>{mfaChallenge.prompt}</div>
|
||||
|
||||
{_needsCodeInput ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.mfaCodeInput}
|
||||
placeholder={t('Code eingeben')}
|
||||
value={mfaCode}
|
||||
onChange={(e) => setMfaCode(e.target.value)}
|
||||
autoFocus
|
||||
onKeyDown={(e) => e.key === 'Enter' && _handleSubmitMfaCode()}
|
||||
/>
|
||||
<button className={styles.startButton} onClick={_handleSubmitMfaCode} disabled={!mfaCode.trim()}>
|
||||
Bestaetigen
|
||||
</button>
|
||||
</>
|
||||
) : mfaWaitingPush ? (
|
||||
<>
|
||||
<div className={styles.mfaSpinner} />
|
||||
<p style={{ fontSize: '0.85rem', color: '#888' }}>
|
||||
Warte auf Bestaetigung in der Authenticator App...
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<button className={styles.startButton} onClick={_handleSubmitMfaCode}>
|
||||
Ich habe bestaetigt
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.tbDash}>
|
||||
<header className={styles.tbDashHero}>
|
||||
<div className={styles.tbDashHeroText}>
|
||||
<h1 className={styles.tbDashTitle}>{t('Teams Bot')}</h1>
|
||||
<p className={styles.tbDashSubtitle}>
|
||||
{t('Dashboard mit Übersicht, Modulen und Live-Sitzung — neues Meeting über den Assistenten starten.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Start New Session Card */}
|
||||
<div className={styles.startSessionCard}>
|
||||
<h3 className={styles.cardTitle}>{t('Neue Botsitzung starten')}</h3>
|
||||
<p className={styles.cardDescription}>
|
||||
Fuege den Teams Meeting-Link ein, um den AI-Bot in ein Meeting einzuschleusen.
|
||||
</p>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.label}>{t('Teams-Meetinglink')}</label>
|
||||
<input
|
||||
type="url"
|
||||
className={styles.input}
|
||||
placeholder="https://teams.microsoft.com/l/meetup-join/..."
|
||||
value={meetingLink}
|
||||
onChange={(e) => setMeetingLink(e.target.value)}
|
||||
disabled={isStarting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.label}>Join-Modus</label>
|
||||
<select
|
||||
className={styles.select || styles.input}
|
||||
value={joinMode}
|
||||
onChange={(e) => setJoinMode(e.target.value as TeamsbotJoinMode)}
|
||||
disabled={isStarting}
|
||||
<div className={styles.tbDashQuickActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tbDashBtnPrimary}
|
||||
onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/assistant`)}
|
||||
>
|
||||
{_isSysAdmin && <option value="systemBot">{t('Systembot authentifiziert')}</option>}
|
||||
<option value="anonymous">{t('Anonymer Gast')}</option>
|
||||
<option value="userAccount">{t('Mein Account')}</option>
|
||||
</select>
|
||||
{t('Neues Meeting')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tbDashBtnSecondary}
|
||||
onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/modules`)}
|
||||
>
|
||||
{t('Module')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tbDashBtnSecondary}
|
||||
onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/sessions`)}
|
||||
>
|
||||
{t('Live-Session')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* User Account: saved credentials info or credential form */}
|
||||
{joinMode === 'userAccount' && (
|
||||
<div className={styles.credentialsCard}>
|
||||
{userAccount?.hasSavedCredentials && !showCredentialForm ? (
|
||||
<div className={styles.credentialsInfo}>
|
||||
<span>
|
||||
Gespeichert: <span className={styles.credentialsEmail}>{userAccount.email}</span>
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
className={styles.viewButton}
|
||||
onClick={() => { setShowCredentialForm(true); setCredEmail(userAccount.email || ''); }}
|
||||
>
|
||||
Aendern
|
||||
</button>
|
||||
<button className={styles.deleteButton} onClick={_handleDeleteUserAccount}>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.label}>{t('Microsoft E-Mail')}</label>
|
||||
<input
|
||||
type="email"
|
||||
className={styles.input}
|
||||
placeholder="name@example.com"
|
||||
value={credEmail}
|
||||
onChange={(e) => setCredEmail(e.target.value)}
|
||||
disabled={savingCredentials}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.label}>{t('Passwort')}</label>
|
||||
<input
|
||||
type="password"
|
||||
className={styles.input}
|
||||
placeholder="Microsoft-Passwort"
|
||||
value={credPassword}
|
||||
onChange={(e) => setCredPassword(e.target.value)}
|
||||
disabled={savingCredentials}
|
||||
/>
|
||||
</div>
|
||||
<span style={{ fontSize: '12px', color: '#888', marginTop: '4px', display: 'block' }}>
|
||||
Zugangsdaten werden verschluesselt gespeichert.
|
||||
</span>
|
||||
{userAccount?.hasSavedCredentials && (
|
||||
<button
|
||||
className={styles.viewButton}
|
||||
style={{ marginTop: '0.5rem' }}
|
||||
onClick={() => setShowCredentialForm(false)}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.label}>{t('Botname (optional)')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
placeholder={t('KI-Assistent')}
|
||||
value={botName}
|
||||
onChange={(e) => setBotName(e.target.value)}
|
||||
disabled={isStarting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.label}>{t('Sitzungskontext (optional)')}</label>
|
||||
<textarea
|
||||
className={styles.textarea || styles.input}
|
||||
placeholder={t('Agenda, Hintergrundinfos, Dokumente oder …')}
|
||||
value={sessionContext}
|
||||
onChange={(e) => setSessionContext(e.target.value)}
|
||||
disabled={isStarting}
|
||||
rows={4}
|
||||
style={{ resize: 'vertical', minHeight: '80px' }}
|
||||
/>
|
||||
<span style={{ fontSize: '12px', color: '#888' }}>
|
||||
Kontext den der Bot waehrend der Sitzung nutzen kann (z.B. Meeting-Agenda, Projektinfos).
|
||||
{sessionContext.length > 0 && ` (${sessionContext.length} Zeichen)`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={styles.startButton}
|
||||
onClick={_handleStartSession}
|
||||
disabled={isStarting || !meetingLink.trim() || (joinMode === 'userAccount' && showCredentialForm && (!credEmail || !credPassword))}
|
||||
>
|
||||
{isStarting ? t('Wird gestartet') : t('Bot ins Meeting senden')}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className={styles.errorBanner}>{error}</div>}
|
||||
|
||||
{/* Active Sessions */}
|
||||
<section className={styles.tbDashKpiGrid} aria-label={t('Kennzahlen')}>
|
||||
<div className={styles.tbDashKpiCard}>
|
||||
<div className={styles.tbDashKpiValue}>{modules.length}</div>
|
||||
<div className={styles.tbDashKpiLabel}>{t('Meeting-Module')}</div>
|
||||
</div>
|
||||
<div className={styles.tbDashKpiCard}>
|
||||
<div className={styles.tbDashKpiValue}>{activeSessions.length}</div>
|
||||
<div className={styles.tbDashKpiLabel}>{t('Aktive Sitzungen')}</div>
|
||||
</div>
|
||||
<div className={styles.tbDashKpiCard}>
|
||||
<div className={styles.tbDashKpiValue}>{sessions.length}</div>
|
||||
<div className={styles.tbDashKpiLabel}>{t('Sitzungen gesamt')}</div>
|
||||
</div>
|
||||
<div className={styles.tbDashKpiCard}>
|
||||
<div className={styles.tbDashKpiValue}>{totalSegments}</div>
|
||||
<div className={styles.tbDashKpiLabel}>{t('Transkript-Segmente')}</div>
|
||||
<div className={styles.tbDashKpiHint}>{totalResponses} {t('Bot-Antworten')}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.tbDashSection}>
|
||||
<h2 className={styles.tbDashSectionTitle}>{t('Module nach Aktivität')}</h2>
|
||||
{topModules.length === 0 ? (
|
||||
<p className={styles.emptyState}>{t('Noch keine Sitzungen — starte ein Meeting im Assistenten.')}</p>
|
||||
) : (
|
||||
<div className={styles.tbDashModuleGrid}>
|
||||
{topModules.map((row) => (
|
||||
<button
|
||||
key={row.moduleId}
|
||||
type="button"
|
||||
className={styles.tbDashModuleCard}
|
||||
onClick={() => navigate(
|
||||
row.moduleId === '_adhoc'
|
||||
? `/mandates/${mandateId}/${featureCode}/${instanceId}/modules`
|
||||
: `/mandates/${mandateId}/${featureCode}/${instanceId}/modules?moduleId=${encodeURIComponent(row.moduleId)}`,
|
||||
)}
|
||||
>
|
||||
<span className={styles.tbDashModuleTitle}>{row.title}</span>
|
||||
<span className={styles.tbDashModuleCount}>{row.sessionCount} {t('Sitzungen')}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{activeSessions.length > 0 && (
|
||||
<div className={styles.sectionContainer}>
|
||||
<h3 className={styles.sectionTitle}>{t('Aktive Sitzungen')}</h3>
|
||||
<div className={styles.sessionList}>
|
||||
<section className={styles.tbDashSection}>
|
||||
<h2 className={styles.tbDashSectionTitle}>{t('Aktive Sitzungen')}</h2>
|
||||
<div className={styles.tbDashSessionList}>
|
||||
{activeSessions.map((session) => (
|
||||
<div key={session.id} className={styles.sessionCard}>
|
||||
<div className={styles.sessionHeader}>
|
||||
<div key={session.id} className={styles.tbDashSessionRow}>
|
||||
<div className={styles.tbDashSessionMain}>
|
||||
<span className={styles.sessionBotName}>{session.botName}</span>
|
||||
<span className={`${styles.statusBadge} ${_getStatusBadgeClass(session.status)}`}>
|
||||
{_getStatusLabel(session.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.sessionMeta}>
|
||||
<span>{session.transcriptSegmentCount} Segmente</span>
|
||||
<span>{session.botResponseCount} Antworten</span>
|
||||
{session.startedAt && <span>Seit: {new Date(session.startedAt).toLocaleTimeString('de-CH')}</span>}
|
||||
<div className={styles.tbDashSessionMeta}>
|
||||
{session.moduleId && (
|
||||
<span>{moduleTitleById.get(session.moduleId) || session.moduleId}</span>
|
||||
)}
|
||||
<span>{session.transcriptSegmentCount} {t('Segmente')}</span>
|
||||
<span>{session.botResponseCount} {t('Antworten')}</span>
|
||||
</div>
|
||||
<div className={styles.sessionActions}>
|
||||
<button className={styles.viewButton} onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/sessions?sessionId=${session.id}`)}>{t('Live ansehen')}</button>
|
||||
{session.status === 'active' && (
|
||||
<button className={styles.stopButton} onClick={() => _handleStopSession(session.id)}>
|
||||
Stoppen
|
||||
<div className={styles.tbDashSessionActions}>
|
||||
<button type="button" className={styles.viewButton} onClick={() => navigate(_sessionPath(session.id))}>
|
||||
{t('Live ansehen')}
|
||||
</button>
|
||||
{!['ended', 'error', 'leaving'].includes(session.status) && (
|
||||
<button type="button" className={styles.stopButton} onClick={() => _handleStopSession(session.id)}>
|
||||
{t('Stoppen')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Past Sessions */}
|
||||
<div className={styles.sectionContainer}>
|
||||
<h3 className={styles.sectionTitle}>
|
||||
{loading ? 'Lade Sitzungen...' : `Vergangene Sitzungen (${pastSessions.length})`}
|
||||
</h3>
|
||||
{pastSessions.length === 0 && !loading && (
|
||||
<p className={styles.emptyState}>{t('Noch keine vergangenen Sitzungen')}</p>
|
||||
)}
|
||||
<div className={styles.sessionList}>
|
||||
{pastSessions.map((session) => (
|
||||
<div key={session.id} className={styles.sessionCard}>
|
||||
<div className={styles.sessionHeader}>
|
||||
<span className={styles.sessionBotName}>{session.botName}</span>
|
||||
<span className={`${styles.statusBadge} ${_getStatusBadgeClass(session.status)}`}>
|
||||
{_getStatusLabel(session.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.sessionMeta}>
|
||||
<span>{session.transcriptSegmentCount} Segmente</span>
|
||||
<span>{session.botResponseCount} Antworten</span>
|
||||
{session.startedAt && <span>{new Date(session.startedAt).toLocaleDateString('de-CH')}</span>}
|
||||
</div>
|
||||
{session.summary && (
|
||||
<div className={styles.sessionSummary}>{session.summary.substring(0, 200)}...</div>
|
||||
)}
|
||||
{session.errorMessage && (
|
||||
<div className={styles.sessionError}>{session.errorMessage}</div>
|
||||
)}
|
||||
<div className={styles.sessionActions}>
|
||||
<button className={styles.viewButton} onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/sessions?sessionId=${session.id}`)}>Details</button>
|
||||
<button className={styles.deleteButton} onClick={() => _handleDeleteSession(session.id)}>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@
|
|||
*
|
||||
* CRUD list of MeetingModules with expandable session lists per module.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
import * as teamsbotApi from '../../../api/teamsbotApi';
|
||||
import type { MeetingModule, TeamsbotSession, MediaFileInfo } from '../../../api/teamsbotApi';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import { useFileContext } from '../../../contexts/FileContext';
|
||||
import { FaSpinner } from 'react-icons/fa';
|
||||
import styles from './Teamsbot.module.css';
|
||||
|
||||
const SERIES_TYPE_LABELS: Record<string, string> = {
|
||||
|
|
@ -29,13 +32,62 @@ export const TeamsbotModulesView: React.FC = () => {
|
|||
const { instance, mandateId } = useCurrentInstance();
|
||||
const instanceId = instance?.id || '';
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const focusModuleId = searchParams.get('moduleId') || '';
|
||||
const moduleRowRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
const [modules, setModules] = useState<any[]>([]);
|
||||
const [modules, setModules] = useState<MeetingModule[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [moduleSessions, setModuleSessions] = useState<Record<string, any[]>>({});
|
||||
const [moduleSessions, setModuleSessions] = useState<Record<string, TeamsbotSession[]>>({});
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [editingModule, setEditingModule] = useState<any | null>(null);
|
||||
const [editingModule, setEditingModule] = useState<MeetingModule | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createTitle, setCreateTitle] = useState('');
|
||||
const [createSeriesType, setCreateSeriesType] = useState<string>('adhoc');
|
||||
const [createDefaultLink, setCreateDefaultLink] = useState('');
|
||||
const [createDefaultBotName, setCreateDefaultBotName] = useState('');
|
||||
const [createGoals, setCreateGoals] = useState('');
|
||||
const [createDefaultAvatarFileId, setCreateDefaultAvatarFileId] = useState('');
|
||||
const [createSaving, setCreateSaving] = useState(false);
|
||||
|
||||
const [mediaFiles, setMediaFiles] = useState<MediaFileInfo[]>([]);
|
||||
const fileCtx = useFileContext();
|
||||
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [avatarUploading, setAvatarUploading] = useState(false);
|
||||
const [avatarTarget, setAvatarTarget] = useState<'create' | 'edit'>('create');
|
||||
|
||||
const _refreshMediaFiles = useCallback(async () => {
|
||||
const result = await teamsbotApi.listMediaFiles().catch(() => [] as MediaFileInfo[]);
|
||||
setMediaFiles(result);
|
||||
}, []);
|
||||
|
||||
const _handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !fileCtx?.handleFileUpload) return;
|
||||
setAvatarUploading(true);
|
||||
try {
|
||||
const result = await fileCtx.handleFileUpload(file);
|
||||
if (result?.success) {
|
||||
const data: any = (result.fileData as any)?.file || result.fileData;
|
||||
const id = data?.id || (result.fileData as any)?.id;
|
||||
if (id) {
|
||||
if (avatarTarget === 'create') {
|
||||
setCreateDefaultAvatarFileId(id);
|
||||
} else if (editingModule) {
|
||||
setEditingModule({ ...editingModule, defaultAvatarFileId: id });
|
||||
}
|
||||
const refreshed = await teamsbotApi.listMediaFiles().catch(() => [] as MediaFileInfo[]);
|
||||
setMediaFiles(refreshed);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// upload error handled by FileContext
|
||||
} finally {
|
||||
setAvatarUploading(false);
|
||||
if (avatarInputRef.current) avatarInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const _loadModules = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
|
|
@ -52,6 +104,10 @@ export const TeamsbotModulesView: React.FC = () => {
|
|||
|
||||
useEffect(() => { _loadModules(); }, [_loadModules]);
|
||||
|
||||
useEffect(() => {
|
||||
teamsbotApi.listMediaFiles().then(setMediaFiles).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const _loadModuleSessions = useCallback(async (moduleId: string) => {
|
||||
if (!instanceId) return;
|
||||
try {
|
||||
|
|
@ -62,6 +118,20 @@ export const TeamsbotModulesView: React.FC = () => {
|
|||
}
|
||||
}, [instanceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusModuleId || modules.length === 0) return;
|
||||
if (!modules.some((m) => m.id === focusModuleId)) return;
|
||||
setExpandedId(focusModuleId);
|
||||
_loadModuleSessions(focusModuleId);
|
||||
}, [focusModuleId, modules, _loadModuleSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusModuleId || expandedId !== focusModuleId) return;
|
||||
requestAnimationFrame(() => {
|
||||
moduleRowRefs.current[focusModuleId]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
});
|
||||
}, [focusModuleId, expandedId]);
|
||||
|
||||
const _toggleExpand = (moduleId: string) => {
|
||||
if (expandedId === moduleId) {
|
||||
setExpandedId(null);
|
||||
|
|
@ -81,7 +151,7 @@ export const TeamsbotModulesView: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const _handleUpdate = async (moduleId: string, updates: any) => {
|
||||
const _handleUpdate = async (moduleId: string, updates: Partial<MeetingModule>) => {
|
||||
try {
|
||||
await teamsbotApi.updateModule(instanceId, moduleId, updates);
|
||||
setEditingModule(null);
|
||||
|
|
@ -91,27 +161,96 @@ export const TeamsbotModulesView: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const _handleCreateModule = async () => {
|
||||
if (!createTitle.trim()) return;
|
||||
setCreateSaving(true);
|
||||
try {
|
||||
await teamsbotApi.createModule(instanceId, {
|
||||
title: createTitle.trim(),
|
||||
seriesType: createSeriesType,
|
||||
defaultMeetingLink: createDefaultLink.trim() || undefined,
|
||||
defaultBotName: createDefaultBotName.trim() || undefined,
|
||||
defaultAvatarFileId: createDefaultAvatarFileId || undefined,
|
||||
goals: createGoals.trim() || undefined,
|
||||
});
|
||||
setCreateOpen(false);
|
||||
setCreateTitle('');
|
||||
setCreateSeriesType('adhoc');
|
||||
setCreateDefaultLink('');
|
||||
setCreateDefaultBotName('');
|
||||
setCreateDefaultAvatarFileId('');
|
||||
setCreateGoals('');
|
||||
_loadModules();
|
||||
} catch (err) {
|
||||
console.error('Create module failed:', err);
|
||||
} finally {
|
||||
setCreateSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const _formatSessionDateTime = (ts?: string | number): string => {
|
||||
if (ts == null) return '-';
|
||||
const ms = typeof ts === 'number' ? ts * 1000 : Date.parse(String(ts));
|
||||
if (Number.isNaN(ms)) return '-';
|
||||
const d = new Date(ms);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
};
|
||||
|
||||
const _calcDurationMin = (startedAt?: string | number, endedAt?: string | number): string => {
|
||||
if (!startedAt) return '-';
|
||||
const startMs = typeof startedAt === 'number' ? startedAt * 1000 : Date.parse(String(startedAt));
|
||||
if (Number.isNaN(startMs)) return '-';
|
||||
const endMs = endedAt
|
||||
? (typeof endedAt === 'number' ? endedAt * 1000 : Date.parse(String(endedAt)))
|
||||
: Date.now();
|
||||
if (Number.isNaN(endMs)) return '-';
|
||||
const mins = Math.round((endMs - startMs) / 60000);
|
||||
return `${mins} min`;
|
||||
};
|
||||
|
||||
const _sortedSessions = (sessions: TeamsbotSession[]): TeamsbotSession[] =>
|
||||
[...sessions].sort((a, b) => {
|
||||
const ta = a.startedAt ? (typeof a.startedAt === 'number' ? a.startedAt * 1000 : Date.parse(String(a.startedAt))) : 0;
|
||||
const tb = b.startedAt ? (typeof b.startedAt === 'number' ? b.startedAt * 1000 : Date.parse(String(b.startedAt))) : 0;
|
||||
return tb - ta;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.modulesContainer}>
|
||||
<div className={styles.modulesHeader}>
|
||||
<h2>{t('Meeting-Module')}</h2>
|
||||
<button
|
||||
className={styles.btnPrimary}
|
||||
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant`)}
|
||||
>
|
||||
{t('Neues Modul')}
|
||||
</button>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnSecondary}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
{t('Modul anlegen')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnPrimary}
|
||||
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant`)}
|
||||
>
|
||||
{t('Meeting starten')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <div className={styles.loading}>{t('Laden...')}</div>}
|
||||
|
||||
<div className={styles.modulesList}>
|
||||
{modules.map(mod => (
|
||||
<div key={mod.id} className={`${styles.moduleCard} ${expandedId === mod.id ? styles.moduleExpanded : ''}`}>
|
||||
<div
|
||||
key={mod.id}
|
||||
ref={(el) => { moduleRowRefs.current[mod.id] = el; }}
|
||||
className={`${styles.moduleCard} ${expandedId === mod.id ? styles.moduleExpanded : ''} ${focusModuleId === mod.id ? styles.moduleRowFocused : ''}`}
|
||||
>
|
||||
<div className={styles.moduleRow} onClick={() => _toggleExpand(mod.id)}>
|
||||
<span className={styles.moduleType}>{t(SERIES_TYPE_LABELS[mod.seriesType] || mod.seriesType)}</span>
|
||||
<span className={styles.moduleTitle}>{mod.title}</span>
|
||||
<span className={styles.moduleStatus}>{t(STATUS_LABELS[mod.status] || mod.status)}</span>
|
||||
<span className={styles.moduleStatus}>{t(STATUS_LABELS[mod.status] || mod.status || 'Aktiv')}</span>
|
||||
<div className={styles.moduleActions}>
|
||||
<button className={styles.btnPrimary} style={{ padding: '0.3rem 0.7rem', fontSize: '0.8rem' }} onClick={e => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -127,17 +266,50 @@ export const TeamsbotModulesView: React.FC = () => {
|
|||
{(moduleSessions[mod.id] || []).length === 0 ? (
|
||||
<p className={styles.noSessions}>{t('Keine Sitzungen')}</p>
|
||||
) : (
|
||||
(moduleSessions[mod.id] || []).map((sess: any) => (
|
||||
<div
|
||||
key={sess.id}
|
||||
className={styles.sessionRow}
|
||||
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/sessions?sessionId=${sess.id}`)}
|
||||
>
|
||||
<span>{sess.botName || 'Bot'}</span>
|
||||
<span className={styles.sessionStatus}>{sess.status}</span>
|
||||
<span>{sess.startedAt ? new Date(sess.startedAt * 1000).toLocaleDateString() : '-'}</span>
|
||||
</div>
|
||||
))
|
||||
<table className={styles.sessionTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 32 }}></th>
|
||||
<th>{t('Datum')}</th>
|
||||
<th>{t('Dauer')}</th>
|
||||
<th>{t('Status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{_sortedSessions(moduleSessions[mod.id] || []).map((sess) => (
|
||||
<tr
|
||||
key={sess.id}
|
||||
className={styles.sessionTableRow}
|
||||
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/sessions?sessionId=${sess.id}`)}
|
||||
>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sessionDeleteBtn}
|
||||
title={t('Sitzung loeschen')}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await teamsbotApi.deleteSession(instanceId, sess.id);
|
||||
setModuleSessions((prev) => ({
|
||||
...prev,
|
||||
[mod.id]: (prev[mod.id] || []).filter((s) => s.id !== sess.id),
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('Delete session failed:', err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</td>
|
||||
<td>{_formatSessionDateTime(sess.startedAt)}</td>
|
||||
<td>{_calcDurationMin(sess.startedAt, sess.endedAt)}</td>
|
||||
<td><span className={styles.sessionStatus}>{sess.status}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -145,6 +317,85 @@ export const TeamsbotModulesView: React.FC = () => {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{createOpen && (
|
||||
<div className={styles.confirmOverlay}>
|
||||
<div className={styles.editDialog}>
|
||||
<h3>{t('Modul anlegen')}</h3>
|
||||
<label className={styles.label}>{t('Titel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.wizardInput}
|
||||
value={createTitle}
|
||||
onChange={e => setCreateTitle(e.target.value)}
|
||||
placeholder={t('z.B. Weekly Standup')}
|
||||
autoFocus
|
||||
/>
|
||||
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Serientyp')}</label>
|
||||
<select
|
||||
className={styles.wizardSelect}
|
||||
value={createSeriesType}
|
||||
onChange={e => setCreateSeriesType(e.target.value)}
|
||||
>
|
||||
{Object.entries(SERIES_TYPE_LABELS).map(([code, lab]) => (
|
||||
<option key={code} value={code}>{lab}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Standard-Meeting-Link')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.wizardInput}
|
||||
value={createDefaultLink}
|
||||
onChange={e => setCreateDefaultLink(e.target.value)}
|
||||
placeholder="https://teams.microsoft.com/..."
|
||||
/>
|
||||
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Standard-Bot-Name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.wizardInput}
|
||||
value={createDefaultBotName}
|
||||
onChange={e => setCreateDefaultBotName(e.target.value)}
|
||||
/>
|
||||
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Avatar-Bild / Video')}</label>
|
||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
||||
<select
|
||||
className={styles.wizardSelect}
|
||||
style={{ flex: 1 }}
|
||||
value={createDefaultAvatarFileId}
|
||||
onChange={e => setCreateDefaultAvatarFileId(e.target.value)}
|
||||
onFocus={_refreshMediaFiles}
|
||||
>
|
||||
<option value="">{t('Standard (aus Bot-Einstellungen)')}</option>
|
||||
{mediaFiles.map(f => (
|
||||
<option key={f.id} value={f.id}>{f.fileName} ({f.mimeType})</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className={styles.btnSmall} disabled={avatarUploading} onClick={() => { setAvatarTarget('create'); avatarInputRef.current?.click(); }} style={{ whiteSpace: 'nowrap' }}>
|
||||
{avatarUploading && avatarTarget === 'create' ? <FaSpinner className={styles.spinner} /> : null}
|
||||
{t('Hochladen')}
|
||||
</button>
|
||||
</div>
|
||||
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Ziele')}</label>
|
||||
<textarea
|
||||
className={styles.wizardTextarea}
|
||||
rows={2}
|
||||
value={createGoals}
|
||||
onChange={e => setCreateGoals(e.target.value)}
|
||||
/>
|
||||
<div className={styles.confirmActions}>
|
||||
<button type="button" className={styles.btnSecondary} onClick={() => setCreateOpen(false)}>{t('Abbrechen')}</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnPrimary}
|
||||
onClick={_handleCreateModule}
|
||||
disabled={!createTitle.trim() || createSaving}
|
||||
>
|
||||
{createSaving ? t('Speichern…') : t('Anlegen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleteConfirm && (
|
||||
<div className={styles.confirmOverlay}>
|
||||
<div className={styles.confirmDialog}>
|
||||
|
|
@ -167,6 +418,41 @@ export const TeamsbotModulesView: React.FC = () => {
|
|||
className={styles.wizardInput}
|
||||
onBlur={e => setEditingModule({ ...editingModule, title: e.target.value })}
|
||||
/>
|
||||
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Standard-Meeting-Link')}</label>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={editingModule.defaultMeetingLink || ''}
|
||||
className={styles.wizardInput}
|
||||
placeholder="https://teams.microsoft.com/l/meetup-join/..."
|
||||
onBlur={e => setEditingModule({ ...editingModule, defaultMeetingLink: e.target.value })}
|
||||
/>
|
||||
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Standard-Bot-Name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={editingModule.defaultBotName || ''}
|
||||
className={styles.wizardInput}
|
||||
placeholder={t('z.B. AI Assistant')}
|
||||
onBlur={e => setEditingModule({ ...editingModule, defaultBotName: e.target.value })}
|
||||
/>
|
||||
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Avatar-Bild / Video')}</label>
|
||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
||||
<select
|
||||
className={styles.wizardSelect}
|
||||
style={{ flex: 1 }}
|
||||
value={editingModule.defaultAvatarFileId || ''}
|
||||
onChange={e => setEditingModule({ ...editingModule, defaultAvatarFileId: e.target.value || undefined })}
|
||||
onFocus={_refreshMediaFiles}
|
||||
>
|
||||
<option value="">{t('Standard (aus Bot-Einstellungen)')}</option>
|
||||
{mediaFiles.map(f => (
|
||||
<option key={f.id} value={f.id}>{f.fileName} ({f.mimeType})</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className={styles.btnSmall} disabled={avatarUploading} onClick={() => { setAvatarTarget('edit'); avatarInputRef.current?.click(); }} style={{ whiteSpace: 'nowrap' }}>
|
||||
{avatarUploading && avatarTarget === 'edit' ? <FaSpinner className={styles.spinner} /> : null}
|
||||
{t('Hochladen')}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
defaultValue={editingModule.goals || ''}
|
||||
className={styles.wizardTextarea}
|
||||
|
|
@ -179,11 +465,22 @@ export const TeamsbotModulesView: React.FC = () => {
|
|||
<button className={styles.btnPrimary} onClick={() => _handleUpdate(editingModule.id, {
|
||||
title: editingModule.title,
|
||||
goals: editingModule.goals,
|
||||
defaultMeetingLink: (editingModule.defaultMeetingLink || '').trim(),
|
||||
defaultBotName: (editingModule.defaultBotName || '').trim(),
|
||||
defaultAvatarFileId: (editingModule.defaultAvatarFileId || '').trim() || undefined,
|
||||
})}>{t('Speichern')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={_handleAvatarUpload}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
ScreenshotInfo,
|
||||
DirectorPrompt,
|
||||
DirectorPromptMode,
|
||||
MfaChallengeEvent,
|
||||
} from '../../../api/teamsbotApi';
|
||||
import {
|
||||
DIRECTOR_PROMPT_TEXT_LIMIT,
|
||||
|
|
@ -22,6 +23,8 @@ import { useFileContext } from '../../../contexts/FileContext';
|
|||
import styles from './Teamsbot.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
/**
|
||||
* TeamsbotSessionView - Live session view with real-time transcript and bot responses.
|
||||
|
|
@ -50,6 +53,7 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
const [screenshots, setScreenshots] = useState<ScreenshotInfo[]>([]);
|
||||
const [screenshotsLoading, setScreenshotsLoading] = useState(false);
|
||||
const [screenshotsLoaded, setScreenshotsLoaded] = useState(false);
|
||||
const [screenshotsExpanded, setScreenshotsExpanded] = useState(false);
|
||||
const [ttsStatusEvents, setTtsStatusEvents] = useState<Array<{
|
||||
status: string;
|
||||
message?: string;
|
||||
|
|
@ -59,6 +63,8 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
|
||||
const [agentStatus, setAgentStatus] = useState<{ toolName?: string; status?: string; reason?: string } | null>(null);
|
||||
const agentStatusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [agentProgressLog, setAgentProgressLog] = useState<Array<{ id: number; text: string; ts: string }>>([]);
|
||||
const agentProgressIdRef = useRef(0);
|
||||
const [sessionStats, setSessionStats] = useState<any>(null);
|
||||
const [reconnectTick, setReconnectTick] = useState(0);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
|
@ -80,6 +86,10 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
// the gateway. Director prompts can only be processed once botConnected=true.)
|
||||
const [botConnected, setBotConnected] = useState(false);
|
||||
|
||||
const [mfaChallenge, setMfaChallenge] = useState<MfaChallengeEvent | null>(null);
|
||||
const [mfaCode, setMfaCode] = useState('');
|
||||
const [mfaWaitingPush, setMfaWaitingPush] = useState(false);
|
||||
|
||||
// UDB Sidebar state
|
||||
const [udbCollapsed, setUdbCollapsed] = useState(false);
|
||||
const [udbTab, setUdbTab] = useState<UdbTab>('files');
|
||||
|
|
@ -305,6 +315,22 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
setAgentStatus({ toolName: data.toolName, status: data.status, reason: data.reason });
|
||||
if (agentStatusTimerRef.current) clearTimeout(agentStatusTimerRef.current);
|
||||
agentStatusTimerRef.current = setTimeout(() => setAgentStatus(null), 15000);
|
||||
} else if (data.status === 'toolCall') {
|
||||
const label = data.displayLabel || data.toolName || '';
|
||||
setAgentStatus({ toolName: label, status: 'running' });
|
||||
if (agentStatusTimerRef.current) clearTimeout(agentStatusTimerRef.current);
|
||||
agentStatusTimerRef.current = setTimeout(() => setAgentStatus(null), 15000);
|
||||
agentProgressIdRef.current += 1;
|
||||
const entry = { id: agentProgressIdRef.current, text: `${label}`, ts: new Date().toLocaleTimeString() };
|
||||
setAgentProgressLog((prev) => [...prev.slice(-19), entry]);
|
||||
} else if (data.status === 'toolResult') {
|
||||
const summary = data.summary ? `: ${data.summary.substring(0, 120)}` : '';
|
||||
agentProgressIdRef.current += 1;
|
||||
const entry = { id: agentProgressIdRef.current, text: `${data.toolName || ''}${summary}`, ts: new Date().toLocaleTimeString() };
|
||||
setAgentProgressLog((prev) => [...prev.slice(-19), entry]);
|
||||
} else if (data.status === 'completed') {
|
||||
if (agentStatusTimerRef.current) clearTimeout(agentStatusTimerRef.current);
|
||||
setAgentStatus(null);
|
||||
} else {
|
||||
if (agentStatusTimerRef.current) clearTimeout(agentStatusTimerRef.current);
|
||||
agentStatusTimerRef.current = setTimeout(() => setAgentStatus(null), 2000);
|
||||
|
|
@ -325,6 +351,31 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
|
||||
case 'ping':
|
||||
break;
|
||||
|
||||
case 'mfaChallenge': {
|
||||
const data = sseEvent.data as MfaChallengeEvent;
|
||||
if (data.mfaType === 'timeout') {
|
||||
setMfaChallenge(null);
|
||||
setMfaWaitingPush(false);
|
||||
setError(t('MFA-Zeitlimit überschritten, bitte erneut versuchen'));
|
||||
} else {
|
||||
setMfaChallenge(data);
|
||||
setMfaCode('');
|
||||
setMfaWaitingPush(data.mfaType === 'pushApproval' || data.mfaType === 'numberMatch');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'mfaResolved': {
|
||||
setMfaChallenge(null);
|
||||
setMfaWaitingPush(false);
|
||||
teamsbotApi.getSession(instanceId, sessionId).then((result) => {
|
||||
setSession(result.session);
|
||||
if (result.transcripts) setTranscripts(result.transcripts);
|
||||
if (result.botResponses) setBotResponses(result.botResponses);
|
||||
}).catch(() => {});
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
_dlog('SSE-ERR', String(err));
|
||||
|
|
@ -358,8 +409,35 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [instanceId, sessionId, reconnectTick]);
|
||||
|
||||
// Polling fallback: refresh session data every 5s when SSE is not connected.
|
||||
// Uses isActive (boolean) instead of session object to prevent interval resets.
|
||||
// Keep session switcher labels in sync when the selected session updates (SSE, poll, etc.).
|
||||
useEffect(() => {
|
||||
if (!session?.id) return;
|
||||
setAllSessions((prev) => {
|
||||
const idx = prev.findIndex((s) => s.id === session.id);
|
||||
if (idx < 0) return prev;
|
||||
const row = prev[idx];
|
||||
if (
|
||||
row.status === session.status
|
||||
&& row.botName === session.botName
|
||||
&& row.startedAt === session.startedAt
|
||||
&& row.endedAt === session.endedAt
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
const next = [...prev];
|
||||
next[idx] = {
|
||||
...next[idx],
|
||||
status: session.status,
|
||||
botName: session.botName,
|
||||
startedAt: session.startedAt,
|
||||
endedAt: session.endedAt,
|
||||
};
|
||||
return next;
|
||||
});
|
||||
}, [session?.id, session?.status, session?.botName, session?.startedAt, session?.endedAt]);
|
||||
|
||||
// Polling: while joining/pending, poll even if SSE is connected (status may not arrive on stream).
|
||||
// For active-only, poll only when SSE is down (previous behavior).
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const isActive = useMemo(() => session && ['pending', 'joining', 'active'].includes(session.status), [session?.status]);
|
||||
const isLiveRef = useRef(isLive);
|
||||
|
|
@ -367,22 +445,24 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
useEffect(() => {
|
||||
if (!instanceId || !sessionId) return;
|
||||
if (!isActive) return;
|
||||
const intervalMs = session?.status === 'active' ? 5000 : 2500;
|
||||
pollRef.current = setInterval(async () => {
|
||||
if (isLiveRef.current) return;
|
||||
const st = sessionStatusRef.current;
|
||||
const pollWhileLive = st === 'pending' || st === 'joining';
|
||||
if (!pollWhileLive && isLiveRef.current) return;
|
||||
try {
|
||||
const result = await teamsbotApi.getSession(instanceId, sessionId);
|
||||
setSession(result.session);
|
||||
if (result.transcripts) setTranscripts(result.transcripts);
|
||||
if (result.botResponses) setBotResponses(result.botResponses);
|
||||
// If session became active and SSE is dead, trigger reconnect
|
||||
const newStatus = result.session?.status;
|
||||
if (newStatus && ['active', 'joining', 'pending'].includes(newStatus) && !eventSourceRef.current) {
|
||||
setReconnectTick(v => v + 1);
|
||||
setReconnectTick((v) => v + 1);
|
||||
}
|
||||
} catch {}
|
||||
}, 5000);
|
||||
} catch { /* ignore */ }
|
||||
}, intervalMs);
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
||||
}, [isActive, instanceId, sessionId]);
|
||||
}, [isActive, instanceId, sessionId, session?.status]);
|
||||
|
||||
// Auto-scroll transcript
|
||||
useEffect(() => {
|
||||
|
|
@ -398,6 +478,25 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const _mfaNeedsCodeInput = mfaChallenge?.mfaType === 'totpCode' || mfaChallenge?.mfaType === 'smsCode';
|
||||
|
||||
const _handleSubmitMfaCode = async () => {
|
||||
if (!instanceId || !sessionId) return;
|
||||
try {
|
||||
await teamsbotApi.submitMfaCode(
|
||||
instanceId,
|
||||
sessionId,
|
||||
_mfaNeedsCodeInput ? mfaCode : '',
|
||||
_mfaNeedsCodeInput ? 'code' : 'confirmed',
|
||||
);
|
||||
if (!_mfaNeedsCodeInput) {
|
||||
setMfaWaitingPush(true);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('Fehler beim Senden des MFA-Codes'));
|
||||
}
|
||||
};
|
||||
|
||||
const _formatTime = (timestamp: string) => {
|
||||
try {
|
||||
const dt = new Date(timestamp);
|
||||
|
|
@ -632,13 +731,16 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
<p style={{ color: 'var(--text-secondary, #666)', textAlign: 'center', maxWidth: 400 }}>
|
||||
{t('Starte ein neues Meeting ueber den Assistenten oder die Module-Seite.')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<button className={styles.btnPrimary}
|
||||
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant`)}
|
||||
>{t('Zum Assistenten')}</button>
|
||||
<button className={styles.btnSecondary}
|
||||
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/modules`)}
|
||||
>{t('Zu den Modulen')}</button>
|
||||
<button className={styles.btnSecondary}
|
||||
onClick={() => navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/dashboard`)}
|
||||
>{t('Zum Dashboard')}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -650,39 +752,88 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
|
||||
return (
|
||||
<div className={styles.sessionContainer}>
|
||||
{/* Session Switcher (if multiple sessions exist) */}
|
||||
{allSessions.length > 1 && (
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>
|
||||
{allSessions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => _switchSession(s.id)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '6px',
|
||||
border: s.id === sessionId ? '2px solid #4A90D9' : '1px solid #ddd',
|
||||
background: s.id === sessionId ? '#EBF3FC' : '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: s.id === sessionId ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{s.botName}
|
||||
{['active', 'joining', 'pending'].includes(s.status) && ' (aktiv)'}
|
||||
{s.status === 'ended' && ' (beendet)'}
|
||||
</button>
|
||||
))}
|
||||
{mfaChallenge && (
|
||||
<div className={styles.mfaOverlay}>
|
||||
<div className={styles.mfaDialog}>
|
||||
<div className={styles.mfaTitle}>{t('Multi-Faktor-Authentifizierung')}</div>
|
||||
{mfaChallenge.displayNumber && (
|
||||
<div className={styles.mfaNumber}>{mfaChallenge.displayNumber}</div>
|
||||
)}
|
||||
<div className={styles.mfaPrompt}>{mfaChallenge.prompt}</div>
|
||||
{_mfaNeedsCodeInput ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.mfaCodeInput}
|
||||
placeholder={t('Code eingeben')}
|
||||
value={mfaCode}
|
||||
onChange={(e) => setMfaCode(e.target.value)}
|
||||
autoFocus
|
||||
onKeyDown={(e) => e.key === 'Enter' && _handleSubmitMfaCode()}
|
||||
/>
|
||||
<button type="button" className={styles.startButton} onClick={_handleSubmitMfaCode} disabled={!mfaCode.trim()}>
|
||||
{t('Bestätigen')}
|
||||
</button>
|
||||
</>
|
||||
) : mfaWaitingPush ? (
|
||||
<>
|
||||
<div className={styles.mfaSpinner} />
|
||||
<p style={{ fontSize: '0.85rem', color: '#888' }}>
|
||||
{t('Warte auf Bestätigung in der Authenticator-App …')}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className={styles.startButton} onClick={_handleSubmitMfaCode}>
|
||||
{t('Ich habe bestätigt')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent Status Bubble (F-fix-2) */}
|
||||
{/* Session Switcher */}
|
||||
{allSessions.length > 1 && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<select
|
||||
value={sessionId}
|
||||
onChange={(e) => _switchSession(e.target.value)}
|
||||
className={styles.sessionSwitcherSelect}
|
||||
>
|
||||
{[...allSessions]
|
||||
.sort((a, b) => {
|
||||
const ta = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
||||
const tb = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
||||
return tb - ta;
|
||||
})
|
||||
.map((s) => {
|
||||
const dt = s.startedAt ? new Date(s.startedAt) : null;
|
||||
const ts = dt && !Number.isNaN(dt.getTime())
|
||||
? `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')} ${String(dt.getHours()).padStart(2, '0')}:${String(dt.getMinutes()).padStart(2, '0')}`
|
||||
: '';
|
||||
const statusTag = ['active', 'joining', 'pending'].includes(s.status)
|
||||
? ` (${t('aktiv')})`
|
||||
: s.status === 'ended'
|
||||
? ` (${t('beendet')})`
|
||||
: s.status === 'error'
|
||||
? ` (${t('Fehler')})`
|
||||
: '';
|
||||
return (
|
||||
<option key={s.id} value={s.id}>
|
||||
{ts ? `${ts} — ` : ''}{s.botName}{statusTag}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent Status Bubble + Progress Log */}
|
||||
{agentStatus && (
|
||||
<div className={styles.agentStatusBubble}>
|
||||
<span className={styles.agentStatusDot} />
|
||||
<span>{t('Agent denkt nach')}{agentStatus.toolName ? `: ${agentStatus.toolName}` : '...'}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Cards (F-fix-3) */}
|
||||
{sessionStats && (
|
||||
<div className={styles.statsCards}>
|
||||
|
|
@ -717,7 +868,7 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
{isLive && <span className={styles.liveBadge}>LIVE</span>}
|
||||
</div>
|
||||
<div className={styles.sessionControls}>
|
||||
{['active', 'joining', 'pending'].includes(session.status) && (
|
||||
{!['ended', 'error', 'leaving'].includes(session.status) && (
|
||||
<button className={styles.stopButton} onClick={_handleStop}>{t('Sitzung beenden')}</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -778,8 +929,12 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
{/* Main column */}
|
||||
<div className={styles.sessionMain}>
|
||||
|
||||
{/* Director Prompt Panel (private operator instructions) */}
|
||||
{['active', 'joining', 'pending'].includes(session.status) && (
|
||||
{/* Director Prompt Panel (private operator instructions).
|
||||
Blacklist statt Whitelist: zeigen ausser bei terminal-Status,
|
||||
damit das Panel nicht waehrend kurzer SSE-Race-Conditions
|
||||
(Status briefly leer/unbekannt direkt nach Mount) verschwindet
|
||||
und erst nach Reload wieder auftaucht. */}
|
||||
{!['ended', 'error', 'leaving'].includes(session.status) && (
|
||||
<div
|
||||
className={`${styles.directorPanel} ${directorDragOver ? styles.directorPanelDragOver : ''}`}
|
||||
onDragEnter={_onDirectorDragEnter}
|
||||
|
|
@ -957,7 +1112,8 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
<div className={styles.directorHistoryText}>{p.text}</div>
|
||||
{p.responseText && (
|
||||
<div className={styles.directorHistoryText} style={{ opacity: 0.85 }}>
|
||||
<em>{t('Antwort')}:</em> {p.responseText}
|
||||
<em>{t('Antwort')}:</em>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{p.responseText}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
{p.statusMessage && p.status === 'failed' && (
|
||||
|
|
@ -1009,7 +1165,9 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
<span className={styles.responseIntent}>{r.detectedIntent}</span>
|
||||
<span className={styles.responseTime}>{_formatTime(r.timestamp || '')}</span>
|
||||
</div>
|
||||
<div className={styles.responseText}>{r.responseText}</div>
|
||||
<div className={styles.responseText}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{r.responseText || ''}</ReactMarkdown>
|
||||
</div>
|
||||
{r.reasoning && (
|
||||
<div className={styles.responseReasoning}>
|
||||
<em>{t('Begründung: {text}', { text: r.reasoning })}</em>
|
||||
|
|
@ -1035,17 +1193,40 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
{session.summary && (
|
||||
<div className={styles.summaryCard}>
|
||||
<h4 className={styles.panelTitle}>{t('Meeting-Zusammenfassung')}</h4>
|
||||
<div className={styles.summaryText}>{session.summary}</div>
|
||||
<div className={styles.summaryText}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{session.summary || ''}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TTS Delivery Debug */}
|
||||
<div className={styles.summaryCard}>
|
||||
<h4 className={styles.panelTitle}>{t('TTS-Lieferstatus')}</h4>
|
||||
{/* Agent Progress Log (collapsed by default) */}
|
||||
<details className={styles.summaryCard}>
|
||||
<summary className={styles.panelTitle} style={{ cursor: 'pointer' }}>
|
||||
{t('Agent-Fortschritt')} ({agentProgressLog.length})
|
||||
</summary>
|
||||
{agentProgressLog.length === 0 ? (
|
||||
<div className={styles.emptyState}>{t('Noch keine Agent-Aktivitaet')}</div>
|
||||
) : (
|
||||
<div className={styles.agentProgressLog}>
|
||||
{agentProgressLog.map((entry) => (
|
||||
<div key={entry.id} className={styles.agentProgressEntry}>
|
||||
<span className={styles.agentProgressTime}>{entry.ts}</span>
|
||||
<span className={styles.agentProgressText}>{entry.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
|
||||
{/* TTS Delivery Debug (collapsed by default) */}
|
||||
<details className={styles.summaryCard}>
|
||||
<summary className={styles.panelTitle} style={{ cursor: 'pointer' }}>
|
||||
{t('TTS-Lieferstatus')} ({ttsStatusEvents.length})
|
||||
</summary>
|
||||
{ttsStatusEvents.length === 0 ? (
|
||||
<div className={styles.emptyState}>{t('Noch keine TTS-Events')}</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', padding: '0.5rem 0' }}>
|
||||
{ttsStatusEvents.slice(-10).reverse().map((ev, idx) => (
|
||||
<div key={`${ev.timestamp}-${idx}`} className={styles.responseMeta}>
|
||||
<span>{_formatTime(ev.timestamp)}</span>
|
||||
|
|
@ -1056,7 +1237,7 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* Debug Log (SSE/Transcript/Chat) */}
|
||||
<div style={{ position: 'fixed', bottom: 0, right: 0, zIndex: 9999 }}>
|
||||
|
|
@ -1073,62 +1254,77 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Debug Screenshots (SysAdmin only) */}
|
||||
{/* Debug Screenshots (SysAdmin only, collapsible) */}
|
||||
{_isSysAdmin && (
|
||||
<div className={styles.summaryCard}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||
<h4 className={styles.panelTitle} style={{ margin: 0 }}>{t('Debug-Screenshots')}</h4>
|
||||
<button
|
||||
className={styles.viewButton}
|
||||
onClick={async () => {
|
||||
setScreenshotsLoading(true);
|
||||
try {
|
||||
const result = await teamsbotApi.listScreenshots(instanceId, session.id);
|
||||
setScreenshots(result.screenshots || []);
|
||||
setScreenshotsLoaded(true);
|
||||
} catch (err: any) {
|
||||
setScreenshots([]);
|
||||
setScreenshotsLoaded(true);
|
||||
} finally {
|
||||
setScreenshotsLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={screenshotsLoading}
|
||||
>
|
||||
{screenshotsLoading ? t('Laden…') : screenshotsLoaded ? t('Aktualisieren') : t('Screenshots laden')}
|
||||
</button>
|
||||
</div>
|
||||
{screenshotsLoaded && screenshots.length === 0 && (
|
||||
<div className={styles.emptyState}>{t('Keine Screenshots für diese Sitzung')}</div>
|
||||
)}
|
||||
{screenshots.length > 0 && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '12px' }}>
|
||||
{screenshots.map((s) => {
|
||||
const imgUrl = teamsbotApi.getScreenshotUrl(instanceId, s.name);
|
||||
return (
|
||||
<a
|
||||
key={s.name}
|
||||
href={imgUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ display: 'block', textDecoration: 'none', color: 'inherit', border: '1px solid #333', borderRadius: '8px', overflow: 'hidden', background: '#1a1a2e' }}
|
||||
>
|
||||
<img
|
||||
src={imgUrl}
|
||||
alt={s.step}
|
||||
style={{ width: '100%', height: '140px', objectFit: 'cover', display: 'block' }}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div style={{ padding: '8px', fontSize: '12px' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: '2px' }}>{s.step}</div>
|
||||
<div style={{ color: '#888' }}>
|
||||
{new Date(s.timestamp).toLocaleTimeString('de-CH')} — {(s.sizeBytes / 1024).toFixed(0)} KB
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer' }}
|
||||
onClick={() => setScreenshotsExpanded((v) => !v)}
|
||||
>
|
||||
<h4 className={styles.panelTitle} style={{ margin: 0 }}>
|
||||
{screenshotsExpanded ? '\u25BC' : '\u25B6'} {t('Debug-Screenshots')}
|
||||
{screenshotsLoaded && screenshots.length > 0 && ` (${screenshots.length})`}
|
||||
</h4>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
{screenshotsExpanded && (
|
||||
<button
|
||||
className={styles.viewButton}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
setScreenshotsLoading(true);
|
||||
try {
|
||||
const result = await teamsbotApi.listScreenshots(instanceId, session.id);
|
||||
setScreenshots(result.screenshots || []);
|
||||
setScreenshotsLoaded(true);
|
||||
} catch (err: any) {
|
||||
setScreenshots([]);
|
||||
setScreenshotsLoaded(true);
|
||||
} finally {
|
||||
setScreenshotsLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={screenshotsLoading}
|
||||
>
|
||||
{screenshotsLoading ? t('Laden…') : screenshotsLoaded ? t('Aktualisieren') : t('Screenshots laden')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{screenshotsExpanded && (
|
||||
<>
|
||||
{screenshotsLoaded && screenshots.length === 0 && (
|
||||
<div className={styles.emptyState} style={{ marginTop: '12px' }}>{t('Keine Screenshots für diese Sitzung')}</div>
|
||||
)}
|
||||
{screenshots.length > 0 && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '12px', marginTop: '12px' }}>
|
||||
{screenshots.map((s) => {
|
||||
const imgUrl = teamsbotApi.getScreenshotUrl(instanceId, s.name);
|
||||
return (
|
||||
<a
|
||||
key={s.name}
|
||||
href={imgUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ display: 'block', textDecoration: 'none', color: 'inherit', border: '1px solid #333', borderRadius: '8px', overflow: 'hidden', background: '#1a1a2e' }}
|
||||
>
|
||||
<img
|
||||
src={imgUrl}
|
||||
alt={s.step}
|
||||
style={{ width: '100%', height: '140px', objectFit: 'cover', display: 'block' }}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div style={{ padding: '8px', fontSize: '12px' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: '2px' }}>{s.step}</div>
|
||||
<div style={{ color: '#888' }}>
|
||||
{new Date(s.timestamp).toLocaleTimeString('de-CH')} — {(s.sizeBytes / 1024).toFixed(0)} KB
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
import * as teamsbotApi from '../../../api/teamsbotApi';
|
||||
import type { TeamsbotConfig, ConfigUpdateRequest, VoiceOption, SystemBot } from '../../../api/teamsbotApi';
|
||||
import type { TeamsbotConfig, ConfigUpdateRequest, VoiceOption, SystemBot, MediaFileInfo } from '../../../api/teamsbotApi';
|
||||
import type { VoiceLanguage } from '../../../api/voiceCatalogApi';
|
||||
import { FaPlay, FaSpinner, FaTrash } from 'react-icons/fa';
|
||||
import styles from './Teamsbot.module.css';
|
||||
import { getUserDataCache } from '../../../utils/userCache';
|
||||
import { useFileContext } from '../../../contexts/FileContext';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
|
|
@ -45,16 +46,24 @@ export const TeamsbotSettingsView: React.FC = () => {
|
|||
const [voices, setVoices] = useState<VoiceOption[]>([]);
|
||||
const [loadingVoices, setLoadingVoices] = useState(false);
|
||||
|
||||
// Media files for avatar picker
|
||||
const [mediaFiles, setMediaFiles] = useState<MediaFileInfo[]>([]);
|
||||
const fileCtx = useFileContext();
|
||||
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [avatarUploading, setAvatarUploading] = useState(false);
|
||||
|
||||
|
||||
const _loadConfig = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
// Load per-user settings (merged with instance defaults)
|
||||
const [settingsResult, languagesResult] = await Promise.all([
|
||||
const [settingsResult, languagesResult, mediaResult] = await Promise.all([
|
||||
teamsbotApi.getUserSettings(instanceId),
|
||||
teamsbotApi.fetchLanguages(),
|
||||
teamsbotApi.listMediaFiles().catch(() => [] as MediaFileInfo[]),
|
||||
]);
|
||||
setMediaFiles(mediaResult);
|
||||
const effectiveConfig = settingsResult.effectiveConfig;
|
||||
setConfig(effectiveConfig);
|
||||
setFormData(effectiveConfig);
|
||||
|
|
@ -117,6 +126,35 @@ export const TeamsbotSettingsView: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const _refreshMediaFiles = useCallback(async () => {
|
||||
const result = await teamsbotApi.listMediaFiles().catch(() => [] as MediaFileInfo[]);
|
||||
setMediaFiles(result);
|
||||
}, []);
|
||||
|
||||
const _handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !fileCtx?.handleFileUpload) return;
|
||||
setAvatarUploading(true);
|
||||
try {
|
||||
const result = await fileCtx.handleFileUpload(file);
|
||||
if (result?.success) {
|
||||
const data: any = (result.fileData as any)?.file || result.fileData;
|
||||
const id = data?.id || (result.fileData as any)?.id;
|
||||
if (id) {
|
||||
_updateField('avatarFileId', id);
|
||||
const refreshed = await teamsbotApi.listMediaFiles().catch(() => [] as MediaFileInfo[]);
|
||||
setMediaFiles(refreshed);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('Upload fehlgeschlagen'));
|
||||
setTimeout(() => setError(null), 3000);
|
||||
} finally {
|
||||
setAvatarUploading(false);
|
||||
if (avatarInputRef.current) avatarInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const _handleTestVoice = async () => {
|
||||
if (!instanceId) return;
|
||||
setTestingVoice(true);
|
||||
|
|
@ -210,6 +248,48 @@ export const TeamsbotSettingsView: React.FC = () => {
|
|||
Default-Name fuer den Bot im Meeting. Falls keiner angegeben, wird der Name des System-Bots verwendet (z.B. "Nyla Larsson").
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.label}>{t('Avatar-Bild / Video')}</label>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<select
|
||||
className={styles.select}
|
||||
style={{ flex: 1 }}
|
||||
value={formData.avatarFileId || ''}
|
||||
onChange={(e) => _updateField('avatarFileId', e.target.value || undefined)}
|
||||
onFocus={_refreshMediaFiles}
|
||||
>
|
||||
<option value="">{t('Standard (statische Farbfläche)')}</option>
|
||||
{mediaFiles.map(f => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.fileName} ({f.mimeType})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={_handleAvatarUpload}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.testButton || styles.saveButton}
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
disabled={avatarUploading}
|
||||
style={{ minWidth: '44px', padding: '8px 12px', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{avatarUploading ? <FaSpinner className={styles.spinner} /> : null}
|
||||
{avatarUploading ? t('Laden...') : t('Hochladen')}
|
||||
</button>
|
||||
</div>
|
||||
<span className={styles.hint}>
|
||||
{mediaFiles.length === 0
|
||||
? t('Noch keine Bild-/Video-Dateien vorhanden. Lade ein Bild oder Video hoch.')
|
||||
: t('Bild oder Video, das als Bot-Video im Meeting angezeigt wird.')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Behavior */}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -246,7 +246,7 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
|||
label: 'Teams Bot',
|
||||
icon: 'headset_mic',
|
||||
views: [
|
||||
{ code: 'dashboard', label: 'Übersicht', path: 'dashboard' },
|
||||
{ code: 'dashboard', label: 'Dashboard', path: 'dashboard' },
|
||||
{ code: 'assistant', label: 'Assistent', path: 'assistant' },
|
||||
{ code: 'modules', label: 'Module', path: 'modules' },
|
||||
{ code: 'sessions', label: 'Live-Session', path: 'sessions' },
|
||||
|
|
|
|||
Loading…
Reference in a new issue