Merge branch 'Wren' into shiy-login

# Conflicts:
#	app/config/api-config.ts
This commit is contained in:
2025-12-08 09:36:55 +08:00
47 changed files with 5473 additions and 206 deletions
+1
View File
@@ -17,3 +17,4 @@ docreview-frontend-deploy.tar.gz
.database/
.auth_doc/
typecheck_result.txt
*.DS_Store
+127
View File
@@ -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('获取默认对话应用失败');
}
+41
View File
@@ -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;
}
+9 -2
View File
@@ -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 对象
+19 -4
View File
@@ -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] 没有提供 JWTFastAPI 请求可能失败');
}
// 如果指定了应用 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,
});
+11 -2
View File
@@ -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, {
+1
View File
@@ -30,6 +30,7 @@ export type {
MessageMore,
Feedbacktype,
ThoughtItem,
RetrieverResource,
// 文件类型
VisionFile,
+41
View File
@@ -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;
}
/**
+25 -1
View File
@@ -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;
}
+16 -2
View File
@@ -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;
}
/**
+154
View File
@@ -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!;
}
+13 -4
View File
@@ -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>
);
}
+84 -6
View File
@@ -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}
/>
{/* 主内容区域 */}
+117 -68
View File
@@ -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>
);
}
+30 -1
View File
@@ -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>
+39 -2
View File
@@ -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>
+6 -6
View File
@@ -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: {
+138
View File
@@ -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,
};
}
+104 -8
View File
@@ -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,
+348
View File
@@ -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,
};
}
+28 -2
View File
@@ -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
View File
@@ -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={{
+4 -1
View File
@@ -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
);
// 对于流式响应,直接返回流
+7 -1
View File
@@ -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: {
+49 -11
View File
@@ -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 }
);
}
}
+100
View File
@@ -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 }
);
}
}
+48
View File
@@ -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 }
);
}
}
+34 -18
View File
@@ -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;
+8 -1
View File
@@ -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;
}
+875
View File
@@ -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 知识库 ID1-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
+51
View File
@@ -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
View File
@@ -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' },
],
},
});