220 lines
9.7 KiB
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
|