All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
615 lines
20 KiB
TypeScript
615 lines
20 KiB
TypeScript
"use client";
|
||
|
||
import type { KeyboardEvent, RefObject } from "react";
|
||
import { useCallback, useState, useEffect, useRef } from "react";
|
||
|
||
export const AVAILABLE_MODELS = [
|
||
{ id: "llama3.2", label: "Llama 3.2" },
|
||
{ id: "claude-3.5-sonnet", label: "Claude 3.5 Sonnet" },
|
||
{ id: "gpt-4o", label: "GPT-4o" },
|
||
{ id: "deepseek-r1", label: "DeepSeek R1" },
|
||
] as const;
|
||
|
||
export type ModelId = (typeof AVAILABLE_MODELS)[number]["id"];
|
||
|
||
const STORAGE_KEY_MODEL = "chat:selectedModel";
|
||
const STORAGE_KEY_TEMPERATURE = "chat:temperature";
|
||
const STORAGE_KEY_MAX_TOKENS = "chat:maxTokens";
|
||
|
||
export const DEFAULT_TEMPERATURE = 0.7;
|
||
export const DEFAULT_MAX_TOKENS = 4096;
|
||
export const DEFAULT_MODEL: ModelId = "llama3.2";
|
||
|
||
function loadStoredModel(): ModelId {
|
||
try {
|
||
const stored = localStorage.getItem(STORAGE_KEY_MODEL);
|
||
if (stored && AVAILABLE_MODELS.some((m) => m.id === stored)) {
|
||
return stored as ModelId;
|
||
}
|
||
} catch {
|
||
// localStorage not available
|
||
}
|
||
return DEFAULT_MODEL;
|
||
}
|
||
|
||
function loadStoredTemperature(): number {
|
||
try {
|
||
const stored = localStorage.getItem(STORAGE_KEY_TEMPERATURE);
|
||
if (stored !== null) {
|
||
const parsed = parseFloat(stored);
|
||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 2) {
|
||
return parsed;
|
||
}
|
||
}
|
||
} catch {
|
||
// localStorage not available
|
||
}
|
||
return DEFAULT_TEMPERATURE;
|
||
}
|
||
|
||
function loadStoredMaxTokens(): number {
|
||
try {
|
||
const stored = localStorage.getItem(STORAGE_KEY_MAX_TOKENS);
|
||
if (stored !== null) {
|
||
const parsed = parseInt(stored, 10);
|
||
if (!isNaN(parsed) && parsed >= 100 && parsed <= 32000) {
|
||
return parsed;
|
||
}
|
||
}
|
||
} catch {
|
||
// localStorage not available
|
||
}
|
||
return DEFAULT_MAX_TOKENS;
|
||
}
|
||
|
||
interface ChatInputProps {
|
||
onSend: (message: string) => void;
|
||
disabled?: boolean;
|
||
inputRef?: RefObject<HTMLTextAreaElement | null>;
|
||
isStreaming?: boolean;
|
||
onStopStreaming?: () => void;
|
||
onModelChange?: (model: ModelId) => void;
|
||
onTemperatureChange?: (temperature: number) => void;
|
||
onMaxTokensChange?: (maxTokens: number) => void;
|
||
onSuggestionFill?: (text: string) => void;
|
||
externalValue?: string;
|
||
}
|
||
|
||
export function ChatInput({
|
||
onSend,
|
||
disabled,
|
||
inputRef,
|
||
isStreaming = false,
|
||
onStopStreaming,
|
||
onModelChange,
|
||
onTemperatureChange,
|
||
onMaxTokensChange,
|
||
externalValue,
|
||
}: ChatInputProps): React.JSX.Element {
|
||
const [message, setMessage] = useState("");
|
||
const [version, setVersion] = useState<string | null>(null);
|
||
const [selectedModel, setSelectedModel] = useState<ModelId>(DEFAULT_MODEL);
|
||
const [temperature, setTemperature] = useState<number>(DEFAULT_TEMPERATURE);
|
||
const [maxTokens, setMaxTokens] = useState<number>(DEFAULT_MAX_TOKENS);
|
||
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
|
||
const [isParamsOpen, setIsParamsOpen] = useState(false);
|
||
|
||
const modelDropdownRef = useRef<HTMLDivElement>(null);
|
||
const paramsDropdownRef = useRef<HTMLDivElement>(null);
|
||
|
||
// Stable refs for callbacks so the mount effect stays dependency-free
|
||
const onModelChangeRef = useRef(onModelChange);
|
||
onModelChangeRef.current = onModelChange;
|
||
const onTemperatureChangeRef = useRef(onTemperatureChange);
|
||
onTemperatureChangeRef.current = onTemperatureChange;
|
||
const onMaxTokensChangeRef = useRef(onMaxTokensChange);
|
||
onMaxTokensChangeRef.current = onMaxTokensChange;
|
||
|
||
// Load persisted values from localStorage on mount only
|
||
useEffect(() => {
|
||
const storedModel = loadStoredModel();
|
||
const storedTemperature = loadStoredTemperature();
|
||
const storedMaxTokens = loadStoredMaxTokens();
|
||
|
||
setSelectedModel(storedModel);
|
||
setTemperature(storedTemperature);
|
||
setMaxTokens(storedMaxTokens);
|
||
|
||
// Notify parent of initial values via refs to avoid stale closure
|
||
onModelChangeRef.current?.(storedModel);
|
||
onTemperatureChangeRef.current?.(storedTemperature);
|
||
onMaxTokensChangeRef.current?.(storedMaxTokens);
|
||
}, []);
|
||
|
||
// Sync external value (e.g. from suggestion clicks)
|
||
useEffect(() => {
|
||
if (externalValue !== undefined) {
|
||
setMessage(externalValue);
|
||
}
|
||
}, [externalValue]);
|
||
|
||
useEffect(() => {
|
||
interface VersionData {
|
||
version?: string;
|
||
commit?: string;
|
||
}
|
||
|
||
fetch("/version.json")
|
||
.then((res) => res.json() as Promise<VersionData>)
|
||
.then((data) => {
|
||
if (data.version) {
|
||
const fullVersion = data.commit ? `${data.version}+${data.commit}` : data.version;
|
||
setVersion(fullVersion);
|
||
}
|
||
})
|
||
.catch(() => {
|
||
// Silently fail - version display is non-critical
|
||
});
|
||
}, []);
|
||
|
||
// Close dropdowns on outside click
|
||
useEffect(() => {
|
||
const handleClickOutside = (e: MouseEvent): void => {
|
||
if (modelDropdownRef.current && !modelDropdownRef.current.contains(e.target as Node)) {
|
||
setIsModelDropdownOpen(false);
|
||
}
|
||
if (paramsDropdownRef.current && !paramsDropdownRef.current.contains(e.target as Node)) {
|
||
setIsParamsOpen(false);
|
||
}
|
||
};
|
||
document.addEventListener("mousedown", handleClickOutside);
|
||
return (): void => {
|
||
document.removeEventListener("mousedown", handleClickOutside);
|
||
};
|
||
}, []);
|
||
|
||
const handleSubmit = useCallback(() => {
|
||
if (message.trim() && !disabled && !isStreaming) {
|
||
onSend(message);
|
||
setMessage("");
|
||
}
|
||
}, [message, onSend, disabled, isStreaming]);
|
||
|
||
const handleStop = useCallback(() => {
|
||
onStopStreaming?.();
|
||
}, [onStopStreaming]);
|
||
|
||
const handleKeyDown = useCallback(
|
||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||
if (e.key === "Enter" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleSubmit();
|
||
}
|
||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||
e.preventDefault();
|
||
handleSubmit();
|
||
}
|
||
},
|
||
[handleSubmit]
|
||
);
|
||
|
||
const handleModelSelect = useCallback(
|
||
(model: ModelId): void => {
|
||
setSelectedModel(model);
|
||
try {
|
||
localStorage.setItem(STORAGE_KEY_MODEL, model);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
onModelChange?.(model);
|
||
setIsModelDropdownOpen(false);
|
||
},
|
||
[onModelChange]
|
||
);
|
||
|
||
const handleTemperatureChange = useCallback(
|
||
(value: number): void => {
|
||
setTemperature(value);
|
||
try {
|
||
localStorage.setItem(STORAGE_KEY_TEMPERATURE, value.toString());
|
||
} catch {
|
||
// ignore
|
||
}
|
||
onTemperatureChange?.(value);
|
||
},
|
||
[onTemperatureChange]
|
||
);
|
||
|
||
const handleMaxTokensChange = useCallback(
|
||
(value: number): void => {
|
||
setMaxTokens(value);
|
||
try {
|
||
localStorage.setItem(STORAGE_KEY_MAX_TOKENS, value.toString());
|
||
} catch {
|
||
// ignore
|
||
}
|
||
onMaxTokensChange?.(value);
|
||
},
|
||
[onMaxTokensChange]
|
||
);
|
||
|
||
const selectedModelLabel =
|
||
AVAILABLE_MODELS.find((m) => m.id === selectedModel)?.label ?? selectedModel;
|
||
|
||
const characterCount = message.length;
|
||
const maxCharacters = 4000;
|
||
const isNearLimit = characterCount > maxCharacters * 0.9;
|
||
const isOverLimit = characterCount > maxCharacters;
|
||
const isInputDisabled = disabled ?? false;
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
{/* Model Selector + Params Row */}
|
||
<div className="flex items-center gap-2">
|
||
{/* Model Selector */}
|
||
<div className="relative" ref={modelDropdownRef}>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setIsModelDropdownOpen((prev) => !prev);
|
||
setIsParamsOpen(false);
|
||
}}
|
||
className="flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-black/5 focus:outline-none focus:ring-2"
|
||
style={{
|
||
borderColor: "rgb(var(--border-default))",
|
||
backgroundColor: "rgb(var(--surface-1))",
|
||
color: "rgb(var(--text-secondary))",
|
||
}}
|
||
aria-label={`Model: ${selectedModelLabel}. Click to change`}
|
||
aria-expanded={isModelDropdownOpen}
|
||
aria-haspopup="listbox"
|
||
title="Select AI model"
|
||
>
|
||
<svg
|
||
className="h-3 w-3 flex-shrink-0"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
strokeWidth={2}
|
||
aria-hidden="true"
|
||
>
|
||
<circle cx="12" cy="12" r="3" />
|
||
<path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83" />
|
||
</svg>
|
||
<span>{selectedModelLabel}</span>
|
||
<svg
|
||
className={`h-3 w-3 transition-transform ${isModelDropdownOpen ? "rotate-180" : ""}`}
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
strokeWidth={2}
|
||
aria-hidden="true"
|
||
>
|
||
<path d="M6 9l6 6 6-6" />
|
||
</svg>
|
||
</button>
|
||
|
||
{/* Model Dropdown */}
|
||
{isModelDropdownOpen && (
|
||
<div
|
||
className="absolute bottom-full left-0 mb-1 z-50 min-w-[160px] rounded-lg border shadow-lg"
|
||
style={{
|
||
backgroundColor: "rgb(var(--surface-0))",
|
||
borderColor: "rgb(var(--border-default))",
|
||
}}
|
||
role="listbox"
|
||
aria-label="Available models"
|
||
>
|
||
{AVAILABLE_MODELS.map((model) => (
|
||
<button
|
||
key={model.id}
|
||
role="option"
|
||
aria-selected={model.id === selectedModel}
|
||
onClick={() => {
|
||
handleModelSelect(model.id);
|
||
}}
|
||
className="w-full px-3 py-2 text-left text-xs transition-colors first:rounded-t-lg last:rounded-b-lg hover:bg-black/5"
|
||
style={{
|
||
color:
|
||
model.id === selectedModel
|
||
? "rgb(var(--accent-primary))"
|
||
: "rgb(var(--text-primary))",
|
||
fontWeight: model.id === selectedModel ? 600 : 400,
|
||
}}
|
||
>
|
||
{model.label}
|
||
{model.id === selectedModel && (
|
||
<svg
|
||
className="inline-block ml-1.5 h-3 w-3"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
strokeWidth={3}
|
||
aria-hidden="true"
|
||
>
|
||
<path d="M5 13l4 4L19 7" />
|
||
</svg>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Settings / Params Icon */}
|
||
<div className="relative" ref={paramsDropdownRef}>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setIsParamsOpen((prev) => !prev);
|
||
setIsModelDropdownOpen(false);
|
||
}}
|
||
className="flex items-center justify-center rounded-full border p-1 transition-colors hover:bg-black/5 focus:outline-none focus:ring-2"
|
||
style={{
|
||
borderColor: "rgb(var(--border-default))",
|
||
backgroundColor: isParamsOpen ? "rgb(var(--surface-2))" : "rgb(var(--surface-1))",
|
||
color: "rgb(var(--text-muted))",
|
||
}}
|
||
aria-label="Chat parameters"
|
||
aria-expanded={isParamsOpen}
|
||
aria-haspopup="dialog"
|
||
title="Configure temperature and max tokens"
|
||
>
|
||
<svg
|
||
className="h-3.5 w-3.5"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
strokeWidth={2}
|
||
aria-hidden="true"
|
||
>
|
||
<path d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
|
||
</svg>
|
||
</button>
|
||
|
||
{/* Params Popover */}
|
||
{isParamsOpen && (
|
||
<div
|
||
className="absolute bottom-full left-0 mb-1 z-50 w-64 rounded-lg border p-4 shadow-lg"
|
||
style={{
|
||
backgroundColor: "rgb(var(--surface-0))",
|
||
borderColor: "rgb(var(--border-default))",
|
||
}}
|
||
role="dialog"
|
||
aria-label="Chat parameters"
|
||
>
|
||
<h3
|
||
className="mb-3 text-xs font-semibold uppercase tracking-wide"
|
||
style={{ color: "rgb(var(--text-muted))" }}
|
||
>
|
||
Parameters
|
||
</h3>
|
||
|
||
{/* Temperature */}
|
||
<div className="mb-4">
|
||
<div className="mb-1.5 flex items-center justify-between">
|
||
<label
|
||
className="text-xs font-medium"
|
||
style={{ color: "rgb(var(--text-secondary))" }}
|
||
htmlFor="temperature-slider"
|
||
>
|
||
Temperature
|
||
</label>
|
||
<span
|
||
className="text-xs font-mono tabular-nums"
|
||
style={{ color: "rgb(var(--accent-primary))" }}
|
||
>
|
||
{temperature.toFixed(1)}
|
||
</span>
|
||
</div>
|
||
<input
|
||
id="temperature-slider"
|
||
type="range"
|
||
min={0}
|
||
max={2}
|
||
step={0.1}
|
||
value={temperature}
|
||
onChange={(e) => {
|
||
handleTemperatureChange(parseFloat(e.target.value));
|
||
}}
|
||
className="w-full h-1.5 rounded-full appearance-none cursor-pointer"
|
||
style={{
|
||
accentColor: "rgb(var(--accent-primary))",
|
||
backgroundColor: "rgb(var(--surface-2))",
|
||
}}
|
||
aria-label={`Temperature: ${temperature.toFixed(1)}`}
|
||
/>
|
||
<div
|
||
className="mt-1 flex justify-between text-[10px]"
|
||
style={{ color: "rgb(var(--text-muted))" }}
|
||
>
|
||
<span>Precise</span>
|
||
<span>Creative</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Max Tokens */}
|
||
<div>
|
||
<label
|
||
className="mb-1.5 block text-xs font-medium"
|
||
style={{ color: "rgb(var(--text-secondary))" }}
|
||
htmlFor="max-tokens-input"
|
||
>
|
||
Max Tokens
|
||
</label>
|
||
<input
|
||
id="max-tokens-input"
|
||
type="number"
|
||
min={100}
|
||
max={32000}
|
||
step={100}
|
||
value={maxTokens}
|
||
onChange={(e) => {
|
||
const val = parseInt(e.target.value, 10);
|
||
if (!isNaN(val) && val >= 100 && val <= 32000) {
|
||
handleMaxTokensChange(val);
|
||
}
|
||
}}
|
||
className="w-full rounded-md border px-2.5 py-1.5 text-xs outline-none focus:ring-2"
|
||
style={{
|
||
backgroundColor: "rgb(var(--surface-1))",
|
||
borderColor: "rgb(var(--border-default))",
|
||
color: "rgb(var(--text-primary))",
|
||
}}
|
||
aria-label="Maximum tokens"
|
||
/>
|
||
<p className="mt-1 text-[10px]" style={{ color: "rgb(var(--text-muted))" }}>
|
||
100 – 32,000
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Input Container */}
|
||
<div
|
||
className="relative rounded-lg border transition-all duration-150"
|
||
style={{
|
||
backgroundColor: "rgb(var(--surface-0))",
|
||
borderColor:
|
||
isInputDisabled || isStreaming
|
||
? "rgb(var(--border-default))"
|
||
: "rgb(var(--border-strong))",
|
||
}}
|
||
>
|
||
<textarea
|
||
ref={inputRef}
|
||
value={message}
|
||
onChange={(e) => {
|
||
setMessage(e.target.value);
|
||
}}
|
||
onKeyDown={handleKeyDown}
|
||
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={{
|
||
color: "rgb(var(--text-primary))",
|
||
minHeight: "48px",
|
||
maxHeight: "200px",
|
||
}}
|
||
onInput={(e): void => {
|
||
const target = e.target as HTMLTextAreaElement;
|
||
target.style.height = "auto";
|
||
target.style.height = `${String(Math.min(target.scrollHeight, 200))}px`;
|
||
}}
|
||
aria-label="Message input"
|
||
aria-describedby="input-help"
|
||
/>
|
||
|
||
{/* Send / Stop Button */}
|
||
<div className="absolute bottom-2 right-2 flex items-center gap-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"
|
||
>
|
||
<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>
|
||
|
||
{/* Footer: Keyboard shortcuts + character count */}
|
||
<div
|
||
className="flex items-center justify-between text-xs"
|
||
style={{ color: "rgb(var(--text-muted))" }}
|
||
id="input-help"
|
||
>
|
||
<div className="hidden items-center gap-4 sm:flex">
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="kbd">Enter</span>
|
||
<span>to send</span>
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="kbd">Shift</span>
|
||
<span>+</span>
|
||
<span className="kbd">Enter</span>
|
||
<span>for new line</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="sm:hidden">Tap send or press Enter</div>
|
||
|
||
<div
|
||
className="flex items-center gap-2"
|
||
style={{
|
||
color: isOverLimit
|
||
? "rgb(var(--semantic-error))"
|
||
: isNearLimit
|
||
? "rgb(var(--semantic-warning))"
|
||
: "rgb(var(--text-muted))",
|
||
}}
|
||
>
|
||
{characterCount > 0 && (
|
||
<>
|
||
<span>
|
||
{characterCount.toLocaleString()}/{maxCharacters.toLocaleString()}
|
||
</span>
|
||
{isOverLimit && (
|
||
<svg
|
||
className="h-3.5 w-3.5"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
strokeWidth={2}
|
||
>
|
||
<circle cx="12" cy="12" r="10" />
|
||
<line x1="12" y1="8" x2="12" y2="12" />
|
||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||
</svg>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* AI Disclaimer & Version */}
|
||
<div
|
||
className="flex items-center justify-center gap-2 text-xs"
|
||
style={{ color: "rgb(var(--text-muted))" }}
|
||
>
|
||
<span>AI may produce inaccurate information. Verify important details.</span>
|
||
{version && (
|
||
<>
|
||
<span>·</span>
|
||
<span>v{version}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|