mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-23 06:49:24 +01:00
* chore: shadcn config * feat: add shadcn ui config * style: adjust styles * style: adjust styles * refactor: update style * style: adjust styles * style: adjust styles * style: adjust styles * style: adjust styles * refactor: update * refactor: update * refactor: update * refactor: update * style: adjust styles * style: adjust styles * refactor: update * refactor: update * refactor: update * refactor: update * refactor: update * refactor: update * style: web styles * refactor: update * style: web styles * style: web styles * refactor: update * refactor: update * refactor: update * chhore: add * chore: add * refactor: update * refactor: update * refactor: update * refactor: update * chore: update * refactor: update * refactor: update * refactor: update * refactor: update * refactor: update * refactor: update * chore: rename * refactor: update * refactor: update * chore: add * refactor: update * chore: update * chroe: up * refactor: update * refactor: update * chore: up * refactor: update * chore: up * feat: support for extracting css variables * chore: update * fix: fixed dark mode * refactor: update * refactor: update * refactor: update * refactor: update * docs: update release notes * style: adjust styles * style: adjust styles * refactor: update * refactor: update * refactor: update * refactor: update * refactor: update * refactor: update --------- Co-authored-by: ayang <473033518@qq.com>
292 lines
9.0 KiB
TypeScript
292 lines
9.0 KiB
TypeScript
import { useState, useRef, useCallback, useEffect } from "react";
|
||
import { ChevronDownIcon, RefreshCw } from "lucide-react";
|
||
import { useTranslation } from "react-i18next";
|
||
import {
|
||
Popover,
|
||
PopoverTrigger,
|
||
PopoverContent,
|
||
} from "@/components/ui/popover";
|
||
import { useDebounce, useKeyPress, usePagination } from "ahooks";
|
||
import clsx from "clsx";
|
||
|
||
import logoImg from "@/assets/icon.svg";
|
||
import VisibleKey from "@/components/Common/VisibleKey";
|
||
import { useConnectStore } from "@/stores/connectStore";
|
||
import FontIcon from "@/components/Common/Icons/FontIcon";
|
||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||
import NoDataImage from "@/components/Common/NoDataImage";
|
||
import PopoverInput from "@/components/Common/PopoverInput";
|
||
import { AssistantFetcher } from "./AssistantFetcher";
|
||
import AssistantItem from "./AssistantItem";
|
||
import Pagination from "@/components/Common/Pagination";
|
||
import { useSearchStore } from "@/stores/searchStore";
|
||
import { Button } from "../ui/button";
|
||
|
||
interface AssistantListProps {
|
||
assistantIDs?: string[];
|
||
}
|
||
|
||
export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
|
||
const { t } = useTranslation();
|
||
|
||
const currentService = useConnectStore((state) => state.currentService);
|
||
const currentAssistant = useConnectStore((state) => state.currentAssistant);
|
||
const setCurrentAssistant = useConnectStore((state) => {
|
||
return state.setCurrentAssistant;
|
||
});
|
||
const assistantList = useConnectStore((state) => state.assistantList);
|
||
|
||
const aiAssistant = useShortcutsStore((state) => state.aiAssistant);
|
||
|
||
const [assistants, setAssistants] = useState<any[]>([]);
|
||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||
const popoverRef = useRef<HTMLDivElement>(null);
|
||
const popoverButtonRef = useRef<HTMLButtonElement>(null);
|
||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||
const [keyword, setKeyword] = useState("");
|
||
|
||
const debounceKeyword = useDebounce(keyword, { wait: 500 });
|
||
|
||
const askAiAssistantId = useSearchStore((state) => state.askAiAssistantId);
|
||
const setAskAiAssistantId = useSearchStore((state) => {
|
||
return state.setAskAiAssistantId;
|
||
});
|
||
const targetAssistantId = useSearchStore((state) => state.targetAssistantId);
|
||
const setTargetAssistantId = useSearchStore((state) => {
|
||
return state.setTargetAssistantId;
|
||
});
|
||
|
||
const { fetchAssistant } = AssistantFetcher({
|
||
debounceKeyword,
|
||
assistantIDs,
|
||
});
|
||
|
||
const getAssistants = (params: { current: number; pageSize: number }) => {
|
||
return fetchAssistant(params);
|
||
};
|
||
|
||
const { pagination, runAsync } = usePagination(getAssistants, {
|
||
defaultPageSize: 5,
|
||
refreshDeps: [currentService?.id, debounceKeyword, currentService?.enabled],
|
||
onSuccess(data) {
|
||
setAssistants(data.list);
|
||
|
||
if (data.list.length === 0) {
|
||
setCurrentAssistant(void 0);
|
||
}
|
||
},
|
||
});
|
||
|
||
const handleRefresh = async () => {
|
||
setIsRefreshing(true);
|
||
|
||
await runAsync({ current: 1, pageSize: 5 });
|
||
|
||
setTimeout(() => setIsRefreshing(false), 1000);
|
||
};
|
||
|
||
const [highlightIndex, setHighlightIndex] = useState<number>(-1);
|
||
const [isKeyboardActive, setIsKeyboardActive] = useState(false);
|
||
const [open, setOpen] = useState(false);
|
||
|
||
useEffect(() => {
|
||
const targetId = askAiAssistantId ?? targetAssistantId;
|
||
if (!targetId || assistantList.length === 0) return;
|
||
|
||
const matched = assistantList.find((item) => item._id === targetId);
|
||
if (!matched) return;
|
||
|
||
if (currentAssistant?._id !== matched._id) {
|
||
setCurrentAssistant(matched);
|
||
}
|
||
|
||
if (askAiAssistantId) {
|
||
setAskAiAssistantId(void 0);
|
||
} else if (targetAssistantId) {
|
||
setTargetAssistantId(void 0);
|
||
}
|
||
}, [assistantList, askAiAssistantId, targetAssistantId]);
|
||
|
||
useKeyPress(
|
||
["uparrow", "downarrow", "enter"],
|
||
(event, key) => {
|
||
const isClose = !open;
|
||
|
||
if (isClose) return;
|
||
|
||
event.stopPropagation();
|
||
event.preventDefault();
|
||
|
||
setIsKeyboardActive(true);
|
||
|
||
const index = assistants.findIndex(
|
||
(item) => item._id === currentAssistant?._id
|
||
);
|
||
const length = assistants.length;
|
||
|
||
if (length <= 1) return;
|
||
|
||
let nextIndex = highlightIndex === -1 ? index : highlightIndex;
|
||
|
||
if (key === "uparrow") {
|
||
nextIndex = nextIndex > 0 ? nextIndex - 1 : length - 1;
|
||
} else if (key === "downarrow") {
|
||
nextIndex = nextIndex < length - 1 ? nextIndex + 1 : 0;
|
||
}
|
||
|
||
if (key === "enter") {
|
||
setCurrentAssistant(assistants[nextIndex]);
|
||
return popoverButtonRef.current?.click();
|
||
}
|
||
|
||
setHighlightIndex(nextIndex);
|
||
},
|
||
{
|
||
target: popoverRef,
|
||
}
|
||
);
|
||
|
||
const handlePrev = useCallback(() => {
|
||
if (pagination.current <= 1) return;
|
||
|
||
pagination.changeCurrent(pagination.current - 1);
|
||
}, [pagination]);
|
||
|
||
const handleNext = useCallback(() => {
|
||
if (pagination.current >= pagination.totalPage) {
|
||
return;
|
||
}
|
||
|
||
pagination.changeCurrent(pagination.current + 1);
|
||
}, [pagination]);
|
||
|
||
const handleMouseMove = useCallback(() => {
|
||
setHighlightIndex(-1);
|
||
setIsKeyboardActive(false);
|
||
}, []);
|
||
|
||
return (
|
||
<div ref={popoverRef} className="relative">
|
||
<Popover
|
||
open={open}
|
||
onOpenChange={(v) => {
|
||
setOpen(v);
|
||
}}
|
||
>
|
||
<PopoverTrigger
|
||
ref={popoverButtonRef}
|
||
className="h-6 p-1 px-1.5 flex items-center gap-1 rounded-full border border-input bg-background text-sm/6 font-semibold text-foreground hover:bg-accent hover:text-accent-foreground focus:outline-none"
|
||
>
|
||
{currentAssistant?._source?.icon?.startsWith("font_") ? (
|
||
<FontIcon
|
||
name={currentAssistant._source.icon}
|
||
className="w-4 h-4"
|
||
/>
|
||
) : (
|
||
<img
|
||
src={logoImg}
|
||
className="w-4 h-4"
|
||
alt={t("assistant.message.logo")}
|
||
/>
|
||
)}
|
||
<div className="max-w-[100px] truncate">
|
||
{currentAssistant?._source?.name || "Coco AI"}
|
||
</div>
|
||
<VisibleKey
|
||
shortcut={aiAssistant}
|
||
onKeyPress={() => {
|
||
popoverButtonRef.current?.click();
|
||
}}
|
||
>
|
||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform" />
|
||
</VisibleKey>
|
||
</PopoverTrigger>
|
||
|
||
<PopoverContent
|
||
side="bottom"
|
||
align="start"
|
||
className="z-50 w-60 rounded-xl p-3 shadow-lg focus:outline-none max-h-[calc(100vh-150px)] overflow-y-auto"
|
||
onMouseMove={handleMouseMove}
|
||
>
|
||
<div className="flex items-center justify-between text-sm font-bold">
|
||
<div>
|
||
{t("assistant.popover.title")}({pagination.total})
|
||
</div>
|
||
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
onClick={handleRefresh}
|
||
className="size-6"
|
||
disabled={isRefreshing}
|
||
>
|
||
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
|
||
<RefreshCw
|
||
className={clsx(
|
||
"size-3 text-[#0287FF] transition-transform duration-1000",
|
||
{
|
||
"animate-spin": isRefreshing,
|
||
}
|
||
)}
|
||
/>
|
||
</VisibleKey>
|
||
</Button>
|
||
</div>
|
||
|
||
<VisibleKey
|
||
shortcut="F"
|
||
rootClassName="w-full my-3"
|
||
shortcutClassName="left-4"
|
||
onKeyPress={() => {
|
||
searchInputRef.current?.focus();
|
||
}}
|
||
>
|
||
<PopoverInput
|
||
ref={searchInputRef}
|
||
autoFocus
|
||
value={keyword}
|
||
placeholder={t("assistant.popover.search")}
|
||
className="w-full h-8"
|
||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
||
setKeyword(event.target.value);
|
||
}}
|
||
/>
|
||
</VisibleKey>
|
||
|
||
{assistants.length > 0 ? (
|
||
<>
|
||
{assistants.map((assistant, index) => {
|
||
return (
|
||
<AssistantItem
|
||
key={assistant._id}
|
||
{...assistant}
|
||
isActive={currentAssistant?._id === assistant._id}
|
||
isHighlight={highlightIndex === index}
|
||
isKeyboardActive={isKeyboardActive}
|
||
onClick={() => {
|
||
setCurrentAssistant(assistant);
|
||
popoverButtonRef.current?.click();
|
||
}}
|
||
/>
|
||
);
|
||
})}
|
||
|
||
<Pagination
|
||
current={pagination.current}
|
||
totalPage={pagination.totalPage}
|
||
onPrev={handlePrev}
|
||
onNext={handleNext}
|
||
className="-mx-3 -mb-3"
|
||
/>
|
||
</>
|
||
) : (
|
||
<div className="flex justify-center items-center py-2">
|
||
<NoDataImage />
|
||
</div>
|
||
)}
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
);
|
||
}
|