This commit is contained in:
2026-07-29 11:42:41 +08:00
commit 48b7391365
71 changed files with 28910 additions and 0 deletions

View 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>
);
}