diff --git a/.gitignore b/.gitignore index 538486e..ac18449 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ docreview-frontend-deploy.tar.gz .database/ .auth_doc/ typecheck_result.txt +*.DS_Store diff --git a/app/api/dify-chat-apps/chatAppsApi.ts b/app/api/dify-chat-apps/chatAppsApi.ts new file mode 100644 index 0000000..e67d131 --- /dev/null +++ b/app/api/dify-chat-apps/chatAppsApi.ts @@ -0,0 +1,127 @@ +/** + * Dify 对话应用管理 API 模块 + * + * 提供浏览器端调用对话应用管理 API 的函数 + * 注意:这些 API 调用的是前端 Remix 路由(/api/...),不需要后端 baseURL + * + * @module api/dify-chat-apps/chatAppsApi + */ + +import type { + ChatApp, + MyChatAppsResponse, + DefaultChatAppResponse, +} from './types'; + +/** + * API 基础 URL(前端 Remix 路由) + */ +const API_URL = '/api/v3/dify/chat-apps'; + +/** + * HTTP 状态码对应的友好错误信息 + */ +const HTTP_ERROR_MESSAGES: Record = { + 400: '请求参数错误', + 401: '登录已过期,请重新登录', + 403: '您没有权限执行此操作', + 404: '请求的资源不存在', + 409: '数据冲突,该记录可能已存在', + 500: '服务器内部错误,请稍后重试', + 502: '网关错误,请稍后重试', + 503: '服务暂时不可用,请稍后重试', +}; + +/** + * 封装 fetch 请求,自动处理 credentials + */ +async function request(url: string, options: RequestInit = {}): Promise { + const response = await fetch(url, { + ...options, + credentials: 'include', // 包含 cookies + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + // 优先使用后端返回的错误信息,否则使用友好的默认信息 + const friendlyMessage = errorData.message + || errorData.error + || HTTP_ERROR_MESSAGES[response.status] + || '操作失败,请稍后重试'; + const error = new Error(friendlyMessage); + (error as any).response = { status: response.status, data: errorData }; + throw error; + } + + return response.json(); +} + +/** + * 获取当前实例配置的对话应用列表 + * + * 根据用户角色自动返回对应数据范围: + * - provincial_admin: 全部可用应用 + * - admin/common: 本地区可用应用 + * + * @returns 用户可访问的对话应用列表 + */ +export async function getMyChatApps(): Promise { + const response = await request(`${API_URL}/my`); + + // 兼容嵌套格式 { data: { data: [], total: ... } } + if (response?.data?.data) { + return { + data: response.data.data, + total: response.data.total || 0, + page: response.data.page || 1, + page_size: response.data.page_size || 10, + }; + } + // 格式 { data: [], total: ... } + if (response?.data && Array.isArray(response.data)) { + return response as MyChatAppsResponse; + } + // 直接返回 + if (Array.isArray(response?.data)) { + return { + data: response.data, + total: response.data.length, + page: 1, + page_size: response.data.length, + }; + } + + console.warn('[API] getMyChatApps: 无效的响应格式', response); + return { data: [], total: 0, page: 1, page_size: 10 }; +} + +/** + * 获取默认对话应用 + * + * 返回配置文件中的第一个应用作为默认应用 + * + * @returns 默认对话应用 + */ +export async function getDefaultChatApp(): Promise { + const response = await request(`${API_URL}/default`); + + // 兼容嵌套格式 { data: { data: {...} } } + if (response?.data?.data) { + return { + data: response.data.data, + }; + } + // 格式 { data: {...} } + if (response?.data) { + return { + data: response.data, + }; + } + + console.warn('[API] getDefaultChatApp: 无效的响应格式', response); + throw new Error('获取默认对话应用失败'); +} diff --git a/app/api/dify-chat-apps/types.ts b/app/api/dify-chat-apps/types.ts new file mode 100644 index 0000000..515dbc6 --- /dev/null +++ b/app/api/dify-chat-apps/types.ts @@ -0,0 +1,41 @@ +/** + * 对话应用类型定义 + */ +export interface ChatApp { + /** 应用ID */ + app_id: string; + /** 应用名称 */ + app_name: string; + /** 应用描述 */ + description: string; + /** 是否默认应用 */ + is_default: boolean; + /** 应用类型 */ + type: string; + /** 创建时间 */ + created_at: string; + /** 更新时间 */ + updated_at: string; +} + +/** + * 获取我的对话应用列表响应 + */ +export interface MyChatAppsResponse { + /** 应用列表 */ + data: ChatApp[]; + /** 总数 */ + total: number; + /** 分页页码 */ + page: number; + /** 每页数量 */ + page_size: number; +} + +/** + * 获取默认对话应用响应 + */ +export interface DefaultChatAppResponse { + /** 默认应用 */ + data: ChatApp; +} diff --git a/app/api/dify-chat/chat.ts b/app/api/dify-chat/chat.ts index 8a8d531..fb9801b 100644 --- a/app/api/dify-chat/chat.ts +++ b/app/api/dify-chat/chat.ts @@ -32,8 +32,11 @@ export const difyClient = { /** * 获取会话列表 + * + * @param jwt - JWT 认证令牌 + * @param appId - 对话应用 ID(可选,用于获取特定应用的会话列表) */ - async getConversations(jwt?: string): Promise { + async getConversations(jwt?: string, appId?: string): Promise { const params = new URLSearchParams({ limit: '100', first_id: '', @@ -41,6 +44,7 @@ export const difyClient = { const response = await difyFetch(`conversations?${params}`, { method: 'GET', + appId, // 传递应用 ID,会在请求头中添加 X-Dify-App-Id }, jwt); return response.json(); }, @@ -70,6 +74,7 @@ export const difyClient = { * @param conversationId - 会话 ID * @param files - 附件文件 * @param jwt - JWT 认证令牌 + * @param appId - 对话应用 ID(可选,用于切换不同的 Dify 应用) * @returns 对于流式响应返回 Response 对象,否则返回 JSON */ async createChatMessage( @@ -78,7 +83,8 @@ export const difyClient = { responseMode: string = 'streaming', conversationId?: string, files?: any[], - jwt?: string + jwt?: string, + appId?: string ): Promise { const body = { inputs, @@ -90,6 +96,7 @@ export const difyClient = { const response = await difyFetch('chat-messages', { method: 'POST', body: JSON.stringify(body), + appId, // 传递应用 ID,会在请求头中添加 X-Dify-App-Id }, jwt); // 对于流式响应,直接返回 Response 对象 diff --git a/app/api/dify-chat/client.server.ts b/app/api/dify-chat/client.server.ts index dc91a33..a993afa 100644 --- a/app/api/dify-chat/client.server.ts +++ b/app/api/dify-chat/client.server.ts @@ -27,6 +27,14 @@ const DIFY_CHAT_API_URL = `${API_BASE_URL}/dify_chat`; // 基础请求函数 // ============================================================================ +/** + * Dify Fetch 请求选项 + */ +export interface DifyFetchOptions extends RequestInit { + /** 对话应用 ID,用于切换不同的 Dify 应用 */ + appId?: string; +} + /** * Dify Chat API 基础请求函数 * @@ -34,20 +42,21 @@ const DIFY_CHAT_API_URL = `${API_BASE_URL}/dify_chat`; * FastAPI 后端会验证 JWT 并添加 Dify API_KEY * * @param endpoint - API 端点路径 - * @param options - fetch 请求选项 + * @param options - fetch 请求选项(可包含 appId) * @param jwt - 用户 JWT 认证令牌 * @returns Response 对象 */ export async function difyFetch( endpoint: string, - options: RequestInit = {}, + options: DifyFetchOptions = {}, jwt?: string ): Promise { + const { appId, ...fetchOptions } = options; const url = `${DIFY_CHAT_API_URL}/${endpoint.replace(/^\//, '')}`; const headers: HeadersInit = { 'Content-Type': 'application/json', - ...options.headers, + ...fetchOptions.headers, }; if (jwt) { @@ -56,8 +65,14 @@ export async function difyFetch( console.warn('[Dify Chat] 没有提供 JWT,FastAPI 请求可能失败'); } + // 如果指定了应用 ID,添加 X-Dify-App-Id 请求头 + if (appId) { + (headers as Record)['X-Dify-App-Id'] = appId; + console.log('[Dify Chat] 使用应用 ID:', appId); + } + const response = await fetch(url, { - ...options, + ...fetchOptions, headers, }); diff --git a/app/api/dify-chat/client.ts b/app/api/dify-chat/client.ts index 99d6243..44e3a53 100644 --- a/app/api/dify-chat/client.ts +++ b/app/api/dify-chat/client.ts @@ -51,6 +51,7 @@ const baseOptions: RequestInit = { /** * 获取用户的会话列表 * + * @param appId - 对话应用 ID(可选,用于获取特定应用的会话列表) * @returns 包含会话列表的响应对象 * @throws {Error} 当获取会话列表失败时抛出错误 * @@ -59,15 +60,23 @@ const baseOptions: RequestInit = { * const response = await fetchConversations(); * const conversations = response.data; * console.log('会话数量:', conversations.length); + * + * // 获取指定应用的会话列表 + * const appConversations = await fetchConversations('app-123'); * ``` */ -export async function fetchConversations(): Promise { +export async function fetchConversations(appId?: string): Promise { const params = new URLSearchParams({ limit: '100', }); + // 如果指定了 appId,添加到查询参数中 + if (appId) { + params.append('app_id', appId); + } + const url = `${API_URL}/conversations?${params}`; - console.log('📋 [Dify Client] 获取会话列表:', { url }); + console.log('📋 [Dify Client] 获取会话列表:', { url, appId }); try { const response = await axios.get(url, { diff --git a/app/api/dify-chat/index.ts b/app/api/dify-chat/index.ts index f690c41..6edca65 100644 --- a/app/api/dify-chat/index.ts +++ b/app/api/dify-chat/index.ts @@ -30,6 +30,7 @@ export type { MessageMore, Feedbacktype, ThoughtItem, + RetrieverResource, // 文件类型 VisionFile, diff --git a/app/api/dify-chat/types.ts b/app/api/dify-chat/types.ts index 8fa79de..cba4967 100644 --- a/app/api/dify-chat/types.ts +++ b/app/api/dify-chat/types.ts @@ -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 | 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; } /** diff --git a/app/api/dify-dataset/api/datasetApi.ts b/app/api/dify-dataset/api/datasetApi.ts index 036a788..fa3ebca 100644 --- a/app/api/dify-dataset/api/datasetApi.ts +++ b/app/api/dify-dataset/api/datasetApi.ts @@ -7,7 +7,7 @@ */ import axios from 'axios'; -import type { Dataset, DatasetsResponse } from '../type'; +import type { Dataset, DatasetsResponse, UpdateDatasetRequest } from '../type'; /** * API 基础 URL @@ -76,3 +76,27 @@ export async function updateDatasetName( ); return response.data; } + +/** + * 更新知识库设置(包含检索模型配置) + * + * @param datasetId - 知识库 ID + * @param settings - 更新的设置项 + * @returns 更新后的知识库详情 + */ +export async function updateDatasetSettings( + datasetId: string, + settings: UpdateDatasetRequest +): Promise { + console.log('[Dataset Client] 更新知识库设置:', { datasetId, settings }); + + const response = await axios.patch( + `${API_URL}/datasets/${datasetId}`, + settings, + { + headers: { 'Content-Type': 'application/json' }, + withCredentials: true, + } + ); + return response.data; +} diff --git a/app/api/dify-dataset/type/datasetTypes.ts b/app/api/dify-dataset/type/datasetTypes.ts index 65dd4ee..f504f9c 100644 --- a/app/api/dify-dataset/type/datasetTypes.ts +++ b/app/api/dify-dataset/type/datasetTypes.ts @@ -12,8 +12,8 @@ export interface Dataset { name: string; description: string; permission: 'only_me' | 'all_team_members'; - data_source_type: 'upload_file' | 'notion_import' | 'website_crawl'; - indexing_technique: 'high_quality' | 'economy'; + data_source_type: 'upload_file' | 'notion_import' | 'website_crawl' | null; + indexing_technique: 'high_quality' | 'economy' | null; app_count: number; document_count: number; word_count: number; @@ -21,6 +21,20 @@ export interface Dataset { created_at: number; updated_by: string; updated_at: number; + /** 嵌入模型提供商 */ + embedding_model_provider?: string | null; + /** 嵌入模型名称 */ + embedding_model?: string | null; + /** 嵌入模型是否可用 */ + embedding_available?: boolean; + /** 检索模型配置(Dify API 返回字段名为 retrieval_model_dict) */ + retrieval_model_dict?: RetrievalModel; + /** 标签 */ + tags?: string[]; + /** 文档形式 */ + doc_form?: string | null; + /** 供应商 */ + provider?: string; } /** diff --git a/app/api/v3/dify/area-datasets.ts b/app/api/v3/dify/area-datasets.ts new file mode 100644 index 0000000..0a10b58 --- /dev/null +++ b/app/api/v3/dify/area-datasets.ts @@ -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 { + code: number; + message: string; + data: T; +} + +// ==================== API Functions ==================== + +const API_BASE = '/api/v3/dify/area-datasets'; + +/** + * 获取当前用户可访问的知识库列表 + * 权限: dify:dataset:read + */ +export async function getMyDatasets(): Promise { + const response = await get(`${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 { + const queryParams: Record = {}; + + 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(API_BASE, queryParams); + return response.data!; +} + +/** + * 获取可用地区列表(管理员) + * 权限: dify:dataset:manage + */ +export async function getAvailableAreas(): Promise { + const response = await get(`${API_BASE}/areas`); + return response.data?.data || []; +} + +/** + * 创建知识库绑定(管理员) + * 权限: dify:dataset:manage + */ +export async function createDatasetBinding( + data: CreateDatasetRequest +): Promise> { + const response = await post>(API_BASE, data); + return response.data!; +} + +/** + * 更新知识库绑定(管理员) + * 权限: dify:dataset:manage + */ +export async function updateDatasetBinding( + id: number, + data: UpdateDatasetRequest +): Promise> { + const response = await put>(`${API_BASE}/${id}`, data); + return response.data!; +} + +/** + * 删除知识库绑定(管理员) + * 权限: dify:dataset:manage + */ +export async function deleteDatasetBinding(id: number): Promise> { + const response = await del>(`${API_BASE}/${id}`); + return response.data!; +} diff --git a/app/components/dify-chat/chat-message.tsx b/app/components/dify-chat/chat-message.tsx index a481c50..8e5207d 100644 --- a/app/components/dify-chat/chat-message.tsx +++ b/app/components/dify-chat/chat-message.tsx @@ -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({
{thought.thought && (
- +
)} {thought.tool && ( @@ -98,7 +101,7 @@ export default function ChatMessage({ {/* 实际回复内容 */} {parsed.response && (
- +
)}
@@ -184,6 +187,12 @@ export default function ChatMessage({ + {/* 引用来源面板 - 放在气泡外面 */} + {isAnswer && retriever_resources && retriever_resources.length > 0 && ( +
+ +
+ )} ); } \ No newline at end of file diff --git a/app/components/dify-chat/index.tsx b/app/components/dify-chat/index.tsx index d692aee..ad46076 100644 --- a/app/components/dify-chat/index.tsx +++ b/app/components/dify-chat/index.tsx @@ -10,6 +10,7 @@ import { fetchAppParams, fetchChatList, fetchConversations } from '~/api/dify-ch import { CHAT_CONFIG } from '../../config/chat'; import useChatMessage from '../../hooks/use-chat-message'; import useConversation from '../../hooks/use-conversation'; +import { useChatApps } from '../../hooks/dify-chat-apps/useChatApps'; import '../../styles/components/chat-with-llm/index.css'; const { Content } = Layout; @@ -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} /> {/* 主内容区域 */} diff --git a/app/components/dify-chat/markdown.tsx b/app/components/dify-chat/markdown.tsx index 3eb51af..0b01df8 100644 --- a/app/components/dify-chat/markdown.tsx +++ b/app/components/dify-chat/markdown.tsx @@ -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 [{children}]; + } + + // 查找对应的资源 + const resource = resources.find(r => r.position === refNumber); + if (!resource) { + return [{children}]; + } + + // 构建引用项列表 - 显示完整内容 + const items = resources.map((r) => ({ + title: `${r.position}. ${r.document_name}`, + key: r.position, + description: r.content || '', + })); + + return ( + + ); +}); + +SourceRefComponent.displayName = 'SourceRefComponent'; + +/** + * 引用来源面板组件 - 在消息下方显示所有引用(独立导出) + */ +export const SourcesPanel = React.memo(({ resources }: { resources: RetrieverResource[] }) => { + if (!resources || resources.length === 0) { + return null; + } + + return ( +
+
+ + 引用来源 +
+
+ {resources.map((resource) => ( + +
+ {resource.document_name} +
+
+ {resource.content} +
+
+ 相关度: {(resource.score * 100).toFixed(0)}% + 来源: {resource.dataset_name} +
+
+ } + placement="topLeft" + autoAdjustOverflow={false} + color="rgba(0, 104, 74, 0.92)" + classNames={{ root: 'source-tooltip-overlay' }} + > +
+ {resource.position} + {resource.document_name} + + {(resource.score * 100).toFixed(0)}% + +
+ + ))} +
+ + ); +}); + +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) => ( + + ), + }; + }, [retrieverResources]); if (!content) { - console.log('⚠️ [Markdown] 内容为空,返回null'); return null; } return (
- - - {String(children).replace(/\n$/, '')} - - - ); - } else { - // 内联代码 - return ( - - {children} - - ); - } - }, - }} + {content} - +
); -} \ No newline at end of file +} diff --git a/app/components/dify-chat/sidebar.tsx b/app/components/dify-chat/sidebar.tsx index bbbaf66..5ba0205 100644 --- a/app/components/dify-chat/sidebar.tsx +++ b/app/components/dify-chat/sidebar.tsx @@ -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(({ collapsed, onToggle, + chatApps, + loadingChatApps, + currentChatApp, + onChatAppChange, conversations, currentConversationId, onConversationSelect, @@ -281,6 +291,25 @@ const ChatSidebar = forwardRef(({ {/* 搜索框 */} + {/* 对话应用选择器 */} + {!collapsed && ( +
+ +
+ )} {!collapsed && ( ([]); + const [difyDatasetsLoading, setDifyDatasetsLoading] = useState(false); + const [difyDatasetsTotal, setDifyDatasetsTotal] = useState(0); + const [difyDatasetsPage, setDifyDatasetsPage] = useState(1); + const [isLoadingDifyDatasets, setIsLoadingDifyDatasets] = useState(false); + + // ==================== Effects ==================== + + // 当编辑的ID变化时,加载表单数据 + useEffect(() => { + if (editingId && modalVisible) { + const record = datasets.find((item) => item.id === editingId); + if (record) { + form.setFieldsValue({ + area: record.area, + dataset_id: record.dataset_id, + dataset_name: record.dataset_name, + dataset_description: record.dataset_description, + is_public: record.is_public, + is_default: record.is_default, + sort_order: record.sort_order, + }); + } + } else if (!editingId && modalVisible) { + // 新增时重置表单 + form.resetFields(); + loadDifyDatasets(); // 加载Dify知识库列表 + } + }, [editingId, modalVisible, datasets, form]); + + // ==================== Dify Datasets Loading ==================== + + /** + * 从Dify API加载知识库列表 + */ + const loadDifyDatasets = async (pageNum: number = 1) => { + if (isLoadingDifyDatasets) return; + + setIsLoadingDifyDatasets(true); + try { + const response = await fetchDatasets(pageNum, 20); + setDifyDatasets(response.data); + setDifyDatasetsTotal(response.total); + setDifyDatasetsPage(pageNum); + setDifyDatasetsLoading(false); + } catch (error: any) { + console.error('加载Dify知识库列表失败:', error); + message.error('加载Dify知识库列表失败'); + setDifyDatasetsLoading(false); + } finally { + setIsLoadingDifyDatasets(false); + } + }; + + /** + * Dify数据集选择器滚动加载 + */ + const handleDatasetSelectScroll = (e: React.UIEvent) => { + const { target } = e; + const { scrollTop, scrollHeight, clientHeight } = target as HTMLDivElement; + + // 滚动到底部且还有更多数据时加载下一页 + if (scrollHeight - scrollTop === clientHeight && + difyDatasets.length < difyDatasetsTotal && + !difyDatasetsLoading) { + loadDifyDatasets(difyDatasetsPage + 1); + } + }; + + // ==================== Event Handlers ==================== + + /** + * 处理新建按钮点击 + */ + const handleCreateClick = () => { + if (!canManageDataset) { + message.error('您没有创建知识库绑定的权限'); + return; + } + setEditingId(null); + setModalVisible(true); + form.resetFields(); + loadDifyDatasets(); + }; + + /** + * 处理编辑按钮点击 + */ + const handleEditClick = (record: AreaDataset) => { + if (!canManageDataset) { + message.error('您没有编辑知识库绑定的权限'); + return; + } + setEditingId(record.id); + setModalVisible(true); + }; + + /** + * 处理删除按钮点击 + */ + const handleDeleteClick = async (id: number) => { + if (!canManageDataset) { + message.error('您没有删除知识库绑定的权限'); + return; + } + + const success = await handleDelete(id); + if (success) { + message.success('删除成功'); + } + }; + + /** + * 处理表单提交 + */ + const handleFormSubmit = async (values: any) => { + let success = false; + + if (editingId) { + success = await handleUpdate(editingId, values); + } else { + success = await handleCreate(values); + } + + if (success) { + setModalVisible(false); + form.resetFields(); + setEditingId(null); + } + }; + + /** + * 处理表单取消 + */ + const handleFormCancel = () => { + setModalVisible(false); + form.resetFields(); + setEditingId(null); + }; + + /** + * 处理地区筛选变化 - 支持多选 + */ + const handleAreaFilterChange = (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) => ( + {area} + ), + }, + { + title: '知识库名称', + dataIndex: 'dataset_name', + key: 'dataset_name', + width: 200, + ellipsis: true, + render: (text: string) => ( + + + {text} + + + ), + }, + { + title: '知识库ID', + dataIndex: 'dataset_id', + key: 'dataset_id', + width: 200, + ellipsis: true, + render: (text: string) => ( + + + {text.substring(0, 8)}...{text.substring(text.length - 4)} + + + ), + }, + { + title: '描述', + dataIndex: 'dataset_description', + key: 'dataset_description', + ellipsis: true, + render: (text: string) => + text ? ( + + + {text.length > 30 ? text.substring(0, 30) + '...' : text} + + + ) : ( + - + ), + }, + { + title: '标签', + key: 'tags', + width: 120, + render: (_: any, record: AreaDataset) => ( + + {record.is_public && ( + }> + 公共 + + )} + {record.is_default && ( + + 默认 + + )} + + ), + }, + { + title: '排序', + dataIndex: 'sort_order', + key: 'sort_order', + width: 70, + align: 'center' as const, + sorter: (a: AreaDataset, b: AreaDataset) => a.sort_order - b.sort_order, + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 80, + render: (status: number) => ( + + {status === 1 ? '启用' : '禁用'} + + ), + }, + { + title: '创建时间', + dataIndex: 'created_at', + key: 'created_at', + width: 150, + render: (text: string) => ( + + {new Date(text).toLocaleString('zh-CN')} + + ), + }, + // 操作列(仅管理员可见) + ...(canManageDataset + ? [ + { + title: '操作', + key: 'actions', + width: 120, + fixed: 'right' as const, + render: (_: any, record: AreaDataset) => ( + + + handleDeleteClick(record.id)} + okText="确定" + cancelText="取消" + > + + + + ), + }, + ] + : []), + ]; + + return ( +
+ {/* 页面头部 */} + + +
+ + 知识库配置管理 + + + + 地区: {userArea || '-'} + + + 角色: {userRoleLabel} + + + 总数: {total} + + +
+ + {/* 仅管理员显示新增按钮 */} + {canManageDataset && ( + + )} +
+
+ + {/* 筛选区域(仅管理员可见) */} + {canManageDataset && ( + + + 地区筛选: + ({ + label: area, + value: area, + })) : []} + /> + + + {/* Dify知识库选择(仅新增时可选) */} + + + + + {/* 知识库描述 */} + + + + + {/* 高级设置折叠面板 */} +
+ + 高级设置 + + + + {/* 是否公开 */} + + + + + {/* 是否默认 */} + + + + + {/* 排序顺序 */} + + + + +
+ + +
+ ); +} diff --git a/app/components/dify-dataset-manager/dataset-settings.tsx b/app/components/dify-dataset-manager/dataset-settings.tsx index 05f1a90..af651ac 100644 --- a/app/components/dify-dataset-manager/dataset-settings.tsx +++ b/app/components/dify-dataset-manager/dataset-settings.tsx @@ -1,10 +1,18 @@ -import { Form, Input, Button, Card, Spin } from 'antd'; -import { SaveOutlined } from '@ant-design/icons'; -import { useDatasetSettings } from '~/hooks/dify-dataset-manager/dataset-settings'; +import { Form, Input, Button, Card, Spin, Divider, Select, Slider, InputNumber, Tooltip, Checkbox } from 'antd'; +import { SaveOutlined, QuestionCircleOutlined, CheckCircleFilled } from '@ant-design/icons'; +import { useDatasetSettings, type SearchMethod } from '~/hooks/dify-dataset-manager/dataset-settings'; import type { DatasetSettingsProps } from '~/types/dify-dataset-manager/dataset-settings'; const { TextArea } = Input; +// 检索方式选项 +const SEARCH_METHOD_OPTIONS: { label: string; value: SearchMethod; description: string }[] = [ + { label: '向量检索', value: 'semantic_search', description: '基于语义理解的智能检索,适合需要理解上下文的场景' }, + { label: '全文检索', value: 'full_text_search', description: '基于关键词匹配的传统检索方式' }, + { label: '混合检索', value: 'hybrid_search', description: '结合向量和全文检索,综合效果最佳' }, + { label: '关键字检索', value: 'keyword_search', description: '精确关键字匹配' }, +]; + /** * 知识库设置组件 * 用于修改知识库名称和描述 @@ -14,15 +22,23 @@ export default function DatasetSettings({ onDatasetUpdated, }: DatasetSettingsProps) { const [form] = Form.useForm(); - + const { saving, hasChanges, + retrievalSettings, handleValuesChange, handleSave, handleReset, + updateRetrievalSettings, } = useDatasetSettings(dataset, form, onDatasetUpdated); + // 是否需要显示 Reranking 提示(语义检索和混合检索需要,且强制开启) + const showRerankingInfo = retrievalSettings.searchMethod === 'semantic_search' || retrievalSettings.searchMethod === 'hybrid_search'; + // 权重设置:由于 Reranking 强制开启,混合检索时由 Reranking 模型决定排序,不需要手动设置权重 + // 所以这里始终不显示权重设置 + const showWeightsOption = false; + if (!dataset) { return (
@@ -81,12 +97,6 @@ export default function DatasetSettings({ {dataset.indexing_technique === 'high_quality' ? '高质量' : '经济'}
- {/*
- Embedding 模型: - - {dataset.embedding_model || '默认模型'} - -
*/}
文档数量: {dataset.document_count} @@ -102,24 +112,167 @@ export default function DatasetSettings({
- - {/* 操作按钮 */} -
- - -
+ + {/* 检索设置卡片 */} + +

+ 检索设置 + + + +

+ + {/* 检索方式 */} +
+ + } + popupMatchSelectWidth={false} + dropdownStyle={{ minWidth: 200 }} + > + {availableDatasets.map(ds => ( + +
+ {ds.dataset_name} + {ds.is_default && 默认} + {ds.is_public && 公共} +
+
+ ))} + + 本地文档 +
+ ) : ( + /* 单个或无知识库时显示名称 */ +
+ +

{dataset?.name || '知识库'}

+
+ 本地文档 +
+ )} {/* 统计信息 */} diff --git a/app/components/dify-dataset-manager/retrieve-test.tsx b/app/components/dify-dataset-manager/retrieve-test.tsx index 840dc6b..8a6c7bd 100644 --- a/app/components/dify-dataset-manager/retrieve-test.tsx +++ b/app/components/dify-dataset-manager/retrieve-test.tsx @@ -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} + + {/* Score 阈值设置 */} + + + + Score 阈值: + + + + {scoreThresholdEnabled && ( + <> + + setScoreThreshold(value ?? 0.5)} + min={0} + max={1} + step={0.01} + size="small" + style={{ width: 70 }} + /> + + )} + diff --git a/app/config/api-config.ts b/app/config/api-config.ts index 0fcaf46..59e5818 100644 --- a/app/config/api-config.ts +++ b/app/config/api-config.ts @@ -44,9 +44,9 @@ const portConfigs: Record> = { // 主要 // 梅州 '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 = { // 测试环境 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: { diff --git a/app/hooks/dify-chat-apps/useChatApps.ts b/app/hooks/dify-chat-apps/useChatApps.ts new file mode 100644 index 0000000..cbce67d --- /dev/null +++ b/app/hooks/dify-chat-apps/useChatApps.ts @@ -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([]); + // 加载状态 + const [loadingChatApps, setLoadingChatApps] = useState(true); + // 加载默认应用状态 + const [loadingDefault, setLoadingDefault] = useState(true); + // 当前选中的应用 + const [currentChatApp, setCurrentChatApp] = useState(null); + // 错误信息 + const [error, setError] = useState(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, + }; +} diff --git a/app/hooks/dify-dataset-manager/dataset-settings.ts b/app/hooks/dify-dataset-manager/dataset-settings.ts index dcaba5a..0409ab3 100644 --- a/app/hooks/dify-dataset-manager/dataset-settings.ts +++ b/app/hooks/dify-dataset-manager/dataset-settings.ts @@ -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( + () => 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(( + 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, }; } diff --git a/app/hooks/dify-dataset-manager/index.ts b/app/hooks/dify-dataset-manager/index.ts index 853291b..547a4d6 100644 --- a/app/hooks/dify-dataset-manager/index.ts +++ b/app/hooks/dify-dataset-manager/index.ts @@ -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(null); const [loadingDataset, setLoadingDataset] = useState(true); + // 用户可访问的知识库列表(基于权限) + const [availableDatasets, setAvailableDatasets] = useState([]); + const [loadingAvailableDatasets, setLoadingAvailableDatasets] = useState(true); + // 文档状态 const [documents, setDocuments] = useState([]); const [loadingDocuments, setLoadingDocuments] = useState(false); @@ -58,22 +63,96 @@ 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 response = await fetchDatasets(1, 1); - console.log('[DatasetManager] 知识库响应:', response); - if (response && response.data && response.data.length > 0) { - const firstDataset = response.data[0]; - setDataset(firstDataset); + // 先加载用户可访问的知识库列表 + 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(firstDataset.id, 1); + await loadDocuments(datasetId, 1); } else { - setError('未找到知识库,请先在Dify中创建知识库'); + // 回退到原有逻辑:直接从 Dify 获取 + console.log('[DatasetManager] 用户无绑定知识库,使用默认逻辑...'); + const response = await fetchDatasets(1, 1); + console.log('[DatasetManager] Dify知识库列表响应:', response); + + if (response && response.data && response.data.length > 0) { + 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); @@ -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]); /** * 处理文档页码变化 @@ -184,7 +275,11 @@ export function useDatasetManager() { error, activeTab, selectedDocument, - + + // 知识库列表(基于权限) + availableDatasets, + loadingAvailableDatasets, + // 方法 loadDataset, loadDocuments, @@ -196,6 +291,7 @@ export function useDatasetManager() { handleBackToDocuments, handleTabChange, handleDatasetUpdated, + handleDatasetChange, }; } diff --git a/app/hooks/dify-dataset-manager/retrieve-test.ts b/app/hooks/dify-dataset-manager/retrieve-test.ts index cf24d62..5b93268 100644 --- a/app/hooks/dify-dataset-manager/retrieve-test.ts +++ b/app/hooks/dify-dataset-manager/retrieve-test.ts @@ -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('semantic_search'); const [topK, setTopK] = useState(5); + // Score 阈值相关状态 + const [scoreThresholdEnabled, setScoreThresholdEnabled] = useState(false); + const [scoreThreshold, setScoreThreshold] = useState(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, diff --git a/app/hooks/use-area-dataset-config.ts b/app/hooks/use-area-dataset-config.ts new file mode 100644 index 0000000..f964ca1 --- /dev/null +++ b/app/hooks/use-area-dataset-config.ts @@ -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; + loadAreas: () => Promise; + handleCreate: (data: CreateDatasetRequest) => Promise; + handleUpdate: (id: number, data: UpdateDatasetRequest) => Promise; + handleDelete: (id: number) => Promise; + + // 权限 + canManageDataset: boolean; +} + +// 角色名称映射 +const ROLE_LABELS: Record = { + 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([]); + const [allDatasets, setAllDatasets] = useState([]); // 保存所有数据用于提取地区 + const [loading, setLoading] = useState(false); + const [total, setTotal] = useState(0); + const [userArea, setUserArea] = useState(''); + const [apiAreas, setApiAreas] = useState([]); // API 返回的地区列表 + const [areasLoading, setAreasLoading] = useState(false); + + // 筛选状态 - 支持多选 + const [filterAreas, setFilterAreas] = useState([]); + const [page, setPage] = useState(1); + const pageSize = 20; + + // 表单状态 + const [modalVisible, setModalVisible] = useState(false); + const [editingId, setEditingId] = useState(null); + const [submitLoading, setSubmitLoading] = useState(false); + + // 从数据集中提取地区列表 + const extractedAreas = useMemo(() => { + const areaSet = new Set(); + allDatasets.forEach(ds => { + if (ds.area) { + areaSet.add(ds.area); + } + }); + return Array.from(areaSet).sort(); + }, [allDatasets]); + + // 合并 API 返回的地区和从数据中提取的地区 + const areas = useMemo(() => { + const areaSet = new Set([...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 => { + if (!canManageDataset) { + message.error('您没有创建知识库绑定的权限'); + return false; + } + + setSubmitLoading(true); + try { + const response = await createDatasetBinding(data); + + if (response.code === 0) { + message.success('创建成功'); + await loadDatasets(); + return true; + } else { + message.error(`创建失败: ${response.message}`); + return false; + } + } catch (error: any) { + console.error('创建知识库绑定失败:', error); + message.error('创建失败,请稍后重试'); + return false; + } finally { + setSubmitLoading(false); + } + }, + [canManageDataset, loadDatasets] + ); + + /** + * 更新知识库绑定 + */ + const handleUpdate = useCallback( + async (id: number, data: UpdateDatasetRequest): Promise => { + if (!canManageDataset) { + message.error('您没有编辑知识库绑定的权限'); + return false; + } + + setSubmitLoading(true); + try { + const response = await updateDatasetBinding(id, data); + + if (response.code === 0) { + message.success('更新成功'); + await loadDatasets(); + return true; + } else { + message.error(`更新失败: ${response.message}`); + return false; + } + } catch (error: any) { + console.error('更新知识库绑定失败:', error); + message.error('更新失败,请稍后重试'); + return false; + } finally { + setSubmitLoading(false); + } + }, + [canManageDataset, loadDatasets] + ); + + /** + * 删除知识库绑定 + */ + const handleDelete = useCallback( + async (id: number): Promise => { + if (!canManageDataset) { + message.error('您没有删除知识库绑定的权限'); + return false; + } + + try { + const response = await deleteDatasetBinding(id); + + if (response.code === 0) { + message.success('删除成功'); + await loadDatasets(); + return true; + } else { + message.error(`删除失败: ${response.message}`); + return false; + } + } catch (error: any) { + console.error('删除知识库绑定失败:', error); + message.error('删除失败,请稍后重试'); + return false; + } + }, + [canManageDataset, loadDatasets] + ); + + // ==================== Effects ==================== + + // 初始加载数据 + useEffect(() => { + loadDatasets(); + }, []); // 只在挂载时加载一次 + + // 加载地区列表 + 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, + }; +} diff --git a/app/hooks/use-chat-message.ts b/app/hooks/use-chat-message.ts index 7519358..b74ae50 100644 --- a/app/hooks/use-chat-message.ts +++ b/app/hooks/use-chat-message.ts @@ -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, + 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) => { diff --git a/app/root.tsx b/app/root.tsx index f3d5c20..309863f 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -288,8 +288,8 @@ export default function App() { return ( - - + + +``` + +--- + +## 更新日志 + +| 版本 | 日期 | 说明 | +|------|------|------| +| 1.0 | 2025-12-06 | 初始版本,包含完整的前端对接说明 | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7fcf91..0e4d2fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/vite.config.ts b/vite.config.ts index adc9e23..f09dc30 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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' }, + ], + }, });