mirror of
https://github.com/HugeFrog24/nini-artgallery.git
synced 2026-06-30 04:57:12 +00:00
AI chat widget
This commit is contained in:
@@ -0,0 +1,828 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
import { useChat, type UIMessage } from "@ai-sdk/react";
|
||||
import {
|
||||
DefaultChatTransport,
|
||||
lastAssistantMessageIsCompleteWithToolCalls,
|
||||
} from "ai";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import {
|
||||
XMarkIcon,
|
||||
PaperAirplaneIcon,
|
||||
ArrowPathIcon,
|
||||
TrashIcon,
|
||||
MicrophoneIcon,
|
||||
StopIcon,
|
||||
Cog6ToothIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import type { ChatToolHandler } from "@/lib/chat-tools";
|
||||
import { blobToWav, blobToBase64, isWavSilent } from "@/lib/wav-encoder";
|
||||
import { toSpeechLang } from "@/lib/locales";
|
||||
import type {
|
||||
SpeechRecognitionEvent,
|
||||
SpeechRecognitionErrorEvent,
|
||||
SpeechRecognitionInstance,
|
||||
} from "@/lib/speech-recognition";
|
||||
import { getSpeechRecognition } from "@/lib/speech-recognition";
|
||||
|
||||
// ── Chat message persistence ──────────────────────────────────────
|
||||
// Key is intentionally locale-independent so the conversation survives
|
||||
// language switches (which re-mount the entire [locale] layout tree).
|
||||
export const CHAT_STORAGE_KEY = "chat-messages";
|
||||
|
||||
/** Read previously stored messages from sessionStorage (runs once on mount). */
|
||||
function loadStoredMessages(): UIMessage[] | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(CHAT_STORAGE_KEY);
|
||||
if (!raw) return undefined;
|
||||
const parsed: UIMessage[] = JSON.parse(raw);
|
||||
return parsed.length > 0 ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dictation mode types ──────────────────────────────────────────
|
||||
type DictationMode = "browser" | "server";
|
||||
const DICTATION_STORAGE_KEY = "chat-dictation-mode";
|
||||
|
||||
/** Format seconds as m:ss for the recording timer. */
|
||||
function formatRecordingTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable chat panel — "I am the window", not the button that opens me.
|
||||
*
|
||||
* The parent controls visibility (`open` / `onClose`).
|
||||
* The parent also controls positioning (e.g. fixed bottom-right, sidebar,
|
||||
* modal — whatever the layout needs). This component just fills whatever
|
||||
* container it's placed in.
|
||||
*
|
||||
* Wrap it in a positioned container to get floating-window behaviour:
|
||||
*
|
||||
* ```tsx
|
||||
* {isOpen && (
|
||||
* <div className="fixed bottom-6 right-6 z-50 w-96 h-[32rem]">
|
||||
* <ChatWidget open={isOpen} onClose={() => setIsOpen(false)} />
|
||||
* </div>
|
||||
* )}
|
||||
* ```
|
||||
*/
|
||||
interface ChatWidgetProps {
|
||||
/** Whether the panel is visible. */
|
||||
open: boolean;
|
||||
/** Called when the user clicks the close button inside the panel. */
|
||||
onClose: () => void;
|
||||
/** Override the API endpoint (defaults to "/api/chat") */
|
||||
apiEndpoint?: string;
|
||||
/**
|
||||
* Client-side tool handlers keyed by tool name.
|
||||
* Each handler receives the tool input and returns a short result string
|
||||
* that the model will see as the tool output.
|
||||
*
|
||||
* Tools are declared on the server (no `execute`), so the model can call
|
||||
* them; the handlers here run on the client when the call arrives.
|
||||
*/
|
||||
clientTools?: Record<string, ChatToolHandler>;
|
||||
/** Extra key-value pairs merged into every request body (e.g. theme state). */
|
||||
extraBody?: Record<string, unknown>;
|
||||
/** Explicit user-initiated reset action from the header bin button. */
|
||||
onReset?: () => void;
|
||||
/**
|
||||
* Fires once when the chat stream finishes and status returns to "ready".
|
||||
* Useful for deferring side-effects (e.g. navigation) until the model's
|
||||
* response has been fully streamed and persisted.
|
||||
*/
|
||||
onReady?: () => void;
|
||||
}
|
||||
|
||||
export default function ChatWidget({
|
||||
open,
|
||||
onClose,
|
||||
apiEndpoint = "/api/chat",
|
||||
clientTools,
|
||||
extraBody,
|
||||
onReset,
|
||||
onReady,
|
||||
}: ChatWidgetProps) {
|
||||
const t = useTranslations();
|
||||
const locale = useLocale();
|
||||
const speechLang = useMemo(() => toSpeechLang(locale), [locale]);
|
||||
const [input, setInput] = useState("");
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// ── Dictation mode (browser vs server) ──────────────────────────
|
||||
const [dictationMode, setDictationMode] = useState<DictationMode>(() => {
|
||||
if (typeof window === "undefined") return "server";
|
||||
try {
|
||||
const stored = localStorage.getItem(DICTATION_STORAGE_KEY);
|
||||
if (stored === "browser" || stored === "server") return stored;
|
||||
} catch { /* private browsing / quota */ }
|
||||
return "server";
|
||||
});
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
// Persist dictation mode preference
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(DICTATION_STORAGE_KEY, dictationMode); } catch { /* private browsing / quota */ }
|
||||
}, [dictationMode]);
|
||||
|
||||
// ── Speech-to-text dictation (browser mode) ─────────────────────
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const recognitionRef = useRef<SpeechRecognitionInstance | null>(null);
|
||||
// Tracks whether we *want* to be listening. Chrome's SpeechRecognition
|
||||
// can fire `onend` at any time (silence, network blip, etc.) even with
|
||||
// `continuous: true`. When this ref is true, the `onend` handler will
|
||||
// automatically restart the engine.
|
||||
const shouldListenRef = useRef(false);
|
||||
// Check once whether the browser supports the Web Speech API.
|
||||
const [speechSupported, setSpeechSupported] = useState(false);
|
||||
const [speechTemporarilyUnavailable, setSpeechTemporarilyUnavailable] =
|
||||
useState(false);
|
||||
useEffect(() => {
|
||||
const supported = getSpeechRecognition() !== null;
|
||||
setSpeechSupported(supported);
|
||||
// If "browser" was stored but this browser doesn't support it, auto-correct
|
||||
if (!supported) {
|
||||
setDictationMode((prev) => (prev === "browser" ? "server" : prev));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Server-side recording state ─────────────────────────────────
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isTranscribing, setIsTranscribing] = useState(false);
|
||||
const [recordingSeconds, setRecordingSeconds] = useState(0);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
const recordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(
|
||||
null,
|
||||
);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const transcribeAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// ── Sound effects ────────────────────────────────────────────────
|
||||
const sentSoundRef = useRef<HTMLAudioElement | null>(null);
|
||||
const receivedSoundRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
// Preload audio files once on mount (safe in "use client" components).
|
||||
useEffect(() => {
|
||||
sentSoundRef.current = new Audio("/sounds/message_sent.mp3");
|
||||
receivedSoundRef.current = new Audio("/sounds/new_notification.mp3");
|
||||
}, []);
|
||||
|
||||
const playSent = useCallback(() => {
|
||||
const s = sentSoundRef.current;
|
||||
if (!s) return;
|
||||
s.currentTime = 0;
|
||||
s.play().catch(() => {});
|
||||
}, []);
|
||||
|
||||
const playReceived = useCallback(() => {
|
||||
const s = receivedSoundRef.current;
|
||||
if (!s) return;
|
||||
s.currentTime = 0;
|
||||
s.play().catch(() => {});
|
||||
}, []);
|
||||
|
||||
// ── Restore persisted conversation (computed once on mount) ──────
|
||||
const [storedMessages] = useState(loadStoredMessages);
|
||||
|
||||
// Keep a ref so the onToolCall closure always sees the latest handlers
|
||||
// without needing to re-create the useChat config on every render.
|
||||
const clientToolsRef = useRef(clientTools);
|
||||
clientToolsRef.current = clientTools;
|
||||
|
||||
// Serialise extraBody so useMemo only re-creates the transport when the
|
||||
// values actually change (object identity would change every render).
|
||||
const extraBodyKey = JSON.stringify(extraBody ?? {});
|
||||
|
||||
const transport = useMemo(
|
||||
() =>
|
||||
new DefaultChatTransport({
|
||||
api: apiEndpoint,
|
||||
body: { locale, ...extraBody },
|
||||
}),
|
||||
[apiEndpoint, locale, extraBodyKey], // extraBodyKey is the stable proxy for extraBody
|
||||
);
|
||||
|
||||
const { messages, sendMessage, regenerate, addToolOutput, status, error, clearError } = useChat({
|
||||
messages: storedMessages,
|
||||
transport,
|
||||
|
||||
// After all client-side tool results are available, automatically send
|
||||
// them back so the model can generate a follow-up text response.
|
||||
sendAutomaticallyWhen: clientTools
|
||||
? lastAssistantMessageIsCompleteWithToolCalls
|
||||
: undefined,
|
||||
|
||||
async onToolCall({ toolCall }) {
|
||||
const handler = clientToolsRef.current?.[toolCall.toolName];
|
||||
if (!handler) return;
|
||||
|
||||
try {
|
||||
const output = await handler(
|
||||
toolCall.input as Record<string, unknown>,
|
||||
);
|
||||
addToolOutput({
|
||||
tool: toolCall.toolName,
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output,
|
||||
});
|
||||
} catch {
|
||||
addToolOutput({
|
||||
tool: toolCall.toolName,
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: "Tool execution failed.",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Play the notification sound when the assistant finishes a text response.
|
||||
// Skip aborts, disconnects, errors, and intermediate tool-call turns
|
||||
// (those will be resubmitted automatically by sendAutomaticallyWhen).
|
||||
onFinish({ isAbort, isDisconnect, isError, finishReason }) {
|
||||
if (isAbort || isDisconnect || isError) return;
|
||||
if (finishReason === "tool-calls") return;
|
||||
playReceived();
|
||||
},
|
||||
|
||||
onError() {
|
||||
// handled via the `error` return value — no need to rethrow
|
||||
},
|
||||
});
|
||||
|
||||
const isLoading = status === "streaming" || status === "submitted";
|
||||
|
||||
// ── Persist messages to sessionStorage on every change ──────────
|
||||
// Declared BEFORE the status watcher so messages are saved before
|
||||
// onReady can trigger navigation (React runs effects in order).
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (messages.length > 0) {
|
||||
sessionStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(messages));
|
||||
} else {
|
||||
sessionStorage.removeItem(CHAT_STORAGE_KEY);
|
||||
}
|
||||
} catch { /* private browsing / quota */ }
|
||||
}, [messages]);
|
||||
|
||||
// ── Notify parent when a stream *truly* completes (active → ready) ─
|
||||
// Guard: skip the intermediate "ready" after a tool-call message —
|
||||
// sendAutomaticallyWhen is about to fire another round-trip. Only
|
||||
// signal when the final text response is done.
|
||||
const prevStatusRef = useRef(status);
|
||||
useEffect(() => {
|
||||
const wasActive =
|
||||
prevStatusRef.current === "streaming" ||
|
||||
prevStatusRef.current === "submitted";
|
||||
prevStatusRef.current = status;
|
||||
if (!wasActive || status !== "ready") return;
|
||||
// If the last assistant message is a complete tool call, the auto-send
|
||||
// will resubmit momentarily — don't fire onReady yet.
|
||||
if (clientTools && lastAssistantMessageIsCompleteWithToolCalls({ messages })) return;
|
||||
onReady?.();
|
||||
}, [status, messages, clientTools, onReady]);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
// Focus input when panel opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 150);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed || isLoading) return;
|
||||
sendMessage({ text: trimmed });
|
||||
playSent();
|
||||
setInput("");
|
||||
|
||||
// Reset textarea height after sending
|
||||
if (inputRef.current) {
|
||||
inputRef.current.style.height = "auto";
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-resize textarea to fit content (up to max-h cap)
|
||||
const autoResize = useCallback(() => {
|
||||
const el = inputRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}, []);
|
||||
|
||||
// Enter sends, Shift+Enter inserts a newline
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Speech-to-text helpers ─────────────────────────────────────
|
||||
|
||||
// Mutable ref that holds the latest "committed" (final) transcript
|
||||
// across recognition restarts so we don't lose words.
|
||||
const committedRef = useRef("");
|
||||
|
||||
/** Stop the current recognition session intentionally. */
|
||||
const stopListening = useCallback(() => {
|
||||
shouldListenRef.current = false;
|
||||
recognitionRef.current?.abort();
|
||||
recognitionRef.current = null;
|
||||
setIsListening(false);
|
||||
}, []);
|
||||
|
||||
/** Tear down any in-progress server recording / transcription. */
|
||||
const cleanupRecording = useCallback(() => {
|
||||
transcribeAbortRef.current?.abort();
|
||||
transcribeAbortRef.current = null;
|
||||
if (recordingTimerRef.current) {
|
||||
clearInterval(recordingTimerRef.current);
|
||||
recordingTimerRef.current = null;
|
||||
}
|
||||
if (mediaRecorderRef.current?.state === "recording") {
|
||||
try { mediaRecorderRef.current.stop(); } catch { /* already stopped */ }
|
||||
}
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
mediaRecorderRef.current = null;
|
||||
audioChunksRef.current = [];
|
||||
setIsRecording(false);
|
||||
setIsTranscribing(false);
|
||||
setRecordingSeconds(0);
|
||||
}, []);
|
||||
|
||||
// Ensure all dictation is stopped when the widget closes or unmounts.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
stopListening();
|
||||
cleanupRecording();
|
||||
}
|
||||
return () => {
|
||||
stopListening();
|
||||
cleanupRecording();
|
||||
};
|
||||
}, [open, stopListening, cleanupRecording]);
|
||||
|
||||
/**
|
||||
* Create, configure, and start a SpeechRecognition instance.
|
||||
* Extracted so both `startListening` and the auto-restart in
|
||||
* `onend` can share the same setup logic.
|
||||
*/
|
||||
const bootRecognition = useCallback(() => {
|
||||
const SR = getSpeechRecognition();
|
||||
if (!SR) return;
|
||||
|
||||
const recognition = new SR();
|
||||
recognition.lang = speechLang;
|
||||
recognition.interimResults = true;
|
||||
recognition.continuous = true;
|
||||
|
||||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||
let interim = "";
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const transcript = event.results[i][0].transcript;
|
||||
if (event.results[i].isFinal) {
|
||||
const trimmed = transcript.trim();
|
||||
if (trimmed) {
|
||||
committedRef.current +=
|
||||
(committedRef.current ? " " : "") + trimmed;
|
||||
}
|
||||
} else {
|
||||
interim += transcript;
|
||||
}
|
||||
}
|
||||
|
||||
// Show committed + interim preview in the textarea
|
||||
const preview = interim
|
||||
? committedRef.current +
|
||||
(committedRef.current ? " " : "") +
|
||||
interim
|
||||
: committedRef.current;
|
||||
|
||||
setInput(preview);
|
||||
// Auto-grow the textarea to fit the dictated text
|
||||
requestAnimationFrame(() => {
|
||||
const el = inputRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
});
|
||||
};
|
||||
|
||||
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
|
||||
// Helpful during integration/testing: shows why no text is emitted.
|
||||
console.warn("[chat] SpeechRecognition error:", event.error);
|
||||
// Fatal errors that mean we should truly stop.
|
||||
const fatal = new Set([
|
||||
"not-allowed",
|
||||
"service-not-allowed",
|
||||
"language-not-supported",
|
||||
"network",
|
||||
]);
|
||||
if (fatal.has(event.error)) {
|
||||
shouldListenRef.current = false;
|
||||
if (event.error === "network") {
|
||||
// Constructor exists but backend speech service is unreachable.
|
||||
// Disable dictation button for this page session to avoid a retry loop.
|
||||
setSpeechTemporarilyUnavailable(true);
|
||||
}
|
||||
}
|
||||
// Non-fatal errors (no-speech, aborted, audio-capture)
|
||||
// will trigger `onend`, which will auto-restart if shouldListenRef
|
||||
// is still true.
|
||||
};
|
||||
|
||||
recognition.onend = () => {
|
||||
recognitionRef.current = null;
|
||||
// Auto-restart if we haven't explicitly stopped.
|
||||
if (shouldListenRef.current) {
|
||||
try {
|
||||
bootRecognition();
|
||||
} catch {
|
||||
shouldListenRef.current = false;
|
||||
setIsListening(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
recognitionRef.current = recognition;
|
||||
recognition.start();
|
||||
}, [speechLang]);
|
||||
|
||||
/** Start browser speech recognition and stream results into the textarea. */
|
||||
const startListening = useCallback(() => {
|
||||
if (!getSpeechRecognition() || speechTemporarilyUnavailable) return;
|
||||
|
||||
// Seed the committed buffer with whatever text is already in the input
|
||||
committedRef.current = input;
|
||||
shouldListenRef.current = true;
|
||||
setIsListening(true);
|
||||
|
||||
bootRecognition();
|
||||
}, [input, bootRecognition, speechTemporarilyUnavailable]);
|
||||
|
||||
// ── Server-side recording helpers ──────────────────────────────
|
||||
|
||||
/** Start capturing audio via MediaRecorder (server transcription mode). */
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
streamRef.current = stream;
|
||||
audioChunksRef.current = [];
|
||||
|
||||
const recorder = new MediaRecorder(stream);
|
||||
mediaRecorderRef.current = recorder;
|
||||
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) audioChunksRef.current.push(e.data);
|
||||
};
|
||||
|
||||
recorder.onstop = async () => {
|
||||
const blob = new Blob(audioChunksRef.current, {
|
||||
type: recorder.mimeType,
|
||||
});
|
||||
audioChunksRef.current = [];
|
||||
|
||||
// Release mic
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
|
||||
if (blob.size === 0) return;
|
||||
|
||||
setIsTranscribing(true);
|
||||
try {
|
||||
const wavBlob = await blobToWav(blob);
|
||||
|
||||
// ── Silence / too-short guard ───────────────────────────
|
||||
// Whisper-family models hallucinate plausible text on silent
|
||||
// audio. Catch it client-side to save an API round-trip.
|
||||
const silent = await isWavSilent(wavBlob);
|
||||
if (silent) {
|
||||
console.log("[chat] Recording was silent — skipping transcription");
|
||||
return; // finally block resets isTranscribing
|
||||
}
|
||||
|
||||
const base64 = await blobToBase64(wavBlob);
|
||||
|
||||
const controller = new AbortController();
|
||||
transcribeAbortRef.current = controller;
|
||||
|
||||
const res = await fetch("/api/transcribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
audio: base64,
|
||||
mimeType: "audio/wav",
|
||||
locale,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.text) {
|
||||
setInput((prev) => {
|
||||
const separator = prev.trim() ? " " : "";
|
||||
return prev + separator + data.text;
|
||||
});
|
||||
// Auto-grow textarea
|
||||
requestAnimationFrame(() => {
|
||||
const el = inputRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
});
|
||||
} else if (data.error) {
|
||||
console.warn("[chat] Transcription failed:", data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
console.warn("[chat] Transcription error:", err);
|
||||
} finally {
|
||||
transcribeAbortRef.current = null;
|
||||
setIsTranscribing(false);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.start();
|
||||
setIsRecording(true);
|
||||
setRecordingSeconds(0);
|
||||
recordingTimerRef.current = setInterval(() => {
|
||||
setRecordingSeconds((s) => s + 1);
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
console.warn("[chat] Microphone access denied:", err);
|
||||
}
|
||||
}, [locale]);
|
||||
|
||||
/** Stop the MediaRecorder — triggers onstop → transcribe flow. */
|
||||
const stopRecording = useCallback(() => {
|
||||
if (recordingTimerRef.current) {
|
||||
clearInterval(recordingTimerRef.current);
|
||||
recordingTimerRef.current = null;
|
||||
}
|
||||
if (mediaRecorderRef.current?.state === "recording") {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
setIsRecording(false);
|
||||
}, []);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col rounded-xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-800 transition-colors">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between rounded-t-xl border-b border-gray-200 bg-accent-600 px-4 py-3 dark:border-gray-700 dark:bg-accent-700">
|
||||
<h2 className="text-sm font-semibold text-white">{t("Chat.title")}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setSettingsOpen((v) => !v)}
|
||||
className={`transition-colors focus:outline-none ${
|
||||
settingsOpen
|
||||
? "text-white"
|
||||
: "text-white/80 hover:text-white"
|
||||
}`}
|
||||
aria-label={t("Chat.settings")}
|
||||
>
|
||||
<Cog6ToothIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="text-white/80 hover:text-white transition-colors focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label={t("Chat.clearChat")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/80 hover:text-white transition-colors focus:outline-none"
|
||||
aria-label={t("Chat.closeChat")}
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings panel */}
|
||||
{settingsOpen && (
|
||||
<div className="border-b border-gray-200 bg-gray-50 px-4 py-2 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{t("Chat.dictationMode")}
|
||||
</label>
|
||||
<select
|
||||
value={dictationMode}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as DictationMode;
|
||||
// Stop any active session before switching
|
||||
if (isListening) stopListening();
|
||||
if (isRecording) stopRecording();
|
||||
setDictationMode(next);
|
||||
}}
|
||||
className="rounded border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
>
|
||||
<option value="server">{t("Chat.dictationServer")}</option>
|
||||
<option
|
||||
value="browser"
|
||||
disabled={!speechSupported || speechTemporarilyUnavailable}
|
||||
>
|
||||
{t("Chat.dictationBrowser")}
|
||||
{(!speechSupported || speechTemporarilyUnavailable)
|
||||
? ` — ${t("Chat.dictationBrowserUnavailable")}`
|
||||
: ""}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages area */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
|
||||
{messages.length === 0 && (
|
||||
<p className="text-center text-sm text-gray-400 dark:text-gray-500 mt-8">
|
||||
{t("Chat.emptyState")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{messages.map((message) => {
|
||||
const isUser = message.role === "user";
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${isUser ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-lg px-3 py-2 text-sm leading-relaxed whitespace-pre-wrap ${
|
||||
isUser
|
||||
? "bg-accent-600 text-white dark:bg-accent-500"
|
||||
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
{message.parts.map((part, i) => {
|
||||
if (part.type === "text") {
|
||||
return <span key={i}>{part.text}</span>;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{isLoading &&
|
||||
messages[messages.length - 1]?.role !== "assistant" && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-lg bg-gray-100 px-3 py-2 text-sm text-gray-500 dark:bg-gray-700 dark:text-gray-400">
|
||||
<span className="inline-flex gap-1">
|
||||
<span className="animate-bounce">.</span>
|
||||
<span
|
||||
className="animate-bounce"
|
||||
style={{ animationDelay: "0.15s" }}
|
||||
>
|
||||
.
|
||||
</span>
|
||||
<span
|
||||
className="animate-bounce"
|
||||
style={{ animationDelay: "0.3s" }}
|
||||
>
|
||||
.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error banner with retry */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 dark:border-red-800 dark:bg-red-900/30">
|
||||
<div className="flex items-center gap-2 text-sm text-red-700 dark:text-red-300">
|
||||
<span className="flex-1">
|
||||
{t("Chat.errorMessage")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
clearError();
|
||||
regenerate();
|
||||
}}
|
||||
className="inline-flex items-center gap-1 shrink-0 rounded-md px-2 py-1 text-xs font-medium text-red-700 hover:bg-red-100 dark:text-red-300 dark:hover:bg-red-900/50 transition-colors"
|
||||
aria-label={t("Chat.retry")}
|
||||
>
|
||||
<ArrowPathIcon className="h-3.5 w-3.5" />
|
||||
{t("Chat.retry")}
|
||||
</button>
|
||||
<button
|
||||
onClick={clearError}
|
||||
className="shrink-0 text-red-400 hover:text-red-600 dark:text-red-500 dark:hover:text-red-300"
|
||||
aria-label={t("Chat.dismissError")}
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex items-end gap-2 border-t border-gray-200 px-3 py-2 dark:border-gray-700"
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
rows={1}
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
autoResize();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
isListening
|
||||
? t("Chat.listening")
|
||||
: isRecording
|
||||
? t("Chat.recording", { time: formatRecordingTime(recordingSeconds) })
|
||||
: isTranscribing
|
||||
? t("Chat.transcribing")
|
||||
: t("Chat.placeholder")
|
||||
}
|
||||
disabled={isLoading || isTranscribing}
|
||||
className={`min-w-0 flex-1 resize-none rounded-md border bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 disabled:opacity-60 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 transition-colors max-h-32 overflow-y-auto ${
|
||||
isListening || isRecording
|
||||
? "border-red-400 focus:border-red-500 focus:ring-red-400 dark:border-red-500"
|
||||
: "border-gray-300 focus:border-accent-500 focus:ring-accent-500 dark:border-gray-600"
|
||||
}`}
|
||||
/>
|
||||
{/* Mic button — dispatches to browser or server dictation mode */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (dictationMode === "browser") {
|
||||
if (isListening) stopListening(); else startListening();
|
||||
} else {
|
||||
if (isRecording) stopRecording(); else startRecording();
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
isLoading ||
|
||||
isTranscribing ||
|
||||
(dictationMode === "browser" &&
|
||||
(!speechSupported || speechTemporarilyUnavailable))
|
||||
}
|
||||
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-md transition-colors focus:outline-none disabled:opacity-40 disabled:cursor-not-allowed ${
|
||||
isListening || isRecording
|
||||
? "bg-red-600 text-white hover:bg-red-700"
|
||||
: isTranscribing
|
||||
? "bg-amber-500 text-white animate-pulse"
|
||||
: "border border-gray-300 text-gray-500 hover:text-accent-600 hover:border-accent-400 dark:border-gray-600 dark:text-gray-400 dark:hover:text-accent-400 dark:hover:border-accent-500"
|
||||
}`}
|
||||
aria-label={
|
||||
isListening
|
||||
? t("Chat.stopListening")
|
||||
: isRecording
|
||||
? t("Chat.stopRecording")
|
||||
: isTranscribing
|
||||
? t("Chat.transcribing")
|
||||
: dictationMode === "browser"
|
||||
? t("Chat.startListening")
|
||||
: t("Chat.startRecording")
|
||||
}
|
||||
>
|
||||
{isListening || isRecording ? (
|
||||
<StopIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<MicrophoneIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
{/* Send button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !input.trim() || isRecording || isTranscribing}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-accent-600 text-white hover:bg-accent-700 disabled:opacity-40 disabled:cursor-not-allowed dark:bg-accent-500 dark:hover:bg-accent-600 transition-colors focus:outline-none"
|
||||
aria-label={t("Chat.sendMessage")}
|
||||
>
|
||||
<PaperAirplaneIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user