"use client"; import { ArrowUpDown, ArrowUp, ChevronsRight, Command, CornerDownLeft, LoaderCircle, Plus, Square, X, } from "lucide-react"; import { ComposerPrimitive, useAui, useAuiState, } from "@assistant-ui/react"; import { Fragment, useId, useMemo, useRef, useState, type KeyboardEvent, } from "react"; import type { Skill } from "@/lib/types"; import { cn } from "@/lib/utils"; import { SkillTag } from "@/components/skill-tag"; export function ChatComposer({ skills, selectedIds, resolvedSkillIds, activeSkillId, completedSkillIds, onSelectedChange, onOpenSkillPicker, isStreaming, isStopping, }: { skills: Skill[]; selectedIds: string[]; resolvedSkillIds: string[] | null; activeSkillId: string | null; completedSkillIds: string[]; onSelectedChange: (ids: string[]) => void; onOpenSkillPicker: () => void; isStreaming: boolean; isStopping: boolean; }) { const aui = useAui(); const value = useAuiState((state) => state.composer.text); const textareaRef = useRef(null); const listboxId = useId(); const [activeSuggestionId, setActiveSuggestionId] = useState( null, ); const slashMatch = value.match(/(?:^|\s)\/([^\s/]*)$/); const slashQuery = slashMatch?.[1]?.toLowerCase() ?? null; const availableSuggestions = useMemo(() => { if (slashQuery === null) return []; return skills .filter((skill) => !selectedIds.includes(skill.id)) .filter((skill) => `${skill.name} ${skill.description}` .toLowerCase() .includes(slashQuery), ) .slice(0, 6); }, [skills, selectedIds, slashQuery]); const matchedSuggestionIndex = availableSuggestions.findIndex( (skill) => skill.id === activeSuggestionId, ); const activeSuggestionIndex = matchedSuggestionIndex === -1 ? 0 : matchedSuggestionIndex; const selectedSkills = selectedIds .map((id) => skills.find((skill) => skill.id === id)) .filter((skill): skill is Skill => Boolean(skill)); const routeResolved = isStreaming && resolvedSkillIds !== null; function handleKeyDown(event: KeyboardEvent) { if (!slashMatch || event.nativeEvent.isComposing) return; if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); closeSlashMenu(); return; } if (availableSuggestions.length === 0) return; if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); event.stopPropagation(); const direction = event.key === "ArrowDown" ? 1 : -1; const nextIndex = (activeSuggestionIndex + direction + availableSuggestions.length) % availableSuggestions.length; setActiveSuggestionId(availableSuggestions[nextIndex].id); return; } if ( (event.key === "Enter" && !event.shiftKey) || event.key === "Tab" ) { event.preventDefault(); event.stopPropagation(); addSkill( availableSuggestions[ Math.min(activeSuggestionIndex, availableSuggestions.length - 1) ], ); } } function closeSlashMenu() { setActiveSuggestionId(null); aui.composer().setText(value.replace(/(?:^|\s)\/([^\s/]*)$/, (match) => match.startsWith(" ") ? " " : "", )); requestAnimationFrame(() => textareaRef.current?.focus()); } function addSkill(skill: Skill) { setActiveSuggestionId(null); onSelectedChange([...selectedIds, skill.id]); aui.composer().setText( value.replace(/(?:^|\s)\/([^\s/]*)$/, (match) => match.startsWith(" ") ? " " : "", ), ); requestAnimationFrame(() => textareaRef.current?.focus()); } return ( {slashMatch && (
插入 Skill 移动 · 选择
{availableSuggestions.length > 0 ? (
{availableSuggestions.map((skill, index) => ( ))}
) : (
没有更多可添加的 Skill
)}
)}
{selectedSkills.length > 0 && (
{selectedSkills.map((skill, index) => { const status = !isStreaming ? "selected" : !routeResolved ? "checking" : !resolvedSkillIds.includes(skill.id) ? "unused" : activeSkillId === skill.id ? "running" : completedSkillIds.includes(skill.id) ? "completed" : "queued"; return ( {index > 0 && ( ); })}
)} 0 ? `${listboxId}-${ availableSuggestions[ Math.min( activeSuggestionIndex, availableSuggestions.length - 1, ) ].id }` : undefined } rows={1} maxRows={7} placeholder="说说你想解决什么,输入 / 添加 Skill…" className="block min-h-[68px] w-full resize-none bg-transparent px-4 pb-1.5 pt-3.5 text-[15px] leading-6 text-foreground outline-none placeholder:text-muted-foreground/70" aria-label="聊天输入" />
Enter 发送 {isStreaming ? ( {isStopping ? ( ) : ( )} ) : ( )}
AI 可能犯错,请核对重要信息 {slashMatch && ( <> · )}
); }