fox/resources/views/livewire/chat.blade.php

220 lines
9.7 KiB
PHP

<div
x-data="foxChat()"
x-init="init()"
class="grid grid-rows-[1fr_auto] gap-3 h-full p-3"
>
<div class="glass overflow-hidden flex flex-col">
<div class="px-5 py-3 border-b border-white/10 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-emerald-400 shadow-[0_0_8px_rgb(52_211_153)]"></span>
<span class="text-sm text-white/70">Fox ist online</span>
</div>
<div class="text-xs text-white/40">
{{ $messages->count() }} Nachrichten
</div>
</div>
<div
x-ref="feed"
class="flex-1 overflow-y-auto px-5 py-6 space-y-4"
>
@forelse ($messages as $msg)
@if ($msg->role === 'user')
<div class="flex justify-end">
<div class="max-w-[78%] bg-white/8 border border-white/10 rounded-2xl rounded-tr-sm px-4 py-3 text-white">
<div class="whitespace-pre-wrap break-words">{{ $msg->content }}</div>
<div class="text-[10px] text-white/40 mt-1.5 text-right">
{{ $msg->created_at?->format('H:i') }}
</div>
</div>
</div>
@elseif ($msg->role === 'tool')
<div class="flex justify-center">
<div class="text-xs text-white/50 italic px-3 py-1.5 rounded-full bg-white/5 border border-white/10">
{{ $msg->content }}
@if (data_get($msg->metadata, 'url'))
<a href="{{ data_get($msg->metadata, 'url') }}" target="_blank" class="text-primary underline ml-1">öffnen</a>
@endif
</div>
</div>
@else
<div class="flex justify-start">
<div class="flex gap-3 max-w-[78%]">
<div class="w-8 h-8 rounded-lg bg-primary/20 border border-primary/40 flex-shrink-0 flex items-center justify-center text-primary text-sm font-bold">F</div>
<div>
@if ($msg->type === 'proactive')
<div class="badge-proactive mb-1.5">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-6m0 0V6m0 6h6m-6 0H6"/></svg>
Fox meldet sich
</div>
@endif
<div class="bg-primary/10 border border-primary/30 rounded-2xl rounded-tl-sm px-4 py-3">
<div class="whitespace-pre-wrap break-words text-white">{{ $msg->content }}</div>
<div class="text-[10px] text-white/40 mt-1.5">
{{ $msg->created_at?->format('H:i') }}
</div>
</div>
</div>
</div>
</div>
@endif
@empty
<div class="text-center text-white/40 py-16">
<div class="text-6xl mb-3">🦊</div>
<div class="text-sm">Sag etwas, oder warte Fox meldet sich von selbst.</div>
</div>
@endforelse
@if ($foxIsThinking)
<div class="flex justify-start">
<div class="flex gap-3">
<div class="w-8 h-8 rounded-lg bg-primary/20 border border-primary/40 flex-shrink-0 flex items-center justify-center text-primary text-sm font-bold">F</div>
<div class="bg-primary/10 border border-primary/30 rounded-2xl rounded-tl-sm px-4 py-3 flex items-center gap-1">
<span class="typing-dot"></span>
<span class="typing-dot"></span>
<span class="typing-dot"></span>
</div>
</div>
</div>
@endif
</div>
</div>
<form wire:submit="send" class="glass p-3 flex items-end gap-2">
<button
type="button"
x-on:mousedown="startRecording()"
x-on:mouseup="stopRecording()"
x-on:touchstart.prevent="startRecording()"
x-on:touchend.prevent="stopRecording()"
class="w-12 h-12 rounded-xl bg-white/5 border border-white/10 flex items-center justify-center hover:bg-primary/20 hover:border-primary/40 transition flex-shrink-0"
x-bind:class="recording && 'ptt-ring bg-primary/30 border-primary'"
title="Push-to-Talk: gedrückt halten oder Strg+Leertaste"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z"/>
</svg>
</button>
<textarea
wire:model="draft"
x-ref="input"
x-on:keydown.enter.prevent="if (!$event.shiftKey) $wire.send()"
rows="1"
placeholder="Schreib was, oder halte Strg+Leertaste..."
class="input resize-none max-h-32"
></textarea>
<button type="submit" class="btn-primary h-12 w-12 p-0">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12L21 4.5l-7.5 16.5-2.25-7.5L3.75 12z"/>
</svg>
</button>
</form>
</div>
@assets
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('foxChat', () => ({
recording: false,
mediaRecorder: null,
chunks: [],
audioQueue: [],
audioPlaying: false,
init() {
this.scrollToBottom();
this.$wire.on('message-sent', () => this.scrollToBottom());
// Auto-scroll bei neuen Broadcasts. Audio wird in bootstrap.js global gehandelt
// damit der Listener nicht bei jedem Re-Render verdoppelt wird.
window.Echo?.channel('fox').listen('.message.created', () => {
this.$nextTick(() => this.scrollToBottom());
});
// Globaler Push-to-Talk Shortcut: Strg+Leertaste
window.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.code === 'Space' && !this.recording) {
e.preventDefault();
this.startRecording();
}
});
window.addEventListener('keyup', (e) => {
if (e.code === 'Space' && this.recording) {
e.preventDefault();
this.stopRecording();
}
});
},
playNextAudio() {
if (this.audioPlaying || this.audioQueue.length === 0) return;
this.audioPlaying = true;
const url = this.audioQueue.shift();
const audio = new Audio(url);
audio.onended = () => {
this.audioPlaying = false;
this.playNextAudio();
};
audio.play().catch(() => { this.audioPlaying = false; });
},
scrollToBottom() {
this.$nextTick(() => {
const feed = this.$refs.feed;
if (feed) feed.scrollTop = feed.scrollHeight;
});
},
async startRecording() {
if (this.recording) return;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.chunks = [];
this.mediaRecorder = new MediaRecorder(stream);
this.mediaRecorder.ondataavailable = (e) => this.chunks.push(e.data);
this.mediaRecorder.onstop = () => this.uploadRecording();
this.mediaRecorder.start();
this.recording = true;
} catch (err) {
console.error('Mikrofon nicht verfügbar:', err);
}
},
stopRecording() {
if (!this.recording || !this.mediaRecorder) return;
this.mediaRecorder.stop();
this.mediaRecorder.stream.getTracks().forEach(t => t.stop());
this.recording = false;
},
async uploadRecording() {
const blob = new Blob(this.chunks, { type: 'audio/webm' });
const fd = new FormData();
fd.append('audio', blob, 'fox-input.webm');
const res = await fetch('/fox/voice', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content },
body: fd,
});
const data = await res.json();
if (data.text) {
this.$wire.dispatch('voice-transcribed', { text: data.text });
}
if (data.audio_url) {
new Audio(data.audio_url).play().catch(() => {});
}
},
audioFromEvent(payload) {
const url = payload?.metadata?.audio_url;
if (url) new Audio(url).play().catch(() => {});
},
}));
});
</script>
@endassets