From 767ef31551119b622bcc6b8bf1aa22c9116e2c16 Mon Sep 17 00:00:00 2001 From: BHznJNs Date: Wed, 22 Jul 2026 00:13:44 +0800 Subject: [PATCH 1/3] backend skill search endpoint --- src-server/src/api/routes/skill.py | 11 +- src-server/src/services/skill.py | 11 +- .../tests/services/test_skill_service.py | 161 ++++++++++++++++++ 3 files changed, 177 insertions(+), 6 deletions(-) diff --git a/src-server/src/api/routes/skill.py b/src-server/src/api/routes/skill.py index 4bec0f32..9abbf39c 100644 --- a/src-server/src/api/routes/skill.py +++ b/src-server/src/api/routes/skill.py @@ -19,7 +19,7 @@ from dais_skills.scanner.exceptions import ( InvalidRepoUrlError as ScannerInvalidRepoUrlError, ) -from fastapi import APIRouter, BackgroundTasks, File, UploadFile, status +from fastapi import APIRouter, BackgroundTasks, File, Query, UploadFile, status from fastapi_pagination import Page from fastapi_pagination.ext.sqlalchemy import apaginate @@ -75,9 +75,12 @@ async def clear_and_rematerialize(skill: skill_schemas.SkillRead): background_tasks.add_task(clear_and_rematerialize, skill_data) @skills_router.get("/", response_model=Page[skill_schemas.SkillBrief]) -async def get_skills(db_session: DbSessionDep): - query = SkillService(db_session).get_skills_query() - return await apaginate(db_session, query) +async def get_skills( + db_session: DbSessionDep, + query: str | None = Query(default=None), +): + db_query = SkillService(db_session).get_skills_query(query) + return await apaginate(db_session, db_query) @skills_router.post( "/scan-repo", diff --git a/src-server/src/services/skill.py b/src-server/src/services/skill.py index a97a31e7..912b5062 100644 --- a/src-server/src/services/skill.py +++ b/src-server/src/services/skill.py @@ -24,12 +24,19 @@ def relations(): selectinload(skill_models.Skill.resources) ] - def get_skills_query(self): - return ( + def get_skills_query(self, query: str | None = None): + stmt = ( select(skill_models.Skill) .order_by(skill_models.Skill.id.asc()) .options(selectinload(skill_models.Skill.resources)) ) + if query: + search_term = f"%{query}%" + stmt = stmt.where( + skill_models.Skill.name.ilike(search_term) + | skill_models.Skill.description.ilike(search_term) + ) + return stmt async def get_all_skills(self) -> list[skill_models.Skill]: stmt = ( diff --git a/src-server/tests/services/test_skill_service.py b/src-server/tests/services/test_skill_service.py index 3d002cd1..416338a8 100644 --- a/src-server/tests/services/test_skill_service.py +++ b/src-server/tests/services/test_skill_service.py @@ -238,3 +238,164 @@ async def test_get_all_skills_orders_by_id_and_loads_resources( assert [len(skill.resources) for skill in skills] == [1, 1] assert skills[0].resources[0].relative == "a.md" assert skills[1].resources[0].relative == "b.md" + + @pytest.mark.asyncio + async def test_get_skills_query_without_query_returns_all_ordered_by_id( + self, + skill_service: SkillService, + db_session: AsyncSession, + ): + first = await skill_service.create_skill( + skill_schemas.SkillCreate( + name="Alpha Skill", + description="first skill", + is_enabled=True, + content="content-a", + resources=[], + ) + ) + second = await skill_service.create_skill( + skill_schemas.SkillCreate( + name="Beta Skill", + description="second skill", + is_enabled=True, + content="content-b", + resources=[], + ) + ) + + db_session.expunge_all() + + rows = await skill_service._db_session.scalars( + skill_service.get_skills_query() + ) + skills = list(rows.all()) + + assert [skill.id for skill in skills] == [first.id, second.id] + + @pytest.mark.asyncio + async def test_get_skills_query_filters_by_name_case_insensitive( + self, + skill_service: SkillService, + db_session: AsyncSession, + ): + matched = await skill_service.create_skill( + skill_schemas.SkillCreate( + name="Pytest Helper", + description="unrelated description", + is_enabled=True, + content="content", + resources=[], + ) + ) + await skill_service.create_skill( + skill_schemas.SkillCreate( + name="Other Skill", + description="nothing here", + is_enabled=True, + content="content", + resources=[], + ) + ) + + db_session.expunge_all() + + rows = await skill_service._db_session.scalars( + skill_service.get_skills_query("pytest") + ) + skills = list(rows.all()) + + assert [skill.id for skill in skills] == [matched.id] + + @pytest.mark.asyncio + async def test_get_skills_query_filters_by_description( + self, + skill_service: SkillService, + db_session: AsyncSession, + ): + matched = await skill_service.create_skill( + skill_schemas.SkillCreate( + name="Generic Name", + description="handles alembic migrations", + is_enabled=True, + content="content", + resources=[], + ) + ) + await skill_service.create_skill( + skill_schemas.SkillCreate( + name="Another Skill", + description="frontend styling", + is_enabled=True, + content="content", + resources=[], + ) + ) + + db_session.expunge_all() + + rows = await skill_service._db_session.scalars( + skill_service.get_skills_query("alembic") + ) + skills = list(rows.all()) + + assert [skill.id for skill in skills] == [matched.id] + + @pytest.mark.asyncio + async def test_get_skills_query_returns_empty_when_no_match( + self, + skill_service: SkillService, + db_session: AsyncSession, + ): + await skill_service.create_skill( + skill_schemas.SkillCreate( + name="Existing Skill", + description="existing description", + is_enabled=True, + content="content", + resources=[], + ) + ) + + db_session.expunge_all() + + rows = await skill_service._db_session.scalars( + skill_service.get_skills_query("nonexistent-term") + ) + skills = list(rows.all()) + + assert skills == [] + + @pytest.mark.asyncio + async def test_get_skills_query_empty_string_does_not_filter( + self, + skill_service: SkillService, + db_session: AsyncSession, + ): + first = await skill_service.create_skill( + skill_schemas.SkillCreate( + name="Skill A", + description="desc a", + is_enabled=True, + content="content", + resources=[], + ) + ) + second = await skill_service.create_skill( + skill_schemas.SkillCreate( + name="Skill B", + description="desc b", + is_enabled=True, + content="content", + resources=[], + ) + ) + + db_session.expunge_all() + + rows = await skill_service._db_session.scalars( + skill_service.get_skills_query("") + ) + skills = list(rows.all()) + + assert [skill.id for skill in skills] == [first.id, second.id] From 4a0bae511d7fe81e80b8a3d0a8c194c6204f9298 Mon Sep 17 00:00:00 2001 From: BHznJNs Date: Fri, 24 Jul 2026 01:42:37 +0800 Subject: [PATCH 2/3] feat(frontend): skill searching --- .../custom/form/ExtendableSearchInput.tsx | 164 ++++++++++++++++++ .../SideBar/components/SideBarHeader.tsx | 6 +- .../SideBar/views/SkillsView/SkillList.tsx | 19 +- .../SideBar/views/SkillsView/index.tsx | 40 +++-- src-frontend/src/i18n/locales/en/sidebar.json | 1 + .../src/i18n/locales/zh_CN/sidebar.json | 1 + 6 files changed, 212 insertions(+), 19 deletions(-) create mode 100644 src-frontend/src/components/custom/form/ExtendableSearchInput.tsx diff --git a/src-frontend/src/components/custom/form/ExtendableSearchInput.tsx b/src-frontend/src/components/custom/form/ExtendableSearchInput.tsx new file mode 100644 index 00000000..0f315c6e --- /dev/null +++ b/src-frontend/src/components/custom/form/ExtendableSearchInput.tsx @@ -0,0 +1,164 @@ +import React, { useEffect, useRef, useState } from "react"; +import { SearchIcon, XIcon } from "lucide-react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { Button } from "@/components/ui/button"; +import { InputGroup, InputGroupInput } from "@/components/ui/input-group"; +import { cn } from "@/lib/utils"; +import { useDebounceFn } from "ahooks"; + +export type ExpandableSearchBarProps = { + expandDirection?: "left" | "right"; + placeholder?: string; + onValueChange?: (query: string) => void; + className?: string; + defaultOpen?: boolean; + width?: number; +}; + +// Match Button `size="icon"` (size-9) so this sits flush with SideBarHeader actions. +const COLLAPSED_SIZE = 36; +// Keep this in sync with the `duration-*` class / transitionDuration below. +const TRANSITION_MS = 260; + +export function ExpandableSearchBar({ + expandDirection = "right", + placeholder = "Search...", + onValueChange, + className = "", + defaultOpen = false, +}: ExpandableSearchBarProps) { + const [open, setOpen] = useState(defaultOpen); + // Whether the form is in the DOM at all. + const [mounted, setMounted] = useState(defaultOpen); + // Whether it's painted at its *expanded* width/opacity (drives the transition). + const [expanded, setExpanded] = useState(defaultOpen); + const [value, setValue] = useState(""); + + const containerRef = useRef(null); + const inputRef = useRef(null); + const unmountTimer = useRef | null>(null); + const rafIds = useRef([]); + + const handleClear = () => { + setValue(""); + onValueChange?.(""); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + handleOnChange.run(value); + }; + + const handleOnChange = useDebounceFn((next: string) => { + onValueChange?.(next); + }, { wait: 300 }); + + // Mount/unmount + expand/collapse orchestration. + // On open: mount immediately at the collapsed size, then flip to expanded + // a couple of frames later so the browser has something to transition *from*. + // On close: collapse immediately, then unmount once the transition ends. + useEffect(() => { + if (unmountTimer.current) clearTimeout(unmountTimer.current); + rafIds.current.forEach(cancelAnimationFrame); + rafIds.current = []; + + if (open) { + setMounted(true); + const id1 = requestAnimationFrame(() => { + const id2 = requestAnimationFrame(() => setExpanded(true)); + rafIds.current.push(id2); + }); + rafIds.current.push(id1); + } else { + setExpanded(false); + unmountTimer.current = setTimeout(() => setMounted(false), TRANSITION_MS); + } + + return () => { + if (unmountTimer.current) clearTimeout(unmountTimer.current); + rafIds.current.forEach(cancelAnimationFrame); + rafIds.current = []; + }; + }, [open]); + + // Click outside to close (only when empty). + useEffect(() => { + function onDocClick(e: MouseEvent) { + if (!containerRef.current?.contains(e.target as Node) && open && value === "") { + setOpen(false); + handleClear(); + } + } + document.addEventListener("mousedown", onDocClick); + return () => document.removeEventListener("mousedown", onDocClick); + }, [open, value, onValueChange]); + + // Autofocus on open, clear value on close. + useEffect(() => { + if (open) { + const id = setTimeout(() => inputRef.current?.focus(), 120); + return () => clearTimeout(id); + } else { + handleClear(); + } + }, [open, onValueChange]); + + // Escape to close; Enter is handled by form submit. + useHotkeys("esc", () => { + setOpen(false); + handleClear(); + }, { + enabled: open, + preventDefault: true, + enableOnFormTags: true, + }); + + return ( +
+ + + {mounted && ( +
+ + { + const next = e.target.value; + setValue(next); + if ((e.nativeEvent as InputEvent).isComposing) return; + handleOnChange.run(next); + }} + onCompositionEnd={() => handleOnChange.run(value)} + placeholder={placeholder} + className="whitespace-nowrap overflow-x-auto" + /> + +
+ )} +
+ ); +} diff --git a/src-frontend/src/features/SideBar/components/SideBarHeader.tsx b/src-frontend/src/features/SideBar/components/SideBarHeader.tsx index f1739b54..7e4bfd68 100644 --- a/src-frontend/src/features/SideBar/components/SideBarHeader.tsx +++ b/src-frontend/src/features/SideBar/components/SideBarHeader.tsx @@ -11,17 +11,19 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; type SideBarHeaderProps = { title: string; children?: React.ReactNode; + actionsClass?: string; }; -export function SideBarHeader({ title, children }: SideBarHeaderProps) { +export function SideBarHeader({ title, children, actionsClass }: SideBarHeaderProps) { return (
{title} -
{children}
+
{children}
); } diff --git a/src-frontend/src/features/SideBar/views/SkillsView/SkillList.tsx b/src-frontend/src/features/SideBar/views/SkillsView/SkillList.tsx index cbab00fc..897c2dc5 100644 --- a/src-frontend/src/features/SideBar/views/SkillsView/SkillList.tsx +++ b/src-frontend/src/features/SideBar/views/SkillsView/SkillList.tsx @@ -101,13 +101,24 @@ function SkillItem({ skill, index, ref, onDelete }: SkillItemProps) { ); } -export function SkillList() { +type SkillListProps = { + searchQuery?: string; +}; + +export function SkillList({ searchQuery }: SkillListProps) { const { t } = useTranslation(SIDEBAR_NAMESPACE); const removeTabs = useTabsStore((state) => state.remove); - const query = useGetSkillsSuspenseInfinite(undefined, { - query: { ...PAGINATED_QUERY_DEFAULT_OPTIONS, gcTime: SIDEBAR_QUERY_GC_TIME }, - }); + const trimmedQuery = searchQuery?.trim(); + const query = useGetSkillsSuspenseInfinite( + trimmedQuery ? { query: trimmedQuery } : undefined, + { + query: { + ...PAGINATED_QUERY_DEFAULT_OPTIONS, + gcTime: SIDEBAR_QUERY_GC_TIME, + }, + }, + ); const deleteSkillMutation = useDeleteSkill({ mutation: { diff --git a/src-frontend/src/features/SideBar/views/SkillsView/index.tsx b/src-frontend/src/features/SideBar/views/SkillsView/index.tsx index 4eac8f52..8843e1f0 100644 --- a/src-frontend/src/features/SideBar/views/SkillsView/index.tsx +++ b/src-frontend/src/features/SideBar/views/SkillsView/index.tsx @@ -1,8 +1,12 @@ +import { useDebounce } from "ahooks"; import { FileUpIcon, PlusIcon } from "lucide-react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { invalidateSkillQueries, useUploadArchive } from "@/api/skill"; import { AsyncBoundary } from "@/components/custom/AsyncBoundary"; +import { ExpandableSearchBar } from "@/components/custom/form/ExtendableSearchInput"; +import { ButtonGroup } from "@/components/ui/button-group"; import { i18n } from "@/i18n"; import { SIDEBAR_NAMESPACE } from "@/i18n/resources"; import { useTabsStore } from "@/stores/tabs-store"; @@ -24,6 +28,8 @@ function openSkillCreateTab() { export function SkillsView() { const { t } = useTranslation(SIDEBAR_NAMESPACE); + const [searchValue, setSearchValue] = useState(""); + const { inputProps, open: openFileDialog } = useFileSelect({ accept: ".zip,application/zip", multiple: false, @@ -55,23 +61,31 @@ export function SkillsView() { return (
- - - - + + + + + +
}> - +
diff --git a/src-frontend/src/i18n/locales/en/sidebar.json b/src-frontend/src/i18n/locales/en/sidebar.json index a4998176..b55419cd 100644 --- a/src-frontend/src/i18n/locales/en/sidebar.json +++ b/src-frontend/src/i18n/locales/en/sidebar.json @@ -241,6 +241,7 @@ "header": { "create_tooltip": "Create new skill", "install_github_tooltip": "Install from GitHub", + "search_placeholder": "Search skills...", "title": "Skills", "upload_tooltip": "Upload skill archive" }, diff --git a/src-frontend/src/i18n/locales/zh_CN/sidebar.json b/src-frontend/src/i18n/locales/zh_CN/sidebar.json index 0b7dd717..c4846e09 100644 --- a/src-frontend/src/i18n/locales/zh_CN/sidebar.json +++ b/src-frontend/src/i18n/locales/zh_CN/sidebar.json @@ -241,6 +241,7 @@ "header": { "create_tooltip": "创建技能", "install_github_tooltip": "从 GitHub 安装", + "search_placeholder": "搜索技能...", "title": "技能", "upload_tooltip": "上传技能归档" }, From fc1c2e26d289efec4b54be05e2d9cfc1592f4844 Mon Sep 17 00:00:00 2001 From: BHznJNs Date: Fri, 24 Jul 2026 01:43:45 +0800 Subject: [PATCH 3/3] removed frontend unused console.log --- .../src/components/custom/form/fields/DirectoryField.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src-frontend/src/components/custom/form/fields/DirectoryField.tsx b/src-frontend/src/components/custom/form/fields/DirectoryField.tsx index cfe0c94c..10381d60 100644 --- a/src-frontend/src/components/custom/form/fields/DirectoryField.tsx +++ b/src-frontend/src/components/custom/form/fields/DirectoryField.tsx @@ -12,7 +12,6 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, - DialogDescription, DialogFooter, DialogHeader, DialogTitle, @@ -91,10 +90,7 @@ function DirectorySelectDialog({ > { - console.log(path); - setDraftPath(path); - }} + onSelect={setDraftPath} />