add slash command
This commit is contained in:
@@ -14,6 +14,7 @@ import rehypeRaw from 'rehype-raw';
|
|||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import { InlineVisualizationCard } from "./InlineVisualizationCard";
|
import { InlineVisualizationCard } from "./InlineVisualizationCard";
|
||||||
import { useProjectStore } from "@/store/projectStore";
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
|
import { SlashCommandMenu } from "./SlashCommandMenu";
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -81,6 +82,74 @@ export function ChatInterface() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { currentProject } = useProjectStore();
|
const { currentProject } = useProjectStore();
|
||||||
|
|
||||||
|
// Slash Command State
|
||||||
|
const [slashQuery, setSlashQuery] = useState<string | null>(null);
|
||||||
|
const [slashIndex, setSlashIndex] = useState(0);
|
||||||
|
|
||||||
|
const filteredSlashSkills = slashQuery !== null
|
||||||
|
? availableSkills.filter(s => s.name.toLowerCase().includes(slashQuery.toLowerCase()))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const handleSelectSlashSkill = (skill: Skill) => {
|
||||||
|
if (!selectedSkillIds.includes(skill.id)) {
|
||||||
|
setSelectedSkillIds(prev => [...prev, skill.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the slash command from input
|
||||||
|
// Match the last occurrence of /query
|
||||||
|
const match = input.match(/(?:^|\s)\/([a-zA-Z0-9_\-]*)$/);
|
||||||
|
if (match && match.index !== undefined) {
|
||||||
|
// match[0] includes the leading space if present
|
||||||
|
const prefix = input.slice(0, match.index);
|
||||||
|
const suffix = input.slice(match.index + match[0].length);
|
||||||
|
setInput((prefix + suffix).trim());
|
||||||
|
}
|
||||||
|
setSlashQuery(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (slashQuery !== null && filteredSlashSkills.length > 0) {
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSlashIndex(prev => Math.max(0, prev - 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSlashIndex(prev => Math.min(filteredSlashSkills.length - 1, prev + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelectSlashSkill(filteredSlashSkills[slashIndex]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSlashQuery(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Enter' && !isLoading) {
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setInput(val);
|
||||||
|
|
||||||
|
// Simple slash detection: if the last word starts with /
|
||||||
|
const match = val.match(/(?:^|\s)\/([a-zA-Z0-9_\-]*)$/);
|
||||||
|
if (match) {
|
||||||
|
setSlashQuery(match[1]);
|
||||||
|
setSlashIndex(0);
|
||||||
|
} else {
|
||||||
|
setSlashQuery(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const setMessagesForSession = (sessionKey: string, updater: React.SetStateAction<Message[]>) => {
|
const setMessagesForSession = (sessionKey: string, updater: React.SetStateAction<Message[]>) => {
|
||||||
setMessagesBySession(prev => {
|
setMessagesBySession(prev => {
|
||||||
const current = prev[sessionKey] || [];
|
const current = prev[sessionKey] || [];
|
||||||
@@ -787,12 +856,19 @@ export function ChatInterface() {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={handleInputChange}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && !isLoading && handleSend()}
|
onKeyDown={handleInputKeyDown}
|
||||||
placeholder="有问题,尽管问"
|
placeholder="有问题,尽管问"
|
||||||
className="flex-1 bg-transparent border-none focus:ring-0 text-lg px-3 py-2 text-zinc-900 placeholder:text-zinc-300 outline-none"
|
className="flex-1 bg-transparent border-none focus:ring-0 text-lg px-3 py-2 text-zinc-900 placeholder:text-zinc-300 outline-none"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
|
<SlashCommandMenu
|
||||||
|
isOpen={slashQuery !== null}
|
||||||
|
skills={filteredSlashSkills}
|
||||||
|
selectedIndex={slashIndex}
|
||||||
|
onSelect={handleSelectSlashSkill}
|
||||||
|
onClose={() => setSlashQuery(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
@@ -1019,12 +1095,19 @@ export function ChatInterface() {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={handleInputChange}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && !isLoading && handleSend()}
|
onKeyDown={handleInputKeyDown}
|
||||||
placeholder="有问题,尽管问"
|
placeholder="有问题,尽管问"
|
||||||
className="flex-1 bg-transparent border-none focus:ring-0 text-lg px-3 py-2 text-zinc-900 placeholder:text-zinc-300 outline-none"
|
className="flex-1 bg-transparent border-none focus:ring-0 text-lg px-3 py-2 text-zinc-900 placeholder:text-zinc-300 outline-none"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
|
<SlashCommandMenu
|
||||||
|
isOpen={slashQuery !== null}
|
||||||
|
skills={filteredSlashSkills}
|
||||||
|
selectedIndex={slashIndex}
|
||||||
|
onSelect={handleSelectSlashSkill}
|
||||||
|
onClose={() => setSlashQuery(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface Skill {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SlashCommandMenuProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
skills: Skill[];
|
||||||
|
selectedIndex: number;
|
||||||
|
onSelect: (skill: Skill) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SlashCommandMenu({ isOpen, skills, selectedIndex, onSelect, onClose }: SlashCommandMenuProps) {
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const selectedRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && selectedRef.current) {
|
||||||
|
selectedRef.current.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
}, [isOpen, selectedIndex]);
|
||||||
|
|
||||||
|
// Click outside to close
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen || skills.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="absolute bottom-full left-0 mb-2 w-full max-w-md overflow-hidden rounded-xl border border-zinc-800 bg-zinc-950 shadow-2xl animate-in fade-in slide-in-from-bottom-2 duration-100 z-50"
|
||||||
|
>
|
||||||
|
<div className="max-h-[240px] overflow-y-auto py-1.5 custom-scrollbar">
|
||||||
|
{skills.map((skill, index) => (
|
||||||
|
<button
|
||||||
|
key={skill.id}
|
||||||
|
ref={index === selectedIndex ? selectedRef : null}
|
||||||
|
onClick={() => onSelect(skill)}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-3 px-3 py-2.5 text-left text-sm transition-colors",
|
||||||
|
index === selectedIndex ? "bg-zinc-800" : "hover:bg-zinc-900"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-bold text-blue-400 shrink-0 font-mono">/{skill.name}</span>
|
||||||
|
<span className="text-zinc-400 truncate text-xs">{skill.description || "无描述"}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user