feat(web): polish master chat with model selector, params config, and empty state (#519)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
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>
This commit was merged in pull request #519.
This commit is contained in:
@@ -1,7 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import type { KeyboardEvent, RefObject } from "react";
|
||||
import { useCallback, useState, useEffect } 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;
|
||||
@@ -9,6 +68,11 @@ interface ChatInputProps {
|
||||
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({
|
||||
@@ -17,9 +81,52 @@ export function ChatInput({
|
||||
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 {
|
||||
@@ -40,6 +147,22 @@ export function ChatInput({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 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);
|
||||
@@ -65,6 +188,49 @@ export function ChatInput({
|
||||
[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;
|
||||
@@ -72,7 +238,230 @@ export function ChatInput({
|
||||
const isInputDisabled = disabled ?? false;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<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"
|
||||
|
||||
Reference in New Issue
Block a user