mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-28 11:48:10 +08:00
Feat: add skills space to context engine (#13908)
### What problem does this PR solve? issue #13714 ### Type of change - [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
19
web/src/assets/svg/home-icon/skill-folder.svg
Normal file
19
web/src/assets/svg/home-icon/skill-folder.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.667 20.0004H17.3337C18.0409 20.0004 18.7192 19.7194 19.2193 19.2193C19.7194 18.7192 20.0003 18.0409 20.0003 17.3337C20.0003 16.6265 19.7194 15.9482 19.2193 15.4481C18.7192 14.948 18.0409 14.667 17.3337 14.667H13.3337C12.5337 14.667 11.867 14.9337 11.467 15.467L4.00033 22.667M9.33366 28.0004L11.467 26.1337C11.867 25.6004 12.5337 25.3337 13.3337 25.3337H18.667C20.1337 25.3337 21.467 24.8004 22.4003 23.7337L28.5337 17.867C29.0482 17.3808 29.3485 16.7101 29.3685 16.0025C29.3885 15.2948 29.1266 14.6082 28.6403 14.0937C28.1541 13.5792 27.4834 13.2789 26.7757 13.2589C26.0681 13.2389 25.3815 13.5008 24.867 13.987L19.267 19.187M2.66699 21.3337L10.667 29.3337" stroke="url(#paint0_linear_2839_15297)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12.2916 5.3066L12.4457 4.95317C12.7159 4.32755 13.2105 3.82586 13.832 3.54695L14.3069 3.33605C14.3645 3.30967 14.4133 3.26729 14.4476 3.21394C14.4818 3.1606 14.5 3.09853 14.5 3.03514C14.5 2.97174 14.4818 2.90968 14.4476 2.85633C14.4133 2.80298 14.3645 2.7606 14.3069 2.73422L13.8587 2.53416C13.2212 2.24802 12.7178 1.72795 12.4524 1.08126L12.2941 0.699488C12.2706 0.640617 12.2301 0.590136 12.1777 0.554573C12.1252 0.51901 12.0633 0.5 12 0.5C11.9367 0.5 11.8748 0.51901 11.8223 0.554573C11.7699 0.590136 11.7294 0.640617 11.7059 0.699488L11.5476 1.08126C11.2824 1.72826 10.779 2.24866 10.1413 2.535L9.69312 2.73422C9.63551 2.7606 9.58667 2.80298 9.55244 2.85633C9.5182 2.90968 9.5 2.97174 9.5 3.03514C9.5 3.09853 9.5182 3.1606 9.55244 3.21394C9.58667 3.26729 9.63551 3.30967 9.69312 3.33605L10.168 3.54695C10.7845 3.82202 11.2794 4.32299 11.5543 4.95317L11.7084 5.3066C11.7326 5.36394 11.7732 5.41286 11.8251 5.44726C11.8769 5.48166 11.9378 5.5 12 5.5C12.0622 5.5 12.1231 5.48166 12.1749 5.44726C12.2268 5.41286 12.2674 5.36394 12.2916 5.3066Z" fill="url(#paint1_linear_2839_15297)"/>
|
||||
<path d="M16 8.968L17.2307 8.45867M17.2307 5.39733L16 4.88667M19.396 3.232L18.8853 2M19.396 10.624L18.8853 11.8547M22.4573 3.232L22.968 2M22.9667 11.856L22.4573 10.624M24.6227 5.39733L25.8547 4.88667M24.6227 8.45867L25.8547 8.96933M24.9267 6.928C24.9267 9.13714 23.1358 10.928 20.9267 10.928C18.7175 10.928 16.9267 9.13714 16.9267 6.928C16.9267 4.71886 18.7175 2.928 20.9267 2.928C23.1358 2.928 24.9267 4.71886 24.9267 6.928Z" stroke="url(#paint2_linear_2839_15297)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_2839_15297" x1="9.00049" y1="3.00195" x2="30.0005" y2="29.002" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#00BEB4"/>
|
||||
<stop offset="1" stop-color="#00BE6F"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_2839_15297" x1="6.31224" y1="0.503555" x2="13.7116" y2="10.3087" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#01BEB3"/>
|
||||
<stop offset="1" stop-color="#01C48D"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_2839_15297" x1="5.91825" y1="3.62484" x2="23.9137" y2="28.7657" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#00BEB4"/>
|
||||
<stop offset="1" stop-color="#00BE6F"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
14
web/src/assets/svg/home-icon/skill-space.svg
Normal file
14
web/src/assets/svg/home-icon/skill-space.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2839_15291)">
|
||||
<path d="M3.99986 0.570312C4.90917 0.570313 5.78124 0.931536 6.42423 1.57452C7.06721 2.2175 7.42843 3.08957 7.42843 3.99888C7.42843 4.9082 7.06721 5.78027 6.42423 6.42325C5.78124 7.06623 4.90917 7.42746 3.99986 7.42746C3.09055 7.42746 2.21848 7.06623 1.57549 6.42325C0.932512 5.78027 0.571289 4.9082 0.571289 3.99888C0.571289 3.08957 0.932512 2.2175 1.57549 1.57452C2.21848 0.931536 3.09055 0.570313 3.99986 0.570312ZM3.99986 1.71317C3.6997 1.71317 3.40247 1.77229 3.12516 1.88716C2.84784 2.00203 2.59586 2.17039 2.38362 2.38264C2.17137 2.59489 2.003 2.84686 1.88814 3.12418C1.77327 3.40149 1.71415 3.69872 1.71415 3.99888C1.71415 4.29905 1.77327 4.59627 1.88814 4.87359C2.003 5.1509 2.17137 5.40288 2.38362 5.61513C2.59586 5.82738 2.84784 5.99574 3.12516 6.11061C3.40247 6.22548 3.6997 6.2846 3.99986 6.2846C4.60607 6.2846 5.18745 6.04378 5.6161 5.61513C6.04476 5.18647 6.28557 4.60509 6.28557 3.99888C6.28557 3.39267 6.04476 2.81129 5.6161 2.38264C5.18745 1.95399 4.60607 1.71317 3.99986 1.71317ZM3.99986 8.57031C4.90917 8.57031 5.78124 8.93154 6.42423 9.57452C7.06721 10.2175 7.42843 11.0896 7.42843 11.9989C7.42843 12.9082 7.06721 13.7803 6.42423 14.4233C5.78124 15.0662 4.90917 15.4275 3.99986 15.4275C3.09055 15.4275 2.21848 15.0662 1.57549 14.4233C0.932512 13.7803 0.571289 12.9082 0.571289 11.9989C0.571289 11.0896 0.932512 10.2175 1.57549 9.57452C2.21848 8.93154 3.09055 8.57031 3.99986 8.57031ZM3.99986 9.71317C3.6997 9.71317 3.40247 9.77229 3.12516 9.88716C2.84784 10.002 2.59586 10.1704 2.38362 10.3826C2.17137 10.5949 2.003 10.8469 1.88814 11.1242C1.77327 11.4015 1.71415 11.6987 1.71415 11.9989C1.71415 12.299 1.77327 12.5963 1.88814 12.8736C2.003 13.1509 2.17137 13.4029 2.38362 13.6151C2.59586 13.8274 2.84784 13.9957 3.12516 14.1106C3.40247 14.2255 3.6997 14.2846 3.99986 14.2846C4.60607 14.2846 5.18745 14.0438 5.6161 13.6151C6.04476 13.1865 6.28557 12.6051 6.28557 11.9989C6.28557 11.3927 6.04476 10.8113 5.6161 10.3826C5.18745 9.95399 4.60607 9.71317 3.99986 9.71317ZM11.9999 8.57031C12.9092 8.57031 13.7812 8.93154 14.4242 9.57452C15.0672 10.2175 15.4284 11.0896 15.4284 11.9989C15.4284 12.9082 15.0672 13.7803 14.4242 14.4233C13.7812 15.0662 12.9092 15.4275 11.9999 15.4275C11.0905 15.4275 10.2185 15.0662 9.57549 14.4233C8.93251 13.7803 8.57129 12.9082 8.57129 11.9989C8.57129 11.0896 8.93251 10.2175 9.57549 9.57452C10.2185 8.93154 11.0905 8.57031 11.9999 8.57031ZM11.9999 9.71317C11.6997 9.71317 11.4025 9.77229 11.1252 9.88716C10.8478 10.002 10.5959 10.1704 10.3836 10.3826C10.1714 10.5949 10.003 10.8469 9.88814 11.1242C9.77327 11.4015 9.71415 11.6987 9.71415 11.9989C9.71415 12.299 9.77327 12.5963 9.88814 12.8736C10.003 13.1509 10.1714 13.4029 10.3836 13.6151C10.5959 13.8274 10.8478 13.9957 11.1252 14.1106C11.4025 14.2255 11.6997 14.2846 11.9999 14.2846C12.6061 14.2846 13.1875 14.0438 13.6161 13.6151C14.0448 13.1865 14.2856 12.6051 14.2856 11.9989C14.2856 11.3927 14.0448 10.8113 13.6161 10.3826C13.1875 9.95399 12.6061 9.71317 11.9999 9.71317ZM12.1896 7.7326L12.401 7.24803C12.7717 6.39027 13.4501 5.70243 14.3027 5.32003L14.9541 5.03088C15.0332 4.99472 15.1002 4.93661 15.1471 4.86347C15.1941 4.79032 15.2191 4.70523 15.2191 4.61831C15.2191 4.53139 15.1941 4.4463 15.1471 4.37316C15.1002 4.30002 15.0332 4.24191 14.9541 4.20574L14.3393 3.93146C13.4648 3.53914 12.7742 2.82609 12.4101 1.93946L12.193 1.41603C12.1608 1.33531 12.1052 1.2661 12.0333 1.21734C11.9614 1.16858 11.8765 1.14252 11.7896 1.14252C11.7027 1.14252 11.6178 1.16858 11.5459 1.21734C11.474 1.2661 11.4183 1.33531 11.3861 1.41603L11.169 1.93946C10.8052 2.82652 10.1146 3.54001 9.23986 3.9326L8.625 4.20574C8.54596 4.24191 8.47898 4.30002 8.43201 4.37316C8.38505 4.4463 8.36008 4.53139 8.36008 4.61831C8.36008 4.70523 8.38505 4.79032 8.43201 4.86347C8.47898 4.93661 8.54596 4.99472 8.625 5.03088L9.27643 5.32003C10.1221 5.69717 10.801 6.38403 11.1781 7.24803L11.3896 7.7326C11.4228 7.81121 11.4785 7.87829 11.5496 7.92545C11.6208 7.97261 11.7042 7.99776 11.7896 7.99776C11.8749 7.99776 11.9584 7.97261 12.0295 7.92545C12.1007 7.87829 12.1563 7.81121 12.1896 7.7326Z" fill="url(#paint0_linear_2839_15291)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_2839_15291" x1="3.98714" y1="1.14745" x2="14.1304" y2="14.5961" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#01BEB3"/>
|
||||
<stop offset="1" stop-color="#01C48D"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_2839_15291">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
19
web/src/assets/svg/home-icon/skills.svg
Normal file
19
web/src/assets/svg/home-icon/skills.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2839_15284)">
|
||||
<path d="M7.33325 9.9987H8.66659C9.02021 9.9987 9.35935 9.85822 9.60939 9.60817C9.85944 9.35813 9.99992 9.01899 9.99992 8.66537C9.99992 8.31174 9.85944 7.9726 9.60939 7.72256C9.35935 7.47251 9.02021 7.33203 8.66659 7.33203H6.66659C6.26659 7.33203 5.93325 7.46537 5.73325 7.73203L1.99992 11.332M4.66659 13.9987L5.73325 13.0654C5.93325 12.7987 6.26659 12.6654 6.66659 12.6654H9.33325C10.0666 12.6654 10.7333 12.3987 11.1999 11.8654L14.2666 8.93203C14.5238 8.68892 14.674 8.35356 14.684 7.99974C14.694 7.64593 14.563 7.30262 14.3199 7.04536C14.0768 6.7881 13.7414 6.63796 13.3876 6.62796C13.0338 6.61795 12.6905 6.74892 12.4333 6.99203L9.63325 9.59203M1.33325 10.6654L5.33325 14.6654M12.1666 4.9987C12.1666 5.9652 11.3831 6.7487 10.4166 6.7487C9.45009 6.7487 8.66659 5.9652 8.66659 4.9987C8.66659 4.0322 9.45009 3.2487 10.4166 3.2487C11.3831 3.2487 12.1666 4.0322 12.1666 4.9987ZM5.99992 2.58203C5.99992 3.6866 5.10449 4.58203 3.99992 4.58203C2.89535 4.58203 1.99992 3.6866 1.99992 2.58203C1.99992 1.47746 2.89535 0.582031 3.99992 0.582031C5.10449 0.582031 5.99992 1.47746 5.99992 2.58203Z" stroke="url(#paint0_linear_2839_15284)" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.42495 3.13396L8.51743 2.9219C8.67955 2.54653 8.9763 2.24552 9.3492 2.07817L9.63413 1.95163C9.6687 1.9358 9.698 1.91037 9.71854 1.87837C9.73908 1.84636 9.75 1.80912 9.75 1.77108C9.75 1.73304 9.73908 1.69581 9.71854 1.6638C9.698 1.63179 9.6687 1.60636 9.63413 1.59053L9.3652 1.4705C8.9827 1.29881 8.68067 0.986768 8.52143 0.598757L8.42645 0.369693C8.41238 0.33437 8.38805 0.304082 8.35659 0.282744C8.32513 0.261406 8.288 0.25 8.25 0.25C8.212 0.25 8.17487 0.261406 8.14341 0.282744C8.11195 0.304082 8.08762 0.33437 8.07355 0.369693L7.97857 0.598757C7.81944 0.986956 7.51739 1.29919 7.1348 1.471L6.86587 1.59053C6.8313 1.60636 6.802 1.63179 6.78146 1.6638C6.76092 1.69581 6.75 1.73304 6.75 1.77108C6.75 1.80912 6.76092 1.84636 6.78146 1.87837C6.802 1.91037 6.8313 1.9358 6.86587 1.95163L7.1508 2.07817C7.5207 2.24321 7.81762 2.5438 7.98257 2.9219L8.07505 3.13396C8.08959 3.16836 8.11393 3.19772 8.14505 3.21836C8.17616 3.23899 8.21267 3.25 8.25 3.25C8.28733 3.25 8.32384 3.23899 8.35495 3.21836C8.38607 3.19772 8.41041 3.16836 8.42495 3.13396Z" fill="url(#paint1_linear_2839_15284)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_2839_15284" x1="4.5" y1="1.5" x2="15" y2="14.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#00BEB4"/>
|
||||
<stop offset="1" stop-color="#00BE6F"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_2839_15284" x1="4.83734" y1="0.252133" x2="9.27694" y2="6.13523" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#01BEB3"/>
|
||||
<stop offset="1" stop-color="#01C48D"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_2839_15284">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -11,6 +11,7 @@ export enum EmptyCardType {
|
||||
Chat = 'chat',
|
||||
Search = 'search',
|
||||
Memory = 'memory',
|
||||
Skills = 'skills',
|
||||
}
|
||||
|
||||
export const EmptyCardData = {
|
||||
@@ -39,4 +40,9 @@ export const EmptyCardData = {
|
||||
titleKey: 'empty.memoryTitle',
|
||||
notFoundKey: 'empty.notFoundMemory',
|
||||
},
|
||||
[EmptyCardType.Skills]: {
|
||||
icon: <HomeIcon name="skills" width={'24'} />,
|
||||
titleKey: 'empty.skillsTitle',
|
||||
notFoundKey: 'empty.notFoundSkills',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { FileIconMap } from '@/constants/file';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getExtension } from '@/utils/document-util';
|
||||
import { CSSProperties } from 'react';
|
||||
import SvgIcon from './svg-icon';
|
||||
|
||||
type IconFontType = {
|
||||
name: string;
|
||||
@@ -36,6 +37,14 @@ export function FileIcon({
|
||||
type,
|
||||
}: IconFontType & { type?: string }) {
|
||||
const isFolder = type === 'folder';
|
||||
const isSkills = type === 'skills';
|
||||
if (isSkills) {
|
||||
return (
|
||||
<span className={cn('size-4', className)}>
|
||||
<SvgIcon name="home-icon/skills" width={16} height={16} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className={cn('size-4', className)}>
|
||||
<IconFont
|
||||
|
||||
@@ -16,6 +16,7 @@ interface IProps {
|
||||
searchString?: string;
|
||||
onSearchChange?: ChangeEventHandler<HTMLInputElement>;
|
||||
showFilter?: boolean;
|
||||
showSearch?: boolean;
|
||||
leftPanel?: ReactNode;
|
||||
preChildren?: ReactNode;
|
||||
}
|
||||
@@ -58,6 +59,7 @@ export default function ListFilterBar({
|
||||
searchString,
|
||||
onSearchChange,
|
||||
showFilter = true,
|
||||
showSearch = true,
|
||||
leftPanel,
|
||||
value,
|
||||
onChange,
|
||||
@@ -121,12 +123,14 @@ export default function ListFilterBar({
|
||||
</FilterPopover>
|
||||
)}
|
||||
|
||||
<SearchInput
|
||||
value={searchString}
|
||||
onChange={onSearchChange}
|
||||
className="w-32"
|
||||
role="searchbox"
|
||||
></SearchInput>
|
||||
{showSearch && (
|
||||
<SearchInput
|
||||
value={searchString}
|
||||
onChange={onSearchChange}
|
||||
className="w-32"
|
||||
role="searchbox"
|
||||
></SearchInput>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ const SheetOverlay = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:pointer-events-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
'fixed z-50 gap-4 bg-bg-base rounded-lg p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
'fixed z-50 gap-4 bg-bg-base rounded-lg p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=closed]:pointer-events-none',
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
|
||||
@@ -17,15 +17,15 @@ const Slider = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-colors-background-inverse-strong">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
<SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full bg-border-button">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-accent-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
|
||||
<SliderPrimitive.Thumb
|
||||
className="
|
||||
block h-5 w-5 rounded-full border-2 border-primary bg-colors-text-core-standard transition-colors ring-offset-background
|
||||
block h-2.5 w-2.5 rounded-full border-2 border-accent-primary bg-white ring-offset-background transition-colors
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2
|
||||
disabled:pointer-events-none disabled:opacity-50"
|
||||
disabled:pointer-events-none disabled:opacity-50 cursor-pointer"
|
||||
/>
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
|
||||
@@ -26,13 +26,16 @@ export const Spin: React.FC<SpinProps> = ({
|
||||
className,
|
||||
children,
|
||||
}) => {
|
||||
// When used without children (standalone), don't show mask background
|
||||
const hasChildren = React.Children.count(children) > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative',
|
||||
{
|
||||
'after:content-[""] after:absolute after:inset-0 after:z-10 after:bg-text-primary/40 after:transition-all after:duration-300 h-full w-full':
|
||||
spinning,
|
||||
spinning && hasChildren,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
@@ -40,7 +43,9 @@ export const Spin: React.FC<SpinProps> = ({
|
||||
{spinning && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 z-10 flex items-center justify-center bg-text-primary/30',
|
||||
hasChildren
|
||||
? 'absolute inset-0 z-10 flex items-center justify-center bg-text-primary/30'
|
||||
: 'flex items-center justify-center',
|
||||
minSizeClasses[size],
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -62,6 +62,7 @@ export default {
|
||||
openInNewTab: 'Chat in new tab',
|
||||
previousPage: 'Previous',
|
||||
nextPage: 'Next',
|
||||
previous: 'Previous',
|
||||
add: 'Add',
|
||||
remove: 'Remove',
|
||||
search: 'Search',
|
||||
@@ -113,12 +114,174 @@ export default {
|
||||
setting: 'User settings',
|
||||
logout: 'Log out',
|
||||
fileManager: 'File',
|
||||
skills: 'Skills',
|
||||
flow: 'Agent',
|
||||
search: 'Search',
|
||||
welcome: 'Welcome to',
|
||||
dataset: 'Dataset',
|
||||
memories: 'Memory',
|
||||
},
|
||||
skills: {
|
||||
title: 'Skills',
|
||||
selectSpace: 'Select a skill space to get started',
|
||||
spacePlaceholder: 'Enter space name',
|
||||
createSpace: 'Create skill space',
|
||||
createSpaceTitle: 'Create new skill space',
|
||||
createSpaceDescription:
|
||||
'Create a new space to organize and manage your skills.',
|
||||
spaceName: 'Space name',
|
||||
spaceNamePlaceholder: 'e.g., my-space',
|
||||
spaceNameRequired: 'Please enter space name',
|
||||
noSpaces: 'No skill space yet. Create your first one!',
|
||||
enterSpace: 'Enter',
|
||||
spaceCreated: 'Skill space created successfully',
|
||||
spaceDeleted: 'Skill space deleted successfully',
|
||||
fetchError: 'Failed to fetch skills',
|
||||
deleteSpaceTitle: 'Delete skill space',
|
||||
deleteSpaceDescription:
|
||||
'Are you sure you want to delete this skill space? This action cannot be undone and all skills in this space will be permanently deleted.',
|
||||
deleteSpaceName: 'Space name',
|
||||
uploadSuccess: 'Skill uploaded successfully',
|
||||
uploadError: 'Failed to upload skill',
|
||||
deleteSuccess: 'Skill deleted successfully',
|
||||
deleteError: 'Failed to delete skill',
|
||||
skillExists:
|
||||
'A skill with this name already exists. Please delete it first or use a different name.',
|
||||
uploadSkill: 'Upload skill',
|
||||
searchPlaceholder: 'Search skills...',
|
||||
noSkills: 'No skills yet. Upload your first skill.',
|
||||
noSearchResults: 'No skills matching your search',
|
||||
filesCount: '{{count}} files',
|
||||
foldersCount: '{{count}} folders',
|
||||
pageInfo: 'Page {{current}} of {{total}}',
|
||||
totalSkills: '{{total}} skills total',
|
||||
backToSkills: 'Back to skills',
|
||||
selectFileToView: 'Select a file to view',
|
||||
skillName: 'Skill name',
|
||||
skillNamePlaceholder: 'e.g., my-awesome-skill',
|
||||
skillNameHelp: 'Only letters, numbers, hyphens and underscores allowed',
|
||||
source: 'Source',
|
||||
version: 'Version',
|
||||
skillVersion: 'Version',
|
||||
skillVersionPlaceholder: 'e.g., 1.0.0',
|
||||
versionFormatHelp: 'Version must be in semver format (e.g., 1.0.0)',
|
||||
versionRequired: 'Version is required',
|
||||
selectFilesOrFolder: 'Select files or folder',
|
||||
uploadDescription:
|
||||
'Upload skill files. You can drag and drop files or select a folder.',
|
||||
selectFolder: 'Select folder',
|
||||
dragFilesHint: 'or drag files below',
|
||||
dragFilesTitle: 'Drag skill folder here',
|
||||
dragFilesDescription:
|
||||
'Drag and drop a skill folder here, or use the "Select Folder" button below.',
|
||||
filesSelected: '{{count}} files selected',
|
||||
uploading: 'Uploading...',
|
||||
files: 'Files',
|
||||
noFiles: 'No files',
|
||||
versionHistory: 'Version history',
|
||||
selectVersion: 'Select version to preview',
|
||||
latest: 'Latest',
|
||||
metadata: {
|
||||
basic: 'Basic info',
|
||||
emoji: 'Emoji',
|
||||
skillKey: 'Skill key',
|
||||
always: 'Always active',
|
||||
primaryEnv: 'Primary Environment Variable',
|
||||
requires: 'Requirements',
|
||||
requiredBins: 'Required Binaries',
|
||||
requiredEnv: 'Required Environment Variables',
|
||||
anyBins: 'At Least One Required',
|
||||
install: 'Dependencies',
|
||||
links: 'Links',
|
||||
homepage: 'Homepage',
|
||||
repository: 'Repository',
|
||||
documentation: 'Documentation',
|
||||
},
|
||||
validation: {
|
||||
missing_skill_md:
|
||||
'Invalid skill: SKILL.md not found. Please ensure your skill directory contains a valid SKILL.md file.',
|
||||
invalid_frontmatter:
|
||||
'Invalid skill: SKILL.md must have valid frontmatter (start and end with ---).',
|
||||
missing_name:
|
||||
'Invalid skill: SKILL.md frontmatter must include a "name" field.',
|
||||
invalid_name_format:
|
||||
'Invalid skill: "name" must be lowercase and URL-safe (letters, numbers, hyphens only).',
|
||||
invalid_version:
|
||||
'Invalid skill: "version" must be valid semver (e.g., 1.0.0).',
|
||||
invalid_metadata: 'Invalid skill: metadata contains invalid fields.',
|
||||
invalid_file_type: 'Invalid skill: Only text-based files are allowed.',
|
||||
invalid_path: 'Invalid skill: File path contains invalid characters.',
|
||||
file_too_large:
|
||||
'Invalid skill: Individual file size exceeds 5MB limit.',
|
||||
total_size_exceeded:
|
||||
'Invalid skill: Total bundle size exceeds 50MB limit.',
|
||||
no_files: 'No files selected. Please select a skill folder.',
|
||||
noValidFiles: 'No valid files found. Please check your selection.',
|
||||
junkFilesFound:
|
||||
'Temporary files detected (e.g., .DS_Store). Please remove them before uploading.',
|
||||
read_failed: 'Invalid skill: Failed to read SKILL.md file.',
|
||||
invalid: 'Invalid skill format.',
|
||||
valid: 'Valid skill format. Ready to upload.',
|
||||
versionExists:
|
||||
'This version already exists. Please use a different version number.',
|
||||
error: 'Validation failed',
|
||||
},
|
||||
parsedMetadata: 'Parsed from SKILL.md:',
|
||||
addSkill: 'Add Skill',
|
||||
upload: 'Upload',
|
||||
importFromGit: 'Import from Git',
|
||||
gitPlatform: 'Platform',
|
||||
repoUrl: 'Repository URL',
|
||||
repoUrlHelp: 'Supports repository URL with optional path',
|
||||
accessToken: 'Access Token',
|
||||
githubTokenHelp:
|
||||
'For private repos or higher rate limits (5000 req/hour)',
|
||||
giteeTokenHelp: 'For private repos or higher rate limits (2000 req/hour)',
|
||||
rateLimitInfo: 'Rate Limit Info',
|
||||
githubRateLimit:
|
||||
'Public repos: 60 requests/hour per IP. Use token for 5000 req/hour.',
|
||||
giteeRateLimit:
|
||||
'Public repos: 1000 requests/hour per IP. Use token for 2000 req/hour.',
|
||||
import: 'Import',
|
||||
importing: 'Importing...',
|
||||
configureSearch: 'Configure Search',
|
||||
},
|
||||
skillSearch: {
|
||||
configTitle: 'Skill Search Configuration',
|
||||
configDesc: 'Configure how skills are indexed and searched',
|
||||
embeddingModel: 'Embedding Model',
|
||||
embeddingModelPlaceholder: 'Select an embedding model',
|
||||
vectorSimilarityWeight: 'Vector Similarity Weight',
|
||||
similarityThreshold: 'Similarity Threshold',
|
||||
topK: 'Top K Results',
|
||||
indexFields: 'Index Fields',
|
||||
indexFieldsDesc: 'Select which fields to include in the search index',
|
||||
fieldName: 'Name',
|
||||
fieldNameDesc: 'Skill name',
|
||||
fieldTags: 'Tags',
|
||||
fieldTagsDesc: 'Skill tags',
|
||||
fieldDescription: 'Description',
|
||||
fieldDescriptionDesc: 'Skill description',
|
||||
fieldContent: 'Content',
|
||||
fieldContentDesc: 'Skill content (e.g., README)',
|
||||
weight: 'Weight',
|
||||
pureVector: 'Vector Only',
|
||||
hybrid: 'Hybrid',
|
||||
keyword: 'Keyword',
|
||||
vector: 'Vector',
|
||||
keywordOnly: 'Keyword Only',
|
||||
balanced: 'Balanced',
|
||||
vectorOnly: 'Vector Only',
|
||||
reindex: 'Reindex All',
|
||||
reindexing: 'Reindexing...',
|
||||
reindexSuccess: 'Reindexed successfully',
|
||||
pleaseSelectEmbeddingModel: 'Please select an embedding model',
|
||||
saveSuccess: 'Saved successfully',
|
||||
saveError: 'Failed to save',
|
||||
semanticSearchPlaceholder: 'Search skills by meaning...',
|
||||
switchToSemantic: 'Switch to semantic search',
|
||||
switchToLocal: 'Switch to local search',
|
||||
},
|
||||
memories: {
|
||||
llmTooltip:
|
||||
'Analyzes conversation content, extracts key information, and generates structured memory summaries.',
|
||||
@@ -2628,6 +2791,8 @@ Important structured information may include: names, dates, locations, events, k
|
||||
notFoundSearch: 'Search app not found',
|
||||
memoryTitle: 'No memory created yet',
|
||||
notFoundMemory: 'Memory not found',
|
||||
skillsTitle: 'No skill space created yet',
|
||||
notFoundSkills: 'Skill space not found',
|
||||
addNow: 'Add Now',
|
||||
},
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ export default {
|
||||
openInNewTab: '在新标签页中聊天',
|
||||
previousPage: '上一页',
|
||||
nextPage: '下一页',
|
||||
previous: '上一步',
|
||||
add: '添加',
|
||||
remove: '移除',
|
||||
search: '搜索',
|
||||
@@ -97,12 +98,166 @@ export default {
|
||||
setting: '用户设置',
|
||||
logout: '登出',
|
||||
fileManager: '文件管理',
|
||||
skills: '技能',
|
||||
flow: '智能体',
|
||||
search: '搜索',
|
||||
welcome: '欢迎来到',
|
||||
dataset: '知识库',
|
||||
memories: '记忆',
|
||||
},
|
||||
skills: {
|
||||
title: '技能',
|
||||
selectSpace: '选择一个技能空间开始使用',
|
||||
spacePlaceholder: '输入中心名称',
|
||||
createSpace: '创建技能空间',
|
||||
createSpaceTitle: '创建新技能空间',
|
||||
createSpaceDescription: '创建一个新的中心来组织和管理您的技能。',
|
||||
spaceName: '技能空间名称',
|
||||
spaceNamePlaceholder: '例如:my-space',
|
||||
spaceNameRequired: '请输入中心名称',
|
||||
noSpaces: '暂无技能空间,创建您的第一个!',
|
||||
enterSpace: '进入',
|
||||
spaceCreated: '技能空间创建成功',
|
||||
spaceDeleted: '技能空间删除成功',
|
||||
fetchError: '获取技能列表失败',
|
||||
deleteSpaceTitle: '删除技能空间',
|
||||
deleteSpaceDescription:
|
||||
'确定要删除此技能空间吗?此操作无法撤销,该中心中的所有技能将被永久删除。',
|
||||
deleteSpaceName: '中心名称',
|
||||
uploadSuccess: '技能上传成功',
|
||||
uploadError: '技能上传失败',
|
||||
deleteSuccess: '技能删除成功',
|
||||
deleteError: '技能删除失败',
|
||||
skillExists: '同名技能已存在,请先删除或使用其他名称',
|
||||
uploadSkill: '上传技能',
|
||||
searchPlaceholder: '搜索技能...',
|
||||
noSkills: '暂无技能,上传您的第一个技能',
|
||||
noSearchResults: '没有找到匹配的技能',
|
||||
filesCount: '{{count}} 个文件',
|
||||
foldersCount: '{{count}} 个文件夹',
|
||||
pageInfo: '第 {{current}} 页,共 {{total}} 页',
|
||||
totalSkills: '共 {{total}} 个技能',
|
||||
backToSkills: '返回技能列表',
|
||||
selectFileToView: '选择文件查看',
|
||||
skillName: '技能名称',
|
||||
skillNamePlaceholder: '例如:my-awesome-skill',
|
||||
skillNameHelp: '只允许字母、数字、连字符和下划线',
|
||||
source: '来源',
|
||||
version: '版本',
|
||||
skillVersion: '版本号',
|
||||
skillVersionPlaceholder: '例如:1.0.0',
|
||||
versionFormatHelp: '版本号必须符合语义化版本格式(例如:1.0.0)',
|
||||
versionRequired: '版本号为必填项',
|
||||
selectFilesOrFolder: '选择文件或文件夹',
|
||||
uploadDescription: '上传技能文件。您可以拖拽文件或选择文件夹上传。',
|
||||
selectFolder: '选择文件夹',
|
||||
dragFilesHint: '或拖拽文件到下方',
|
||||
dragFilesTitle: '拖拽技能文件夹到此处',
|
||||
dragFilesDescription:
|
||||
'将技能文件夹拖拽到此处,或使用下方的"选择文件夹"按钮。',
|
||||
filesSelected: '已选择 {{count}} 个文件',
|
||||
uploading: '上传中...',
|
||||
files: '文件',
|
||||
noFiles: '没有文件',
|
||||
versionHistory: '版本历史',
|
||||
selectVersion: '选择版本预览',
|
||||
latest: '最新',
|
||||
metadata: {
|
||||
basic: '基本信息',
|
||||
emoji: '表情符号',
|
||||
skillKey: '技能键名',
|
||||
always: '始终激活',
|
||||
primaryEnv: '主环境变量',
|
||||
requires: '运行要求',
|
||||
requiredBins: '必需的二进制文件',
|
||||
requiredEnv: '必需的环境变量',
|
||||
anyBins: '至少需要一个',
|
||||
install: '依赖项',
|
||||
links: '链接',
|
||||
homepage: '主页',
|
||||
repository: '代码仓库',
|
||||
documentation: '文档',
|
||||
},
|
||||
validation: {
|
||||
missing_skill_md:
|
||||
'无效的技能:未找到 SKILL.md 文件。请确保技能目录包含有效的 SKILL.md 文件。',
|
||||
invalid_frontmatter:
|
||||
'无效的技能:SKILL.md 必须包含有效的 frontmatter(以 --- 开头和结尾)。',
|
||||
missing_name: '无效的技能:SKILL.md frontmatter 必须包含 "name" 字段。',
|
||||
invalid_name_format:
|
||||
'无效的技能:"name" 必须是小写且 URL 安全的(仅字母、数字、连字符)。',
|
||||
invalid_version:
|
||||
'无效的技能:"version" 必须是有效的语义化版本(例如:1.0.0)。',
|
||||
invalid_metadata: '无效的技能:metadata 包含无效字段。',
|
||||
invalid_file_type: '无效的技能:只允许文本类型的文件。',
|
||||
invalid_path: '无效的技能:文件路径包含无效字符。',
|
||||
file_too_large: '无效的技能:单个文件大小超过 5MB 限制。',
|
||||
total_size_exceeded: '无效的技能:总包大小超过 50MB 限制。',
|
||||
no_files: '未选择文件。请选择技能文件夹。',
|
||||
noValidFiles: '未找到有效文件,请检查您的选择。',
|
||||
junkFilesFound: '检测到临时文件(如 .DS_Store),请删除后再上传。',
|
||||
read_failed: '无效的技能:无法读取 SKILL.md 文件。',
|
||||
invalid: '无效的技能格式。',
|
||||
valid: '有效的技能格式,可以上传。',
|
||||
error: '验证失败',
|
||||
versionExists: '该版本已存在,请使用不同的版本号。',
|
||||
},
|
||||
parsedMetadata: '从 SKILL.md 解析:',
|
||||
addSkill: '添加技能',
|
||||
upload: '本地上传',
|
||||
importFromGit: '从 Git 导入',
|
||||
gitPlatform: '平台',
|
||||
repoUrl: '仓库地址',
|
||||
repoUrlHelp: '支持带路径的仓库地址',
|
||||
accessToken: '访问令牌',
|
||||
githubTokenHelp: '用于私有仓库或更高配额(5000 次/小时)',
|
||||
giteeTokenHelp: '用于私有仓库或更高配额(2000 次/小时)',
|
||||
rateLimitInfo: '速率限制说明',
|
||||
githubRateLimit:
|
||||
'公开仓库:每小时 60 次请求/IP。使用令牌可达 5000 次/小时。',
|
||||
giteeRateLimit:
|
||||
'公开仓库:每小时 1000 次请求/IP。使用令牌可达 2000 次/小时。',
|
||||
import: '导入',
|
||||
importing: '导入中...',
|
||||
configureSearch: '配置搜索',
|
||||
},
|
||||
skillSearch: {
|
||||
configTitle: '技能搜索配置',
|
||||
configDesc: '配置技能的索引和搜索方式',
|
||||
embeddingModel: '嵌入模型',
|
||||
embeddingModelPlaceholder: '选择嵌入模型',
|
||||
vectorSimilarityWeight: '向量相似度权重',
|
||||
similarityThreshold: '相似度阈值',
|
||||
topK: '返回结果数量',
|
||||
indexFields: '索引字段',
|
||||
indexFieldsDesc: '选择要包含在搜索索引中的字段',
|
||||
fieldName: '名称',
|
||||
fieldNameDesc: '技能名称',
|
||||
fieldTags: '标签',
|
||||
fieldTagsDesc: '技能标签',
|
||||
fieldDescription: '描述',
|
||||
fieldDescriptionDesc: '技能描述',
|
||||
fieldContent: '内容',
|
||||
fieldContentDesc: '技能内容(如 README)',
|
||||
weight: '权重',
|
||||
pureKeyword: '仅关键词',
|
||||
pureVector: '仅向量',
|
||||
hybrid: '混合搜索',
|
||||
keyword: '关键词',
|
||||
vector: '向量',
|
||||
keywordOnly: '仅关键词',
|
||||
balanced: '平衡',
|
||||
vectorOnly: '仅向量',
|
||||
reindex: '重建索引',
|
||||
reindexing: '重建索引中...',
|
||||
reindexSuccess: '重建索引成功',
|
||||
pleaseSelectEmbeddingModel: '请选择嵌入模型',
|
||||
saveSuccess: '保存成功',
|
||||
saveError: '保存失败',
|
||||
semanticSearchPlaceholder: '按语义搜索技能...',
|
||||
switchToSemantic: '切换到语义搜索',
|
||||
switchToLocal: '切换到本地搜索',
|
||||
},
|
||||
memories: {
|
||||
llmTooltip: '分析对话内容,提取关键信息,并生成结构化的记忆摘要。',
|
||||
embeddingModelTooltip:
|
||||
@@ -2309,6 +2464,8 @@ Tokenizer 会根据所选方式将内容存储为对应的数据结构。`,
|
||||
notFoundSearch: '未查询到搜索应用',
|
||||
memoryTitle: '尚未创建记忆',
|
||||
notFoundMemory: '未查询到记忆',
|
||||
skillsTitle: '尚未创建技能空间',
|
||||
notFoundSkills: '未查询到技能空间',
|
||||
addNow: '立即添加',
|
||||
},
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ export function ActionCell({
|
||||
|
||||
const { downloadFile } = useDownloadFile();
|
||||
const isFolder = isFolderType(record.type);
|
||||
const isSkillsFolder = isFolder && record.name.toLowerCase() === 'skills';
|
||||
const extension = getExtension(record.name);
|
||||
const isKnowledgeBase = isKnowledgeBaseType(record.source_type);
|
||||
|
||||
@@ -77,6 +78,10 @@ export function ActionCell({
|
||||
handleRemoveFile([documentId]);
|
||||
}, [handleRemoveFile, documentId]);
|
||||
|
||||
if (isSkillsFolder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex gap-2 items-center text-text-sub-title-invert opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{isKnowledgeBase || (
|
||||
|
||||
@@ -41,6 +41,7 @@ import { formatDate } from '@/utils/date';
|
||||
import { pick } from 'lodash';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { ActionCell } from './action-cell';
|
||||
import { useHandleConnectToKnowledge, useRenameCurrentFile } from './hooks';
|
||||
import { KnowledgeCell } from './knowledge-cell';
|
||||
@@ -49,6 +50,8 @@ import { UseMoveDocumentShowType } from './use-move-file';
|
||||
import { useNavigateToOtherFolder } from './use-navigate-to-folder';
|
||||
import { isFolderType, isKnowledgeBaseType } from './util';
|
||||
|
||||
declare const __API_PROXY_SCHEME__: string;
|
||||
|
||||
type FilesTableProps = Pick<
|
||||
ReturnType<typeof useFetchFileList>,
|
||||
'files' | 'loading' | 'pagination' | 'setPagination' | 'total'
|
||||
@@ -76,6 +79,7 @@ export function FilesTable({
|
||||
keyPrefix: 'fileManager',
|
||||
});
|
||||
const navigateToOtherFolder = useNavigateToOtherFolder();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
connectToKnowledgeVisible,
|
||||
hideConnectToKnowledgeModal,
|
||||
@@ -93,6 +97,44 @@ export function FilesTable({
|
||||
fileRenameLoading,
|
||||
} = useRenameCurrentFile();
|
||||
|
||||
// Check if skills feature is enabled (only in hybrid or go mode)
|
||||
const isSkillsEnabled = useMemo(() => {
|
||||
const scheme =
|
||||
typeof __API_PROXY_SCHEME__ !== 'undefined'
|
||||
? __API_PROXY_SCHEME__
|
||||
: 'python';
|
||||
return scheme === 'hybrid' || scheme === 'go';
|
||||
}, []);
|
||||
|
||||
// Sort files with skills folder first, then by time
|
||||
// Filter out skills folder if not in hybrid/go mode
|
||||
const sortedFiles = useMemo(() => {
|
||||
if (!files) return [];
|
||||
|
||||
// Filter out skills folder if feature is disabled
|
||||
const filteredFiles = isSkillsEnabled
|
||||
? files
|
||||
: files.filter((file) => {
|
||||
const isSkills =
|
||||
isFolderType(file.type) && file.name.toLowerCase() === 'skills';
|
||||
return !isSkills;
|
||||
});
|
||||
|
||||
return [...filteredFiles].sort((a, b) => {
|
||||
const aIsSkills =
|
||||
isFolderType(a.type) && a.name.toLowerCase() === 'skills';
|
||||
const bIsSkills =
|
||||
isFolderType(b.type) && b.name.toLowerCase() === 'skills';
|
||||
|
||||
// Skills folder always comes first
|
||||
if (aIsSkills && !bIsSkills) return -1;
|
||||
if (!aIsSkills && bIsSkills) return 1;
|
||||
|
||||
// Then sort by create_time desc (newest first)
|
||||
return (b.create_time || 0) - (a.create_time || 0);
|
||||
});
|
||||
}, [files, isSkillsEnabled]);
|
||||
|
||||
const columns: ColumnDef<IFile>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
@@ -141,9 +183,12 @@ export function FilesTable({
|
||||
const type = row.original.type;
|
||||
const id = row.original.id;
|
||||
const isFolder = isFolderType(type);
|
||||
const isSkillsFolder = isFolder && name.toLowerCase() === 'skills';
|
||||
|
||||
const handleNameClick = () => {
|
||||
if (isFolder) {
|
||||
if (isSkillsFolder) {
|
||||
navigate('/files/skills');
|
||||
} else if (isFolder) {
|
||||
navigateToOtherFolder(id);
|
||||
}
|
||||
};
|
||||
@@ -156,7 +201,7 @@ export function FilesTable({
|
||||
onClick={handleNameClick}
|
||||
className="max-w-full p-0 flex justify-start gap-2 text-text-primary"
|
||||
>
|
||||
<FileIcon name={name} type={type} />
|
||||
<FileIcon name={name} type={isSkillsFolder ? 'skills' : type} />
|
||||
|
||||
<span className="truncate">{name}</span>
|
||||
</Button>
|
||||
@@ -250,7 +295,7 @@ export function FilesTable({
|
||||
}, [pagination]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: files || [],
|
||||
data: sortedFiles,
|
||||
columns,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
@@ -263,7 +308,13 @@ export function FilesTable({
|
||||
getRowId: (row) => row.id, // Use file ID instead of row index
|
||||
manualPagination: true, //we're doing manual "server-side" pagination
|
||||
enableRowSelection(row) {
|
||||
return !isKnowledgeBaseType(row.original.source_type);
|
||||
const name = row.original.name;
|
||||
const type = row.original.type;
|
||||
const isSkillsFolder =
|
||||
isFolderType(type) && name.toLowerCase() === 'skills';
|
||||
// Skills folder is not selectable when enabled (it's a special entry)
|
||||
// When disabled, it's already filtered out
|
||||
return !isKnowledgeBaseType(row.original.source_type) && !isSkillsFolder;
|
||||
},
|
||||
state: {
|
||||
sorting,
|
||||
|
||||
118
web/src/pages/skills/components/code-viewer.tsx
Normal file
118
web/src/pages/skills/components/code-viewer.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useIsDarkTheme } from '@/components/theme-provider';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import React, { memo } from 'react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import {
|
||||
oneDark,
|
||||
oneLight,
|
||||
} from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
|
||||
interface CodeViewerProps {
|
||||
content: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
const EXT_LANG: Record<string, string> = {
|
||||
ts: 'typescript',
|
||||
tsx: 'tsx',
|
||||
js: 'javascript',
|
||||
jsx: 'jsx',
|
||||
py: 'python',
|
||||
rs: 'rust',
|
||||
go: 'go',
|
||||
rb: 'ruby',
|
||||
java: 'java',
|
||||
kt: 'kotlin',
|
||||
swift: 'swift',
|
||||
c: 'c',
|
||||
cpp: 'cpp',
|
||||
h: 'c',
|
||||
hpp: 'cpp',
|
||||
cs: 'csharp',
|
||||
css: 'css',
|
||||
scss: 'scss',
|
||||
less: 'less',
|
||||
html: 'html',
|
||||
xml: 'xml',
|
||||
json: 'json',
|
||||
yaml: 'yaml',
|
||||
yml: 'yaml',
|
||||
toml: 'toml',
|
||||
sh: 'bash',
|
||||
bash: 'bash',
|
||||
zsh: 'bash',
|
||||
sql: 'sql',
|
||||
dockerfile: 'docker',
|
||||
lua: 'lua',
|
||||
r: 'r',
|
||||
dart: 'dart',
|
||||
php: 'php',
|
||||
pl: 'perl',
|
||||
ex: 'elixir',
|
||||
exs: 'elixir',
|
||||
erl: 'erlang',
|
||||
hs: 'haskell',
|
||||
vim: 'vim',
|
||||
ini: 'ini',
|
||||
cfg: 'ini',
|
||||
};
|
||||
|
||||
const getLang = (filename: string): string => {
|
||||
const lower = filename.toLowerCase();
|
||||
if (lower === 'dockerfile' || lower.startsWith('dockerfile.'))
|
||||
return 'docker';
|
||||
if (lower === 'makefile' || lower === 'gnumakefile') return 'makefile';
|
||||
const ext = lower.split('.').pop() ?? '';
|
||||
return EXT_LANG[ext] || ext || 'text';
|
||||
};
|
||||
|
||||
const CodeViewer: React.FC<CodeViewerProps> = ({ content, filename }) => {
|
||||
const isDarkTheme = useIsDarkTheme();
|
||||
const language = getLang(filename);
|
||||
|
||||
const lineCount = content.split('\n').length;
|
||||
const charCount = content.length;
|
||||
|
||||
// Format file size
|
||||
const formatSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* File Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-background">
|
||||
<span className="font-semibold">{filename}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">{language}</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{lineCount} lines | {formatSize(charCount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code Content */}
|
||||
<div className="bg-bg-component">
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={isDarkTheme ? oneDark : oneLight}
|
||||
showLineNumbers
|
||||
lineNumberStyle={{ minWidth: 40, paddingRight: 16 }}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '16px',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{content || '// Empty file'}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(CodeViewer);
|
||||
75
web/src/pages/skills/components/create-space-dialog.tsx
Normal file
75
web/src/pages/skills/components/create-space-dialog.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface CreateSpaceDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
spaceInput: string;
|
||||
onSpaceInputChange: (value: string) => void;
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
export const CreateSpaceDialog: React.FC<CreateSpaceDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
spaceInput,
|
||||
onSpaceInputChange,
|
||||
onCreate,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t('skills.createSpaceTitle') || 'Create New Skill Space'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('skills.createSpaceDescription') ||
|
||||
'Create a new space to organize and manage your skills.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<label className="text-sm font-medium mb-2 block">
|
||||
{t('skills.spaceName') || 'Space Name'}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t('skills.spaceNamePlaceholder') || 'e.g., my-space'}
|
||||
value={spaceInput}
|
||||
onChange={(e) => onSpaceInputChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && spaceInput.trim()) {
|
||||
onCreate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
onSpaceInputChange('');
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={onCreate} disabled={!spaceInput.trim()}>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface DeleteSelectedSpacesDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
selectedCount: number;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export const DeleteSelectedSpacesDialog: React.FC<
|
||||
DeleteSelectedSpacesDialogProps
|
||||
> = ({ open, onOpenChange, selectedCount, onDelete }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t('skills.deleteSelectedTitle') || 'Delete Selected Spaces'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('skills.deleteSelectedDescription', { count: selectedCount }) ||
|
||||
`Are you sure you want to delete ${selectedCount} selected spaces? This action cannot be undone.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onDelete}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteSelectedSpacesDialog;
|
||||
60
web/src/pages/skills/components/delete-space-dialog.tsx
Normal file
60
web/src/pages/skills/components/delete-space-dialog.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { SkillSpace } from '../types';
|
||||
|
||||
interface DeleteSpaceDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
spaceToDelete: SkillSpace | null;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export const DeleteSpaceDialog: React.FC<DeleteSpaceDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
spaceToDelete,
|
||||
onDelete,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t('skills.deleteSpaceTitle') || 'Delete Skill Space'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('skills.deleteSpaceDescription') ||
|
||||
'Are you sure you want to delete this skill space? This action cannot be undone and all skills in this space will be permanently deleted.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-text-secondary">
|
||||
{t('skills.deleteSpaceName') || 'Space name'}:{' '}
|
||||
<strong>{spaceToDelete?.name}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onDelete}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteSpaceDialog;
|
||||
147
web/src/pages/skills/components/markdown-viewer.tsx
Normal file
147
web/src/pages/skills/components/markdown-viewer.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useIsDarkTheme } from '@/components/theme-provider';
|
||||
import React, { memo } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import {
|
||||
oneDark,
|
||||
oneLight,
|
||||
} from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
interface MarkdownViewerProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
// Remove YAML frontmatter from content
|
||||
const removeFrontmatter = (content: string): string => {
|
||||
const lines = content.split('\n');
|
||||
if (lines[0]?.trim() === '---') {
|
||||
const endIndex = lines.slice(1).findIndex((line) => line.trim() === '---');
|
||||
if (endIndex !== -1) {
|
||||
return lines.slice(endIndex + 2).join('\n');
|
||||
}
|
||||
}
|
||||
return content;
|
||||
};
|
||||
|
||||
const MarkdownViewer: React.FC<MarkdownViewerProps> = ({ content }) => {
|
||||
const isDarkTheme = useIsDarkTheme();
|
||||
const cleanContent = removeFrontmatter(content);
|
||||
|
||||
return (
|
||||
<div className="markdown-body max-w-[900px] mx-auto">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-3xl font-bold mb-2 text-text-primary">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-2xl font-bold mt-4 mb-2 text-text-primary">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-xl font-bold mt-4 mb-2 text-text-primary">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4 className="text-lg font-bold mt-4 mb-2 text-text-primary">
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="text-text-primary mb-2 leading-relaxed">{children}</p>
|
||||
),
|
||||
code: ({ className, children }) => {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : '';
|
||||
|
||||
if (language) {
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
style={isDarkTheme ? oneDark : oneLight}
|
||||
language={language}
|
||||
PreTag="div"
|
||||
customStyle={{
|
||||
backgroundColor: 'var(--bg-component)',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '1em',
|
||||
}}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<code
|
||||
className={`${className} bg-bg-elevated text-text-primary px-1.5 py-0.5 rounded font-mono text-sm`}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
img: ({ src, alt }) => (
|
||||
<img src={src} alt={alt} className="max-w-full h-auto rounded" />
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<table className="w-full border-collapse mb-4">{children}</table>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="border border-border-secondary px-3 py-2 bg-bg-elevated font-semibold text-text-primary text-left">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="border border-border-secondary px-3 py-2 text-text-primary">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="text-text-primary">{children}</li>
|
||||
),
|
||||
a: ({ children, href }) => (
|
||||
<a href={href} className="text-accent-primary hover:underline">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-border-secondary pl-4 italic text-text-secondary my-4">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
hr: () => <hr className="border-border-secondary my-4" />,
|
||||
pre: ({ children }) => (
|
||||
<pre className="bg-bg-elevated rounded-lg p-4 overflow-x-auto mb-4">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc list-inside mb-4 text-text-primary">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal list-inside mb-4 text-text-primary">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-bold text-text-primary">{children}</strong>
|
||||
),
|
||||
em: ({ children }) => (
|
||||
<em className="italic text-text-primary">{children}</em>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{cleanContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(MarkdownViewer);
|
||||
80
web/src/pages/skills/components/rename-space-dialog.tsx
Normal file
80
web/src/pages/skills/components/rename-space-dialog.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { SkillSpace } from '../types';
|
||||
|
||||
interface RenameSpaceDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
spaceToRename: SkillSpace | null;
|
||||
renameSpaceInput: string;
|
||||
onRenameInputChange: (value: string) => void;
|
||||
onRename: () => void;
|
||||
}
|
||||
|
||||
export const RenameSpaceDialog: React.FC<RenameSpaceDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
spaceToRename,
|
||||
renameSpaceInput,
|
||||
onRenameInputChange,
|
||||
onRename,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t('skills.renameSpaceTitle') || 'Rename Skill Space'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('skills.renameSpaceDescription') ||
|
||||
'Enter a new name for this skill space.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<label className="text-sm font-medium mb-2 block">
|
||||
{t('skills.spaceName') || 'Space Name'}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t('skills.spaceNamePlaceholder') || 'e.g., my-space'}
|
||||
value={renameSpaceInput}
|
||||
onChange={(e) => onRenameInputChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && renameSpaceInput.trim()) {
|
||||
onRename();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onRename}
|
||||
disabled={
|
||||
!renameSpaceInput.trim() ||
|
||||
renameSpaceInput.trim() === spaceToRename?.name
|
||||
}
|
||||
>
|
||||
{t('common.save') || 'Save'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default RenameSpaceDialog;
|
||||
429
web/src/pages/skills/components/search-config-modal.tsx
Normal file
429
web/src/pages/skills/components/search-config-modal.tsx
Normal file
@@ -0,0 +1,429 @@
|
||||
import {
|
||||
SelectWithSearch,
|
||||
SelectWithSearchFlagOptionType,
|
||||
} from '@/components/originui/select-with-search';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Form } from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import message from '@/components/ui/message';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { LlmModelType } from '@/constants/knowledge';
|
||||
import { useSelectLlmOptionsByModelType } from '@/hooks/use-llm-request';
|
||||
import { SkillSearchConfig } from '@/services/skill-space-service';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type {
|
||||
FieldConfig,
|
||||
FieldWeight,
|
||||
SearchConfigModalProps,
|
||||
} from '../types';
|
||||
|
||||
// Use SearchConfig as alias for SkillSearchConfig for backward compatibility
|
||||
type SearchConfig = SkillSearchConfig;
|
||||
|
||||
const defaultFieldConfig: FieldConfig = {
|
||||
name: { enabled: true, weight: 3.0 },
|
||||
tags: { enabled: true, weight: 2.0 },
|
||||
description: { enabled: true, weight: 1.0 },
|
||||
content: { enabled: false, weight: 0.5 },
|
||||
};
|
||||
|
||||
const defaultConfig: SearchConfig = {
|
||||
id: '',
|
||||
tenant_id: '',
|
||||
space_id: '',
|
||||
embd_id: '',
|
||||
vector_similarity_weight: 0.3,
|
||||
similarity_threshold: 0.2,
|
||||
field_config: defaultFieldConfig as Record<string, any>,
|
||||
rerank_id: undefined,
|
||||
tenant_rerank_id: undefined,
|
||||
top_k: 10,
|
||||
index_version: '',
|
||||
status: '',
|
||||
};
|
||||
|
||||
export const SearchConfigModal: React.FC<SearchConfigModalProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
config,
|
||||
onSave,
|
||||
onReindex,
|
||||
loading = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const form = useForm<SearchConfig>({
|
||||
defaultValues: defaultConfig,
|
||||
});
|
||||
const { setValue, watch, handleSubmit, reset } = form;
|
||||
|
||||
const formData = watch();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [reindexing, setReindexing] = useState(false);
|
||||
|
||||
// Get embedding model options from user's configured LLMs
|
||||
const llmOptions = useSelectLlmOptionsByModelType();
|
||||
const embeddingModelOptions = useMemo(() => {
|
||||
return llmOptions[
|
||||
LlmModelType.Embedding
|
||||
] as SelectWithSearchFlagOptionType[];
|
||||
}, [llmOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (config) {
|
||||
reset({
|
||||
...defaultConfig,
|
||||
...config,
|
||||
field_config: {
|
||||
...defaultFieldConfig,
|
||||
...config.field_config,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
reset(defaultConfig);
|
||||
}
|
||||
}
|
||||
}, [open, config, reset]);
|
||||
|
||||
const handleSave = handleSubmit(async (data) => {
|
||||
if (!data.embd_id) {
|
||||
message.error(t('skillSearch.pleaseSelectEmbeddingModel'));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const success = await onSave(data);
|
||||
if (success) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleReindex = async () => {
|
||||
if (!onReindex) return;
|
||||
const currentEmbdId = form.getValues('embd_id');
|
||||
if (!currentEmbdId) {
|
||||
message.error(t('skillSearch.pleaseSelectEmbeddingModel'));
|
||||
return;
|
||||
}
|
||||
setReindexing(true);
|
||||
try {
|
||||
await onReindex(currentEmbdId);
|
||||
} finally {
|
||||
setReindexing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateFieldWeight = (
|
||||
field: keyof FieldConfig,
|
||||
updates: Partial<FieldWeight>,
|
||||
) => {
|
||||
const currentFieldConfig = form.getValues('field_config');
|
||||
setValue(
|
||||
'field_config',
|
||||
{
|
||||
...currentFieldConfig,
|
||||
[field]: {
|
||||
...currentFieldConfig[field],
|
||||
...updates,
|
||||
},
|
||||
},
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
};
|
||||
|
||||
const getSearchTypeLabel = (weight: number) => {
|
||||
if (weight === 0) return t('skillSearch.pureKeyword');
|
||||
if (weight === 1) return t('skillSearch.pureVector');
|
||||
return `${t('skillSearch.hybrid')} (${Math.round((1 - weight) * 100)}% ${t('skillSearch.keyword')} + ${Math.round(weight * 100)}% ${t('skillSearch.vector')})`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('skillSearch.configTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('skillSearch.configDesc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Embedding Model */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="embd_id">{t('skillSearch.embeddingModel')}</Label>
|
||||
<SelectWithSearch
|
||||
value={formData.embd_id}
|
||||
onChange={(value) =>
|
||||
setValue('embd_id', value, { shouldDirty: true })
|
||||
}
|
||||
options={embeddingModelOptions}
|
||||
placeholder={t('skillSearch.embeddingModelPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hybrid Search Weight */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>{t('skillSearch.vectorSimilarityWeight')}</Label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{getSearchTypeLabel(formData.vector_similarity_weight)}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[formData.vector_similarity_weight]}
|
||||
onValueChange={([value]) =>
|
||||
setValue('vector_similarity_weight', value, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{t('skillSearch.keywordOnly')}</span>
|
||||
<span>{t('skillSearch.balanced')}</span>
|
||||
<span>{t('skillSearch.vectorOnly')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Similarity Threshold */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>{t('skillSearch.similarityThreshold')}</Label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formData.similarity_threshold.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[formData.similarity_threshold]}
|
||||
onValueChange={([value]) =>
|
||||
setValue('similarity_threshold', value, { shouldDirty: true })
|
||||
}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top K */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="top_k">{t('skillSearch.topK')}</Label>
|
||||
<Input
|
||||
id="top_k"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={formData.top_k}
|
||||
onChange={(e) =>
|
||||
setValue('top_k', parseInt(e.target.value) || 10, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Field Configuration */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">
|
||||
{t('skillSearch.indexFields')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('skillSearch.indexFieldsDesc')}
|
||||
</p>
|
||||
|
||||
{/* Name Field */}
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={formData.field_config.name.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updateFieldWeight('name', { enabled: checked })
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium">{t('skillSearch.fieldName')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('skillSearch.fieldNameDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('skillSearch.weight')}:
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
step={0.1}
|
||||
min={0}
|
||||
max={10}
|
||||
value={formData.field_config.name.weight}
|
||||
onChange={(e) =>
|
||||
updateFieldWeight('name', {
|
||||
weight: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
className="w-20"
|
||||
disabled={!formData.field_config.name.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags Field */}
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={formData.field_config.tags.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updateFieldWeight('tags', { enabled: checked })
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium">{t('skillSearch.fieldTags')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('skillSearch.fieldTagsDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('skillSearch.weight')}:
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
step={0.1}
|
||||
min={0}
|
||||
max={10}
|
||||
value={formData.field_config.tags.weight}
|
||||
onChange={(e) =>
|
||||
updateFieldWeight('tags', {
|
||||
weight: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
className="w-20"
|
||||
disabled={!formData.field_config.tags.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description Field */}
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={formData.field_config.description.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updateFieldWeight('description', { enabled: checked })
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t('skillSearch.fieldDescription')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('skillSearch.fieldDescriptionDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('skillSearch.weight')}:
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
step={0.1}
|
||||
min={0}
|
||||
max={10}
|
||||
value={formData.field_config.description.weight}
|
||||
onChange={(e) =>
|
||||
updateFieldWeight('description', {
|
||||
weight: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
className="w-20"
|
||||
disabled={!formData.field_config.description.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Field */}
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={formData.field_config.content.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updateFieldWeight('content', { enabled: checked })
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t('skillSearch.fieldContent')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('skillSearch.fieldContentDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('skillSearch.weight')}:
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
step={0.1}
|
||||
min={0}
|
||||
max={10}
|
||||
value={formData.field_config.content.weight}
|
||||
onChange={(e) =>
|
||||
updateFieldWeight('content', {
|
||||
weight: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
className="w-20"
|
||||
disabled={!formData.field_config.content.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
{onReindex && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReindex}
|
||||
disabled={reindexing || loading}
|
||||
>
|
||||
{reindexing
|
||||
? t('skillSearch.reindexing')
|
||||
: t('skillSearch.reindex')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving || loading}>
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchConfigModal;
|
||||
168
web/src/pages/skills/components/skill-card.tsx
Normal file
168
web/src/pages/skills/components/skill-card.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import SvgIcon from '@/components/svg-icon';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { Eye, Tag, Trash2 } from 'lucide-react';
|
||||
import React, { memo } from 'react';
|
||||
import type { Skill } from '../types';
|
||||
|
||||
interface SkillCardProps {
|
||||
skill: Skill;
|
||||
onView: (skill: Skill) => void;
|
||||
onDelete: (skillId: string, skillName: string, folderId?: string) => void;
|
||||
formatRelative: (timestamp: number) => string;
|
||||
}
|
||||
|
||||
const SkillCard: React.FC<SkillCardProps> = ({
|
||||
skill,
|
||||
onView,
|
||||
onDelete,
|
||||
formatRelative,
|
||||
}) => {
|
||||
const fileCount = skill.files.filter((f) => !f.is_dir).length;
|
||||
const filesLoading = skill.files.length === 0 && (skill as any)._folderId;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card
|
||||
className="cursor-pointer hover:shadow-md transition-all bg-bg-card border border-border rounded-xl p-4"
|
||||
onClick={() => onView(skill)}
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<SvgIcon name="home-icon/skill-folder" width={24} height={24} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex justify-between items-start">
|
||||
<h5 className="font-semibold text-base m-0 mb-2 truncate pr-2">
|
||||
{skill.name}
|
||||
</h5>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onView(skill);
|
||||
}}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<AlertDialog>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
<Trash2 className="size-4 text-state-error" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete</TooltipContent>
|
||||
</Tooltip>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Skill</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this skill? This action
|
||||
cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() =>
|
||||
onDelete(
|
||||
skill.id,
|
||||
skill.name,
|
||||
(skill as any)._folderId,
|
||||
)
|
||||
}
|
||||
className="bg-state-error hover:bg-state-error/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{skill.description && (
|
||||
<p className="text-text-secondary text-sm mb-3 line-clamp-2">
|
||||
{skill.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{skill.metadata?.tags?.slice(0, 4).map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{skill.metadata?.tags && skill.metadata.tags.length > 4 && (
|
||||
<Badge variant="secondary">
|
||||
+{skill.metadata.tags.length - 4}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<span className="text-text-secondary text-xs">
|
||||
{filesLoading
|
||||
? '...'
|
||||
: fileCount > 0
|
||||
? `${fileCount} files`
|
||||
: ''}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{skill.metadata?.version && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Tag className="size-3 mr-1" />v{skill.metadata.version}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-text-secondary text-xs">
|
||||
{formatRelative(skill.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(SkillCard);
|
||||
505
web/src/pages/skills/components/skill-detail.tsx
Normal file
505
web/src/pages/skills/components/skill-detail.tsx
Normal file
@@ -0,0 +1,505 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Spin } from '@/components/ui/spin';
|
||||
import { TreeDataItem, TreeView } from '@/components/ui/tree-view';
|
||||
import {
|
||||
ArrowBigLeft,
|
||||
ChevronDown,
|
||||
FileCode,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
Tag,
|
||||
} from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { isMarkdownFile } from '../hooks';
|
||||
import type { Skill, SkillFileEntry } from '../types';
|
||||
import CodeViewer from './code-viewer';
|
||||
import MarkdownViewer from './markdown-viewer';
|
||||
|
||||
interface SkillDetailProps {
|
||||
skill: Skill | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
getFileContent: (
|
||||
skillId: string,
|
||||
filePath: string,
|
||||
version?: string,
|
||||
skillObj?: Skill,
|
||||
) => Promise<string | null>;
|
||||
getVersionFiles?: (
|
||||
skillId: string,
|
||||
version: string,
|
||||
skillObj?: Skill,
|
||||
) => Promise<SkillFileEntry[]>;
|
||||
}
|
||||
|
||||
const getFileIcon = (filename: string, isDir: boolean) => {
|
||||
if (isDir) return FolderOpen;
|
||||
if (isMarkdownFile(filename)) return FileCode;
|
||||
return FileText;
|
||||
};
|
||||
|
||||
// Build tree from flat file list
|
||||
const buildFileTree = (files: SkillFileEntry[]): TreeDataItem[] => {
|
||||
const root: TreeDataItem[] = [];
|
||||
const map: Record<string, TreeDataItem> = {};
|
||||
|
||||
// Sort files: directories first, then alphabetically
|
||||
const sortedFiles = [...files].sort((a, b) => {
|
||||
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
sortedFiles.forEach((file) => {
|
||||
const parts = file.path.split('/');
|
||||
const name = parts[parts.length - 1];
|
||||
|
||||
const node: TreeDataItem = {
|
||||
name: name,
|
||||
id: file.path,
|
||||
icon: getFileIcon(name, file.is_dir),
|
||||
};
|
||||
|
||||
if (file.is_dir) {
|
||||
node.children = [];
|
||||
}
|
||||
|
||||
map[file.path] = node;
|
||||
|
||||
if (parts.length === 1) {
|
||||
root.push(node);
|
||||
} else {
|
||||
const parentPath = parts.slice(0, -1).join('/');
|
||||
const parent = map[parentPath];
|
||||
if (parent && parent.children) {
|
||||
parent.children.push(node);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return root;
|
||||
};
|
||||
|
||||
const SkillDetail: React.FC<SkillDetailProps> = ({
|
||||
skill,
|
||||
open,
|
||||
onClose,
|
||||
getFileContent,
|
||||
getVersionFiles,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [fileContent, setFileContent] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedVersion, setSelectedVersion] = useState<string>('');
|
||||
const [versionFiles, setVersionFiles] = useState<SkillFileEntry[]>([]);
|
||||
const [versionLoading, setVersionLoading] = useState(false);
|
||||
|
||||
// Check if skill has multiple versions
|
||||
const hasVersions = skill?.versions && skill.versions.length > 0;
|
||||
const availableVersions = skill?.versions || [];
|
||||
|
||||
// Reset state when skill changes or drawer opens/closes
|
||||
useEffect(() => {
|
||||
if (open && skill) {
|
||||
// Initialize version
|
||||
// For multi-version skill, check if metadata.version is in availableVersions
|
||||
// If not, use the first available version
|
||||
let defaultVersion = skill.metadata?.version || '';
|
||||
if (hasVersions) {
|
||||
if (!defaultVersion || !availableVersions.includes(defaultVersion)) {
|
||||
defaultVersion = availableVersions[0];
|
||||
}
|
||||
}
|
||||
setSelectedVersion(defaultVersion);
|
||||
} else {
|
||||
// Reset when closed
|
||||
setSelectedVersion('');
|
||||
setVersionFiles([]);
|
||||
setVersionLoading(false);
|
||||
setSelectedFile(null);
|
||||
setFileContent('');
|
||||
}
|
||||
}, [
|
||||
open,
|
||||
skill?.id,
|
||||
hasVersions,
|
||||
skill?.metadata?.version,
|
||||
availableVersions,
|
||||
]);
|
||||
|
||||
const resolvedVersion = useMemo(() => {
|
||||
if (!skill) return '';
|
||||
return (
|
||||
selectedVersion || skill.metadata?.version || skill.versions?.[0] || ''
|
||||
);
|
||||
}, [selectedVersion, skill?.id, skill?.metadata?.version, skill?.versions]);
|
||||
|
||||
// Load files when version or skill changes
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
|
||||
const loadVersionFiles = async () => {
|
||||
if (!skill || !getVersionFiles) {
|
||||
if (isActive) {
|
||||
setVersionFiles([]);
|
||||
setVersionLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if skill has _folderId (required for file operations)
|
||||
if (!(skill as any)._folderId) {
|
||||
console.warn(
|
||||
`[Skill Detail] Skill "${skill.name}" has no folder_id. ` +
|
||||
'Please reindex skills in settings to fix this issue.',
|
||||
);
|
||||
if (isActive) {
|
||||
setVersionFiles([]);
|
||||
setVersionLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's the default version and skill.files is not empty, use skill.files
|
||||
// Only for local skills (not search results which have empty files array)
|
||||
if (
|
||||
resolvedVersion ===
|
||||
(skill.metadata?.version || skill.versions?.[0] || '') &&
|
||||
skill.files.length > 0 &&
|
||||
skill.source_type !== 'search'
|
||||
) {
|
||||
if (isActive) {
|
||||
setVersionFiles(skill.files);
|
||||
setVersionLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Load files for the selected version
|
||||
if (isActive) setVersionLoading(true);
|
||||
try {
|
||||
const versionToLoad = resolvedVersion;
|
||||
// Pass skill object to handle search results not in skills state
|
||||
const files = await getVersionFiles(skill.id, versionToLoad, skill);
|
||||
if (isActive) setVersionFiles(files);
|
||||
} catch (error) {
|
||||
console.error('Failed to load version files:', error);
|
||||
if (isActive) setVersionFiles([]);
|
||||
} finally {
|
||||
if (isActive) setVersionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadVersionFiles();
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [
|
||||
skill?.id,
|
||||
skill?.source_type,
|
||||
skill?.metadata?.version,
|
||||
skill?.versions,
|
||||
(skill as any)?._folderId,
|
||||
skill?.files,
|
||||
resolvedVersion,
|
||||
getVersionFiles,
|
||||
]);
|
||||
|
||||
// Use version files if available, otherwise use skill.files
|
||||
const currentFiles = useMemo(() => {
|
||||
if (hasVersions && versionFiles.length > 0) {
|
||||
return versionFiles;
|
||||
}
|
||||
if (skill?.files && skill.files.length > 0) {
|
||||
return skill.files;
|
||||
}
|
||||
return versionFiles;
|
||||
}, [skill?.files, versionFiles, hasVersions]);
|
||||
|
||||
const treeData = useMemo(() => buildFileTree(currentFiles), [currentFiles]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (item: TreeDataItem | undefined) => {
|
||||
if (!skill || !item) return;
|
||||
|
||||
const file = currentFiles.find((f) => f.path === item.id);
|
||||
if (!file || file.is_dir) return;
|
||||
|
||||
setSelectedFile(item.id);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Pass skill object to handle search results not in skills state
|
||||
const content = await getFileContent(
|
||||
skill.id,
|
||||
file.path,
|
||||
selectedVersion || undefined,
|
||||
skill,
|
||||
);
|
||||
setFileContent(content || '');
|
||||
} catch (error) {
|
||||
console.error('Failed to load file content');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[skill, currentFiles, selectedVersion, getFileContent],
|
||||
);
|
||||
|
||||
// Auto-select SKILL.md or README on open
|
||||
useEffect(() => {
|
||||
if (open && skill && currentFiles.length > 0 && !selectedFile) {
|
||||
// Priority: SKILL.md > README.md > index.md
|
||||
const priorityFiles = ['skill.md', 'readme.md', 'index.md'];
|
||||
let targetFile: SkillFileEntry | undefined;
|
||||
|
||||
for (const priority of priorityFiles) {
|
||||
targetFile = currentFiles.find(
|
||||
(f) => f.name.toLowerCase() === priority && !f.is_dir,
|
||||
);
|
||||
if (targetFile) break;
|
||||
}
|
||||
|
||||
if (targetFile) {
|
||||
handleSelect({ id: targetFile.path } as TreeDataItem);
|
||||
}
|
||||
}
|
||||
}, [open, skill?.id, currentFiles.length]);
|
||||
|
||||
const renderFileContent = () => {
|
||||
if (!selectedFile) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-text-secondary">
|
||||
<FileText className="size-12 mb-4 opacity-50" />
|
||||
<p>Select a file to view</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-10">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filename = selectedFile.split('/').pop() || '';
|
||||
|
||||
if (isMarkdownFile(filename)) {
|
||||
return <MarkdownViewer content={fileContent} />;
|
||||
}
|
||||
|
||||
return <CodeViewer content={fileContent} filename={filename} />;
|
||||
};
|
||||
|
||||
if (!open || !skill) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-bg-base">
|
||||
{/* Page Header with Back Button - aligned with container below */}
|
||||
<header className="flex items-center justify-between px-6 py-4 bg-bg-base">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
<ArrowBigLeft />
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasVersions ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs gap-1"
|
||||
disabled={versionLoading}
|
||||
>
|
||||
<GitBranch className="size-3.5" />
|
||||
<span className="max-w-[120px] truncate">
|
||||
{t('skills.versionHistory')}
|
||||
</span>
|
||||
<ChevronDown className="size-3 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[200px]">
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-text-secondary border-b border-border mb-1">
|
||||
{t('skills.selectVersion')}
|
||||
</div>
|
||||
{availableVersions.map((version, index) => (
|
||||
<DropdownMenuItem
|
||||
key={version}
|
||||
onClick={() => setSelectedVersion(version)}
|
||||
className={`flex items-center justify-between cursor-pointer ${
|
||||
selectedVersion === version ? 'bg-accent-primary/10' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="size-3 text-text-secondary" />
|
||||
<span
|
||||
className={
|
||||
selectedVersion === version ? 'font-medium' : ''
|
||||
}
|
||||
>
|
||||
v{version}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{index === 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] h-4 px-1"
|
||||
>
|
||||
{t('skills.latest')}
|
||||
</Badge>
|
||||
)}
|
||||
{selectedVersion === version && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-accent-primary" />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
skill.metadata?.version && (
|
||||
<Badge variant="outline" className="text-xs h-8 px-2">
|
||||
<Tag className="size-3 mr-1" />v{skill.metadata.version}
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content Area with Border */}
|
||||
<div className="flex-1 mx-6 mb-4 border-0.5 border-border-button rounded-lg overflow-hidden bg-bg-base">
|
||||
<div className="flex h-full">
|
||||
{/* Sidebar - File Tree */}
|
||||
<div className="w-80 border-r border-r-0.5 border-border-button flex flex-col bg-bg-base">
|
||||
<div className="p-4 border-b border-b-0.5 border-border-button bg-bg-base">
|
||||
<h2 className="font-semibold text-lg truncate">{skill.name}</h2>
|
||||
{skill.metadata?.description && (
|
||||
<p className="text-text-secondary text-xs mt-2">
|
||||
{skill.metadata.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{skill.metadata?.tags?.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version History Section */}
|
||||
{hasVersions && (
|
||||
<div className="border-b border-b-0.5 border-border-button bg-bg-secondary/30">
|
||||
<div className="px-4 py-2 flex items-center gap-2 text-xs font-medium text-text-secondary">
|
||||
<GitBranch className="size-3.5" />
|
||||
<span>{t('skills.versionHistory')}</span>
|
||||
<span className="text-text-tertiary">
|
||||
({availableVersions.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-2 pb-2 max-h-[120px] overflow-y-auto">
|
||||
{availableVersions.map((version, index) => (
|
||||
<button
|
||||
key={version}
|
||||
onClick={() => setSelectedVersion(version)}
|
||||
disabled={versionLoading}
|
||||
className={`w-full flex items-center justify-between px-2 py-1.5 text-xs rounded-md transition-colors ${
|
||||
selectedVersion === version
|
||||
? 'bg-accent-primary/10 text-accent-primary'
|
||||
: 'hover:bg-bg-secondary text-text-secondary'
|
||||
} ${versionLoading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="size-3" />
|
||||
<span
|
||||
className={
|
||||
selectedVersion === version ? 'font-medium' : ''
|
||||
}
|
||||
>
|
||||
v{version}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{index === 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] h-4 px-1"
|
||||
>
|
||||
{t('skills.latest')}
|
||||
</Badge>
|
||||
)}
|
||||
{selectedVersion === version && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-accent-primary" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
{/* File Tree */}
|
||||
{versionLoading ? (
|
||||
<div className="flex justify-center py-10">
|
||||
<Spin size="default" />
|
||||
</div>
|
||||
) : currentFiles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-text-secondary">
|
||||
<FolderOpen className="size-8 mb-2 opacity-50" />
|
||||
<p className="text-sm">
|
||||
{skill?.source_type === 'search' &&
|
||||
!(skill as any)._folderId
|
||||
? 'Please reindex skills in settings to view files'
|
||||
: t('skills.noFiles')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-text-secondary text-xs pl-2 mb-2">
|
||||
{t('skills.files')}
|
||||
{currentFiles.length > 0 && (
|
||||
<span className="ml-1 text-text-tertiary">
|
||||
({currentFiles.filter((f) => !f.is_dir).length} files)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<TreeView
|
||||
data={treeData}
|
||||
initialSelectedItemId={selectedFile || undefined}
|
||||
onSelectChange={handleSelect}
|
||||
expandAll
|
||||
defaultNodeIcon={FolderOpen}
|
||||
defaultLeafIcon={FileText}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 overflow-auto p-6 bg-bg-base">
|
||||
{renderFileContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(SkillDetail);
|
||||
1055
web/src/pages/skills/components/upload-modal.tsx
Normal file
1055
web/src/pages/skills/components/upload-modal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1567
web/src/pages/skills/hooks.ts
Normal file
1567
web/src/pages/skills/hooks.ts
Normal file
File diff suppressed because it is too large
Load Diff
1199
web/src/pages/skills/index.tsx
Normal file
1199
web/src/pages/skills/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
166
web/src/pages/skills/types.ts
Normal file
166
web/src/pages/skills/types.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
// Skill types for Skill Space
|
||||
|
||||
// ============================================================================
|
||||
// Core Skill Types
|
||||
// ============================================================================
|
||||
|
||||
export interface Skill {
|
||||
id: string; // Skill name (used as identifier, consistent with search results)
|
||||
name: string;
|
||||
description: string;
|
||||
source_type: 'local' | 'git' | 'central' | 'search';
|
||||
source_ref?: string;
|
||||
central_path?: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
files: SkillFileEntry[];
|
||||
metadata?: SkillMetadata;
|
||||
versions?: string[]; // Available versions (for versioned skills)
|
||||
_folderId?: string; // Internal: file system folder ID for file operations
|
||||
}
|
||||
|
||||
export interface SkillSpace {
|
||||
id: string;
|
||||
name: string;
|
||||
folder_id?: string;
|
||||
create_time?: number;
|
||||
}
|
||||
|
||||
export interface SkillFileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
is_dir: boolean;
|
||||
size: number;
|
||||
content?: string;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Skill Metadata Types
|
||||
// ============================================================================
|
||||
|
||||
export interface SkillMetadata {
|
||||
// Basic fields
|
||||
name?: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
author?: string;
|
||||
tags?: string[];
|
||||
tools?: string[];
|
||||
|
||||
// Legacy fields for backward compatibility
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Payload Types
|
||||
// ============================================================================
|
||||
|
||||
export interface SkillUploadPayload {
|
||||
name: string;
|
||||
description?: string;
|
||||
files: { path: string; content: string }[];
|
||||
}
|
||||
|
||||
export interface SkillUpdatePayload {
|
||||
id: string;
|
||||
description?: string;
|
||||
metadata?: SkillMetadata;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Validation Types
|
||||
// ============================================================================
|
||||
|
||||
export interface SkillValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
details?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UI Types
|
||||
// ============================================================================
|
||||
|
||||
export type ViewMode = 'grid' | 'list';
|
||||
|
||||
export interface SkillCardProps {
|
||||
skill: Skill;
|
||||
onView: (skill: Skill) => void;
|
||||
onDelete: (skillId: string, skillName: string, folderId?: string) => void;
|
||||
formatRelative: (timestamp: number) => string;
|
||||
}
|
||||
|
||||
export interface SkillDetailProps {
|
||||
skill: Skill | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
getFileContent: (
|
||||
skillId: string,
|
||||
filePath: string,
|
||||
version?: string,
|
||||
) => Promise<string | null>;
|
||||
getVersionFiles?: (
|
||||
skillId: string,
|
||||
version: string,
|
||||
) => Promise<SkillFileEntry[]>;
|
||||
}
|
||||
|
||||
export interface UploadModalProps {
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
onUpload: (name: string, version: string, files: File[]) => Promise<boolean>;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Skill Search Types
|
||||
// ============================================================================
|
||||
|
||||
export interface FieldWeight {
|
||||
enabled: boolean;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface FieldConfig {
|
||||
name: FieldWeight;
|
||||
tags: FieldWeight;
|
||||
description: FieldWeight;
|
||||
content: FieldWeight;
|
||||
}
|
||||
|
||||
// Re-export SkillSearchConfig from service to ensure consistency
|
||||
export { SkillSearchConfig } from '@/services/skill-space-service';
|
||||
|
||||
export interface SkillSearchResult {
|
||||
skill_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
score: number;
|
||||
bm25_score?: number;
|
||||
vector_score?: number;
|
||||
}
|
||||
|
||||
export interface SkillSearchResponse {
|
||||
results: SkillSearchResult[];
|
||||
total: number;
|
||||
query: string;
|
||||
search_type: string;
|
||||
}
|
||||
|
||||
export interface SearchConfigModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
config?: SkillSearchConfig;
|
||||
onSave: (config: SkillSearchConfig) => Promise<boolean>;
|
||||
onReindex?: (embdId: string) => Promise<boolean>;
|
||||
loading?: boolean;
|
||||
}
|
||||
14
web/src/pages/skills/utils.ts
Normal file
14
web/src/pages/skills/utils.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Skill Space - Utility exports
|
||||
// Re-export validation utilities for external use
|
||||
|
||||
export {
|
||||
DEFAULT_IGNORE_PATTERNS,
|
||||
filterIgnoredFiles,
|
||||
isMacJunkPath,
|
||||
isTextFile,
|
||||
parseFrontmatter,
|
||||
sanitizeRelPath,
|
||||
shouldIgnore,
|
||||
validateSkillFormat,
|
||||
validateSkillStructure,
|
||||
} from './validation';
|
||||
563
web/src/pages/skills/validation.ts
Normal file
563
web/src/pages/skills/validation.ts
Normal file
@@ -0,0 +1,563 @@
|
||||
// Skill validation utilities
|
||||
|
||||
import type {
|
||||
SkillFileEntry,
|
||||
SkillMetadata,
|
||||
SkillValidationResult,
|
||||
} from './types';
|
||||
|
||||
// ============================================================================
|
||||
// Text File Validation
|
||||
// ============================================================================
|
||||
|
||||
const TEXT_FILE_EXTENSIONS = [
|
||||
'md',
|
||||
'mdx',
|
||||
'txt',
|
||||
'json',
|
||||
'json5',
|
||||
'yaml',
|
||||
'yml',
|
||||
'toml',
|
||||
'js',
|
||||
'cjs',
|
||||
'mjs',
|
||||
'ts',
|
||||
'tsx',
|
||||
'jsx',
|
||||
'py',
|
||||
'sh',
|
||||
'rb',
|
||||
'go',
|
||||
'rs',
|
||||
'swift',
|
||||
'kt',
|
||||
'java',
|
||||
'cs',
|
||||
'cpp',
|
||||
'c',
|
||||
'h',
|
||||
'hpp',
|
||||
'sql',
|
||||
'csv',
|
||||
'ini',
|
||||
'cfg',
|
||||
'env',
|
||||
'xml',
|
||||
'html',
|
||||
'css',
|
||||
'scss',
|
||||
'sass',
|
||||
'svg',
|
||||
] as const;
|
||||
|
||||
const TEXT_FILE_EXTENSION_SET = new Set<string>(TEXT_FILE_EXTENSIONS);
|
||||
|
||||
const TEXT_CONTENT_TYPES = [
|
||||
'application/json',
|
||||
'application/xml',
|
||||
'application/yaml',
|
||||
'application/x-yaml',
|
||||
'application/toml',
|
||||
'application/javascript',
|
||||
'application/typescript',
|
||||
'application/markdown',
|
||||
'image/svg+xml',
|
||||
] as const;
|
||||
|
||||
const TEXT_CONTENT_TYPE_SET = new Set<string>(TEXT_CONTENT_TYPES);
|
||||
|
||||
/**
|
||||
* Check if a content type is text-based
|
||||
*/
|
||||
export function isTextContentType(contentType: string): boolean {
|
||||
if (!contentType) return false;
|
||||
const normalized = contentType.split(';', 1)[0]?.trim().toLowerCase() ?? '';
|
||||
if (!normalized) return false;
|
||||
if (normalized.startsWith('text/')) return true;
|
||||
return TEXT_CONTENT_TYPE_SET.has(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is a text file based on its extension
|
||||
*/
|
||||
export function isTextFile(filePath: string, contentType?: string): boolean {
|
||||
// Check content type first
|
||||
if (contentType && isTextContentType(contentType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check extension
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
|
||||
if (!ext) return false;
|
||||
return TEXT_FILE_EXTENSION_SET.has(ext);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Path Sanitization
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Sanitize relative path to prevent directory traversal attacks
|
||||
*/
|
||||
export function sanitizeRelPath(path: string): string | null {
|
||||
const normalized = path.replace(/^\.\/+/, '').replace(/^\/+/, '');
|
||||
if (!normalized || normalized.endsWith('/')) return null;
|
||||
if (normalized.includes('..') || normalized.includes('\\')) return null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is Mac junk file (should be ignored)
|
||||
*/
|
||||
export function isMacJunkPath(path: string): boolean {
|
||||
const normalized = path.toLowerCase();
|
||||
// Check for .DS_Store files (any location, any case)
|
||||
if (normalized === '.ds_store' || normalized.endsWith('/.ds_store')) {
|
||||
return true;
|
||||
}
|
||||
// Check for __MACOSX directories
|
||||
if (normalized.startsWith('__macosx/') || normalized === '__macosx') {
|
||||
return true;
|
||||
}
|
||||
// Check for resource fork files (._*)
|
||||
if (normalized.startsWith('._') || normalized.includes('/._')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if files contain any junk/temporary files
|
||||
* Returns an array of junk file paths found
|
||||
*/
|
||||
export function findJunkFiles(files: File[]): string[] {
|
||||
const junkFiles: string[] = [];
|
||||
for (const file of files) {
|
||||
const path = file.webkitRelativePath || file.name;
|
||||
const sanitized = sanitizeRelPath(path);
|
||||
if (sanitized && isMacJunkPath(sanitized)) {
|
||||
junkFiles.push(path);
|
||||
}
|
||||
}
|
||||
return junkFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if files contain any junk/temporary files
|
||||
* Returns true if any junk files are found
|
||||
*/
|
||||
export function hasJunkFiles(files: File[]): boolean {
|
||||
return findJunkFiles(files).length > 0;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SKILL.md Validation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parse YAML frontmatter from markdown content
|
||||
* Returns metadata and body content
|
||||
*/
|
||||
export function parseFrontmatter(content: string): {
|
||||
metadata: SkillMetadata;
|
||||
body: string;
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
const lines = content.split('\n');
|
||||
const metadata: SkillMetadata = {};
|
||||
|
||||
// Check frontmatter start
|
||||
if (lines[0]?.trim() !== '---') {
|
||||
return {
|
||||
metadata,
|
||||
body: content,
|
||||
valid: false,
|
||||
error: 'invalid_frontmatter',
|
||||
};
|
||||
}
|
||||
|
||||
// Find end of frontmatter
|
||||
const endIndex = lines.slice(1).findIndex((line) => line.trim() === '---');
|
||||
if (endIndex === -1) {
|
||||
return {
|
||||
metadata,
|
||||
body: content,
|
||||
valid: false,
|
||||
error: 'invalid_frontmatter',
|
||||
};
|
||||
}
|
||||
|
||||
const metaLines = lines.slice(1, endIndex + 1);
|
||||
const body = lines.slice(endIndex + 2).join('\n');
|
||||
|
||||
// Parse YAML-like format
|
||||
let currentKey = '';
|
||||
let currentIndent = 0;
|
||||
|
||||
for (const line of metaLines) {
|
||||
if (!line.trim() || line.trim().startsWith('#')) continue;
|
||||
|
||||
const indent = line.search(/\S/);
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// Handle nested objects (simple implementation)
|
||||
const colonMatch = trimmedLine.match(/^(\w+):\s*(.*)$/);
|
||||
if (colonMatch) {
|
||||
const [, key, value] = colonMatch;
|
||||
currentKey = key;
|
||||
currentIndent = indent;
|
||||
|
||||
if (value) {
|
||||
// Parse value
|
||||
metadata[key] = parseYamlValue(value);
|
||||
} else {
|
||||
// Could be an object or array start
|
||||
metadata[key] = {};
|
||||
}
|
||||
} else if (currentKey && indent > currentIndent) {
|
||||
// Nested property
|
||||
const nestedMatch = trimmedLine.match(/^(\w+):\s*(.*)$/);
|
||||
if (nestedMatch) {
|
||||
const [, nestedKey, nestedValue] = nestedMatch;
|
||||
if (
|
||||
typeof metadata[currentKey] === 'object' &&
|
||||
metadata[currentKey] !== null
|
||||
) {
|
||||
(metadata[currentKey] as Record<string, unknown>)[nestedKey] =
|
||||
parseYamlValue(nestedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { metadata, body, valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a YAML value string
|
||||
*/
|
||||
function parseYamlValue(value: string): unknown {
|
||||
const trimmed = value.trim();
|
||||
|
||||
// Boolean
|
||||
if (trimmed === 'true') return true;
|
||||
if (trimmed === 'false') return false;
|
||||
|
||||
// Null
|
||||
if (trimmed === 'null' || trimmed === '~') return null;
|
||||
|
||||
// Number
|
||||
if (/^-?\d+$/.test(trimmed)) return parseInt(trimmed, 10);
|
||||
if (/^-?\d+\.\d+$/.test(trimmed)) return parseFloat(trimmed);
|
||||
|
||||
// Array
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
return trimmed
|
||||
.slice(1, -1)
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s)
|
||||
.map(parseYamlValue);
|
||||
}
|
||||
|
||||
// Quoted string
|
||||
if (
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
||||
) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
|
||||
// Unquoted string
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Validation Function
|
||||
// ============================================================================
|
||||
|
||||
const MAX_TOTAL_SIZE = 50 * 1024 * 1024; // 50MB
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB per file
|
||||
|
||||
/**
|
||||
* Validate skill format
|
||||
* This is the main validation function used before upload
|
||||
*/
|
||||
export async function validateSkillFormat(
|
||||
files: File[],
|
||||
): Promise<SkillValidationResult> {
|
||||
// Check if there are any files
|
||||
if (files.length === 0) {
|
||||
return { valid: false, error: 'no_files' };
|
||||
}
|
||||
|
||||
// Check total size
|
||||
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
|
||||
if (totalSize > MAX_TOTAL_SIZE) {
|
||||
return { valid: false, error: 'total_size_exceeded' };
|
||||
}
|
||||
|
||||
// Check individual file sizes
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return { valid: false, error: 'file_too_large' };
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize and filter paths
|
||||
const validFiles: File[] = [];
|
||||
for (const file of files) {
|
||||
const path = file.webkitRelativePath || file.name;
|
||||
const sanitized = sanitizeRelPath(path);
|
||||
|
||||
if (!sanitized) {
|
||||
return { valid: false, error: 'invalid_path' };
|
||||
}
|
||||
|
||||
if (
|
||||
isMacJunkPath(sanitized) ||
|
||||
shouldIgnore(sanitized, DEFAULT_IGNORE_PATTERNS)
|
||||
) {
|
||||
continue; // Skip junk and ignored files
|
||||
}
|
||||
|
||||
validFiles.push(file);
|
||||
}
|
||||
|
||||
// Find SKILL.md file
|
||||
const skillMdFile = validFiles.find((f) => {
|
||||
const path = f.webkitRelativePath || f.name;
|
||||
const normalized = path.toLowerCase();
|
||||
return normalized === 'skill.md' || normalized.endsWith('/skill.md');
|
||||
});
|
||||
|
||||
if (!skillMdFile) {
|
||||
return { valid: false, error: 'missing_skill_md' };
|
||||
}
|
||||
|
||||
// Read and validate SKILL.md content
|
||||
try {
|
||||
const content = await readFileAsText(skillMdFile);
|
||||
const {
|
||||
metadata,
|
||||
valid: frontmatterValid,
|
||||
error: frontmatterError,
|
||||
} = parseFrontmatter(content);
|
||||
|
||||
if (!frontmatterValid) {
|
||||
return { valid: false, error: frontmatterError || 'invalid_frontmatter' };
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!metadata.name) {
|
||||
return { valid: false, error: 'missing_name' };
|
||||
}
|
||||
|
||||
// Validate name format (slug format: lowercase, URL-safe)
|
||||
if (!/^[a-z0-9][a-z0-9_-]*$/.test(metadata.name)) {
|
||||
return { valid: false, error: 'invalid_name_format' };
|
||||
}
|
||||
|
||||
// Validate version if provided (should be semver)
|
||||
if (metadata.version) {
|
||||
const version = String(metadata.version);
|
||||
// Simple semver check: x.y.z format
|
||||
if (!/^\d+\.\d+\.\d+/.test(version)) {
|
||||
return { valid: false, error: 'invalid_version' };
|
||||
}
|
||||
}
|
||||
|
||||
// Validate all files are text-based
|
||||
for (const file of validFiles) {
|
||||
const path = file.webkitRelativePath || file.name;
|
||||
if (!isTextFile(path, file.type)) {
|
||||
return { valid: false, error: 'invalid_file_type', details: path };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
name: metadata.name,
|
||||
description: metadata.description || '',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Validation error:', error);
|
||||
return { valid: false, error: 'read_failed' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a File as text
|
||||
*/
|
||||
function readFileAsText(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Ignore Pattern Handling (simplified version of ignore package)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Simple ignore pattern matching
|
||||
* Supports basic glob patterns: *, ?, **
|
||||
*/
|
||||
export function shouldIgnore(filePath: string, patterns: string[]): boolean {
|
||||
const normalizedPath = filePath.toLowerCase();
|
||||
for (const pattern of patterns) {
|
||||
const trimmedPattern = pattern.trim();
|
||||
if (!trimmedPattern || trimmedPattern.startsWith('#')) continue;
|
||||
|
||||
if (matchPattern(normalizedPath, trimmedPattern.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function matchPattern(filePath: string, pattern: string): boolean {
|
||||
// Handle directory patterns (trailing slash)
|
||||
if (pattern.endsWith('/')) {
|
||||
const dirPattern = pattern.slice(0, -1);
|
||||
return filePath.startsWith(dirPattern + '/') || filePath === dirPattern;
|
||||
}
|
||||
|
||||
// Handle exact match
|
||||
if (filePath === pattern) return true;
|
||||
|
||||
// Handle glob patterns
|
||||
const regex = globToRegex(pattern);
|
||||
return regex.test(filePath);
|
||||
}
|
||||
|
||||
function globToRegex(pattern: string): RegExp {
|
||||
let regex = '';
|
||||
let i = 0;
|
||||
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
// ** matches any number of directories
|
||||
regex += '.*';
|
||||
i += 2;
|
||||
} else {
|
||||
// * matches any characters except /
|
||||
regex += '[^/]*';
|
||||
i++;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
// ? matches any single character except /
|
||||
regex += '[^/]';
|
||||
i++;
|
||||
} else if (c === '.') {
|
||||
regex += '\\.';
|
||||
i++;
|
||||
} else if (
|
||||
c === '\\' ||
|
||||
c === '/' ||
|
||||
c === '$' ||
|
||||
c === '^' ||
|
||||
c === '+' ||
|
||||
c === '(' ||
|
||||
c === ')' ||
|
||||
c === '[' ||
|
||||
c === ']' ||
|
||||
c === '{' ||
|
||||
c === '}'
|
||||
) {
|
||||
regex += '\\' + c;
|
||||
i++;
|
||||
} else {
|
||||
regex += c;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return new RegExp(`^${regex}$`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default Ignore Patterns
|
||||
// ============================================================================
|
||||
|
||||
export const DEFAULT_IGNORE_PATTERNS = [
|
||||
'.git/',
|
||||
'.svn/',
|
||||
'.hg/',
|
||||
'node_modules/',
|
||||
'__MACOSX/',
|
||||
'.DS_Store',
|
||||
'._*',
|
||||
'*.log',
|
||||
'*.tmp',
|
||||
'*.temp',
|
||||
'*.swp',
|
||||
'*.swo',
|
||||
'*~',
|
||||
'.env',
|
||||
'.env.*',
|
||||
'.vscode/',
|
||||
'.idea/',
|
||||
'Thumbs.db',
|
||||
'desktop.ini',
|
||||
'.skill-meta.json',
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// File List Filtering
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Filter files based on ignore patterns
|
||||
*/
|
||||
export function filterIgnoredFiles(
|
||||
files: SkillFileEntry[],
|
||||
ignorePatterns: string[] = DEFAULT_IGNORE_PATTERNS,
|
||||
): SkillFileEntry[] {
|
||||
return files.filter((file) => !shouldIgnore(file.path, ignorePatterns));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter upload files (File objects) based on ignore patterns
|
||||
* Removes junk files like .DS_Store, __MACOSX, etc.
|
||||
*/
|
||||
export function filterUploadFiles(files: File[]): File[] {
|
||||
return files.filter((file) => {
|
||||
const path = file.webkitRelativePath || file.name;
|
||||
const sanitized = sanitizeRelPath(path);
|
||||
if (!sanitized) return false;
|
||||
return (
|
||||
!isMacJunkPath(sanitized) &&
|
||||
!shouldIgnore(sanitized, DEFAULT_IGNORE_PATTERNS)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a skill folder structure is valid
|
||||
*/
|
||||
export function validateSkillStructure(files: SkillFileEntry[]): {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
skillMdPath?: string;
|
||||
} {
|
||||
// Find SKILL.md
|
||||
const skillMdFile = files.find((f) => {
|
||||
const normalized = f.path.toLowerCase();
|
||||
return normalized === 'skill.md' || normalized.endsWith('/skill.md');
|
||||
});
|
||||
|
||||
if (!skillMdFile) {
|
||||
return { valid: false, error: 'missing_skill_md' };
|
||||
}
|
||||
|
||||
return { valid: true, skillMdPath: skillMdFile.path };
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export enum Routes {
|
||||
Chats = '/chats',
|
||||
Chat = '/chat',
|
||||
Files = '/files',
|
||||
Skills = '/files/skills',
|
||||
ProfileSetting = '/profile-setting',
|
||||
Profile = '/profile',
|
||||
Api = '/api',
|
||||
@@ -246,6 +247,10 @@ const routeConfigOptions = [
|
||||
path: Routes.Files,
|
||||
Component: () => import('@/pages/files'),
|
||||
},
|
||||
{
|
||||
path: Routes.Skills,
|
||||
Component: () => import('@/pages/skills'),
|
||||
},
|
||||
{
|
||||
path: Routes.UserSetting,
|
||||
Component: () => import('@/pages/user-setting'),
|
||||
|
||||
257
web/src/services/skill-space-service.ts
Normal file
257
web/src/services/skill-space-service.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import api from '@/utils/api';
|
||||
import request from '@/utils/request';
|
||||
|
||||
export interface SkillSpace {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
folder_id: string;
|
||||
description?: string;
|
||||
embd_id?: string;
|
||||
rerank_id?: string;
|
||||
top_k?: number;
|
||||
status?: string;
|
||||
create_time?: number;
|
||||
update_time?: string;
|
||||
}
|
||||
|
||||
export interface CreateSpaceRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
embd_id?: string;
|
||||
rerank_id?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSpaceRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
embd_id?: string;
|
||||
rerank_id?: string;
|
||||
top_k?: number;
|
||||
}
|
||||
|
||||
export interface SkillSearchConfig {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
space_id: string;
|
||||
embd_id: string;
|
||||
vector_similarity_weight: number;
|
||||
similarity_threshold: number;
|
||||
field_config: Record<string, any>;
|
||||
rerank_id?: string;
|
||||
tenant_rerank_id?: number;
|
||||
top_k: number;
|
||||
index_version: string;
|
||||
status: string;
|
||||
create_time?: number;
|
||||
update_time?: string;
|
||||
}
|
||||
|
||||
export interface UpdateConfigRequest {
|
||||
tenant_id?: string;
|
||||
space_id?: string;
|
||||
embd_id: string;
|
||||
vector_similarity_weight: number;
|
||||
similarity_threshold: number;
|
||||
field_config: Record<string, any>;
|
||||
rerank_id?: string;
|
||||
top_k: number;
|
||||
}
|
||||
|
||||
export interface SearchRequest {
|
||||
tenant_id?: string;
|
||||
space_id?: string;
|
||||
query: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
skills: Array<{
|
||||
skill_id: string;
|
||||
folder_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
score: number;
|
||||
bm25_score?: number;
|
||||
vector_score?: number;
|
||||
index_version?: string;
|
||||
}>;
|
||||
total: number;
|
||||
query: string;
|
||||
search_type: string;
|
||||
}
|
||||
|
||||
export interface SkillInfo {
|
||||
id: string;
|
||||
folder_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface IndexSkillsRequest {
|
||||
tenant_id?: string;
|
||||
space_id?: string;
|
||||
skills: SkillInfo[];
|
||||
embd_id?: string;
|
||||
}
|
||||
|
||||
class SkillSpaceService {
|
||||
private async request<T>(
|
||||
method: string,
|
||||
url: string,
|
||||
data?: any,
|
||||
params?: any,
|
||||
): Promise<T> {
|
||||
const response: any = await request(url, {
|
||||
method: method as any,
|
||||
data,
|
||||
params,
|
||||
});
|
||||
|
||||
const jsonData = response?.data ?? response;
|
||||
|
||||
if (jsonData?.code !== 0) {
|
||||
throw new Error(jsonData?.message || 'Request failed');
|
||||
}
|
||||
|
||||
return jsonData.data;
|
||||
}
|
||||
|
||||
// ==================== Skill Space Management ====================
|
||||
|
||||
// List all skill spaces
|
||||
async listSpaces(): Promise<{ spaces: SkillSpace[]; total: number }> {
|
||||
return await this.request<{ spaces: SkillSpace[]; total: number }>(
|
||||
'GET',
|
||||
api.skillSpaces,
|
||||
);
|
||||
}
|
||||
|
||||
// Create a new skill space
|
||||
async createSpace(request: CreateSpaceRequest): Promise<SkillSpace> {
|
||||
return await this.request<SkillSpace>(
|
||||
'POST',
|
||||
api.skillSpaces,
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
// Get a skill space by ID
|
||||
async getSpace(spaceId: string): Promise<SkillSpace> {
|
||||
return await this.request<SkillSpace>(
|
||||
'GET',
|
||||
api.skillSpace(spaceId),
|
||||
);
|
||||
}
|
||||
|
||||
// Update a skill space
|
||||
async updateSpace(
|
||||
spaceId: string,
|
||||
request: UpdateSpaceRequest,
|
||||
): Promise<SkillSpace> {
|
||||
return await this.request<SkillSpace>(
|
||||
'PUT',
|
||||
api.skillSpace(spaceId),
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
// Delete a skill space
|
||||
async deleteSpace(spaceId: string): Promise<void> {
|
||||
await this.request<void>(
|
||||
'DELETE',
|
||||
api.skillSpace(spaceId),
|
||||
);
|
||||
}
|
||||
|
||||
// Get space by folder ID
|
||||
async getSpaceByFolder(folderId: string): Promise<SkillSpace> {
|
||||
return await this.request<SkillSpace>(
|
||||
'GET',
|
||||
api.skillSpaceByFolder,
|
||||
null,
|
||||
{ folder_id: folderId },
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Skill Search Config ====================
|
||||
|
||||
// Get skill search config
|
||||
async getConfig(
|
||||
spaceId?: string,
|
||||
embdId?: string,
|
||||
): Promise<SkillSearchConfig> {
|
||||
const params: Record<string, string> = {};
|
||||
if (spaceId) params.space_id = spaceId;
|
||||
if (embdId) params.embd_id = embdId;
|
||||
|
||||
return await this.request<SkillSearchConfig>(
|
||||
'GET',
|
||||
api.skillConfig,
|
||||
null,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
// Update skill search config
|
||||
async updateConfig(request: UpdateConfigRequest): Promise<SkillSearchConfig> {
|
||||
return await this.request<SkillSearchConfig>(
|
||||
'POST',
|
||||
api.skillConfig,
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Skill Search ====================
|
||||
|
||||
// Search skills
|
||||
async search(request: SearchRequest): Promise<SearchResult> {
|
||||
return await this.request<SearchResult>(
|
||||
'POST',
|
||||
api.skillSearch,
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Skill Indexing ====================
|
||||
|
||||
// Index skills
|
||||
async indexSkills(
|
||||
request: IndexSkillsRequest,
|
||||
): Promise<{ indexed_count: number }> {
|
||||
return await this.request<{ indexed_count: number }>(
|
||||
'POST',
|
||||
api.skillIndex,
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
// Delete skill index
|
||||
async deleteSkillIndex(skillId: string, spaceId?: string): Promise<void> {
|
||||
const params: Record<string, string> = { skill_id: skillId };
|
||||
if (spaceId) params.space_id = spaceId;
|
||||
|
||||
await this.request<void>(
|
||||
'DELETE',
|
||||
api.skillIndex,
|
||||
null,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
// Reindex all skills
|
||||
async reindex(request: IndexSkillsRequest): Promise<any> {
|
||||
return await this.request<any>(
|
||||
'POST',
|
||||
api.skillReindex,
|
||||
request,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const skillSpaceService = new SkillSpaceService();
|
||||
export default skillSpaceService;
|
||||
@@ -333,4 +333,13 @@ export default {
|
||||
adminGetSandboxConfig: `${restAPIv1}/admin/sandbox/config`,
|
||||
adminSetSandboxConfig: `${restAPIv1}/admin/sandbox/config`,
|
||||
adminTestSandboxConnection: `${restAPIv1}/admin/sandbox/test`,
|
||||
|
||||
// Skill spaces
|
||||
skillSpaces: `${restAPIv1}/skills/spaces`,
|
||||
skillSpace: (spaceId: string) => `${restAPIv1}/skills/spaces/${spaceId}`,
|
||||
skillSpaceByFolder: `${restAPIv1}/skills/space/by-folder`,
|
||||
skillConfig: `${restAPIv1}/skills/config`,
|
||||
skillSearch: `${restAPIv1}/skills/search`,
|
||||
skillIndex: `${restAPIv1}/skills/index`,
|
||||
skillReindex: `${restAPIv1}/skills/reindex`,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user