teamsbot anonymous bot working

This commit is contained in:
ValueOn AG 2026-05-12 19:16:28 +02:00
parent ccb2798170
commit 2ee08c314b
4 changed files with 216 additions and 7 deletions

View file

@ -71,6 +71,7 @@ export interface TeamsbotConfig {
triggerCooldownSeconds: number;
contextWindowSegments: number;
debugMode?: boolean;
avatarFileId?: string;
}
export interface TeamsbotSessionStats {
@ -103,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
@ -602,6 +604,7 @@ export interface MeetingModule {
kpiTargets?: string;
defaultMeetingLink?: string;
defaultBotName?: string;
defaultAvatarFileId?: string;
status: string;
}
@ -612,7 +615,7 @@ export async function listModules(instanceId: string): Promise<MeetingModule[]>
export async function createModule(instanceId: string, body: {
title: string; seriesType?: string; defaultBotId?: string; goals?: string; kpiTargets?: string;
defaultMeetingLink?: string; defaultBotName?: string;
defaultMeetingLink?: string; defaultBotName?: string; defaultAvatarFileId?: string;
}): Promise<MeetingModule> {
const response = await api.post(`/api/teamsbot/${instanceId}/modules`, body);
return response.data?.module;
@ -631,3 +634,31 @@ export async function updateModule(instanceId: string, moduleId: string, body: P
export async function deleteModule(instanceId: string, moduleId: string): Promise<void> {
await api.delete(`/api/teamsbot/${instanceId}/modules/${moduleId}`);
}
export interface MediaFileInfo {
id: string;
fileName: string;
mimeType: string;
}
export async function listMediaFiles(): Promise<MediaFileInfo[]> {
const response = await api.get('/api/files/list', {
params: { pagination: JSON.stringify({ pageSize: 500 }) },
});
const data = response.data;
let items: any[];
if (Array.isArray(data)) {
items = data;
} else if (Array.isArray(data?.items)) {
items = data.items;
} else {
console.warn('[listMediaFiles] unexpected response shape:', Object.keys(data || {}));
items = [];
}
const filtered = items.filter((f: any) => {
const mime = (f.mimeType || '').toLowerCase();
return mime.startsWith('image/') || mime.startsWith('video/');
});
console.log(`[listMediaFiles] ${items.length} total files, ${filtered.length} media files`);
return filtered.map((f: any) => ({ id: f.id, fileName: f.fileName, mimeType: f.mimeType }));
}

View file

@ -7,8 +7,10 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import * as teamsbotApi from '../../../api/teamsbotApi';
import type { MeetingModule, TeamsbotSession } from '../../../api/teamsbotApi';
import type { MeetingModule, TeamsbotSession, MediaFileInfo } from '../../../api/teamsbotApi';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { useFileContext } from '../../../contexts/FileContext';
import { FaSpinner } from 'react-icons/fa';
import styles from './Teamsbot.module.css';
const SERIES_TYPE_LABELS: Record<string, string> = {
@ -46,8 +48,47 @@ export const TeamsbotModulesView: React.FC = () => {
const [createDefaultLink, setCreateDefaultLink] = useState('');
const [createDefaultBotName, setCreateDefaultBotName] = useState('');
const [createGoals, setCreateGoals] = useState('');
const [createDefaultAvatarFileId, setCreateDefaultAvatarFileId] = useState('');
const [createSaving, setCreateSaving] = useState(false);
const [mediaFiles, setMediaFiles] = useState<MediaFileInfo[]>([]);
const fileCtx = useFileContext();
const avatarInputRef = useRef<HTMLInputElement | null>(null);
const [avatarUploading, setAvatarUploading] = useState(false);
const [avatarTarget, setAvatarTarget] = useState<'create' | 'edit'>('create');
const _refreshMediaFiles = useCallback(async () => {
const result = await teamsbotApi.listMediaFiles().catch(() => [] as MediaFileInfo[]);
setMediaFiles(result);
}, []);
const _handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !fileCtx?.handleFileUpload) return;
setAvatarUploading(true);
try {
const result = await fileCtx.handleFileUpload(file);
if (result?.success) {
const data: any = (result.fileData as any)?.file || result.fileData;
const id = data?.id || (result.fileData as any)?.id;
if (id) {
if (avatarTarget === 'create') {
setCreateDefaultAvatarFileId(id);
} else if (editingModule) {
setEditingModule({ ...editingModule, defaultAvatarFileId: id });
}
const refreshed = await teamsbotApi.listMediaFiles().catch(() => [] as MediaFileInfo[]);
setMediaFiles(refreshed);
}
}
} catch {
// upload error handled by FileContext
} finally {
setAvatarUploading(false);
if (avatarInputRef.current) avatarInputRef.current.value = '';
}
};
const _loadModules = useCallback(async () => {
if (!instanceId) return;
setLoading(true);
@ -63,6 +104,10 @@ export const TeamsbotModulesView: React.FC = () => {
useEffect(() => { _loadModules(); }, [_loadModules]);
useEffect(() => {
teamsbotApi.listMediaFiles().then(setMediaFiles).catch(() => {});
}, []);
const _loadModuleSessions = useCallback(async (moduleId: string) => {
if (!instanceId) return;
try {
@ -125,6 +170,7 @@ export const TeamsbotModulesView: React.FC = () => {
seriesType: createSeriesType,
defaultMeetingLink: createDefaultLink.trim() || undefined,
defaultBotName: createDefaultBotName.trim() || undefined,
defaultAvatarFileId: createDefaultAvatarFileId || undefined,
goals: createGoals.trim() || undefined,
});
setCreateOpen(false);
@ -132,6 +178,7 @@ export const TeamsbotModulesView: React.FC = () => {
setCreateSeriesType('adhoc');
setCreateDefaultLink('');
setCreateDefaultBotName('');
setCreateDefaultAvatarFileId('');
setCreateGoals('');
_loadModules();
} catch (err) {
@ -185,7 +232,7 @@ export const TeamsbotModulesView: React.FC = () => {
<div className={styles.moduleRow} onClick={() => _toggleExpand(mod.id)}>
<span className={styles.moduleType}>{t(SERIES_TYPE_LABELS[mod.seriesType] || mod.seriesType)}</span>
<span className={styles.moduleTitle}>{mod.title}</span>
<span className={styles.moduleStatus}>{t(STATUS_LABELS[mod.status] || mod.status)}</span>
<span className={styles.moduleStatus}>{t(STATUS_LABELS[mod.status] || mod.status || 'Aktiv')}</span>
<div className={styles.moduleActions}>
<button className={styles.btnPrimary} style={{ padding: '0.3rem 0.7rem', fontSize: '0.8rem' }} onClick={e => {
e.stopPropagation();
@ -257,6 +304,25 @@ export const TeamsbotModulesView: React.FC = () => {
value={createDefaultBotName}
onChange={e => setCreateDefaultBotName(e.target.value)}
/>
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Avatar-Bild / Video')}</label>
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
<select
className={styles.wizardSelect}
style={{ flex: 1 }}
value={createDefaultAvatarFileId}
onChange={e => setCreateDefaultAvatarFileId(e.target.value)}
onFocus={_refreshMediaFiles}
>
<option value="">{t('Standard (aus Bot-Einstellungen)')}</option>
{mediaFiles.map(f => (
<option key={f.id} value={f.id}>{f.fileName} ({f.mimeType})</option>
))}
</select>
<button type="button" className={styles.btnSmall} disabled={avatarUploading} onClick={() => { setAvatarTarget('create'); avatarInputRef.current?.click(); }} style={{ whiteSpace: 'nowrap' }}>
{avatarUploading && avatarTarget === 'create' ? <FaSpinner className={styles.spinner} /> : null}
{t('Hochladen')}
</button>
</div>
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Ziele')}</label>
<textarea
className={styles.wizardTextarea}
@ -317,6 +383,25 @@ export const TeamsbotModulesView: React.FC = () => {
placeholder={t('z.B. AI Assistant')}
onBlur={e => setEditingModule({ ...editingModule, defaultBotName: e.target.value })}
/>
<label className={styles.label} style={{ display: 'block', marginTop: 8 }}>{t('Avatar-Bild / Video')}</label>
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
<select
className={styles.wizardSelect}
style={{ flex: 1 }}
value={editingModule.defaultAvatarFileId || ''}
onChange={e => setEditingModule({ ...editingModule, defaultAvatarFileId: e.target.value || undefined })}
onFocus={_refreshMediaFiles}
>
<option value="">{t('Standard (aus Bot-Einstellungen)')}</option>
{mediaFiles.map(f => (
<option key={f.id} value={f.id}>{f.fileName} ({f.mimeType})</option>
))}
</select>
<button type="button" className={styles.btnSmall} disabled={avatarUploading} onClick={() => { setAvatarTarget('edit'); avatarInputRef.current?.click(); }} style={{ whiteSpace: 'nowrap' }}>
{avatarUploading && avatarTarget === 'edit' ? <FaSpinner className={styles.spinner} /> : null}
{t('Hochladen')}
</button>
</div>
<textarea
defaultValue={editingModule.goals || ''}
className={styles.wizardTextarea}
@ -331,11 +416,20 @@ export const TeamsbotModulesView: React.FC = () => {
goals: editingModule.goals,
defaultMeetingLink: (editingModule.defaultMeetingLink || '').trim(),
defaultBotName: (editingModule.defaultBotName || '').trim(),
defaultAvatarFileId: (editingModule.defaultAvatarFileId || '').trim() || undefined,
})}>{t('Speichern')}</button>
</div>
</div>
</div>
)}
<input
ref={avatarInputRef}
type="file"
accept="image/*,video/*"
style={{ display: 'none' }}
onChange={_handleAvatarUpload}
/>
</div>
);
};

View file

@ -899,8 +899,12 @@ export const TeamsbotSessionView: React.FC = () => {
{/* Main column */}
<div className={styles.sessionMain}>
{/* Director Prompt Panel (private operator instructions) */}
{['active', 'joining', 'pending'].includes(session.status) && (
{/* Director Prompt Panel (private operator instructions).
Blacklist statt Whitelist: zeigen ausser bei terminal-Status,
damit das Panel nicht waehrend kurzer SSE-Race-Conditions
(Status briefly leer/unbekannt direkt nach Mount) verschwindet
und erst nach Reload wieder auftaucht. */}
{!['ended', 'error', 'leaving'].includes(session.status) && (
<div
className={`${styles.directorPanel} ${directorDragOver ? styles.directorPanelDragOver : ''}`}
onDragEnter={_onDirectorDragEnter}

View file

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