From d53742948d00b6d8a5b11a8902d67fdbc71575ff Mon Sep 17 00:00:00 2001 From: Wenyan Date: Fri, 5 Dec 2025 22:07:16 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E7=9F=A5=E8=AF=86=E5=BA=93?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=A1=B5=E9=9D=A2=E5=A2=9E=E5=8A=A0=20retrie?= =?UTF-8?q?val=5Fmodel=20=E6=A3=80=E7=B4=A2=E9=85=8D=E7=BD=AE=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 召回测试页面增加 Score 阈值参数配置 2. 知识库设置页面新增检索模型配置: - 检索方式 (向量/全文/混合/关键字检索) - Reranking 模型 (默认开启,不可关闭) - Top K 返回数量 - Score 阈值 (默认开启,可调节数值) 3. 修复 Dify API 字段名问题 (retrieval_model_dict) 4. 优化数据加载流程,使用详情接口获取完整配置 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/api/dify-dataset/api/datasetApi.ts | 26 ++- app/api/dify-dataset/type/datasetTypes.ts | 18 +- .../dify-dataset-manager/dataset-settings.tsx | 205 +++++++++++++++--- .../dify-dataset-manager/retrieve-test.tsx | 46 +++- .../dify-dataset-manager/dataset-settings.ts | 143 ++++++++++-- app/hooks/dify-dataset-manager/index.ts | 18 +- .../dify-dataset-manager/retrieve-test.ts | 22 +- .../api.dataset.datasets.$datasetId.tsx | 60 ++++- .../dify-dataset-manager/retrieve-test.ts | 4 + 9 files changed, 477 insertions(+), 65 deletions(-) diff --git a/app/api/dify-dataset/api/datasetApi.ts b/app/api/dify-dataset/api/datasetApi.ts index 036a788..fa3ebca 100644 --- a/app/api/dify-dataset/api/datasetApi.ts +++ b/app/api/dify-dataset/api/datasetApi.ts @@ -7,7 +7,7 @@ */ import axios from 'axios'; -import type { Dataset, DatasetsResponse } from '../type'; +import type { Dataset, DatasetsResponse, UpdateDatasetRequest } from '../type'; /** * API 基础 URL @@ -76,3 +76,27 @@ export async function updateDatasetName( ); return response.data; } + +/** + * 更新知识库设置(包含检索模型配置) + * + * @param datasetId - 知识库 ID + * @param settings - 更新的设置项 + * @returns 更新后的知识库详情 + */ +export async function updateDatasetSettings( + datasetId: string, + settings: UpdateDatasetRequest +): Promise { + console.log('[Dataset Client] 更新知识库设置:', { datasetId, settings }); + + const response = await axios.patch( + `${API_URL}/datasets/${datasetId}`, + settings, + { + headers: { 'Content-Type': 'application/json' }, + withCredentials: true, + } + ); + return response.data; +} diff --git a/app/api/dify-dataset/type/datasetTypes.ts b/app/api/dify-dataset/type/datasetTypes.ts index 65dd4ee..f504f9c 100644 --- a/app/api/dify-dataset/type/datasetTypes.ts +++ b/app/api/dify-dataset/type/datasetTypes.ts @@ -12,8 +12,8 @@ export interface Dataset { name: string; description: string; permission: 'only_me' | 'all_team_members'; - data_source_type: 'upload_file' | 'notion_import' | 'website_crawl'; - indexing_technique: 'high_quality' | 'economy'; + data_source_type: 'upload_file' | 'notion_import' | 'website_crawl' | null; + indexing_technique: 'high_quality' | 'economy' | null; app_count: number; document_count: number; word_count: number; @@ -21,6 +21,20 @@ export interface Dataset { created_at: number; updated_by: string; updated_at: number; + /** 嵌入模型提供商 */ + embedding_model_provider?: string | null; + /** 嵌入模型名称 */ + embedding_model?: string | null; + /** 嵌入模型是否可用 */ + embedding_available?: boolean; + /** 检索模型配置(Dify API 返回字段名为 retrieval_model_dict) */ + retrieval_model_dict?: RetrievalModel; + /** 标签 */ + tags?: string[]; + /** 文档形式 */ + doc_form?: string | null; + /** 供应商 */ + provider?: string; } /** diff --git a/app/components/dify-dataset-manager/dataset-settings.tsx b/app/components/dify-dataset-manager/dataset-settings.tsx index 05f1a90..af651ac 100644 --- a/app/components/dify-dataset-manager/dataset-settings.tsx +++ b/app/components/dify-dataset-manager/dataset-settings.tsx @@ -1,10 +1,18 @@ -import { Form, Input, Button, Card, Spin } from 'antd'; -import { SaveOutlined } from '@ant-design/icons'; -import { useDatasetSettings } from '~/hooks/dify-dataset-manager/dataset-settings'; +import { Form, Input, Button, Card, Spin, Divider, Select, Slider, InputNumber, Tooltip, Checkbox } from 'antd'; +import { SaveOutlined, QuestionCircleOutlined, CheckCircleFilled } from '@ant-design/icons'; +import { useDatasetSettings, type SearchMethod } from '~/hooks/dify-dataset-manager/dataset-settings'; import type { DatasetSettingsProps } from '~/types/dify-dataset-manager/dataset-settings'; const { TextArea } = Input; +// 检索方式选项 +const SEARCH_METHOD_OPTIONS: { label: string; value: SearchMethod; description: string }[] = [ + { label: '向量检索', value: 'semantic_search', description: '基于语义理解的智能检索,适合需要理解上下文的场景' }, + { label: '全文检索', value: 'full_text_search', description: '基于关键词匹配的传统检索方式' }, + { label: '混合检索', value: 'hybrid_search', description: '结合向量和全文检索,综合效果最佳' }, + { label: '关键字检索', value: 'keyword_search', description: '精确关键字匹配' }, +]; + /** * 知识库设置组件 * 用于修改知识库名称和描述 @@ -14,15 +22,23 @@ export default function DatasetSettings({ onDatasetUpdated, }: DatasetSettingsProps) { const [form] = Form.useForm(); - + const { saving, hasChanges, + retrievalSettings, handleValuesChange, handleSave, handleReset, + updateRetrievalSettings, } = useDatasetSettings(dataset, form, onDatasetUpdated); + // 是否需要显示 Reranking 提示(语义检索和混合检索需要,且强制开启) + const showRerankingInfo = retrievalSettings.searchMethod === 'semantic_search' || retrievalSettings.searchMethod === 'hybrid_search'; + // 权重设置:由于 Reranking 强制开启,混合检索时由 Reranking 模型决定排序,不需要手动设置权重 + // 所以这里始终不显示权重设置 + const showWeightsOption = false; + if (!dataset) { return (
@@ -81,12 +97,6 @@ export default function DatasetSettings({ {dataset.indexing_technique === 'high_quality' ? '高质量' : '经济'}
- {/*
- Embedding 模型: - - {dataset.embedding_model || '默认模型'} - -
*/}
文档数量: {dataset.document_count} @@ -102,24 +112,167 @@ export default function DatasetSettings({
- - {/* 操作按钮 */} -
- - -
+ + {/* 检索设置卡片 */} + +

+ 检索设置 + + + +

+ + {/* 检索方式 */} +
+ + + {(chatApps || []).map(app => ( + + {app.app_name} + + ))} + +
+ )} {!collapsed && ( ([]); + const [difyDatasetsLoading, setDifyDatasetsLoading] = useState(false); + const [difyDatasetsTotal, setDifyDatasetsTotal] = useState(0); + const [difyDatasetsPage, setDifyDatasetsPage] = useState(1); + const [isLoadingDifyDatasets, setIsLoadingDifyDatasets] = useState(false); + + // ==================== Effects ==================== + + // 当编辑的ID变化时,加载表单数据 + useEffect(() => { + if (editingId && modalVisible) { + const record = datasets.find((item) => item.id === editingId); + if (record) { + form.setFieldsValue({ + area: record.area, + dataset_id: record.dataset_id, + dataset_name: record.dataset_name, + dataset_description: record.dataset_description, + is_public: record.is_public, + is_default: record.is_default, + sort_order: record.sort_order, + }); + } + } else if (!editingId && modalVisible) { + // 新增时重置表单 + form.resetFields(); + loadDifyDatasets(); // 加载Dify知识库列表 + } + }, [editingId, modalVisible, datasets, form]); + + // ==================== Dify Datasets Loading ==================== + + /** + * 从Dify API加载知识库列表 + */ + const loadDifyDatasets = async (pageNum: number = 1) => { + if (isLoadingDifyDatasets) return; + + setIsLoadingDifyDatasets(true); + try { + const response = await fetchDatasets(pageNum, 20); + setDifyDatasets(response.data); + setDifyDatasetsTotal(response.total); + setDifyDatasetsPage(pageNum); + setDifyDatasetsLoading(false); + } catch (error: any) { + console.error('加载Dify知识库列表失败:', error); + message.error('加载Dify知识库列表失败'); + setDifyDatasetsLoading(false); + } finally { + setIsLoadingDifyDatasets(false); + } + }; + + /** + * Dify数据集选择器滚动加载 + */ + const handleDatasetSelectScroll = (e: React.UIEvent) => { + const { target } = e; + const { scrollTop, scrollHeight, clientHeight } = target as HTMLDivElement; + + // 滚动到底部且还有更多数据时加载下一页 + if (scrollHeight - scrollTop === clientHeight && + difyDatasets.length < difyDatasetsTotal && + !difyDatasetsLoading) { + loadDifyDatasets(difyDatasetsPage + 1); + } + }; + + // ==================== Event Handlers ==================== + + /** + * 处理新建按钮点击 + */ + const handleCreateClick = () => { + if (!canManageDataset) { + message.error('您没有创建知识库绑定的权限'); + return; + } + setEditingId(null); + setModalVisible(true); + form.resetFields(); + loadDifyDatasets(); + }; + + /** + * 处理编辑按钮点击 + */ + const handleEditClick = (record: AreaDataset) => { + if (!canManageDataset) { + message.error('您没有编辑知识库绑定的权限'); + return; + } + setEditingId(record.id); + setModalVisible(true); + }; + + /** + * 处理删除按钮点击 + */ + const handleDeleteClick = async (id: number) => { + if (!canManageDataset) { + message.error('您没有删除知识库绑定的权限'); + return; + } + + const success = await handleDelete(id); + if (success) { + message.success('删除成功'); + } + }; + + /** + * 处理表单提交 + */ + const handleFormSubmit = async (values: any) => { + let success = false; + + if (editingId) { + success = await handleUpdate(editingId, values); + } else { + success = await handleCreate(values); + } + + if (success) { + setModalVisible(false); + form.resetFields(); + setEditingId(null); + } + }; + + /** + * 处理表单取消 + */ + const handleFormCancel = () => { + setModalVisible(false); + form.resetFields(); + setEditingId(null); + }; + + /** + * 处理地区筛选变化 + */ + const handleAreaFilterChange = (value: string) => { + setFilterArea(value); + setPage(1); // 重置到第一页 + }; + + // ==================== Render ==================== + + // 计算用户角色标签 + const userRoleLabel = (() => { + const labels: Record = { + common: '普通用户', + admin: '市级管理员', + provincial_admin: '省级管理员', + }; + return labels[userRole] || '未知角色'; + })(); + + // 表格列定义 + const columns = [ + { + title: '序号', + key: 'index', + width: 60, + render: (_: any, __: any, index: number) => + (page - 1) * pageSize + index + 1, + }, + { + title: '地区', + dataIndex: 'area', + key: 'area', + width: 100, + render: (area: string) => ( + {area} + ), + }, + { + title: '知识库名称', + dataIndex: 'dataset_name', + key: 'dataset_name', + width: 200, + ellipsis: true, + render: (text: string) => ( + + + {text} + + + ), + }, + { + title: '知识库ID', + dataIndex: 'dataset_id', + key: 'dataset_id', + width: 200, + ellipsis: true, + render: (text: string) => ( + + + {text.substring(0, 8)}...{text.substring(text.length - 4)} + + + ), + }, + { + title: '描述', + dataIndex: 'dataset_description', + key: 'dataset_description', + ellipsis: true, + render: (text: string) => + text ? ( + + + {text.length > 30 ? text.substring(0, 30) + '...' : text} + + + ) : ( + - + ), + }, + { + title: '标签', + key: 'tags', + width: 120, + render: (_: any, record: AreaDataset) => ( + + {record.is_public && ( + }> + 公共 + + )} + {record.is_default && ( + + 默认 + + )} + + ), + }, + { + title: '排序', + dataIndex: 'sort_order', + key: 'sort_order', + width: 70, + align: 'center' as const, + sorter: (a: AreaDataset, b: AreaDataset) => a.sort_order - b.sort_order, + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 80, + render: (status: number) => ( + + {status === 1 ? '启用' : '禁用'} + + ), + }, + { + title: '创建时间', + dataIndex: 'created_at', + key: 'created_at', + width: 150, + render: (text: string) => ( + + {new Date(text).toLocaleString('zh-CN')} + + ), + }, + // 操作列(仅管理员可见) + ...(canManageDataset + ? [ + { + title: '操作', + key: 'actions', + width: 120, + fixed: 'right' as const, + render: (_: any, record: AreaDataset) => ( + + + handleDeleteClick(record.id)} + okText="确定" + cancelText="取消" + > + + + + ), + }, + ] + : []), + ]; + + return ( +
+ {/* 页面头部 */} + + +
+ + 知识库配置管理 + + + + 地区: {userArea || '-'} + + + 角色: {userRoleLabel} + + + 总数: {total} + + +
+ + {/* 仅管理员显示新增按钮 */} + {canManageDataset && ( + + )} +
+
+ + {/* 筛选区域(仅管理员可见) */} + {canManageDataset && ( + + + 地区筛选: + ({ + label: area, + value: area, + }))} + /> + + + {/* Dify知识库选择(仅新增时可选) */} + + + + + {/* 知识库描述 */} + + + + + {/* 高级设置折叠面板 */} +
+ + 高级设置 + + + + {/* 是否公开 */} + + + + + {/* 是否默认 */} + + + + + {/* 排序顺序 */} + + + + +
+ + +
+ ); +} diff --git a/app/hooks/use-area-dataset-config.ts b/app/hooks/use-area-dataset-config.ts new file mode 100644 index 0000000..5cd85bb --- /dev/null +++ b/app/hooks/use-area-dataset-config.ts @@ -0,0 +1,308 @@ +/** + * 知识库配置管理 Hook + * + * 提供地区-知识库绑定管理功能 + */ + +import { useState, useEffect, useCallback } from 'react'; +import { + getMyDatasets, + getAllDatasets, + getAvailableAreas, + createDatasetBinding, + updateDatasetBinding, + deleteDatasetBinding, + type AreaDataset, + type CreateDatasetRequest, + type UpdateDatasetRequest, +} from '~/api/v3/dify/area-datasets'; +import { message } from 'antd'; +import { usePermission } from '~/hooks/usePermission'; + +// ==================== Type Definitions ==================== + +export interface UseAreaDatasetConfigReturn { + // 数据 + datasets: AreaDataset[]; + loading: boolean; + total: number; + userArea: string; + userRole: string; + areas: string[]; + areasLoading: boolean; + + // 筛选 + filterArea: string; + setFilterArea: (area: string) => void; + page: number; + setPage: (page: number) => void; + pageSize: number; + + // 表单状态 + modalVisible: boolean; + setModalVisible: (visible: boolean) => void; + editingId: number | null; + setEditingId: (id: number | null) => void; + submitLoading: boolean; + + // 操作方法 + loadDatasets: () => Promise; + loadAreas: () => Promise; + handleCreate: (data: CreateDatasetRequest) => Promise; + handleUpdate: (id: number, data: UpdateDatasetRequest) => Promise; + handleDelete: (id: number) => Promise; + + // 权限 + canManageDataset: boolean; +} + +// ==================== Hook Implementation ==================== + +export function useAreaDatasetConfig(): UseAreaDatasetConfigReturn { + // 权限控制 + const { canManageDataset, canViewDataset, userRole } = usePermission(); + + // 数据状态 + const [datasets, setDatasets] = useState([]); + const [loading, setLoading] = useState(false); + const [total, setTotal] = useState(0); + const [userArea, setUserArea] = useState(''); + const [userRoleState, setUserRoleState] = useState(''); + const [areas, setAreas] = useState([]); + const [areasLoading, setAreasLoading] = useState(false); + + // 筛选状态 + const [filterArea, setFilterArea] = useState(''); + const [page, setPage] = useState(1); + const pageSize = 20; + + // 表单状态 + const [modalVisible, setModalVisible] = useState(false); + const [editingId, setEditingId] = useState(null); + const [submitLoading, setSubmitLoading] = useState(false); + + // ==================== Data Loading ==================== + + /** + * 加载知识库列表 + */ + const loadDatasets = useCallback(async () => { + if (!canViewDataset) { + message.warning('您没有查看知识库的权限'); + return; + } + + setLoading(true); + try { + let response; + + if (canManageDataset) { + // 省级管理员:获取所有知识库 + response = await getAllDatasets({ + area: filterArea || undefined, + only_enabled: true, + page, + page_size: pageSize, + }); + } else { + // 普通用户/市级管理员:获取我的知识库 + response = await getMyDatasets(); + } + + if (response.code === 0) { + setDatasets(response.data.data); + setTotal(response.data.total); + + // 如果是 my 接口,保存用户信息 + if ('user_area' in response.data) { + setUserArea((response.data as any).user_area); + setUserRoleState((response.data as any).user_role); + } + } else { + message.error(`加载失败: ${response.message}`); + } + } catch (error: any) { + console.error('加载知识库失败:', error); + message.error('加载知识库失败,请稍后重试'); + } finally { + setLoading(false); + } + }, [canManageDataset, canViewDataset, filterArea, page, pageSize]); + + /** + * 加载地区列表(仅省级管理员) + */ + const loadAreas = useCallback(async () => { + if (!canManageDataset) return; + + setAreasLoading(true); + try { + const areasList = await getAvailableAreas(); + setAreas(areasList); + } catch (error: any) { + console.error('加载地区列表失败:', error); + message.error('加载地区列表失败'); + } finally { + setAreasLoading(false); + } + }, [canManageDataset]); + + // ==================== Operations ==================== + + /** + * 创建知识库绑定 + */ + const handleCreate = useCallback( + async (data: CreateDatasetRequest): Promise => { + if (!canManageDataset) { + message.error('您没有创建知识库绑定的权限'); + return false; + } + + setSubmitLoading(true); + try { + const response = await createDatasetBinding(data); + + if (response.code === 0) { + message.success('创建成功'); + await loadDatasets(); + return true; + } else { + message.error(`创建失败: ${response.message}`); + return false; + } + } catch (error: any) { + console.error('创建知识库绑定失败:', error); + message.error('创建失败,请稍后重试'); + return false; + } finally { + setSubmitLoading(false); + } + }, + [canManageDataset, loadDatasets] + ); + + /** + * 更新知识库绑定 + */ + const handleUpdate = useCallback( + async (id: number, data: UpdateDatasetRequest): Promise => { + if (!canManageDataset) { + message.error('您没有编辑知识库绑定的权限'); + return false; + } + + setSubmitLoading(true); + try { + const response = await updateDatasetBinding(id, data); + + if (response.code === 0) { + message.success('更新成功'); + await loadDatasets(); + return true; + } else { + message.error(`更新失败: ${response.message}`); + return false; + } + } catch (error: any) { + console.error('更新知识库绑定失败:', error); + message.error('更新失败,请稍后重试'); + return false; + } finally { + setSubmitLoading(false); + } + }, + [canManageDataset, loadDatasets] + ); + + /** + * 删除知识库绑定 + */ + const handleDelete = useCallback( + async (id: number): Promise => { + if (!canManageDataset) { + message.error('您没有删除知识库绑定的权限'); + return false; + } + + try { + const response = await deleteDatasetBinding(id); + + if (response.code === 0) { + message.success('删除成功'); + await loadDatasets(); + return true; + } else { + message.error(`删除失败: ${response.message}`); + return false; + } + } catch (error: any) { + console.error('删除知识库绑定失败:', error); + message.error('删除失败,请稍后重试'); + return false; + } + }, + [canManageDataset, loadDatasets] + ); + + // ==================== Effects ==================== + + // 加载数据 + useEffect(() => { + loadDatasets(); + }, [loadDatasets]); + + useEffect(() => { + loadAreas(); + }, [loadAreas]); + + // 监听筛选条件变化 + useEffect(() => { + if (canManageDataset) { + setPage(1); // 筛选条件变化时重置到第一页 + loadDatasets(); + } + }, [filterArea, canManageDataset, loadDatasets]); + + // 监听页码变化 + useEffect(() => { + if (canManageDataset) { + loadDatasets(); + } + }, [page, canManageDataset, loadDatasets]); + + return { + // 数据 + datasets, + loading, + total, + userArea, + userRole: userRoleState, + areas, + areasLoading, + + // 筛选 + filterArea, + setFilterArea, + page, + setPage, + pageSize, + + // 表单状态 + modalVisible, + setModalVisible, + editingId, + setEditingId, + submitLoading, + + // 操作方法 + loadDatasets, + loadAreas, + handleCreate, + handleUpdate, + handleDelete, + + // 权限 + canManageDataset, + }; +} diff --git a/app/routes/api.v3.dify.area-datasets.$id.tsx b/app/routes/api.v3.dify.area-datasets.$id.tsx new file mode 100644 index 0000000..bba19a6 --- /dev/null +++ b/app/routes/api.v3.dify.area-datasets.$id.tsx @@ -0,0 +1,76 @@ +/** + * PUT /api/v3/dify/area-datasets/{id} - 更新知识库绑定 + * DELETE /api/v3/dify/area-datasets/{id} - 删除知识库绑定 + */ + +import { type LoaderFunctionArgs, json } from '@remix-run/node'; +import { API_BASE_URL } from '~/config/api-config'; +import { getUserSession } from '~/api/login/auth.server'; +import { request as backendRequest } from '~/api/axios-client'; + +export async function loader({ request, params }: LoaderFunctionArgs) { + return json({ error: 'Method not allowed' }, { status: 405 }); +} + +/** + * 更新知识库绑定 + */ +export async function action({ request, params }: LoaderFunctionArgs) { + try { + const { frontendJWT } = await getUserSession(request); + + if (!frontendJWT) { + return new Response( + JSON.stringify({ error: 'JWT认证失败,请重新登录' }), + { status: 401, headers: { 'Content-Type': 'application/json' } } + ); + } + + const { id } = params; + const method = request.method; + + if (method === 'PUT') { + // 更新知识库绑定 + const body = await request.json(); + console.log(`[API V3] Update Area Dataset: ${id}`, body); + + const apiUrl = `${API_BASE_URL}/v3/dify/area-datasets/${id}`; + const response = await backendRequest(apiUrl, { + method: 'PUT', + headers: { + Authorization: `Bearer ${frontendJWT}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + return json(data, { status: response.status }); + + } else if (method === 'DELETE') { + // 删除知识库绑定 + console.log(`[API V3] Delete Area Dataset: ${id}`); + + const apiUrl = `${API_BASE_URL}/v3/dify/area-datasets/${id}`; + const response = await backendRequest(apiUrl, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${frontendJWT}`, + }, + }); + + const data = await response.json(); + return json(data, { status: response.status }); + + } else { + return json({ error: 'Method not allowed' }, { status: 405 }); + } + + } catch (error: any) { + console.error('[API V3] Area Dataset Action - Error:', error.message); + return json( + { error: error.message || 'Operation failed' }, + { status: 500 } + ); + } +} diff --git a/app/routes/api.v3.dify.area-datasets.areas.tsx b/app/routes/api.v3.dify.area-datasets.areas.tsx new file mode 100644 index 0000000..3c7adcc --- /dev/null +++ b/app/routes/api.v3.dify.area-datasets.areas.tsx @@ -0,0 +1,42 @@ +/** + * GET /api/v3/dify/area-datasets/areas - 获取可用地区列表(管理员) + */ + +import { type LoaderFunctionArgs, json } from '@remix-run/node'; +import { API_BASE_URL } from '~/config/api-config'; +import { getUserSession } from '~/api/login/auth.server'; +import { request as backendRequest } from '~/api/axios-client'; + +export async function loader({ request }: LoaderFunctionArgs) { + try { + const { frontendJWT } = await getUserSession(request); + + if (!frontendJWT) { + return new Response( + JSON.stringify({ error: 'JWT认证失败,请重新登录' }), + { status: 401, headers: { 'Content-Type': 'application/json' } } + ); + } + + console.log('[API V3] Get Available Areas'); + + // 转发请求到后端 + const apiUrl = `${API_BASE_URL}/v3/dify/area-datasets/areas`; + const response = await backendRequest(apiUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${frontendJWT}`, + }, + }); + + const data = await response.json(); + return json(data, { status: response.status }); + + } catch (error: any) { + console.error('[API V3] Get Available Areas - Error:', error.message); + return json( + { error: error.message || 'Failed to get available areas' }, + { status: 500 } + ); + } +} diff --git a/app/routes/api.v3.dify.area-datasets.my.tsx b/app/routes/api.v3.dify.area-datasets.my.tsx new file mode 100644 index 0000000..14a43c7 --- /dev/null +++ b/app/routes/api.v3.dify.area-datasets.my.tsx @@ -0,0 +1,42 @@ +/** + * GET /api/v3/dify/area-datasets/my - 获取当前用户可访问的知识库列表 + */ + +import { type LoaderFunctionArgs, json } from '@remix-run/node'; +import { API_BASE_URL } from '~/config/api-config'; +import { getUserSession } from '~/api/login/auth.server'; +import { request as backendRequest } from '~/api/axios-client'; + +export async function loader({ request }: LoaderFunctionArgs) { + try { + const { frontendJWT } = await getUserSession(request); + + if (!frontendJWT) { + return new Response( + JSON.stringify({ error: 'JWT认证失败,请重新登录' }), + { status: 401, headers: { 'Content-Type': 'application/json' } } + ); + } + + console.log('[API V3] Get My Area Datasets'); + + // 转发请求到后端 + const apiUrl = `${API_BASE_URL}/v3/dify/area-datasets/my`; + const response = await backendRequest(apiUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${frontendJWT}`, + }, + }); + + const data = await response.json(); + return json(data, { status: response.status }); + + } catch (error: any) { + console.error('[API V3] Get My Area Datasets - Error:', error.message); + return json( + { error: error.message || 'Failed to get area datasets' }, + { status: 500 } + ); + } +} diff --git a/app/routes/api.v3.dify.area-datasets.tsx b/app/routes/api.v3.dify.area-datasets.tsx new file mode 100644 index 0000000..3452e5d --- /dev/null +++ b/app/routes/api.v3.dify.area-datasets.tsx @@ -0,0 +1,100 @@ +/** + * GET /api/v3/dify/area-datasets - 获取所有知识库绑定列表(管理员) + * POST /api/v3/dify/area-datasets - 创建知识库绑定 + */ + +import { type LoaderFunctionArgs, json } from '@remix-run/node'; +import { API_BASE_URL } from '~/config/api-config'; +import { getUserSession } from '~/api/login/auth.server'; +import { request as backendRequest } from '~/api/axios-client'; + +/** + * GET - 获取所有知识库绑定列表 + */ +export async function loader({ request }: LoaderFunctionArgs) { + try { + const { frontendJWT } = await getUserSession(request); + + if (!frontendJWT) { + return new Response( + JSON.stringify({ error: 'JWT认证失败,请重新登录' }), + { status: 401, headers: { 'Content-Type': 'application/json' } } + ); + } + + // 获取查询参数 + const url = new URL(request.url); + const area = url.searchParams.get('area'); + const only_enabled = url.searchParams.get('only_enabled'); + const page = url.searchParams.get('page') || '1'; + const page_size = url.searchParams.get('page_size') || '20'; + + // 构建查询参数 + const params = new URLSearchParams(); + if (area) params.append('area', area); + if (only_enabled !== null) params.append('only_enabled', only_enabled); + params.append('page', page); + params.append('page_size', page_size); + + console.log('[API V3] Get All Area Datasets', { area, only_enabled, page, page_size }); + + // 转发请求到后端 + const apiUrl = `${API_BASE_URL}/v3/dify/area-datasets?${params}`; + const response = await backendRequest(apiUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${frontendJWT}`, + }, + }); + + const data = await response.json(); + return json(data, { status: response.status }); + + } catch (error: any) { + console.error('[API V3] Get All Area Datasets - Error:', error.message); + return json( + { error: error.message || 'Failed to get all area datasets' }, + { status: 500 } + ); + } +} + +/** + * POST - 创建知识库绑定 + */ +export async function action({ request }: LoaderFunctionArgs) { + try { + const { frontendJWT } = await getUserSession(request); + + if (!frontendJWT) { + return new Response( + JSON.stringify({ error: 'JWT认证失败,请重新登录' }), + { status: 401, headers: { 'Content-Type': 'application/json' } } + ); + } + + const body = await request.json(); + console.log('[API V3] Create Area Dataset', body); + + // 转发创建请求到后端 + const apiUrl = `${API_BASE_URL}/v3/dify/area-datasets`; + const response = await backendRequest(apiUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${frontendJWT}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + return json(data, { status: response.status }); + + } catch (error: any) { + console.error('[API V3] Create Area Dataset - Error:', error.message); + return json( + { error: error.message || 'Failed to create area dataset' }, + { status: 500 } + ); + } +} diff --git a/app/routes/chat-with-llm.dataset-manager.tsx b/app/routes/chat-with-llm.dataset-manager.tsx index 16f641b..cc608d2 100644 --- a/app/routes/chat-with-llm.dataset-manager.tsx +++ b/app/routes/chat-with-llm.dataset-manager.tsx @@ -1,26 +1,36 @@ +import { Tabs } from 'antd'; import DatasetManager from "~/components/dify-dataset-manager"; -import datasetManagerStyles from "~/styles/components/dify-dataset-manager/index.css?url"; -// import sidebarStyles from "~/styles/components/dify-dataset-manager/sidebar.css?url"; -// import documentListStyles from "~/styles/components/dify-dataset-manager/document-list.css?url"; +import AreaDatasetConfig from "~/components/dify-dataset-manager/area-dataset-config"; /** - * 注册样式 - */ -export function links() { - return [ - { rel: "stylesheet", href: datasetManagerStyles }, - // { rel: "stylesheet", href: sidebarStyles }, - // { rel: "stylesheet", href: documentListStyles }, - ]; -} - -/** - * 知识库管理首页 + * 知识库管理首页 - 带标签页导航 + * 标签1: 知识库列表 - 进入单个知识库管理 + * 标签2: 知识库配置管理 - 地区-知识库绑定管理 */ export default function DatasetManagerIndex() { + const items = [ + { + key: 'dataset-list', + label: '知识库列表', + children: , + }, + { + key: 'area-config', + label: '知识库配置管理', + children: , + }, + ]; + + // 使用Tabs作为顶层导航,默认选中第一个 + const defaultActiveTab = 'dataset-list'; + return ( -
- +
+
); } diff --git a/app/styles/components/dify-dataset-manager/area-dataset-config.css b/app/styles/components/dify-dataset-manager/area-dataset-config.css new file mode 100644 index 0000000..e69de29 diff --git a/docs/new-dify/dify_api_doc.md b/docs/new-dify/dify_api_doc.md new file mode 100644 index 0000000..588dbc5 --- /dev/null +++ b/docs/new-dify/dify_api_doc.md @@ -0,0 +1,875 @@ +# Dify 模块 API 接口文档 + +> 版本:2.3 +> 更新时间:2025-01-22 +> 基础路径:`/api/v3` + + +--- + +## 目录 + + +1. [概述](#概述) +2. [权限说明](#权限说明) +3. [安全设计](#安全设计) +4. [地区知识库管理接口](#地区知识库管理接口) + - [获取当前用户可访问的知识库](#1-获取当前用户可访问的知识库) + - [获取所有知识库绑定列表](#2-获取所有知识库绑定列表管理员) + - [获取可用地区列表](#3-获取可用地区列表) + - [获取知识库绑定详情](#4-获取知识库绑定详情) + - [创建知识库绑定](#5-创建知识库绑定) + +- [更新知识库绑定](#6-更新知识库绑定) + - [删除知识库绑定](#7-删除知识库绑定) + - [检查用户知识库访问权限](#8- [对话应用管理接口](#9-检查用户知识库访问权限) + - [获取可用的对话应用列表](#9-获取可用的对话应用列表) + - [获取默认对话应用](#10-获取默认对话应用) + + +5. [对话应用多实例支持](#对话应用多实例支持) + - [配置方式](#配置方式) + - [前端切换应用](#前端切换应用) + - [端口多实例特性](#端口多实例特性) +6. [错误码说明](#错误码说明) + - [接口说明](#接口说明) +7. [数据字典](#数据字典) + + +--- + +## 概述 + +Dify 模块提供 AI 对话和知识库管理功能。本文档主要描述**地区-知识库绑定管理**相关接口。 + +### 核心概念 + +|概念 |说明 | +|---|---| +|**地区(area)** |用户所属地区,如:梅州、云浮、揭阳、潮州 | +|**知识库绑定** |将 Dify 平台的知识库与特定地区关联,控制用户可访问的知识库范围 | +|**公共知识库** |`is_public=true` 的知识库,所有地区用户均可访问(如省级知识库) | +|**默认知识库** |`is_default=true` 的知识库,该地区用户的首选知识库 | + +### 业务规则 + + +1. **普通用户(common)只能访问**本地区绑定的知识库 + **公共知识库** +2. **市级管理员(admin)只能访问**本地区绑定的知识库 + **公共知识库**,可编辑本地区知识库内容 +3. **省级管理员(provincial_admin)可以访问和管理**所有地区的知识库 +4. 只有**省级管理员**可以管理知识库绑定(增删改查绑定关系) +5. 同一地区不能重复绑定同一知识库 +6. 删除为软删除,可恢复 + + +--- + +## 权限说明 + +### 权限键定义(无通配符) + +|权限键 |说明 |适用角色 | +|---|---|---| +|`dify:chat:use` |使用 AI 对话 |common, provincial_admin, admin | +|`dify:conversation:read` |查看对话历史(仅自己) |common, provincial_admin, admin | +|`dify:conversation:delete` |删除对话(仅自己) |common, provincial_admin, admin | +|`dify:message:feedback` |消息反馈 |common, provincial_admin, admin | +|`dify:dataset:read` |查看知识库列表 |common, admin, provincial_admin | +|`dify:dataset:write` |编辑知识库内容 |admin(仅本地区), provincial_admin(全部) | +|`dify:dataset:manage` |管理知识库绑定(增删改查) |provincial_admin(仅省级管理员) | +|`dify:file:read` |下载/预览文件 |common, provincial_admin, admin | +|`dify:file:upload` |上传文件 |common, provincial_admin, admin | + +### 角色权限矩阵 + +|接口 |common |admin(市级管理员) |provincial_admin(省级管理员) | +|---|---|---|---| +|获取用户可访问的知识库 |✅ 本地区+公共 |✅ 本地区+公共 |✅ 全部 | +|编辑知识库内容 |❌ |✅ 仅本地区 |✅ 全部 | +|获取所有绑定列表(管理) |❌ |❌ |✅ | +|创建/更新/删除绑定 |❌ |❌ |✅ | +|检查访问权限 |✅ |✅ |✅ | + +### 数据范围说明 + +|数据范围 |代码值 |说明 | +|---|---|---| +|全部 |`ALL` |可访问所有地区数据(仅 provincial_admin) | +|本地区 |`DEPT` |只能访问本地区数据 + 公共数据(common, admin) | +|仅自己 |`SELF` |只能操作自己的数据(对话历史) | + + +--- + +## 安全设计 + +### 1. 对话数据越权防护 + +**问题**:用户 A 不能查看/删除用户 B 的对话 + +**解决方案**: + + +1. **Dify 端隔离**:请求时强制注入 `user` 参数为当前登录用户的 `username`,无法伪造 +2. **本地数据库隔离**:`dify_conversation` 表的操作都带 `user_id` 条件 + +```sql +-- 删除对话时的 SQL +UPDATE dify_conversation +SET deleted_at = CURRENT_TIMESTAMP +WHERE user_id = $1 AND conversation_id = $2 -- 必须是自己的 +``` + +### 2. 知识库数据范围控制 + +**问题**:普通用户和市级管理员不能访问其他地区的知识库 + +**解决方案**: + +```python +# 根据用户角色控制数据范围 +if user_role == "provincial_admin": + # 仅省级管理员返回所有知识库 +else: + # 市级管理员和普通用户:只返回本地区 + 公共知识库 + WHERE area = {user_area} OR is_public = true +``` + +### 3. 请求伪造防护 + +**问题**:用户伪造请求参数访问他人数据 + +**解决方案**: + +- 所有用户身份信息从 JWT Token 解析,不信任请求参数 +- `user` 参数强制覆盖,不允许前端传入 + +```python +# process_request_body 中 +body_data['user'] = username # 强制使用当前用户 +``` + + +--- + +## 地区知识库管理接口 + +### 1. 获取当前用户可访问的知识库 + +根据当前登录用户的地区和角色,返回可访问的知识库列表。 + +**请求** + +``` +GET /api/v3/dify/area-datasets/my +``` + +**请求头** + +|参数 |类型 |必填 |说明 | +|---|---|---|---| +|Authorization |string |是 |Bearer {token} | + +**数据范围控制** + +|角色 |返回数据 | +|---|---| +|common(普通员工) |用户所属地区的知识库 + 公共知识库 | +|admin(市级管理员) |用户所属地区的知识库 + 公共知识库 | +|provincial_admin(省级管理员) |所有地区的知识库 | + +**响应** + +```json +{ + "code": 0, + "message": "success", + "data": { + "data": [ + { + "id": 1, + "area": "省级", + "dataset_id": "fd680642-4493-416b-b592-972c69b3f595", + "dataset_name": "省级法务知识库", + "dataset_description": "全省通用法务知识库", + "is_default": false, + "is_public": true, + "sort_order": 0, + "status": 1, + "created_at": "2025-01-22T10:00:00", + "updated_at": "2025-01-22T10:00:00" + }, + { + "id": 2, + "area": "梅州", + "dataset_id": "a1b2c3d4-5678-90ab-cdef-1234567890ab", + "dataset_name": "梅州法务知识库", + "dataset_description": "梅州地区专用知识库", + "is_default": true, + "is_public": false, + "sort_order": 10, + "status": 1, + "created_at": "2025-01-22T10:30:00", + "updated_at": "2025-01-22T10:30:00" + } + ], + "total": 2, + "user_area": "梅州", + "user_role": "common" + } +} +``` + +**响应字段说明** + +|字段 |类型 |说明 | +|---|---|---| +|data.data |array |知识库列表 | +|data.total |int |知识库总数 | +|data.user_area |string |当前用户所属地区 | +|data.user_role |string |当前用户角色 | + +**错误响应** + +|HTTP 状态码 |错误信息 |说明 | +|---|---|---| +|400 |用户未设置地区,无法获取知识库列表 |用户 area 字段为空(非管理员) | +|401 |JWT 认证失败 |Token 无效或过期 | +|403 |Permission denied |无 `dify:dataset:read` 权限 | + + +--- + +### 2. 获取所有知识库绑定列表(管理员) + +获取系统中所有地区的知识库绑定记录,支持按地区筛选和分页。 + +**权限要求**:`dify:dataset:manage`(仅 provincial_admin) + +**请求** + +``` +GET /api/v3/dify/area-datasets +``` + +**请求头** + +|参数 |类型 |必填 |说明 | +|---|---|---|---| +|Authorization |string |是 |Bearer {token} | + +**查询参数** + +|参数 |类型 |必填 |默认值 |说明 | +|---|---|---|---|---| +|area |string |否 |- |筛选地区,如:梅州 | +|only_enabled |boolean |否 |true |是否只返回启用的记录 | +|page |int |否 |1 |页码,从 1 开始 | +|page_size |int |否 |20 |每页数量,范围 1-100 | + +**请求示例** + +```bash +curl -X GET "http://localhost:8000/api/v3/dify/area-datasets?area=梅州&page=1&page_size=20" \ + -H "Authorization: Bearer {token}" +``` + +**响应** + +```json +{ + "code": 0, + "message": "success", + "data": { + "data": [ + { + "id": 2, + "area": "梅州", + "dataset_id": "a1b2c3d4-5678-90ab-cdef-1234567890ab", + "dataset_name": "梅州法务知识库", + "dataset_description": "梅州地区专用知识库", + "is_default": true, + "is_public": false, + "sort_order": 10, + "status": 1, + "created_by": 1, + "created_at": "2025-01-22T10:30:00", + "updated_by": null, + "updated_at": "2025-01-22T10:30:00" + } + ], + "total": 1, + "page": 1, + "page_size": 20, + "has_more": false + } +} +``` + + +--- + +### 3. 获取可用地区列表 + +获取系统中所有可用的地区列表,用于管理员创建绑定时选择地区。 + +**权限要求**:`dify:dataset:manage` + +**请求** + +``` +GET /api/v3/dify/area-datasets/areas +``` + +**响应** + +```json +{ + "code": 0, + "message": "success", + "data": { + "data": ["云浮", "揭阳", "梅州", "潮州"] + } +} +``` + + +--- + +### 4. 获取知识库绑定详情 + +根据绑定记录 ID 获取详情。 + +**权限要求**:`dify:dataset:manage` + +**请求** + +``` +GET /api/v3/dify/area-datasets/{dataset_bind_id} +``` + +**路径参数** + +|参数 |类型 |必填 |说明 | +|---|---|---|---| +|dataset_bind_id |int |是 |绑定记录 ID | + +**响应** + +```json +{ + "code": 0, + "message": "success", + "data": { + "data": { + "id": 2, + "area": "梅州", + "dataset_id": "a1b2c3d4-5678-90ab-cdef-1234567890ab", + "dataset_name": "梅州法务知识库", + "dataset_description": "梅州地区专用知识库", + "is_default": true, + "is_public": false, + "sort_order": 10, + "status": 1, + "created_by": 1, + "created_at": "2025-01-22T10:30:00", + "updated_by": null, + "updated_at": "2025-01-22T10:30:00" + } + } +} +``` + + +--- + +### 5. 创建知识库绑定 + +将 Dify 知识库绑定到指定地区。 + +**权限要求**:`dify:dataset:manage` + +**请求** + +``` +POST /api/v3/dify/area-datasets +``` + +**请求体** + +|参数 |类型 |必填 |默认值 |说明 | +|---|---|---|---|---| +|area |string |是 |- |地区名称(1-50字符) | +|dataset_id |string |是 |- |Dify 知识库 ID(1-100字符) | +|dataset_name |string |是 |- |知识库名称(1-255字符) | +|dataset_description |string |否 |null |知识库描述 | +|is_default |boolean |否 |false |是否为该地区默认知识库 | +|is_public |boolean |否 |false |是否对所有地区公开 | +|sort_order |int |否 |0 |排序顺序,越小越靠前 | + +**请求示例** + +```bash +curl -X POST "http://localhost:8000/api/v3/dify/area-datasets" \ + -H "Authorization: Bearer {token}" \ + -H "Content-Type: application/json" \ + -d '{ + "area": "梅州", + "dataset_id": "a1b2c3d4-5678-90ab-cdef-1234567890ab", + "dataset_name": "梅州法务知识库", + "dataset_description": "梅州地区专用知识库", + "is_default": true, + "is_public": false, + "sort_order": 10 + }' +``` + +**响应** + +```json +{ + "code": 0, + "message": "success", + "data": { + "data": { + "id": 2, + "area": "梅州", + "dataset_id": "a1b2c3d4-5678-90ab-cdef-1234567890ab", + "dataset_name": "梅州法务知识库", + "dataset_description": "梅州地区专用知识库", + "is_default": true, + "is_public": false, + "sort_order": 10, + "status": 1, + "created_at": "2025-01-22T10:30:00" + }, + "message": "创建成功" + } +} +``` + +**错误响应** + +|HTTP 状态码 |错误信息 |说明 | +|---|---|---| +|400 |地区 '梅州' 已绑定知识库 'xxx' |重复绑定 | +|422 |Validation Error |参数校验失败 | + + +--- + +### 6. 更新知识库绑定 + +更新知识库绑定信息,支持部分更新。 + +**权限要求**:`dify:dataset:manage` + +**请求** + +``` +PUT /api/v3/dify/area-datasets/{dataset_bind_id} +``` + +**请求体** + +|参数 |类型 |必填 |说明 | +|---|---|---|---| +|dataset_name |string |否 |知识库名称 | +|dataset_description |string |否 |知识库描述 | +|is_default |boolean |否 |是否默认 | +|is_public |boolean |否 |是否公开 | +|sort_order |int |否 |排序顺序 | +|status |int |否 |状态:1=启用, 0=禁用 | + +**响应** + +```json +{ + "code": 0, + "message": "success", + "data": { + "data": { + "id": 2, + "area": "梅州", + "dataset_id": "a1b2c3d4-5678-90ab-cdef-1234567890ab", + "dataset_name": "梅州法务知识库(更新)", + "is_default": true, + "sort_order": 5, + "status": 1, + "updated_at": "2025-01-22T11:00:00" + }, + "message": "更新成功" + } +} +``` + + +--- + +### 7. 删除知识库绑定 + +软删除知识库绑定记录。 + +**权限要求**:`dify:dataset:manage` + +**请求** + +``` +DELETE /api/v3/dify/area-datasets/{dataset_bind_id} +``` + +**响应** + +```json +{ + "code": 0, + "message": "success", + "data": { + "message": "删除成功" + } +} +``` + + +--- + +### 8. 检查用户知识库访问权限 + +检查当前用户是否有权访问指定的 Dify 知识库。 + +**权限要求**:`dify:dataset:read` + +**请求** + +``` +GET /api/v3/dify/area-datasets/check/{dataset_id} +``` + +**路径参数** + +|参数 |类型 |必填 |说明 | +|---|---|---|---| +|dataset_id |string |是 |Dify 知识库 ID | + +**响应(有权限)** + +```json +{ + "code": 0, + "message": "success", + "data": { + "has_access": true, + "user_area": "梅州", + "dataset_id": "a1b2c3d4-5678-90ab-cdef-1234567890ab" + } +} +``` + +**响应(无权限)** + +```json +{ + "code": 0, + "message": "success", + "data": { + "has_access": false, + "user_area": "梅州", + "dataset_id": "xyz-not-allowed" + } +} +``` + + +--- + +## 错误码说明 + +### HTTP 状态码 + +|状态码 |说明 | +|---|---| +|200 |请求成功 | +|400 |请求参数错误 | +|401 |未认证或 Token 无效 | +|403 |权限不足 | +|404 |资源不存在 | +|422 |参数校验失败 | +|500 |服务器内部错误 | + +### 业务错误码 + +|code |说明 | +|---|---| +|0 |成功 | +|-1 |通用错误 | + + +--- + +## 数据字典 + +### dify_area_dataset 表 + +|字段 |类型 |说明 | +|---|---|---| +|id |int |主键 ID | +|area |varchar(50) |地区名称 | +|dataset_id |varchar(100) |Dify 知识库 ID | +|dataset_name |varchar(255) |知识库名称 | +|dataset_description |text |知识库描述 | +|is_default |boolean |是否为该地区默认知识库 | +|is_public |boolean |是否对所有地区公开 | +|sort_order |int |排序顺序,越小越靠前 | +|status |smallint |状态:1=启用, 0=禁用 | +|created_by |int |创建人 ID | +|created_at |timestamp |创建时间 | +|updated_by |int |更新人 ID | +|updated_at |timestamp |更新时间 | +|deleted_at |timestamp |删除时间(软删除) | + +### 地区枚举值 + +|值 |说明 | +|---|---| +|梅州 |梅州地区 | +|云浮 |云浮地区 | +|揭阳 |揭阳地区 | +|潮州 |潮州地区 | +|省级 |省级(用于公共知识库) | + + +--- + +## 使用示例 + +### 场景1:管理员配置知识库 + +```bash +# 1. 创建省级公共知识库(所有用户可见) +curl -X POST "/api/v3/dify/area-datasets" \ + -H "Authorization: Bearer {admin_token}" \ + -d '{ + "area": "省级", + "dataset_id": "省级知识库ID", + "dataset_name": "省级法务知识库", + "is_public": true, + "sort_order": 0 + }' + +# 2. 为各地区创建专属知识库(仅该地区用户可见) +curl -X POST "/api/v3/dify/area-datasets" \ + -d '{"area": "梅州", "dataset_id": "梅州知识库ID", "dataset_name": "梅州法务知识库", "is_default": true}' + +curl -X POST "/api/v3/dify/area-datasets" \ + -d '{"area": "云浮", "dataset_id": "云浮知识库ID", "dataset_name": "云浮法务知识库", "is_default": true}' +``` + +### 场景2:普通用户获取可访问的知识库 + +```bash +# 梅州用户(common 角色) +curl -X GET "/api/v3/dify/area-datasets/my" \ + -H "Authorization: Bearer {meizhou_user_token}" + +# 返回:省级公共知识库 + 梅州专属知识库(2条) +``` + +### 场景3:省级管理员获取所有知识库 + +```bash +# 省级管理员(provincial_admin 角色) +curl -X GET "/api/v3/dify/area-datasets/my" \ + -H "Authorization: Bearer {provincial_admin_token}" + +# 返回:所有地区的知识库(包括梅州、云浮、揭阳、潮州、省级) +``` + +### 场景4:前端调用 Dify API 前检查权限 + +```bash +# 检查用户是否有权访问某个知识库 +curl -X GET "/api/v3/dify/area-datasets/check/某知识库ID" \ + -H "Authorization: Bearer {token}" + +# 根据 has_access 字段决定是否允许调用 Dify API +``` + + +--- + +## 9. 对话应用管理接口 + +### 9.1 获取可用的对话应用列表 + +**接口**: `GET /api/v3/dify/chat-apps` + +**权限**: `dify:chat:use` + +**说明**: 获取当前实例配置的所有对话应用列表,供前端切换使用。 + +**请求示例**: + +```bash +curl -X GET "http://localhost:8000/api/v3/dify/chat-apps" \ + -H "Authorization: Bearer " +``` + +**Response Body**: `success (data)` + +```json +{ + "code": 200, + "msg": "OK", + "data": { + "data": [ + { + "app_id": "app-xxx", + "app_name": "AI法务助手", + "description": "通用法律咨询助手", + "is_default": true + }, + { + "app_id": "app-yyy", + "app_name": "合同审查助手", + "description": "专业合同分析", + "is_default": false + } + ], + "total": 2 + } +} +``` + +**字段说明**: + +|字段 |类型 |说明 | +|---|---|---| +|`app_id` |string |对话应用 ID | +|`app_name` |string |对话应用名称 | +|`description` |string |应用描述 | +|`is_default` |boolean |是否为默认应用 | + +### 9.2 获取默认对话应用 + +**接口**: `GET /api/v3/dify/chat-apps/default` + +**权限**: `dify:chat:use` + +**说明**: 获取当前配置的默认对话应用。 + +**请求示例**: + +```bash +curl -X GET "http://localhost:8000/api/v3/dify/chat-apps/default" \ + -H "Authorization: Bearer " +``` + +**Response Body**: `success (data)` + +```json +{ + "code": 200, + "msg": "OK", + "data": { + "data": { + "app_id": "app-xxx", + "app_name": "AI法务助手", + "description": "通用法律咨询助手", + "is_default": true + } + } +} +``` + + +--- + +## 10. 对话应用多实例支持 + +### 10.1 配置方式 + +在 `config/env.{port}` 配置文件中添加 `DIFY_CHAT_APPS` 配置项: + +**格式**: + +```ini +DIFY_CHAT_APPS=app_id:api_key:app_name:description|app_id2:api_key2:app_name2:description + +**说明**: +- 多个应用用 `|` 分隔 +- 每个应用格式:`app_id`:`api_key`:`app_name`:`description` +- 第一个应用为默认应用 + +**示例**: +```ini +DIFY_CHAT_APPS=app-dmYZISz60ZGQHlJAPHmngoZZ:app-dmYZISz60ZGQHlJAPHmngoZZ:AI法务助手:通用法律咨询助手|app-xxx:app-xxx:合同审查助手:专业合同分析 +``` + +### 10.2 前端切换应用 + +发送对话消息时,可通过以下方式指定应用: + +**方式 1:请求头(推荐)** + +```javascript +axios.post('/api/dify/chat/chat-messages', data, { + headers: { + 'Authorization': 'Bearer {token}', + 'X-Dify-App-Id': 'app-xxx' // 指定应用 ID + } +}) +``` + +**方式 2:查询参数** + +```javascript +axios.post('/api/dify/chat/chat-messages?app_id=app-xxx', data, { + headers: { + 'Authorization': 'Bearer {token}' + } +}) +``` + +**使用默认应用**: + +不指定 `X-Dify-App-Id` 或 `app_id` 时,使用配置文件中的第一个应用作为默认应用。 + +### 10.3 端口多实例特性 + +本系统的特点是**端口多实例**部署架构,每个端口读取对应的配置文件: + +|端口 |配置文件 |默认对话应用 | +|---|---|---| +|8073 |`config/env.8073` |梅州实例的对话应用 | +|8000 |`config/env.8000` |云浮实例的对话应用 | +|8001 |`config/env.8001` |揭阳实例的对话应用 | +|8002 |`config/env.8002` |潮州实例的对话应用 | + +因此,前端请求不同端口时,会获得不同的对话应用列表: + +```javascript +// 请求梅州实例 +GET http://172.16.0.55:8073/api/v3/dify/chat-apps +// 返回梅州配置: [梅州法务助手, 梅州合同审查] + +// 请求云浮实例 +GET http://172.16.0.55:8000/api/v3/dify/chat-apps +// 返回云浮配置: [云浮法务助手, 云浮合同审查] +``` + +这种设计实现了区域间的业务隔离,无需后端代码判断,完全由配置驱动。 + + +--- + +## 11. 更新日志 + +|版本 |日期 |说明 | +|---|---|---| +|2.3 |2025-01-22 |新增对话应用多实例支持,支持配置多个对话应用供前端切换 | +|2.2 |2025-01-22 |修正角色权限:市级管理员(admin)只能访问本地区,省级管理员(provincial_admin)可访问全部;知识库绑定管理仅限省级管理员 | +|2.1 |2025-01-22 |修正权限角色(去掉 area_admin),添加数据范围控制和越权防护说明 | +|2.0 |2025-01-22 |新增地区-知识库绑定管理接口 | +|1.0 |2025-01-20 |初始版本,Dify 代理接口 | + + diff --git a/docs/new-dify/dify_frontend_integration.md b/docs/new-dify/dify_frontend_integration.md new file mode 100644 index 0000000..71fdd0f --- /dev/null +++ b/docs/new-dify/dify_frontend_integration.md @@ -0,0 +1,1399 @@ +# Dify 模块前端对接文档 + +> 版本:1.0 +> 更新时间:2025-12-06 +> 适用前端:Vue 3 + TypeScript + +--- + +## 目录 + +1. [概述](#概述) +2. [权限体系](#权限体系) +3. [API 接口清单](#api-接口清单) +4. [大模型对话模块](#大模型对话模块) +5. [知识库管理模块](#知识库管理模块) +6. [权限控制实现](#权限控制实现) +7. [错误处理](#错误处理) +8. [完整代码示例](#完整代码示例) + +--- + +## 概述 + +### 模块结构 + +``` +AI法务助手 /chat-with-llm +├── 大模型对话 /chat-with-llm/chat +│ ├── 使用AI对话 (dify:chat:use) +│ ├── 查看对话历史 (dify:conversation:read) +│ ├── 删除对话 (dify:conversation:delete) +│ └── 消息反馈 (dify:message:feedback) +│ +└── 知识库管理 /chat-with-llm/dataset-manager + ├── 查看知识库 (dify:dataset:read) + ├── 编辑知识库内容 (dify:dataset:write) + ├── 管理知识库绑定 (dify:dataset:manage) + ├── 下载文件 (dify:file:read) + └── 上传文件 (dify:file:upload) +``` + +### 角色权限矩阵 + +| 功能 | common (普通员工) | admin (市级管理员) | provincial_admin (省级管理员) | +|------|------------------|-------------------|------------------------------| +| 大模型对话 | ✅ | ✅ | ✅ | +| 查看对话历史 | ✅ 仅自己 | ✅ 仅自己 | ✅ 仅自己 | +| 删除对话 | ✅ 仅自己 | ✅ 仅自己 | ✅ 仅自己 | +| 查看知识库 | ✅ 本地区+公共 | ✅ 本地区+公共 | ✅ 全部 | +| 编辑知识库内容 | ❌ | ✅ 仅本地区 | ✅ 全部 | +| 管理知识库绑定 | ❌ | ❌ | ✅ | +| 文件上传/下载 | ✅ | ✅ | ✅ | + +--- + +## 权限体系 + +### 权限标识 (permission_key) + +| 权限标识 | 显示名称 | HTTP方法 | API路径 | +|---------|---------|----------|---------| +| `dify:chat:use` | 使用AI对话 | POST | /api/dify/chat/chat-messages | +| `dify:conversation:read` | 查看对话历史 | GET | /api/dify/chat/conversations | +| `dify:conversation:delete` | 删除对话 | DELETE | /api/dify/chat/conversations/{conversation_id} | +| `dify:message:feedback` | 消息反馈 | POST | /api/dify/chat/messages/{message_id}/feedbacks | +| `dify:dataset:read` | 查看知识库 | GET | /api/v3/dify/area-datasets/my | +| `dify:dataset:write` | 编辑知识库内容 | POST | /api/dify/dataset/documents | +| `dify:dataset:manage` | 管理知识库绑定 | POST | /api/v3/dify/area-datasets | +| `dify:file:read` | 下载文件 | GET | /api/dify/file/{file_id}/file-preview | +| `dify:file:upload` | 上传文件 | POST | /api/dify/chat/files/upload | + +### 前端权限检查 + +```typescript +// stores/permission.ts +import { defineStore } from 'pinia' + +interface RoutePermission { + route_path: string + permissions: string[] +} + +export const usePermissionStore = defineStore('permission', { + state: () => ({ + routes: [] as RoutePermission[], + routesFlat: [] as RoutePermission[] + }), + + actions: { + // 检查是否有某个权限 + hasPermission(permissionKey: string): boolean { + return this.routesFlat.some(route => + route.permissions.includes(permissionKey) + ) + }, + + // 检查是否有某个路由下的权限 + hasRoutePermission(routePath: string, permissionKey: string): boolean { + const route = this.routesFlat.find(r => r.route_path === routePath) + return route?.permissions.includes(permissionKey) ?? false + } + } +}) + +// 使用示例 +const permissionStore = usePermissionStore() + +// 检查是否可以编辑知识库 +if (permissionStore.hasPermission('dify:dataset:write')) { + // 显示编辑按钮 +} + +// 检查是否可以管理知识库绑定 +if (permissionStore.hasPermission('dify:dataset:manage')) { + // 显示管理按钮 +} +``` + +--- + +## API 接口清单 + +### 基础配置 + +```typescript +// api/config.ts +const API_BASE = '/api' + +export const DIFY_API = { + // 对话相关 + CHAT_MESSAGES: `${API_BASE}/dify/chat/chat-messages`, + CONVERSATIONS: `${API_BASE}/dify/chat/conversations`, + CONVERSATION_MESSAGES: (id: string) => `${API_BASE}/dify/chat/conversations/${id}/messages`, + CONVERSATION_DELETE: (id: string) => `${API_BASE}/dify/chat/conversations/${id}`, + CONVERSATION_RENAME: (id: string) => `${API_BASE}/dify/chat/conversations/${id}/name`, + MESSAGE_FEEDBACK: (id: string) => `${API_BASE}/dify/chat/messages/${id}/feedbacks`, + SUGGESTED_QUESTIONS: (id: string) => `${API_BASE}/dify/chat/messages/${id}/suggested`, + + // 文件相关 + FILE_UPLOAD: `${API_BASE}/dify/chat/files/upload`, + FILE_PREVIEW: (id: string) => `${API_BASE}/dify/file/${id}/file-preview`, + + // 知识库相关 (新增) + AREA_DATASETS_MY: `${API_BASE}/v3/dify/area-datasets/my`, + AREA_DATASETS: `${API_BASE}/v3/dify/area-datasets`, + AREA_DATASETS_DETAIL: (id: number) => `${API_BASE}/v3/dify/area-datasets/${id}`, + AREA_DATASETS_AREAS: `${API_BASE}/v3/dify/area-datasets/areas`, + AREA_DATASETS_CHECK: (datasetId: string) => `${API_BASE}/v3/dify/area-datasets/check/${datasetId}`, +} +``` + +--- + +## 大模型对话模块 + +### 1. 发送对话消息 + +**权限要求**: `dify:chat:use` + +```typescript +// api/dify/chat.ts +interface ChatMessageRequest { + query: string // 用户输入的问题 + conversation_id?: string // 对话ID(首次对话不传) + response_mode?: 'streaming' | 'blocking' // 响应模式,默认 streaming + files?: Array<{ + type: 'image' | 'document' + transfer_method: 'local_file' + upload_file_id: string + }> +} + +interface ChatMessageResponse { + event: string + message_id: string + conversation_id: string + answer: string + created_at: number +} + +// 流式对话(推荐) +export async function sendChatMessageStream( + data: ChatMessageRequest, + onMessage: (chunk: string) => void, + onDone: (response: ChatMessageResponse) => void, + onError: (error: Error) => void +) { + const response = await fetch(DIFY_API.CHAT_MESSAGES, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${getToken()}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + ...data, + response_mode: 'streaming' + }) + }) + + if (!response.ok) { + if (response.status === 403) { + onError(new Error('权限不足:您没有使用AI对话的权限')) + return + } + throw new Error(`HTTP error! status: ${response.status}`) + } + + const reader = response.body?.getReader() + const decoder = new TextDecoder() + + while (true) { + const { done, value } = await reader!.read() + if (done) break + + const chunk = decoder.decode(value) + const lines = chunk.split('\n') + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = JSON.parse(line.slice(6)) + + if (data.event === 'message') { + onMessage(data.answer) + } else if (data.event === 'message_end') { + onDone(data) + } else if (data.event === 'error') { + onError(new Error(data.message)) + } + } + } + } +} + +// 阻塞式对话 +export async function sendChatMessage(data: ChatMessageRequest): Promise { + const response = await request.post(DIFY_API.CHAT_MESSAGES, { + ...data, + response_mode: 'blocking' + }) + return response.data +} +``` + +**使用示例**: + +```vue + + + +``` + +### 2. 获取对话历史列表 + +**权限要求**: `dify:conversation:read` + +```typescript +interface Conversation { + id: string + name: string + created_at: number + updated_at: number +} + +interface ConversationListResponse { + data: Conversation[] + has_more: boolean + limit: number +} + +export async function getConversations( + limit: number = 20, + last_id?: string +): Promise { + const params = new URLSearchParams({ limit: limit.toString() }) + if (last_id) params.append('last_id', last_id) + + const response = await request.get(`${DIFY_API.CONVERSATIONS}?${params}`) + return response.data +} +``` + +### 3. 获取对话消息详情 + +**权限要求**: `dify:conversation:read` + +```typescript +interface Message { + id: string + conversation_id: string + query: string + answer: string + created_at: number + feedback?: { + rating: 'like' | 'dislike' + } +} + +interface MessageListResponse { + data: Message[] + has_more: boolean + limit: number +} + +export async function getConversationMessages( + conversationId: string, + limit: number = 20, + first_id?: string +): Promise { + const params = new URLSearchParams({ limit: limit.toString() }) + if (first_id) params.append('first_id', first_id) + + const response = await request.get( + `${DIFY_API.CONVERSATION_MESSAGES(conversationId)}?${params}` + ) + return response.data +} +``` + +### 4. 删除对话 + +**权限要求**: `dify:conversation:delete` + +```typescript +export async function deleteConversation(conversationId: string): Promise { + await request.delete(DIFY_API.CONVERSATION_DELETE(conversationId)) +} +``` + +**使用示例**: + +```vue + + + +``` + +### 5. 消息反馈(点赞/点踩) + +**权限要求**: `dify:message:feedback` + +```typescript +interface FeedbackRequest { + rating: 'like' | 'dislike' | null // null 表示取消反馈 + content?: string // 反馈内容(可选) +} + +export async function submitMessageFeedback( + messageId: string, + feedback: FeedbackRequest +): Promise { + await request.post(DIFY_API.MESSAGE_FEEDBACK(messageId), feedback) +} +``` + +### 6. 上传文件 + +**权限要求**: `dify:file:upload` + +```typescript +interface FileUploadResponse { + id: string + name: string + size: number + extension: string + mime_type: string + created_at: number +} + +export async function uploadFile(file: File): Promise { + const formData = new FormData() + formData.append('file', file) + + const response = await request.post(DIFY_API.FILE_UPLOAD, formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }) + return response.data +} +``` + +--- + +## 知识库管理模块 + +### 1. 获取当前用户可访问的知识库列表 + +**权限要求**: `dify:dataset:read` + +**接口说明**: 根据用户角色自动返回对应数据范围 +- `common` / `admin`: 本地区 + 公共知识库 +- `provincial_admin`: 全部知识库 + +```typescript +interface AreaDataset { + id: number + area: string // 地区:梅州、云浮、揭阳、潮州、省级 + dataset_id: string // Dify 知识库 ID + dataset_name: string // 知识库名称 + dataset_description?: string // 知识库描述 + is_default: boolean // 是否为该地区默认知识库 + is_public: boolean // 是否公开(省级公共知识库) + sort_order: number // 排序顺序 + status: number // 状态:1=启用, 0=禁用 + created_at: string + updated_at: string +} + +interface MyDatasetsResponse { + data: AreaDataset[] + total: number + user_area: string // 当前用户所属地区 + user_role: string // 当前用户角色 +} + +export async function getMyDatasets(): Promise { + const response = await request.get(DIFY_API.AREA_DATASETS_MY) + return response.data +} +``` + +**使用示例**: + +```vue + + + +``` + +### 2. 获取所有知识库绑定列表(管理员) + +**权限要求**: `dify:dataset:manage` (仅省级管理员) + +```typescript +interface DatasetListResponse { + data: AreaDataset[] + total: number + page: number + page_size: number + has_more: boolean +} + +interface DatasetListParams { + area?: string // 筛选地区 + only_enabled?: boolean // 是否只返回启用的,默认 true + page?: number // 页码,默认 1 + page_size?: number // 每页数量,默认 20 +} + +export async function getAllDatasets( + params: DatasetListParams = {} +): Promise { + const response = await request.get(DIFY_API.AREA_DATASETS, { params }) + return response.data +} +``` + +### 3. 获取可用地区列表 + +**权限要求**: `dify:dataset:manage` + +```typescript +export async function getAvailableAreas(): Promise { + const response = await request.get(DIFY_API.AREA_DATASETS_AREAS) + return response.data.data +} +``` + +### 4. 创建知识库绑定 + +**权限要求**: `dify:dataset:manage` (仅省级管理员) + +```typescript +interface CreateDatasetRequest { + area: string // 地区名称 + dataset_id: string // Dify 知识库 ID + dataset_name: string // 知识库名称 + dataset_description?: string // 描述 + is_default?: boolean // 是否默认,默认 false + is_public?: boolean // 是否公开,默认 false + sort_order?: number // 排序,默认 0 +} + +export async function createDatasetBinding( + data: CreateDatasetRequest +): Promise { + const response = await request.post(DIFY_API.AREA_DATASETS, data) + return response.data.data +} +``` + +### 5. 更新知识库绑定 + +**权限要求**: `dify:dataset:manage` (仅省级管理员) + +```typescript +interface UpdateDatasetRequest { + dataset_name?: string + dataset_description?: string + is_default?: boolean + is_public?: boolean + sort_order?: number + status?: number // 1=启用, 0=禁用 +} + +export async function updateDatasetBinding( + id: number, + data: UpdateDatasetRequest +): Promise { + const response = await request.put(DIFY_API.AREA_DATASETS_DETAIL(id), data) + return response.data.data +} +``` + +### 6. 删除知识库绑定 + +**权限要求**: `dify:dataset:manage` (仅省级管理员) + +```typescript +export async function deleteDatasetBinding(id: number): Promise { + await request.delete(DIFY_API.AREA_DATASETS_DETAIL(id)) +} +``` + +### 7. 检查知识库访问权限 + +**权限要求**: `dify:dataset:read` + +```typescript +interface CheckAccessResponse { + has_access: boolean + user_area: string + dataset_id: string +} + +export async function checkDatasetAccess( + datasetId: string +): Promise { + const response = await request.get(DIFY_API.AREA_DATASETS_CHECK(datasetId)) + return response.data +} +``` + +**使用示例**: + +```typescript +// 在访问 Dify 知识库详情之前检查权限 +async function viewDatasetDetail(datasetId: string) { + try { + const { has_access } = await checkDatasetAccess(datasetId) + + if (!has_access) { + ElMessage.warning('您没有访问该知识库的权限') + return + } + + // 继续访问知识库详情... + router.push(`/dataset/${datasetId}`) + } catch (error) { + ElMessage.error('权限检查失败') + } +} +``` + +--- + +## 权限控制实现 + +### 1. 获取用户权限 + +登录后调用 `/user/routes` 获取用户权限: + +```typescript +// api/auth.ts +interface UserRoutesResponse { + user_id: number + username: string + routes: RouteInfo[] + routes_flat: RouteInfo[] +} + +export async function getUserRoutes(): Promise { + const response = await request.get('/user/routes') + return response.data +} + +// 登录后初始化权限 +async function initPermissions() { + const data = await getUserRoutes() + const permissionStore = usePermissionStore() + permissionStore.setRoutes(data.routes, data.routes_flat) +} +``` + +### 2. 路由守卫 + +```typescript +// router/guards.ts +import { usePermissionStore } from '@/stores/permission' + +router.beforeEach(async (to, from, next) => { + const permissionStore = usePermissionStore() + + // 检查是否有该路由的访问权限 + const hasAccess = permissionStore.routesFlat.some( + route => route.route_path === to.path + ) + + if (!hasAccess && to.path !== '/403') { + next('/403') + return + } + + next() +}) +``` + +### 3. 按钮级权限控制 + +```typescript +// directives/permission.ts +import { usePermissionStore } from '@/stores/permission' + +export const vPermission = { + mounted(el: HTMLElement, binding: { value: string }) { + const permissionStore = usePermissionStore() + const permissionKey = binding.value + + if (!permissionStore.hasPermission(permissionKey)) { + el.parentNode?.removeChild(el) + } + } +} + +// main.ts +app.directive('permission', vPermission) +``` + +**使用示例**: + +```vue + +``` + +### 4. 组合式权限 Hook + +```typescript +// composables/usePermission.ts +import { computed } from 'vue' +import { usePermissionStore } from '@/stores/permission' + +export function usePermission() { + const store = usePermissionStore() + + // Dify 对话权限 + const canChat = computed(() => store.hasPermission('dify:chat:use')) + const canViewHistory = computed(() => store.hasPermission('dify:conversation:read')) + const canDeleteConversation = computed(() => store.hasPermission('dify:conversation:delete')) + const canFeedback = computed(() => store.hasPermission('dify:message:feedback')) + + // Dify 知识库权限 + const canViewDataset = computed(() => store.hasPermission('dify:dataset:read')) + const canEditDataset = computed(() => store.hasPermission('dify:dataset:write')) + const canManageDataset = computed(() => store.hasPermission('dify:dataset:manage')) + + // Dify 文件权限 + const canDownloadFile = computed(() => store.hasPermission('dify:file:read')) + const canUploadFile = computed(() => store.hasPermission('dify:file:upload')) + + return { + // 检查任意权限 + hasPermission: (key: string) => store.hasPermission(key), + + // 对话权限 + canChat, + canViewHistory, + canDeleteConversation, + canFeedback, + + // 知识库权限 + canViewDataset, + canEditDataset, + canManageDataset, + + // 文件权限 + canDownloadFile, + canUploadFile + } +} +``` + +**使用示例**: + +```vue + + + +``` + +--- + +## 错误处理 + +### HTTP 状态码 + +| 状态码 | 说明 | 处理方式 | +|--------|------|---------| +| 200 | 成功 | 正常处理 | +| 400 | 请求参数错误 | 显示错误信息 | +| 401 | 未认证 | 跳转登录页 | +| 403 | 权限不足 | 显示权限提示 | +| 404 | 资源不存在 | 显示不存在提示 | +| 500 | 服务器错误 | 显示系统错误提示 | + +### 统一错误处理 + +```typescript +// utils/request.ts +import axios from 'axios' +import { ElMessage } from 'element-plus' +import router from '@/router' + +const request = axios.create({ + baseURL: import.meta.env.VITE_API_BASE, + timeout: 30000 +}) + +// 请求拦截器 +request.interceptors.request.use(config => { + const token = localStorage.getItem('token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +// 响应拦截器 +request.interceptors.response.use( + response => response, + error => { + const { response } = error + + if (response) { + switch (response.status) { + case 401: + ElMessage.error('登录已过期,请重新登录') + localStorage.removeItem('token') + router.push('/login') + break + + case 403: + // 权限不足 + const errorData = response.data + ElMessage.error(errorData?.detail || '权限不足,无法执行此操作') + break + + case 404: + ElMessage.error('请求的资源不存在') + break + + case 500: + ElMessage.error('服务器错误,请稍后重试') + break + + default: + ElMessage.error(response.data?.message || '请求失败') + } + } else { + ElMessage.error('网络错误,请检查网络连接') + } + + return Promise.reject(error) + } +) + +export default request +``` + +### 权限错误特殊处理 + +```typescript +// 403 错误的详细信息格式 +interface PermissionError { + error: string // 如 "权限不足: dify:dataset:manage" + detail: string // 如 "您没有执行此操作的权限,请联系管理员" +} + +// 解析权限错误 +function parsePermissionError(error: any): string { + if (error.response?.status === 403) { + const data = error.response.data as PermissionError + + // 提取权限标识 + const match = data.error?.match(/权限不足: (.+)/) + if (match) { + const permissionKey = match[1] + const permissionLabels: Record = { + 'dify:chat:use': '使用AI对话', + 'dify:conversation:read': '查看对话历史', + 'dify:conversation:delete': '删除对话', + 'dify:dataset:read': '查看知识库', + 'dify:dataset:write': '编辑知识库', + 'dify:dataset:manage': '管理知识库绑定', + 'dify:file:read': '下载文件', + 'dify:file:upload': '上传文件' + } + return `您没有"${permissionLabels[permissionKey] || permissionKey}"的权限` + } + + return data.detail || '权限不足' + } + + return '操作失败' +} +``` + +--- + +## 完整代码示例 + +### 知识库管理页面完整实现 + +```vue + + + + + + +``` + +--- + +## 更新日志 + +| 版本 | 日期 | 说明 | +|------|------|------| +| 1.0 | 2025-12-06 | 初始版本,包含完整的前端对接说明 | From 3f5c23123b19567dd3b38f9dcea744659b44784d Mon Sep 17 00:00:00 2001 From: Wenyan Date: Mon, 8 Dec 2025 01:44:34 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E5=BA=94=E7=94=A8=E9=80=89=E6=8B=A9=E5=92=8C=E7=9F=A5?= =?UTF-8?q?=E8=AF=86=E5=BA=93=E5=88=87=E6=8D=A2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增对话应用管理模块(dify-chat-apps),支持获取和切换对话应用 - 优化对话应用切换后自动刷新会话列表功能 - 知识库管理页面新增下拉选择器,支持切换不同知识库 - API 层支持 app_id 参数传递,实现多应用会话隔离 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/api/dify-chat-apps/chatAppsApi.ts | 127 ++++++++++++++++ app/api/dify-chat-apps/types.ts | 41 ++++++ app/api/dify-chat/chat.ts | 11 +- app/api/dify-chat/client.server.ts | 23 ++- app/api/dify-chat/client.ts | 13 +- app/api/dify-chat/types.ts | 2 + app/api/v3/dify/area-datasets.ts | 40 +++-- app/components/dify-chat/index.tsx | 65 ++++++++- .../area-dataset-config.tsx | 42 +++--- app/components/dify-dataset-manager/index.tsx | 16 +- .../dify-dataset-manager/layout.tsx | 53 ++++++- app/hooks/dify-chat-apps/useChatApps.ts | 138 ++++++++++++++++++ app/hooks/dify-dataset-manager/index.ts | 112 ++++++++++++-- app/hooks/use-area-dataset-config.ts | 116 ++++++++++----- app/hooks/use-chat-message.ts | 7 + app/root.tsx | 4 +- app/routes/api.chat-messages.tsx | 5 +- app/routes/api.conversations.tsx | 8 +- app/routes/api.v3.dify.area-datasets.$id.tsx | 10 +- .../api.v3.dify.area-datasets.areas.tsx | 6 +- app/routes/api.v3.dify.area-datasets.my.tsx | 6 +- app/routes/api.v3.dify.area-datasets.tsx | 10 +- app/routes/api.v3.dify.chat-apps.default.tsx | 47 ++++++ app/routes/api.v3.dify.chat-apps.my.tsx | 48 ++++++ app/routes/chat-with-llm.dataset-manager.tsx | 66 +++++---- .../components/dify-dataset-manager/index.css | 67 +++++++++ app/types/dify-dataset-manager/layout.ts | 9 +- 27 files changed, 925 insertions(+), 167 deletions(-) create mode 100644 app/api/dify-chat-apps/chatAppsApi.ts create mode 100644 app/api/dify-chat-apps/types.ts create mode 100644 app/hooks/dify-chat-apps/useChatApps.ts create mode 100644 app/routes/api.v3.dify.chat-apps.default.tsx create mode 100644 app/routes/api.v3.dify.chat-apps.my.tsx diff --git a/app/api/dify-chat-apps/chatAppsApi.ts b/app/api/dify-chat-apps/chatAppsApi.ts new file mode 100644 index 0000000..e67d131 --- /dev/null +++ b/app/api/dify-chat-apps/chatAppsApi.ts @@ -0,0 +1,127 @@ +/** + * Dify 对话应用管理 API 模块 + * + * 提供浏览器端调用对话应用管理 API 的函数 + * 注意:这些 API 调用的是前端 Remix 路由(/api/...),不需要后端 baseURL + * + * @module api/dify-chat-apps/chatAppsApi + */ + +import type { + ChatApp, + MyChatAppsResponse, + DefaultChatAppResponse, +} from './types'; + +/** + * API 基础 URL(前端 Remix 路由) + */ +const API_URL = '/api/v3/dify/chat-apps'; + +/** + * HTTP 状态码对应的友好错误信息 + */ +const HTTP_ERROR_MESSAGES: Record = { + 400: '请求参数错误', + 401: '登录已过期,请重新登录', + 403: '您没有权限执行此操作', + 404: '请求的资源不存在', + 409: '数据冲突,该记录可能已存在', + 500: '服务器内部错误,请稍后重试', + 502: '网关错误,请稍后重试', + 503: '服务暂时不可用,请稍后重试', +}; + +/** + * 封装 fetch 请求,自动处理 credentials + */ +async function request(url: string, options: RequestInit = {}): Promise { + const response = await fetch(url, { + ...options, + credentials: 'include', // 包含 cookies + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + // 优先使用后端返回的错误信息,否则使用友好的默认信息 + const friendlyMessage = errorData.message + || errorData.error + || HTTP_ERROR_MESSAGES[response.status] + || '操作失败,请稍后重试'; + const error = new Error(friendlyMessage); + (error as any).response = { status: response.status, data: errorData }; + throw error; + } + + return response.json(); +} + +/** + * 获取当前实例配置的对话应用列表 + * + * 根据用户角色自动返回对应数据范围: + * - provincial_admin: 全部可用应用 + * - admin/common: 本地区可用应用 + * + * @returns 用户可访问的对话应用列表 + */ +export async function getMyChatApps(): Promise { + const response = await request(`${API_URL}/my`); + + // 兼容嵌套格式 { data: { data: [], total: ... } } + if (response?.data?.data) { + return { + data: response.data.data, + total: response.data.total || 0, + page: response.data.page || 1, + page_size: response.data.page_size || 10, + }; + } + // 格式 { data: [], total: ... } + if (response?.data && Array.isArray(response.data)) { + return response as MyChatAppsResponse; + } + // 直接返回 + if (Array.isArray(response?.data)) { + return { + data: response.data, + total: response.data.length, + page: 1, + page_size: response.data.length, + }; + } + + console.warn('[API] getMyChatApps: 无效的响应格式', response); + return { data: [], total: 0, page: 1, page_size: 10 }; +} + +/** + * 获取默认对话应用 + * + * 返回配置文件中的第一个应用作为默认应用 + * + * @returns 默认对话应用 + */ +export async function getDefaultChatApp(): Promise { + const response = await request(`${API_URL}/default`); + + // 兼容嵌套格式 { data: { data: {...} } } + if (response?.data?.data) { + return { + data: response.data.data, + }; + } + // 格式 { data: {...} } + if (response?.data) { + return { + data: response.data, + }; + } + + console.warn('[API] getDefaultChatApp: 无效的响应格式', response); + throw new Error('获取默认对话应用失败'); +} diff --git a/app/api/dify-chat-apps/types.ts b/app/api/dify-chat-apps/types.ts new file mode 100644 index 0000000..515dbc6 --- /dev/null +++ b/app/api/dify-chat-apps/types.ts @@ -0,0 +1,41 @@ +/** + * 对话应用类型定义 + */ +export interface ChatApp { + /** 应用ID */ + app_id: string; + /** 应用名称 */ + app_name: string; + /** 应用描述 */ + description: string; + /** 是否默认应用 */ + is_default: boolean; + /** 应用类型 */ + type: string; + /** 创建时间 */ + created_at: string; + /** 更新时间 */ + updated_at: string; +} + +/** + * 获取我的对话应用列表响应 + */ +export interface MyChatAppsResponse { + /** 应用列表 */ + data: ChatApp[]; + /** 总数 */ + total: number; + /** 分页页码 */ + page: number; + /** 每页数量 */ + page_size: number; +} + +/** + * 获取默认对话应用响应 + */ +export interface DefaultChatAppResponse { + /** 默认应用 */ + data: ChatApp; +} diff --git a/app/api/dify-chat/chat.ts b/app/api/dify-chat/chat.ts index 8a8d531..fb9801b 100644 --- a/app/api/dify-chat/chat.ts +++ b/app/api/dify-chat/chat.ts @@ -32,8 +32,11 @@ export const difyClient = { /** * 获取会话列表 + * + * @param jwt - JWT 认证令牌 + * @param appId - 对话应用 ID(可选,用于获取特定应用的会话列表) */ - async getConversations(jwt?: string): Promise { + async getConversations(jwt?: string, appId?: string): Promise { const params = new URLSearchParams({ limit: '100', first_id: '', @@ -41,6 +44,7 @@ export const difyClient = { const response = await difyFetch(`conversations?${params}`, { method: 'GET', + appId, // 传递应用 ID,会在请求头中添加 X-Dify-App-Id }, jwt); return response.json(); }, @@ -70,6 +74,7 @@ export const difyClient = { * @param conversationId - 会话 ID * @param files - 附件文件 * @param jwt - JWT 认证令牌 + * @param appId - 对话应用 ID(可选,用于切换不同的 Dify 应用) * @returns 对于流式响应返回 Response 对象,否则返回 JSON */ async createChatMessage( @@ -78,7 +83,8 @@ export const difyClient = { responseMode: string = 'streaming', conversationId?: string, files?: any[], - jwt?: string + jwt?: string, + appId?: string ): Promise { const body = { inputs, @@ -90,6 +96,7 @@ export const difyClient = { const response = await difyFetch('chat-messages', { method: 'POST', body: JSON.stringify(body), + appId, // 传递应用 ID,会在请求头中添加 X-Dify-App-Id }, jwt); // 对于流式响应,直接返回 Response 对象 diff --git a/app/api/dify-chat/client.server.ts b/app/api/dify-chat/client.server.ts index dc91a33..a993afa 100644 --- a/app/api/dify-chat/client.server.ts +++ b/app/api/dify-chat/client.server.ts @@ -27,6 +27,14 @@ const DIFY_CHAT_API_URL = `${API_BASE_URL}/dify_chat`; // 基础请求函数 // ============================================================================ +/** + * Dify Fetch 请求选项 + */ +export interface DifyFetchOptions extends RequestInit { + /** 对话应用 ID,用于切换不同的 Dify 应用 */ + appId?: string; +} + /** * Dify Chat API 基础请求函数 * @@ -34,20 +42,21 @@ const DIFY_CHAT_API_URL = `${API_BASE_URL}/dify_chat`; * FastAPI 后端会验证 JWT 并添加 Dify API_KEY * * @param endpoint - API 端点路径 - * @param options - fetch 请求选项 + * @param options - fetch 请求选项(可包含 appId) * @param jwt - 用户 JWT 认证令牌 * @returns Response 对象 */ export async function difyFetch( endpoint: string, - options: RequestInit = {}, + options: DifyFetchOptions = {}, jwt?: string ): Promise { + const { appId, ...fetchOptions } = options; const url = `${DIFY_CHAT_API_URL}/${endpoint.replace(/^\//, '')}`; const headers: HeadersInit = { 'Content-Type': 'application/json', - ...options.headers, + ...fetchOptions.headers, }; if (jwt) { @@ -56,8 +65,14 @@ export async function difyFetch( console.warn('[Dify Chat] 没有提供 JWT,FastAPI 请求可能失败'); } + // 如果指定了应用 ID,添加 X-Dify-App-Id 请求头 + if (appId) { + (headers as Record)['X-Dify-App-Id'] = appId; + console.log('[Dify Chat] 使用应用 ID:', appId); + } + const response = await fetch(url, { - ...options, + ...fetchOptions, headers, }); diff --git a/app/api/dify-chat/client.ts b/app/api/dify-chat/client.ts index 99d6243..44e3a53 100644 --- a/app/api/dify-chat/client.ts +++ b/app/api/dify-chat/client.ts @@ -51,6 +51,7 @@ const baseOptions: RequestInit = { /** * 获取用户的会话列表 * + * @param appId - 对话应用 ID(可选,用于获取特定应用的会话列表) * @returns 包含会话列表的响应对象 * @throws {Error} 当获取会话列表失败时抛出错误 * @@ -59,15 +60,23 @@ const baseOptions: RequestInit = { * const response = await fetchConversations(); * const conversations = response.data; * console.log('会话数量:', conversations.length); + * + * // 获取指定应用的会话列表 + * const appConversations = await fetchConversations('app-123'); * ``` */ -export async function fetchConversations(): Promise { +export async function fetchConversations(appId?: string): Promise { const params = new URLSearchParams({ limit: '100', }); + // 如果指定了 appId,添加到查询参数中 + if (appId) { + params.append('app_id', appId); + } + const url = `${API_URL}/conversations?${params}`; - console.log('📋 [Dify Client] 获取会话列表:', { url }); + console.log('📋 [Dify Client] 获取会话列表:', { url, appId }); try { const response = await axios.get(url, { diff --git a/app/api/dify-chat/types.ts b/app/api/dify-chat/types.ts index 59c936f..cba4967 100644 --- a/app/api/dify-chat/types.ts +++ b/app/api/dify-chat/types.ts @@ -521,6 +521,8 @@ export interface SendMessageParams { conversation_id?: string | null; files?: VisionFile[]; response_mode?: 'streaming' | 'blocking'; + /** 对话应用 ID,用于切换不同的 Dify 应用 */ + app_id?: string; } /** diff --git a/app/api/v3/dify/area-datasets.ts b/app/api/v3/dify/area-datasets.ts index 087ee51..0a10b58 100644 --- a/app/api/v3/dify/area-datasets.ts +++ b/app/api/v3/dify/area-datasets.ts @@ -4,7 +4,7 @@ * 提供地区-知识库绑定管理接口 */ -import { request } from '~/api/axios-client'; +import { get, post, put, del } from '~/api/axios-client'; // ==================== Type Definitions ==================== @@ -87,8 +87,8 @@ const API_BASE = '/api/v3/dify/area-datasets'; * 权限: dify:dataset:read */ export async function getMyDatasets(): Promise { - const response = await request.get(`${API_BASE}/my`); - return response.data; + const response = await get(`${API_BASE}/my`); + return response.data!; } /** @@ -101,19 +101,15 @@ export async function getAllDatasets(params: { page?: number; page_size?: number; }): Promise { - const queryParams = new URLSearchParams(); + const queryParams: Record = {}; - if (params.area) queryParams.append('area', params.area); - if (params.only_enabled !== undefined) - queryParams.append('only_enabled', String(params.only_enabled)); - if (params.page) queryParams.append('page', String(params.page)); - if (params.page_size) queryParams.append('page_size', String(params.page_size)); + if (params.area) queryParams.area = params.area; + if (params.only_enabled !== undefined) queryParams.only_enabled = params.only_enabled; + if (params.page) queryParams.page = params.page; + if (params.page_size) queryParams.page_size = params.page_size; - const response = await request.get( - `${API_BASE}?${queryParams.toString()}` - ); - - return response.data; + const response = await get(API_BASE, queryParams); + return response.data!; } /** @@ -121,8 +117,8 @@ export async function getAllDatasets(params: { * 权限: dify:dataset:manage */ export async function getAvailableAreas(): Promise { - const response = await request.get(`${API_BASE}/areas`); - return response.data.data; + const response = await get(`${API_BASE}/areas`); + return response.data?.data || []; } /** @@ -132,8 +128,8 @@ export async function getAvailableAreas(): Promise { export async function createDatasetBinding( data: CreateDatasetRequest ): Promise> { - const response = await request.post(`${API_BASE}`, data); - return response.data; + const response = await post>(API_BASE, data); + return response.data!; } /** @@ -144,8 +140,8 @@ export async function updateDatasetBinding( id: number, data: UpdateDatasetRequest ): Promise> { - const response = await request.put(`${API_BASE}/${id}`, data); - return response.data; + const response = await put>(`${API_BASE}/${id}`, data); + return response.data!; } /** @@ -153,6 +149,6 @@ export async function updateDatasetBinding( * 权限: dify:dataset:manage */ export async function deleteDatasetBinding(id: number): Promise> { - const response = await request.delete(`${API_BASE}/${id}`); - return response.data; + const response = await del>(`${API_BASE}/${id}`); + return response.data!; } diff --git a/app/components/dify-chat/index.tsx b/app/components/dify-chat/index.tsx index 4b1ccf4..ad46076 100644 --- a/app/components/dify-chat/index.tsx +++ b/app/components/dify-chat/index.tsx @@ -10,6 +10,7 @@ import { fetchAppParams, fetchChatList, fetchConversations } from '~/api/dify-ch import { CHAT_CONFIG } from '../../config/chat'; import useChatMessage from '../../hooks/use-chat-message'; import useConversation from '../../hooks/use-conversation'; +import { useChatApps } from '../../hooks/dify-chat-apps/useChatApps'; import '../../styles/components/chat-with-llm/index.css'; const { Content } = Layout; @@ -51,6 +52,14 @@ export default function Chat() { // 获取主题配置,避免SSR错误 const { colorBgContainer, borderRadiusLG } = useChatTheme(); + // 对话应用管理 + const { + chatApps, + loadingChatApps, + currentChatApp, + handleChatAppChange: originalHandleChatAppChange, + } = useChatApps(); + // 会话管理 const { conversationList, @@ -335,7 +344,8 @@ export default function Chat() { message: message.substring(0, 50) + (message.length > 50 ? '...' : ''), currConversationId, isNewConversation, - willSendConversationId: isNewConversation ? null : currConversationId + willSendConversationId: isNewConversation ? null : currConversationId, + appId: currentChatApp?.app_id }); try { @@ -347,12 +357,13 @@ export default function Chat() { }); } - // 使用 useChatMessage 钩子的 handleSend 方法 + // 使用 useChatMessage 钩子的 handleSend 方法,传递当前选中的应用 ID await handleSend( message, isNewConversation ? null : currConversationId, files, - toServerInputs + toServerInputs, + currentChatApp?.app_id // 传递对话应用 ID ); } catch (error) { @@ -389,6 +400,50 @@ export default function Chat() { createNewChat(); }; + /** + * 处理对话应用切换 + * 切换应用后刷新加载对应的会话列表 + */ + const handleChatAppChange = async (appId: string) => { + console.log('🔄 [Chat] 切换对话应用:', appId); + + // 调用原始的切换方法 + originalHandleChatAppChange(appId, async (app) => { + console.log('🔄 [Chat] 应用已切换到:', app.app_name, '开始刷新会话列表...'); + + try { + // 重新获取会话列表,传入新的应用ID获取该应用的会话 + const conversationData = await fetchConversations(app.app_id); + const conversations = (conversationData as any).data || []; + + console.log('📋 [Chat] 切换应用后获取到会话列表:', conversations.length, '条'); + + // 更新会话列表 + setConversationList(conversations); + + // 清空当前聊天,创建新会话 + setChatList([]); + setChatNotStarted(); + + // 如果有会话,选择第一个;否则创建新会话 + if (conversations.length > 0) { + const firstConversation = conversations[0]; + setCurrConversationId(firstConversation.id, app.app_id, false); + console.log('🎯 [Chat] 自动选择第一个会话:', firstConversation.id); + } else { + setCurrConversationId('-1', app.app_id, false); + console.log('🆕 [Chat] 无会话,创建新会话'); + } + } catch (error) { + console.error('❌ [Chat] 切换应用后刷新会话列表失败:', error); + // 即使刷新失败,也清空当前状态 + setConversationList([]); + setChatList([]); + setCurrConversationId('-1', appId, false); + } + }); + }; + /** * 处理会话删除后的状态更新 */ @@ -614,6 +669,10 @@ export default function Chat() { onNewConversation={handleNewConversation} onConversationDeleted={handleConversationDeleted} onConversationRenamed={handleConversationRenamed} + chatApps={chatApps} + loadingChatApps={loadingChatApps} + currentChatApp={currentChatApp} + onChatAppChange={handleChatAppChange} /> {/* 主内容区域 */} diff --git a/app/components/dify-dataset-manager/area-dataset-config.tsx b/app/components/dify-dataset-manager/area-dataset-config.tsx index 6873ada..f482750 100644 --- a/app/components/dify-dataset-manager/area-dataset-config.tsx +++ b/app/components/dify-dataset-manager/area-dataset-config.tsx @@ -65,9 +65,9 @@ export default function AreaDatasetConfig() { areas, // areasLoading, // 地区列表已加载hook中 - // 筛选 - filterArea, - setFilterArea, + // 筛选 - 多选 + filterAreas, + setFilterAreas, page, setPage, pageSize, @@ -233,24 +233,17 @@ export default function AreaDatasetConfig() { }; /** - * 处理地区筛选变化 + * 处理地区筛选变化 - 支持多选 */ - const handleAreaFilterChange = (value: string) => { - setFilterArea(value); + const handleAreaFilterChange = (values: string[]) => { + setFilterAreas(values); setPage(1); // 重置到第一页 }; // ==================== Render ==================== - // 计算用户角色标签 - const userRoleLabel = (() => { - const labels: Record = { - common: '普通用户', - admin: '市级管理员', - provincial_admin: '省级管理员', - }; - return labels[userRole] || '未知角色'; - })(); + // 用户角色已经在 hook 中处理好了,直接使用 userRole + const userRoleLabel = userRole || '未知角色'; // 表格列定义 const columns = [ @@ -456,16 +449,15 @@ export default function AreaDatasetConfig() { 地区筛选: ({ + options={Array.isArray(areas) ? areas.map((area) => ({ label: area, value: area, - }))} + })) : []} /> diff --git a/app/components/dify-dataset-manager/index.tsx b/app/components/dify-dataset-manager/index.tsx index 42b62fe..26eb1e1 100644 --- a/app/components/dify-dataset-manager/index.tsx +++ b/app/components/dify-dataset-manager/index.tsx @@ -4,6 +4,7 @@ import DocumentList from './document-list'; import DocumentDetail from './document-detail'; import RetrieveTest from './retrieve-test'; import DatasetSettings from './dataset-settings'; +import AreaDatasetConfig from './area-dataset-config'; import { useDatasetManager } from '~/hooks/dify-dataset-manager'; import '../../styles/components/dify-dataset-manager/index.css'; @@ -25,7 +26,11 @@ export default function DatasetManager() { error, activeTab, selectedDocument, - + + // 知识库列表(基于权限) + availableDatasets, + loadingAvailableDatasets, + // 方法 handlePageChange, handleDocumentDeleted, @@ -35,6 +40,7 @@ export default function DatasetManager() { handleBackToDocuments, handleTabChange, handleDatasetUpdated, + handleDatasetChange, } = useDatasetManager(); // 加载中状态 @@ -101,6 +107,11 @@ export default function DatasetManager() { return ; } + // 配置管理菜单 + if (activeTab === 'area-config') { + return ; + } + // 设置菜单 if (activeTab === 'settings') { return ( @@ -122,6 +133,9 @@ export default function DatasetManager() { onTabChange={handleTabChange} showBackButton={activeTab === 'documents' && !!selectedDocument} onBack={handleBackToDocuments} + availableDatasets={availableDatasets} + loadingAvailableDatasets={loadingAvailableDatasets} + onDatasetChange={handleDatasetChange} > {renderContent()} diff --git a/app/components/dify-dataset-manager/layout.tsx b/app/components/dify-dataset-manager/layout.tsx index 88e3fe4..dec9eb5 100644 --- a/app/components/dify-dataset-manager/layout.tsx +++ b/app/components/dify-dataset-manager/layout.tsx @@ -1,10 +1,12 @@ -import { Button, Tooltip } from 'antd'; +import { Button, Tooltip, Select, Spin } from 'antd'; import { FileTextOutlined, SearchOutlined, SettingOutlined, ArrowLeftOutlined, DatabaseOutlined, + AppstoreOutlined, + SwapOutlined, } from '@ant-design/icons'; import type { DatasetLayoutProps, MenuTab, MenuItem } from '~/types/dify-dataset-manager/layout'; @@ -19,28 +21,63 @@ export default function DatasetLayout({ showBackButton = false, onBack, children, + availableDatasets = [], + loadingAvailableDatasets = false, + onDatasetChange, }: DatasetLayoutProps) { const menuItems: MenuItem[] = [ { key: 'documents', icon: , label: '文档' }, { key: 'retrieve', icon: , label: '召回测试' }, + { key: 'area-config', icon: , label: '配置管理' }, { key: 'settings', icon: , label: '设置' }, ]; + // 是否显示知识库选择器(有多个知识库时显示) + const showDatasetSelector = availableDatasets.length > 1; + return (
{/* 左侧侧边栏 */}