mirror of
https://github.com/temetro/temetro.git
synced 2026-08-28 03:17:05 +00:00
frontend: runtime backend URL (LAN), voice dictation, version + update UI
Resolve the backend URL from the current host at runtime (lib/backend-url.ts) so one build works on localhost and any clinic LAN IP without a rebuild; used by the API client, auth client, and socket. Wire the AI-chat mic to the Web Speech API with graceful fallback. Add a Settings "About & updates" panel (current/latest version, update command, shareable network address) and an optional dismissible update banner. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
type ChangeEvent,
|
||||
type KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -25,7 +26,37 @@ type ChatInputProps = {
|
||||
};
|
||||
|
||||
const iconButton =
|
||||
"flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground";
|
||||
"flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40";
|
||||
|
||||
// Minimal Web Speech API typings — not in this TS lib.dom. We only use a slice.
|
||||
interface SpeechRecognitionEventLike {
|
||||
readonly results: {
|
||||
readonly length: number;
|
||||
[index: number]: { readonly [index: number]: { transcript: string } };
|
||||
};
|
||||
}
|
||||
interface SpeechRecognitionLike {
|
||||
lang: string;
|
||||
interimResults: boolean;
|
||||
continuous: boolean;
|
||||
onresult: ((event: SpeechRecognitionEventLike) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
onerror: (() => void) | null;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
}
|
||||
type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
|
||||
|
||||
// Web Speech API lives under a vendor prefix in Chromium-based browsers and is
|
||||
// absent in others (e.g. Firefox). Resolve the constructor or null.
|
||||
function getSpeechRecognition(): SpeechRecognitionCtor | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const w = window as typeof window & {
|
||||
SpeechRecognition?: SpeechRecognitionCtor;
|
||||
webkitSpeechRecognition?: SpeechRecognitionCtor;
|
||||
};
|
||||
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
|
||||
}
|
||||
const pillButton =
|
||||
"flex h-8 items-center gap-1.5 rounded-lg px-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground";
|
||||
const contextPill =
|
||||
@@ -47,6 +78,53 @@ export function ChatInput({
|
||||
const [addKey, setAddKey] = useState(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Voice dictation (Web Speech API). Detected client-side so SSR markup and the
|
||||
// first client render agree (button starts disabled, enabled by the effect).
|
||||
const [speechSupported, setSpeechSupported] = useState(false);
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
|
||||
// The textarea contents when dictation started; transcript is appended to it.
|
||||
const dictationBaseRef = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
setSpeechSupported(getSpeechRecognition() !== null);
|
||||
return () => recognitionRef.current?.stop();
|
||||
}, []);
|
||||
|
||||
const toggleDictation = useCallback(() => {
|
||||
if (isListening) {
|
||||
recognitionRef.current?.stop();
|
||||
return;
|
||||
}
|
||||
const Recognition = getSpeechRecognition();
|
||||
if (!Recognition) return;
|
||||
|
||||
const recognition = new Recognition();
|
||||
recognitionRef.current = recognition;
|
||||
recognition.lang = navigator.language || "en-US";
|
||||
recognition.interimResults = true;
|
||||
recognition.continuous = true;
|
||||
// Continue from where the text leaves off, with a separating space.
|
||||
dictationBaseRef.current = value ? `${value.replace(/\s*$/, "")} ` : "";
|
||||
|
||||
recognition.onresult = (event) => {
|
||||
let transcript = "";
|
||||
for (let i = 0; i < event.results.length; i++) {
|
||||
transcript += event.results[i]?.[0]?.transcript ?? "";
|
||||
}
|
||||
setValue(dictationBaseRef.current + transcript);
|
||||
};
|
||||
const end = () => {
|
||||
setIsListening(false);
|
||||
recognitionRef.current = null;
|
||||
};
|
||||
recognition.onend = end;
|
||||
recognition.onerror = end;
|
||||
|
||||
recognition.start();
|
||||
setIsListening(true);
|
||||
}, [isListening, value]);
|
||||
|
||||
const isGenerating = status === "submitted" || status === "streaming";
|
||||
const canSend =
|
||||
(value.trim().length > 0 || files.length > 0) && !isGenerating;
|
||||
@@ -183,8 +261,22 @@ export function ChatInput({
|
||||
triggerClassName={cn(pillButton, "mr-1")}
|
||||
/>
|
||||
<button
|
||||
aria-label={t("chat.input.dictate")}
|
||||
className={iconButton}
|
||||
aria-label={
|
||||
isListening ? t("chat.input.dictateStop") : t("chat.input.dictate")
|
||||
}
|
||||
aria-pressed={isListening}
|
||||
className={cn(
|
||||
iconButton,
|
||||
isListening &&
|
||||
"animate-pulse bg-destructive/10 text-destructive hover:bg-destructive/15 hover:text-destructive"
|
||||
)}
|
||||
disabled={!speechSupported}
|
||||
onClick={toggleDictation}
|
||||
title={
|
||||
speechSupported
|
||||
? t("chat.input.dictate")
|
||||
: t("chat.input.dictateUnsupported")
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Mic className="size-[18px]" />
|
||||
|
||||
Reference in New Issue
Block a user