Initial commit: breakpilot-lehrer - Lehrer KI Platform

Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website,
Klausur-Service, School-Service, Voice-Service, Geo-Service,
BreakPilot Drive, Agent-Core

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Boenisch
2026-02-11 23:47:26 +01:00
commit 5a31f52310
1224 changed files with 425430 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
/**
* Voice Service Library
* Client-side voice streaming and encryption
*/
export { VoiceAPI, type VoiceSession, type VoiceTask } from './voice-api'
export {
VoiceEncryption,
generateMasterKey,
generateKeyHash,
encryptContent,
decryptContent,
} from './voice-encryption'

View File

@@ -0,0 +1,400 @@
/**
* Voice API Client
* WebSocket-based voice streaming to voice-service
*/
import { VoiceEncryption } from './voice-encryption'
const VOICE_SERVICE_URL =
process.env.NEXT_PUBLIC_VOICE_SERVICE_URL || 'http://localhost:8091'
const WS_URL = VOICE_SERVICE_URL.replace('http', 'ws')
export interface VoiceSession {
id: string
namespace_id: string
status: string
created_at: string
websocket_url: string
}
export interface VoiceTask {
id: string
session_id: string
type: string
state: string
created_at: string
updated_at: string
result_available: boolean
error_message?: string
}
export interface TranscriptMessage {
type: 'transcript'
text: string
final: boolean
confidence: number
}
export interface IntentMessage {
type: 'intent'
intent: string
confidence: number
parameters: Record<string, unknown>
}
export interface ResponseMessage {
type: 'response'
text: string
}
export interface StatusMessage {
type: 'status'
status: string
}
export interface TaskCreatedMessage {
type: 'task_created'
task_id: string
task_type: string
state: string
}
export interface ErrorMessage {
type: 'error'
message: string
code: string
}
export type VoiceMessage =
| TranscriptMessage
| IntentMessage
| ResponseMessage
| StatusMessage
| TaskCreatedMessage
| ErrorMessage
export type VoiceEventHandler = (message: VoiceMessage) => void
export type AudioHandler = (audioData: ArrayBuffer) => void
export type ErrorHandler = (error: Error) => void
/**
* Voice API Client
* Handles session management and WebSocket streaming
*/
export class VoiceAPI {
private encryption: VoiceEncryption
private session: VoiceSession | null = null
private ws: WebSocket | null = null
private audioContext: AudioContext | null = null
private mediaStream: MediaStream | null = null
private processor: ScriptProcessorNode | null = null
private onMessage: VoiceEventHandler | null = null
private onAudio: AudioHandler | null = null
private onError: ErrorHandler | null = null
private onStatusChange: ((status: string) => void) | null = null
constructor() {
this.encryption = new VoiceEncryption()
}
/**
* Initialize the voice API
*/
async initialize(): Promise<void> {
await this.encryption.initialize()
}
/**
* Check if API is ready
*/
isReady(): boolean {
return this.encryption.isInitialized()
}
/**
* Create a new voice session
*/
async createSession(): Promise<VoiceSession> {
const namespaceId = this.encryption.getNamespaceId()
const keyHash = this.encryption.getKeyHash()
if (!namespaceId || !keyHash) {
throw new Error('Encryption not initialized')
}
const response = await fetch(`${VOICE_SERVICE_URL}/api/v1/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
namespace_id: namespaceId,
key_hash: keyHash,
device_type: 'pwa',
client_version: '1.0.0',
}),
})
if (!response.ok) {
throw new Error(`Failed to create session: ${response.statusText}`)
}
this.session = await response.json()
return this.session!
}
/**
* Connect to WebSocket for voice streaming
*/
async connect(): Promise<void> {
if (!this.session) {
await this.createSession()
}
return new Promise((resolve, reject) => {
const wsUrl = this.session!.websocket_url
this.ws = new WebSocket(wsUrl)
this.ws.onopen = () => {
console.log('WebSocket connected')
this.onStatusChange?.('connected')
resolve()
}
this.ws.onerror = (event) => {
console.error('WebSocket error:', event)
this.onError?.(new Error('WebSocket connection failed'))
reject(new Error('WebSocket connection failed'))
}
this.ws.onclose = () => {
console.log('WebSocket closed')
this.onStatusChange?.('disconnected')
}
this.ws.onmessage = (event) => {
if (event.data instanceof Blob) {
// Binary audio data
event.data.arrayBuffer().then((buffer) => {
this.onAudio?.(buffer)
})
} else {
// JSON message
try {
const message = JSON.parse(event.data) as VoiceMessage
this.onMessage?.(message)
if (message.type === 'status') {
this.onStatusChange?.(message.status)
}
} catch (e) {
console.warn('Failed to parse message:', event.data)
}
}
}
})
}
/**
* Start capturing audio from microphone
*/
async startCapture(): Promise<void> {
try {
// Request microphone access
this.mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 24000,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
},
})
// Create audio context
this.audioContext = new AudioContext({ sampleRate: 24000 })
const source = this.audioContext.createMediaStreamSource(this.mediaStream)
// Create processor for capturing audio
// Note: ScriptProcessorNode is deprecated but still widely supported
// In production, use AudioWorklet
this.processor = this.audioContext.createScriptProcessor(2048, 1, 1)
this.processor.onaudioprocess = (event) => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return
const inputData = event.inputBuffer.getChannelData(0)
// Convert Float32 to Int16
const int16Data = new Int16Array(inputData.length)
for (let i = 0; i < inputData.length; i++) {
const s = Math.max(-1, Math.min(1, inputData[i]))
int16Data[i] = s < 0 ? s * 0x8000 : s * 0x7fff
}
// Send audio chunk
this.ws.send(int16Data.buffer)
}
source.connect(this.processor)
this.processor.connect(this.audioContext.destination)
this.onStatusChange?.('listening')
} catch (error) {
console.error('Failed to start capture:', error)
this.onError?.(error as Error)
throw error
}
}
/**
* Stop capturing audio
*/
stopCapture(): void {
if (this.processor) {
this.processor.disconnect()
this.processor = null
}
if (this.mediaStream) {
this.mediaStream.getTracks().forEach((track) => track.stop())
this.mediaStream = null
}
if (this.audioContext) {
this.audioContext.close()
this.audioContext = null
}
// Signal end of turn
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'end_turn' }))
}
this.onStatusChange?.('processing')
}
/**
* Send interrupt signal
*/
interrupt(): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'interrupt' }))
}
}
/**
* Disconnect from voice service
*/
async disconnect(): Promise<void> {
this.stopCapture()
if (this.ws) {
this.ws.close()
this.ws = null
}
if (this.session) {
try {
await fetch(
`${VOICE_SERVICE_URL}/api/v1/sessions/${this.session.id}`,
{ method: 'DELETE' }
)
} catch (e) {
console.warn('Failed to close session:', e)
}
this.session = null
}
}
/**
* Get pending tasks for current session
*/
async getTasks(): Promise<VoiceTask[]> {
if (!this.session) return []
const response = await fetch(
`${VOICE_SERVICE_URL}/api/v1/sessions/${this.session.id}/tasks`
)
if (!response.ok) {
throw new Error('Failed to get tasks')
}
return response.json()
}
/**
* Approve a task
*/
async approveTask(taskId: string): Promise<void> {
const response = await fetch(
`${VOICE_SERVICE_URL}/api/v1/tasks/${taskId}/transition`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
new_state: 'approved',
reason: 'user_approved',
}),
}
)
if (!response.ok) {
throw new Error('Failed to approve task')
}
}
/**
* Reject a task
*/
async rejectTask(taskId: string): Promise<void> {
const response = await fetch(
`${VOICE_SERVICE_URL}/api/v1/tasks/${taskId}/transition`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
new_state: 'rejected',
reason: 'user_rejected',
}),
}
)
if (!response.ok) {
throw new Error('Failed to reject task')
}
}
/**
* Set event handlers
*/
setOnMessage(handler: VoiceEventHandler): void {
this.onMessage = handler
}
setOnAudio(handler: AudioHandler): void {
this.onAudio = handler
}
setOnError(handler: ErrorHandler): void {
this.onError = handler
}
setOnStatusChange(handler: (status: string) => void): void {
this.onStatusChange = handler
}
/**
* Get current session
*/
getSession(): VoiceSession | null {
return this.session
}
/**
* Get connection status
*/
isConnected(): boolean {
return this.ws !== null && this.ws.readyState === WebSocket.OPEN
}
}

View File

@@ -0,0 +1,334 @@
/**
* Voice Encryption - Client-Side AES-256-GCM
* DSGVO-compliant: Encryption key NEVER leaves the device
*
* The master key is stored in IndexedDB (encrypted with device key)
* Server only receives:
* - Key hash (for verification)
* - Encrypted blobs
* - Namespace ID (pseudonym)
*
* NOTE: crypto.subtle is only available in secure contexts (HTTPS or localhost).
* In development over HTTP (e.g., http://macmini:3000), encryption is disabled.
*/
/**
* Check if we're in a secure context where crypto.subtle is available
*/
export function isSecureContext(): boolean {
if (typeof window === 'undefined') return false
if (typeof crypto === 'undefined') return false
return crypto.subtle !== undefined
}
/**
* Check if encryption is available
* Returns false in non-secure HTTP contexts
*/
export function isEncryptionAvailable(): boolean {
return isSecureContext()
}
const DB_NAME = 'breakpilot-voice'
const DB_VERSION = 1
const STORE_NAME = 'keys'
const MASTER_KEY_ID = 'master-key'
/**
* Open IndexedDB for key storage
*/
async function openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' })
}
}
})
}
/**
* Generate a new master key for encryption
* This is called once when the teacher first uses voice features
* Returns null if encryption is not available (non-secure context)
*/
export async function generateMasterKey(): Promise<CryptoKey | null> {
if (!isEncryptionAvailable()) {
console.warn('[VoiceEncryption] crypto.subtle nicht verfügbar - HTTP-Kontext erkannt. Verschlüsselung deaktiviert.')
return null
}
const key = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true, // extractable for storage
['encrypt', 'decrypt']
)
// Store in IndexedDB
await storeMasterKey(key)
return key
}
/**
* Store master key in IndexedDB
*/
async function storeMasterKey(key: CryptoKey): Promise<void> {
if (!isEncryptionAvailable()) return
const db = await openDatabase()
const exportedKey = await crypto.subtle.exportKey('raw', key)
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite')
const store = transaction.objectStore(STORE_NAME)
const request = store.put({
id: MASTER_KEY_ID,
key: Array.from(new Uint8Array(exportedKey)),
createdAt: new Date().toISOString(),
})
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve()
})
}
/**
* Get master key from IndexedDB
* Returns null if encryption is not available
*/
export async function getMasterKey(): Promise<CryptoKey | null> {
if (!isEncryptionAvailable()) {
return null
}
try {
const db = await openDatabase()
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readonly')
const store = transaction.objectStore(STORE_NAME)
const request = store.get(MASTER_KEY_ID)
request.onerror = () => reject(request.error)
request.onsuccess = async () => {
const record = request.result
if (!record) {
resolve(null)
return
}
// Import key
const keyData = new Uint8Array(record.key)
const key = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
)
resolve(key)
}
})
} catch {
return null
}
}
/**
* Generate key hash for server verification
* Format: "sha256:base64encodedHash"
* Returns "disabled" if encryption is not available
*/
export async function generateKeyHash(key: CryptoKey | null): Promise<string> {
if (!key || !isEncryptionAvailable()) {
return 'disabled'
}
const exportedKey = await crypto.subtle.exportKey('raw', key)
const hashBuffer = await crypto.subtle.digest('SHA-256', exportedKey)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashBase64 = btoa(String.fromCharCode(...hashArray))
return `sha256:${hashBase64}`
}
/**
* Encrypt content before sending to server
* @param content - Plaintext content
* @param key - Master key
* @returns Base64 encoded encrypted content
*/
export async function encryptContent(
content: string,
key: CryptoKey
): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(12))
const encoded = new TextEncoder().encode(content)
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
encoded
)
// Combine IV + ciphertext
const combined = new Uint8Array(iv.length + ciphertext.byteLength)
combined.set(iv)
combined.set(new Uint8Array(ciphertext), iv.length)
return btoa(String.fromCharCode(...combined))
}
/**
* Decrypt content received from server
* @param encrypted - Base64 encoded encrypted content
* @param key - Master key
* @returns Decrypted plaintext
*/
export async function decryptContent(
encrypted: string,
key: CryptoKey
): Promise<string> {
const data = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0))
const iv = data.slice(0, 12)
const ciphertext = data.slice(12)
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext
)
return new TextDecoder().decode(decrypted)
}
/**
* Generate a namespace ID for the teacher
* This is a pseudonym that doesn't contain PII
*/
export function generateNamespaceId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16))
const hex = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
return `ns-${hex}`
}
/**
* Get or create namespace ID
*/
export async function getNamespaceId(): Promise<string> {
const stored = localStorage.getItem('breakpilot-namespace-id')
if (stored) {
return stored
}
const newId = generateNamespaceId()
localStorage.setItem('breakpilot-namespace-id', newId)
return newId
}
/**
* VoiceEncryption class for managing encryption state
*/
export class VoiceEncryption {
private masterKey: CryptoKey | null = null
private keyHash: string | null = null
private namespaceId: string | null = null
private encryptionEnabled: boolean = false
/**
* Initialize encryption
* Creates master key if not exists
* In non-secure contexts (HTTP), encryption is disabled but the class still works
*/
async initialize(): Promise<void> {
// Check if encryption is available
this.encryptionEnabled = isEncryptionAvailable()
if (!this.encryptionEnabled) {
console.warn('[VoiceEncryption] Verschlüsselung deaktiviert - kein sicherer Kontext (HTTPS erforderlich)')
console.warn('[VoiceEncryption] Für Produktion bitte HTTPS verwenden!')
this.keyHash = 'disabled'
this.namespaceId = await getNamespaceId()
return
}
// Get or create master key
this.masterKey = await getMasterKey()
if (!this.masterKey) {
this.masterKey = await generateMasterKey()
}
// Generate key hash
this.keyHash = await generateKeyHash(this.masterKey)
// Get namespace ID
this.namespaceId = await getNamespaceId()
}
/**
* Check if encryption is initialized
*/
isInitialized(): boolean {
// Consider initialized even if encryption is disabled
return this.keyHash !== null
}
/**
* Check if encryption is actually enabled
*/
isEncryptionEnabled(): boolean {
return this.encryptionEnabled && this.masterKey !== null
}
/**
* Get key hash for server authentication
*/
getKeyHash(): string | null {
return this.keyHash
}
/**
* Get namespace ID
*/
getNamespaceId(): string | null {
return this.namespaceId
}
/**
* Encrypt content
* Returns plaintext if encryption is disabled
*/
async encrypt(content: string): Promise<string> {
if (!this.encryptionEnabled || !this.masterKey) {
// In development without HTTPS, return content as-is (base64 encoded for consistency)
return btoa(unescape(encodeURIComponent(content)))
}
return encryptContent(content, this.masterKey)
}
/**
* Decrypt content
* Returns content as-is if encryption is disabled
*/
async decrypt(encrypted: string): Promise<string> {
if (!this.encryptionEnabled || !this.masterKey) {
// In development without HTTPS, decode base64
try {
return decodeURIComponent(escape(atob(encrypted)))
} catch {
return encrypted
}
}
return decryptContent(encrypted, this.masterKey)
}
}