mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-29 12:09:31 +08:00
1568 lines
49 KiB
TypeScript
1568 lines
49 KiB
TypeScript
import message from '@/components/ui/message';
|
|
import fileManagerService from '@/services/file-manager-service';
|
|
import skillSpaceService, {
|
|
SkillSearchConfig,
|
|
} from '@/services/skill-space-service';
|
|
import { getAuthorization } from '@/utils/authorization-util';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import type { Skill, SkillFileEntry, SkillMetadata, SkillSpace } from './types';
|
|
import {
|
|
filterUploadFiles,
|
|
isTextFile,
|
|
parseFrontmatter,
|
|
validateSkillFormat as validateSkillFormatImpl,
|
|
} from './validation';
|
|
|
|
const SKILLS_FOLDER = 'skills';
|
|
|
|
// Helper to get file extension
|
|
const getFileExt = (filename: string): string => {
|
|
const parts = filename.split('.');
|
|
return parts.length > 1 ? parts.pop()!.toLowerCase() : '';
|
|
};
|
|
|
|
// Helper to check if file is markdown
|
|
export const isMarkdownFile = (filename: string): boolean => {
|
|
const mdExts = ['md', 'markdown', 'mdown', 'mkd'];
|
|
return mdExts.includes(getFileExt(filename));
|
|
};
|
|
|
|
// Helper to parse YAML-like metadata from markdown frontmatter
|
|
export const parseMetadata = (
|
|
content: string,
|
|
): { metadata: SkillMetadata; body: string } => {
|
|
const { metadata, body } = parseFrontmatter(content);
|
|
return { metadata, body };
|
|
};
|
|
|
|
// Normalize timestamp-like values from backend to milliseconds.
|
|
// Supports epoch seconds, epoch milliseconds and ISO datetime strings.
|
|
const toTimestampMs = (value: unknown): number | null => {
|
|
if (value === null || value === undefined || value === '') return null;
|
|
|
|
const normalizeEpoch = (raw: number): number | null => {
|
|
if (!Number.isFinite(raw)) return null;
|
|
|
|
let n = raw;
|
|
// Convert unit by magnitude: ns -> us -> ms -> s.
|
|
// Current epoch in ms is around 1e12.
|
|
if (n > 1e17)
|
|
n = n / 1e6; // nanoseconds
|
|
else if (n > 1e14)
|
|
n = n / 1e3; // microseconds
|
|
else if (n < 1e11) n = n * 1e3; // seconds
|
|
|
|
return Math.round(n);
|
|
};
|
|
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
return normalizeEpoch(value);
|
|
}
|
|
|
|
if (typeof value === 'string') {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
|
|
const numeric = Number(trimmed);
|
|
if (!Number.isNaN(numeric)) {
|
|
return normalizeEpoch(numeric);
|
|
}
|
|
|
|
const parsed = Date.parse(trimmed);
|
|
return Number.isNaN(parsed) ? null : parsed;
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const pickSkillTimestamp = (result: any): number => {
|
|
const candidates = [
|
|
result?.updated_at,
|
|
result?.updatedAt,
|
|
result?.update_time,
|
|
result?.updateTime,
|
|
result?.update_date,
|
|
result?.modified_at,
|
|
result?.modifiedAt,
|
|
result?.metadata?.updated_at,
|
|
result?.metadata?.updatedAt,
|
|
result?.metadata?.update_time,
|
|
result?.metadata?.updateTime,
|
|
result?.metadata?.update_date,
|
|
result?.skill?.updated_at,
|
|
result?.skill?.updatedAt,
|
|
result?.skill?.update_time,
|
|
result?.skill?.updateTime,
|
|
result?.skill?.update_date,
|
|
result?.created_at,
|
|
result?.createdAt,
|
|
result?.create_time,
|
|
result?.createTime,
|
|
result?.create_date,
|
|
result?.metadata?.created_at,
|
|
result?.metadata?.createdAt,
|
|
result?.metadata?.create_time,
|
|
result?.metadata?.createTime,
|
|
result?.metadata?.create_date,
|
|
result?.skill?.created_at,
|
|
result?.skill?.createdAt,
|
|
result?.skill?.create_time,
|
|
result?.skill?.createTime,
|
|
result?.skill?.create_date,
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
const ts = toTimestampMs(candidate);
|
|
if (ts !== null) return ts;
|
|
}
|
|
|
|
return Date.now();
|
|
};
|
|
|
|
// Export validation function from validation module
|
|
export { validateSkillFormatImpl as validateSkillFormat };
|
|
|
|
// Re-export validation utilities for use in components
|
|
export {
|
|
isMacJunkPath,
|
|
isTextFile,
|
|
parseFrontmatter,
|
|
sanitizeRelPath,
|
|
} from './validation';
|
|
|
|
// Query key for file content
|
|
const fileContentQueryKey = (fileId: string) => ['skillFileContent', fileId];
|
|
|
|
// Hook to fetch file content using TanStack Query
|
|
export const useFileContent = (fileId: string | null) => {
|
|
return useQuery({
|
|
queryKey: fileContentQueryKey(fileId || ''),
|
|
queryFn: async (): Promise<string | null> => {
|
|
if (!fileId) return null;
|
|
const response = await fileManagerService.getFile({}, fileId);
|
|
const blob = response.data as Blob;
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(reader.result as string);
|
|
reader.onerror = () => reject(reader.error);
|
|
reader.readAsText(blob);
|
|
});
|
|
},
|
|
enabled: !!fileId,
|
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
});
|
|
};
|
|
|
|
// Hook to manage skills
|
|
export const useSkills = () => {
|
|
const { t } = useTranslation();
|
|
const [skills, setSkills] = useState<Skill[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
// Fetch file content (kept for backward compatibility)
|
|
const fetchFileContent = async (fileId: string): Promise<string | null> => {
|
|
try {
|
|
const response = await fileManagerService.getFile({}, fileId);
|
|
const blob = response.data as Blob;
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(reader.result as string);
|
|
reader.onerror = () => reject(reader.error);
|
|
reader.readAsText(blob);
|
|
});
|
|
} catch (error) {
|
|
console.error('Error fetching file content:', error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Fetch details of a specific skill (with version support)
|
|
const fetchSkillDetails = async (
|
|
folderId: string,
|
|
folderName: string,
|
|
): Promise<Skill | null> => {
|
|
try {
|
|
// First, list the skill folder to find version folders
|
|
const { data: skillFolderData } = await fileManagerService.listFile({
|
|
parent_id: folderId,
|
|
});
|
|
|
|
if (skillFolderData.code !== 0) return null;
|
|
|
|
const skillItems = skillFolderData.data?.files || [];
|
|
|
|
// Find version folders (folders that match semver pattern like x.y.z)
|
|
const versionFolders = skillItems.filter(
|
|
(f: any) => f.type === 'folder' && /^\d+\.\d+\.\d+/.test(f.name),
|
|
);
|
|
|
|
if (versionFolders.length === 0) {
|
|
// No version folders found - fallback to legacy structure
|
|
// oxlint-disable-next-line typescript/no-use-before-define
|
|
return fetchSkillDetailsLegacy(folderId, folderName, skillItems);
|
|
}
|
|
|
|
// Sort versions by version number (descending)
|
|
const sortedVersions = versionFolders.sort((a: any, b: any) => {
|
|
const va = a.name.split('.').map(Number);
|
|
const vb = b.name.split('.').map(Number);
|
|
for (let i = 0; i < Math.max(va.length, vb.length); i++) {
|
|
const na = va[i] || 0;
|
|
const nb = vb[i] || 0;
|
|
if (na !== nb) return nb - na; // Descending order
|
|
}
|
|
return 0;
|
|
});
|
|
|
|
const allVersions = sortedVersions.map((v: any) => v.name);
|
|
const latestVersionFolder = sortedVersions[0];
|
|
const versionFolderId = latestVersionFolder.id;
|
|
const versionName = latestVersionFolder.name;
|
|
|
|
// Get all files recursively in the latest version folder
|
|
const fileEntries: SkillFileEntry[] = [];
|
|
let readmeContent: string | null = null;
|
|
let firstFileDate: string | null = null;
|
|
|
|
// Recursively fetch all files
|
|
const fetchFilesRecursive = async (
|
|
parentId: string,
|
|
basePath: string = '',
|
|
) => {
|
|
const { data } = await fileManagerService.listFile({
|
|
parent_id: parentId,
|
|
});
|
|
if (data.code !== 0) return;
|
|
|
|
const files = data.data?.files || [];
|
|
|
|
// Track date from first encountered file
|
|
if (!firstFileDate && files.length > 0) {
|
|
firstFileDate = files[0]?.create_date || files[0]?.update_date;
|
|
}
|
|
|
|
for (const f of files) {
|
|
const path = basePath ? `${basePath}/${f.name}` : f.name;
|
|
|
|
fileEntries.push({
|
|
name: f.name,
|
|
path: path,
|
|
is_dir: f.type === 'folder',
|
|
size: f.size || 0,
|
|
});
|
|
|
|
// Check for SKILL.md first, then README.md for metadata
|
|
const lowerName = f.name.toLowerCase();
|
|
if (
|
|
lowerName === 'skill.md' ||
|
|
lowerName === 'readme.md' ||
|
|
lowerName === 'index.md'
|
|
) {
|
|
if (!readmeContent) {
|
|
readmeContent = await fetchFileContent(f.id);
|
|
}
|
|
}
|
|
|
|
// Recursively fetch subfolder contents
|
|
if (f.type === 'folder') {
|
|
await fetchFilesRecursive(f.id, path);
|
|
}
|
|
}
|
|
};
|
|
|
|
await fetchFilesRecursive(versionFolderId);
|
|
|
|
// Parse metadata from README
|
|
let metadata: SkillMetadata = {};
|
|
let description = '';
|
|
|
|
if (readmeContent) {
|
|
const parsed = parseMetadata(readmeContent);
|
|
metadata = parsed.metadata;
|
|
description = metadata.description || parsed.body.slice(0, 200);
|
|
}
|
|
|
|
// Get dates
|
|
const createDate = firstFileDate || new Date().toISOString();
|
|
const updateDate = createDate;
|
|
|
|
// Priority: folderName (user-specified) > metadata.name (from SKILL.md)
|
|
// This allows users to override the skill name from SKILL.md
|
|
const skillName = folderName || metadata.name || 'unnamed-skill';
|
|
|
|
return {
|
|
id: skillName, // Use skill name as ID (consistent with search results)
|
|
name: skillName,
|
|
description,
|
|
source_type: 'local',
|
|
created_at: new Date(createDate).getTime(),
|
|
updated_at: new Date(updateDate).getTime(),
|
|
files: fileEntries,
|
|
metadata: { ...metadata, version: versionName },
|
|
versions: allVersions,
|
|
_folderId: folderId, // Internal use for file operations
|
|
};
|
|
} catch (error) {
|
|
console.error('Error fetching skill details:', error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Legacy fetch for skills without version structure
|
|
const fetchSkillDetailsLegacy = async (
|
|
folderId: string,
|
|
folderName: string,
|
|
skillItems: any[],
|
|
): Promise<Skill | null> => {
|
|
try {
|
|
const fileEntries: SkillFileEntry[] = [];
|
|
let readmeContent: string | null = null;
|
|
let firstFileDate: string | null = null;
|
|
|
|
// Recursively fetch all files
|
|
const fetchFilesRecursive = async (
|
|
parentId: string,
|
|
basePath: string = '',
|
|
) => {
|
|
const { data } = await fileManagerService.listFile({
|
|
parent_id: parentId,
|
|
});
|
|
if (data.code !== 0) return;
|
|
|
|
const files = data.data?.files || [];
|
|
|
|
if (!firstFileDate && files.length > 0) {
|
|
firstFileDate = files[0]?.create_date || files[0]?.update_date;
|
|
}
|
|
|
|
for (const f of files) {
|
|
const path = basePath ? `${basePath}/${f.name}` : f.name;
|
|
|
|
fileEntries.push({
|
|
name: f.name,
|
|
path: path,
|
|
is_dir: f.type === 'folder',
|
|
size: f.size || 0,
|
|
});
|
|
|
|
const lowerName = f.name.toLowerCase();
|
|
if (
|
|
lowerName === 'skill.md' ||
|
|
lowerName === 'readme.md' ||
|
|
lowerName === 'index.md'
|
|
) {
|
|
if (!readmeContent) {
|
|
readmeContent = await fetchFileContent(f.id);
|
|
}
|
|
}
|
|
|
|
if (f.type === 'folder') {
|
|
await fetchFilesRecursive(f.id, path);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Process items from the skill folder
|
|
for (const f of skillItems) {
|
|
if (f.type === 'folder') {
|
|
await fetchFilesRecursive(f.id, f.name);
|
|
} else {
|
|
fileEntries.push({
|
|
name: f.name,
|
|
path: f.name,
|
|
is_dir: false,
|
|
size: f.size || 0,
|
|
});
|
|
|
|
const lowerName = f.name.toLowerCase();
|
|
if (
|
|
lowerName === 'skill.md' ||
|
|
lowerName === 'readme.md' ||
|
|
lowerName === 'index.md'
|
|
) {
|
|
if (!readmeContent) {
|
|
readmeContent = await fetchFileContent(f.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let metadata: SkillMetadata = {};
|
|
let description = '';
|
|
|
|
if (readmeContent) {
|
|
const parsed = parseMetadata(readmeContent);
|
|
metadata = parsed.metadata;
|
|
description = metadata.description || parsed.body.slice(0, 200);
|
|
}
|
|
|
|
const createDate = firstFileDate || new Date().toISOString();
|
|
|
|
// Priority: folderName (user-specified) > metadata.name (from SKILL.md)
|
|
// This allows users to override the skill name from SKILL.md
|
|
const skillName = folderName || metadata.name || 'unnamed-skill';
|
|
|
|
return {
|
|
id: skillName, // Use skill name as ID (consistent with search results)
|
|
name: skillName,
|
|
description,
|
|
source_type: 'local',
|
|
created_at: new Date(createDate).getTime(),
|
|
updated_at: new Date(createDate).getTime(),
|
|
files: fileEntries,
|
|
metadata,
|
|
_folderId: folderId, // Internal use for file operations
|
|
};
|
|
} catch (error) {
|
|
console.error('Error fetching legacy skill details:', error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Ensure skills folder exists, returns folder ID
|
|
const ensureSkillsFolder = useCallback(async (): Promise<string | null> => {
|
|
try {
|
|
// List root files to find skills folder
|
|
const { data } = await fileManagerService.listFile({});
|
|
|
|
if (data.code !== 0) return null;
|
|
|
|
const rootId = data.data?.parent_folder?.id;
|
|
const files = data.data?.files || [];
|
|
|
|
// Check if skills folder exists
|
|
const skillsFolder = files.find(
|
|
(f: any) => f.name === SKILLS_FOLDER && f.type === 'folder',
|
|
);
|
|
|
|
if (skillsFolder) {
|
|
return skillsFolder.id;
|
|
}
|
|
|
|
// Create skills folder
|
|
const createRes = await fileManagerService.createFolder({
|
|
name: SKILLS_FOLDER,
|
|
type: 'folder',
|
|
parent_id: rootId,
|
|
});
|
|
|
|
if (createRes.data.code === 0) {
|
|
return createRes.data.data?.id || null;
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
console.error('Error ensuring skills folder:', error);
|
|
return null;
|
|
}
|
|
}, []);
|
|
|
|
const fetchSpaces = useCallback(async (): Promise<SkillSpace[]> => {
|
|
try {
|
|
const result = await skillSpaceService.listSpaces();
|
|
return result.spaces.map((space) => ({
|
|
id: space.id,
|
|
name: space.name,
|
|
create_time: space.create_time,
|
|
folder_id: space.folder_id,
|
|
}));
|
|
} catch (error) {
|
|
console.error('Error fetching skill hubs:', error);
|
|
return [];
|
|
}
|
|
}, []);
|
|
|
|
const ensureSkillSpaceFolder = useCallback(
|
|
async (
|
|
spaceName: string,
|
|
createIfMissing = false,
|
|
): Promise<string | null> => {
|
|
const skillsFolderId = await ensureSkillsFolder();
|
|
if (!skillsFolderId) return null;
|
|
|
|
const { data } = await fileManagerService.listFile({
|
|
parent_id: skillsFolderId,
|
|
});
|
|
|
|
if (data.code !== 0) return null;
|
|
|
|
const hubFolder = (data.data?.files || []).find(
|
|
(f: any) => f.name === spaceName && f.type === 'folder',
|
|
);
|
|
if (hubFolder) return hubFolder.id;
|
|
|
|
if (!createIfMissing) return null;
|
|
|
|
const createRes = await fileManagerService.createFolder({
|
|
name: spaceName,
|
|
type: 'folder',
|
|
parent_id: skillsFolderId,
|
|
});
|
|
|
|
if (createRes.data.code !== 0) return null;
|
|
return createRes.data.data?.id || null;
|
|
},
|
|
[ensureSkillsFolder],
|
|
);
|
|
|
|
const createSpace = useCallback(
|
|
async (spaceName: string): Promise<{ id: string; name: string } | null> => {
|
|
try {
|
|
const space = await skillSpaceService.createSpace({ name: spaceName });
|
|
message.success(
|
|
t('skills.spaceCreated') || 'Skill Space created successfully',
|
|
);
|
|
return space;
|
|
} catch (error: any) {
|
|
console.error('Error creating skill space:', error);
|
|
message.error(error.message || t('skills.fetchError'));
|
|
return null;
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
|
|
// Delete a skill space
|
|
const deleteSpace = useCallback(
|
|
async (spaceId: string): Promise<boolean> => {
|
|
try {
|
|
await skillSpaceService.deleteSpace(spaceId);
|
|
message.success(
|
|
t('skills.spaceDeleted') || 'Skill Space deleted successfully',
|
|
);
|
|
return true;
|
|
} catch (error: any) {
|
|
console.error('Error deleting skill space:', error);
|
|
message.error(error.message || t('skills.fetchError'));
|
|
return false;
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
|
|
// Update a skill space (rename)
|
|
const updateSpace = useCallback(
|
|
async (spaceId: string, spaceName: string): Promise<boolean> => {
|
|
try {
|
|
await skillSpaceService.updateSpace(spaceId, { name: spaceName });
|
|
message.success(
|
|
t('skills.spaceUpdated') || 'Skill Space renamed successfully',
|
|
);
|
|
return true;
|
|
} catch (error: any) {
|
|
console.error('Error updating skill space:', error);
|
|
message.error(error.message || t('skills.fetchError'));
|
|
return false;
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
|
|
// Fetch skills from file system (fallback when search returns empty)
|
|
const fetchSkillsFromFileSystem = useCallback(
|
|
async (spaceName?: string): Promise<{ skills: Skill[]; total: number }> => {
|
|
if (!spaceName) {
|
|
return { skills: [], total: 0 };
|
|
}
|
|
try {
|
|
const spaceFolderId = await ensureSkillSpaceFolder(spaceName, false);
|
|
if (!spaceFolderId) {
|
|
return { skills: [], total: 0 };
|
|
}
|
|
|
|
const { data } = await fileManagerService.listFile({
|
|
parent_id: spaceFolderId,
|
|
});
|
|
|
|
const skillFolders =
|
|
data.code === 0
|
|
? data.data?.files?.filter((f: any) => f.type === 'folder') || []
|
|
: [];
|
|
|
|
// Fetch details for each skill
|
|
const skillsData: Skill[] = (
|
|
await Promise.all(
|
|
skillFolders.map(async (folder: any) => {
|
|
const skill = await fetchSkillDetails(folder.id, folder.name);
|
|
return skill;
|
|
}),
|
|
)
|
|
).filter(Boolean);
|
|
|
|
return { skills: skillsData, total: skillsData.length };
|
|
} catch (error) {
|
|
console.error('Error fetching skills from file system:', error);
|
|
return { skills: [], total: 0 };
|
|
}
|
|
},
|
|
// oxlint-disable-next-line react/exhaustive-deps
|
|
[ensureSkillSpaceFolder],
|
|
);
|
|
|
|
// Fetch skills using search API (supports pagination and sorting)
|
|
// Falls back to file system if search returns empty (skills not indexed yet)
|
|
const fetchSkills = useCallback(
|
|
async (
|
|
spaceName?: string,
|
|
spaceId?: string,
|
|
page = 1,
|
|
pageSize = 50,
|
|
sortBy = 'update_time',
|
|
sortOrder: 'asc' | 'desc' = 'desc',
|
|
) => {
|
|
if (!spaceName || !spaceId) {
|
|
setSkills([]);
|
|
return { skills: [], total: 0 };
|
|
}
|
|
setLoading(true);
|
|
try {
|
|
// Use search API with empty query to list all skills
|
|
const response = await fetch('/api/v1/skills/search', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: getAuthorization(),
|
|
},
|
|
body: JSON.stringify({
|
|
space_id: spaceId,
|
|
query: '', // Empty query = list all
|
|
page,
|
|
page_size: pageSize,
|
|
sort_by: sortBy,
|
|
sort_order: sortOrder,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch skills');
|
|
}
|
|
|
|
const result = await response.json();
|
|
if (result.code !== 0) {
|
|
throw new Error(result.message || 'Failed to fetch skills');
|
|
}
|
|
|
|
const searchSkills = result.data?.skills || [];
|
|
const total = result.data?.total || 0;
|
|
|
|
// If search returned results, use them
|
|
if (searchSkills.length > 0) {
|
|
const skillsData: Skill[] = searchSkills.map((result: any) => {
|
|
const timestamp = pickSkillTimestamp(result);
|
|
const skillId = result.skill_id || result.name;
|
|
|
|
return {
|
|
id: skillId,
|
|
name: result.name,
|
|
description: result.description || '',
|
|
source_type: 'search',
|
|
created_at: timestamp,
|
|
updated_at: timestamp,
|
|
metadata: {
|
|
tags: result.tags || [],
|
|
version: result.version,
|
|
},
|
|
files: [],
|
|
_folderId: result.folder_id,
|
|
};
|
|
});
|
|
|
|
setSkills(skillsData);
|
|
|
|
// Asynchronously load file counts for search results
|
|
Promise.all(
|
|
skillsData.map(async (skill) => {
|
|
if (!skill._folderId) return skill;
|
|
try {
|
|
const detail = await fetchSkillDetails(
|
|
skill._folderId,
|
|
skill.name,
|
|
);
|
|
if (detail) {
|
|
return { ...skill, files: detail.files };
|
|
}
|
|
} catch {
|
|
// Ignore errors for individual skill file loading
|
|
}
|
|
return skill;
|
|
}),
|
|
).then((enrichedSkills) => {
|
|
setSkills(enrichedSkills);
|
|
});
|
|
|
|
return { skills: skillsData, total };
|
|
}
|
|
|
|
// Search returned empty, fall back to file system
|
|
// oxlint-disable-next-line no-console
|
|
console.log(
|
|
'[Skills] Search returned empty, falling back to file system',
|
|
);
|
|
const fsResult = await fetchSkillsFromFileSystem(spaceName);
|
|
setSkills(fsResult.skills);
|
|
return fsResult;
|
|
} catch (error) {
|
|
console.error('Error fetching skills:', error);
|
|
// Fall back to file system on error
|
|
const fsResult = await fetchSkillsFromFileSystem(spaceName);
|
|
setSkills(fsResult.skills);
|
|
return fsResult;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
// oxlint-disable-next-line react/exhaustive-deps
|
|
[t, fetchSkillsFromFileSystem],
|
|
);
|
|
|
|
// Upload a new skill with proper directory structure (with version support)
|
|
const uploadSkill = useCallback(
|
|
async (
|
|
name: string,
|
|
version: string,
|
|
files: File[],
|
|
spaceName?: string,
|
|
spaceId?: string,
|
|
embdId?: string,
|
|
): Promise<boolean> => {
|
|
try {
|
|
setLoading(true);
|
|
if (!spaceName) throw new Error('Space name is required');
|
|
|
|
// Use spaceName for file system operations, spaceId for indexing
|
|
const normalizedSpaceName = spaceName.trim();
|
|
const normalizedSpaceId = spaceId?.trim() || normalizedSpaceName;
|
|
|
|
// Filter out ignored/junk files first
|
|
const filteredFiles = filterUploadFiles(files);
|
|
|
|
// Validate skill format
|
|
const validation = await validateSkillFormatImpl(filteredFiles);
|
|
if (!validation.valid) {
|
|
const errorKey = `skills.validation.${validation.error}`;
|
|
const errorMsg = t(errorKey) || t('skills.validation.invalid');
|
|
message.error(errorMsg);
|
|
return false;
|
|
}
|
|
|
|
// Get space folder ID (using space name for file system)
|
|
const spaceFolderId = await ensureSkillSpaceFolder(
|
|
normalizedSpaceName,
|
|
true,
|
|
);
|
|
|
|
if (!spaceFolderId) throw new Error('Skills space not found');
|
|
|
|
const skillNameNormalized = name.replace(/\s+/g, '-').toLowerCase();
|
|
|
|
// Check if skill folder exists
|
|
const { data: existingData } = await fileManagerService.listFile({
|
|
parent_id: spaceFolderId,
|
|
});
|
|
|
|
let skillFolderId: string;
|
|
|
|
if (existingData.code === 0) {
|
|
const existingSkill = existingData.data?.files?.find(
|
|
(f: any) => f.name === skillNameNormalized && f.type === 'folder',
|
|
);
|
|
|
|
if (existingSkill) {
|
|
// Skill exists, check if version already exists
|
|
const { data: versionData } = await fileManagerService.listFile({
|
|
parent_id: existingSkill.id,
|
|
});
|
|
|
|
if (versionData.code === 0) {
|
|
const existingVersion = versionData.data?.files?.find(
|
|
(f: any) => f.name === version && f.type === 'folder',
|
|
);
|
|
|
|
if (existingVersion) {
|
|
message.error(
|
|
t('skills.versionExists') || 'This version already exists',
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
skillFolderId = existingSkill.id;
|
|
} else {
|
|
// Create skill folder
|
|
const folderRes = await fileManagerService.createFolder({
|
|
name: skillNameNormalized,
|
|
type: 'folder',
|
|
parent_id: spaceFolderId,
|
|
});
|
|
|
|
if (folderRes.data.code !== 0) {
|
|
throw new Error('Failed to create skill folder');
|
|
}
|
|
|
|
skillFolderId = folderRes.data.data?.id;
|
|
}
|
|
} else {
|
|
throw new Error('Failed to list skills folder');
|
|
}
|
|
|
|
if (!skillFolderId) throw new Error('Failed to get skill folder ID');
|
|
|
|
// Create version folder
|
|
const versionRes = await fileManagerService.createFolder({
|
|
name: version,
|
|
type: 'folder',
|
|
parent_id: skillFolderId,
|
|
});
|
|
|
|
if (versionRes.data.code !== 0) {
|
|
throw new Error('Failed to create version folder');
|
|
}
|
|
|
|
const versionFolderId = versionRes.data.data?.id;
|
|
|
|
if (!versionFolderId)
|
|
throw new Error('Failed to get version folder ID');
|
|
|
|
// Upload files recursively to preserve directory structure
|
|
const uploadFileWithStructure = async (
|
|
file: File,
|
|
parentId: string,
|
|
) => {
|
|
const relativePath = (file as any).webkitRelativePath || file.name;
|
|
const pathParts = relativePath.split('/');
|
|
|
|
// If file is in root directory (no subdirectories)
|
|
if (pathParts.length === 1) {
|
|
const formData = new FormData();
|
|
formData.append('parent_id', parentId);
|
|
formData.append('file', file);
|
|
await fileManagerService.uploadFile(formData);
|
|
return;
|
|
}
|
|
|
|
// Navigate/create directory structure
|
|
let currentParentId = parentId;
|
|
for (let i = 0; i < pathParts.length - 1; i++) {
|
|
const dirName = pathParts[i];
|
|
|
|
// List current directory to check if subdirectory exists
|
|
const { data: listData } = await fileManagerService.listFile({
|
|
parent_id: currentParentId,
|
|
});
|
|
|
|
if (listData.code !== 0) {
|
|
throw new Error(`Failed to list directory: ${dirName}`);
|
|
}
|
|
|
|
const existingDir = listData.data?.files?.find(
|
|
(f: any) => f.name === dirName && f.type === 'folder',
|
|
);
|
|
|
|
if (existingDir) {
|
|
currentParentId = existingDir.id;
|
|
} else {
|
|
// Create subdirectory
|
|
const createRes = await fileManagerService.createFolder({
|
|
name: dirName,
|
|
type: 'folder',
|
|
parent_id: currentParentId,
|
|
});
|
|
|
|
if (createRes.data.code !== 0) {
|
|
throw new Error(`Failed to create directory: ${dirName}`);
|
|
}
|
|
|
|
currentParentId = createRes.data.data?.id;
|
|
}
|
|
}
|
|
|
|
// Upload file to the final directory
|
|
const formData = new FormData();
|
|
formData.append('parent_id', currentParentId);
|
|
formData.append('file', file);
|
|
await fileManagerService.uploadFile(formData);
|
|
};
|
|
|
|
// Upload all files sequentially to avoid race conditions
|
|
for (const file of filteredFiles) {
|
|
await uploadFileWithStructure(file, versionFolderId);
|
|
}
|
|
|
|
// Build search index for the uploaded skill
|
|
try {
|
|
// Read all text files and build content
|
|
let skillMetadata: SkillMetadata = {};
|
|
let skillDescription = '';
|
|
const fileContents: { path: string; content: string }[] = [];
|
|
|
|
for (const file of filteredFiles) {
|
|
const relativePath = (file as any).webkitRelativePath || file.name;
|
|
if (!isTextFile(relativePath, file.type)) {
|
|
continue;
|
|
}
|
|
|
|
const content = await file.text();
|
|
fileContents.push({ path: relativePath, content });
|
|
|
|
// Parse metadata from skill.md/readme.md/index.md
|
|
const lowerName = file.name.toLowerCase();
|
|
if (
|
|
lowerName === 'skill.md' ||
|
|
lowerName === 'readme.md' ||
|
|
lowerName === 'index.md'
|
|
) {
|
|
const parsed = parseMetadata(content);
|
|
skillMetadata = parsed.metadata;
|
|
skillDescription =
|
|
skillMetadata.description || parsed.body.slice(0, 200);
|
|
}
|
|
}
|
|
|
|
// Build concatenated content for indexing
|
|
const concatenatedContent = fileContents
|
|
.map((f) => `${f.path}\n===\n${f.content}`)
|
|
.join('\n\n');
|
|
|
|
// Index the skill with embd_id from config (if available)
|
|
// Use user-specified name (skillNameNormalized) as skill ID and name
|
|
// This ensures consistency between folder name, skill ID, and display name
|
|
const indexResponse = await fetch('/api/v1/skills/index', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: getAuthorization(),
|
|
},
|
|
body: JSON.stringify({
|
|
space_id: normalizedSpaceId,
|
|
embd_id: embdId,
|
|
skills: [
|
|
{
|
|
id: skillNameNormalized,
|
|
folder_id: skillFolderId,
|
|
name: skillNameNormalized,
|
|
description: skillDescription,
|
|
tags: skillMetadata.tags || [],
|
|
content: concatenatedContent,
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
|
|
if (!indexResponse.ok) {
|
|
console.warn(
|
|
'[Skill Index] Failed to index skill:',
|
|
await indexResponse.text(),
|
|
);
|
|
}
|
|
} catch (indexError) {
|
|
// Indexing failure should not block upload success
|
|
console.warn('[Skill Index] Error indexing skill:', indexError);
|
|
}
|
|
|
|
message.success(t('skills.uploadSuccess'));
|
|
await fetchSkills(normalizedSpaceName, normalizedSpaceId);
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Error uploading skill:', error);
|
|
message.error(t('skills.uploadError'));
|
|
return false;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[t, fetchSkills, ensureSkillSpaceFolder],
|
|
);
|
|
|
|
// Delete a skill
|
|
const deleteSkill = useCallback(
|
|
async (
|
|
skillId: string,
|
|
_skillName?: string,
|
|
spaceId?: string,
|
|
spaceName?: string,
|
|
folderId?: string,
|
|
): Promise<boolean> => {
|
|
try {
|
|
if (!spaceId) throw new Error('Space ID is required');
|
|
if (!spaceName) throw new Error('Space name is required');
|
|
const normalizedSpaceId = spaceId.trim();
|
|
const normalizedSpaceName = spaceName.trim();
|
|
|
|
let targetFolderId: string | null = folderId || null;
|
|
|
|
// If folderId not provided, try to find the skill in current skills state
|
|
if (!targetFolderId) {
|
|
const skillInState = skills.find((s) => s.id === skillId);
|
|
if (skillInState && (skillInState as any)._folderId) {
|
|
targetFolderId = (skillInState as any)._folderId;
|
|
}
|
|
}
|
|
|
|
// Fallback: search in file system if not found
|
|
if (!targetFolderId) {
|
|
const spaceFolderId = await ensureSkillSpaceFolder(
|
|
normalizedSpaceName,
|
|
false,
|
|
);
|
|
if (spaceFolderId) {
|
|
const { data: listData } = await fileManagerService.listFile({
|
|
parent_id: spaceFolderId,
|
|
});
|
|
|
|
if (listData.code === 0) {
|
|
const skillFolder = (listData.data?.files || []).find(
|
|
(f: any) => f.type === 'folder' && f.name === skillId,
|
|
);
|
|
if (skillFolder) {
|
|
targetFolderId = skillFolder.id;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!targetFolderId) {
|
|
throw new Error('Skill not found');
|
|
}
|
|
|
|
// Get versions by listing the skill folder
|
|
const { data: versionData } = await fileManagerService.listFile({
|
|
parent_id: targetFolderId,
|
|
});
|
|
|
|
let versionsToDelete: string[] = ['latest'];
|
|
if (versionData.code === 0) {
|
|
const versionFolders = (versionData.data?.files || []).filter(
|
|
(f: any) => f.type === 'folder' && /^\d+\.\d+\.\d+/.test(f.name),
|
|
);
|
|
if (versionFolders.length > 0) {
|
|
versionsToDelete = versionFolders.map((f: any) => f.name);
|
|
}
|
|
}
|
|
|
|
// Delete search index for all versions
|
|
// Backend uses skillName_version as doc_id (replacing '/' with '_')
|
|
// We need to delete each version's index separately
|
|
// oxlint-disable-next-line no-console
|
|
console.log(
|
|
`[deleteSkill] Starting index deletion for skillId: ${skillId}, spaceId: ${normalizedSpaceId}`,
|
|
);
|
|
// oxlint-disable-next-line no-console
|
|
console.log(`[deleteSkill] versionsToDelete:`, versionsToDelete);
|
|
|
|
for (const version of versionsToDelete) {
|
|
const indexId =
|
|
version === 'latest' ? skillId : `${skillId}/${version}`;
|
|
try {
|
|
// oxlint-disable-next-line no-console
|
|
console.log(
|
|
`[deleteSkill] Deleting index: ${indexId} for space: ${normalizedSpaceId}`,
|
|
);
|
|
await skillSpaceService.deleteSkillIndex(
|
|
indexId,
|
|
normalizedSpaceId,
|
|
);
|
|
// oxlint-disable-next-line no-console
|
|
console.log(`[deleteSkill] Successfully deleted index: ${indexId}`);
|
|
} catch (indexError: any) {
|
|
// oxlint-disable-next-line no-console
|
|
console.warn(
|
|
`[deleteSkill] Error deleting skill index for ${indexId}:`,
|
|
indexError?.message || indexError,
|
|
);
|
|
}
|
|
}
|
|
|
|
// If we couldn't determine versions from filesystem, try common version formats
|
|
if (versionsToDelete.length === 1 && versionsToDelete[0] === 'latest') {
|
|
// Try to delete the skill with version suffixes
|
|
const commonVersions = ['1.0.0', '0.1.0', '0.0.1', 'latest'];
|
|
for (const version of commonVersions) {
|
|
const indexId = `${skillId}/${version}`;
|
|
try {
|
|
// oxlint-disable-next-line no-console
|
|
console.log(
|
|
`[deleteSkill] Trying to delete index with version: ${indexId}`,
|
|
);
|
|
await skillSpaceService.deleteSkillIndex(
|
|
indexId,
|
|
normalizedSpaceId,
|
|
);
|
|
// oxlint-disable-next-line no-console
|
|
console.log(
|
|
`[deleteSkill] Successfully deleted index: ${indexId}`,
|
|
);
|
|
} catch {
|
|
// Ignore errors for versions that don't exist
|
|
}
|
|
}
|
|
}
|
|
|
|
const { data } = await fileManagerService.removeFile({
|
|
ids: [targetFolderId],
|
|
});
|
|
|
|
if (data.code !== 0) throw new Error('Failed to delete skill');
|
|
|
|
message.success(t('skills.deleteSuccess'));
|
|
// Refresh skills list using space name and space id
|
|
await fetchSkills(normalizedSpaceName, normalizedSpaceId);
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Error deleting skill:', error);
|
|
message.error(t('skills.deleteError'));
|
|
return false;
|
|
}
|
|
},
|
|
// oxlint-disable-next-line react/exhaustive-deps
|
|
[t, fetchSkills, ensureSkillSpaceFolder, skills],
|
|
);
|
|
|
|
// Recursively find file by path in folder structure
|
|
// For versioned skills, automatically finds the version folder first
|
|
const findFileByPath = async (
|
|
folderId: string,
|
|
targetPath: string,
|
|
version?: string,
|
|
): Promise<any | null> => {
|
|
let currentFolderId = folderId;
|
|
|
|
// If version is provided, first find the version folder
|
|
if (version) {
|
|
const { data } = await fileManagerService.listFile({
|
|
parent_id: currentFolderId,
|
|
});
|
|
if (data.code !== 0) return null;
|
|
|
|
const files = data.data?.files || [];
|
|
const versionFolder = files.find(
|
|
(f: any) => f.name === version && f.type === 'folder',
|
|
);
|
|
|
|
if (!versionFolder) return null;
|
|
currentFolderId = versionFolder.id;
|
|
} else {
|
|
// No version specified, try to find the latest version folder
|
|
const { data } = await fileManagerService.listFile({
|
|
parent_id: currentFolderId,
|
|
});
|
|
if (data.code !== 0) return null;
|
|
|
|
const files = data.data?.files || [];
|
|
const versionFolders = files.filter(
|
|
(f: any) => f.type === 'folder' && /^\d+\.\d+\.\d+/.test(f.name),
|
|
);
|
|
|
|
if (versionFolders.length > 0) {
|
|
// Sort by version number (descending) to get the latest
|
|
const sortedVersions = versionFolders.sort((a: any, b: any) => {
|
|
const va = a.name.split('.').map(Number);
|
|
const vb = b.name.split('.').map(Number);
|
|
for (let i = 0; i < Math.max(va.length, vb.length); i++) {
|
|
const na = va[i] || 0;
|
|
const nb = vb[i] || 0;
|
|
if (na !== nb) return nb - na; // Descending order
|
|
}
|
|
return 0;
|
|
});
|
|
currentFolderId = sortedVersions[0].id;
|
|
}
|
|
// If no version folders found, stay at current level (legacy structure)
|
|
}
|
|
|
|
// Now find the file in the version folder (or original folder if no version)
|
|
const parts = targetPath.split('/');
|
|
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const { data } = await fileManagerService.listFile({
|
|
parent_id: currentFolderId,
|
|
});
|
|
if (data.code !== 0) return null;
|
|
|
|
const files = data.data?.files || [];
|
|
const part = parts[i];
|
|
|
|
// Check if this is the last part (the file)
|
|
if (i === parts.length - 1) {
|
|
const file = files.find((f: any) => f.name === part);
|
|
return file || null;
|
|
}
|
|
|
|
// This is a folder, find it and continue
|
|
const subFolder = files.find(
|
|
(f: any) => f.name === part && f.type === 'folder',
|
|
);
|
|
if (!subFolder) return null;
|
|
currentFolderId = subFolder.id;
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
// Get file content for a skill
|
|
// Automatically handles versioned skills by checking skill.metadata.version
|
|
// Can be called with an optional skill object (for search results not in skills state)
|
|
const getSkillFileContent = useCallback(
|
|
async (
|
|
skillId: string,
|
|
filePath: string,
|
|
version?: string,
|
|
skillObj?: Skill,
|
|
): Promise<string | null> => {
|
|
try {
|
|
// Find the skill to get its folder ID
|
|
// Use provided skill object if available (for search results), otherwise look up in skills state
|
|
const skill = skillObj || skills.find((s) => s.id === skillId);
|
|
if (!skill) return null;
|
|
|
|
// Use internal _folderId for file operations
|
|
const folderId = (skill as any)._folderId;
|
|
if (!folderId) return null;
|
|
|
|
// If version is not provided, try to find it from the skill or auto-discover
|
|
let targetVersion = version;
|
|
if (!targetVersion) {
|
|
targetVersion = skill?.metadata?.version;
|
|
}
|
|
|
|
// Handle both file name and file path
|
|
const file = await findFileByPath(folderId, filePath, targetVersion);
|
|
if (!file) return null;
|
|
return await fetchFileContent(file.id);
|
|
} catch (error) {
|
|
console.error('Error getting skill file content:', error);
|
|
return null;
|
|
}
|
|
},
|
|
[skills],
|
|
);
|
|
|
|
// Fetch files for a specific version of a skill
|
|
// Can be called with an optional skill object (for search results not in skills state)
|
|
const getSkillVersionFiles = useCallback(
|
|
async (
|
|
skillId: string,
|
|
version: string,
|
|
skillObj?: Skill,
|
|
): Promise<SkillFileEntry[]> => {
|
|
try {
|
|
// Find the skill to get its folder ID
|
|
// Use provided skill object if available (for search results), otherwise look up in skills state
|
|
const skill = skillObj || skills.find((s) => s.id === skillId);
|
|
if (!skill) return [];
|
|
|
|
// Use internal _folderId for file operations
|
|
const folderId = (skill as any)._folderId;
|
|
if (!folderId) return [];
|
|
|
|
// First, list the skill folder to find the version folder
|
|
const { data: skillFolderData } = await fileManagerService.listFile({
|
|
parent_id: folderId,
|
|
});
|
|
|
|
if (skillFolderData.code !== 0) return [];
|
|
|
|
const skillItems = skillFolderData.data?.files || [];
|
|
|
|
// If version is not provided, find the latest version folder
|
|
let targetVersion = version;
|
|
if (!targetVersion) {
|
|
// Find all version folders (matching semver pattern x.y.z)
|
|
const versionFolders = skillItems.filter(
|
|
(f: any) => f.type === 'folder' && /^\d+\.\d+\.\d+/.test(f.name),
|
|
);
|
|
if (versionFolders.length === 0) return [];
|
|
|
|
// Sort by version number (descending) to get the latest
|
|
const sortedVersions = versionFolders.sort((a: any, b: any) => {
|
|
const va = a.name.split('.').map(Number);
|
|
const vb = b.name.split('.').map(Number);
|
|
for (let i = 0; i < Math.max(va.length, vb.length); i++) {
|
|
const na = va[i] || 0;
|
|
const nb = vb[i] || 0;
|
|
if (na !== nb) return nb - na; // Descending order
|
|
}
|
|
return 0;
|
|
});
|
|
targetVersion = sortedVersions[0].name;
|
|
}
|
|
|
|
const versionFolder = skillItems.find(
|
|
(f: any) => f.name === targetVersion && f.type === 'folder',
|
|
);
|
|
|
|
if (!versionFolder) return [];
|
|
|
|
const fileEntries: SkillFileEntry[] = [];
|
|
|
|
// Recursively fetch all files in the version folder
|
|
const fetchFilesRecursive = async (
|
|
parentId: string,
|
|
basePath: string = '',
|
|
) => {
|
|
const { data } = await fileManagerService.listFile({
|
|
parent_id: parentId,
|
|
});
|
|
if (data.code !== 0) return;
|
|
|
|
const files = data.data?.files || [];
|
|
|
|
for (const f of files) {
|
|
const path = basePath ? `${basePath}/${f.name}` : f.name;
|
|
|
|
fileEntries.push({
|
|
name: f.name,
|
|
path: path,
|
|
is_dir: f.type === 'folder',
|
|
size: f.size || 0,
|
|
});
|
|
|
|
if (f.type === 'folder') {
|
|
await fetchFilesRecursive(f.id, path);
|
|
}
|
|
}
|
|
};
|
|
|
|
await fetchFilesRecursive(versionFolder.id);
|
|
return fileEntries;
|
|
} catch (error) {
|
|
console.error('Error fetching skill version files:', error);
|
|
return [];
|
|
}
|
|
},
|
|
[skills],
|
|
);
|
|
|
|
// Filter skills by search query
|
|
const filteredSkills = useMemo(
|
|
() =>
|
|
skills.filter(
|
|
(skill) =>
|
|
skill.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
skill.description
|
|
?.toLowerCase()
|
|
.includes(searchQuery.toLowerCase()) ||
|
|
skill.metadata?.tags?.some((tag) =>
|
|
tag.toLowerCase().includes(searchQuery.toLowerCase()),
|
|
),
|
|
),
|
|
[skills, searchQuery],
|
|
);
|
|
|
|
// Fetch skills on mount
|
|
useEffect(() => {
|
|
fetchSkills();
|
|
}, [fetchSkills]);
|
|
|
|
// Get skill details by folder ID and name (for loading versions)
|
|
const getSkillDetails = useCallback(
|
|
async (folderId: string, folderName: string): Promise<Skill | null> => {
|
|
return await fetchSkillDetails(folderId, folderName);
|
|
},
|
|
// oxlint-disable-next-line react/exhaustive-deps
|
|
[],
|
|
);
|
|
|
|
return {
|
|
skills,
|
|
filteredSkills,
|
|
loading,
|
|
searchQuery,
|
|
setSearchQuery,
|
|
fetchSpaces,
|
|
createSpace,
|
|
deleteSpace,
|
|
updateSpace,
|
|
fetchSkills,
|
|
uploadSkill,
|
|
deleteSkill,
|
|
getSkillFileContent,
|
|
getSkillVersionFiles,
|
|
getSkillDetails,
|
|
};
|
|
};
|
|
|
|
// Query key for skill search config
|
|
const skillSearchConfigQueryKey = (spaceId: string, embdId?: string) =>
|
|
['skillSearchConfig', spaceId, embdId].filter(Boolean);
|
|
|
|
// Skill Search Config Hook
|
|
export const useSkillSearchConfig = (spaceId?: string) => {
|
|
const { t } = useTranslation();
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
// Use TanStack Query to fetch and cache config
|
|
const {
|
|
data: config,
|
|
refetch: refetchConfig,
|
|
isLoading: configLoading,
|
|
} = useQuery({
|
|
queryKey: skillSearchConfigQueryKey(spaceId || '', undefined),
|
|
queryFn: async () => {
|
|
if (!spaceId) return null;
|
|
const data = await skillSpaceService.getConfig(spaceId);
|
|
return data as SkillSearchConfig | null;
|
|
},
|
|
enabled: !!spaceId,
|
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
});
|
|
|
|
// Fetch config with optional embdId (refreshes the query)
|
|
const fetchConfig = useCallback(
|
|
async (_embdId?: string, currentSpaceId?: string) => {
|
|
const targetSpaceId = currentSpaceId || spaceId;
|
|
if (!targetSpaceId) return null;
|
|
const { data } = await refetchConfig();
|
|
return data as SkillSearchConfig | null;
|
|
},
|
|
[spaceId, refetchConfig],
|
|
);
|
|
|
|
// Save config
|
|
const saveConfig = useCallback(
|
|
async (configData: SkillSearchConfig): Promise<boolean> => {
|
|
try {
|
|
setSaving(true);
|
|
if (!spaceId) throw new Error('Space ID is required');
|
|
await skillSpaceService.updateConfig({
|
|
...configData,
|
|
space_id: spaceId,
|
|
});
|
|
// Refetch config after save
|
|
await refetchConfig();
|
|
message.success(t('skillSearch.saveSuccess'));
|
|
return true;
|
|
} catch (error: any) {
|
|
console.error('Error saving skill search config:', error);
|
|
message.error(error.message || t('skillSearch.saveError'));
|
|
return false;
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
},
|
|
[t, spaceId, refetchConfig],
|
|
);
|
|
|
|
// Reindex all skills
|
|
const reindex = useCallback(
|
|
async (embdId?: string): Promise<boolean> => {
|
|
try {
|
|
setSaving(true);
|
|
if (!spaceId) throw new Error('Space ID is required');
|
|
await skillSpaceService.reindex({
|
|
skills: [],
|
|
space_id: spaceId,
|
|
embd_id: embdId,
|
|
});
|
|
message.success(t('skillSearch.reindexSuccess'));
|
|
return true;
|
|
} catch (error: any) {
|
|
console.error('Error reindexing skills:', error);
|
|
message.error(error.message || t('skillSearch.reindexError'));
|
|
return false;
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
},
|
|
[t, spaceId],
|
|
);
|
|
|
|
// Initialize index
|
|
const initializeIndex = useCallback(async (): Promise<boolean> => {
|
|
try {
|
|
if (!spaceId) throw new Error('Space ID is required');
|
|
// Initialize index is now handled automatically when creating index
|
|
// Call index API directly to ensure index exists
|
|
// embd_id will be fetched from skill search config by backend
|
|
await skillSpaceService.indexSkills({ skills: [], space_id: spaceId });
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Error initializing skill search index:', error);
|
|
return false;
|
|
}
|
|
}, [spaceId]);
|
|
|
|
// Search skills
|
|
const searchSkills = useCallback(
|
|
async (query: string, page = 1, pageSize = 10) => {
|
|
try {
|
|
if (!spaceId) return { skills: [], total: 0 };
|
|
const data = await skillSpaceService.search({
|
|
space_id: spaceId,
|
|
query,
|
|
page,
|
|
page_size: pageSize,
|
|
});
|
|
// Transform backend results to Skill[] format
|
|
// Use folder_id if available (for file operations), otherwise skill_id
|
|
const skills: Skill[] = (data.skills || []).map((result: any) => {
|
|
// Prefer backend timestamp to avoid all cards showing "just now".
|
|
// Fallback to now only when backend doesn't provide time fields.
|
|
const timestamp = pickSkillTimestamp(result);
|
|
|
|
// skill_id from backend is now the skill name (without version suffix)
|
|
const skillId = result.skill_id || result.name;
|
|
|
|
return {
|
|
id: skillId, // Use skill name as ID (consistent with list view)
|
|
name: result.name,
|
|
description: result.description,
|
|
source_type: 'search',
|
|
created_at: timestamp,
|
|
updated_at: timestamp,
|
|
metadata: {
|
|
tags: result.tags || [],
|
|
score: result.score,
|
|
bm25_score: result.bm25_score,
|
|
vector_score: result.vector_score,
|
|
},
|
|
files: [],
|
|
_folderId: result.folder_id, // Store folder_id for file operations if needed
|
|
};
|
|
});
|
|
return {
|
|
skills,
|
|
total: data.total || 0,
|
|
};
|
|
} catch (error) {
|
|
console.error('Error searching skills:', error);
|
|
return { skills: [], total: 0 };
|
|
}
|
|
},
|
|
[spaceId],
|
|
);
|
|
|
|
// Get index status
|
|
const getIndexStatus = useCallback(async () => {
|
|
try {
|
|
const response = await fetch('/api/v1/skills/status', {
|
|
headers: {
|
|
Authorization: getAuthorization(),
|
|
},
|
|
});
|
|
const data = await response.json();
|
|
if (data.code === 0) {
|
|
return data.data;
|
|
}
|
|
return null;
|
|
} catch (error) {
|
|
console.error('Error getting skill index status:', error);
|
|
return null;
|
|
}
|
|
}, []);
|
|
|
|
return {
|
|
config,
|
|
configLoading,
|
|
saving,
|
|
fetchConfig,
|
|
saveConfig,
|
|
reindex,
|
|
initializeIndex,
|
|
searchSkills,
|
|
getIndexStatus,
|
|
};
|
|
};
|