Merge branch 'Wren' into shiy-login
# Conflicts: # app/config/api-config.ts
This commit is contained in:
@@ -17,3 +17,4 @@ docreview-frontend-deploy.tar.gz
|
||||
.database/
|
||||
.auth_doc/
|
||||
typecheck_result.txt
|
||||
*.DS_Store
|
||||
|
||||
@@ -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<number, string> = {
|
||||
400: '请求参数错误',
|
||||
401: '登录已过期,请重新登录',
|
||||
403: '您没有权限执行此操作',
|
||||
404: '请求的资源不存在',
|
||||
409: '数据冲突,该记录可能已存在',
|
||||
500: '服务器内部错误,请稍后重试',
|
||||
502: '网关错误,请稍后重试',
|
||||
503: '服务暂时不可用,请稍后重试',
|
||||
};
|
||||
|
||||
/**
|
||||
* 封装 fetch 请求,自动处理 credentials
|
||||
*/
|
||||
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
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<MyChatAppsResponse> {
|
||||
const response = await request<any>(`${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<DefaultChatAppResponse> {
|
||||
const response = await request<any>(`${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('获取默认对话应用失败');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -32,8 +32,11 @@ export const difyClient = {
|
||||
|
||||
/**
|
||||
* 获取会话列表
|
||||
*
|
||||
* @param jwt - JWT 认证令牌
|
||||
* @param appId - 对话应用 ID(可选,用于获取特定应用的会话列表)
|
||||
*/
|
||||
async getConversations(jwt?: string): Promise<any> {
|
||||
async getConversations(jwt?: string, appId?: string): Promise<any> {
|
||||
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<Response | any> {
|
||||
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 对象
|
||||
|
||||
@@ -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<Response> {
|
||||
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<string, string>)['X-Dify-App-Id'] = appId;
|
||||
console.log('[Dify Chat] 使用应用 ID:', appId);
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
...fetchOptions,
|
||||
headers,
|
||||
});
|
||||
|
||||
|
||||
@@ -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<ConversationsResponse> {
|
||||
export async function fetchConversations(appId?: string): Promise<ConversationsResponse> {
|
||||
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<ConversationsResponse>(url, {
|
||||
|
||||
@@ -30,6 +30,7 @@ export type {
|
||||
MessageMore,
|
||||
Feedbacktype,
|
||||
ThoughtItem,
|
||||
RetrieverResource,
|
||||
|
||||
// 文件类型
|
||||
VisionFile,
|
||||
|
||||
@@ -28,6 +28,29 @@ export interface ConversationItem {
|
||||
introduction?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检索资源类型 - 来自 RAG 的引用内容
|
||||
*/
|
||||
export interface RetrieverResource {
|
||||
position: number;
|
||||
dataset_id: string;
|
||||
dataset_name: string;
|
||||
document_id: string;
|
||||
document_name: string;
|
||||
data_source_type: string;
|
||||
segment_id: string;
|
||||
retriever_from: string;
|
||||
score: number;
|
||||
hit_count: number | null;
|
||||
word_count: number | null;
|
||||
segment_position: number | null;
|
||||
index_node_hash: string | null;
|
||||
content: string;
|
||||
page: number | null;
|
||||
doc_metadata: Record<string, any> | null;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天消息类型
|
||||
*/
|
||||
@@ -45,6 +68,7 @@ export interface ChatItem {
|
||||
useCurrentUserAvatar?: boolean;
|
||||
isOpeningStatement?: boolean;
|
||||
suggestedQuestions?: string[];
|
||||
retriever_resources?: RetrieverResource[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -336,6 +360,21 @@ export interface MessageEnd {
|
||||
task_id: string;
|
||||
conversation_id: string;
|
||||
message_id: string;
|
||||
id?: string;
|
||||
created_at?: number;
|
||||
metadata?: {
|
||||
annotation_reply?: any;
|
||||
retriever_resources?: RetrieverResource[];
|
||||
usage?: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
total_price: string;
|
||||
currency: string;
|
||||
latency: number;
|
||||
};
|
||||
};
|
||||
files?: any[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -482,6 +521,8 @@ export interface SendMessageParams {
|
||||
conversation_id?: string | null;
|
||||
files?: VisionFile[];
|
||||
response_mode?: 'streaming' | 'blocking';
|
||||
/** 对话应用 ID,用于切换不同的 Dify 应用 */
|
||||
app_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Dataset> {
|
||||
console.log('[Dataset Client] 更新知识库设置:', { datasetId, settings });
|
||||
|
||||
const response = await axios.patch<Dataset>(
|
||||
`${API_URL}/datasets/${datasetId}`,
|
||||
settings,
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
withCredentials: true,
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* V3 Dify Area Dataset API 模块
|
||||
*
|
||||
* 提供地区-知识库绑定管理接口
|
||||
*/
|
||||
|
||||
import { get, post, put, del } from '~/api/axios-client';
|
||||
|
||||
// ==================== Type Definitions ====================
|
||||
|
||||
export interface AreaDataset {
|
||||
id: number;
|
||||
area: string;
|
||||
dataset_id: string;
|
||||
dataset_name: string;
|
||||
dataset_description?: string;
|
||||
is_default: boolean;
|
||||
is_public: boolean;
|
||||
sort_order: number;
|
||||
status: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MyDatasetsResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: {
|
||||
data: AreaDataset[];
|
||||
total: number;
|
||||
user_area: string;
|
||||
user_role: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AllDatasetsResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: {
|
||||
data: AreaDataset[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
has_more: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AreasResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: {
|
||||
data: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateDatasetRequest {
|
||||
area: string;
|
||||
dataset_id: string;
|
||||
dataset_name: string;
|
||||
dataset_description?: string;
|
||||
is_default?: boolean;
|
||||
is_public?: boolean;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface UpdateDatasetRequest {
|
||||
dataset_name?: string;
|
||||
dataset_description?: string;
|
||||
is_default?: boolean;
|
||||
is_public?: boolean;
|
||||
sort_order?: number;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// ==================== API Functions ====================
|
||||
|
||||
const API_BASE = '/api/v3/dify/area-datasets';
|
||||
|
||||
/**
|
||||
* 获取当前用户可访问的知识库列表
|
||||
* 权限: dify:dataset:read
|
||||
*/
|
||||
export async function getMyDatasets(): Promise<MyDatasetsResponse> {
|
||||
const response = await get<MyDatasetsResponse>(`${API_BASE}/my`);
|
||||
return response.data!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有知识库绑定列表(管理员)
|
||||
* 权限: dify:dataset:manage
|
||||
*/
|
||||
export async function getAllDatasets(params: {
|
||||
area?: string;
|
||||
only_enabled?: boolean;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}): Promise<AllDatasetsResponse> {
|
||||
const queryParams: Record<string, string | number | boolean | undefined> = {};
|
||||
|
||||
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 get<AllDatasetsResponse>(API_BASE, queryParams);
|
||||
return response.data!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用地区列表(管理员)
|
||||
* 权限: dify:dataset:manage
|
||||
*/
|
||||
export async function getAvailableAreas(): Promise<string[]> {
|
||||
const response = await get<AreasResponse>(`${API_BASE}/areas`);
|
||||
return response.data?.data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建知识库绑定(管理员)
|
||||
* 权限: dify:dataset:manage
|
||||
*/
|
||||
export async function createDatasetBinding(
|
||||
data: CreateDatasetRequest
|
||||
): Promise<ApiResponse<{ data: AreaDataset }>> {
|
||||
const response = await post<ApiResponse<{ data: AreaDataset }>>(API_BASE, data);
|
||||
return response.data!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新知识库绑定(管理员)
|
||||
* 权限: dify:dataset:manage
|
||||
*/
|
||||
export async function updateDatasetBinding(
|
||||
id: number,
|
||||
data: UpdateDatasetRequest
|
||||
): Promise<ApiResponse<{ data: AreaDataset }>> {
|
||||
const response = await put<ApiResponse<{ data: AreaDataset }>>(`${API_BASE}/${id}`, data);
|
||||
return response.data!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除知识库绑定(管理员)
|
||||
* 权限: dify:dataset:manage
|
||||
*/
|
||||
export async function deleteDatasetBinding(id: number): Promise<ApiResponse<{ message: string }>> {
|
||||
const response = await del<ApiResponse<{ message: string }>>(`${API_BASE}/${id}`);
|
||||
return response.data!;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useState } from 'react';
|
||||
import type { ChatItem, Feedbacktype } from '~/api/dify-chat';
|
||||
import '../../styles/components/chat-with-llm/chat-message.css';
|
||||
import { parseMessageContent } from '../../utils/message-parser';
|
||||
import Markdown from './markdown';
|
||||
import Markdown, { SourcesPanel } from './markdown';
|
||||
import ThinkingBlock from './thinking-block';
|
||||
import ThoughtProcess from './thought-process';
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function ChatMessage({
|
||||
message.feedback?.rating || null
|
||||
);
|
||||
|
||||
const { id, content, isAnswer, agent_thoughts, message_files, isOpeningStatement, suggestedQuestions, more } = message;
|
||||
const { id, content, isAnswer, agent_thoughts, message_files, isOpeningStatement, suggestedQuestions, more, retriever_resources } = message;
|
||||
const isAgentMode = !!agent_thoughts && agent_thoughts.length > 0;
|
||||
|
||||
/**
|
||||
@@ -70,7 +70,10 @@ export default function ChatMessage({
|
||||
<div key={index}>
|
||||
{thought.thought && (
|
||||
<div className={isResponding && index === agent_thoughts.length - 1 ? 'streaming-text' : ''}>
|
||||
<Markdown content={thought.thought} />
|
||||
<Markdown
|
||||
content={thought.thought}
|
||||
retrieverResources={index === agent_thoughts.length - 1 ? retriever_resources : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{thought.tool && (
|
||||
@@ -98,7 +101,7 @@ export default function ChatMessage({
|
||||
{/* 实际回复内容 */}
|
||||
{parsed.response && (
|
||||
<div className={isResponding ? 'streaming-text' : ''}>
|
||||
<Markdown content={parsed.response} />
|
||||
<Markdown content={parsed.response} retrieverResources={retriever_resources} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -184,6 +187,12 @@ export default function ChatMessage({
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
{/* 引用来源面板 - 放在气泡外面 */}
|
||||
{isAnswer && retriever_resources && retriever_resources.length > 0 && (
|
||||
<div className="sources-panel-wrapper">
|
||||
<SourcesPanel resources={retriever_resources} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -21,6 +22,24 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
interface ChatTheme {
|
||||
colorBgContainer: string;
|
||||
borderRadiusLG: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取主题token - 避免在SSR环境中调用
|
||||
*/
|
||||
function useChatTheme(): ChatTheme {
|
||||
// Ant Design的theme.useToken()必须在组件顶层调用,不能放在useEffect中
|
||||
const antdToken = typeof window !== 'undefined' ? theme.useToken().token : null;
|
||||
|
||||
return {
|
||||
colorBgContainer: antdToken?.colorBgContainer || '#ffffff',
|
||||
borderRadiusLG: antdToken?.borderRadiusLG || 8,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 主聊天组件
|
||||
* 实现单页面应用模式,参考webapp-conversation的初始化逻辑
|
||||
@@ -29,9 +48,17 @@ export default function Chat() {
|
||||
// 侧边栏状态
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
// 获取主题配置,避免SSR错误
|
||||
const { colorBgContainer, borderRadiusLG } = useChatTheme();
|
||||
|
||||
// 对话应用管理
|
||||
const {
|
||||
token: { colorBgContainer, borderRadiusLG },
|
||||
} = theme.useToken();
|
||||
chatApps,
|
||||
loadingChatApps,
|
||||
currentChatApp,
|
||||
handleChatAppChange: originalHandleChatAppChange,
|
||||
} = useChatApps();
|
||||
|
||||
// 会话管理
|
||||
const {
|
||||
@@ -227,7 +254,7 @@ export default function Chat() {
|
||||
message_files: item.message_files?.filter((file: any) => file.belongs_to === 'user') || [],
|
||||
});
|
||||
|
||||
// 添加AI回答
|
||||
// 添加AI回答(包含检索资源)
|
||||
newChatList.push({
|
||||
id: item.id,
|
||||
content: item.answer,
|
||||
@@ -235,6 +262,7 @@ export default function Chat() {
|
||||
feedback: item.feedback,
|
||||
isAnswer: true,
|
||||
message_files: item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
|
||||
retriever_resources: item.retriever_resources || [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -316,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 {
|
||||
@@ -328,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) {
|
||||
@@ -370,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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理会话删除后的状态更新
|
||||
*/
|
||||
@@ -595,6 +669,10 @@ export default function Chat() {
|
||||
onNewConversation={handleNewConversation}
|
||||
onConversationDeleted={handleConversationDeleted}
|
||||
onConversationRenamed={handleConversationRenamed}
|
||||
chatApps={chatApps}
|
||||
loadingChatApps={loadingChatApps}
|
||||
currentChatApp={currentChatApp}
|
||||
onChatAppChange={handleChatAppChange}
|
||||
/>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
|
||||
@@ -1,89 +1,138 @@
|
||||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import RemarkMath from 'remark-math';
|
||||
import RemarkBreaks from 'remark-breaks';
|
||||
import RehypeKatex from 'rehype-katex';
|
||||
import RemarkGfm from 'remark-gfm';
|
||||
import { Sources } from '@ant-design/x';
|
||||
import XMarkdown, { type ComponentProps } from '@ant-design/x-markdown';
|
||||
import { Tooltip } from 'antd';
|
||||
import React, { useMemo } from 'react';
|
||||
import type { RetrieverResource } from '~/api/dify-chat';
|
||||
import '../../styles/components/chat-with-llm/markdown.css';
|
||||
|
||||
interface MarkdownProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
retrieverResources?: RetrieverResource[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown 渲染组件
|
||||
* 使用 react-markdown 库进行标准 Markdown 解析,支持流式渲染
|
||||
* 引用索引组件 - 在文本中显示可点击的引用标记
|
||||
*/
|
||||
export default function Markdown({ content, className = '' }: MarkdownProps) {
|
||||
// console.log('🎨 [Markdown] 渲染组件:', {
|
||||
// contentLength: content?.length || 0,
|
||||
// contentPreview: content?.substring(0, 100) + (content && content.length > 100 ? '...' : ''),
|
||||
// className,
|
||||
// hasContent: !!content
|
||||
// });
|
||||
const SourceRefComponent = React.memo(({
|
||||
children,
|
||||
resources
|
||||
}: ComponentProps & { resources?: RetrieverResource[] }) => {
|
||||
const refNumber = parseInt(`${children}` || '0', 10);
|
||||
|
||||
// 如果没有资源数据或引用号无效,只显示上标数字
|
||||
if (!resources || resources.length === 0 || refNumber <= 0) {
|
||||
return <sup className="source-ref-plain">[{children}]</sup>;
|
||||
}
|
||||
|
||||
// 查找对应的资源
|
||||
const resource = resources.find(r => r.position === refNumber);
|
||||
if (!resource) {
|
||||
return <sup className="source-ref-plain">[{children}]</sup>;
|
||||
}
|
||||
|
||||
// 构建引用项列表 - 显示完整内容
|
||||
const items = resources.map((r) => ({
|
||||
title: `${r.position}. ${r.document_name}`,
|
||||
key: r.position,
|
||||
description: r.content || '',
|
||||
}));
|
||||
|
||||
return (
|
||||
<Sources
|
||||
activeKey={refNumber}
|
||||
title={`[${children}]`}
|
||||
items={items}
|
||||
inline={true}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SourceRefComponent.displayName = 'SourceRefComponent';
|
||||
|
||||
/**
|
||||
* 引用来源面板组件 - 在消息下方显示所有引用(独立导出)
|
||||
*/
|
||||
export const SourcesPanel = React.memo(({ resources }: { resources: RetrieverResource[] }) => {
|
||||
if (!resources || resources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sources-panel">
|
||||
<div className="sources-panel-header">
|
||||
<i className="ri-file-text-line"></i>
|
||||
<span>引用来源</span>
|
||||
</div>
|
||||
<div className="sources-panel-list">
|
||||
{resources.map((resource) => (
|
||||
<Tooltip
|
||||
key={`${resource.segment_id}-${resource.position}`}
|
||||
title={
|
||||
<div className="source-tooltip">
|
||||
<div className="source-tooltip-header">
|
||||
<strong>{resource.document_name}</strong>
|
||||
</div>
|
||||
<div className="source-tooltip-content">
|
||||
{resource.content}
|
||||
</div>
|
||||
<div className="source-tooltip-meta">
|
||||
<span>相关度: {(resource.score * 100).toFixed(0)}%</span>
|
||||
<span style={{ marginLeft: 12 }}>来源: {resource.dataset_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
placement="topLeft"
|
||||
autoAdjustOverflow={false}
|
||||
color="rgba(0, 104, 74, 0.92)"
|
||||
classNames={{ root: 'source-tooltip-overlay' }}
|
||||
>
|
||||
<div className="source-item">
|
||||
<span className="source-item-number">{resource.position}</span>
|
||||
<span className="source-item-name">{resource.document_name}</span>
|
||||
<span className="source-item-score">
|
||||
{(resource.score * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SourcesPanel.displayName = 'SourcesPanel';
|
||||
|
||||
/**
|
||||
* Markdown 渲染组件
|
||||
* 使用 @ant-design/x-markdown 进行 Markdown 解析,支持流式渲染
|
||||
*/
|
||||
export default function Markdown({
|
||||
content,
|
||||
className = '',
|
||||
retrieverResources
|
||||
}: MarkdownProps) {
|
||||
// 创建自定义的 sup 组件,传入引用资源
|
||||
const customComponents = useMemo(() => {
|
||||
return {
|
||||
sup: (props: ComponentProps) => (
|
||||
<SourceRefComponent {...props} resources={retrieverResources} />
|
||||
),
|
||||
};
|
||||
}, [retrieverResources]);
|
||||
|
||||
if (!content) {
|
||||
console.log('⚠️ [Markdown] 内容为空,返回null');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`markdown-content ${className}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
|
||||
rehypePlugins={[RehypeKatex]}
|
||||
components={{
|
||||
code({ className, children, ...props }: any) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const isCodeBlock = match;
|
||||
|
||||
if (isCodeBlock) {
|
||||
// 代码块
|
||||
return (
|
||||
<pre style={{
|
||||
backgroundColor: '#f6f8fa',
|
||||
padding: '16px',
|
||||
borderRadius: '6px',
|
||||
overflow: 'auto',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.45',
|
||||
margin: '1em 0'
|
||||
}}>
|
||||
<code style={{
|
||||
backgroundColor: 'transparent',
|
||||
padding: '0',
|
||||
fontSize: 'inherit',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace'
|
||||
}}>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
} else {
|
||||
// 内联代码
|
||||
return (
|
||||
<code
|
||||
className={className}
|
||||
style={{
|
||||
backgroundColor: 'rgba(175, 184, 193, 0.2)',
|
||||
padding: '0.2em 0.4em',
|
||||
borderRadius: '3px',
|
||||
fontSize: '85%',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace'
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
},
|
||||
}}
|
||||
<XMarkdown
|
||||
components={customComponents}
|
||||
paragraphTag="div"
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</XMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,8 +9,9 @@ import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button, Dropdown, Input, Layout, Menu, Modal, Tooltip, message, theme } from 'antd';
|
||||
import { Button, Dropdown, Input, Layout, Menu, Modal, Tooltip, message, theme, Select } from 'antd';
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import type { ChatApp } from '~/api/dify-chat-apps/types';
|
||||
import type { ConversationItem } from '~/api/dify-chat';
|
||||
import { deleteConversation, renameConversation } from '~/api/dify-chat';
|
||||
import '../../styles/components/chat-with-llm/sidebar.css';
|
||||
@@ -25,6 +26,11 @@ interface ChatSidebarProps {
|
||||
onConversationSelect: (conversationId: string) => void;
|
||||
onNewConversation: () => void;
|
||||
onConversationDeleted?: (conversationId: string) => void;
|
||||
// 对话应用相关属性
|
||||
chatApps: ChatApp[];
|
||||
loadingChatApps: boolean;
|
||||
currentChatApp: ChatApp | null;
|
||||
onChatAppChange: (appId: string) => void;
|
||||
onConversationRenamed?: (conversationId: string, newName: string) => void;
|
||||
}
|
||||
|
||||
@@ -39,6 +45,10 @@ export interface ChatSidebarRef {
|
||||
const ChatSidebar = forwardRef<ChatSidebarRef, ChatSidebarProps>(({
|
||||
collapsed,
|
||||
onToggle,
|
||||
chatApps,
|
||||
loadingChatApps,
|
||||
currentChatApp,
|
||||
onChatAppChange,
|
||||
conversations,
|
||||
currentConversationId,
|
||||
onConversationSelect,
|
||||
@@ -281,6 +291,25 @@ const ChatSidebar = forwardRef<ChatSidebarRef, ChatSidebarProps>(({
|
||||
</div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
{/* 对话应用选择器 */}
|
||||
{!collapsed && (
|
||||
<div className="mb-3">
|
||||
<Select
|
||||
value={currentChatApp?.app_id}
|
||||
onChange={onChatAppChange}
|
||||
loading={loadingChatApps}
|
||||
className="w-full"
|
||||
placeholder="选择对话应用"
|
||||
size="small"
|
||||
>
|
||||
{(chatApps || []).map(app => (
|
||||
<Select.Option key={app.app_id} value={app.app_id}>
|
||||
{app.app_name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{!collapsed && (
|
||||
<Input
|
||||
placeholder="搜索对话..."
|
||||
|
||||
@@ -0,0 +1,654 @@
|
||||
/**
|
||||
* 知识库配置管理组件
|
||||
*
|
||||
* 提供地区-知识库绑定管理功能,包括增删改查
|
||||
*
|
||||
* @author 开发团队
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Button,
|
||||
Tag,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Switch,
|
||||
InputNumber,
|
||||
message,
|
||||
Flex,
|
||||
Typography,
|
||||
Popconfirm,
|
||||
Spin,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
InfoCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useAreaDatasetConfig } from '~/hooks/use-area-dataset-config';
|
||||
import { fetchDatasets } from '~/api/dify-dataset/api/datasetApi';
|
||||
import type { Dataset as DifyDataset } from '~/api/dify-dataset/type';
|
||||
import type { AreaDataset } from '~/api/v3/dify/area-datasets';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
// 颜色常量
|
||||
const colors = {
|
||||
bgLayout: '#f5f5f5',
|
||||
border: '#e8e8e8',
|
||||
text: '#262626',
|
||||
textSecondary: '#8c8c8c',
|
||||
primary: '#00684a',
|
||||
};
|
||||
|
||||
/**
|
||||
* 知识库配置管理组件
|
||||
*/
|
||||
export default function AreaDatasetConfig() {
|
||||
// 使用自定义hook获取数据和操作方法
|
||||
const {
|
||||
// 数据
|
||||
datasets,
|
||||
loading,
|
||||
total,
|
||||
userArea,
|
||||
userRole,
|
||||
areas,
|
||||
// areasLoading, // 地区列表已加载hook中
|
||||
|
||||
// 筛选 - 多选
|
||||
filterAreas,
|
||||
setFilterAreas,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
|
||||
// 表单状态
|
||||
modalVisible,
|
||||
setModalVisible,
|
||||
editingId,
|
||||
setEditingId,
|
||||
submitLoading,
|
||||
|
||||
// 操作方法
|
||||
loadDatasets,
|
||||
loadAreas,
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
handleDelete,
|
||||
|
||||
// 权限
|
||||
canManageDataset,
|
||||
} = useAreaDatasetConfig();
|
||||
|
||||
// 内部状态
|
||||
const [form] = Form.useForm();
|
||||
const [difyDatasets, setDifyDatasets] = useState<DifyDataset[]>([]);
|
||||
const [difyDatasetsLoading, setDifyDatasetsLoading] = useState<boolean>(false);
|
||||
const [difyDatasetsTotal, setDifyDatasetsTotal] = useState<number>(0);
|
||||
const [difyDatasetsPage, setDifyDatasetsPage] = useState<number>(1);
|
||||
const [isLoadingDifyDatasets, setIsLoadingDifyDatasets] = useState<boolean>(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<HTMLDivElement>) => {
|
||||
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 = (values: string[]) => {
|
||||
setFilterAreas(values);
|
||||
setPage(1); // 重置到第一页
|
||||
};
|
||||
|
||||
// ==================== Render ====================
|
||||
|
||||
// 用户角色已经在 hook 中处理好了,直接使用 userRole
|
||||
const userRoleLabel = 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) => (
|
||||
<Tag color={area === '省级' ? 'gold' : 'blue'}>{area}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '知识库名称',
|
||||
dataIndex: 'dataset_name',
|
||||
key: 'dataset_name',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (text: string) => (
|
||||
<Tooltip title={text}>
|
||||
<Text style={{ color: colors.text }} strong>
|
||||
{text}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '知识库ID',
|
||||
dataIndex: 'dataset_id',
|
||||
key: 'dataset_id',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (text: string) => (
|
||||
<Tooltip title={text}>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
{text.substring(0, 8)}...{text.substring(text.length - 4)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'dataset_description',
|
||||
key: 'dataset_description',
|
||||
ellipsis: true,
|
||||
render: (text: string) =>
|
||||
text ? (
|
||||
<Tooltip title={text}>
|
||||
<Text style={{ color: colors.text }}>
|
||||
{text.length > 30 ? text.substring(0, 30) + '...' : text}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text type="secondary">-</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '标签',
|
||||
key: 'tags',
|
||||
width: 120,
|
||||
render: (_: any, record: AreaDataset) => (
|
||||
<Space size="small">
|
||||
{record.is_public && (
|
||||
<Tag color="success" icon={<CheckCircleOutlined />}>
|
||||
公共
|
||||
</Tag>
|
||||
)}
|
||||
{record.is_default && (
|
||||
<Tag color={colors.primary} style={{ color: '#fff' }}>
|
||||
默认
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<Tag color={status === 1 ? 'success' : 'default'}>
|
||||
{status === 1 ? '启用' : '禁用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 150,
|
||||
render: (text: string) => (
|
||||
<Text style={{ color: colors.textSecondary, fontSize: '12px' }}>
|
||||
{new Date(text).toLocaleString('zh-CN')}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
// 操作列(仅管理员可见)
|
||||
...(canManageDataset
|
||||
? [
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
fixed: 'right' as const,
|
||||
render: (_: any, record: AreaDataset) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEditClick(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定删除?"
|
||||
description="删除后该地区的用户将无法访问此知识库"
|
||||
onConfirm={() => handleDeleteClick(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" danger size="small" icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
background: colors.bgLayout,
|
||||
padding: '16px',
|
||||
}}
|
||||
>
|
||||
{/* 页面头部 */}
|
||||
<Card
|
||||
style={{
|
||||
marginBottom: '16px',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
<Flex justify="space-between" align="center">
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0, marginBottom: '8px', color: colors.text }}>
|
||||
知识库配置管理
|
||||
</Title>
|
||||
<Flex gap="16px" align="center">
|
||||
<Text type="secondary">
|
||||
地区: <Text strong>{userArea || '-'}</Text>
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
角色: <Text strong>{userRoleLabel}</Text>
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
总数: <Text strong>{total}</Text>
|
||||
</Text>
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
{/* 仅管理员显示新增按钮 */}
|
||||
{canManageDataset && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleCreateClick}
|
||||
>
|
||||
新增绑定
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Card>
|
||||
|
||||
{/* 筛选区域(仅管理员可见) */}
|
||||
{canManageDataset && (
|
||||
<Card
|
||||
style={{
|
||||
marginBottom: '16px',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
<Flex gap="16px" align="center">
|
||||
<Text style={{ color: colors.text }}>地区筛选:</Text>
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ minWidth: '200px', maxWidth: '400px' }}
|
||||
placeholder="请选择地区(可多选)"
|
||||
allowClear
|
||||
value={filterAreas}
|
||||
onChange={handleAreaFilterChange}
|
||||
maxTagCount={3}
|
||||
maxTagPlaceholder={(omittedValues) => `+${omittedValues.length}个`}
|
||||
options={Array.isArray(areas) ? areas.map((area) => ({ label: area, value: area })) : []}
|
||||
/>
|
||||
</Flex>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 数据表格 */}
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={datasets}
|
||||
loading={loading}
|
||||
rowKey="id"
|
||||
pagination={
|
||||
canManageDataset
|
||||
? {
|
||||
current: page,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
onChange: (newPage) => setPage(newPage),
|
||||
showSizeChanger: false,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}
|
||||
: false
|
||||
}
|
||||
scroll={{ x: 'max-content' }}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Flex vertical align="center" justify="center" style={{ padding: '48px' }}>
|
||||
<InfoCircleOutlined style={{ fontSize: '48px', color: colors.textSecondary }} />
|
||||
<Text style={{ marginTop: '16px', color: colors.textSecondary }}>
|
||||
暂无知识库数据
|
||||
</Text>
|
||||
</Flex>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 新增/编辑对话框 */}
|
||||
<Modal
|
||||
title={editingId ? '编辑知识库绑定' : '新增知识库绑定'}
|
||||
open={modalVisible}
|
||||
onOk={() => form.submit()}
|
||||
onCancel={handleFormCancel}
|
||||
confirmLoading={submitLoading}
|
||||
width={600}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleFormSubmit}
|
||||
initialValues={{
|
||||
is_public: false,
|
||||
is_default: false,
|
||||
sort_order: 0,
|
||||
}}
|
||||
>
|
||||
{/* 地区选择(仅新增时可选) */}
|
||||
<Form.Item
|
||||
name="area"
|
||||
label="地区"
|
||||
rules={[{ required: true, message: '请选择地区' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择地区"
|
||||
disabled={!!editingId} // 编辑时禁用
|
||||
options={Array.isArray(areas) ? areas.map((area) => ({
|
||||
label: area,
|
||||
value: area,
|
||||
})) : []}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* Dify知识库选择(仅新增时可选) */}
|
||||
<Form.Item
|
||||
name="dataset_id"
|
||||
label="Dify知识库"
|
||||
rules={[{ required: true, message: '请选择Dify知识库' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择或输入知识库ID"
|
||||
disabled={!!editingId}
|
||||
loading={difyDatasetsLoading}
|
||||
onPopupScroll={handleDatasetSelectScroll}
|
||||
dropdownRender={(menu) => (
|
||||
<div>
|
||||
{menu}
|
||||
{difyDatasets.length < difyDatasetsTotal && (
|
||||
<div style={{ textAlign: 'center', padding: '8px' }}>
|
||||
<Spin size="small" />
|
||||
<Text style={{ marginLeft: '8px' }} type="secondary">
|
||||
加载中...
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
onDropdownVisibleChange={(open) => {
|
||||
if (open && !editingId) {
|
||||
loadDifyDatasets();
|
||||
}
|
||||
}}
|
||||
options={difyDatasets.map((ds) => ({
|
||||
label: (
|
||||
<Flex vertical>
|
||||
<Text strong>{ds.name}</Text>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
ID: {ds.id}
|
||||
</Text>
|
||||
</Flex>
|
||||
),
|
||||
value: ds.id,
|
||||
}))}
|
||||
dropdownStyle={{ maxHeight: '300px' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 知识库名称 */}
|
||||
<Form.Item
|
||||
name="dataset_name"
|
||||
label="知识库名称"
|
||||
rules={[
|
||||
{ required: true, message: '请输入知识库名称' },
|
||||
{ max: 255, message: '最多255个字符' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入知识库名称" />
|
||||
</Form.Item>
|
||||
|
||||
{/* 知识库描述 */}
|
||||
<Form.Item
|
||||
name="dataset_description"
|
||||
label="知识库描述"
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder="请输入知识库描述(可选)"
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 高级设置折叠面板 */}
|
||||
<div style={{ marginTop: '24px' }}>
|
||||
<Text strong style={{ color: colors.text, display: 'block', marginBottom: '16px' }}>
|
||||
高级设置
|
||||
</Text>
|
||||
|
||||
<Flex gap="24px">
|
||||
{/* 是否公开 */}
|
||||
<Form.Item
|
||||
name="is_public"
|
||||
label="公开知识库"
|
||||
valuePropName="checked"
|
||||
tooltip="公开后所有地区的用户都可以访问此知识库"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
{/* 是否默认 */}
|
||||
<Form.Item
|
||||
name="is_default"
|
||||
label="默认知识库"
|
||||
valuePropName="checked"
|
||||
tooltip="设置为该地区的默认知识库,用户优先使用"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
{/* 排序顺序 */}
|
||||
<Form.Item
|
||||
name="sort_order"
|
||||
label="排序顺序"
|
||||
tooltip="数值越小越靠前,范围0-100"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="0"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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: '精确关键字匹配' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 知识库设置组件
|
||||
* 用于修改知识库名称和描述
|
||||
@@ -18,11 +26,19 @@ export default function DatasetSettings({
|
||||
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 (
|
||||
<div className="settings-loading">
|
||||
@@ -81,12 +97,6 @@ export default function DatasetSettings({
|
||||
{dataset.indexing_technique === 'high_quality' ? '高质量' : '经济'}
|
||||
</span>
|
||||
</div>
|
||||
{/* <div className="info-item">
|
||||
<span className="info-label">Embedding 模型:</span>
|
||||
<span className="info-value">
|
||||
{dataset.embedding_model || '默认模型'}
|
||||
</span>
|
||||
</div> */}
|
||||
<div className="info-item">
|
||||
<span className="info-label">文档数量:</span>
|
||||
<span className="info-value">{dataset.document_count}</span>
|
||||
@@ -102,9 +112,153 @@ export default function DatasetSettings({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{/* 检索设置卡片 */}
|
||||
<Card className="settings-card" style={{ marginTop: 16 }}>
|
||||
<h3 style={{ marginBottom: 16, fontSize: 16, fontWeight: 500 }}>
|
||||
检索设置
|
||||
<Tooltip title="配置知识库的默认检索参数,影响召回效果">
|
||||
<QuestionCircleOutlined style={{ marginLeft: 8, color: '#8c8c8c', fontSize: 14 }} />
|
||||
</Tooltip>
|
||||
</h3>
|
||||
|
||||
{/* 检索方式 */}
|
||||
<div className="setting-item" style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>
|
||||
检索方式
|
||||
</label>
|
||||
<Select
|
||||
value={retrievalSettings.searchMethod}
|
||||
onChange={(value) => updateRetrievalSettings('searchMethod', value)}
|
||||
style={{ width: '100%' }}
|
||||
options={SEARCH_METHOD_OPTIONS.map(opt => ({
|
||||
value: opt.value,
|
||||
label: (
|
||||
<div>
|
||||
<span>{opt.label}</span>
|
||||
<span style={{ color: '#8c8c8c', fontSize: 12, marginLeft: 8 }}>
|
||||
{opt.description}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reranking 设置(语义检索和混合检索时显示,默认开启不可关闭) */}
|
||||
{showRerankingInfo && (
|
||||
<div className="setting-item" style={{ marginBottom: 16 }}>
|
||||
<Checkbox
|
||||
checked={true}
|
||||
disabled={true}
|
||||
>
|
||||
<span style={{ color: '#262626' }}>
|
||||
启用 Reranking 模型
|
||||
<CheckCircleFilled style={{ marginLeft: 6, color: '#52c41a', fontSize: 12 }} />
|
||||
</span>
|
||||
<Tooltip title="Reranking 模型已默认开启,用于对检索结果进行重新排序,提高相关性">
|
||||
<QuestionCircleOutlined style={{ marginLeft: 4, color: '#8c8c8c' }} />
|
||||
</Tooltip>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 混合检索权重设置 */}
|
||||
{showWeightsOption && (
|
||||
<div className="setting-item" style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>
|
||||
语义权重
|
||||
<Tooltip title="混合检索中语义检索的权重,值越大语义检索占比越高">
|
||||
<QuestionCircleOutlined style={{ marginLeft: 4, color: '#8c8c8c' }} />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={{ fontSize: 12, color: '#8c8c8c' }}>关键词</span>
|
||||
<Slider
|
||||
value={retrievalSettings.weights}
|
||||
onChange={(value) => updateRetrievalSettings('weights', value)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: '#8c8c8c' }}>语义</span>
|
||||
<InputNumber
|
||||
value={retrievalSettings.weights}
|
||||
onChange={(value) => updateRetrievalSettings('weights', value ?? 0.7)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
size="small"
|
||||
style={{ width: 70 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Top K 设置 */}
|
||||
<div className="setting-item" style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>
|
||||
返回数量 (Top K)
|
||||
<Tooltip title="每次检索返回的最大结果数量">
|
||||
<QuestionCircleOutlined style={{ marginLeft: 4, color: '#8c8c8c' }} />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Slider
|
||||
value={retrievalSettings.topK}
|
||||
onChange={(value) => updateRetrievalSettings('topK', value)}
|
||||
min={1}
|
||||
max={20}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<InputNumber
|
||||
value={retrievalSettings.topK}
|
||||
onChange={(value) => updateRetrievalSettings('topK', value ?? 3)}
|
||||
min={1}
|
||||
max={20}
|
||||
size="small"
|
||||
style={{ width: 70 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score 阈值设置(默认开启不可关闭,但可调节数值) */}
|
||||
<div className="setting-item" style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>
|
||||
Score 阈值
|
||||
<CheckCircleFilled style={{ marginLeft: 6, color: '#52c41a', fontSize: 12 }} />
|
||||
<Tooltip title="Score 阈值已默认开启,只返回相似度分数高于阈值的结果,过滤低质量匹配">
|
||||
<QuestionCircleOutlined style={{ marginLeft: 4, color: '#8c8c8c' }} />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Slider
|
||||
value={retrievalSettings.scoreThreshold}
|
||||
onChange={(value) => updateRetrievalSettings('scoreThreshold', value)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<InputNumber
|
||||
value={retrievalSettings.scoreThreshold}
|
||||
onChange={(value) => updateRetrievalSettings('scoreThreshold', value ?? 0.5)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
size="small"
|
||||
style={{ width: 70 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="form-actions">
|
||||
<div className="form-actions" style={{ marginTop: 24, display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Button onClick={handleReset} disabled={!hasChanges}>
|
||||
重置
|
||||
</Button>
|
||||
@@ -118,7 +272,6 @@ export default function DatasetSettings({
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -26,6 +27,10 @@ export default function DatasetManager() {
|
||||
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 <RetrieveTest datasetId={dataset?.id || ''} />;
|
||||
}
|
||||
|
||||
// 配置管理菜单
|
||||
if (activeTab === 'area-config') {
|
||||
return <AreaDatasetConfig />;
|
||||
}
|
||||
|
||||
// 设置菜单
|
||||
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()}
|
||||
</DatasetLayout>
|
||||
|
||||
@@ -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: <FileTextOutlined />, label: '文档' },
|
||||
{ key: 'retrieve', icon: <SearchOutlined />, label: '召回测试' },
|
||||
{ key: 'area-config', icon: <AppstoreOutlined />, label: '配置管理' },
|
||||
{ key: 'settings', icon: <SettingOutlined />, label: '设置' },
|
||||
];
|
||||
|
||||
// 是否显示知识库选择器(有多个知识库时显示)
|
||||
const showDatasetSelector = availableDatasets.length > 1;
|
||||
|
||||
return (
|
||||
<div className="dataset-layout">
|
||||
{/* 左侧侧边栏 */}
|
||||
<aside className="dataset-sidebar">
|
||||
{/* 知识库信息 */}
|
||||
{/* 知识库信息 / 选择器 */}
|
||||
<div className="sidebar-header">
|
||||
<div className="dataset-icon">
|
||||
<DatabaseOutlined />
|
||||
</div>
|
||||
{showDatasetSelector ? (
|
||||
/* 多个知识库时显示下拉选择器 */
|
||||
<div className="dataset-selector">
|
||||
<Select
|
||||
value={dataset?.id}
|
||||
onChange={onDatasetChange}
|
||||
loading={loadingAvailableDatasets}
|
||||
className="dataset-select"
|
||||
placeholder="选择知识库"
|
||||
suffixIcon={<SwapOutlined />}
|
||||
popupMatchSelectWidth={false}
|
||||
dropdownStyle={{ minWidth: 200 }}
|
||||
>
|
||||
{availableDatasets.map(ds => (
|
||||
<Select.Option key={ds.dataset_id} value={ds.dataset_id}>
|
||||
<div className="dataset-option">
|
||||
<span className="dataset-option-name">{ds.dataset_name}</span>
|
||||
{ds.is_default && <span className="dataset-option-tag">默认</span>}
|
||||
{ds.is_public && <span className="dataset-option-tag public">公共</span>}
|
||||
</div>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<span className="dataset-type">本地文档</span>
|
||||
</div>
|
||||
) : (
|
||||
/* 单个或无知识库时显示名称 */
|
||||
<div className="dataset-info">
|
||||
<Tooltip title={dataset?.name} placement="right">
|
||||
<h2 className="dataset-name">{dataset?.name || '知识库'}</h2>
|
||||
</Tooltip>
|
||||
<span className="dataset-type">本地文档</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 统计信息 */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SearchOutlined, FileSearchOutlined } from '@ant-design/icons';
|
||||
import { Button, Tag, Input, Slider, Spin, Select, Flex } from 'antd';
|
||||
import { Button, Tag, Input, Slider, Spin, Select, Flex, Switch, InputNumber, Tooltip } from 'antd';
|
||||
import type { RetrieveRecord } from '~/api/dify-dataset/type';
|
||||
import { useRetrieveTest } from '~/hooks/dify-dataset-manager/retrieve-test';
|
||||
import type { RetrieveTestProps } from '~/types/dify-dataset-manager/retrieve-test';
|
||||
@@ -97,6 +97,10 @@ export default function RetrieveTest({ datasetId }: RetrieveTestProps) {
|
||||
setSearchMethod,
|
||||
topK,
|
||||
setTopK,
|
||||
scoreThresholdEnabled,
|
||||
setScoreThresholdEnabled,
|
||||
scoreThreshold,
|
||||
setScoreThreshold,
|
||||
handleRetrieve,
|
||||
} = useRetrieveTest(datasetId);
|
||||
|
||||
@@ -229,6 +233,46 @@ export default function RetrieveTest({ datasetId }: RetrieveTestProps) {
|
||||
{topK}
|
||||
</span>
|
||||
</Flex>
|
||||
|
||||
{/* Score 阈值设置 */}
|
||||
<Flex align="center" gap={12}>
|
||||
<Tooltip title="开启后,只返回相似度分数高于阈值的结果">
|
||||
<span style={{
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary,
|
||||
whiteSpace: 'nowrap',
|
||||
cursor: 'help',
|
||||
}}>
|
||||
Score 阈值:
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={scoreThresholdEnabled}
|
||||
onChange={setScoreThresholdEnabled}
|
||||
/>
|
||||
{scoreThresholdEnabled && (
|
||||
<>
|
||||
<Slider
|
||||
value={scoreThreshold}
|
||||
onChange={setScoreThreshold}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<InputNumber
|
||||
value={scoreThreshold}
|
||||
onChange={(value) => setScoreThreshold(value ?? 0.5)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
size="small"
|
||||
style={{ width: 70 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
|
||||
@@ -44,9 +44,9 @@ const portConfigs: Record<string, Partial<ApiConfig>> = {
|
||||
// 主要
|
||||
// 梅州
|
||||
'51703': {
|
||||
baseUrl: 'http://172.16.0.78:8073',
|
||||
documentUrl: 'http://172.16.0.78:8073/docauditai/',
|
||||
uploadUrl: 'http://172.16.0.78:8073/admin/documents',
|
||||
baseUrl: 'http://172.16.0.56:8073',
|
||||
documentUrl: 'http://172.16.0.56:8073/docauditai/',
|
||||
uploadUrl: 'http://172.16.0.56:8073/admin/documents',
|
||||
|
||||
collaboraUrl: 'http://172.16.0.81:9980',
|
||||
appUrl: 'http://172.16.0.34:51703',
|
||||
@@ -146,9 +146,9 @@ const configs: Record<string, ApiConfig> = {
|
||||
|
||||
// 测试环境
|
||||
testing: {
|
||||
baseUrl: 'http://nas.7bm.co:8873', // FastAPI后端(包含/dify代理)
|
||||
documentUrl: 'http://nas.7bm.co:8873/docauditai/',
|
||||
uploadUrl: 'http://nas.7bm.co:8873/admin/documents',
|
||||
baseUrl: 'http://172.16.0.56:8073', // FastAPI后端(包含/dify代理)
|
||||
documentUrl: 'http://172.16.0.56:8073/docauditai/',
|
||||
uploadUrl: 'http://172.16.0.56:8073/admin/documents',
|
||||
collaboraUrl: 'http://10.79.97.17:9980',
|
||||
appUrl: 'http://10.79.97.17:51703',
|
||||
oauth: {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 对话应用管理钩子
|
||||
*
|
||||
* 提供对话应用的加载、切换和状态管理功能
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { getMyChatApps, getDefaultChatApp } from '~/api/dify-chat-apps/chatAppsApi';
|
||||
import type { ChatApp } from '~/api/dify-chat-apps/types';
|
||||
|
||||
export function useChatApps() {
|
||||
// 对话应用列表
|
||||
const [chatApps, setChatApps] = useState<ChatApp[]>([]);
|
||||
// 加载状态
|
||||
const [loadingChatApps, setLoadingChatApps] = useState(true);
|
||||
// 加载默认应用状态
|
||||
const [loadingDefault, setLoadingDefault] = useState(true);
|
||||
// 当前选中的应用
|
||||
const [currentChatApp, setCurrentChatApp] = useState<ChatApp | null>(null);
|
||||
// 错误信息
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// 初始化完成状态
|
||||
const [inited, setInited] = useState(false);
|
||||
|
||||
/**
|
||||
* 加载我的对话应用列表
|
||||
*/
|
||||
const loadChatApps = useCallback(async () => {
|
||||
setLoadingChatApps(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await getMyChatApps();
|
||||
setChatApps(response.data);
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
console.error('[useChatApps] 加载对话应用列表失败:', err);
|
||||
setChatApps([]);
|
||||
return [];
|
||||
} finally {
|
||||
setLoadingChatApps(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 加载默认对话应用
|
||||
*/
|
||||
const loadDefaultChatApp = useCallback(async () => {
|
||||
setLoadingDefault(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await getDefaultChatApp();
|
||||
// 如果加载所有应用失败,但成功加载了默认应用,将默认应用添加到chatApps数组中
|
||||
setChatApps(prev => [...prev, response.data]);
|
||||
setCurrentChatApp(response.data);
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
console.error('[useChatApps] 加载默认对话应用失败:', err);
|
||||
setError(err.message || '加载默认对话应用失败');
|
||||
return null;
|
||||
} finally {
|
||||
setLoadingDefault(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 切换对话应用
|
||||
* @param appId 应用ID
|
||||
* @param onAppChanged 切换完成后的回调函数
|
||||
*/
|
||||
const handleChatAppChange = useCallback((appId: string, onAppChanged?: (app: ChatApp) => void) => {
|
||||
const app = chatApps.find(chatApp => chatApp.app_id === appId);
|
||||
if (app) {
|
||||
console.log('[useChatApps] 切换对话应用:', app.app_name, app.app_id);
|
||||
setCurrentChatApp(app);
|
||||
// 切换应用后,调用回调函数
|
||||
if (onAppChanged) {
|
||||
onAppChanged(app);
|
||||
}
|
||||
}
|
||||
}, [chatApps]);
|
||||
|
||||
/**
|
||||
* 初始化对话应用
|
||||
*/
|
||||
const initializeChatApps = useCallback(async () => {
|
||||
setLoadingChatApps(true);
|
||||
setLoadingDefault(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
try {
|
||||
// 尝试加载可用应用列表
|
||||
const apps = await loadChatApps();
|
||||
|
||||
if (apps.length > 0) {
|
||||
// 查找默认应用
|
||||
const defaultApp = apps.find((item) => item.is_default) || apps[0];
|
||||
setCurrentChatApp(defaultApp);
|
||||
} else {
|
||||
// 如果没有配置应用,尝试获取默认应用
|
||||
await loadDefaultChatApp();
|
||||
}
|
||||
} catch (err) {
|
||||
// 加载应用列表失败,尝试获取默认应用
|
||||
console.warn('[useChatApps] 加载应用列表失败,尝试获取默认应用:', err);
|
||||
await loadDefaultChatApp();
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[useChatApps] 初始化失败:', err);
|
||||
setError(err.message || '加载对话应用失败');
|
||||
} finally {
|
||||
setLoadingChatApps(false);
|
||||
setLoadingDefault(false);
|
||||
setInited(true);
|
||||
}
|
||||
}, [loadChatApps, loadDefaultChatApp]);
|
||||
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
initializeChatApps();
|
||||
}, [initializeChatApps]);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
chatApps,
|
||||
loadingChatApps,
|
||||
currentChatApp,
|
||||
error,
|
||||
inited,
|
||||
|
||||
// 方法
|
||||
loadChatApps,
|
||||
loadDefaultChatApp,
|
||||
handleChatAppChange,
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,77 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { message } from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import type { Dataset } from '~/api/dify-dataset/type/datasetTypes';
|
||||
import { updateDatasetName } from '~/api/dify-dataset/api/datasetApi';
|
||||
import type { Dataset, RetrievalModel } from '~/api/dify-dataset/type/datasetTypes';
|
||||
import { updateDatasetSettings, fetchDataset } from '~/api/dify-dataset/api/datasetApi';
|
||||
import { DIFY_CONFIG } from '~/config/api-config';
|
||||
|
||||
/**
|
||||
* 检索方法类型
|
||||
*/
|
||||
export type SearchMethod = 'keyword_search' | 'semantic_search' | 'full_text_search' | 'hybrid_search';
|
||||
|
||||
/**
|
||||
* 检索设置表单值
|
||||
*/
|
||||
export interface RetrievalSettingsFormValues {
|
||||
searchMethod: SearchMethod;
|
||||
topK: number;
|
||||
scoreThresholdEnabled: boolean;
|
||||
scoreThreshold: number;
|
||||
rerankingEnable: boolean;
|
||||
weights: number; // 混合检索的语义权重 (0-1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认检索设置
|
||||
*/
|
||||
const DEFAULT_RETRIEVAL_SETTINGS: RetrievalSettingsFormValues = {
|
||||
searchMethod: 'semantic_search',
|
||||
topK: 3,
|
||||
scoreThresholdEnabled: true, // 默认开启
|
||||
scoreThreshold: 0.5,
|
||||
rerankingEnable: true, // 默认开启
|
||||
weights: 0.7,
|
||||
};
|
||||
|
||||
/**
|
||||
* 从 Dataset 的 retrieval_model 转换为表单值
|
||||
*/
|
||||
function retrievalModelToFormValues(model?: RetrievalModel): RetrievalSettingsFormValues {
|
||||
if (!model) {
|
||||
return { ...DEFAULT_RETRIEVAL_SETTINGS };
|
||||
}
|
||||
return {
|
||||
searchMethod: model.search_method || 'semantic_search',
|
||||
topK: model.top_k ?? 3,
|
||||
scoreThresholdEnabled: model.score_threshold_enabled ?? false,
|
||||
scoreThreshold: model.score_threshold ?? 0.5,
|
||||
rerankingEnable: model.reranking_enable ?? false,
|
||||
weights: model.weights ?? 0.7,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从表单值转换为 API 请求的 retrieval_model
|
||||
*/
|
||||
function formValuesToRetrievalModel(values: RetrievalSettingsFormValues): RetrievalModel {
|
||||
// 语义检索和混合检索需要 Reranking,强制开启
|
||||
const needReranking = values.searchMethod === 'semantic_search' || values.searchMethod === 'hybrid_search';
|
||||
|
||||
return {
|
||||
search_method: values.searchMethod,
|
||||
reranking_enable: needReranking, // 强制开启,不受用户控制
|
||||
reranking_mode: null,
|
||||
reranking_model: {
|
||||
reranking_provider_name: DIFY_CONFIG.rerankingProviderName,
|
||||
reranking_model_name: DIFY_CONFIG.rerankingModelName,
|
||||
},
|
||||
weights: values.searchMethod === 'hybrid_search' ? values.weights : null,
|
||||
top_k: values.topK,
|
||||
score_threshold_enabled: true, // 强制开启,不受用户控制
|
||||
score_threshold: values.scoreThreshold, // 用户可调节数值
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 知识库设置状态管理 Hook
|
||||
@@ -15,6 +84,11 @@ export function useDatasetSettings(
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// 检索设置状态(注意:Dify API 返回的字段名是 retrieval_model_dict)
|
||||
const [retrievalSettings, setRetrievalSettings] = useState<RetrievalSettingsFormValues>(
|
||||
() => retrievalModelToFormValues(dataset?.retrieval_model_dict)
|
||||
);
|
||||
|
||||
// 初始化表单数据
|
||||
useEffect(() => {
|
||||
if (dataset) {
|
||||
@@ -22,20 +96,53 @@ export function useDatasetSettings(
|
||||
name: dataset.name,
|
||||
description: dataset.description || '',
|
||||
});
|
||||
console.log('[DatasetSettings] 初始化检索设置, retrieval_model_dict:', dataset.retrieval_model_dict);
|
||||
setRetrievalSettings(retrievalModelToFormValues(dataset.retrieval_model_dict));
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [dataset, form]);
|
||||
|
||||
/**
|
||||
* 更新检索设置
|
||||
*/
|
||||
const updateRetrievalSettings = useCallback(<K extends keyof RetrievalSettingsFormValues>(
|
||||
key: K,
|
||||
value: RetrievalSettingsFormValues[K]
|
||||
) => {
|
||||
setRetrievalSettings(prev => {
|
||||
const newSettings = { ...prev, [key]: value };
|
||||
// 检查是否有变化
|
||||
checkForChanges(newSettings);
|
||||
return newSettings;
|
||||
});
|
||||
}, [dataset]);
|
||||
|
||||
/**
|
||||
* 检查是否有变化
|
||||
*/
|
||||
const checkForChanges = useCallback((newRetrievalSettings?: RetrievalSettingsFormValues) => {
|
||||
const values = form.getFieldsValue();
|
||||
const currentRetrieval = newRetrievalSettings || retrievalSettings;
|
||||
const originalRetrieval = retrievalModelToFormValues(dataset?.retrieval_model_dict);
|
||||
|
||||
const nameChanged = values.name !== dataset?.name;
|
||||
const retrievalChanged =
|
||||
currentRetrieval.searchMethod !== originalRetrieval.searchMethod ||
|
||||
currentRetrieval.topK !== originalRetrieval.topK ||
|
||||
currentRetrieval.scoreThresholdEnabled !== originalRetrieval.scoreThresholdEnabled ||
|
||||
currentRetrieval.scoreThreshold !== originalRetrieval.scoreThreshold ||
|
||||
currentRetrieval.rerankingEnable !== originalRetrieval.rerankingEnable ||
|
||||
currentRetrieval.weights !== originalRetrieval.weights;
|
||||
|
||||
setHasChanges(nameChanged || retrievalChanged);
|
||||
}, [form, dataset, retrievalSettings]);
|
||||
|
||||
/**
|
||||
* 处理表单值变化
|
||||
*/
|
||||
const handleValuesChange = useCallback(() => {
|
||||
const values = form.getFieldsValue();
|
||||
const changed =
|
||||
values.name !== dataset?.name ||
|
||||
values.description !== (dataset?.description || '');
|
||||
setHasChanges(changed);
|
||||
}, [form, dataset]);
|
||||
checkForChanges();
|
||||
}, [checkForChanges]);
|
||||
|
||||
/**
|
||||
* 保存设置
|
||||
@@ -50,11 +157,18 @@ export function useDatasetSettings(
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
|
||||
// 目前只支持修改名称
|
||||
const updatedDataset = await updateDatasetName(dataset.id, values.name);
|
||||
// 构建完整的更新请求
|
||||
await updateDatasetSettings(dataset.id, {
|
||||
name: values.name,
|
||||
retrieval_model: formValuesToRetrievalModel(retrievalSettings),
|
||||
});
|
||||
|
||||
// PATCH 接口返回的数据可能不完整,重新获取详情
|
||||
const fullDataset = await fetchDataset(dataset.id);
|
||||
console.log('[DatasetSettings] 保存后获取完整数据:', fullDataset);
|
||||
|
||||
message.success('保存成功');
|
||||
onDatasetUpdated(updatedDataset);
|
||||
onDatasetUpdated(fullDataset);
|
||||
setHasChanges(false);
|
||||
} catch (err: any) {
|
||||
console.error('保存设置失败:', err);
|
||||
@@ -62,7 +176,7 @@ export function useDatasetSettings(
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [dataset, form, onDatasetUpdated]);
|
||||
}, [dataset, form, retrievalSettings, onDatasetUpdated]);
|
||||
|
||||
/**
|
||||
* 重置表单
|
||||
@@ -73,6 +187,7 @@ export function useDatasetSettings(
|
||||
name: dataset.name,
|
||||
description: dataset.description || '',
|
||||
});
|
||||
setRetrievalSettings(retrievalModelToFormValues(dataset.retrieval_model_dict));
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [dataset, form]);
|
||||
@@ -81,11 +196,13 @@ export function useDatasetSettings(
|
||||
// 状态
|
||||
saving,
|
||||
hasChanges,
|
||||
retrievalSettings,
|
||||
|
||||
// 方法
|
||||
handleValuesChange,
|
||||
handleSave,
|
||||
handleReset,
|
||||
updateRetrievalSettings,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { message } from 'antd';
|
||||
import type { Dataset } from '~/api/dify-dataset/type/datasetTypes';
|
||||
import type { Document } from '~/api/dify-dataset/type/documentTypes';
|
||||
import { fetchDatasets } from '~/api/dify-dataset/api/datasetApi';
|
||||
import { fetchDatasets, fetchDataset } from '~/api/dify-dataset/api/datasetApi';
|
||||
import { fetchDocuments } from '~/api/dify-dataset/api/documentApi';
|
||||
import { getMyDatasets, type AreaDataset } from '~/api/v3/dify/area-datasets';
|
||||
import type { MenuTab } from '~/types/dify-dataset-manager/layout';
|
||||
import { DEFAULT_DOCUMENT_PAGE_SIZE } from '~/types/dify-dataset-manager/index';
|
||||
|
||||
@@ -15,6 +16,10 @@ export function useDatasetManager() {
|
||||
const [dataset, setDataset] = useState<Dataset | null>(null);
|
||||
const [loadingDataset, setLoadingDataset] = useState(true);
|
||||
|
||||
// 用户可访问的知识库列表(基于权限)
|
||||
const [availableDatasets, setAvailableDatasets] = useState<AreaDataset[]>([]);
|
||||
const [loadingAvailableDatasets, setLoadingAvailableDatasets] = useState(true);
|
||||
|
||||
// 文档状态
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
const [loadingDocuments, setLoadingDocuments] = useState(false);
|
||||
@@ -58,23 +63,97 @@ export function useDatasetManager() {
|
||||
}, [documentPageSize]);
|
||||
|
||||
/**
|
||||
* 加载知识库(获取第一个知识库)
|
||||
* 加载用户可访问的知识库列表(基于权限)
|
||||
*/
|
||||
const loadAvailableDatasets = useCallback(async () => {
|
||||
setLoadingAvailableDatasets(true);
|
||||
try {
|
||||
console.log('[DatasetManager] 加载用户可访问的知识库列表...');
|
||||
const response = await getMyDatasets();
|
||||
console.log('[DatasetManager] 用户知识库列表响应:', response);
|
||||
|
||||
if (response && response.code === 0 && response.data) {
|
||||
const dataList = Array.isArray(response.data.data) ? response.data.data : [];
|
||||
setAvailableDatasets(dataList);
|
||||
return dataList;
|
||||
} else {
|
||||
console.error('[DatasetManager] 获取用户知识库列表失败:', response);
|
||||
setAvailableDatasets([]);
|
||||
return [];
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[DatasetManager] 加载用户知识库列表失败:', err);
|
||||
setAvailableDatasets([]);
|
||||
return [];
|
||||
} finally {
|
||||
setLoadingAvailableDatasets(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 根据 dataset_id 加载知识库详情
|
||||
*/
|
||||
const loadDatasetById = useCallback(async (datasetId: string) => {
|
||||
setLoadingDataset(true);
|
||||
try {
|
||||
console.log('[DatasetManager] 加载知识库详情:', datasetId);
|
||||
const fullDataset = await fetchDataset(datasetId);
|
||||
console.log('[DatasetManager] 知识库详情响应:', fullDataset);
|
||||
|
||||
setDataset(fullDataset);
|
||||
// 立即加载文档
|
||||
await loadDocuments(datasetId, 1);
|
||||
} catch (err: any) {
|
||||
console.error('[DatasetManager] 加载知识库详情失败:', err);
|
||||
setError(err.message || '加载知识库失败');
|
||||
message.error('加载知识库失败');
|
||||
} finally {
|
||||
setLoadingDataset(false);
|
||||
}
|
||||
}, [loadDocuments]);
|
||||
|
||||
/**
|
||||
* 加载知识库(获取第一个知识库,再获取详情以包含 retrieval_model)
|
||||
*/
|
||||
const loadDataset = useCallback(async () => {
|
||||
setLoadingDataset(true);
|
||||
try {
|
||||
console.log('[DatasetManager] 加载知识库...');
|
||||
|
||||
// 先加载用户可访问的知识库列表
|
||||
const userDatasets = await loadAvailableDatasets();
|
||||
|
||||
if (userDatasets.length > 0) {
|
||||
// 找到默认知识库或第一个知识库
|
||||
const defaultDataset = userDatasets.find(ds => ds.is_default) || userDatasets[0];
|
||||
const datasetId = defaultDataset.dataset_id;
|
||||
|
||||
console.log('[DatasetManager] 使用知识库:', defaultDataset.dataset_name, datasetId);
|
||||
|
||||
// 获取知识库详情
|
||||
const fullDataset = await fetchDataset(datasetId);
|
||||
console.log('[DatasetManager] 知识库详情响应:', fullDataset);
|
||||
|
||||
setDataset(fullDataset);
|
||||
// 立即加载文档
|
||||
await loadDocuments(datasetId, 1);
|
||||
} else {
|
||||
// 回退到原有逻辑:直接从 Dify 获取
|
||||
console.log('[DatasetManager] 用户无绑定知识库,使用默认逻辑...');
|
||||
const response = await fetchDatasets(1, 1);
|
||||
console.log('[DatasetManager] 知识库响应:', response);
|
||||
console.log('[DatasetManager] Dify知识库列表响应:', response);
|
||||
|
||||
if (response && response.data && response.data.length > 0) {
|
||||
const firstDataset = response.data[0];
|
||||
setDataset(firstDataset);
|
||||
// 立即加载文档
|
||||
await loadDocuments(firstDataset.id, 1);
|
||||
const firstDatasetId = response.data[0].id;
|
||||
const fullDataset = await fetchDataset(firstDatasetId);
|
||||
console.log('[DatasetManager] 知识库详情响应:', fullDataset);
|
||||
|
||||
setDataset(fullDataset);
|
||||
await loadDocuments(firstDatasetId, 1);
|
||||
} else {
|
||||
setError('未找到知识库,请先在Dify中创建知识库');
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[DatasetManager] 加载知识库失败:', err);
|
||||
setError(err.message || '加载知识库失败');
|
||||
@@ -83,7 +162,19 @@ export function useDatasetManager() {
|
||||
setLoadingDataset(false);
|
||||
setInited(true);
|
||||
}
|
||||
}, [loadDocuments]);
|
||||
}, [loadDocuments, loadAvailableDatasets]);
|
||||
|
||||
/**
|
||||
* 切换知识库
|
||||
*/
|
||||
const handleDatasetChange = useCallback(async (datasetId: string) => {
|
||||
console.log('[DatasetManager] 切换知识库:', datasetId);
|
||||
// 重置状态
|
||||
setSelectedDocument(null);
|
||||
setActiveTab('documents');
|
||||
// 加载新知识库
|
||||
await loadDatasetById(datasetId);
|
||||
}, [loadDatasetById]);
|
||||
|
||||
/**
|
||||
* 处理文档页码变化
|
||||
@@ -185,6 +276,10 @@ export function useDatasetManager() {
|
||||
activeTab,
|
||||
selectedDocument,
|
||||
|
||||
// 知识库列表(基于权限)
|
||||
availableDatasets,
|
||||
loadingAvailableDatasets,
|
||||
|
||||
// 方法
|
||||
loadDataset,
|
||||
loadDocuments,
|
||||
@@ -196,6 +291,7 @@ export function useDatasetManager() {
|
||||
handleBackToDocuments,
|
||||
handleTabChange,
|
||||
handleDatasetUpdated,
|
||||
handleDatasetChange,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,12 @@ import type { SearchMethod } from '~/types/dify-dataset-manager/retrieve-test';
|
||||
* 构建完整的 retrieval_model 参数(匹配 Dify API 规范)
|
||||
* 根据检索方式启用 Reranking(语义搜索和混合搜索需要启用)
|
||||
*/
|
||||
function buildRetrievalModel(searchMethod: SearchMethod, topK: number): RetrievalModel {
|
||||
function buildRetrievalModel(
|
||||
searchMethod: SearchMethod,
|
||||
topK: number,
|
||||
scoreThresholdEnabled: boolean,
|
||||
scoreThreshold: number
|
||||
): RetrievalModel {
|
||||
// 语义搜索和混合搜索需要启用 Reranking
|
||||
const needReranking = searchMethod === 'semantic_search' || searchMethod === 'hybrid_search';
|
||||
|
||||
@@ -23,8 +28,8 @@ function buildRetrievalModel(searchMethod: SearchMethod, topK: number): Retrieva
|
||||
},
|
||||
weights: null,
|
||||
top_k: topK,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: null,
|
||||
score_threshold_enabled: scoreThresholdEnabled,
|
||||
score_threshold: scoreThresholdEnabled ? scoreThreshold : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,6 +43,9 @@ export function useRetrieveTest(datasetId: string) {
|
||||
// 默认使用语义搜索
|
||||
const [searchMethod, setSearchMethod] = useState<SearchMethod>('semantic_search');
|
||||
const [topK, setTopK] = useState<number>(5);
|
||||
// Score 阈值相关状态
|
||||
const [scoreThresholdEnabled, setScoreThresholdEnabled] = useState(false);
|
||||
const [scoreThreshold, setScoreThreshold] = useState<number>(0.5);
|
||||
|
||||
/**
|
||||
* 执行检索
|
||||
@@ -55,7 +63,7 @@ export function useRetrieveTest(datasetId: string) {
|
||||
|
||||
setRetrieving(true);
|
||||
try {
|
||||
const retrievalModel = buildRetrievalModel(searchMethod, topK);
|
||||
const retrievalModel = buildRetrievalModel(searchMethod, topK, scoreThresholdEnabled, scoreThreshold);
|
||||
console.log('[Hook] 检索参数:', { datasetId, query: searchQuery, retrievalModel });
|
||||
|
||||
const response = await retrieveDataset(datasetId, searchQuery, retrievalModel);
|
||||
@@ -69,7 +77,7 @@ export function useRetrieveTest(datasetId: string) {
|
||||
} finally {
|
||||
setRetrieving(false);
|
||||
}
|
||||
}, [datasetId, searchQuery, searchMethod, topK]);
|
||||
}, [datasetId, searchQuery, searchMethod, topK, scoreThresholdEnabled, scoreThreshold]);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
@@ -81,6 +89,10 @@ export function useRetrieveTest(datasetId: string) {
|
||||
setSearchMethod,
|
||||
topK,
|
||||
setTopK,
|
||||
scoreThresholdEnabled,
|
||||
setScoreThresholdEnabled,
|
||||
scoreThreshold,
|
||||
setScoreThreshold,
|
||||
|
||||
// 方法
|
||||
handleRetrieve,
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* 知识库配置管理 Hook
|
||||
*
|
||||
* 提供地区-知识库绑定管理功能
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } 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;
|
||||
|
||||
// 筛选 - 支持多选
|
||||
filterAreas: string[];
|
||||
setFilterAreas: (areas: 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<void>;
|
||||
loadAreas: () => Promise<void>;
|
||||
handleCreate: (data: CreateDatasetRequest) => Promise<boolean>;
|
||||
handleUpdate: (id: number, data: UpdateDatasetRequest) => Promise<boolean>;
|
||||
handleDelete: (id: number) => Promise<boolean>;
|
||||
|
||||
// 权限
|
||||
canManageDataset: boolean;
|
||||
}
|
||||
|
||||
// 角色名称映射
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
common: '普通用户',
|
||||
admin: '市级管理员',
|
||||
provincial_admin: '省级管理员',
|
||||
};
|
||||
|
||||
// ==================== Hook Implementation ====================
|
||||
|
||||
export function useAreaDatasetConfig(): UseAreaDatasetConfigReturn {
|
||||
// 权限控制
|
||||
const { userRole: permissionUserRole } = usePermission();
|
||||
|
||||
// 根据 userRole 判断权限
|
||||
// provincial_admin 可以管理所有知识库配置
|
||||
// 其他角色只能查看自己地区的配置
|
||||
const canManageDataset = permissionUserRole === 'provincial_admin' || permissionUserRole.toLowerCase().includes('provin');
|
||||
const canViewDataset = true; // 所有登录用户都可以查看
|
||||
|
||||
// 数据状态
|
||||
const [datasets, setDatasets] = useState<AreaDataset[]>([]);
|
||||
const [allDatasets, setAllDatasets] = useState<AreaDataset[]>([]); // 保存所有数据用于提取地区
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [total, setTotal] = useState<number>(0);
|
||||
const [userArea, setUserArea] = useState<string>('');
|
||||
const [apiAreas, setApiAreas] = useState<string[]>([]); // API 返回的地区列表
|
||||
const [areasLoading, setAreasLoading] = useState<boolean>(false);
|
||||
|
||||
// 筛选状态 - 支持多选
|
||||
const [filterAreas, setFilterAreas] = useState<string[]>([]);
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const pageSize = 20;
|
||||
|
||||
// 表单状态
|
||||
const [modalVisible, setModalVisible] = useState<boolean>(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [submitLoading, setSubmitLoading] = useState<boolean>(false);
|
||||
|
||||
// 从数据集中提取地区列表
|
||||
const extractedAreas = useMemo(() => {
|
||||
const areaSet = new Set<string>();
|
||||
allDatasets.forEach(ds => {
|
||||
if (ds.area) {
|
||||
areaSet.add(ds.area);
|
||||
}
|
||||
});
|
||||
return Array.from(areaSet).sort();
|
||||
}, [allDatasets]);
|
||||
|
||||
// 合并 API 返回的地区和从数据中提取的地区
|
||||
const areas = useMemo(() => {
|
||||
const areaSet = new Set<string>([...apiAreas, ...extractedAreas]);
|
||||
return Array.from(areaSet).sort();
|
||||
}, [apiAreas, extractedAreas]);
|
||||
|
||||
// 获取角色显示名称
|
||||
const userRoleLabel = ROLE_LABELS[permissionUserRole] || permissionUserRole || '未知角色';
|
||||
|
||||
// ==================== Data Loading ====================
|
||||
|
||||
/**
|
||||
* 加载知识库列表
|
||||
*/
|
||||
const loadDatasets = useCallback(async () => {
|
||||
if (!canViewDataset) {
|
||||
message.warning('您没有查看知识库的权限');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
let response;
|
||||
|
||||
if (canManageDataset) {
|
||||
// 省级管理员:获取所有知识库
|
||||
// 如果有多个地区筛选,用逗号分隔传递
|
||||
const areaFilter = filterAreas.length > 0 ? filterAreas.join(',') : undefined;
|
||||
response = await getAllDatasets({
|
||||
area: areaFilter,
|
||||
only_enabled: false, // 管理员可以看到所有状态
|
||||
page,
|
||||
page_size: pageSize,
|
||||
});
|
||||
} else {
|
||||
// 普通用户/市级管理员:获取我的知识库
|
||||
response = await getMyDatasets();
|
||||
}
|
||||
|
||||
console.log('[AreaDatasetConfig] API响应:', response);
|
||||
|
||||
if (response && response.code === 0 && response.data) {
|
||||
const dataList = Array.isArray(response.data.data) ? response.data.data : [];
|
||||
setDatasets(dataList);
|
||||
setTotal(response.data.total || dataList.length);
|
||||
|
||||
// 如果没有筛选,保存所有数据用于提取地区
|
||||
if (filterAreas.length === 0) {
|
||||
setAllDatasets(dataList);
|
||||
}
|
||||
|
||||
// 如果是 my 接口,保存用户信息
|
||||
if ('user_area' in response.data) {
|
||||
setUserArea((response.data as any).user_area || '');
|
||||
}
|
||||
} else {
|
||||
console.error('[AreaDatasetConfig] API响应格式错误:', response);
|
||||
message.error(`加载失败: ${response?.message || '未知错误'}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载知识库失败:', error);
|
||||
message.error('加载知识库失败,请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canManageDataset, canViewDataset, filterAreas, page, pageSize]);
|
||||
|
||||
/**
|
||||
* 加载地区列表(仅省级管理员)
|
||||
*/
|
||||
const loadAreas = useCallback(async () => {
|
||||
if (!canManageDataset) return;
|
||||
|
||||
setAreasLoading(true);
|
||||
try {
|
||||
const areasList = await getAvailableAreas();
|
||||
console.log('[AreaDatasetConfig] 地区列表响应:', areasList);
|
||||
if (Array.isArray(areasList)) {
|
||||
setApiAreas(areasList);
|
||||
} else {
|
||||
console.warn('[AreaDatasetConfig] 地区列表不是数组:', areasList);
|
||||
setApiAreas([]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载地区列表失败:', error);
|
||||
// 不显示错误提示,因为可以从数据中提取地区
|
||||
} finally {
|
||||
setAreasLoading(false);
|
||||
}
|
||||
}, [canManageDataset]);
|
||||
|
||||
// ==================== Operations ====================
|
||||
|
||||
/**
|
||||
* 创建知识库绑定
|
||||
*/
|
||||
const handleCreate = useCallback(
|
||||
async (data: CreateDatasetRequest): Promise<boolean> => {
|
||||
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<boolean> => {
|
||||
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<boolean> => {
|
||||
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();
|
||||
}, []); // 只在挂载时加载一次
|
||||
|
||||
// 加载地区列表
|
||||
useEffect(() => {
|
||||
loadAreas();
|
||||
}, [loadAreas]);
|
||||
|
||||
// 监听筛选条件和页码变化
|
||||
useEffect(() => {
|
||||
loadDatasets();
|
||||
}, [filterAreas, page]);
|
||||
|
||||
return {
|
||||
// 数据
|
||||
datasets,
|
||||
loading,
|
||||
total,
|
||||
userArea,
|
||||
userRole: userRoleLabel,
|
||||
areas,
|
||||
areasLoading,
|
||||
|
||||
// 筛选
|
||||
filterAreas,
|
||||
setFilterAreas,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
|
||||
// 表单状态
|
||||
modalVisible,
|
||||
setModalVisible,
|
||||
editingId,
|
||||
setEditingId,
|
||||
submitLoading,
|
||||
|
||||
// 操作方法
|
||||
loadDatasets,
|
||||
loadAreas,
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
handleDelete,
|
||||
|
||||
// 权限
|
||||
canManageDataset,
|
||||
};
|
||||
}
|
||||
@@ -143,12 +143,18 @@ export default function useChatMessage({
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
* @param message - 消息内容
|
||||
* @param conversationId - 会话 ID
|
||||
* @param files - 附件文件
|
||||
* @param inputs - 输入参数
|
||||
* @param appId - 对话应用 ID(可选,用于切换不同的 Dify 应用)
|
||||
*/
|
||||
const handleSend = useCallback(async (
|
||||
message: string,
|
||||
conversationId: string | null,
|
||||
files?: VisionFile[],
|
||||
inputs?: Record<string, any>,
|
||||
appId?: string,
|
||||
) => {
|
||||
if (!checkCanSend() || !message.trim()) {
|
||||
return;
|
||||
@@ -186,6 +192,7 @@ export default function useChatMessage({
|
||||
inputs: toServerInputs,
|
||||
query: message,
|
||||
conversation_id: conversationId === '-1' ? null : conversationId,
|
||||
app_id: appId, // 添加对话应用 ID
|
||||
};
|
||||
|
||||
// 添加文件数据
|
||||
@@ -411,8 +418,27 @@ export default function useChatMessage({
|
||||
},
|
||||
|
||||
onMessageEnd: (messageEnd: MessageEnd) => {
|
||||
// 处理消息结束事件
|
||||
// console.log('Message ended:', messageEnd);
|
||||
// 处理消息结束事件 - 获取检索资源
|
||||
console.log('📋 [useChatMessage] 消息结束事件:', {
|
||||
messageId: messageEnd.message_id,
|
||||
hasMetadata: !!messageEnd.metadata,
|
||||
hasRetrieverResources: !!messageEnd.metadata?.retriever_resources,
|
||||
resourceCount: messageEnd.metadata?.retriever_resources?.length || 0
|
||||
});
|
||||
|
||||
// 如果有检索资源,更新响应项
|
||||
if (messageEnd.metadata?.retriever_resources && messageEnd.metadata.retriever_resources.length > 0) {
|
||||
responseItem.retriever_resources = messageEnd.metadata.retriever_resources;
|
||||
|
||||
// 更新聊天列表
|
||||
updateCurrentQA({
|
||||
responseItem: { ...responseItem },
|
||||
questionId,
|
||||
placeholderAnswerId,
|
||||
questionItem,
|
||||
originalResponseId,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onMessageReplace: (messageReplace: MessageReplace) => {
|
||||
|
||||
+2
-2
@@ -288,8 +288,8 @@ export default function App() {
|
||||
|
||||
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<head suppressHydrationWarning>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<style dangerouslySetInnerHTML={{
|
||||
|
||||
@@ -91,6 +91,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
files,
|
||||
conversation_id: conversationId,
|
||||
response_mode: responseMode,
|
||||
app_id: appId, // 支持前端传递应用 ID
|
||||
} = body;
|
||||
|
||||
console.log('客戶端調用remix路由_Chat Messages API - 收到请求:', {
|
||||
@@ -98,6 +99,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
queryPreview: query?.substring(0, 100) + (query?.length > 100 ? '...' : ''),
|
||||
conversationId,
|
||||
responseMode,
|
||||
appId, // 记录应用 ID
|
||||
hasInputs: !!inputs,
|
||||
hasFiles: !!files && files.length > 0,
|
||||
filesCount: files?.length || 0,
|
||||
@@ -110,7 +112,8 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
responseMode,
|
||||
conversationId,
|
||||
files,
|
||||
frontendJWT // 传递 JWT
|
||||
frontendJWT, // 传递 JWT
|
||||
appId // 传递应用 ID
|
||||
);
|
||||
|
||||
// 对于流式响应,直接返回流
|
||||
|
||||
@@ -23,7 +23,13 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
);
|
||||
}
|
||||
|
||||
const data = await difyClient.getConversations(frontendJWT);
|
||||
// 从 URL 参数获取 app_id
|
||||
const url = new URL(request.url);
|
||||
const appId = url.searchParams.get('app_id') || undefined;
|
||||
|
||||
console.log('[API] Conversations - 获取会话列表:', { appId });
|
||||
|
||||
const data = await difyClient.getConversations(frontendJWT, appId);
|
||||
|
||||
return json(data, {
|
||||
headers: {
|
||||
|
||||
@@ -54,15 +54,22 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/dataset/datasets/:datasetId - 修改知识库名称
|
||||
* PATCH /api/dataset/datasets/:datasetId - 修改知识库设置
|
||||
*
|
||||
* Dify API: PATCH /datasets/{dataset_id}
|
||||
*
|
||||
* 请求体: { "name": "新的知识库名称" }
|
||||
* 请求体支持以下字段:
|
||||
* - name (string): 知识库名称(必填)
|
||||
* - retrieval_model (object): 检索模型配置(选填)
|
||||
* - search_method: 'keyword_search' | 'semantic_search' | 'full_text_search' | 'hybrid_search'
|
||||
* - reranking_enable: boolean
|
||||
* - reranking_model: { reranking_provider_name, reranking_model_name }
|
||||
* - weights: number | null (混合检索的语义权重)
|
||||
* - top_k: number
|
||||
* - score_threshold_enabled: boolean
|
||||
* - score_threshold: number | null
|
||||
*
|
||||
* 注意:
|
||||
* - 仅允许修改知识库名称,其他字段不开放修改
|
||||
* - 删除知识库功能不对外开放
|
||||
* 注意:删除知识库功能不对外开放
|
||||
*/
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
try {
|
||||
@@ -89,7 +96,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
if (method === 'PATCH') {
|
||||
const body = await request.json();
|
||||
|
||||
// 只允许修改 name 字段
|
||||
// name 是必填字段
|
||||
if (!body.name || typeof body.name !== 'string') {
|
||||
return new Response(
|
||||
JSON.stringify({ error: '请提供有效的知识库名称 (name)' }),
|
||||
@@ -97,17 +104,48 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
);
|
||||
}
|
||||
|
||||
// 只传递 name 字段,忽略其他字段
|
||||
const allowedBody = { name: body.name.trim() };
|
||||
|
||||
if (allowedBody.name.length === 0) {
|
||||
const trimmedName = body.name.trim();
|
||||
if (trimmedName.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: '知识库名称不能为空' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[API] Update Dataset Name:', { datasetId, name: allowedBody.name });
|
||||
// 构建允许的请求体
|
||||
const allowedBody: Record<string, any> = {
|
||||
name: trimmedName,
|
||||
};
|
||||
|
||||
// 可选: retrieval_model 检索模型配置
|
||||
if (body.retrieval_model && typeof body.retrieval_model === 'object') {
|
||||
const rm = body.retrieval_model;
|
||||
|
||||
// 验证 search_method
|
||||
const validSearchMethods = ['keyword_search', 'semantic_search', 'full_text_search', 'hybrid_search'];
|
||||
if (rm.search_method && !validSearchMethods.includes(rm.search_method)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: '无效的检索方法 (search_method)' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
allowedBody.retrieval_model = {
|
||||
search_method: rm.search_method,
|
||||
reranking_enable: rm.reranking_enable ?? false,
|
||||
reranking_mode: rm.reranking_mode ?? null,
|
||||
reranking_model: rm.reranking_model ?? {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
weights: rm.weights ?? null,
|
||||
top_k: rm.top_k ?? 3,
|
||||
score_threshold_enabled: rm.score_threshold_enabled ?? false,
|
||||
score_threshold: rm.score_threshold_enabled ? (rm.score_threshold ?? null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
console.log('[API] Update Dataset Settings:', { datasetId, body: allowedBody });
|
||||
|
||||
const apiUrl = `${API_BASE_URL}/dify_dataset/datasets/${datasetId}`;
|
||||
const response = await fetch(apiUrl, {
|
||||
|
||||
@@ -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';
|
||||
|
||||
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 fetch(apiUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${frontendJWT}`,
|
||||
},
|
||||
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 fetch(apiUrl, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
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 fetch(apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
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 fetch(apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
* 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 fetch(apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'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 fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${frontendJWT}`,
|
||||
},
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* GET /api/v3/dify/chat-apps/default - 获取默认对话应用
|
||||
*
|
||||
* 转发请求到后端 API,后端从配置文件读取默认对话应用
|
||||
* 参考文档:docs/new-dify/dify_api_doc.md - 对话应用多实例支持
|
||||
*/
|
||||
|
||||
import { LoaderFunctionArgs, json } from '@remix-run/node';
|
||||
import { API_BASE_URL } from '~/config/api-config';
|
||||
import { getUserSession } from '~/api/login/auth.server';
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
try {
|
||||
const { frontendJWT } = await getUserSession(request);
|
||||
|
||||
if (!frontendJWT) {
|
||||
return json(
|
||||
{ code: 401, message: 'JWT认证失败,请重新登录', data: null },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[API] Get Default Chat App - Forwarding to backend');
|
||||
|
||||
// 转发请求到后端
|
||||
const apiUrl = `${API_BASE_URL}/v3/dify/chat-apps/default`;
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${frontendJWT}`,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log('[API] Get Default Chat App - Backend response:', data);
|
||||
|
||||
return json(data, { status: response.status });
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[API] Get Default Chat App - Error:', error.message);
|
||||
return json(
|
||||
{ code: 500, message: error.message || 'Failed to get default chat app', data: null },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* GET /api/v3/dify/chat-apps/my - 获取当前用户可访问的对话应用列表
|
||||
*
|
||||
* 转发请求到后端 API,后端从配置文件读取对话应用列表
|
||||
* 参考文档:docs/new-dify/dify_api_doc.md - 对话应用多实例支持
|
||||
*/
|
||||
|
||||
import { LoaderFunctionArgs, json } from '@remix-run/node';
|
||||
import { API_BASE_URL } from '~/config/api-config';
|
||||
import { getUserSession } from '~/api/login/auth.server';
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
try {
|
||||
const { frontendJWT } = await getUserSession(request);
|
||||
|
||||
if (!frontendJWT) {
|
||||
return json(
|
||||
{ code: 401, message: 'JWT认证失败,请重新登录', data: { data: [], total: 0 } },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[API] Get My Chat Apps - Forwarding to backend');
|
||||
|
||||
// 转发请求到后端 - 使用正确的接口路径
|
||||
// 根据文档:GET /api/v3/dify/chat-apps
|
||||
const apiUrl = `${API_BASE_URL}/v3/dify/chat-apps`;
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${frontendJWT}`,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log('[API] Get My Chat Apps - Backend response:', data);
|
||||
|
||||
return json(data, { status: response.status });
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[API] Get My Chat Apps - Error:', error.message);
|
||||
return json(
|
||||
{ code: 500, message: error.message || 'Failed to get chat apps', data: { data: [], total: 0 } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,42 @@
|
||||
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 { Spin } from 'antd';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* 注册样式
|
||||
* 知识库管理页面
|
||||
* 动态加载 DatasetManager 组件避免 SSR 问题
|
||||
*/
|
||||
export function links() {
|
||||
return [
|
||||
{ rel: "stylesheet", href: datasetManagerStyles },
|
||||
// { rel: "stylesheet", href: sidebarStyles },
|
||||
// { rel: "stylesheet", href: documentListStyles },
|
||||
];
|
||||
}
|
||||
export default function DatasetManagerPage() {
|
||||
const [DatasetManager, setDatasetManager] = useState<React.ComponentType | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
/**
|
||||
* 知识库管理首页
|
||||
*/
|
||||
export default function DatasetManagerIndex() {
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
// 只在客户端动态导入组件
|
||||
import("~/components/dify-dataset-manager").then((mod) => {
|
||||
setDatasetManager(() => mod.default);
|
||||
}).catch(err => {
|
||||
console.error('加载知识库管理组件失败:', err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 服务端渲染时显示简单加载状态
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="dataset-manager-page" style={{ height: '90vh', padding: '16px' }}>
|
||||
<DatasetManager />
|
||||
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||||
<div>加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 客户端加载中
|
||||
if (!DatasetManager) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||||
<Spin size="large" />
|
||||
<p style={{ marginTop: 16 }}>正在加载知识库管理...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <DatasetManager />;
|
||||
}
|
||||
|
||||
@@ -299,3 +299,200 @@
|
||||
background-color: #a4e2ad;
|
||||
max-width: 65vh;
|
||||
}
|
||||
|
||||
/* ============================================================================ */
|
||||
/* 引用来源样式 - 绿白主题配色 */
|
||||
/* ============================================================================ */
|
||||
|
||||
/* 普通引用上标 */
|
||||
.source-ref-plain {
|
||||
color: #00684a;
|
||||
font-size: 0.75em;
|
||||
vertical-align: super;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 引用来源面板外层容器 */
|
||||
.sources-panel-wrapper {
|
||||
padding-left: 8px;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 引用来源面板 - 更紧凑的设计 */
|
||||
.sources-panel {
|
||||
padding: 6px 10px;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sources-panel-header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.sources-panel-header i {
|
||||
font-size: 12px;
|
||||
color: #00684a;
|
||||
}
|
||||
|
||||
.sources-panel-list {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 引用项 - 更小更紧凑 */
|
||||
.source-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
background: #fff;
|
||||
border: 1px solid #d1e7dd;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.source-item:hover {
|
||||
border-color: #00684a;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
/* 引用编号 - 使用绿色主题 */
|
||||
.source-item-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #00684a;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.source-item-name {
|
||||
color: #374151;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 得分显示 - 使用绿色渐变 */
|
||||
.source-item-score {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
color: #00684a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 引用来源 Tooltip 样式 - 绿色半透明主题 */
|
||||
.source-tooltip-overlay {
|
||||
max-width: 480px !important;
|
||||
}
|
||||
|
||||
.source-tooltip-overlay .ant-tooltip-inner {
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 104, 74, 0.25);
|
||||
}
|
||||
|
||||
.source-tooltip {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.source-tooltip-header {
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.source-tooltip-content {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
margin-bottom: 10px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.source-tooltip-content::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.source-tooltip-content::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.source-tooltip-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.source-tooltip-content::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.source-tooltip-meta {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Ant Design X Sources 组件样式覆盖 - 使用绿色主题 */
|
||||
.markdown-content .ant-x-sources {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.markdown-content .ant-x-sources-tag {
|
||||
font-size: 11px;
|
||||
padding: 0 3px;
|
||||
cursor: pointer;
|
||||
color: #00684a;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.markdown-content .ant-x-sources-tag:hover {
|
||||
color: #005a3f;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.sources-panel-wrapper {
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.sources-panel-list {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.source-item {
|
||||
max-width: 180px;
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,73 @@
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 知识库选择器 */
|
||||
.dataset-selector {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dataset-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dataset-select .ant-select-selector {
|
||||
border: 1px solid #e5e5e5 !important;
|
||||
border-radius: 6px !important;
|
||||
background: #fff !important;
|
||||
padding: 2px 8px !important;
|
||||
height: auto !important;
|
||||
min-height: 32px !important;
|
||||
}
|
||||
|
||||
.dataset-select .ant-select-selection-item {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.dataset-select:hover .ant-select-selector {
|
||||
border-color: rgb(0 104 74) !important;
|
||||
}
|
||||
|
||||
.dataset-select.ant-select-focused .ant-select-selector {
|
||||
border-color: rgb(0 104 74) !important;
|
||||
box-shadow: 0 0 0 2px rgba(0, 104, 74, 0.1) !important;
|
||||
}
|
||||
|
||||
/* 知识库下拉选项 */
|
||||
.dataset-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.dataset-option-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dataset-option-tag {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 104, 74, 0.1);
|
||||
color: rgb(0 104 74);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dataset-option-tag.public {
|
||||
background: rgba(24, 144, 255, 0.1);
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
/* 统计信息 */
|
||||
.sidebar-stats {
|
||||
padding: 0 16px 16px;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Dataset } from '~/api/dify-dataset/type/datasetTypes';
|
||||
import type { AreaDataset } from '~/api/v3/dify/area-datasets';
|
||||
|
||||
/**
|
||||
* 菜单项类型
|
||||
*/
|
||||
export type MenuTab = 'documents' | 'retrieve' | 'settings';
|
||||
export type MenuTab = 'documents' | 'retrieve' | 'area-config' | 'settings';
|
||||
|
||||
/**
|
||||
* 菜单项配置
|
||||
@@ -31,4 +32,10 @@ export interface DatasetLayoutProps {
|
||||
onBack?: () => void;
|
||||
/** 子组件 */
|
||||
children: ReactNode;
|
||||
/** 用户可访问的知识库列表(基于权限) */
|
||||
availableDatasets?: AreaDataset[];
|
||||
/** 加载知识库列表状态 */
|
||||
loadingAvailableDatasets?: boolean;
|
||||
/** 切换知识库回调 */
|
||||
onDatasetChange?: (datasetId: string) => void;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ export type SearchMethod = 'semantic_search' | 'full_text_search' | 'hybrid_sear
|
||||
export interface RetrieveOptions {
|
||||
searchMethod: SearchMethod;
|
||||
topK: number;
|
||||
scoreThresholdEnabled: boolean;
|
||||
scoreThreshold: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,4 +34,6 @@ export interface RetrieveTestState {
|
||||
retrieving: boolean;
|
||||
searchMethod: SearchMethod;
|
||||
topK: number;
|
||||
scoreThresholdEnabled: boolean;
|
||||
scoreThreshold: number;
|
||||
}
|
||||
|
||||
@@ -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 <token>"
|
||||
```
|
||||
|
||||
**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 <token>"
|
||||
```
|
||||
|
||||
**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 代理接口 |
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+51
@@ -59,6 +59,9 @@ importers:
|
||||
docx-preview:
|
||||
specifier: ^0.3.5
|
||||
version: 0.3.5
|
||||
docxtemplater:
|
||||
specifier: ^3.67.5
|
||||
version: 3.67.5
|
||||
dotenv:
|
||||
specifier: ^16.5.0
|
||||
version: 16.6.1
|
||||
@@ -95,6 +98,9 @@ importers:
|
||||
pg:
|
||||
specifier: ^8.14.1
|
||||
version: 8.16.3
|
||||
pizzip:
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0
|
||||
pm2:
|
||||
specifier: ^6.0.8
|
||||
version: 6.0.8
|
||||
@@ -1431,56 +1437,67 @@ packages:
|
||||
resolution: {integrity: sha512-n0edDmSHlXFhrlmTK7XBuwKlG5MbS7yleS1cQ9nn4kIeW+dJH+ExqNgQ0RrFRew8Y+0V/x6C5IjsHrJmiHtkxQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.44.1':
|
||||
resolution: {integrity: sha512-8WVUPy3FtAsKSpyk21kV52HCxB+me6YkbkFHATzC2Yd3yuqHwy2lbFL4alJOLXKljoRw08Zk8/xEj89cLQ/4Nw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.44.1':
|
||||
resolution: {integrity: sha512-yuktAOaeOgorWDeFJggjuCkMGeITfqvPgkIXhDqsfKX8J3jGyxdDZgBV/2kj/2DyPaLiX6bPdjJDTu9RB8lUPQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.44.1':
|
||||
resolution: {integrity: sha512-W+GBM4ifET1Plw8pdVaecwUgxmiH23CfAUj32u8knq0JPFyK4weRy6H7ooxYFD19YxBulL0Ktsflg5XS7+7u9g==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.44.1':
|
||||
resolution: {integrity: sha512-1zqnUEMWp9WrGVuVak6jWTl4fEtrVKfZY7CvcBmUUpxAJ7WcSowPSAWIKa/0o5mBL/Ij50SIf9tuirGx63Ovew==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.44.1':
|
||||
resolution: {integrity: sha512-Rl3JKaRu0LHIx7ExBAAnf0JcOQetQffaw34T8vLlg9b1IhzcBgaIdnvEbbsZq9uZp3uAH+JkHd20Nwn0h9zPjA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.44.1':
|
||||
resolution: {integrity: sha512-j5akelU3snyL6K3N/iX7otLBIl347fGwmd95U5gS/7z6T4ftK288jKq3A5lcFKcx7wwzb5rgNvAg3ZbV4BqUSw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.44.1':
|
||||
resolution: {integrity: sha512-ppn5llVGgrZw7yxbIm8TTvtj1EoPgYUAbfw0uDjIOzzoqlZlZrLJ/KuiE7uf5EpTpCTrNt1EdtzF0naMm0wGYg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.44.1':
|
||||
resolution: {integrity: sha512-Hu6hEdix0oxtUma99jSP7xbvjkUM/ycke/AQQ4EC5g7jNRLLIwjcNwaUy95ZKBJJwg1ZowsclNnjYqzN4zwkAw==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.44.1':
|
||||
resolution: {integrity: sha512-EtnsrmZGomz9WxK1bR5079zee3+7a+AdFlghyd6VbAjgRJDbTANJ9dcPIPAi76uG05micpEL+gPGmAKYTschQw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.44.1':
|
||||
resolution: {integrity: sha512-iAS4p+J1az6Usn0f8xhgL4PaU878KEtutP4hqw52I4IO6AGoyOkHCxcc4bqufv1tQLdDWFx8lR9YlwxKuv3/3g==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.44.1':
|
||||
resolution: {integrity: sha512-NtSJVKcXwcqozOl+FwI41OH3OApDyLk3kqTJgx8+gp6On9ZEt5mYhIsKNPGuaZr3p9T6NWPKGU/03Vw4CNU9qg==}
|
||||
@@ -1802,41 +1819,49 @@ packages:
|
||||
resolution: {integrity: sha512-UYA0MA8ajkEDCFRQdng/FVx3F6szBvk3EPnkTTQuuO9lV1kPGuTB+V9TmbDxy5ikaEgyWKxa4CI3ySjklZ9lFA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-arm64-musl@1.9.2':
|
||||
resolution: {integrity: sha512-P/CO3ODU9YJIHFqAkHbquKtFst0COxdphc8TKGL5yCX75GOiVpGqd1d15ahpqu8xXVsqP4MGFP2C3LRZnnL5MA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-ppc64-gnu@1.9.2':
|
||||
resolution: {integrity: sha512-uKStFlOELBxBum2s1hODPtgJhY4NxYJE9pAeyBgNEzHgTqTiVBPjfTlPFJkfxyTjQEuxZbbJlJnMCrRgD7ubzw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-gnu@1.9.2':
|
||||
resolution: {integrity: sha512-LkbNnZlhINfY9gK30AHs26IIVEZ9PEl9qOScYdmY2o81imJYI4IMnJiW0vJVtXaDHvBvxeAgEy5CflwJFIl3tQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-musl@1.9.2':
|
||||
resolution: {integrity: sha512-vI+e6FzLyZHSLFNomPi+nT+qUWN4YSj8pFtQZSFTtmgFoxqB6NyjxSjAxEC1m93qn6hUXhIsh8WMp+fGgxCoRg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-s390x-gnu@1.9.2':
|
||||
resolution: {integrity: sha512-sSO4AlAYhSM2RAzBsRpahcJB1msc6uYLAtP6pesPbZtptF8OU/CbCPhSRW6cnYOGuVmEmWVW5xVboAqCnWTeHQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-gnu@1.9.2':
|
||||
resolution: {integrity: sha512-jkSkwch0uPFva20Mdu8orbQjv2A3G88NExTN2oPTI1AJ+7mZfYW3cDCTyoH6OnctBKbBVeJCEqh0U02lTkqD5w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-musl@1.9.2':
|
||||
resolution: {integrity: sha512-Uk64NoiTpQbkpl+bXsbeyOPRpUoMdcUqa+hDC1KhMW7aN1lfW8PBlBH4mJ3n3Y47dYE8qi0XTxy1mBACruYBaw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-wasm32-wasi@1.9.2':
|
||||
resolution: {integrity: sha512-EpBGwkcjDicjR/ybC0g8wO5adPNdVuMrNalVgYcWi+gYtC1XYNuxe3rufcO7dA76OHGeVabcO6cSkPJKVcbCXQ==}
|
||||
@@ -1877,6 +1902,10 @@ packages:
|
||||
resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
'@xmldom/xmldom@0.9.8':
|
||||
resolution: {integrity: sha512-p96FSY54r+WJ50FIOsCOjyj/wavs8921hG5+kVMmZgKcvIKxMXHTrjNJvRgWa/zuX3B6t2lijLNFaOyuxUH+2A==}
|
||||
engines: {node: '>=14.6'}
|
||||
|
||||
'@zxing/text-encoding@0.9.0':
|
||||
resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==}
|
||||
|
||||
@@ -2698,6 +2727,10 @@ packages:
|
||||
docx-preview@0.3.5:
|
||||
resolution: {integrity: sha512-nod1jG5PkvzDIiZAcgAY4gSFQzgmAAChcuZH4Hj9dj7oCzscY3Hn8NfbUv7X7Jk4xL1lfKO113JLDhWKOt6fYw==}
|
||||
|
||||
docxtemplater@3.67.5:
|
||||
resolution: {integrity: sha512-Jnh9rdMf5sDmrfONs3nVDhZwVFxLNdP3RVHkqLQYA6eZLkWA+kx5aYy0cVmEqJcVIaFYf5JJEFdClD7fn6ZUng==}
|
||||
engines: {node: '>=0.10'}
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||
|
||||
@@ -4549,6 +4582,9 @@ packages:
|
||||
pako@1.0.11:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
|
||||
pako@2.1.0:
|
||||
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
|
||||
|
||||
parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -4692,6 +4728,9 @@ packages:
|
||||
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
pizzip@3.2.0:
|
||||
resolution: {integrity: sha512-X4NPNICxCfIK8VYhF6wbksn81vTiziyLbvKuORVAmolvnUzl1A1xmz9DAWKxPRq9lZg84pJOOAMq3OE61bD8IQ==}
|
||||
|
||||
pkg-types@1.3.1:
|
||||
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
|
||||
|
||||
@@ -7859,6 +7898,8 @@ snapshots:
|
||||
|
||||
'@xmldom/xmldom@0.8.10': {}
|
||||
|
||||
'@xmldom/xmldom@0.9.8': {}
|
||||
|
||||
'@zxing/text-encoding@0.9.0':
|
||||
optional: true
|
||||
|
||||
@@ -8756,6 +8797,10 @@ snapshots:
|
||||
dependencies:
|
||||
jszip: 3.10.1
|
||||
|
||||
docxtemplater@3.67.5:
|
||||
dependencies:
|
||||
'@xmldom/xmldom': 0.9.8
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
@@ -11385,6 +11430,8 @@ snapshots:
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
pako@2.1.0: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
@@ -11530,6 +11577,10 @@ snapshots:
|
||||
|
||||
pirates@4.0.7: {}
|
||||
|
||||
pizzip@3.2.0:
|
||||
dependencies:
|
||||
pako: 2.1.0
|
||||
|
||||
pkg-types@1.3.1:
|
||||
dependencies:
|
||||
confbox: 0.1.8
|
||||
|
||||
+16
-5
@@ -93,9 +93,20 @@ export default defineConfig({
|
||||
'@ant-design/x',
|
||||
],
|
||||
},
|
||||
// SSR 配置 - 排除只能在客户端运行的包
|
||||
// ssr: {
|
||||
// noExternal: [],
|
||||
// external: ['@monaco-editor/react', 'monaco-editor']
|
||||
// },
|
||||
// SSR 配置 - 解决 antd 6.0 和 @ant-design/x 的 ESM 模块解析问题
|
||||
ssr: {
|
||||
// 使用正则匹配所有 antd 和 rc-* 相关包
|
||||
noExternal: [/^antd/, /^@ant-design/, /^rc-/, /^@rc-component/],
|
||||
// 优化 SSR 依赖预构建
|
||||
optimizeDeps: {
|
||||
include: ['antd', '@ant-design/x', '@ant-design/x-markdown', '@ant-design/icons'],
|
||||
},
|
||||
},
|
||||
// 解决 antd ESM 导入问题
|
||||
resolve: {
|
||||
alias: [
|
||||
// 将 antd/lib 重定向到 antd/es
|
||||
{ find: /^antd\/lib\/(.*)/, replacement: 'antd/es/$1' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user