SurfSense/surfsense_web/components/new-chat/prompt-picker.tsx

225 lines
7.8 KiB
TypeScript

"use client";
import {
BookOpen,
Check,
Globe,
Languages,
List,
Minimize2,
PenLine,
Search,
Zap,
Plus,
} from "lucide-react";
import { useSetAtom } from "jotai";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import type { PromptRead } from "@/contracts/types/prompts.types";
import { promptsApiService } from "@/lib/apis/prompts-api.service";
import { cn } from "@/lib/utils";
export interface PromptPickerRef {
selectHighlighted: () => void;
moveUp: () => void;
moveDown: () => void;
}
interface PromptPickerProps {
onSelect: (action: { name: string; prompt: string; mode: "transform" | "explore" }) => void;
onDone: () => void;
externalSearch?: string;
containerStyle?: React.CSSProperties;
}
const ICONS: Record<string, React.ReactNode> = {
check: <Check className="size-3.5" />,
minimize: <Minimize2 className="size-3.5" />,
languages: <Languages className="size-3.5" />,
"pen-line": <PenLine className="size-3.5" />,
"book-open": <BookOpen className="size-3.5" />,
list: <List className="size-3.5" />,
search: <Search className="size-3.5" />,
globe: <Globe className="size-3.5" />,
zap: <Zap className="size-3.5" />,
};
const DEFAULT_ACTIONS: { name: string; prompt: string; mode: "transform" | "explore"; icon: string }[] = [
{ name: "Fix grammar", prompt: "Fix the grammar and spelling in the following text. Return only the corrected text, nothing else.\n\n{selection}", mode: "transform", icon: "check" },
{ name: "Make shorter", prompt: "Make the following text more concise while preserving its meaning. Return only the shortened text, nothing else.\n\n{selection}", mode: "transform", icon: "minimize" },
{ name: "Translate", prompt: "Translate the following text to English. If it is already in English, translate it to French. Return only the translation, nothing else.\n\n{selection}", mode: "transform", icon: "languages" },
{ name: "Rewrite", prompt: "Rewrite the following text to improve clarity and readability. Return only the rewritten text, nothing else.\n\n{selection}", mode: "transform", icon: "pen-line" },
{ name: "Summarize", prompt: "Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}", mode: "transform", icon: "list" },
{ name: "Explain", prompt: "Explain the following text in simple terms:\n\n{selection}", mode: "explore", icon: "book-open" },
{ name: "Ask my knowledge base", prompt: "Search my knowledge base for information related to:\n\n{selection}", mode: "explore", icon: "search" },
{ name: "Look up on the web", prompt: "Search the web for information about:\n\n{selection}", mode: "explore", icon: "globe" },
];
export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(
function PromptPicker({ onSelect, onDone, externalSearch = "", containerStyle }, ref) {
const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const [customPrompts, setCustomPrompts] = useState<PromptRead[]>([]);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const shouldScrollRef = useRef(false);
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
useEffect(() => {
promptsApiService.list().then(setCustomPrompts).catch(() => {});
}, []);
const allActions = useMemo(() => {
const customs = customPrompts.map((a) => ({
name: a.name,
prompt: a.prompt,
mode: a.mode as "transform" | "explore",
icon: a.icon || "zap",
}));
return [...DEFAULT_ACTIONS, ...customs];
}, [customPrompts]);
const filtered = useMemo(() => {
if (!externalSearch) return allActions;
return allActions.filter((a) =>
a.name.toLowerCase().includes(externalSearch.toLowerCase())
);
}, [allActions, externalSearch]);
// Reset highlight when results change
const prevSearchRef = useRef(externalSearch);
if (prevSearchRef.current !== externalSearch) {
prevSearchRef.current = externalSearch;
if (highlightedIndex !== 0) {
setHighlightedIndex(0);
}
}
const handleSelect = useCallback(
(index: number) => {
const action = filtered[index];
if (!action) return;
onSelect({ name: action.name, prompt: action.prompt, mode: action.mode });
},
[filtered, onSelect]
);
// Auto-scroll highlighted item into view
useEffect(() => {
if (!shouldScrollRef.current) return;
shouldScrollRef.current = false;
const rafId = requestAnimationFrame(() => {
const item = itemRefs.current.get(highlightedIndex);
const container = scrollContainerRef.current;
if (item && container) {
const itemRect = item.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) {
item.scrollIntoView({ block: "nearest" });
}
}
});
return () => cancelAnimationFrame(rafId);
}, [highlightedIndex]);
useImperativeHandle(
ref,
() => ({
selectHighlighted: () => handleSelect(highlightedIndex),
moveUp: () => {
shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
},
moveDown: () => {
shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
},
}),
[filtered.length, highlightedIndex, handleSelect]
);
if (filtered.length === 0) return null;
const defaultFiltered = filtered.filter((_, i) => i < DEFAULT_ACTIONS.length);
const customFiltered = filtered.filter((_, i) => i >= DEFAULT_ACTIONS.length);
return (
<div
className="w-64 rounded-lg border bg-popover shadow-lg overflow-hidden"
style={containerStyle}
>
<div ref={scrollContainerRef} className="max-h-48 overflow-y-auto py-1">
{defaultFiltered.map((action, index) => (
<button
key={action.name}
ref={(el) => {
if (el) itemRefs.current.set(index, el);
else itemRefs.current.delete(index);
}}
type="button"
onClick={() => handleSelect(index)}
onMouseEnter={() => setHighlightedIndex(index)}
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-sm cursor-pointer",
index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
)}
>
<span className="text-muted-foreground">{ICONS[action.icon] ?? <Zap className="size-3.5" />}</span>
<span className="truncate">{action.name}</span>
</button>
))}
{customFiltered.length > 0 && (
<div className="my-1 h-px bg-border mx-2" />
)}
{customFiltered.map((action, i) => {
const index = defaultFiltered.length + i;
return (
<button
key={action.name}
ref={(el) => {
if (el) itemRefs.current.set(index, el);
else itemRefs.current.delete(index);
}}
type="button"
onClick={() => handleSelect(index)}
onMouseEnter={() => setHighlightedIndex(index)}
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-sm cursor-pointer",
index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
)}
>
<span className="text-muted-foreground"><Zap className="size-3.5" /></span>
<span className="truncate">{action.name}</span>
</button>
);
})}
<div className="my-1 h-px bg-border mx-2" />
<button
type="button"
onClick={() => {
onDone();
setUserSettingsDialog({ open: true, initialTab: "prompts" });
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-accent/50 cursor-pointer"
>
<Plus className="size-3.5" />
<span>Create prompt</span>
</button>
</div>
</div>
);
}
);