346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
"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<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)
|
||
? "unused"
|
||
: activeSkillId === skill.id
|
||
? "running"
|
||
: completedSkillIds.includes(skill.id)
|
||
? "completed"
|
||
: "queued";
|
||
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 &&
|
||
completedSkillIds.includes(skill.id) &&
|
||
"text-emerald-500/80",
|
||
activeSkillId === skill.id &&
|
||
"animate-pulse text-blue-500/90",
|
||
)}
|
||
/>
|
||
)}
|
||
<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>
|
||
);
|
||
}
|