feat(chunk): add chunk editing and image preview functionality

This commit is contained in:
2025-10-30 17:56:03 +08:00
parent b35fe51d60
commit 19b70a4abd
13 changed files with 448 additions and 147 deletions

View File

@@ -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) {

View File

@@ -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;
}
/** /**
* 测试文档块接口 * 测试文档块接口
* 用于知识库测试和检索的文档块信息 * 用于知识库测试和检索的文档块信息

View File

@@ -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',

View File

@@ -1517,6 +1517,9 @@ General实体和关系提取提示来自 GitHub - microsoft/graphrag基于
loadingPreview: '正在加载预览...', loadingPreview: '正在加载预览...',
downloadFile: '下载文件', downloadFile: '下载文件',
openInNewTab: '在新标签页中打开', openInNewTab: '在新标签页中打开',
editChunk: '编辑Chunk',
content: '内容',
saving: '保存中...',
}, },
fileUpload: { fileUpload: {
uploadFiles: '上传文件', uploadFiles: '上传文件',

View File

@@ -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,114 +369,151 @@ 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 <Chip
sx={{ icon={chunk.available_int === 1 ? <VisibilityIcon /> : <VisibilityOffIcon />}
bgcolor: chunk.available_int === 1 ? 'success.main' : 'grey.400', label={chunk.available_int === 1 ? t('chunkPage.enabled') : t('chunkPage.disabled')}
mr: 2, size="small"
width: 32, color={chunk.available_int === 1 ? 'success' : 'default'}
height: 32, variant={chunk.available_int === 1 ? 'filled' : 'outlined'}
}} />
> </Box>
<TextIcon fontSize="small" />
</Avatar>
<Box sx={{ flexGrow: 1 }}> {/* 主要内容区域 - 左右布局 */}
<Typography variant="subtitle1" fontWeight="medium"> <Box sx={{ display: 'flex', gap: 2, minHeight: '120px' }}>
Chunk #{((page - 1) * pageSize) + index + 1} {/* 左侧图片区域 */}
</Typography> <Box sx={{
<Typography variant="caption" color="text.secondary"> width: chunk.image_id ? '120px' : '0px',
ID: {chunk.chunk_id} flexShrink: 0,
</Typography> display: chunk.image_id ? 'block' : 'none'
</Box> }}>
<Stack direction="row" spacing={1} alignItems="center">
{chunk.image_id && ( {chunk.image_id && (
<Tooltip title={t('chunkPage.containsImage')}>
<Avatar sx={{ bgcolor: 'info.main', width: 24, height: 24 }}>
<ImageIcon fontSize="small" />
</Avatar>
</Tooltip>
)}
<Chip
icon={chunk.available_int === 1 ? <VisibilityIcon /> : <VisibilityOffIcon />}
label={chunk.available_int === 1 ? t('chunkPage.enabled') : t('chunkPage.disabled')}
size="small"
color={chunk.available_int === 1 ? 'success' : 'default'}
variant={chunk.available_int === 1 ? 'filled' : 'outlined'}
/>
</Stack>
</Box>
{/* 内容区域 */}
<Box sx={{ mb: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
{t('chunkPage.contentPreview')}
</Typography>
<Paper
variant="outlined"
sx={{
p: 1.5,
maxHeight: '150px',
overflow: 'auto',
backgroundColor: 'grey.50',
borderRadius: 1,
fontSize: '0.875rem',
lineHeight: 1.5,
}}
>
<Typography
variant="body2"
sx={{
whiteSpace: 'pre-wrap',
color: 'text.primary',
}}
>
{chunk.content_with_weight || t('chunkPage.noContent')}
</Typography>
</Paper>
</Box>
{/* 图片显示区域 */}
{chunk.image_id && (
<Box sx={{ mb: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
{t('chunkPage.relatedImage')}
</Typography>
<Box
sx={{
textAlign: 'center',
p: 1,
border: '1px solid',
borderColor: 'grey.300',
borderRadius: 1,
}}
>
<Box <Box
component="img"
src={`${import.meta.env.VITE_API_BASE_URL}/v1/document/image/${chunk.image_id}`}
alt={t('chunkPage.chunkRelatedImage')}
sx={{ sx={{
maxWidth: '100%', width: '100%',
maxHeight: '200px', height: '120px',
border: '1px solid',
borderColor: 'grey.300',
borderRadius: 1, borderRadius: 1,
objectFit: 'contain', overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'grey.50',
position: 'relative',
cursor: 'pointer',
'&:hover': {
borderColor: 'primary.main',
'& .image-overlay': {
opacity: 1,
}
}
}} }}
onError={(e) => { onClick={() => handleImageClick(`${import.meta.env.VITE_API_BASE_URL}/v1/document/image/${chunk.image_id}`)}
const target = e.target as HTMLImageElement; onMouseEnter={(e) => handleImageHover(e, `${import.meta.env.VITE_API_BASE_URL}/v1/document/image/${chunk.image_id}`)}
target.style.display = 'none'; onMouseLeave={handleImageHoverClose}
}} >
/> <Box
</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>
)}
{/* 右侧内容区域 */}
<Box sx={{ flex: 1, minWidth: 0, position: 'relative' }}>
<Paper
variant="outlined"
sx={{
p: 1.5,
height: '120px',
overflow: 'auto',
backgroundColor: 'grey.50',
borderRadius: 1,
fontSize: '0.875rem',
lineHeight: 1.5,
cursor: 'pointer',
'&:hover': {
backgroundColor: 'grey.100',
'& .edit-button': {
opacity: 1,
}
}
}}
onClick={() => handleEditChunk(chunk)}
>
<Typography
variant="body2"
sx={{
whiteSpace: 'pre-wrap',
color: 'text.primary',
}}
>
{chunk.content_with_weight || t('chunkPage.noContent')}
</Typography>
{/* 编辑按钮 */}
<IconButton
className="edit-button"
size="small"
sx={{
position: 'absolute',
top: 8,
right: 8,
opacity: 0,
transition: 'opacity 0.2s ease-in-out',
backgroundColor: 'background.paper',
'&:hover': {
backgroundColor: 'primary.light',
color: 'primary.contrastText',
}
}}
onClick={(e) => {
e.stopPropagation();
handleEditChunk(chunk);
}}
>
<EditIcon fontSize="small" />
</IconButton>
</Paper>
</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}

View File

@@ -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}>
{option.label} <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}
</Box>
</MenuItem> </MenuItem>
)) ))
])} ])}

View File

@@ -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}>
{option.label} <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}
</Box>
</MenuItem> </MenuItem>
)) ))
])} ])}

View File

@@ -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,

View File

@@ -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]);

View File

@@ -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,

View File

@@ -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]);

View File

@@ -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' }}>
<Typography variant="h6" gutterBottom> {/* svg icon */}
{factoryName} <LlmSvgIcon name={getFactoryIconName(factoryName as LLMFactory)} sx={{ fontSize: 36 }} />
</Typography> {/* 模型工厂名称 */}
<Typography variant="h6" gutterBottom>
{factoryName}
</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)}
/> />
))} ))}

View File

@@ -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';
};