feat(web): implement SSE chat streaming with real-time token rendering (#516)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #516.
This commit is contained in:
@@ -7,13 +7,20 @@ interface ChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
disabled?: boolean;
|
||||
inputRef?: RefObject<HTMLTextAreaElement | null>;
|
||||
isStreaming?: boolean;
|
||||
onStopStreaming?: () => void;
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React.JSX.Element {
|
||||
export function ChatInput({
|
||||
onSend,
|
||||
disabled,
|
||||
inputRef,
|
||||
isStreaming = false,
|
||||
onStopStreaming,
|
||||
}: ChatInputProps): React.JSX.Element {
|
||||
const [message, setMessage] = useState("");
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
|
||||
// Fetch version from static version.json (generated at build time)
|
||||
useEffect(() => {
|
||||
interface VersionData {
|
||||
version?: string;
|
||||
@@ -24,7 +31,6 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
.then((res) => res.json() as Promise<VersionData>)
|
||||
.then((data) => {
|
||||
if (data.version) {
|
||||
// Format as "version+commit" for full build identification
|
||||
const fullVersion = data.commit ? `${data.version}+${data.commit}` : data.version;
|
||||
setVersion(fullVersion);
|
||||
}
|
||||
@@ -35,20 +41,22 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (message.trim() && !disabled) {
|
||||
if (message.trim() && !disabled && !isStreaming) {
|
||||
onSend(message);
|
||||
setMessage("");
|
||||
}
|
||||
}, [message, onSend, disabled]);
|
||||
}, [message, onSend, disabled, isStreaming]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
onStopStreaming?.();
|
||||
}, [onStopStreaming]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Enter to send (without Shift)
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
// Ctrl/Cmd + Enter to send (alternative)
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
@@ -61,6 +69,7 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
const maxCharacters = 4000;
|
||||
const isNearLimit = characterCount > maxCharacters * 0.9;
|
||||
const isOverLimit = characterCount > maxCharacters;
|
||||
const isInputDisabled = disabled ?? false;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -69,7 +78,10 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
className="relative rounded-lg border transition-all duration-150"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--surface-0))",
|
||||
borderColor: disabled ? "rgb(var(--border-default))" : "rgb(var(--border-strong))",
|
||||
borderColor:
|
||||
isInputDisabled || isStreaming
|
||||
? "rgb(var(--border-default))"
|
||||
: "rgb(var(--border-strong))",
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
@@ -79,8 +91,8 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
setMessage(e.target.value);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message..."
|
||||
disabled={disabled}
|
||||
placeholder={isStreaming ? "AI is responding..." : "Type a message..."}
|
||||
disabled={isInputDisabled || isStreaming}
|
||||
rows={1}
|
||||
className="block w-full resize-none bg-transparent px-4 py-3 pr-24 text-sm outline-none placeholder:text-[rgb(var(--text-muted))] disabled:opacity-50"
|
||||
style={{
|
||||
@@ -97,28 +109,47 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
aria-describedby="input-help"
|
||||
/>
|
||||
|
||||
{/* Send Button */}
|
||||
{/* Send / Stop Button */}
|
||||
<div className="absolute bottom-2 right-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={(disabled ?? !message.trim()) || isOverLimit}
|
||||
className="btn-primary btn-sm rounded-md"
|
||||
style={{
|
||||
opacity: disabled || !message.trim() || isOverLimit ? 0.5 : 1,
|
||||
}}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
{isStreaming ? (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
className="btn-sm rounded-md flex items-center gap-1.5"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--semantic-error))",
|
||||
color: "white",
|
||||
padding: "0.25rem 0.75rem",
|
||||
}}
|
||||
aria-label="Stop generating"
|
||||
title="Stop generating"
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Send</span>
|
||||
</button>
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="6" y="6" width="12" height="12" rx="1" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline text-sm font-medium">Stop</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isInputDisabled || !message.trim() || isOverLimit}
|
||||
className="btn-primary btn-sm rounded-md"
|
||||
style={{
|
||||
opacity: isInputDisabled || !message.trim() || isOverLimit ? 0.5 : 1,
|
||||
}}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Send</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -128,7 +159,6 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
style={{ color: "rgb(var(--text-muted))" }}
|
||||
id="input-help"
|
||||
>
|
||||
{/* Keyboard Shortcuts */}
|
||||
<div className="hidden items-center gap-4 sm:flex">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="kbd">Enter</span>
|
||||
@@ -142,10 +172,8 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile hint */}
|
||||
<div className="sm:hidden">Tap send or press Enter</div>
|
||||
|
||||
{/* Character Count */}
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
style={{
|
||||
|
||||
Reference in New Issue
Block a user