feat(chunk): add chunk editing and image preview functionality
This commit is contained in:
@@ -69,7 +69,7 @@ export function useLlmModelSetting() {
|
|||||||
|
|
||||||
const fetchMyLlm = async () => {
|
const fetchMyLlm = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await userService.my_llm();
|
const res = await userService.my_llm({include_details: true});
|
||||||
const llm_dic = res.data.data || {};
|
const llm_dic = res.data.data || {};
|
||||||
setMyLlm(llm_dic);
|
setMyLlm(llm_dic);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -343,6 +343,12 @@ export interface IChunk {
|
|||||||
tag_feas?: Record<string, number>;
|
tag_feas?: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IChunkDetail extends IChunk {
|
||||||
|
/** 文档块ID */
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 测试文档块接口
|
* 测试文档块接口
|
||||||
* 用于知识库测试和检索的文档块信息
|
* 用于知识库测试和检索的文档块信息
|
||||||
|
|||||||
@@ -1553,6 +1553,9 @@ Important structured information may include: names, dates, locations, events, k
|
|||||||
fileTypeNotSupportedPreview: 'File type not supported for preview',
|
fileTypeNotSupportedPreview: 'File type not supported for preview',
|
||||||
filePreview: 'File Preview',
|
filePreview: 'File Preview',
|
||||||
loadingFile: 'Loading file...',
|
loadingFile: 'Loading file...',
|
||||||
|
editChunk: 'Edit Chunk',
|
||||||
|
content: 'Content',
|
||||||
|
saving: 'Saving...',
|
||||||
},
|
},
|
||||||
fileUpload: {
|
fileUpload: {
|
||||||
uploadFiles: 'Upload Files',
|
uploadFiles: 'Upload Files',
|
||||||
|
|||||||
@@ -1517,6 +1517,9 @@ General:实体和关系提取提示来自 GitHub - microsoft/graphrag:基于
|
|||||||
loadingPreview: '正在加载预览...',
|
loadingPreview: '正在加载预览...',
|
||||||
downloadFile: '下载文件',
|
downloadFile: '下载文件',
|
||||||
openInNewTab: '在新标签页中打开',
|
openInNewTab: '在新标签页中打开',
|
||||||
|
editChunk: '编辑Chunk',
|
||||||
|
content: '内容',
|
||||||
|
saving: '保存中...',
|
||||||
},
|
},
|
||||||
fileUpload: {
|
fileUpload: {
|
||||||
uploadFiles: '上传文件',
|
uploadFiles: '上传文件',
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import {
|
|||||||
DialogContentText,
|
DialogContentText,
|
||||||
Toolbar,
|
Toolbar,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
|
TextField,
|
||||||
|
Switch,
|
||||||
|
Backdrop,
|
||||||
|
Popover,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
@@ -35,9 +39,13 @@ import {
|
|||||||
ToggleOff as DisableIcon,
|
ToggleOff as DisableIcon,
|
||||||
SelectAll as SelectAllIcon,
|
SelectAll as SelectAllIcon,
|
||||||
Clear as ClearIcon,
|
Clear as ClearIcon,
|
||||||
|
Edit as EditIcon,
|
||||||
|
Close as CloseIcon,
|
||||||
|
Save as SaveIcon,
|
||||||
|
ZoomIn as ZoomInIcon,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { IChunk } from '@/interfaces/database/knowledge';
|
import type { IChunk, IChunkDetail } from '@/interfaces/database/knowledge';
|
||||||
import knowledgeService from '@/services/knowledge_service';
|
import knowledgeService from '@/services/knowledge_service';
|
||||||
|
|
||||||
interface ChunkListResultProps {
|
interface ChunkListResultProps {
|
||||||
@@ -54,7 +62,7 @@ interface ChunkListResultProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ChunkListResult(props: ChunkListResultProps) {
|
function ChunkListResult(props: ChunkListResultProps) {
|
||||||
const { doc_id, chunks, total, loading, error, page, pageSize, onPageChange, onRefresh, docName } = props;
|
const { doc_id, chunks, total, loading, page, pageSize, onPageChange, onRefresh } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// 选择状态
|
// 选择状态
|
||||||
@@ -65,6 +73,19 @@ function ChunkListResult(props: ChunkListResultProps) {
|
|||||||
const [operationLoading, setOperationLoading] = useState(false);
|
const [operationLoading, setOperationLoading] = useState(false);
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
// 图片预览状态
|
||||||
|
const [imagePreviewOpen, setImagePreviewOpen] = useState(false);
|
||||||
|
const [previewImageUrl, setPreviewImageUrl] = useState('');
|
||||||
|
const [imageHoverAnchor, setImageHoverAnchor] = useState<HTMLElement | null>(null);
|
||||||
|
const [hoveredImageUrl, setHoveredImageUrl] = useState('');
|
||||||
|
|
||||||
|
// 编辑状态
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
|
const [editingChunk, setEditingChunk] = useState<IChunkDetail | null>(null);
|
||||||
|
const [editContent, setEditContent] = useState('');
|
||||||
|
const [editAvailable, setEditAvailable] = useState(true);
|
||||||
|
const [editLoading, setEditLoading] = useState(false);
|
||||||
|
|
||||||
// 处理单个选择
|
// 处理单个选择
|
||||||
const handleSelectChunk = useCallback((chunkId: string, checked: boolean) => {
|
const handleSelectChunk = useCallback((chunkId: string, checked: boolean) => {
|
||||||
setSelectedChunks(prev => {
|
setSelectedChunks(prev => {
|
||||||
@@ -116,6 +137,84 @@ function ChunkListResult(props: ChunkListResultProps) {
|
|||||||
}
|
}
|
||||||
}, [selectedChunks, handleClearSelection, onRefresh]);
|
}, [selectedChunks, handleClearSelection, onRefresh]);
|
||||||
|
|
||||||
|
// 图片hover处理
|
||||||
|
const handleImageHover = useCallback((event: React.MouseEvent<HTMLElement>, imageUrl: string) => {
|
||||||
|
setImageHoverAnchor(event.currentTarget);
|
||||||
|
setHoveredImageUrl(imageUrl);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleImageHoverClose = useCallback(() => {
|
||||||
|
setImageHoverAnchor(null);
|
||||||
|
setHoveredImageUrl('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 图片点击放大
|
||||||
|
const handleImageClick = useCallback((imageUrl: string) => {
|
||||||
|
setPreviewImageUrl(imageUrl);
|
||||||
|
setImagePreviewOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 编辑chunk
|
||||||
|
const handleEditChunk = useCallback(async (chunk: IChunk) => {
|
||||||
|
try {
|
||||||
|
setEditLoading(true);
|
||||||
|
// 获取chunk详情
|
||||||
|
const chunk_id = chunk.chunk_id || '';
|
||||||
|
const response = await knowledgeService.getChunk({ chunk_id });
|
||||||
|
const chunkDetail: IChunkDetail = response.data.data;
|
||||||
|
|
||||||
|
setEditingChunk(chunkDetail);
|
||||||
|
setEditContent(chunkDetail.content_with_weight || '');
|
||||||
|
|
||||||
|
const available_int = chunk.available_int
|
||||||
|
const available_int_D = chunkDetail.available_int;
|
||||||
|
if (available_int_D === undefined) {
|
||||||
|
setEditAvailable(available_int === 1);
|
||||||
|
} else {
|
||||||
|
setEditAvailable(available_int_D === 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditDialogOpen(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to get chunk detail:', err);
|
||||||
|
} finally {
|
||||||
|
setEditLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 保存编辑
|
||||||
|
const handleSaveEdit = useCallback(async () => {
|
||||||
|
if (!editingChunk) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setEditLoading(true);
|
||||||
|
await knowledgeService.updateChunk({
|
||||||
|
...editingChunk,
|
||||||
|
chunk_id: editingChunk.id || '',
|
||||||
|
content_with_weight: editContent,
|
||||||
|
available_int: editAvailable ? 1 : 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
setEditDialogOpen(false);
|
||||||
|
setEditingChunk(null);
|
||||||
|
// delay 800 ms
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 800));
|
||||||
|
onRefresh?.();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update chunk:', err);
|
||||||
|
} finally {
|
||||||
|
setEditLoading(false);
|
||||||
|
}
|
||||||
|
}, [editingChunk, editContent, editAvailable, onRefresh]);
|
||||||
|
|
||||||
|
// 取消编辑
|
||||||
|
const handleCancelEdit = useCallback(() => {
|
||||||
|
setEditDialogOpen(false);
|
||||||
|
setEditingChunk(null);
|
||||||
|
setEditContent('');
|
||||||
|
setEditAvailable(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 删除chunks
|
// 删除chunks
|
||||||
const handleDeleteChunks = useCallback(async () => {
|
const handleDeleteChunks = useCallback(async () => {
|
||||||
if (selectedChunks.length === 0) return;
|
if (selectedChunks.length === 0) return;
|
||||||
@@ -148,16 +247,6 @@ function ChunkListResult(props: ChunkListResultProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Paper sx={{ p: 3 }}>
|
|
||||||
<Alert severity="error">
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!chunks || chunks.length === 0) {
|
if (!chunks || chunks.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ p: 3, textAlign: 'center' }}>
|
<Paper sx={{ p: 3, textAlign: 'center' }}>
|
||||||
@@ -176,6 +265,7 @@ function ChunkListResult(props: ChunkListResultProps) {
|
|||||||
const selectedEnabledCount = selectedChunks.filter(id =>
|
const selectedEnabledCount = selectedChunks.filter(id =>
|
||||||
chunks.find(chunk => chunk.chunk_id === id)?.available_int === 1
|
chunks.find(chunk => chunk.chunk_id === id)?.available_int === 1
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
const selectedDisabledCount = selectedChunks.length - selectedEnabledCount;
|
const selectedDisabledCount = selectedChunks.length - selectedEnabledCount;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -261,7 +351,7 @@ function ChunkListResult(props: ChunkListResultProps) {
|
|||||||
|
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
{chunks.map((chunk, index) => (
|
{chunks.map((chunk, index) => (
|
||||||
<Grid size={12} key={chunk.chunk_id}>
|
<Grid size={6} key={chunk.chunk_id}>
|
||||||
<Card
|
<Card
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
sx={{
|
sx={{
|
||||||
@@ -279,42 +369,13 @@ function ChunkListResult(props: ChunkListResultProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 2 }}>
|
<CardContent sx={{ p: 2 }}>
|
||||||
{/* 头部信息 */}
|
{/* 头部操作区域 */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedChunks.includes(chunk.chunk_id)}
|
checked={selectedChunks.includes(chunk.chunk_id)}
|
||||||
onChange={(e) => handleSelectChunk(chunk.chunk_id, e.target.checked)}
|
onChange={(e) => handleSelectChunk(chunk.chunk_id, e.target.checked)}
|
||||||
sx={{ mr: 1 }}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Avatar
|
|
||||||
sx={{
|
|
||||||
bgcolor: chunk.available_int === 1 ? 'success.main' : 'grey.400',
|
|
||||||
mr: 2,
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextIcon fontSize="small" />
|
|
||||||
</Avatar>
|
|
||||||
|
|
||||||
<Box sx={{ flexGrow: 1 }}>
|
|
||||||
<Typography variant="subtitle1" fontWeight="medium">
|
|
||||||
Chunk #{((page - 1) * pageSize) + index + 1}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
ID: {chunk.chunk_id}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Stack direction="row" spacing={1} alignItems="center">
|
|
||||||
{chunk.image_id && (
|
|
||||||
<Tooltip title={t('chunkPage.containsImage')}>
|
|
||||||
<Avatar sx={{ bgcolor: 'info.main', width: 24, height: 24 }}>
|
|
||||||
<ImageIcon fontSize="small" />
|
|
||||||
</Avatar>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<Chip
|
<Chip
|
||||||
icon={chunk.available_int === 1 ? <VisibilityIcon /> : <VisibilityOffIcon />}
|
icon={chunk.available_int === 1 ? <VisibilityIcon /> : <VisibilityOffIcon />}
|
||||||
label={chunk.available_int === 1 ? t('chunkPage.enabled') : t('chunkPage.disabled')}
|
label={chunk.available_int === 1 ? t('chunkPage.enabled') : t('chunkPage.disabled')}
|
||||||
@@ -322,25 +383,100 @@ function ChunkListResult(props: ChunkListResultProps) {
|
|||||||
color={chunk.available_int === 1 ? 'success' : 'default'}
|
color={chunk.available_int === 1 ? 'success' : 'default'}
|
||||||
variant={chunk.available_int === 1 ? 'filled' : 'outlined'}
|
variant={chunk.available_int === 1 ? 'filled' : 'outlined'}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 内容区域 */}
|
{/* 主要内容区域 - 左右布局 */}
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ display: 'flex', gap: 2, minHeight: '120px' }}>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
{/* 左侧图片区域 */}
|
||||||
{t('chunkPage.contentPreview')}
|
<Box sx={{
|
||||||
</Typography>
|
width: chunk.image_id ? '120px' : '0px',
|
||||||
|
flexShrink: 0,
|
||||||
|
display: chunk.image_id ? 'block' : 'none'
|
||||||
|
}}>
|
||||||
|
{chunk.image_id && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
height: '120px',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'grey.300',
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'grey.50',
|
||||||
|
position: 'relative',
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
'& .image-overlay': {
|
||||||
|
opacity: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={() => handleImageClick(`${import.meta.env.VITE_API_BASE_URL}/v1/document/image/${chunk.image_id}`)}
|
||||||
|
onMouseEnter={(e) => handleImageHover(e, `${import.meta.env.VITE_API_BASE_URL}/v1/document/image/${chunk.image_id}`)}
|
||||||
|
onMouseLeave={handleImageHoverClose}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={`${import.meta.env.VITE_API_BASE_URL}/v1/document/image/${chunk.image_id}`}
|
||||||
|
alt={t('chunkPage.chunkRelatedImage')}
|
||||||
|
sx={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
|
}}
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
target.style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* 悬浮覆盖层 */}
|
||||||
|
<Box
|
||||||
|
className="image-overlay"
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 0.2s ease-in-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ZoomInIcon sx={{ color: 'white', fontSize: 32 }} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 右侧内容区域 */}
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0, position: 'relative' }}>
|
||||||
<Paper
|
<Paper
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
sx={{
|
sx={{
|
||||||
p: 1.5,
|
p: 1.5,
|
||||||
maxHeight: '150px',
|
height: '120px',
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
backgroundColor: 'grey.50',
|
backgroundColor: 'grey.50',
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'grey.100',
|
||||||
|
'& .edit-button': {
|
||||||
|
opacity: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
|
onClick={() => handleEditChunk(chunk)}
|
||||||
>
|
>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
@@ -351,42 +487,33 @@ function ChunkListResult(props: ChunkListResultProps) {
|
|||||||
>
|
>
|
||||||
{chunk.content_with_weight || t('chunkPage.noContent')}
|
{chunk.content_with_weight || t('chunkPage.noContent')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 图片显示区域 */}
|
{/* 编辑按钮 */}
|
||||||
{chunk.image_id && (
|
<IconButton
|
||||||
<Box sx={{ mb: 2 }}>
|
className="edit-button"
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
size="small"
|
||||||
{t('chunkPage.relatedImage')}
|
|
||||||
</Typography>
|
|
||||||
<Box
|
|
||||||
sx={{
|
sx={{
|
||||||
textAlign: 'center',
|
position: 'absolute',
|
||||||
p: 1,
|
top: 8,
|
||||||
border: '1px solid',
|
right: 8,
|
||||||
borderColor: 'grey.300',
|
opacity: 0,
|
||||||
borderRadius: 1,
|
transition: 'opacity 0.2s ease-in-out',
|
||||||
|
backgroundColor: 'background.paper',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'primary.light',
|
||||||
|
color: 'primary.contrastText',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEditChunk(chunk);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<EditIcon fontSize="small" />
|
||||||
component="img"
|
</IconButton>
|
||||||
src={`${import.meta.env.VITE_API_BASE_URL}/v1/document/image/${chunk.image_id}`}
|
</Paper>
|
||||||
alt={t('chunkPage.chunkRelatedImage')}
|
|
||||||
sx={{
|
|
||||||
maxWidth: '100%',
|
|
||||||
maxHeight: '200px',
|
|
||||||
borderRadius: 1,
|
|
||||||
objectFit: 'contain',
|
|
||||||
}}
|
|
||||||
onError={(e) => {
|
|
||||||
const target = e.target as HTMLImageElement;
|
|
||||||
target.style.display = 'none';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 关键词区域 */}
|
{/* 关键词区域 */}
|
||||||
{((chunk.important_kwd ?? []).length > 0 || (chunk.question_kwd ?? []).length > 0 || (chunk.tag_kwd ?? []).length > 0) && (
|
{((chunk.important_kwd ?? []).length > 0 || (chunk.question_kwd ?? []).length > 0 || (chunk.tag_kwd ?? []).length > 0) && (
|
||||||
@@ -473,6 +600,120 @@ function ChunkListResult(props: ChunkListResultProps) {
|
|||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
{/* 图片hover预览 */}
|
||||||
|
<Popover
|
||||||
|
open={Boolean(imageHoverAnchor)}
|
||||||
|
anchorEl={imageHoverAnchor}
|
||||||
|
onClose={handleImageHoverClose}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'right',
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'left',
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ p: 1, maxWidth: 300, maxHeight: 300 }}>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={hoveredImageUrl}
|
||||||
|
alt="Preview"
|
||||||
|
sx={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{/* 图片放大预览 */}
|
||||||
|
<Dialog
|
||||||
|
open={imagePreviewOpen}
|
||||||
|
onClose={() => setImagePreviewOpen(false)}
|
||||||
|
maxWidth='md'
|
||||||
|
fullWidth
|
||||||
|
sx={{
|
||||||
|
'& .MuiDialog-paper': {
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.2)',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={previewImageUrl}
|
||||||
|
alt="Full size preview"
|
||||||
|
sx={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
objectFit: 'contain',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setImagePreviewOpen(false)}
|
||||||
|
sx={{ color: 'white' }}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 编辑chunk对话框 */}
|
||||||
|
<Dialog
|
||||||
|
open={editDialogOpen}
|
||||||
|
onClose={handleCancelEdit}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
{t('chunkPage.editChunk')}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label={t('chunkPage.content')}
|
||||||
|
multiline
|
||||||
|
rows={8}
|
||||||
|
fullWidth
|
||||||
|
value={editContent}
|
||||||
|
onChange={(e) => setEditContent(e.target.value)}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={editAvailable}
|
||||||
|
onChange={(e) => setEditAvailable(e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={t('chunkPage.enabled')}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleCancelEdit}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveEdit}
|
||||||
|
variant="contained"
|
||||||
|
disabled={editLoading}
|
||||||
|
startIcon={editLoading ? <CircularProgress size={16} /> : <SaveIcon />}
|
||||||
|
>
|
||||||
|
{editLoading ? t('chunkPage.saving') : t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* 删除确认对话框 */}
|
{/* 删除确认对话框 */}
|
||||||
<Dialog
|
<Dialog
|
||||||
open={deleteDialogOpen}
|
open={deleteDialogOpen}
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ import {
|
|||||||
TextFormField,
|
TextFormField,
|
||||||
type SelectOption,
|
type SelectOption,
|
||||||
} from '@/components/FormField';
|
} from '@/components/FormField';
|
||||||
|
import { LlmSvgIcon } from '@/components/AppSvgIcon';
|
||||||
|
import {type LLMFactory } from '@/constants/llm';
|
||||||
|
import { getFactoryIconName } from '@/utils/common';
|
||||||
|
|
||||||
// 解析器选项配置
|
// 解析器选项配置
|
||||||
const PARSER_OPTIONS = [
|
const PARSER_OPTIONS = [
|
||||||
@@ -365,7 +368,13 @@ export function EmbeddingModelItem() {
|
|||||||
<ListSubheader key={group.label}>{group.label}</ListSubheader>,
|
<ListSubheader key={group.label}>{group.label}</ListSubheader>,
|
||||||
...group.options.map((option) => (
|
...group.options.map((option) => (
|
||||||
<MenuItem key={option.value} value={option.value} disabled={option.disabled}>
|
<MenuItem key={option.value} value={option.value} disabled={option.disabled}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<LlmSvgIcon
|
||||||
|
name={getFactoryIconName(group.label as LLMFactory)}
|
||||||
|
sx={{ width: 20, height: 20, color: 'primary.main' }}
|
||||||
|
/>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
</Box>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))
|
))
|
||||||
])}
|
])}
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ import type { INextTestingResult } from '@/interfaces/database/knowledge';
|
|||||||
import KnowledgeBreadcrumbs from './components/KnowledgeBreadcrumbs';
|
import KnowledgeBreadcrumbs from './components/KnowledgeBreadcrumbs';
|
||||||
import TestChunkResult from './components/TestChunkResult';
|
import TestChunkResult from './components/TestChunkResult';
|
||||||
import { useSnackbar } from '@/components/Provider/SnackbarProvider';
|
import { useSnackbar } from '@/components/Provider/SnackbarProvider';
|
||||||
import { toLower } from 'lodash';
|
import { type LLMFactory } from '@/constants/llm';
|
||||||
import { t } from 'i18next';
|
import { t } from 'i18next';
|
||||||
|
import { LlmSvgIcon } from '@/components/AppSvgIcon';
|
||||||
|
import { getFactoryIconName } from '@/utils/common';
|
||||||
|
|
||||||
// 语言选项常量
|
// 语言选项常量
|
||||||
const options = [
|
const options = [
|
||||||
@@ -327,7 +329,13 @@ function KnowledgeBaseTesting() {
|
|||||||
<ListSubheader key={group.label}>{group.label}</ListSubheader>,
|
<ListSubheader key={group.label}>{group.label}</ListSubheader>,
|
||||||
...group.options.map((option) => (
|
...group.options.map((option) => (
|
||||||
<MenuItem key={option.value} value={option.value} disabled={option.disabled}>
|
<MenuItem key={option.value} value={option.value} disabled={option.disabled}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<LlmSvgIcon
|
||||||
|
name={getFactoryIconName(group.label as LLMFactory)}
|
||||||
|
sx={{ width: 20, height: 20, color: 'primary.main' }}
|
||||||
|
/>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
</Box>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))
|
))
|
||||||
])}
|
])}
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ function OllamaDialog({
|
|||||||
reset,
|
reset,
|
||||||
} = useForm<OllamaFormData>({
|
} = useForm<OllamaFormData>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
model_type: 'chat',
|
model_type: initialData?.model_type || 'chat',
|
||||||
llm_name: '',
|
llm_name: initialData?.llm_name || '',
|
||||||
api_base: initialData?.api_base,
|
api_base: initialData?.api_base,
|
||||||
api_key: initialData?.api_key,
|
api_key: initialData?.api_key,
|
||||||
max_tokens: initialData?.max_tokens,
|
max_tokens: initialData?.max_tokens,
|
||||||
@@ -146,8 +146,8 @@ function OllamaDialog({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
reset({
|
reset({
|
||||||
model_type: 'chat',
|
model_type: initialData?.model_type || 'chat',
|
||||||
llm_name: '',
|
llm_name: initialData?.llm_name || '',
|
||||||
api_base: initialData?.api_base,
|
api_base: initialData?.api_base,
|
||||||
api_key: initialData?.api_key,
|
api_key: initialData?.api_key,
|
||||||
max_tokens: initialData?.max_tokens,
|
max_tokens: initialData?.max_tokens,
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ import {
|
|||||||
import { useForm, Controller } from 'react-hook-form';
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { LlmSvgIcon } from '@/components/AppSvgIcon';
|
import { LlmSvgIcon } from '@/components/AppSvgIcon';
|
||||||
import { IconMap, type LLMFactory } from '@/constants/llm';
|
import { type LLMFactory } from '@/constants/llm';
|
||||||
import type { ITenantInfo } from '@/interfaces/database/knowledge';
|
import type { ITenantInfo } from '@/interfaces/database/knowledge';
|
||||||
import type { LlmModelType } from '@/constants/knowledge';
|
import type { IThirdOAIModel } from '@/interfaces/database/llm';
|
||||||
import type { IMyLlmModel, IThirdOAIModel } from '@/interfaces/database/llm';
|
import { getFactoryIconName } from '@/utils/common';
|
||||||
|
|
||||||
interface AllModelOptionItem {
|
interface AllModelOptionItem {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -75,11 +75,6 @@ function SystemModelDialog({
|
|||||||
defaultValues: {}
|
defaultValues: {}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取工厂图标名称
|
|
||||||
const getFactoryIconName = (factoryName: LLMFactory) => {
|
|
||||||
return IconMap[factoryName] || 'default';
|
|
||||||
};
|
|
||||||
|
|
||||||
// all model options 包含了全部的 options
|
// all model options 包含了全部的 options
|
||||||
const llmOptions = useMemo(() => allModelOptions?.llmOptions || [], [allModelOptions]);
|
const llmOptions = useMemo(() => allModelOptions?.llmOptions || [], [allModelOptions]);
|
||||||
const embdOptions = useMemo(() => allModelOptions?.embeddingOptions || [], [allModelOptions]);
|
const embdOptions = useMemo(() => allModelOptions?.embeddingOptions || [], [allModelOptions]);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { IconMap, type LLMFactory } from "@/constants/llm";
|
|||||||
import type { IFactory } from "@/interfaces/database/llm";
|
import type { IFactory } from "@/interfaces/database/llm";
|
||||||
import { Box, Button, Card, CardContent, Chip, Typography } from "@mui/material";
|
import { Box, Button, Card, CardContent, Chip, Typography } from "@mui/material";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { getFactoryIconName } from '@/utils/common';
|
||||||
|
|
||||||
// 模型类型标签颜色映射
|
// 模型类型标签颜色映射
|
||||||
export const MODEL_TYPE_COLORS: Record<string, string> = {
|
export const MODEL_TYPE_COLORS: Record<string, string> = {
|
||||||
@@ -30,11 +31,6 @@ const LLMFactoryCard: React.FC<ModelFactoryCardProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// 获取工厂图标名称
|
|
||||||
const getFactoryIconName = (factoryName: LLMFactory) => {
|
|
||||||
return IconMap[factoryName] || 'default';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card sx={{
|
<Card sx={{
|
||||||
mb: 2,
|
mb: 2,
|
||||||
|
|||||||
@@ -352,7 +352,6 @@ export const useModelDialogs = (onSuccess?: () => void) => {
|
|||||||
|
|
||||||
// 根据工厂类型打开对应的对话框
|
// 根据工厂类型打开对应的对话框
|
||||||
const openFactoryDialog = useCallback((factoryName: string, data?: any, isEdit = false) => {
|
const openFactoryDialog = useCallback((factoryName: string, data?: any, isEdit = false) => {
|
||||||
// 使用通用的 ConfigurationDialog 替代特定的 Dialog
|
|
||||||
configurationDialog.openConfigurationDialog(factoryName, data, isEdit);
|
configurationDialog.openConfigurationDialog(factoryName, data, isEdit);
|
||||||
}, [configurationDialog]);
|
}, [configurationDialog]);
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,16 @@ import { ModelDialogs } from './components/ModelDialogs';
|
|||||||
import { useDialog } from '@/hooks/useDialog';
|
import { useDialog } from '@/hooks/useDialog';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
import { LLM_FACTORY_LIST, LocalLlmFactories, type LLMFactory } from '@/constants/llm';
|
import { LLM_FACTORY_LIST, LocalLlmFactories, type LLMFactory } from '@/constants/llm';
|
||||||
|
import { LlmSvgIcon } from '@/components/AppSvgIcon';
|
||||||
|
import { getFactoryIconName } from '@/utils/common';
|
||||||
|
|
||||||
function MyLlmGridItem({ model, onDelete }: { model: ILlmItem, onDelete: (model: ILlmItem) => void }) {
|
interface MyLlmGridItemProps {
|
||||||
|
model: ILlmItem,
|
||||||
|
onDelete: (model: ILlmItem) => void,
|
||||||
|
onEditLlm?: (model: ILlmItem) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
function MyLlmGridItem({ model, onDelete, onEditLlm }: MyLlmGridItemProps) {
|
||||||
return (
|
return (
|
||||||
<Grid size={{ xs: 6, sm: 4, md: 3 }} key={model.name}>
|
<Grid size={{ xs: 6, sm: 4, md: 3 }} key={model.name}>
|
||||||
<Card variant="outlined" sx={{ p: 2 }}>
|
<Card variant="outlined" sx={{ p: 2 }}>
|
||||||
@@ -41,7 +49,21 @@ function MyLlmGridItem({ model, onDelete }: { model: ILlmItem, onDelete: (model:
|
|||||||
<Typography variant="body2" fontWeight="bold">
|
<Typography variant="body2" fontWeight="bold">
|
||||||
{model.name}
|
{model.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box>
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 1,
|
||||||
|
}}>
|
||||||
|
{
|
||||||
|
onEditLlm && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => onEditLlm(model)}
|
||||||
|
>
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
color="error"
|
color="error"
|
||||||
@@ -144,7 +166,7 @@ function ModelsPage() {
|
|||||||
logger.debug('handleConfigureFactory', factory);
|
logger.debug('handleConfigureFactory', factory);
|
||||||
}, [modelDialogs]);
|
}, [modelDialogs]);
|
||||||
|
|
||||||
const handleEditLlmFactory = useCallback((factoryName: string) => {
|
const handleEditLlmFactory = useCallback((factoryName: string, llmmodel?: ILlmItem) => {
|
||||||
if (factoryName == null) {
|
if (factoryName == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -164,10 +186,17 @@ function ModelsPage() {
|
|||||||
// local llm
|
// local llm
|
||||||
modelDialogs.ollamaDialog.openDialog({
|
modelDialogs.ollamaDialog.openDialog({
|
||||||
llm_factory: factoryN,
|
llm_factory: factoryN,
|
||||||
|
...llmmodel,
|
||||||
|
llm_name: llmmodel?.name || '',
|
||||||
|
model_type: llmmodel?.type || 'chat',
|
||||||
}, true);
|
}, true);
|
||||||
} else if (configurationFactories.includes(factoryN)) {
|
} else if (configurationFactories.includes(factoryN)) {
|
||||||
// custom configuration llm
|
// custom configuration llm
|
||||||
modelDialogs.configurationDialog.openConfigurationDialog(factoryN);
|
modelDialogs.configurationDialog.openConfigurationDialog(factoryN, {
|
||||||
|
...llmmodel,
|
||||||
|
llm_name: llmmodel?.name || '',
|
||||||
|
model_type: llmmodel?.type || 'chat',
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// llm set api
|
// llm set api
|
||||||
modelDialogs.apiKeyDialog.openApiKeyDialog(factoryN, {}, true);
|
modelDialogs.apiKeyDialog.openApiKeyDialog(factoryN, {}, true);
|
||||||
@@ -246,9 +275,9 @@ function ModelsPage() {
|
|||||||
<AccordionDetails>
|
<AccordionDetails>
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
{Object.entries(myLlm).map(([factoryName, group]) => (
|
{Object.entries(myLlm).map(([factoryName, group]) => (
|
||||||
<Grid size={12} key={factoryName}>
|
<Grid size={12} key={factoryName} >
|
||||||
<Card variant="outlined">
|
<Card variant="outlined">
|
||||||
<CardContent>
|
<CardContent sx={{ padding: 2 }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -267,12 +296,15 @@ function ModelsPage() {
|
|||||||
{collapsedFactories[factoryName] ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
{collapsedFactories[factoryName] ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Box>
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 2, alignItems: 'center' }}>
|
||||||
|
{/* svg icon */}
|
||||||
|
<LlmSvgIcon name={getFactoryIconName(factoryName as LLMFactory)} sx={{ fontSize: 36 }} />
|
||||||
{/* 模型工厂名称 */}
|
{/* 模型工厂名称 */}
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
{factoryName}
|
{factoryName}
|
||||||
</Typography>
|
</Typography></Box>
|
||||||
{/* 模型标签 */}
|
{/* 模型标签 */}
|
||||||
<Box display="flex" gap={1} mb={2}>
|
<Box display="flex" gap={1} mt={2}>
|
||||||
{group.tags.split(',').map((tag) => (
|
{group.tags.split(',').map((tag) => (
|
||||||
<Chip
|
<Chip
|
||||||
key={tag}
|
key={tag}
|
||||||
@@ -293,7 +325,7 @@ function ModelsPage() {
|
|||||||
variant='contained' color='primary' startIcon={<EditIcon />}
|
variant='contained' color='primary' startIcon={<EditIcon />}
|
||||||
onClick={() => handleEditLlmFactory(factoryName)}
|
onClick={() => handleEditLlmFactory(factoryName)}
|
||||||
>
|
>
|
||||||
{ showAddModel(factoryName) ? t('setting.addModel') : t('setting.edit')}
|
{showAddModel(factoryName) ? t('setting.addModel') : t('setting.edit')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant='outlined' color='primary' startIcon={<DeleteIcon />}
|
variant='outlined' color='primary' startIcon={<DeleteIcon />}
|
||||||
@@ -311,6 +343,9 @@ function ModelsPage() {
|
|||||||
<MyLlmGridItem
|
<MyLlmGridItem
|
||||||
key={model.name}
|
key={model.name}
|
||||||
model={model}
|
model={model}
|
||||||
|
onEditLlm={showAddModel(factoryName) ? (llm) => {
|
||||||
|
handleEditLlmFactory(factoryName, llm);
|
||||||
|
} : undefined}
|
||||||
onDelete={() => handleDeleteModel(factoryName, model.name)}
|
onDelete={() => handleDeleteModel(factoryName, model.name)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { IconMap, type LLMFactory } from '@/constants/llm';
|
||||||
import isObject from 'lodash/isObject';
|
import isObject from 'lodash/isObject';
|
||||||
import snakeCase from 'lodash/snakeCase';
|
import snakeCase from 'lodash/snakeCase';
|
||||||
|
|
||||||
@@ -22,3 +23,8 @@ export const convertTheKeysOfTheObjectToSnake = (data: unknown) => {
|
|||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 获取工厂图标名称
|
||||||
|
export const getFactoryIconName = (factoryName: LLMFactory) => {
|
||||||
|
return IconMap[factoryName] || 'default';
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user