demo
This commit is contained in:
335
src/components/chat-composer.tsx
Normal file
335
src/components/chat-composer.tsx
Normal file
@ -0,0 +1,335 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user