diff --git a/.github/workflows/poweron_nyla_int.yml b/.github/workflows/poweron_nyla_int.yml index 7998da8..f2e8030 100644 --- a/.github/workflows/poweron_nyla_int.yml +++ b/.github/workflows/poweron_nyla_int.yml @@ -17,10 +17,10 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20' cache: 'npm' diff --git a/.github/workflows/poweron_nyla_main.yml b/.github/workflows/poweron_nyla_main.yml index 1e0c31e..109069f 100644 --- a/.github/workflows/poweron_nyla_main.yml +++ b/.github/workflows/poweron_nyla_main.yml @@ -17,10 +17,10 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20' cache: 'npm' diff --git a/public/poweron-home.html b/public/poweron-home.html index 0a3c92a..2fb440a 100644 --- a/public/poweron-home.html +++ b/public/poweron-home.html @@ -185,7 +185,10 @@ diff --git a/public/poweron-privacy.html b/public/poweron-privacy.html index 1045b30..76ef25c 100644 --- a/public/poweron-privacy.html +++ b/public/poweron-privacy.html @@ -140,7 +140,7 @@
- Last Updated: August 2025 + Last Updated: May 2026
@@ -272,8 +272,13 @@

Contact Us

If you have any questions about this Privacy Policy or our data practices, please contact us:

-

Email: privacy@poweron-ai.com

-

Address: PowerOn AI Platform, Privacy Team

+

Email: p.motsch@poweron.swiss

+

Address:
+ PowerOn AG
+ Birmensdorferstrasse 94
+ CH-8003 Zürich
+ Switzerland +

@@ -283,7 +288,7 @@ diff --git a/public/poweron-terms.html b/public/poweron-terms.html index c9e057d..c049dbe 100644 --- a/public/poweron-terms.html +++ b/public/poweron-terms.html @@ -153,7 +153,7 @@
- Last Updated: August 2025 + Last Updated: May 2026
@@ -315,8 +315,13 @@

Contact Information

If you have any questions about these Terms of Service, please contact us:

-

Email: legal@poweron-ai.com

-

Address: PowerOn AI Platform, Legal Department

+

Email: p.motsch@poweron.swiss

+

Address:
+ PowerOn AG
+ Birmensdorferstrasse 94
+ CH-8003 Zürich
+ Switzerland +

@@ -326,7 +331,7 @@ diff --git a/src/App.tsx b/src/App.tsx index 499bb37..4c1c1ee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,11 +39,12 @@ import { GDPRPage } from './pages/GDPR'; import StorePage from './pages/Store'; import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage'; import { FeatureViewPage } from './pages/FeatureView'; -import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage, AdminDatabaseHealthPage } from './pages/admin'; +import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage, AdminDatabaseHealthPage, SttBenchmarkPage } from './pages/admin'; import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards'; import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing'; import { AutomationsDashboardPage } from './pages/AutomationsDashboardPage'; +import { RagInventoryPage } from './pages/RagInventoryPage'; import { ComplianceAuditPage } from './pages/ComplianceAuditPage'; function App() { // Load saved theme preference and set app name on app mount @@ -127,6 +128,11 @@ function App() { {/* ============================================== */} } /> + {/* ============================================== */} + {/* RAG INVENTORY */} + {/* ============================================== */} + } /> + {/* Legacy top-level routes – redirect to dashboard (migrated to feature-instance routes) */} } /> } /> @@ -167,7 +173,6 @@ function App() { {/* Workspace + Automation2 Editor */} } /> - } /> {/* Automation2 Workflows & Tasks */} } /> @@ -220,6 +225,7 @@ function App() { } /> } /> + } /> } /> } /> diff --git a/src/api/connectionApi.ts b/src/api/connectionApi.ts index 8c47c6d..72b5097 100644 --- a/src/api/connectionApi.ts +++ b/src/api/connectionApi.ts @@ -6,17 +6,11 @@ import { ApiRequestOptions } from '../hooks/useApi'; export interface KnowledgePreferences { schemaVersion?: number; - neutralizeBeforeEmbed?: boolean; mailContentDepth?: 'metadata' | 'snippet' | 'full'; mailIndexAttachments?: boolean; filesIndexBinaries?: boolean; - mimeAllowlist?: string[]; clickupScope?: 'titles' | 'title_description' | 'with_comments'; clickupIndexAttachments?: boolean; - surfaceToggles?: { - google?: { gmail?: boolean; drive?: boolean }; - msft?: { sharepoint?: boolean; outlook?: boolean }; - }; maxAgeDays?: number; } @@ -292,3 +286,119 @@ export async function submitInfomaniakToken( }); } +// ============================================================================ +// RAG KNOWLEDGE CONSENT & CONTROL +// ============================================================================ + +export async function patchKnowledgeConsent( + request: ApiRequestFunction, + connectionId: string, + enabled: boolean +): Promise<{ connectionId: string; knowledgeIngestionEnabled: boolean; purged?: any; cancelledJobs?: number; bootstrapEnqueued?: boolean }> { + return await request({ + url: `/api/connections/${connectionId}/knowledge-consent`, + method: 'patch', + data: { enabled } + }); +} + +export async function patchKnowledgePreferences( + request: ApiRequestFunction, + connectionId: string, + preferences: KnowledgePreferences +): Promise<{ connectionId: string; knowledgePreferences: KnowledgePreferences; updated: boolean }> { + return await request({ + url: `/api/connections/${connectionId}/knowledge-preferences`, + method: 'patch', + data: { preferences } + }); +} + +export async function postKnowledgeStop( + request: ApiRequestFunction, + connectionId: string +): Promise<{ connectionId: string; cancelled: number }> { + return await request({ + url: `/api/connections/${connectionId}/knowledge-stop`, + method: 'post' + }); +} + +export async function patchDataSourceRagIndex( + request: ApiRequestFunction, + dataSourceId: string, + ragIndexEnabled: boolean +): Promise<{ sourceId: string; ragIndexEnabled: boolean; updated: boolean }> { + return await request({ + url: `/api/datasources/${dataSourceId}/rag-index`, + method: 'patch', + data: { ragIndexEnabled } + }); +} + +// ============================================================================ +// RAG INVENTORY +// ============================================================================ + +export interface RagDataSourceDto { + id: string; + label: string; + path: string; + sourceType: string; + ragIndexEnabled: boolean; + neutralize: boolean; + lastIndexed: number | null; + chunkCount: number; +} + +export interface RagConnectionDto { + id: string; + authority: string; + externalEmail: string; + knowledgeIngestionEnabled: boolean; + preferences: KnowledgePreferences; + dataSources: RagDataSourceDto[]; + totalChunks: number; + runningJobs: { jobId: string; progress: number; progressMessage: string }[]; + lastError?: { jobId: string; errorMessage: string; finishedAt: number | null } | null; + lastSuccess?: { + jobId: string; + finishedAt: number | null; + indexed: number; + skippedDuplicate: number; + skippedPolicy: number; + failed: number; + durationMs: number; + } | null; +} + +export interface RagInventoryDto { + connections: RagConnectionDto[]; + totals: { chunks: number; bytes?: number }; +} + +export interface RagActiveJobDto { + jobId: string; + connectionId: string; + connectionLabel?: string; + jobType: string; + progress: number | null; + progressMessage: string; +} + +export async function getRagInventoryMe(request: ApiRequestFunction): Promise { + return await request({ url: '/api/rag/inventory/me', method: 'get' }); +} + +export async function getRagInventoryMandate(request: ApiRequestFunction): Promise { + return await request({ url: '/api/rag/inventory/mandate', method: 'get' }); +} + +export async function getRagInventoryPlatform(request: ApiRequestFunction): Promise { + return await request({ url: '/api/rag/inventory/platform', method: 'get' }); +} + +export async function getRagActiveJobs(request: ApiRequestFunction): Promise { + return await request({ url: '/api/rag/inventory/jobs', method: 'get' }); +} + diff --git a/src/api/teamsbotApi.ts b/src/api/teamsbotApi.ts index 3918f7c..627ecc9 100644 --- a/src/api/teamsbotApi.ts +++ b/src/api/teamsbotApi.ts @@ -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 export async function createModule(instanceId: string, body: { title: string; seriesType?: string; defaultBotId?: string; goals?: string; kpiTargets?: string; + defaultMeetingLink?: string; defaultBotName?: string; defaultAvatarFileId?: string; }): Promise { 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 { await api.delete(`/api/teamsbot/${instanceId}/modules/${moduleId}`); } + +export interface MediaFileInfo { + id: string; + fileName: string; + mimeType: string; +} + +export async function listMediaFiles(): Promise { + 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 })); +} diff --git a/src/components/AddConnectionWizard/AddConnectionWizard.module.css b/src/components/AddConnectionWizard/AddConnectionWizard.module.css index 5cabd64..65cf185 100644 --- a/src/components/AddConnectionWizard/AddConnectionWizard.module.css +++ b/src/components/AddConnectionWizard/AddConnectionWizard.module.css @@ -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); diff --git a/src/components/AddConnectionWizard/AddConnectionWizard.tsx b/src/components/AddConnectionWizard/AddConnectionWizard.tsx index 85c9336..34c5cb0 100644 --- a/src/components/AddConnectionWizard/AddConnectionWizard.tsx +++ b/src/components/AddConnectionWizard/AddConnectionWizard.tsx @@ -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 = { google: 'Google', msft: 'Microsoft 365', clickup: 'ClickUp', + infomaniak: 'Infomaniak', }; const CONNECTOR_ICONS: Record = { google: , msft: , clickup: , + infomaniak: , }; -// --------------------------------------------------------------------------- -// 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 = { 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; + onConnect: (type: ConnectorType, knowledgeEnabled: boolean) => Promise; + onInfomaniakConnect?: (token: string, knowledgeEnabled: boolean) => Promise; + onMsftAdminConsent?: () => void; isConnecting?: boolean; } @@ -173,84 +70,91 @@ export const AddConnectionWizard: React.FC = ({ open, onClose, onConnect, + onInfomaniakConnect, + onMsftAdminConsent, isConnecting = false, }) => { const [state, setState] = useState({ - 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 = (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 ( - + {/* Stepper */}
- {[0, 1, 2, 3].map(i => ( + {steps.map((s, i) => (
i ? styles.stepDotDone : '', - !visibleSteps.includes(i) ? styles.stepDotHidden : '', + stepIndex === i ? styles.stepDotActive : '', + stepIndex > i ? styles.stepDotDone : '', ].join(' ')} > - {state.step > i ? : i + 1} + {stepIndex > i ? : i + 1}
))}
- {/* ---- Step 0: Connector ---- */} - {state.step === 0 && ( + {/* ---- Step: Connector ---- */} + {state.currentStep === 'connector' && (

Anbieter wählen

Welchen Dienst möchtest du verbinden?

- {(['google', 'msft', 'clickup'] as ConnectorType[]).map(type => ( + {(['google', 'msft', 'clickup', 'infomaniak'] as ConnectorType[]).map(type => (
)} - {/* ---- Step 1: Consent ---- */} - {state.step === 1 && ( + {/* ---- Step: Consent ---- */} + {state.currentStep === 'consent' && (
-

Wissensdatenbank

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?

- Du kannst diese Einstellung später in den Verbindungsdetails ändern. + Du kannst dies später jederzeit in der UDB pro Datenquelle steuern. +

+
+ + +
+
+ +
+
+ )} + + {/* ---- Step: MSFT Admin Consent ---- */} + {state.currentStep === 'msftAdminConsent' && ( +
+
+ +
+

Organisations-Zustimmung (optional)

+

+ Falls du Mandant-Administrator bist, kannst du jetzt für deine ganze Organisation zustimmen. + So müssen andere Benutzer nicht einzeln bestätigen. +

+

+ Wenn du kein Admin bist oder dies später tun möchtest, überspringe diesen Schritt.

-
- +
)} - {/* ---- Step 2: Preferences ---- */} - {state.step === 2 && ( + {/* ---- Step: Infomaniak PAT ---- */} + {state.currentStep === 'infomaniakPat' && (
-

Einstellungen

-

- Steuere, welche Inhalte und in welcher Form sie indexiert werden. +

Infomaniak Personal Access Token

+

+ Erstelle einen Personal Access Token in deinem Infomaniak-Konto und füge ihn hier ein.

- -
- -

- Persönliche Daten (Namen, E-Mail-Adressen) werden vor dem Speichern ersetzt. -

-
- - {(state.connector === 'google' || state.connector === 'msft') && ( - <> -
- -
-
- -
- - )} - - {state.connector === 'clickup' && ( -
- -
- )} - -
- -

0 = kein Limit

-
- + setState(s => ({ ...s, infomaniakToken: e.target.value }))} + className={styles.patInput} + autoFocus + />
- - +
)} - {/* ---- Step 3: Summary ---- */} - {state.step === 3 && ( + {/* ---- Step: Connect ---- */} + {state.currentStep === 'connect' && (
-

Zusammenfassung

+

Verbindung herstellen

Anbieter - {CONNECTOR_ICONS[state.connector!]}  + {state.connector && CONNECTOR_ICONS[state.connector]}  {state.connector ? CONNECTOR_LABELS[state.connector] : '—'}
@@ -414,96 +270,13 @@ export const AddConnectionWizard: React.FC = ({ {state.knowledgeEnabled ? '✓ Aktiv' : '✗ Nicht aktiv'}
- {state.knowledgeEnabled && ( - <> -
- Anonymisierung - - {state.prefs.neutralizeBeforeEmbed ? 'Ja' : 'Nein'} - -
- {(state.connector === 'google' || state.connector === 'msft') && ( -
- E-Mail-Tiefe - - {{ metadata: 'Nur Metadaten', snippet: 'Vorschautext', full: 'Volltext' }[ - state.prefs.mailContentDepth ?? 'full' - ] ?? state.prefs.mailContentDepth} - -
- )} - {state.connector === 'clickup' && ( -
- Aufgaben-Inhalt - - {{ - titles: 'Nur Titel', - title_description: 'Titel + Beschreibung', - with_comments: 'Titel + Beschreibung + Kommentare', - }[state.prefs.clickupScope ?? 'title_description'] ?? state.prefs.clickupScope} - -
- )} -
- Zeitfenster - - {state.prefs.maxAgeDays ? `${state.prefs.maxAgeDays} Tage` : 'Unbegrenzt'} - -
- - )}
- - {/* Cost estimate — only shown when knowledge ingestion is enabled */} - {state.knowledgeEnabled && (() => { - const est = computeCostEstimate(state.connector, state.prefs); - if (!est) return null; - return ( -
- -
- Geschätzte Kosten (erster Sync) - - - - - - - {est.neutralizationLow && ( - - - - - )} - -
Embedding - {est.embeddingLow} – {est.embeddingHigh} -
Anonymisierung (Private LLM) - {est.neutralizationLow} – {est.neutralizationHigh} -
- {est.neutralizationLow && ( - - ⚠ Anonymisierung ist der Hauptkostentreiber (CHF 0.01 pro LLM-Aufruf, on-premise). - - )} - {est.note} -
-
- ); - })()} -
- + + + {expanded && ( +
+
+ {t('Aktive RAG-Jobs')} +
+ {jobs.map(job => ( +
+ {job.connectionLabel || job.jobType} + + {job.progressMessage || t('läuft...')} + +
+ ))} +
+ )} +
+ ); +}; diff --git a/src/components/UnifiedDataBar/SourcesTab.tsx b/src/components/UnifiedDataBar/SourcesTab.tsx index 33605ad..f2cc679 100644 --- a/src/components/UnifiedDataBar/SourcesTab.tsx +++ b/src/components/UnifiedDataBar/SourcesTab.tsx @@ -28,6 +28,8 @@ import type { UdbContext } from './UnifiedDataBar'; import api from '../../api'; import { getPageIcon } from '../../config/pageRegistry'; import styles from './SourcesTab.module.css'; +import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaLink, FaFolder, FaEnvelope, FaComments, FaCalendarAlt, FaUser, FaCloudUploadAlt } from 'react-icons/fa'; +import { SiJira } from 'react-icons/si'; import { useLanguage } from '../../providers/language/LanguageContext'; @@ -42,6 +44,7 @@ interface UdbDataSource { displayPath?: string; scope: string; neutralize: boolean; + ragIndexEnabled?: boolean; } interface UdbFeatureDataSource { @@ -60,7 +63,7 @@ interface UdbFeatureDataSource { interface TreeNode { key: string; label: string; - icon: string; + icon: React.ReactNode; type: 'connection' | 'service' | 'folder' | 'file'; expanded: boolean; loading: boolean; @@ -122,28 +125,28 @@ interface SourcesTabProps { /* ─── Icons ──────────────────────────────────────────────────────────── */ -const _AUTHORITY_ICONS: Record = { - msft: '\uD83D\uDFE6', - google: '\uD83D\uDFE9', - clickup: '\uD83D\uDCCB', - infomaniak: '\uD83D\uDFE5', - 'local:ftp': '\uD83D\uDD17', - 'local:jira': '\uD83D\uDD27', +const _AUTHORITY_ICONS: Record = { + msft: , + google: , + clickup: , + infomaniak: , + 'local:ftp': , + 'local:jira': , }; -const _SERVICE_ICONS: Record = { - sharepoint: '\uD83D\uDCC1', - onedrive: '\u2601\uFE0F', - outlook: '\uD83D\uDCE7', - teams: '\uD83D\uDCAC', - drive: '\uD83D\uDCC2', - gmail: '\uD83D\uDCE8', - files: '\uD83D\uDCC2', - clickup: '\uD83D\uDCCB', - kdrive: '\uD83D\uDCC2', - mail: '\uD83D\uDCE7', - calendar: '\uD83D\uDCC5', - contact: '\uD83D\uDC64', +const _SERVICE_ICONS: Record = { + sharepoint: , + onedrive: , + outlook: , + teams: , + drive: , + gmail: , + files: , + clickup: , + kdrive: , + mail: , + calendar: , + contact: , }; /* ─── Source colors & icons ──────────────────────────────────────────── */ @@ -333,7 +336,7 @@ async function _loadServices(instanceId: string, connectionId: string): Promise< return services.map((s: any) => ({ key: `svc-${connectionId}-${s.service}`, label: s.label || s.service, - icon: _SERVICE_ICONS[s.service] || '\uD83D\uDCC2', + icon: _SERVICE_ICONS[s.service] || , type: 'service' as const, expanded: false, loading: false, @@ -342,7 +345,7 @@ async function _loadServices(instanceId: string, connectionId: string): Promise< service: s.service, path: '/', displayPath: s.label || s.service, - })); + })).sort((a: TreeNode, b: TreeNode) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })); } async function _browseService( @@ -374,6 +377,10 @@ async function _browseService( path: entry.path, displayPath, }; + }).sort((a: TreeNode, b: TreeNode) => { + if (a.type === 'folder' && b.type !== 'folder') return -1; + if (a.type !== 'folder' && b.type === 'folder') return 1; + return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }); }); } @@ -495,6 +502,7 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe scope: d.scope || 'personal', neutralize: d.neutralize ?? false, })); + list.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })); setDataSources(list); }) .catch(() => { if (mountedRef.current) setDataSources([]); }); @@ -518,6 +526,7 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe neutralizeFields: d.neutralizeFields || undefined, recordFilter: d.recordFilter || undefined, })); + list.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })); setFeatureDataSources(list); }) .catch(() => { if (mountedRef.current) setFeatureDataSources([]); }); @@ -539,14 +548,15 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe .map((c: any) => ({ key: `conn-${c.id}`, label: c.externalEmail || c.externalUsername || c.authority, - icon: _AUTHORITY_ICONS[c.authority] || '\uD83D\uDD17', + icon: _AUTHORITY_ICONS[c.authority] || , type: 'connection' as const, expanded: false, loading: false, children: null, connectionId: c.id, authority: c.authority, - })); + })) + .sort((a: TreeNode, b: TreeNode) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })); setTree(nodes); }) .catch(() => { if (mountedRef.current) setTree([]); }) @@ -689,6 +699,17 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe } }, []); + /* ── RAG-Index toggle (personal data source, optimistic) ── */ + const _togglePersonalRagIndex = useCallback(async (ds: UdbDataSource) => { + const newValue = !ds.ragIndexEnabled; + setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, ragIndexEnabled: newValue } : d)); + try { + await api.patch(`/api/datasources/${ds.id}/rag-index`, { ragIndexEnabled: newValue }); + } catch { + setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, ragIndexEnabled: ds.ragIndexEnabled } : d)); + } + }, []); + /* ── Scope change (feature data source, optimistic) ── */ const _cycleFeatureScope = useCallback(async (fds: UdbFeatureDataSource) => { const newScope = _nextScope(fds.scope); @@ -748,7 +769,7 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe expanded: false, loading: false, tables: null, - })), + })).sort((a: FeatureConnectionNode, b: FeatureConnectionNode) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })), }))); }) .catch(() => { if (mountedRef.current) setFeatureTree([]); }) @@ -786,14 +807,14 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe objectKey: t.objectKey ?? '', tableName: t.tableName ?? '', label: t.label ?? '', - fields: t.fields ?? [], + fields: (t.fields ?? []).slice().sort((a: string, b: string) => a.localeCompare(b, undefined, { sensitivity: 'base' })), isParent: Boolean(t.isParent), parentTable: t.parentTable ?? null, parentKey: t.parentKey ?? null, displayFields: t.displayFields ?? [], isGroup: Boolean(t.isGroup), group: t.group ?? null, - })); + })).sort((a: FeatureTableNode, b: FeatureTableNode) => (a.label || a.tableName).localeCompare(b.label || b.tableName, undefined, { sensitivity: 'base' })); // Default-expand all categorical groups so users immediately see their content. const defaultExpansions: string[] = tables @@ -900,7 +921,7 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe displayLabel: r.displayLabel || r.id, fields: r.fields || {}, tableName: table.tableName, - })); + })).sort((a: ParentRecordNode, b: ParentRecordNode) => a.displayLabel.localeCompare(b.displayLabel, undefined, { sensitivity: 'base' })); if (mountedRef.current) { setFeatureRecordsByPath(prev => ({ ...prev, [pathKey]: records })); } @@ -1018,6 +1039,7 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe dataSources={dataSources} onCycleScope={_cyclePersonalScope} onToggleNeutralize={_togglePersonalNeutralize} + onToggleRagIndex={_togglePersonalRagIndex} onSendToChat={_sendNodeToChat} scopeCycleTitle={_scopeCycleTitle} selectedKeys={selectedKeys} @@ -1105,18 +1127,20 @@ interface _TreeNodeViewProps { dataSources: UdbDataSource[]; onCycleScope: (ds: UdbDataSource) => void; onToggleNeutralize: (ds: UdbDataSource) => void; + onToggleRagIndex: (ds: UdbDataSource) => void; onSendToChat?: (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => void; scopeCycleTitle: (scope: string) => string; selectedKeys: Set; onSelect: (node: TreeNode, e: React.MouseEvent) => void; inheritedScope?: string; inheritedNeutralize?: boolean; + inheritedRagIndex?: boolean; } const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ node, depth, onToggle, onEnsureDs, isAdded, addingPath, - dataSources, onCycleScope, onToggleNeutralize, onSendToChat, scopeCycleTitle, - selectedKeys, onSelect, inheritedScope, inheritedNeutralize, + dataSources, onCycleScope, onToggleNeutralize, onToggleRagIndex, onSendToChat, scopeCycleTitle, + selectedKeys, onSelect, inheritedScope, inheritedNeutralize, inheritedRagIndex, }) => { const { t } = useLanguage(); const [hovered, setHovered] = useState(false); @@ -1128,8 +1152,10 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ const effectiveScope = ds?.scope ?? inheritedScope; const effectiveNeutralize = ds?.neutralize ?? inheritedNeutralize ?? false; + const effectiveRagIndex = ds?.ragIndexEnabled ?? inheritedRagIndex ?? false; const childInheritedScope = ds?.scope ?? inheritedScope; const childInheritedNeutralize = ds?.neutralize ?? inheritedNeutralize; + const childInheritedRagIndex = ds?.ragIndexEnabled ?? inheritedRagIndex; const _dragPayload = { connectionId: node.connectionId, @@ -1202,7 +1228,7 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ {node.loading ? _Spinner() : chevron} - {node.icon} + {node.icon} = ({ {node.label} - {/* ── 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. */} +
diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index d8b84c1..7c43674 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -19,7 +19,7 @@ import { FaHome, FaCog, FaBriefcase, FaPlay, FaBuilding, FaUsers, FaUserTag, FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt, FaLightbulb, FaRegFileAlt, FaLink, FaComments, - FaListAlt, FaChartLine, FaChartBar, FaFileAlt, FaUserShield, FaDatabase, + FaListAlt, FaChartLine, FaChartBar, FaFileAlt, FaUserShield, FaDatabase, FaMicrophone, FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock, FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList, FaFileContract, FaRobot, FaGlobe, FaClipboardCheck, @@ -53,6 +53,7 @@ export const PAGE_ICONS: Record = { 'page.system.billingAdmin': , 'page.system.statistics': , 'page.system.automations': , + 'page.system.ragInventory': , // Billing pages (legacy compat) 'page.billing.dashboard': , @@ -87,6 +88,8 @@ export const PAGE_ICONS: Record = { 'page.admin.database-health': , 'page.admin.demoConfig': , 'page.admin.demo-config': , + 'page.admin.sttBenchmark': , + 'page.admin.stt-benchmark': , 'page.admin.mandate-wizard': , 'page.admin.mandateWizard': , 'page.admin.invitation-wizard': , @@ -109,6 +112,8 @@ export const PAGE_ICONS: Record = { // Feature pages - Teams Bot 'page.feature.teamsbot.dashboard': , + 'page.feature.teamsbot.assistant': , + 'page.feature.teamsbot.modules': , 'page.feature.teamsbot.sessions': , 'page.feature.teamsbot.settings': , diff --git a/src/hooks/useConnections.ts b/src/hooks/useConnections.ts index 299480c..f53db1f 100644 --- a/src/hooks/useConnections.ts +++ b/src/hooks/useConnections.ts @@ -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 = { 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 => { diff --git a/src/hooks/useFiles.ts b/src/hooks/useFiles.ts index a770388..704d778 100644 --- a/src/hooks/useFiles.ts +++ b/src/hooks/useFiles.ts @@ -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 = { 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; diff --git a/src/hooks/usePrompts.ts b/src/hooks/usePrompts.ts index 26cef0b..004ed29 100644 --- a/src/hooks/usePrompts.ts +++ b/src/hooks/usePrompts.ts @@ -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 = { 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; diff --git a/src/hooks/useSpeechAudioCapture.ts b/src/hooks/useSpeechAudioCapture.ts index 1b6d7ac..6645289 100644 --- a/src/hooks/useSpeechAudioCapture.ts +++ b/src/hooks/useSpeechAudioCapture.ts @@ -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; + start: (language?: string, sttOpenOptions?: SttStreamOpenOptions) => Promise; stop: () => void; } @@ -42,6 +49,7 @@ export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi const recorderRef = useRef(null); const streamRef = useRef(null); const languageRef = useRef('de-DE'); + const sttOpenOptsRef = useRef(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 () => { diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index 880c702..bc4e2c9 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -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 = () => {
+ + ); }; diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx index 826b195..d86de0b 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -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> = { workspace: { dashboard: WorkspacePage, editor: WorkspaceEditorPage, - 'rag-insights': WorkspaceRagInsightsPage, settings: WorkspaceSettingsPage, }, teamsbot: { @@ -229,7 +227,7 @@ export const FeatureViewPage: React.FC = ({ view }) => { // Workspace dashboard is rendered persistently by WorkspaceKeepAlive at MainLayout level; // other workspace views (e.g. settings, editor) use the standard FeatureViewPage rendering. - if (featureCode === 'workspace' && view !== 'settings' && view !== 'editor' && view !== 'rag-insights') { + if (featureCode === 'workspace' && view !== 'settings' && view !== 'editor') { return null; } diff --git a/src/pages/RagInventoryPage.module.css b/src/pages/RagInventoryPage.module.css new file mode 100644 index 0000000..a04d275 --- /dev/null +++ b/src/pages/RagInventoryPage.module.css @@ -0,0 +1,353 @@ +.page { + padding: 24px 32px; + max-width: 1100px; +} + +/* ── Page Header ── */ +.pageHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 24px; + gap: 16px; + flex-wrap: wrap; +} + +.headerLeft { + display: flex; + align-items: center; + gap: 14px; +} + +.headerIcon { + font-size: 24px; + color: var(--color-primary, #2563eb); + flex-shrink: 0; +} + +.pageTitle { + font-size: 1.5rem; + font-weight: 600; + margin: 0; +} + +.pageDesc { + font-size: 0.8125rem; + color: var(--color-text-muted, #6b7280); + margin: 2px 0 0; +} + +.headerRight { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.filterGroup { + display: flex; + align-items: center; + gap: 8px; +} + +.filterLabel { + font-size: 0.8125rem; + color: var(--color-text-muted, #6b7280); + white-space: nowrap; +} + +.scopeSelect { + padding: 7px 12px; + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 6px; + font-size: 0.875rem; + background: var(--color-surface, #fff); + color: var(--color-text, #111); + cursor: pointer; + min-width: 180px; +} + +.scopeSelect:focus { + outline: none; + border-color: var(--color-primary, #2563eb); + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.12); +} + +.checkboxLabel { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.8125rem; + cursor: pointer; + white-space: nowrap; +} + +.checkboxLabel input { + cursor: pointer; +} + +/* ── Loading / Error ── */ +.loading { + padding: 32px; + text-align: center; + color: var(--color-text-muted, #6b7280); +} + +.error { + padding: 12px 16px; + border-radius: 6px; + background: #fef2f2; + color: #b91c1c; + font-size: 0.875rem; + margin-bottom: 16px; +} + +/* ── Totals ── */ +.totals { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 20px; + font-size: 0.875rem; +} + +.totalLabel { + color: var(--color-text-muted, #6b7280); +} + +.totalValue { + font-size: 1.125rem; +} + +.totalBytes { + color: var(--color-text-muted, #6b7280); + font-size: 0.8125rem; +} + +/* ── Content ── */ +.content { + display: flex; + flex-direction: column; + gap: 16px; +} + +/* ── Connection Card ── */ +.connectionCard { + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 8px; + padding: 16px; + background: var(--color-surface, #fff); +} + +.connectionHeader { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; +} + +.authority { + font-weight: 600; + font-size: 0.875rem; + text-transform: capitalize; +} + +.email { + color: var(--color-text-muted, #6b7280); + font-size: 0.8125rem; + flex: 1; +} + +.connChunks { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-primary, #2563eb); + background: var(--color-info-bg, #eff6ff); + padding: 2px 8px; + border-radius: 10px; +} + +.consentToggle { + background: none; + border: none; + cursor: pointer; + color: var(--color-primary, #2563eb); + display: flex; + align-items: center; +} + +/* ── Consent Warning ── */ +.consentWarning { + padding: 8px 12px; + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: 6px; + margin-bottom: 8px; + font-size: 0.8125rem; + color: #92400e; +} + +/* ── Error Banner ── */ +.errorBanner { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 6px; + margin-bottom: 8px; + font-size: 0.8125rem; + color: #b91c1c; +} + +.successBanner { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 6px; + margin-bottom: 8px; + font-size: 0.8125rem; + color: #166534; +} + +.successBanner .duration { + color: #65a30d; + margin-left: 6px; + opacity: 0.85; +} + +.reindexBtn { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + background: var(--color-info-bg, #eff6ff); + color: var(--color-primary, #2563eb); + border: 1px solid var(--color-primary-light, #93c5fd); + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + margin-left: auto; + white-space: nowrap; +} + +.reindexBtn:hover { + background: #dbeafe; +} + +.reindexHint { + display: flex; + padding: 6px 0; + margin-bottom: 4px; +} + +/* ── Job Banner ── */ +.jobBanner { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--color-info-bg, #eff6ff); + border-radius: 6px; + margin-bottom: 8px; + font-size: 0.8125rem; +} + +.spinIcon { + animation: spin 1.5s linear infinite; + color: var(--color-primary, #2563eb); +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.stopBtn { + margin-left: auto; + display: flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + background: #fef2f2; + color: #b91c1c; + border: 1px solid #fecaca; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; +} + +.stopBtn:hover { + background: #fee2e2; +} + +/* ── DataSource List ── */ +.dsList { + display: flex; + flex-direction: column; + gap: 0; +} + +.dsRow { + display: flex; + align-items: center; + gap: 12px; + padding: 6px 12px; + border-bottom: 1px solid var(--color-border, #f3f4f6); + font-size: 0.8125rem; +} + +.dsRow:last-child { + border-bottom: none; +} + +.dsActive { + background: rgba(37, 99, 235, 0.03); +} + +.dsLabel { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dsType { + color: var(--color-text-muted, #6b7280); + font-size: 0.75rem; + min-width: 90px; +} + +.dsChunks { + font-size: 0.75rem; + min-width: 70px; + text-align: right; +} + +.dsIndex { + width: 24px; + text-align: center; +} + +.dsEmpty { + padding: 12px; + text-align: center; + color: var(--color-text-muted, #9ca3af); + font-size: 0.8125rem; + font-style: italic; +} + +/* ── Empty State ── */ +.emptyState { + padding: 48px; + text-align: center; + color: var(--color-text-muted, #9ca3af); + font-size: 0.9rem; +} diff --git a/src/pages/RagInventoryPage.tsx b/src/pages/RagInventoryPage.tsx new file mode 100644 index 0000000..92dad83 --- /dev/null +++ b/src/pages/RagInventoryPage.tsx @@ -0,0 +1,316 @@ +/** + * RagInventoryPage — Global RAG knowledge store management. + * + * Accessible via Start > Nutzung > RAG-Inventar. + * Context selector top-right (same pattern as BillingDataView / Statistiken): + * Dropdown: "Meine Verbindungen" | "Mandant: XY" | "Plattform (alle)" + * Checkbox: "nur meine Daten" + */ + +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { useLanguage } from '../providers/language/LanguageContext'; +import { useApiRequest } from '../hooks/useApi'; +import { useUserMandates } from '../hooks/useUserMandates'; +import type { RagInventoryDto, RagConnectionDto } from '../api/connectionApi'; +import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle, FaCheckCircle } from 'react-icons/fa'; +import { mandateDisplayLabel } from '../utils/mandateDisplayUtils'; +import styles from './RagInventoryPage.module.css'; + +export const RagInventoryPage: React.FC = () => { + const { t } = useLanguage(); + const { request } = useApiRequest(); + const { fetchMandates } = useUserMandates(); + + const [mandates, setMandates] = useState([]); + const [mandatesLoading, setMandatesLoading] = useState(true); + const [selectedScope, setSelectedScope] = useState('personal'); + const [onlyMyData, setOnlyMyData] = useState(false); + + const [inventory, setInventory] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const pollRef = useRef | 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 = {}; + if (selectedScope !== 'personal' && selectedScope !== 'platform') { + params.mandateId = selectedScope; + } + if (onlyMyData) params.onlyMine = 'true'; + const data = await request({ url: _apiEndpoint, method: 'get', params }); + setInventory(data); + } catch (err: any) { + if (err?.message?.includes('403')) { + setError(t('Keine Berechtigung für diese Sicht.')); + } else { + setError(err?.message || t('Fehler beim Laden')); + } + setInventory(null); + } finally { + setLoading(false); + } + }, [request, _apiEndpoint, selectedScope, onlyMyData, t]); + + useEffect(() => { + _fetchInventory(); + }, [_fetchInventory]); + + const _hasActiveJobs = !!inventory?.connections?.some(c => (c.runningJobs?.length || 0) > 0); + + useEffect(() => { + if (pollRef.current) clearInterval(pollRef.current); + // Fast poll (5s) while a sync is in flight so the user gets a snappy + // success/error confirmation; slow poll (60s) at rest to keep the DB + // load low. Visibility check skips polling for backgrounded tabs. + const intervalMs = _hasActiveJobs ? 5000 : 60000; + pollRef.current = setInterval(() => { + if (document.visibilityState === 'visible') _fetchInventory(); + }, intervalMs); + return () => { if (pollRef.current) clearInterval(pollRef.current); }; + }, [_fetchInventory, _hasActiveJobs]); + + const _handleStop = async (connectionId: string) => { + try { + await request({ url: `/api/connections/${connectionId}/knowledge-stop`, method: 'post' }); + _fetchInventory(); + } catch {} + }; + + const _handleReindex = async (connectionId: string) => { + try { + await request({ url: `/api/rag/inventory/reindex/${connectionId}`, method: 'post' }); + _fetchInventory(); + } catch {} + }; + + const _handleConsentToggle = async (connectionId: string, currentEnabled: boolean) => { + if (!currentEnabled || window.confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?'))) { + try { + await request({ + url: `/api/connections/${connectionId}/knowledge-consent`, + method: 'patch', + data: { enabled: !currentEnabled }, + }); + _fetchInventory(); + } catch {} + } + }; + + const _formatRelative = useCallback((finishedAt: number | null | undefined): string => { + if (!finishedAt) return ''; + const nowSec = Date.now() / 1000; + const diff = Math.max(0, nowSec - finishedAt); + if (diff < 45) return t('gerade eben'); + if (diff < 3600) return t('vor {n} Min', { n: Math.floor(diff / 60) }); + if (diff < 86400) return t('vor {n} Std', { n: Math.floor(diff / 3600) }); + return t('vor {n} Tag(en)', { n: Math.floor(diff / 86400) }); + }, [t]); + + const _formatDuration = useCallback((ms: number | undefined): string => { + if (!ms || ms <= 0) return ''; + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`; + }, []); + + const scopeOptions = useMemo(() => { + const opts: { value: string; label: string }[] = [ + { value: 'personal', label: t('Meine Verbindungen') }, + ]; + for (const m of mandates) { + opts.push({ value: m.id, label: t('Mandant: {name}', { name: mandateDisplayLabel(m) }) }); + } + opts.push({ value: 'platform', label: t('Plattform (alle)') }); + return opts; + }, [mandates, t]); + + return ( +
+
+
+ +
+

{t('RAG-Inventar')}

+

+ {t('Übersicht und Steuerung der indexierten Wissensdaten.')} +

+
+
+
+
+ + +
+ +
+
+ + {loading && !inventory &&
{t('Laden...')}
} + {error &&
{error}
} + + {inventory && ( +
+
+ {t('Total Chunks')}: + {inventory.totals?.chunks ?? 0} + {inventory.totals?.bytes != null && inventory.totals.bytes > 0 && ( + {(inventory.totals.bytes / 1024 / 1024).toFixed(1)} MB + )} +
+ + {(inventory.connections || []).map((conn: RagConnectionDto) => ( +
+
+ {conn.authority} + {conn.externalEmail} + {conn.totalChunks > 0 && ( + {conn.totalChunks} chunks + )} + +
+ + {!conn.knowledgeIngestionEnabled && conn.dataSources.length > 0 && ( +
+ {t('Wissensdatenbank deaktiviert — Indexierung pausiert. Toggle aktivieren um Daten zu indexieren.')} +
+ )} + + {/* Status banner: priority is Running > Error-newer-than-Success > Success > Reindex-Hint. + This way a stale error doesn't override a fresh successful resync, and the + spinner is never shown without a real job behind it. */} + {conn.runningJobs.length > 0 ? ( +
+ + {conn.runningJobs[0].progressMessage || t('Synchronisierung läuft...')} + +
+ ) : (() => { + const errAt = conn.lastError?.finishedAt ?? 0; + const okAt = conn.lastSuccess?.finishedAt ?? 0; + const errorIsNewer = !!conn.lastError && errAt > okAt; + + if (errorIsNewer) { + return ( +
+ + + {t('Letzter Sync fehlgeschlagen')} ({_formatRelative(errAt)}): {conn.lastError?.errorMessage || t('unbekannter Fehler')} + + +
+ ); + } + + if (conn.lastSuccess) { + const s = conn.lastSuccess; + const stats = [ + s.indexed > 0 ? t('{n} neu indexiert', { n: s.indexed }) : null, + s.skippedDuplicate > 0 ? t('{n} unverändert', { n: s.skippedDuplicate }) : null, + s.skippedPolicy > 0 ? t('{n} übersprungen', { n: s.skippedPolicy }) : null, + s.failed > 0 ? t('{n} fehler', { n: s.failed }) : null, + ].filter(Boolean).join(' · '); + return ( +
+ + + {t('Sync erfolgreich')} {_formatRelative(okAt)} + {stats && <> — {stats}} + {s.durationMs > 0 && ({_formatDuration(s.durationMs)})} + + +
+ ); + } + + if (conn.dataSources.some(ds => ds.ragIndexEnabled) && conn.knowledgeIngestionEnabled) { + return ( +
+ +
+ ); + } + return null; + })()} + +
+ {conn.dataSources.map(ds => ( +
+ {ds.label || ds.path} + {ds.sourceType} + {ds.chunkCount} chunks + {ds.ragIndexEnabled ? '\uD83E\uDDE0' : '\u2014'} +
+ ))} + {conn.dataSources.length === 0 && ( +
{t('Keine Datenquellen konfiguriert')}
+ )} +
+
+ ))} + + {(inventory.connections || []).length === 0 && ( +
{t('Keine Daten für diese Sicht vorhanden.')}
+ )} +
+ )} +
+ ); +}; + +export default RagInventoryPage; diff --git a/src/pages/admin/SttBenchmarkPage.tsx b/src/pages/admin/SttBenchmarkPage.tsx new file mode 100644 index 0000000..92b297e --- /dev/null +++ b/src/pages/admin/SttBenchmarkPage.tsx @@ -0,0 +1,258 @@ +/** + * SttBenchmarkPage — Compare STT v1 (latest_long) vs v2 (Chirp 2). + * SysAdmin only. Upload audio, run both engines, compare results. + */ + +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { FaMicrophone, FaUpload, FaPlay, FaStop, FaSpinner } from 'react-icons/fa'; +import { useLanguage } from '../../providers/language/LanguageContext'; +import { useApiRequest } from '../../hooks/useApi'; +import styles from '../admin/Admin.module.css'; + +interface ModelOption { value: string; label: string } +interface BenchmarkResult { + api: string; + model: string; + latencyMs: number; + results: { transcript: string; confidence: number; words: number }[]; + resultCount: number; + location?: string; + error?: string; +} +interface BenchmarkResponse { + filename: string; + fileSizeBytes: number; + language: string; + v1: BenchmarkResult | { error: string }; + v2: BenchmarkResult | { error: string }; +} +interface ModelsResponse { + v1Models: ModelOption[]; + v2Models: ModelOption[]; + locations: ModelOption[]; + languages: ModelOption[]; +} + +export const SttBenchmarkPage: React.FC = () => { + const { t } = useLanguage(); + const { request } = useApiRequest(); + + const [models, setModels] = useState(null); + const [language, setLanguage] = useState('de-DE'); + const [v1Model, setV1Model] = useState('latest_long'); + const [v2Model, setV2Model] = useState('chirp_2'); + const [v2Location, setV2Location] = useState('europe-west4'); + const [running, setRunning] = useState(false); + const [result, setResult] = useState(null); + + const [recording, setRecording] = useState(false); + const [audioBlob, setAudioBlob] = useState(null); + const [audioUrl, setAudioUrl] = useState(null); + const mediaRecorderRef = useRef(null); + const chunksRef = useRef([]); + const fileInputRef = useRef(null); + + useEffect(() => { + request({ url: '/api/admin/stt-benchmark/models', method: 'get' }) + .then((data: any) => setModels(data)) + .catch(() => {}); + }, []); + + const _startRecording = useCallback(async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const recorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' }); + chunksRef.current = []; + recorder.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); }; + recorder.onstop = () => { + const blob = new Blob(chunksRef.current, { type: 'audio/webm' }); + setAudioBlob(blob); + setAudioUrl(URL.createObjectURL(blob)); + stream.getTracks().forEach(t => t.stop()); + }; + mediaRecorderRef.current = recorder; + recorder.start(); + setRecording(true); + } catch (err) { + console.error('Microphone access denied', err); + } + }, []); + + const _stopRecording = useCallback(() => { + mediaRecorderRef.current?.stop(); + setRecording(false); + }, []); + + const _handleFileSelect = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setAudioBlob(file); + setAudioUrl(URL.createObjectURL(file)); + }, []); + + const _runBenchmark = useCallback(async () => { + if (!audioBlob) return; + setRunning(true); + setResult(null); + try { + const formData = new FormData(); + const filename = audioBlob instanceof File ? audioBlob.name : 'recording.webm'; + formData.append('file', audioBlob, filename); + formData.append('language', language); + formData.append('v1Model', v1Model); + formData.append('v2Model', v2Model); + formData.append('v2Location', v2Location); + + const resp = await fetch('/api/admin/stt-benchmark/run', { + method: 'POST', + body: formData, + credentials: 'include', + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data: BenchmarkResponse = await resp.json(); + setResult(data); + } catch (err: any) { + console.error('Benchmark failed:', err); + } finally { + setRunning(false); + } + }, [audioBlob, language, v1Model, v2Model, v2Location]); + + const _renderResult = (label: string, r: BenchmarkResult | { error: string }) => { + if ('error' in r && r.error) { + return ( +
+

{label}

+

{r.error}

+
+ ); + } + const res = r as BenchmarkResult; + const topTranscript = res.results?.[0]?.transcript || '(no result)'; + const topConfidence = res.results?.[0]?.confidence ?? 0; + return ( +
+

{label}

+
+
{t('Modell')}: {res.model}
+
{t('Latenz')}: {res.latencyMs} ms
+
{t('Konfidenz')}: {(topConfidence * 100).toFixed(1)}%
+
{t('Alternativen')}: {res.results?.length || 0}
+ {res.location &&
{t('Region')}: {res.location}
} +
+
+ {topTranscript} +
+ {res.results?.length > 1 && ( +
+ {t('Weitere Alternativen')} + {res.results.slice(1).map((alt, i) => ( +
+ [{(alt.confidence * 100).toFixed(1)}%] {alt.transcript} +
+ ))} +
+ )} +
+ ); + }; + + return ( +
+
+

{t('STT Benchmark')}

+

+ {t('Vergleiche Speech-to-Text v1 (latest_long) mit v2 (Chirp 2). Lade eine Audio-Datei hoch oder nimm direkt auf.')} +

+
+ +
+ + + + +
+ +
+ {!recording ? ( + + ) : ( + + )} + + + + + {audioBlob && ( + <> + + {audioBlob instanceof File ? audioBlob.name : 'recording.webm'} ({(audioBlob.size / 1024).toFixed(0)} KB) + + {audioUrl &&
+ + + + {result && ( +
+

{t('Ergebnis')}

+

+ {result.filename} ({(result.fileSizeBytes / 1024).toFixed(0)} KB) — {result.language} +

+
+ {_renderResult(`v1 — ${(result.v1 as any).model || v1Model}`, result.v1)} + {_renderResult(`v2 — ${(result.v2 as any).model || v2Model}`, result.v2)} +
+
+ )} +
+ ); +}; + +export default SttBenchmarkPage; diff --git a/src/pages/admin/index.ts b/src/pages/admin/index.ts index 74bc916..2f5a6db 100644 --- a/src/pages/admin/index.ts +++ b/src/pages/admin/index.ts @@ -19,3 +19,4 @@ export { AdminLogsPage } from './AdminLogsPage'; export { AdminLanguagesPage } from './AdminLanguagesPage'; export { AdminDemoConfigPage } from './AdminDemoConfigPage'; export { AdminDatabaseHealthPage } from './AdminDatabaseHealthPage'; +export { SttBenchmarkPage } from './SttBenchmarkPage'; diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx index 64e7826..173946c 100644 --- a/src/pages/basedata/ConnectionsPage.tsx +++ b/src/pages/basedata/ConnectionsPage.tsx @@ -9,8 +9,7 @@ import React, { useState, useMemo, useEffect, useRef } from 'react'; import { useConnections, type Connection } from '../../hooks/useConnections'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; -import { FaSync, FaLink, FaRedo, FaShieldAlt, FaPlus, FaSpinner, FaTimes, FaSyncAlt, FaCloud } from 'react-icons/fa'; -import { getApiBaseUrl } from '../../../config/config'; +import { FaSync, FaLink, FaRedo, FaPlus, FaSpinner, FaTimes, FaSyncAlt } from 'react-icons/fa'; import styles from '../admin/Admin.module.css'; import bannerStyles from './ConnectionsPage.module.css'; import { AddConnectionWizard } from '../../components/AddConnectionWizard/AddConnectionWizard'; @@ -18,6 +17,7 @@ import type { ConnectorType } from '../../components/AddConnectionWizard/AddConn import type { KnowledgePreferences } from '../../api/connectionApi'; import { useLanguage } from '../../providers/language/LanguageContext'; import { resolveColumnTypes } from '../../utils/columnTypeResolver'; +import { getApiBaseUrl } from '../../../config/config'; const SYNC_BANNER_TTL_MS = 10 * 60 * 1000; // 10 minutes — conservative upper bound for bootstrap @@ -42,8 +42,6 @@ export const ConnectionsPage: React.FC = () => { deleteConnection, handleInlineUpdate, createConnectionAndAuth, - createInfomaniakConnection, - submitInfomaniakToken, connectWithPopup, refreshMicrosoftToken, refreshGoogleToken, @@ -54,7 +52,6 @@ export const ConnectionsPage: React.FC = () => { const [deletingConnections, setDeletingConnections] = useState>(new Set()); const [refreshingConnections, setRefreshingConnections] = useState>(new Set()); const [reconnectingConnections, setReconnectingConnections] = useState>(new Set()); - const [adminConsentPending, setAdminConsentPending] = useState(false); const [wizardOpen, setWizardOpen] = useState(false); // Banner shown while knowledge bootstrap is running in the background const [syncBanner, setSyncBanner] = useState<{ @@ -73,15 +70,6 @@ export const ConnectionsPage: React.FC = () => { setSyncBanner(null); }; - // Infomaniak PAT modal: holds the pending connectionId (created up-front so the - // user only commits if they actually paste a valid token; on cancel we delete it). - const [infomaniakModal, setInfomaniakModal] = useState<{ - connectionId: string; - token: string; - submitting: boolean; - error: string | null; - } | null>(null); - // Initial fetch useEffect(() => { refetch(); @@ -228,13 +216,13 @@ export const ConnectionsPage: React.FC = () => { const handleWizardConnect = async ( type: ConnectorType, knowledgeEnabled: boolean, - knowledgePreferences: KnowledgePreferences | null, + knowledgePreferences?: KnowledgePreferences | null, ) => { try { - await createConnectionAndAuth(type, knowledgeEnabled, knowledgePreferences); + await createConnectionAndAuth(type, knowledgeEnabled, knowledgePreferences ?? null); refetch(); if (knowledgeEnabled) { - const LABELS: Record = { google: 'Google', msft: 'Microsoft 365', clickup: 'ClickUp' }; + const LABELS: Record = { google: 'Google', msft: 'Microsoft 365', clickup: 'ClickUp', infomaniak: 'Infomaniak' }; showSyncBanner(LABELS[type] ?? type); } } catch (error) { @@ -242,74 +230,9 @@ export const ConnectionsPage: React.FC = () => { } }; - const handleCreateInfomaniak = async () => { - if (isConnecting || infomaniakModal) return; - try { - const newConnection = await createInfomaniakConnection(); - setInfomaniakModal({ - connectionId: newConnection.id, - token: '', - submitting: false, - error: null, - }); - refetch(); - } catch (error) { - console.error('Error creating Infomaniak connection:', error); - } - }; - - const handleInfomaniakCancel = async () => { - if (!infomaniakModal) return; - const { connectionId, submitting } = infomaniakModal; - if (submitting) return; - setInfomaniakModal(null); - try { - await deleteConnection(connectionId); - refetch(); - } catch (error) { - console.error('Error rolling back pending Infomaniak connection:', error); - } - }; - - const handleInfomaniakSubmit = async () => { - if (!infomaniakModal) return; - const trimmed = infomaniakModal.token.trim(); - if (!trimmed) { - setInfomaniakModal({ ...infomaniakModal, error: t('Bitte Personal Access Token einfügen') }); - return; - } - setInfomaniakModal({ ...infomaniakModal, submitting: true, error: null }); - try { - await submitInfomaniakToken(infomaniakModal.connectionId, trimmed); - setInfomaniakModal(null); - refetch(); - } catch (error: any) { - const detail = - error?.response?.data?.detail || - error?.message || - t('Token konnte nicht gespeichert werden'); - setInfomaniakModal((prev) => - prev ? { ...prev, submitting: false, error: String(detail) } : prev - ); - } - }; - - // Open Microsoft Admin Consent flow in a popup - const handleAdminConsent = () => { - setAdminConsentPending(true); - const consentUrl = `${getApiBaseUrl()}/api/msft/adminconsent`; - const popup = window.open(consentUrl, 'msft-admin-consent', 'width=600,height=700,scrollbars=yes,resizable=yes'); - if (!popup) { - setAdminConsentPending(false); - return; - } - const checkClosed = setInterval(() => { - if (popup.closed) { - clearInterval(checkClosed); - setAdminConsentPending(false); - refetch(); - } - }, 1000); + const handleMsftAdminConsent = () => { + const url = `${getApiBaseUrl()}/api/msft/adminconsent`; + window.open(url, 'msft-admin-consent', 'width=560,height=720,scrollbars=yes,resizable=yes'); }; // Form attributes for edit modal @@ -348,14 +271,6 @@ export const ConnectionsPage: React.FC = () => {

- {canCreate && ( - <> - - - + )}
@@ -419,7 +323,7 @@ export const ConnectionsPage: React.FC = () => { columns={columns} apiEndpoint="/api/connections/" tableContextKey="connections" - tableGroupLayoutMode="sections" + tableGroupLayoutMode="inline" loading={loading} pagination={true} pageSize={25} @@ -519,141 +423,11 @@ export const ConnectionsPage: React.FC = () => { )} - {/* Infomaniak Personal Access Token Modal */} - {infomaniakModal && ( -
-
-
-

{t('Infomaniak verbinden')}

- -
-
-

- {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.' - )} -

-
    -
  1. - {t('Öffne den Infomaniak-Manager:')}{' '} - - manager.infomaniak.com – API-Tokens - -
  2. -
  3. - {t('Klicke auf')} {t('Token erstellen')}{' '} - {t('und vergib einen aussagekräftigen Namen, z. B.')}{' '} - PowerOn.{' '} - {t('Application bleibt auf')} Default application. -
  4. -
  5. - {t('Suche im Scope-Feld nach')}{' '} - {t('allen vier')}{' '} - {t('Berechtigungen und kreuze sie an:')} -
      -
    • - drive — {t('kDrive (Pflicht, heute aktiv)')} -
    • -
    • - workspace:calendar —{' '} - {t('Kalender (Pflicht, heute aktiv)')} -
    • -
    • - workspace:contact —{' '} - {t('Kontakte (heute aktiv)')} -
    • -
    • - workspace:mail —{' '} - {t('Mail (in Vorbereitung, Scope schon mitnehmen)')} -
    • -
    - - {t( - 'Nicht "All" auswählen und nicht user_info / accounts — wird nicht benötigt.' - )} - -
  6. -
  7. - {t( - 'Kopiere das Token sofort (Infomaniak zeigt es nur einmal) und füge es hier ein:' - )} -
  8. -
- - 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 && ( -
- {infomaniakModal.error} -
- )} -

- {t( - 'Das Token wird verschlüsselt gespeichert und nur für API-Aufrufe an Infomaniak verwendet.' - )} -

-
- - -
-
-
-
- )} - setWizardOpen(false)} onConnect={handleWizardConnect} + onMsftAdminConsent={handleMsftAdminConsent} isConnecting={isConnecting} /> diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index 5e69bd1..e5dbecd 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -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} diff --git a/src/pages/basedata/PromptsPage.tsx b/src/pages/basedata/PromptsPage.tsx index 3cbc1fa..31732e6 100644 --- a/src/pages/basedata/PromptsPage.tsx +++ b/src/pages/basedata/PromptsPage.tsx @@ -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} diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx index ade7771..22d4300 100644 --- a/src/pages/billing/BillingDataView.tsx +++ b/src/pages/billing/BillingDataView.tsx @@ -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 = { 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} diff --git a/src/pages/views/commcoach/Commcoach.module.css b/src/pages/views/commcoach/Commcoach.module.css index 4101cbf..396969a 100644 --- a/src/pages/views/commcoach/Commcoach.module.css +++ b/src/pages/views/commcoach/Commcoach.module.css @@ -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 { diff --git a/src/pages/views/commcoach/CommcoachAssistantView.tsx b/src/pages/views/commcoach/CommcoachAssistantView.tsx index 4747597..ba61cf1 100644 --- a/src/pages/views/commcoach/CommcoachAssistantView.tsx +++ b/src/pages/views/commcoach/CommcoachAssistantView.tsx @@ -75,10 +75,24 @@ export const CommcoachAssistantView: React.FC = () => {

{t('Neues Modul erstellen')}

-
- {STEPS.map((s, i) => ( -
- ))} +
+
+ {STEPS.map((s, i) => ( +
+ ))} +
+
+ {stepIdx > 0 && ( + + )} + {step !== 'confirm' ? ( + + ) : ( + + )} +
@@ -156,19 +170,6 @@ export const CommcoachAssistantView: React.FC = () => { )}
-
- {stepIdx > 0 && ( - - )} -
- {step !== 'confirm' ? ( - - ) : ( - - )} -
); }; diff --git a/src/pages/views/commcoach/useVoiceController.ts b/src/pages/views/commcoach/useVoiceController.ts index 148bbed..7a2ad29 100644 --- a/src/pages/views/commcoach/useVoiceController.ts +++ b/src/pages/views/commcoach/useVoiceController.ts @@ -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('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 () => { diff --git a/src/pages/views/teamsbot/Teamsbot.module.css b/src/pages/views/teamsbot/Teamsbot.module.css index 7d4ae26..19414b2 100644 --- a/src/pages/views/teamsbot/Teamsbot.module.css +++ b/src/pages/views/teamsbot/Teamsbot.module.css @@ -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; +} diff --git a/src/pages/views/teamsbot/TeamsbotAssistantView.tsx b/src/pages/views/teamsbot/TeamsbotAssistantView.tsx index a6a473b..0718217 100644 --- a/src/pages/views/teamsbot/TeamsbotAssistantView.tsx +++ b/src/pages/views/teamsbot/TeamsbotAssistantView.tsx @@ -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(preselectedModuleId ? 'meeting' : 'module'); - const [modules, setModules] = useState([]); + const [modules, setModules] = useState([]); + const [moduleFilter, setModuleFilter] = useState(''); const [selectedModuleId, setSelectedModuleId] = useState(preselectedModuleId); const [newModuleTitle, setNewModuleTitle] = useState(''); const [createNewModule, setCreateNewModule] = useState(false); const [meetingLink, setMeetingLink] = useState(''); const [botName, setBotName] = useState('AI Assistant'); + const [joinMode, setJoinMode] = useState('anonymous'); + const [sessionContext, setSessionContext] = useState(''); + const [userAccount, setUserAccount] = useState(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(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(''); + 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 = () => {

{t('Neues Meeting starten')}

-
- {STEPS.map((s, i) => ( -
- ))} +
+
+ {STEPS.map((s, i) => ( +
+ ))} +
+
+ {stepIdx > 0 && ( + + )} + {step !== 'confirm' ? ( + + ) : ( + + )} +
@@ -106,16 +190,27 @@ export const TeamsbotAssistantView: React.FC = () => { {t('Bestehendes Modul')} {!createNewModule && ( - + <> + setModuleFilter(e.target.value)} + aria-label={t('Modul suchen')} + /> + + )}