diff --git a/app/api/dify-chat-apps/chatAppsApi.ts b/app/api/dify-chat-apps/chatAppsApi.ts new file mode 100644 index 0000000..e67d131 --- /dev/null +++ b/app/api/dify-chat-apps/chatAppsApi.ts @@ -0,0 +1,127 @@ +/** + * Dify 对话应用管理 API 模块 + * + * 提供浏览器端调用对话应用管理 API 的函数 + * 注意:这些 API 调用的是前端 Remix 路由(/api/...),不需要后端 baseURL + * + * @module api/dify-chat-apps/chatAppsApi + */ + +import type { + ChatApp, + MyChatAppsResponse, + DefaultChatAppResponse, +} from './types'; + +/** + * API 基础 URL(前端 Remix 路由) + */ +const API_URL = '/api/v3/dify/chat-apps'; + +/** + * HTTP 状态码对应的友好错误信息 + */ +const HTTP_ERROR_MESSAGES: Record = { + 400: '请求参数错误', + 401: '登录已过期,请重新登录', + 403: '您没有权限执行此操作', + 404: '请求的资源不存在', + 409: '数据冲突,该记录可能已存在', + 500: '服务器内部错误,请稍后重试', + 502: '网关错误,请稍后重试', + 503: '服务暂时不可用,请稍后重试', +}; + +/** + * 封装 fetch 请求,自动处理 credentials + */ +async function request(url: string, options: RequestInit = {}): Promise { + const response = await fetch(url, { + ...options, + credentials: 'include', // 包含 cookies + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + // 优先使用后端返回的错误信息,否则使用友好的默认信息 + const friendlyMessage = errorData.message + || errorData.error + || HTTP_ERROR_MESSAGES[response.status] + || '操作失败,请稍后重试'; + const error = new Error(friendlyMessage); + (error as any).response = { status: response.status, data: errorData }; + throw error; + } + + return response.json(); +} + +/** + * 获取当前实例配置的对话应用列表 + * + * 根据用户角色自动返回对应数据范围: + * - provincial_admin: 全部可用应用 + * - admin/common: 本地区可用应用 + * + * @returns 用户可访问的对话应用列表 + */ +export async function getMyChatApps(): Promise { + const response = await request(`${API_URL}/my`); + + // 兼容嵌套格式 { data: { data: [], total: ... } } + if (response?.data?.data) { + return { + data: response.data.data, + total: response.data.total || 0, + page: response.data.page || 1, + page_size: response.data.page_size || 10, + }; + } + // 格式 { data: [], total: ... } + if (response?.data && Array.isArray(response.data)) { + return response as MyChatAppsResponse; + } + // 直接返回 + if (Array.isArray(response?.data)) { + return { + data: response.data, + total: response.data.length, + page: 1, + page_size: response.data.length, + }; + } + + console.warn('[API] getMyChatApps: 无效的响应格式', response); + return { data: [], total: 0, page: 1, page_size: 10 }; +} + +/** + * 获取默认对话应用 + * + * 返回配置文件中的第一个应用作为默认应用 + * + * @returns 默认对话应用 + */ +export async function getDefaultChatApp(): Promise { + const response = await request(`${API_URL}/default`); + + // 兼容嵌套格式 { data: { data: {...} } } + if (response?.data?.data) { + return { + data: response.data.data, + }; + } + // 格式 { data: {...} } + if (response?.data) { + return { + data: response.data, + }; + } + + console.warn('[API] getDefaultChatApp: 无效的响应格式', response); + throw new Error('获取默认对话应用失败'); +} diff --git a/app/api/dify-chat-apps/types.ts b/app/api/dify-chat-apps/types.ts new file mode 100644 index 0000000..515dbc6 --- /dev/null +++ b/app/api/dify-chat-apps/types.ts @@ -0,0 +1,41 @@ +/** + * 对话应用类型定义 + */ +export interface ChatApp { + /** 应用ID */ + app_id: string; + /** 应用名称 */ + app_name: string; + /** 应用描述 */ + description: string; + /** 是否默认应用 */ + is_default: boolean; + /** 应用类型 */ + type: string; + /** 创建时间 */ + created_at: string; + /** 更新时间 */ + updated_at: string; +} + +/** + * 获取我的对话应用列表响应 + */ +export interface MyChatAppsResponse { + /** 应用列表 */ + data: ChatApp[]; + /** 总数 */ + total: number; + /** 分页页码 */ + page: number; + /** 每页数量 */ + page_size: number; +} + +/** + * 获取默认对话应用响应 + */ +export interface DefaultChatAppResponse { + /** 默认应用 */ + data: ChatApp; +} diff --git a/app/api/dify-chat/chat.ts b/app/api/dify-chat/chat.ts index 8a8d531..fb9801b 100644 --- a/app/api/dify-chat/chat.ts +++ b/app/api/dify-chat/chat.ts @@ -32,8 +32,11 @@ export const difyClient = { /** * 获取会话列表 + * + * @param jwt - JWT 认证令牌 + * @param appId - 对话应用 ID(可选,用于获取特定应用的会话列表) */ - async getConversations(jwt?: string): Promise { + async getConversations(jwt?: string, appId?: string): Promise { const params = new URLSearchParams({ limit: '100', first_id: '', @@ -41,6 +44,7 @@ export const difyClient = { const response = await difyFetch(`conversations?${params}`, { method: 'GET', + appId, // 传递应用 ID,会在请求头中添加 X-Dify-App-Id }, jwt); return response.json(); }, @@ -70,6 +74,7 @@ export const difyClient = { * @param conversationId - 会话 ID * @param files - 附件文件 * @param jwt - JWT 认证令牌 + * @param appId - 对话应用 ID(可选,用于切换不同的 Dify 应用) * @returns 对于流式响应返回 Response 对象,否则返回 JSON */ async createChatMessage( @@ -78,7 +83,8 @@ export const difyClient = { responseMode: string = 'streaming', conversationId?: string, files?: any[], - jwt?: string + jwt?: string, + appId?: string ): Promise { const body = { inputs, @@ -90,6 +96,7 @@ export const difyClient = { const response = await difyFetch('chat-messages', { method: 'POST', body: JSON.stringify(body), + appId, // 传递应用 ID,会在请求头中添加 X-Dify-App-Id }, jwt); // 对于流式响应,直接返回 Response 对象 diff --git a/app/api/dify-chat/client.server.ts b/app/api/dify-chat/client.server.ts index dc91a33..a993afa 100644 --- a/app/api/dify-chat/client.server.ts +++ b/app/api/dify-chat/client.server.ts @@ -27,6 +27,14 @@ const DIFY_CHAT_API_URL = `${API_BASE_URL}/dify_chat`; // 基础请求函数 // ============================================================================ +/** + * Dify Fetch 请求选项 + */ +export interface DifyFetchOptions extends RequestInit { + /** 对话应用 ID,用于切换不同的 Dify 应用 */ + appId?: string; +} + /** * Dify Chat API 基础请求函数 * @@ -34,20 +42,21 @@ const DIFY_CHAT_API_URL = `${API_BASE_URL}/dify_chat`; * FastAPI 后端会验证 JWT 并添加 Dify API_KEY * * @param endpoint - API 端点路径 - * @param options - fetch 请求选项 + * @param options - fetch 请求选项(可包含 appId) * @param jwt - 用户 JWT 认证令牌 * @returns Response 对象 */ export async function difyFetch( endpoint: string, - options: RequestInit = {}, + options: DifyFetchOptions = {}, jwt?: string ): Promise { + const { appId, ...fetchOptions } = options; const url = `${DIFY_CHAT_API_URL}/${endpoint.replace(/^\//, '')}`; const headers: HeadersInit = { 'Content-Type': 'application/json', - ...options.headers, + ...fetchOptions.headers, }; if (jwt) { @@ -56,8 +65,14 @@ export async function difyFetch( console.warn('[Dify Chat] 没有提供 JWT,FastAPI 请求可能失败'); } + // 如果指定了应用 ID,添加 X-Dify-App-Id 请求头 + if (appId) { + (headers as Record)['X-Dify-App-Id'] = appId; + console.log('[Dify Chat] 使用应用 ID:', appId); + } + const response = await fetch(url, { - ...options, + ...fetchOptions, headers, }); diff --git a/app/api/dify-chat/client.ts b/app/api/dify-chat/client.ts index 99d6243..44e3a53 100644 --- a/app/api/dify-chat/client.ts +++ b/app/api/dify-chat/client.ts @@ -51,6 +51,7 @@ const baseOptions: RequestInit = { /** * 获取用户的会话列表 * + * @param appId - 对话应用 ID(可选,用于获取特定应用的会话列表) * @returns 包含会话列表的响应对象 * @throws {Error} 当获取会话列表失败时抛出错误 * @@ -59,15 +60,23 @@ const baseOptions: RequestInit = { * const response = await fetchConversations(); * const conversations = response.data; * console.log('会话数量:', conversations.length); + * + * // 获取指定应用的会话列表 + * const appConversations = await fetchConversations('app-123'); * ``` */ -export async function fetchConversations(): Promise { +export async function fetchConversations(appId?: string): Promise { const params = new URLSearchParams({ limit: '100', }); + // 如果指定了 appId,添加到查询参数中 + if (appId) { + params.append('app_id', appId); + } + const url = `${API_URL}/conversations?${params}`; - console.log('📋 [Dify Client] 获取会话列表:', { url }); + console.log('📋 [Dify Client] 获取会话列表:', { url, appId }); try { const response = await axios.get(url, { diff --git a/app/api/dify-chat/types.ts b/app/api/dify-chat/types.ts index 59c936f..cba4967 100644 --- a/app/api/dify-chat/types.ts +++ b/app/api/dify-chat/types.ts @@ -521,6 +521,8 @@ export interface SendMessageParams { conversation_id?: string | null; files?: VisionFile[]; response_mode?: 'streaming' | 'blocking'; + /** 对话应用 ID,用于切换不同的 Dify 应用 */ + app_id?: string; } /** diff --git a/app/api/v3/dify/area-datasets.ts b/app/api/v3/dify/area-datasets.ts index 087ee51..0a10b58 100644 --- a/app/api/v3/dify/area-datasets.ts +++ b/app/api/v3/dify/area-datasets.ts @@ -4,7 +4,7 @@ * 提供地区-知识库绑定管理接口 */ -import { request } from '~/api/axios-client'; +import { get, post, put, del } from '~/api/axios-client'; // ==================== Type Definitions ==================== @@ -87,8 +87,8 @@ const API_BASE = '/api/v3/dify/area-datasets'; * 权限: dify:dataset:read */ export async function getMyDatasets(): Promise { - const response = await request.get(`${API_BASE}/my`); - return response.data; + const response = await get(`${API_BASE}/my`); + return response.data!; } /** @@ -101,19 +101,15 @@ export async function getAllDatasets(params: { page?: number; page_size?: number; }): Promise { - const queryParams = new URLSearchParams(); + const queryParams: Record = {}; - if (params.area) queryParams.append('area', params.area); - if (params.only_enabled !== undefined) - queryParams.append('only_enabled', String(params.only_enabled)); - if (params.page) queryParams.append('page', String(params.page)); - if (params.page_size) queryParams.append('page_size', String(params.page_size)); + if (params.area) queryParams.area = params.area; + if (params.only_enabled !== undefined) queryParams.only_enabled = params.only_enabled; + if (params.page) queryParams.page = params.page; + if (params.page_size) queryParams.page_size = params.page_size; - const response = await request.get( - `${API_BASE}?${queryParams.toString()}` - ); - - return response.data; + const response = await get(API_BASE, queryParams); + return response.data!; } /** @@ -121,8 +117,8 @@ export async function getAllDatasets(params: { * 权限: dify:dataset:manage */ export async function getAvailableAreas(): Promise { - const response = await request.get(`${API_BASE}/areas`); - return response.data.data; + const response = await get(`${API_BASE}/areas`); + return response.data?.data || []; } /** @@ -132,8 +128,8 @@ export async function getAvailableAreas(): Promise { export async function createDatasetBinding( data: CreateDatasetRequest ): Promise> { - const response = await request.post(`${API_BASE}`, data); - return response.data; + const response = await post>(API_BASE, data); + return response.data!; } /** @@ -144,8 +140,8 @@ export async function updateDatasetBinding( id: number, data: UpdateDatasetRequest ): Promise> { - const response = await request.put(`${API_BASE}/${id}`, data); - return response.data; + const response = await put>(`${API_BASE}/${id}`, data); + return response.data!; } /** @@ -153,6 +149,6 @@ export async function updateDatasetBinding( * 权限: dify:dataset:manage */ export async function deleteDatasetBinding(id: number): Promise> { - const response = await request.delete(`${API_BASE}/${id}`); - return response.data; + const response = await del>(`${API_BASE}/${id}`); + return response.data!; } diff --git a/app/components/dify-chat/index.tsx b/app/components/dify-chat/index.tsx index 4b1ccf4..ad46076 100644 --- a/app/components/dify-chat/index.tsx +++ b/app/components/dify-chat/index.tsx @@ -10,6 +10,7 @@ import { fetchAppParams, fetchChatList, fetchConversations } from '~/api/dify-ch import { CHAT_CONFIG } from '../../config/chat'; import useChatMessage from '../../hooks/use-chat-message'; import useConversation from '../../hooks/use-conversation'; +import { useChatApps } from '../../hooks/dify-chat-apps/useChatApps'; import '../../styles/components/chat-with-llm/index.css'; const { Content } = Layout; @@ -51,6 +52,14 @@ export default function Chat() { // 获取主题配置,避免SSR错误 const { colorBgContainer, borderRadiusLG } = useChatTheme(); + // 对话应用管理 + const { + chatApps, + loadingChatApps, + currentChatApp, + handleChatAppChange: originalHandleChatAppChange, + } = useChatApps(); + // 会话管理 const { conversationList, @@ -335,7 +344,8 @@ export default function Chat() { message: message.substring(0, 50) + (message.length > 50 ? '...' : ''), currConversationId, isNewConversation, - willSendConversationId: isNewConversation ? null : currConversationId + willSendConversationId: isNewConversation ? null : currConversationId, + appId: currentChatApp?.app_id }); try { @@ -347,12 +357,13 @@ export default function Chat() { }); } - // 使用 useChatMessage 钩子的 handleSend 方法 + // 使用 useChatMessage 钩子的 handleSend 方法,传递当前选中的应用 ID await handleSend( message, isNewConversation ? null : currConversationId, files, - toServerInputs + toServerInputs, + currentChatApp?.app_id // 传递对话应用 ID ); } catch (error) { @@ -389,6 +400,50 @@ export default function Chat() { createNewChat(); }; + /** + * 处理对话应用切换 + * 切换应用后刷新加载对应的会话列表 + */ + const handleChatAppChange = async (appId: string) => { + console.log('🔄 [Chat] 切换对话应用:', appId); + + // 调用原始的切换方法 + originalHandleChatAppChange(appId, async (app) => { + console.log('🔄 [Chat] 应用已切换到:', app.app_name, '开始刷新会话列表...'); + + try { + // 重新获取会话列表,传入新的应用ID获取该应用的会话 + const conversationData = await fetchConversations(app.app_id); + const conversations = (conversationData as any).data || []; + + console.log('📋 [Chat] 切换应用后获取到会话列表:', conversations.length, '条'); + + // 更新会话列表 + setConversationList(conversations); + + // 清空当前聊天,创建新会话 + setChatList([]); + setChatNotStarted(); + + // 如果有会话,选择第一个;否则创建新会话 + if (conversations.length > 0) { + const firstConversation = conversations[0]; + setCurrConversationId(firstConversation.id, app.app_id, false); + console.log('🎯 [Chat] 自动选择第一个会话:', firstConversation.id); + } else { + setCurrConversationId('-1', app.app_id, false); + console.log('🆕 [Chat] 无会话,创建新会话'); + } + } catch (error) { + console.error('❌ [Chat] 切换应用后刷新会话列表失败:', error); + // 即使刷新失败,也清空当前状态 + setConversationList([]); + setChatList([]); + setCurrConversationId('-1', appId, false); + } + }); + }; + /** * 处理会话删除后的状态更新 */ @@ -614,6 +669,10 @@ export default function Chat() { onNewConversation={handleNewConversation} onConversationDeleted={handleConversationDeleted} onConversationRenamed={handleConversationRenamed} + chatApps={chatApps} + loadingChatApps={loadingChatApps} + currentChatApp={currentChatApp} + onChatAppChange={handleChatAppChange} /> {/* 主内容区域 */} diff --git a/app/components/dify-dataset-manager/area-dataset-config.tsx b/app/components/dify-dataset-manager/area-dataset-config.tsx index 6873ada..f482750 100644 --- a/app/components/dify-dataset-manager/area-dataset-config.tsx +++ b/app/components/dify-dataset-manager/area-dataset-config.tsx @@ -65,9 +65,9 @@ export default function AreaDatasetConfig() { areas, // areasLoading, // 地区列表已加载hook中 - // 筛选 - filterArea, - setFilterArea, + // 筛选 - 多选 + filterAreas, + setFilterAreas, page, setPage, pageSize, @@ -233,24 +233,17 @@ export default function AreaDatasetConfig() { }; /** - * 处理地区筛选变化 + * 处理地区筛选变化 - 支持多选 */ - const handleAreaFilterChange = (value: string) => { - setFilterArea(value); + const handleAreaFilterChange = (values: string[]) => { + setFilterAreas(values); setPage(1); // 重置到第一页 }; // ==================== Render ==================== - // 计算用户角色标签 - const userRoleLabel = (() => { - const labels: Record = { - common: '普通用户', - admin: '市级管理员', - provincial_admin: '省级管理员', - }; - return labels[userRole] || '未知角色'; - })(); + // 用户角色已经在 hook 中处理好了,直接使用 userRole + const userRoleLabel = userRole || '未知角色'; // 表格列定义 const columns = [ @@ -456,16 +449,15 @@ export default function AreaDatasetConfig() { 地区筛选: ({ + options={Array.isArray(areas) ? areas.map((area) => ({ label: area, value: area, - }))} + })) : []} /> diff --git a/app/components/dify-dataset-manager/index.tsx b/app/components/dify-dataset-manager/index.tsx index 42b62fe..26eb1e1 100644 --- a/app/components/dify-dataset-manager/index.tsx +++ b/app/components/dify-dataset-manager/index.tsx @@ -4,6 +4,7 @@ import DocumentList from './document-list'; import DocumentDetail from './document-detail'; import RetrieveTest from './retrieve-test'; import DatasetSettings from './dataset-settings'; +import AreaDatasetConfig from './area-dataset-config'; import { useDatasetManager } from '~/hooks/dify-dataset-manager'; import '../../styles/components/dify-dataset-manager/index.css'; @@ -25,7 +26,11 @@ export default function DatasetManager() { error, activeTab, selectedDocument, - + + // 知识库列表(基于权限) + availableDatasets, + loadingAvailableDatasets, + // 方法 handlePageChange, handleDocumentDeleted, @@ -35,6 +40,7 @@ export default function DatasetManager() { handleBackToDocuments, handleTabChange, handleDatasetUpdated, + handleDatasetChange, } = useDatasetManager(); // 加载中状态 @@ -101,6 +107,11 @@ export default function DatasetManager() { return ; } + // 配置管理菜单 + if (activeTab === 'area-config') { + return ; + } + // 设置菜单 if (activeTab === 'settings') { return ( @@ -122,6 +133,9 @@ export default function DatasetManager() { onTabChange={handleTabChange} showBackButton={activeTab === 'documents' && !!selectedDocument} onBack={handleBackToDocuments} + availableDatasets={availableDatasets} + loadingAvailableDatasets={loadingAvailableDatasets} + onDatasetChange={handleDatasetChange} > {renderContent()} diff --git a/app/components/dify-dataset-manager/layout.tsx b/app/components/dify-dataset-manager/layout.tsx index 88e3fe4..dec9eb5 100644 --- a/app/components/dify-dataset-manager/layout.tsx +++ b/app/components/dify-dataset-manager/layout.tsx @@ -1,10 +1,12 @@ -import { Button, Tooltip } from 'antd'; +import { Button, Tooltip, Select, Spin } from 'antd'; import { FileTextOutlined, SearchOutlined, SettingOutlined, ArrowLeftOutlined, DatabaseOutlined, + AppstoreOutlined, + SwapOutlined, } from '@ant-design/icons'; import type { DatasetLayoutProps, MenuTab, MenuItem } from '~/types/dify-dataset-manager/layout'; @@ -19,28 +21,63 @@ export default function DatasetLayout({ showBackButton = false, onBack, children, + availableDatasets = [], + loadingAvailableDatasets = false, + onDatasetChange, }: DatasetLayoutProps) { const menuItems: MenuItem[] = [ { key: 'documents', icon: , label: '文档' }, { key: 'retrieve', icon: , label: '召回测试' }, + { key: 'area-config', icon: , label: '配置管理' }, { key: 'settings', icon: , label: '设置' }, ]; + // 是否显示知识库选择器(有多个知识库时显示) + const showDatasetSelector = availableDatasets.length > 1; + return (
{/* 左侧侧边栏 */}