Files
ai-skill-chat/src/components/chat-composer.tsx
2026-07-29 11:42:41 +08:00

336 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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,
onSelectedChange,
onOpenSkillPicker,
isStreaming,
isStopping,
}: {
skills: Skill[];
selectedIds: string[];
resolvedSkillIds: string[] | null;
onSelectedChange: (ids: string[]) => void;
onOpenSkillPicker: () => void;
isStreaming: boolean;
isStopping: boolean;
}) {
const aui = useAui();
const value = useAuiState((state) => state.composer.text);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const listboxId = useId();
const [activeSuggestionId, setActiveSuggestionId] = useState<string | null>(
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<HTMLTextAreaElement>) {
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 (
<ComposerPrimitive.Root className="relative">
{slashMatch && (
<div
id={listboxId}
role="listbox"
aria-label="可添加的 Skill"
className="absolute bottom-[calc(100%+10px)] left-0 z-20 w-[min(440px,calc(100vw-32px))] overflow-hidden rounded-xl border border-border bg-popover p-1.5 text-popover-foreground shadow-lg"
>
<div className="flex items-center justify-between px-2 pb-2 pt-1">
<span className="text-[11px] font-medium text-muted-foreground">
Skill
</span>
<span className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<ArrowUpDown className="size-3" />
<span className="text-muted-foreground/45">·</span>
<CornerDownLeft className="size-3" />
</span>
</div>
{availableSuggestions.length > 0 ? (
<div className="space-y-1">
{availableSuggestions.map((skill, index) => (
<button
key={skill.id}
id={`${listboxId}-${skill.id}`}
type="button"
role="option"
aria-selected={index === activeSuggestionIndex}
className={cn(
"flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left hover:bg-accent",
index === activeSuggestionIndex && "bg-accent/70",
)}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => setActiveSuggestionId(skill.id)}
onClick={() => addSkill(skill)}
>
<span className="grid size-7 place-items-center rounded-md bg-muted font-mono text-[10px] font-bold text-foreground">
/
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-foreground">
{skill.name}
</span>
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
{skill.description}
</span>
</span>
</button>
))}
</div>
) : (
<div className="rounded-lg bg-muted/50 px-3 py-4 text-center text-xs text-muted-foreground">
Skill
</div>
)}
</div>
)}
<div className="overflow-hidden rounded-3xl border border-border/80 bg-muted/35 shadow-[0_1px_2px_rgba(0,0,0,0.03),0_8px_24px_rgba(0,0,0,0.04)] transition-[border-color,box-shadow] focus-within:border-ring focus-within:shadow-[0_1px_2px_rgba(0,0,0,0.03),0_10px_30px_rgba(0,0,0,0.06)]">
{selectedSkills.length > 0 && (
<div className="px-3.5 pt-2.5">
<div className="flex flex-wrap items-center gap-1">
{selectedSkills.map((skill, index) => {
const status = !isStreaming
? "selected"
: !routeResolved
? "checking"
: resolvedSkillIds.includes(skill.id)
? "used"
: "unused";
return (
<Fragment key={skill.id}>
{index > 0 && (
<ChevronsRight
aria-hidden="true"
className={cn(
"mx-0.5 size-3.5 shrink-0 text-muted-foreground/35",
isStreaming &&
!routeResolved &&
"animate-pulse text-amber-500/70",
routeResolved &&
resolvedSkillIds?.includes(skill.id) &&
"text-emerald-500/80",
)}
/>
)}
<SkillTag
name={skill.name}
status={status}
showStatusLabel={false}
className="h-6 px-2 text-[10px]"
onRemove={
isStreaming
? undefined
: () =>
onSelectedChange(
selectedIds.filter((id) => id !== skill.id),
)
}
/>
</Fragment>
);
})}
</div>
</div>
)}
<ComposerPrimitive.Input
ref={textareaRef}
onKeyDown={handleKeyDown}
aria-autocomplete="list"
aria-controls={slashMatch ? listboxId : undefined}
aria-expanded={Boolean(slashMatch)}
aria-activedescendant={
slashMatch && availableSuggestions.length > 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="聊天输入"
/>
<div className="flex items-center justify-between px-3 pb-3">
<button
type="button"
onClick={onOpenSkillPicker}
disabled={isStreaming}
className="inline-flex h-8 items-center gap-2 rounded-full px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-55"
>
<span className="grid size-6 place-items-center rounded-full border border-border bg-background">
<Plus className="size-3.5" />
</span>
Skill
{selectedSkills.length > 0 && (
<span className="grid size-5 place-items-center rounded-full bg-primary text-[10px] text-primary-foreground">
{selectedSkills.length}
</span>
)}
</button>
<div className="flex items-center gap-3">
<span className="hidden items-center gap-1 text-[10px] text-muted-foreground/75 sm:flex">
<Command className="size-3" />
Enter
</span>
{isStreaming ? (
<ComposerPrimitive.Cancel
className="grid size-9 place-items-center rounded-full bg-destructive text-white transition-colors hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/30 focus-visible:ring-offset-2"
aria-label={
isStopping ? "正在中止本轮回答" : "中止本轮回答"
}
disabled={isStopping}
>
{isStopping ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Square className="size-4 fill-current" />
)}
</ComposerPrimitive.Cancel>
) : (
<ComposerPrimitive.Send
className="grid size-9 place-items-center rounded-full bg-primary text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:bg-muted-foreground/35"
aria-label="发送消息"
>
<ArrowUp className="size-4.5" strokeWidth={2.4} />
</ComposerPrimitive.Send>
)}
</div>
</div>
</div>
<div className="mt-2 flex items-center justify-center gap-1.5 text-[10px] text-muted-foreground/70">
<span>AI </span>
{slashMatch && (
<>
<span>·</span>
<button
type="button"
className="inline-flex items-center gap-1 hover:text-foreground"
onClick={closeSlashMenu}
>
<X className="size-3" /> Skill
</button>
</>
)}
</div>
</ComposerPrimitive.Root>
);
}