From 9614899171db3d320138a3484b6784d1199ca02f Mon Sep 17 00:00:00 2001 From: PingChuan <1259732256@qq.com> Date: Sun, 30 Nov 2025 16:24:35 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=E9=87=8D=E6=9E=84dify=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E7=BB=84=E4=BB=B6=E4=BB=A5=E5=8F=8A=E8=BD=AC=E5=8F=91?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/dify/client.server.ts | 284 +++++ app/api/dify/client.ts | 418 +++++++ app/api/dify/index.ts | 124 ++ app/api/dify/sse-handler.ts | 360 ++++++ app/api/dify/types.ts | 524 ++++++++ app/components/chat/chat-input.tsx | 2 +- app/components/chat/chat-message.tsx | 2 +- app/components/chat/index.tsx | 4 +- app/components/chat/sidebar.tsx | 4 +- app/components/chat/thought-process.tsx | 2 +- app/config/chat.ts | 2 +- app/hooks/use-chat-message.ts | 89 +- app/hooks/use-conversation.ts | 2 +- app/routes/api.chat-messages.tsx | 76 +- app/routes/api.conversations.$id.name.tsx | 6 +- app/routes/api.conversations.$id.tsx | 8 +- app/routes/api.conversations.tsx | 8 +- .../api.messages.$messageId.feedbacks.tsx | 67 + app/routes/api.parameters.tsx | 8 +- app/services/api.client.ts | 1088 ----------------- app/services/dify-client.server.ts | 264 ---- app/types/dify_chat.ts | 264 ---- app/utils/chat-utils.ts | 2 +- 23 files changed, 1863 insertions(+), 1745 deletions(-) create mode 100644 app/api/dify/client.server.ts create mode 100644 app/api/dify/client.ts create mode 100644 app/api/dify/index.ts create mode 100644 app/api/dify/sse-handler.ts create mode 100644 app/api/dify/types.ts create mode 100644 app/routes/api.messages.$messageId.feedbacks.tsx delete mode 100644 app/services/api.client.ts delete mode 100644 app/services/dify-client.server.ts delete mode 100644 app/types/dify_chat.ts diff --git a/app/api/dify/client.server.ts b/app/api/dify/client.server.ts new file mode 100644 index 0000000..7871bd8 --- /dev/null +++ b/app/api/dify/client.server.ts @@ -0,0 +1,284 @@ +/** + * Dify 服务端 API 模块 + * + * 提供 Node.js 服务端调用 FastAPI 后端的函数 + * 用于 Remix loader/action 中调用 Dify API + * + * 调用链路: + * Remix Server → FastAPI /dify/* → Dify + * + * @module api/dify/client.server + */ + +import { API_BASE_URL } from '~/config/api-config'; + +// ============================================================================ +// 配置 +// ============================================================================ + +/** + * 获取环境变量的服务端函数 + */ +const getServerEnvVar = (name: string, defaultValue: string = ''): string => { + return process.env[name] || defaultValue; +}; + +/** + * Dify API 客户端配置 + * 通过 FastAPI 后端的 /dify 路由代理访问 Dify + */ +const DIFY_CONFIG = { + API_URL: `${API_BASE_URL}/dify`, + API_KEY: getServerEnvVar('NEXT_PUBLIC_APP_KEY', ''), + APP_ID: (() => { + const rawAppId = getServerEnvVar('NEXT_PUBLIC_APP_ID', ''); + const match = rawAppId.match(/\/app\/([a-f0-9-]{36})/); + return match ? match[1] : rawAppId; + })(), +}; + +console.log('🔧 [Dify Server] 配置:', { + apiUrl: DIFY_CONFIG.API_URL, + apiBaseUrl: API_BASE_URL, + appId: DIFY_CONFIG.APP_ID, + configComplete: !!(DIFY_CONFIG.API_URL && DIFY_CONFIG.APP_ID) +}); + +// ============================================================================ +// 基础请求函数 +// ============================================================================ + +/** + * Dify API 基础请求函数 + * + * 使用 JWT 认证通过 FastAPI 代理访问 Dify + * + * @param endpoint - API 端点路径 + * @param options - fetch 请求选项 + * @param jwt - JWT 认证令牌 + * @returns Response 对象 + */ +async function difyFetch( + endpoint: string, + options: RequestInit = {}, + jwt?: string +): Promise { + const url = `${DIFY_CONFIG.API_URL}/${endpoint.replace(/^\//, '')}`; + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...options.headers, + }; + + if (jwt) { + (headers as Record)['Authorization'] = `Bearer ${jwt}`; + } else { + console.warn('⚠️ [Dify Server] 没有提供 JWT,请求可能失败'); + } + + const response = await fetch(url, { + ...options, + headers, + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('❌ [Dify Server] Dify API 错误:', { + status: response.status, + statusText: response.statusText, + error: errorText + }); + + if (response.status === 401) { + throw new Error('JWT认证失败,请重新登录'); + } + + throw new Error(`Dify API Error: ${response.status} ${response.statusText}`); + } + + return response; +} + +// ============================================================================ +// Dify API 客户端 +// ============================================================================ + +/** + * Dify 服务端 API 客户端 + * + * 所有方法都需要传入 JWT 进行认证 + * user 参数由后端自动从 JWT 中提取 + */ +export const difyClient = { + /** + * 获取应用参数 + */ + async getApplicationParameters(jwt?: string): Promise { + const response = await difyFetch('parameters', { + method: 'GET', + }, jwt); + return response.json(); + }, + + /** + * 获取会话列表 + */ + async getConversations(jwt?: string): Promise { + const params = new URLSearchParams({ + limit: '100', + first_id: '', + }); + + const response = await difyFetch(`conversations?${params}`, { + method: 'GET', + }, jwt); + return response.json(); + }, + + /** + * 获取会话消息 + */ + async getConversationMessages(conversationId: string, jwt?: string): Promise { + const params = new URLSearchParams({ + conversation_id: conversationId, + limit: '20', + last_id: '', + }); + + const response = await difyFetch(`messages?${params}`, { + method: 'GET', + }, jwt); + return response.json(); + }, + + /** + * 发送聊天消息 + * + * @param inputs - 输入参数 + * @param query - 用户问题 + * @param responseMode - 响应模式 ('streaming' | 'blocking') + * @param conversationId - 会话 ID + * @param files - 附件文件 + * @param jwt - JWT 认证令牌 + * @returns 对于流式响应返回 Response 对象,否则返回 JSON + */ + async createChatMessage( + inputs: Record, + query: string, + responseMode: string = 'streaming', + conversationId?: string, + files?: any[], + jwt?: string + ): Promise { + const body = { + inputs, + query, + response_mode: responseMode, + conversation_id: conversationId, + files: files || [], + }; + const response = await difyFetch('chat-messages', { + method: 'POST', + body: JSON.stringify(body), + }, jwt); + + // 对于流式响应,直接返回 Response 对象 + if (responseMode === 'streaming') { + return response; + } + + console.log('[Dify Server] 解析 JSON 响应'); + return response.json(); + }, + + /** + * 重命名会话 + */ + async renameConversation( + conversationId: string, + name: string, + autoGenerate: boolean = false, + jwt?: string + ): Promise { + const body = { + name, + auto_generate: autoGenerate, + }; + + const response = await difyFetch(`conversations/${conversationId}/name`, { + method: 'POST', + body: JSON.stringify(body), + }, jwt); + return response.json(); + }, + + /** + * 删除会话 + */ + async deleteConversation(conversationId: string, jwt?: string): Promise { + console.log('remix后端接收到删除请求,调用fastapi:', conversationId); + + try { + const response = await difyFetch(`conversations/${conversationId}`, { + method: 'DELETE', + body: JSON.stringify({}), + }, jwt); + + + // 对于 204 No Content 响应,直接返回成功 + if (response.status === 204) { + console.log('删除会话' + conversationId + '成功'); + return { result: 'success' }; + } + + const contentType = response.headers.get('Content-Type'); + + if (contentType && contentType.includes('application/json')) { + const data = await response.json(); + console.log('🗑️ [Dify Server] 删除会话 JSON 响应:', data); + return data; + } + + const text = await response.text(); + console.log('🗑️ [Dify Server] 删除会话文本响应:', text); + + return { result: 'success' }; + } catch (error: any) { + console.warn('⚠️ [Dify Server] 删除会话请求失败,但可能已成功删除:', error.message); + return { result: 'success' }; + } + }, + + /** + * 更新消息反馈 + */ + async updateMessageFeedback( + messageId: string, + rating: 'like' | 'dislike' | null, + jwt?: string + ): Promise { + const body = { + rating, + }; + + const response = await difyFetch(`messages/${messageId}/feedbacks`, { + method: 'POST', + body: JSON.stringify(body), + }, jwt); + return response.json(); + }, +}; + +// ============================================================================ +// 工具函数 +// ============================================================================ + +/** + * Dify 工具函数 + */ +export const difyUtils = { + /** + * 获取 Dify 配置 + */ + getConfig: () => DIFY_CONFIG, +}; diff --git a/app/api/dify/client.ts b/app/api/dify/client.ts new file mode 100644 index 0000000..99d6243 --- /dev/null +++ b/app/api/dify/client.ts @@ -0,0 +1,418 @@ +/** + * Dify 客户端 API 模块 + * + * 提供浏览器端调用 Dify API 的函数,通过 Remix API Routes 代理请求 + * + * 调用链路: + * 客户端 → Remix /api/* → FastAPI /dify/* → Dify + * + * @module api/dify/client + */ + +import axios from 'axios'; +import { CHAT_CONFIG, ContentType } from '~/config/chat'; +import { handleSSEStream, handleStream } from './sse-handler'; +import type { + SSECallbacks, + Feedbacktype, + SendMessageParams, + ConversationsResponse, + MessagesResponse, + AppParametersResponse, +} from './types'; + +// ============================================================================ +// 基础配置 +// ============================================================================ + +/** + * API 基础 URL + * 指向 Remix API Routes(/api/*) + */ +const API_URL = CHAT_CONFIG.API_URL; + +/** + * 基础请求选项 + */ +const baseOptions: RequestInit = { + method: 'GET', + mode: 'cors', + credentials: 'include', // 携带 cookie + headers: { + 'Content-Type': ContentType.json, + }, + redirect: 'follow', +}; + +// ============================================================================ +// 会话管理 API +// ============================================================================ + +/** + * 获取用户的会话列表 + * + * @returns 包含会话列表的响应对象 + * @throws {Error} 当获取会话列表失败时抛出错误 + * + * @example + * ```typescript + * const response = await fetchConversations(); + * const conversations = response.data; + * console.log('会话数量:', conversations.length); + * ``` + */ +export async function fetchConversations(): Promise { + const params = new URLSearchParams({ + limit: '100', + }); + + const url = `${API_URL}/conversations?${params}`; + console.log('📋 [Dify Client] 获取会话列表:', { url }); + + try { + const response = await axios.get(url, { + withCredentials: true, + }); + + console.log('📋 [Dify Client] 会话列表响应:', { status: response.status }); + return response.data; + } catch (err) { + if (axios.isAxiosError(err)) { + console.error('❌ [Dify Client] 获取会话列表失败:', { + status: err.response?.status, + body: err.response?.data + }); + throw new Error(`获取会话列表失败: ${err.response?.status}`); + } + throw err; + } +} + +/** + * 获取指定会话的聊天消息列表 + * + * @param conversationId - 会话 ID + * @returns 包含消息列表的响应对象 + * @throws {Error} 当获取消息列表失败时抛出错误 + * + * @example + * ```typescript + * const response = await fetchChatList('conv-123'); + * const messages = response.data; + * console.log('消息数量:', messages.length); + * ``` + */ +export async function fetchChatList(conversationId: string): Promise { + const params = new URLSearchParams({ + conversation_id: conversationId, + }); + + try { + const response = await axios.get( + `${API_URL}/chat-messages?${params}`, + { withCredentials: true } + ); + return response.data; + } catch (err) { + if (axios.isAxiosError(err)) { + throw new Error(`获取消息列表失败: ${err.response?.status}`); + } + throw err; + } +} + +/** + * 重命名会话 + * + * @param id - 会话 ID + * @param name - 新的会话名称 + * @param autoGenerate - 是否使用 AI 自动生成名称,默认为 false + * @returns 重命名结果 + * @throws {Error} 当重命名失败时抛出错误 + * + * @example + * ```typescript + * // 手动设置名称 + * await renameConversation('conv-123', '关于编程的讨论'); + * + * // AI 自动生成名称 + * await renameConversation('conv-123', '', true); + * ``` + */ +export async function renameConversation( + id: string, + name: string, + autoGenerate: boolean = false +): Promise<{ name: string }> { + try { + const response = await axios.post( + `${API_URL}/conversations/${id}/name`, + { + name: autoGenerate ? undefined : name, + auto_generate: autoGenerate, + }, + { + headers: { 'Content-Type': 'application/json' }, + withCredentials: true, + } + ); + return response.data; + } catch (err) { + if (axios.isAxiosError(err)) { + throw new Error(`重命名会话失败: ${err.response?.status}`); + } + throw err; + } +} + +/** + * 生成会话名称 + * + * 让 AI 根据会话内容自动生成合适的会话名称 + * + * @param id - 会话 ID + * @returns 包含生成名称的响应对象 + * @throws {Error} 当生成名称失败时抛出错误 + */ +export async function generateConversationName(id: string): Promise<{ name: string }> { + return renameConversation(id, '', true); +} + +/** + * 删除会话 + * + * @param id - 要删除的会话 ID + * @returns 删除操作结果 + * @throws {Error} 当删除会话失败时抛出错误 + * + * @example + * ```typescript + * await deleteConversation('conv-123'); + * console.log('会话已删除'); + * ``` + */ +export async function deleteConversation(id: string): Promise<{ result: string }> { + console.log('🗑️ [Dify Client] 删除会话:', id); + + try { + const response = await axios.delete(`${API_URL}/conversations/${id}`, { + headers: { 'Content-Type': 'application/json' }, + withCredentials: true, + }); + + console.log('🗑️ [Dify Client] 删除会话响应:', { + status: response.status, + statusText: response.statusText + }); + + return response.data; + } catch (err) { + if (axios.isAxiosError(err)) { + console.error('❌ [Dify Client] 删除会话失败:', err.response?.data); + throw new Error(`删除会话失败: ${err.response?.status}`); + } + throw err; + } +} + +// ============================================================================ +// 应用参数 API +// ============================================================================ + +/** + * 获取应用参数配置 + * + * @returns 包含应用参数的响应对象 + * @throws {Error} 当获取应用参数失败时抛出错误 + * + * @example + * ```typescript + * const params = await fetchAppParams(); + * const { user_input_form, opening_statement } = params; + * console.log('开场白:', opening_statement); + * ``` + */ +export async function fetchAppParams(): Promise { + const url = `${API_URL}/parameters`; + console.log('⚙️ [Dify Client] 获取应用参数:', { url }); + + try { + const response = await axios.get(url, { + withCredentials: true, + }); + console.log('⚙️ [Dify Client] 应用参数响应:', { status: response.status }); + return response.data; + } catch (err) { + if (axios.isAxiosError(err)) { + console.error('❌ [Dify Client] 获取应用参数失败:', { + status: err.response?.status, + body: err.response?.data + }); + throw new Error(`获取应用参数失败: ${err.response?.status}`); + } + throw err; + } +} + +// ============================================================================ +// 消息 API +// ============================================================================ + +/** + * 发送聊天消息(流式响应) + * + * @param params - 消息参数 + * @param callbacks - SSE 回调配置 + * + * @example + * ```typescript + * await sendChatMessage( + * { query: '你好', conversation_id: 'conv-123' }, + * { + * onData: (message, isFirst, info) => updateUI(message), + * onCompleted: () => setLoading(false), + * onError: (error) => showError(error), + * } + * ); + * ``` + */ +export async function sendChatMessage( + params: SendMessageParams, + callbacks: SSECallbacks +): Promise { + const body = { + ...params, + response_mode: 'streaming', + }; + + const url = `${API_URL}/chat-messages`; + + const controller = new AbortController(); + callbacks.getAbortController?.(controller); + + const options: RequestInit = { + ...baseOptions, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': ContentType.stream, + }, + body: JSON.stringify(body), + signal: controller.signal, + }; + + try { + const response = await fetch(url, options); + + if (!/^(2|3)\d{2}$/.test(response.status.toString())) { + const data = await response.json(); + console.error('❌ [Dify Client] SSE 错误:', data.message || 'Server Error'); + callbacks.onError?.(data.message || 'Server Error'); + return; + } + + handleSSEStream(response, callbacks); + } catch (err: any) { + if (err.name !== 'AbortError') { + console.error('❌ [Dify Client] SSE 请求错误:', err); + callbacks.onError?.(err.message); + } + } +} + +/** + * 更新消息反馈 + * + * @param messageId - 消息 ID + * @param feedback - 反馈内容 + * @returns 反馈提交结果 + * @throws {Error} 当提交反馈失败时抛出错误 + * + * @example + * ```typescript + * await updateFeedback('msg-123', { rating: 'like' }); + * ``` + */ +export async function updateFeedback( + messageId: string, + feedback: Feedbacktype +): Promise { + try { + const response = await axios.post( + `${API_URL}/messages/${messageId}/feedbacks`, + feedback, + { + headers: { 'Content-Type': 'application/json' }, + withCredentials: true, + } + ); + return response.data; + } catch (err) { + if (axios.isAxiosError(err)) { + throw new Error(`提交反馈失败: ${err.response?.status}`); + } + throw err; + } +} + +// ============================================================================ +// 文件上传 API +// ============================================================================ + +/** + * 上传文件 + * + * @param options - 上传配置选项 + * @returns 包含文件 ID 的对象 + * @throws {Error} 当文件上传失败时抛出错误 + * + * @example + * ```typescript + * const formData = new FormData(); + * formData.append('file', fileBlob); + * + * const xhr = new XMLHttpRequest(); + * const result = await uploadFile({ + * data: formData, + * xhr: xhr, + * onProgress: (event) => { + * const progress = (event.loaded / event.total) * 100; + * console.log('上传进度:', progress + '%'); + * } + * }); + * console.log('文件ID:', result.id); + * ``` + */ +export function uploadFile(options: { + data: FormData; + xhr: XMLHttpRequest; + onProgress?: (event: ProgressEvent) => void; +}): Promise<{ id: string }> { + const url = `${API_URL}/files/upload`; + + return new Promise((resolve, reject) => { + const { xhr, data, onProgress } = options; + + xhr.open('POST', url); + xhr.withCredentials = true; + + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + if (xhr.status === 200) { + resolve({ id: xhr.response }); + } else { + reject(new Error(xhr.responseText || '上传失败')); + } + } + }; + + if (onProgress) { + xhr.upload.onprogress = onProgress; + } + + xhr.send(data); + }); +} + +// 重新导出 SSE 处理器 +export { handleStream, handleSSEStream }; diff --git a/app/api/dify/index.ts b/app/api/dify/index.ts new file mode 100644 index 0000000..f690c41 --- /dev/null +++ b/app/api/dify/index.ts @@ -0,0 +1,124 @@ +/** + * Dify API 模块统一导出 + * + * 提供 Dify 相关功能的统一入口 + * + * @module api/dify + * + * @example + * ```typescript + * // 客户端使用 + * import { sendChatMessage, fetchConversations } from '~/api/dify'; + * + * // 使用 SSE 处理器 + * import { handleSSEStream, SSEStreamHandler } from '~/api/dify'; + * + * // 使用类型 + * import type { ChatItem, ConversationItem, SSECallbacks } from '~/api/dify'; + * ``` + */ + +// ============================================================================ +// 类型导出 +// ============================================================================ + +export type { + // 基础类型 + AppInfo, + ConversationItem, + ChatItem, + MessageMore, + Feedbacktype, + ThoughtItem, + + // 文件类型 + VisionFile, + ImageFile, + VisionSettings, + + // 表单类型 + TextTypeFormItem, + SelectTypeFormItem, + UserInputFormItem, + PromptConfig, + PromptVariable, + + // 工作流类型 + NodeTracing, + WorkflowProcess, + + // SSE 事件类型 + MessageEvent, + MessageReplace, + MessageEnd, + WorkflowStartedResponse, + WorkflowFinishedResponse, + NodeStartedResponse, + NodeFinishedResponse, + + // SSE 回调类型 + OnDataMoreInfo, + OnData, + OnThought, + OnFile, + OnMessageEnd, + OnMessageReplace, + OnCompleted, + OnError, + OnWorkflowStarted, + OnWorkflowFinished, + OnNodeStarted, + OnNodeFinished, + SSECallbacks, + + // API 请求/响应类型 + SendMessageParams, + ConversationsResponse, + MessagesResponse, + AppParametersResponse, +} from './types'; + +export { + // 枚举 + Resolution, + TransferMethod, + WorkflowRunningStatus, + NodeRunningStatus, + BlockEnum, + CodeLanguage, +} from './types'; + +// ============================================================================ +// SSE 处理器导出 +// ============================================================================ + +export { + SSEStreamHandler, + SSEEventType, + handleSSEStream, + handleStream, + createSSECallbacks, +} from './sse-handler'; + +// ============================================================================ +// 客户端 API 导出 +// ============================================================================ + +export { + // 会话管理 + fetchConversations, + fetchChatList, + renameConversation, + generateConversationName, + deleteConversation, + + // 应用参数 + fetchAppParams, + + // 消息 + sendChatMessage, + updateFeedback, + + // 文件上传 + uploadFile, +} from './client'; diff --git a/app/api/dify/sse-handler.ts b/app/api/dify/sse-handler.ts new file mode 100644 index 0000000..73e776b --- /dev/null +++ b/app/api/dify/sse-handler.ts @@ -0,0 +1,360 @@ +/** + * SSE (Server-Sent Events) 流处理模块 + * + * 负责处理 Dify API 的流式响应,包括: + * - 解析 SSE 数据流 + * - 处理各种事件类型(消息、思考、文件、工作流等) + * - 错误处理和状态管理 + * + * @module api/dify/sse-handler + */ + +import type { + OnData, + OnCompleted, + OnThought, + OnFile, + OnMessageEnd, + OnMessageReplace, + OnError, + OnWorkflowStarted, + OnWorkflowFinished, + OnNodeStarted, + OnNodeFinished, + SSECallbacks, + ThoughtItem, + VisionFile, + MessageEnd, + MessageReplace, + WorkflowStartedResponse, + WorkflowFinishedResponse, + NodeStartedResponse, + NodeFinishedResponse, +} from './types'; +import { unicodeToChar } from '~/utils/chat-utils'; + +/** + * SSE 事件类型枚举 + */ +export enum SSEEventType { + Message = 'message', + AgentMessage = 'agent_message', + AgentThought = 'agent_thought', + MessageFile = 'message_file', + MessageEnd = 'message_end', + MessageReplace = 'message_replace', + WorkflowStarted = 'workflow_started', + WorkflowFinished = 'workflow_finished', + NodeStarted = 'node_started', + NodeFinished = 'node_finished', + Error = 'error', +} + +/** + * SSE 流处理器类 + * + * 封装 SSE 流的读取和解析逻辑,提供清晰的事件分发机制 + */ +export class SSEStreamHandler { + private reader: ReadableStreamDefaultReader | null = null; + private decoder = new TextDecoder('utf-8'); + private buffer = ''; + private isFirstMessage = true; + private callbacks: SSECallbacks; + private aborted = false; + + constructor(callbacks: SSECallbacks) { + this.callbacks = callbacks; + } + + /** + * 开始处理 SSE 流 + * @param response - fetch API 返回的 Response 对象 + */ + async handleStream(response: Response): Promise { + if (!response.ok) { + console.error('❌ [SSEHandler] 响应错误:', response.status, response.statusText); + this.callbacks.onError?.('网络响应错误'); + throw new Error('网络响应错误'); + } + + this.reader = response.body?.getReader() ?? null; + if (!this.reader) { + this.callbacks.onError?.('无法读取响应流'); + throw new Error('无法读取响应流'); + } + + await this.read(); + } + + /** + * 中止流处理 + */ + abort(): void { + this.aborted = true; + this.reader?.cancel(); + } + + /** + * 递归读取流数据 + */ + private async read(): Promise { + if (this.aborted || !this.reader) { + return; + } + + try { + const result = await this.reader.read(); + + if (result.done) { + this.callbacks.onCompleted?.(); + return; + } + + const chunk = this.decoder.decode(result.value, { stream: true }); + this.buffer += chunk; + + const hasError = this.processBuffer(); + + if (!hasError && !this.aborted) { + await this.read(); + } + } catch (err: any) { + if (!this.aborted) { + console.error('❌ [SSEHandler] 读取流时出错:', err); + this.callbacks.onError?.(err.message); + } + } + } + + /** + * 处理缓冲区中的数据 + * @returns 是否发生错误 + */ + private processBuffer(): boolean { + const lines = this.buffer.split('\n'); + let hasError = false; + + try { + for (const line of lines) { + if (line.startsWith('data: ')) { + const jsonStr = line.substring(6); + hasError = this.processEventData(jsonStr); + if (hasError) break; + } + } + + // 保留最后一行(可能是不完整的消息) + this.buffer = lines[lines.length - 1]; + } catch (err) { + console.error('❌ [SSEHandler] 解析响应时出错:', err); + this.callbacks.onData?.('', false, { + conversationId: undefined, + messageId: '', + errorMessage: `${err}`, + }); + hasError = true; + this.callbacks.onCompleted?.(true); + } + + return hasError; + } + + /** + * 处理单个事件数据 + * @param jsonStr - JSON 字符串 + * @returns 是否发生错误 + */ + private processEventData(jsonStr: string): boolean { + let eventData: Record; + + try { + eventData = JSON.parse(jsonStr); + } catch (e) { + console.warn('⚠️ [SSEHandler] JSON解析失败:', e); + // 处理消息截断情况 + this.callbacks.onData?.('', this.isFirstMessage, { + conversationId: undefined, + messageId: '', + }); + return false; + } + + // 检查错误响应 + if (eventData.status === 400 || !eventData.event) { + console.error('❌ [SSEHandler] 错误响应:', { + status: eventData.status, + event: eventData.event, + message: eventData.message, + code: eventData.code + }); + this.callbacks.onData?.('', false, { + conversationId: undefined, + messageId: '', + errorMessage: eventData.message, + errorCode: eventData.code, + }); + this.callbacks.onCompleted?.(true); + return true; + } + + // 分发事件 + this.dispatchEvent(eventData); + return false; + } + + /** + * 根据事件类型分发到对应的回调 + * @param eventData - 事件数据 + */ + private dispatchEvent(eventData: Record): void { + const { event } = eventData; + + switch (event) { + case SSEEventType.Message: + case SSEEventType.AgentMessage: + this.handleMessageEvent(eventData); + break; + + case SSEEventType.AgentThought: + this.callbacks.onThought?.(eventData as ThoughtItem); + break; + + case SSEEventType.MessageFile: + this.callbacks.onFile?.(eventData as VisionFile); + break; + + case SSEEventType.MessageEnd: + this.callbacks.onMessageEnd?.(eventData as MessageEnd); + break; + + case SSEEventType.MessageReplace: + this.callbacks.onMessageReplace?.(eventData as MessageReplace); + break; + + case SSEEventType.WorkflowStarted: + this.callbacks.onWorkflowStarted?.(eventData as WorkflowStartedResponse); + break; + + case SSEEventType.WorkflowFinished: + this.callbacks.onWorkflowFinished?.(eventData as WorkflowFinishedResponse); + break; + + case SSEEventType.NodeStarted: + this.callbacks.onNodeStarted?.(eventData as NodeStartedResponse); + break; + + case SSEEventType.NodeFinished: + this.callbacks.onNodeFinished?.(eventData as NodeFinishedResponse); + break; + + default: + // 未知事件类型,忽略 + break; + } + } + + /** + * 处理消息事件 + * @param eventData - 事件数据 + */ + private handleMessageEvent(eventData: Record): void { + const answer = unicodeToChar(eventData.answer); + + this.callbacks.onData?.(answer, this.isFirstMessage, { + conversationId: eventData.conversation_id, + messageId: eventData.id || eventData.message_id, + taskId: eventData.task_id, + }); + + this.isFirstMessage = false; + } +} + +/** + * 处理 SSE 流式响应(函数式 API) + * + * 这是核心的流式响应处理函数,负责: + * - 解析 SSE 数据流 + * - 处理各种事件类型(消息、思考、文件、工作流等) + * - 错误处理和状态管理 + * + * @param response - fetch API 返回的 Response 对象 + * @param callbacks - SSE 回调配置 + * @returns SSEStreamHandler 实例(可用于中止流) + * + * @example + * ```typescript + * const handler = handleSSEStream(response, { + * onData: (message, isFirst, info) => console.log('收到消息:', message), + * onCompleted: () => console.log('流式响应完成'), + * onThought: (thought) => console.log('AI思考:', thought), + * onError: (error) => console.error('错误:', error), + * }); + * + * // 需要时可以中止流 + * handler.abort(); + * ``` + */ +export function handleSSEStream( + response: Response, + callbacks: SSECallbacks +): SSEStreamHandler { + const handler = new SSEStreamHandler(callbacks); + handler.handleStream(response); + return handler; +} + +/** + * 创建 SSE 回调配置的工厂函数 + * + * 提供类型安全的回调配置创建方式 + * + * @param callbacks - 部分回调配置 + * @returns 完整的 SSE 回调配置 + * + * @example + * ```typescript + * const callbacks = createSSECallbacks({ + * onData: (message) => updateUI(message), + * onCompleted: () => setLoading(false), + * }); + * ``` + */ +export function createSSECallbacks(callbacks: Partial & { onData: OnData }): SSECallbacks { + return callbacks as SSECallbacks; +} + +/** + * 兼容旧版 handleStream 函数的适配器 + * + * @deprecated 请使用 handleSSEStream 替代 + */ +export const handleStream = ( + response: Response, + onData: OnData, + onCompleted?: OnCompleted, + onThought?: OnThought, + onMessageEnd?: OnMessageEnd, + onMessageReplace?: OnMessageReplace, + onFile?: OnFile, + onWorkflowStarted?: OnWorkflowStarted, + onWorkflowFinished?: OnWorkflowFinished, + onNodeStarted?: OnNodeStarted, + onNodeFinished?: OnNodeFinished, + onError?: OnError, +): void => { + handleSSEStream(response, { + onData, + onCompleted, + onThought, + onFile, + onMessageEnd, + onMessageReplace, + onError, + onWorkflowStarted, + onWorkflowFinished, + onNodeStarted, + onNodeFinished, + }); +}; diff --git a/app/api/dify/types.ts b/app/api/dify/types.ts new file mode 100644 index 0000000..8fa79de --- /dev/null +++ b/app/api/dify/types.ts @@ -0,0 +1,524 @@ +/** + * Dify API 类型定义 + * 统一管理所有 Dify 相关的 TypeScript 类型 + */ + +// ============================================================================ +// 基础类型 +// ============================================================================ + +/** + * 应用信息类型 + */ +export interface AppInfo { + title: string; + description: string; + copyright: string; + privacy_policy: string; + default_language: string; +} + +/** + * 会话项类型 + */ +export interface ConversationItem { + id: string; + name: string; + inputs?: Record; + introduction?: string; +} + +/** + * 聊天消息类型 + */ +export interface ChatItem { + id: string; + content: string; + isAnswer: boolean; + feedback?: Feedbacktype; + agent_thoughts?: ThoughtItem[]; + message_files?: VisionFile[]; + isError?: boolean; + workflow_run_id?: string; + workflowProcess?: WorkflowProcess; + more?: MessageMore; + useCurrentUserAvatar?: boolean; + isOpeningStatement?: boolean; + suggestedQuestions?: string[]; +} + +/** + * 消息更多信息类型 + */ +export interface MessageMore { + time: string; + tokens: number; + latency: number | string; +} + +/** + * 反馈类型 + */ +export type Feedbacktype = { + rating: 'like' | 'dislike' | null; + content?: string; +} + +/** + * 思考过程类型 + */ +export interface ThoughtItem { + id?: string; + chain_id?: string; + thought?: string; + observation?: string; + message_files?: VisionFile[]; + tool_name?: string; + tool_input?: string; + tool_output?: string; + tool_finished?: boolean; + parent_id?: string; + children_ids?: string[]; + sort?: number; + message_id?: string; + tool?: string; + position?: number; + files?: string[]; +} + +// ============================================================================ +// 文件相关类型 +// ============================================================================ + +/** + * 视觉文件类型 + */ +export interface VisionFile { + id?: string; + type: string; + transfer_method: TransferMethod; + url?: string; + upload_file_id?: string; + belongs_to?: string; + usage?: string; + result?: any; + detail?: Resolution; +} + +/** + * 图片文件类型 + */ +export interface ImageFile { + type: TransferMethod; + _id: string; + fileId: string; + file?: File; + progress: number; + url: string; + base64Url?: string; + deleted?: boolean; +} + +/** + * 视觉设置类型 + */ +export interface VisionSettings { + enabled: boolean; + detail?: Resolution; + number_limits?: number; + transfer_methods?: TransferMethod[]; + image_file_size_limit?: number | string; +} + +/** + * 分辨率枚举 + */ +export enum Resolution { + low = 'low', + high = 'high', +} + +/** + * 传输方法枚举 + */ +export enum TransferMethod { + local_file = 'local_file', + remote_url = 'remote_url', + all = 'all', +} + +// ============================================================================ +// 表单相关类型 +// ============================================================================ + +/** + * 文本类型表单项 + */ +export interface TextTypeFormItem { + label: string; + variable: string; + required: boolean; + max_length: number; +} + +/** + * 选择类型表单项 + */ +export interface SelectTypeFormItem { + label: string; + variable: string; + required: boolean; + options: string[]; +} + +/** + * 用户输入表单项 + */ +export type UserInputFormItem = { + 'text-input': TextTypeFormItem; +} | { + 'select': SelectTypeFormItem; +} | { + 'paragraph': TextTypeFormItem; +} + +/** + * 提示配置类型 + */ +export interface PromptConfig { + prompt_template: string; + prompt_variables: PromptVariable[]; +} + +/** + * 提示变量类型 + */ +export interface PromptVariable { + key: string; + name: string; + type: string; + required: boolean; + options?: string[]; + max_length?: number; + allowed_file_extensions?: string[]; + allowed_file_types?: string[]; + allowed_file_upload_methods?: TransferMethod[]; +} + +// ============================================================================ +// 工作流相关类型 +// ============================================================================ + +/** + * 工作流运行状态枚举 + */ +export enum WorkflowRunningStatus { + init = 'init', + running = 'running', + completed = 'completed', + error = 'error', + waiting = 'waiting', + succeeded = 'succeeded', + failed = 'failed', + stopped = 'stopped', +} + +/** + * 节点运行状态枚举 + */ +export enum NodeRunningStatus { + NotStart = 'not-start', + Waiting = 'waiting', + Running = 'running', + Succeeded = 'succeeded', + Failed = 'failed', +} + +/** + * 块类型枚举 + */ +export enum BlockEnum { + Start = 'start', + End = 'end', + Answer = 'answer', + LLM = 'llm', + KnowledgeRetrieval = 'knowledge-retrieval', + QuestionClassifier = 'question-classifier', + IfElse = 'if-else', + Code = 'code', + TemplateTransform = 'template-transform', + HttpRequest = 'http-request', + VariableAssigner = 'variable-assigner', + Tool = 'tool', +} + +/** + * 节点追踪类型 + */ +export interface NodeTracing { + id: string; + index: number; + predecessor_node_id: string; + node_id: string; + node_type: BlockEnum; + title: string; + inputs: any; + process_data: any; + outputs?: any; + status: string; + error?: string; + elapsed_time: number; + execution_metadata: { + total_tokens: number; + total_price: number; + currency: string; + }; + created_at: number; + created_by: { + id: string; + name: string; + email: string; + }; + finished_at: number; + extras?: any; + expand?: boolean; +} + +/** + * 工作流进程类型 + */ +export interface WorkflowProcess { + status: WorkflowRunningStatus; + tracing: NodeTracing[]; + expand?: boolean; +} + +/** + * 代码语言枚举 + */ +export enum CodeLanguage { + python3 = 'python3', + javascript = 'javascript', + json = 'json', +} + +// ============================================================================ +// SSE 事件类型 +// ============================================================================ + +/** + * 消息事件类型 + */ +export interface MessageEvent { + event: string; + task_id: string; + conversation_id: string; + message_id: string; + answer: string; +} + +/** + * 消息替换类型 + */ +export interface MessageReplace { + event: string; + task_id: string; + conversation_id: string; + message_id: string; + answer: string; +} + +/** + * 消息结束类型 + */ +export interface MessageEnd { + event: string; + task_id: string; + conversation_id: string; + message_id: string; +} + +/** + * 工作流开始响应 + */ +export interface WorkflowStartedResponse { + task_id: string; + workflow_run_id: string; + event: string; + data: { + id: string; + workflow_id: string; + sequence_number: number; + created_at: number; + }; +} + +/** + * 工作流完成响应 + */ +export interface WorkflowFinishedResponse { + task_id: string; + workflow_run_id: string; + event: string; + data: { + id: string; + workflow_id: string; + status: string; + outputs: any; + error: string; + elapsed_time: number; + total_tokens: number; + total_steps: number; + created_at: number; + finished_at: number; + }; +} + +/** + * 节点开始响应 + */ +export interface NodeStartedResponse { + task_id: string; + workflow_run_id: string; + event: string; + data: { + id: string; + node_id: string; + node_type: string; + index: number; + predecessor_node_id?: string; + inputs: any; + created_at: number; + extras?: any; + }; +} + +/** + * 节点完成响应 + */ +export interface NodeFinishedResponse { + task_id: string; + workflow_run_id: string; + event: string; + data: { + id: string; + node_id: string; + node_type: string; + index: number; + predecessor_node_id?: string; + inputs: any; + process_data: any; + outputs: any; + status: string; + error: string; + elapsed_time: number; + execution_metadata: { + total_tokens: number; + total_price: number; + currency: string; + }; + created_at: number; + }; +} + +// ============================================================================ +// SSE 回调类型 +// ============================================================================ + +/** + * 数据回调附加信息 + */ +export interface OnDataMoreInfo { + conversationId?: string; + taskId?: string; + messageId: string; + errorMessage?: string; + errorCode?: string; +} + +/** + * SSE 回调函数类型 + */ +export type OnData = (message: string, isFirstMessage: boolean, moreInfo: OnDataMoreInfo) => void; +export type OnThought = (thought: ThoughtItem) => void; +export type OnFile = (file: VisionFile) => void; +export type OnMessageEnd = (messageEnd: MessageEnd) => void; +export type OnMessageReplace = (messageReplace: MessageReplace) => void; +export type OnCompleted = (hasError?: boolean) => void; +export type OnError = (msg: string, code?: string) => void; +export type OnWorkflowStarted = (workflowStarted: WorkflowStartedResponse) => void; +export type OnWorkflowFinished = (workflowFinished: WorkflowFinishedResponse) => void; +export type OnNodeStarted = (nodeStarted: NodeStartedResponse) => void; +export type OnNodeFinished = (nodeFinished: NodeFinishedResponse) => void; + +/** + * SSE 回调配置 + */ +export interface SSECallbacks { + onData: OnData; + onCompleted?: OnCompleted; + onThought?: OnThought; + onFile?: OnFile; + onMessageEnd?: OnMessageEnd; + onMessageReplace?: OnMessageReplace; + onError?: OnError; + onWorkflowStarted?: OnWorkflowStarted; + onWorkflowFinished?: OnWorkflowFinished; + onNodeStarted?: OnNodeStarted; + onNodeFinished?: OnNodeFinished; + getAbortController?: (controller: AbortController) => void; +} + +// ============================================================================ +// API 请求/响应类型 +// ============================================================================ + +/** + * 发送消息请求参数 + */ +export interface SendMessageParams { + query: string; + inputs?: Record; + conversation_id?: string | null; + files?: VisionFile[]; + response_mode?: 'streaming' | 'blocking'; +} + +/** + * 会话列表响应 + */ +export interface ConversationsResponse { + data: ConversationItem[]; + has_more: boolean; + limit: number; +} + +/** + * 消息列表响应 + */ +export interface MessagesResponse { + data: Array<{ + id: string; + query: string; + answer: string; + agent_thoughts?: ThoughtItem[]; + message_files?: VisionFile[]; + feedback?: Feedbacktype; + }>; + has_more: boolean; + limit: number; +} + +/** + * 应用参数响应 + */ +export interface AppParametersResponse { + user_input_form?: UserInputFormItem[]; + opening_statement?: string; + file_upload?: { + enabled: boolean; + allowed_file_extensions?: string[]; + allowed_file_types?: string[]; + number_limits?: number; + }; +} diff --git a/app/components/chat/chat-input.tsx b/app/components/chat/chat-input.tsx index 70ca555..2e3c024 100644 --- a/app/components/chat/chat-input.tsx +++ b/app/components/chat/chat-input.tsx @@ -1,7 +1,7 @@ import React, { useState, useRef, useCallback, useEffect } from 'react'; import { Input, Button, Upload, Tooltip, message as antdMessage, Space } from 'antd'; import { StopOutlined, PictureOutlined, CommentOutlined } from '@ant-design/icons'; -import type { VisionFile } from '../../types/dify_chat'; +import type { VisionFile } from '~/api/dify'; import '../../styles/components/chat-with-llm/chat-input.css'; const { TextArea } = Input; diff --git a/app/components/chat/chat-message.tsx b/app/components/chat/chat-message.tsx index c08df7f..4eee4cd 100644 --- a/app/components/chat/chat-message.tsx +++ b/app/components/chat/chat-message.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { Button, Card, Spin } from 'antd'; -import type { ChatItem, Feedbacktype, ThoughtItem, VisionFile } from '../../types/dify_chat'; +import type { ChatItem, Feedbacktype, ThoughtItem, VisionFile } from '~/api/dify'; import { CHAT_CONFIG } from '../../config/chat'; import Markdown from './markdown'; import ThoughtProcess from './thought-process'; diff --git a/app/components/chat/index.tsx b/app/components/chat/index.tsx index f91edcd..710fcf8 100644 --- a/app/components/chat/index.tsx +++ b/app/components/chat/index.tsx @@ -8,9 +8,9 @@ import ChatSidebar, { type ChatSidebarRef } from './sidebar'; // import Header from '../layout/Header'; import useConversation from '../../hooks/use-conversation'; import useChatMessage from '../../hooks/use-chat-message'; -import type { ChatItem, ConversationItem } from '../../types/dify_chat'; +import type { ChatItem, ConversationItem } from '~/api/dify'; import { CHAT_CONFIG } from '../../config/chat'; -import { fetchConversations, fetchAppParams, fetchChatList } from '../../services/api.client'; +import { fetchConversations, fetchAppParams, fetchChatList } from '~/api/dify'; import '../../styles/components/chat-with-llm/index.css'; const { Content } = Layout; diff --git a/app/components/chat/sidebar.tsx b/app/components/chat/sidebar.tsx index 5e48317..dd6d7f1 100644 --- a/app/components/chat/sidebar.tsx +++ b/app/components/chat/sidebar.tsx @@ -11,8 +11,8 @@ import { EditOutlined, ExclamationCircleOutlined, } from '@ant-design/icons'; -import type { ConversationItem } from '../../types/dify_chat'; -import { deleteConversation, renameConversation } from '../../services/api.client'; +import type { ConversationItem } from '~/api/dify'; +import { deleteConversation, renameConversation } from '~/api/dify'; import '../../styles/components/chat-with-llm/sidebar.css'; const { Sider } = Layout; diff --git a/app/components/chat/thought-process.tsx b/app/components/chat/thought-process.tsx index b6d51c2..b86e016 100644 --- a/app/components/chat/thought-process.tsx +++ b/app/components/chat/thought-process.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { Card, Collapse, Tag, Spin, Typography, Button } from 'antd'; import { ToolOutlined, ThunderboltOutlined, CheckCircleOutlined, LoadingOutlined } from '@ant-design/icons'; -import type { ThoughtItem } from '../../types/dify_chat'; +import type { ThoughtItem } from '~/api/dify'; import Markdown from './markdown'; import '../../styles/components/chat-with-llm/thought-process.css'; diff --git a/app/config/chat.ts b/app/config/chat.ts index e064775..2301a7f 100644 --- a/app/config/chat.ts +++ b/app/config/chat.ts @@ -1,4 +1,4 @@ -import type { AppInfo } from '../types/dify_chat'; +import type { AppInfo } from '~/api/dify'; // 在客户端获取环境变量的辅助函数 const getEnvVar = (name: string, defaultValue: string = '') => { diff --git a/app/hooks/use-chat-message.ts b/app/hooks/use-chat-message.ts index 9d60603..d96cbbb 100644 --- a/app/hooks/use-chat-message.ts +++ b/app/hooks/use-chat-message.ts @@ -1,9 +1,8 @@ -import { useState, useCallback, useRef } from 'react'; -import { produce } from 'immer'; import { useBoolean, useGetState } from 'ahooks'; -import { sendChatMessage, updateFeedback, generateConversationName } from '../services/api.client'; -import type { ChatItem, Feedbacktype, ThoughtItem, VisionFile, MessageEnd, MessageReplace } from '../types/dify_chat'; -import { CHAT_CONFIG } from '../config/chat'; +import { produce } from 'immer'; +import { useCallback, useRef, useState } from 'react'; +import { sendChatMessage, updateFeedback, generateConversationName } from '~/api/dify'; +import type { ChatItem, Feedbacktype, MessageEnd, MessageReplace, VisionFile } from '~/api/dify'; /** * 聊天消息处理钩子 @@ -61,22 +60,9 @@ export default function useChatMessage({ questionItem: ChatItem, originalResponseId?: string ) => { - // console.log('🔄 [useChatMessage] 更新聊天列表:', { - // responseItemId: responseItem.id, - // responseContentLength: responseItem.content.length, - // responsePreview: responseItem.content.substring(0, 50) + (responseItem.content.length > 50 ? '...' : ''), - // originalResponseId, - // questionId, - // placeholderAnswerId - // }); setChatList(produce(getChatList(), (draft) => { - // console.log('📝 [useChatMessage] 当前聊天列表:', draft.map(item => ({ - // id: item.id, - // contentLength: item.content.length, - // contentPreview: item.content.substring(0, 20) + (item.content.length > 20 ? '...' : ''), - // isAnswer: item.isAnswer - // }))); + // 移除占位符 const placeholderIndex = draft.findIndex(item => item.id === placeholderAnswerId); @@ -112,26 +98,11 @@ export default function useChatMessage({ } if (responseIndex !== -1) { - // console.log('✏️ [useChatMessage] 更新现有响应:', { - // responseIndex, - // oldContentLength: draft[responseIndex].content.length, - // newContentLength: responseItem.content.length - // }); + draft[responseIndex] = { ...responseItem }; } else { - // console.log('➕ [useChatMessage] 添加新响应:', { - // responseId: responseItem.id, - // contentLength: responseItem.content.length - // }); draft.push({ ...responseItem }); } - - // console.log('📝 [useChatMessage] 更新后聊天列表:', draft.map(item => ({ - // id: item.id, - // contentLength: item.content.length, - // contentPreview: item.content.substring(0, 20) + (item.content.length > 20 ? '...' : ''), - // isAnswer: item.isAnswer - // }))); })); }, [getChatList, setChatList]); @@ -274,27 +245,11 @@ export default function useChatMessage({ // 发送消息 await sendChatMessage(data, { onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }) => { - // console.log('📨 [useChatMessage] 收到流式数据:', { - // messageLength: message.length, - // message: message.substring(0, 100) + (message.length > 100 ? '...' : ''), - // isFirstMessage, - // messageId, - // newConversationId, - // taskId, - // isAgentMode, - // currentContentLength: responseItem.content.length - // }); if (!isAgentMode) { // 累积消息内容 const oldContent = responseItem.content; responseItem.content = responseItem.content + message; - // console.log('📝 [useChatMessage] 累积消息内容:', { - // oldLength: oldContent.length, - // newLength: responseItem.content.length, - // addedLength: message.length, - // preview: responseItem.content.substring(0, 50) + '...' - // }); } else { const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]; if (lastThought) { @@ -323,13 +278,6 @@ export default function useChatMessage({ setMessageTaskId(taskId || ''); - // 检查是否切换到其他会话 - // console.log('🔍 会话检查:', { - // prevTempNewConversationId, - // conversationId, - // isEqual: prevTempNewConversationId === conversationId - // }); - // 修复新会话的匹配逻辑 const isNewConversationMatch = (prevTempNewConversationId === '-1' && conversationId === null) || (prevTempNewConversationId === conversationId); @@ -340,25 +288,6 @@ export default function useChatMessage({ return; } - // console.log('🔄 准备调用updateCurrentQA:', { - // responseItemId: responseItem.id, - // responseContent: responseItem.content, - // questionId, - // placeholderAnswerId, - // originalResponseId - // }); - - // console.log('🔄 [useChatMessage] 准备调用updateCurrentQA:', { - // responseItemId: responseItem.id, - // responseContentLength: responseItem.content.length, - // responsePreview: responseItem.content.substring(0, 100) + (responseItem.content.length > 100 ? '...' : ''), - // questionId, - // placeholderAnswerId, - // originalResponseId, - // isAgentMode, - // agentThoughtsCount: responseItem.agent_thoughts?.length || 0 - // }); - // 更新当前问答(使用防抖) updateCurrentQA({ responseItem: { ...responseItem }, // 创建副本避免引用问题 @@ -370,7 +299,6 @@ export default function useChatMessage({ }, onCompleted: async (hasError?: boolean) => { - // console.log('✅ 消息发送完成:', { hasError }); // 立即更新最终状态 if (currentResponseRef.current) { @@ -541,10 +469,7 @@ export default function useChatMessage({ */ const handleFeedback = useCallback(async (messageId: string, feedback: Feedbacktype) => { try { - await updateFeedback({ - url: `messages/${messageId}/feedbacks`, - body: feedback, - }); + await updateFeedback(messageId, feedback); // 更新聊天列表中的反馈 setChatList(produce(getChatList(), (draft) => { diff --git a/app/hooks/use-conversation.ts b/app/hooks/use-conversation.ts index bd81dac..13de6c3 100644 --- a/app/hooks/use-conversation.ts +++ b/app/hooks/use-conversation.ts @@ -2,7 +2,7 @@ import { useState, useCallback } from 'react'; import { useParams } from '@remix-run/react'; import { produce } from 'immer'; import { useGetState, useLocalStorageState } from 'ahooks'; -import type { ConversationItem } from '../types/dify_chat'; +import type { ConversationItem } from '~/api/dify'; import { CHAT_CONFIG } from '../config/chat'; // 本地存储键名 diff --git a/app/routes/api.chat-messages.tsx b/app/routes/api.chat-messages.tsx index 23417c3..1aa86d7 100644 --- a/app/routes/api.chat-messages.tsx +++ b/app/routes/api.chat-messages.tsx @@ -1,7 +1,67 @@ -import { type ActionFunctionArgs } from '@remix-run/node'; -import { difyClient } from '../services/dify-client.server'; +import { type ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node'; +import { difyClient } from '~/api/dify/client.server'; import { getSessionInfo } from '../utils/session.server'; +/** + * GET /api/chat-messages - 获取会话消息列表 + */ +export async function loader({ request }: LoaderFunctionArgs) { + try { + // 获取用户会话信息和 JWT + const { getUserSession } = await import("~/api/login/auth.server"); + const { frontendJWT } = await getUserSession(request); + + // 检查 JWT 是否存在 + if (!frontendJWT) { + console.error('❌ [API] Chat Messages GET - JWT不存在'); + return new Response( + JSON.stringify({ error: 'JWT认证失败,请重新登录' }), + { + status: 401, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + // 从 URL 参数获取 conversation_id + const url = new URL(request.url); + const conversationId = url.searchParams.get('conversation_id'); + + if (!conversationId) { + return new Response( + JSON.stringify({ error: '缺少 conversation_id 参数' }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + console.log('客戶端調用remix路由_Chat Messages GET - 获取消息列表:', { conversationId }); + + const result = await difyClient.getConversationMessages(conversationId, frontendJWT); + + return new Response(JSON.stringify(result), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + } catch (error: any) { + console.error('❌ [API] Chat Messages GET - Error:', error.message); + const status = error.message?.includes('JWT认证失败') ? 401 : 500; + return new Response( + JSON.stringify({ error: error.message || 'Failed to get messages' }), + { + status, + headers: { 'Content-Type': 'application/json' }, + } + ); + } +} + +/** + * POST /api/chat-messages - 发送聊天消息 + */ export async function action({ request }: ActionFunctionArgs) { if (request.method !== 'POST') { return new Response('Method not allowed', { status: 405 }); @@ -34,7 +94,7 @@ export async function action({ request }: ActionFunctionArgs) { response_mode: responseMode, } = body; - console.log('🚀 [API] Chat Messages API - 收到请求:', { + console.log('客戶端調用remix路由_Chat Messages API - 收到请求:', { queryLength: query?.length || 0, queryPreview: query?.substring(0, 100) + (query?.length > 100 ? '...' : ''), conversationId, @@ -54,16 +114,9 @@ export async function action({ request }: ActionFunctionArgs) { frontendJWT // 传递 JWT ); - console.log('📡 [API] Dify响应状态:', { - status: response.status, - statusText: response.statusText, - hasBody: !!response.body, - headers: Object.fromEntries(response.headers.entries()) - }); - // 对于流式响应,直接返回流 if (responseMode === 'streaming') { - console.log('🌊 [API] 返回流式响应'); + console.log('Dify转发fastapi,返回流式响应'); return new Response(response.body, { status: response.status, headers: { @@ -78,7 +131,6 @@ export async function action({ request }: ActionFunctionArgs) { } // 对于非流式响应,返回JSON - console.log('📄 [API] 返回JSON响应'); return new Response(JSON.stringify(response), { status: 200, headers: { diff --git a/app/routes/api.conversations.$id.name.tsx b/app/routes/api.conversations.$id.name.tsx index 72a7e1a..42035f4 100644 --- a/app/routes/api.conversations.$id.name.tsx +++ b/app/routes/api.conversations.$id.name.tsx @@ -1,5 +1,5 @@ import { json, type ActionFunctionArgs } from '@remix-run/node'; -import { difyClient } from '../services/dify-client.server'; +import { difyClient } from '~/api/dify/client.server'; import { getSessionInfo, commitSession } from '../utils/session.server'; export async function action({ request, params }: ActionFunctionArgs) { @@ -31,7 +31,7 @@ export async function action({ request, params }: ActionFunctionArgs) { const body = await request.json(); const { auto_generate, name } = body; - console.log('💬 [API] Rename Conversation API - 重命名会话:', { + console.log('客戶端調用remix路由Rename Conversation API - 重命名会话:', { id, autoGenerate: auto_generate, name, @@ -41,8 +41,6 @@ export async function action({ request, params }: ActionFunctionArgs) { // 调用服务端API重命名会话 const data = await difyClient.renameConversation(id, name, auto_generate, frontendJWT); - console.log('✅ [API] Rename Conversation API - Success'); - return json(data, { headers: { 'Set-Cookie': await commitSession(session), diff --git a/app/routes/api.conversations.$id.tsx b/app/routes/api.conversations.$id.tsx index b02c1d2..a6c6830 100644 --- a/app/routes/api.conversations.$id.tsx +++ b/app/routes/api.conversations.$id.tsx @@ -1,5 +1,5 @@ import { json, type ActionFunctionArgs } from '@remix-run/node'; -import { difyClient } from '../services/dify-client.server'; +import { difyClient } from '~/api/dify/client.server'; import { getSessionInfo, commitSession } from '../utils/session.server'; export async function action({ request, params }: ActionFunctionArgs) { @@ -31,16 +31,10 @@ export async function action({ request, params }: ActionFunctionArgs) { const method = request.method; if (method === 'DELETE') { - console.log('🗑️ [API] Delete Conversation API - 删除会话:', { - id, - hasJWT: !!frontendJWT - }); // 调用服务端API删除会话 const data = await difyClient.deleteConversation(id, frontendJWT); - console.log('✅ [API] Delete Conversation API - Success'); - return json(data, { headers: { 'Set-Cookie': await commitSession(session), diff --git a/app/routes/api.conversations.tsx b/app/routes/api.conversations.tsx index a291809..663c894 100644 --- a/app/routes/api.conversations.tsx +++ b/app/routes/api.conversations.tsx @@ -1,5 +1,5 @@ import { json, type LoaderFunctionArgs } from '@remix-run/node'; -import { difyClient } from '../services/dify-client.server'; +import { difyClient } from '~/api/dify/client.server'; import { getSessionInfo, commitSession } from '../utils/session.server'; export async function loader({ request }: LoaderFunctionArgs) { @@ -23,14 +23,8 @@ export async function loader({ request }: LoaderFunctionArgs) { ); } - console.log('💬 [API] Conversations API - 获取会话列表:', { - hasJWT: !!frontendJWT - }); - const data = await difyClient.getConversations(frontendJWT); - console.log('✅ [API] Conversations API - Success'); - return json(data, { headers: { 'Set-Cookie': await commitSession(session), diff --git a/app/routes/api.messages.$messageId.feedbacks.tsx b/app/routes/api.messages.$messageId.feedbacks.tsx new file mode 100644 index 0000000..fc13f68 --- /dev/null +++ b/app/routes/api.messages.$messageId.feedbacks.tsx @@ -0,0 +1,67 @@ +import { type ActionFunctionArgs } from '@remix-run/node'; +import { difyClient } from '~/api/dify/client.server'; + +/** + * POST /api/messages/:messageId/feedbacks - 提交消息反馈 + */ +export async function action({ request, params }: ActionFunctionArgs) { + if (request.method !== 'POST') { + return new Response('Method not allowed', { status: 405 }); + } + + try { + // 获取用户会话信息和 JWT + const { getUserSession } = await import("~/api/login/auth.server"); + const { frontendJWT } = await getUserSession(request); + + // 检查 JWT 是否存在 + if (!frontendJWT) { + console.error('❌ [API] Message Feedback - JWT不存在'); + return new Response( + JSON.stringify({ error: 'JWT认证失败,请重新登录' }), + { + status: 401, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + const { messageId } = params; + if (!messageId) { + return new Response( + JSON.stringify({ error: '缺少 messageId 参数' }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + const body = await request.json(); + const { rating } = body; + + console.log('👍 [API] Message Feedback - 提交反馈:', { + messageId, + rating, + }); + + const result = await difyClient.updateMessageFeedback(messageId, rating, frontendJWT); + + console.log('✅ [API] Message Feedback - Success'); + return new Response(JSON.stringify(result), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + } catch (error: any) { + console.error('❌ [API] Message Feedback - Error:', error.message); + const status = error.message?.includes('JWT认证失败') ? 401 : 500; + return new Response( + JSON.stringify({ error: error.message || 'Failed to submit feedback' }), + { + status, + headers: { 'Content-Type': 'application/json' }, + } + ); + } +} diff --git a/app/routes/api.parameters.tsx b/app/routes/api.parameters.tsx index c8e572b..f25de4a 100644 --- a/app/routes/api.parameters.tsx +++ b/app/routes/api.parameters.tsx @@ -1,5 +1,5 @@ import { json, type LoaderFunctionArgs } from '@remix-run/node'; -import { difyClient } from '../services/dify-client.server'; +import { difyClient } from '~/api/dify/client.server'; import { getSessionInfo, commitSession } from '../utils/session.server'; export async function loader({ request }: LoaderFunctionArgs) { @@ -23,14 +23,8 @@ export async function loader({ request }: LoaderFunctionArgs) { ); } - console.log('📋 [API] Parameters API - 获取应用参数:', { - hasJWT: !!frontendJWT - }); - const data = await difyClient.getApplicationParameters(frontendJWT); - console.log('✅ [API] Parameters API - Success'); - return json(data, { headers: { 'Set-Cookie': await commitSession(session), diff --git a/app/services/api.client.ts b/app/services/api.client.ts deleted file mode 100644 index b1479d1..0000000 --- a/app/services/api.client.ts +++ /dev/null @@ -1,1088 +0,0 @@ -import { CHAT_CONFIG, ContentType, SSE_TIMEOUT } from '../config/chat'; -import type { Feedbacktype, ThoughtItem, VisionFile, MessageEnd, MessageReplace } from '../types/dify_chat'; -import { unicodeToChar } from '../utils/chat-utils'; -import axios from 'axios'; - -// 基础请求选项 -// 注意:客户端调用Remix API routes,不需要手动添加Authorization -// Remix会通过session自动处理JWT认证 -const baseOptions = { - method: 'GET', - mode: 'cors' as RequestMode, - credentials: 'include' as RequestCredentials, // 改为include以携带cookie - headers: new Headers({ - 'Content-Type': ContentType.json, - // 移除Authorization头,由服务端自动处理 - }), - redirect: 'follow' as RequestRedirect, -}; - -// 回调接口定义 -export type IOnDataMoreInfo = { - conversationId?: string; - taskId?: string; - messageId: string; - errorMessage?: string; - errorCode?: string; -} - -export type IOnData = (message: string, isFirstMessage: boolean, moreInfo: IOnDataMoreInfo) => void; -export type IOnThought = (thought: ThoughtItem) => void; -export type IOnFile = (file: VisionFile) => void; -export type IOnMessageEnd = (messageEnd: MessageEnd) => void; -export type IOnMessageReplace = (messageReplace: MessageReplace) => void; -export type IOnCompleted = (hasError?: boolean) => void; -export type IOnError = (msg: string, code?: string) => void; - -// 工作流相关类型 -export type WorkflowStartedResponse = { - task_id: string; - workflow_run_id: string; - event: string; - data: { - id: string; - workflow_id: string; - sequence_number: number; - created_at: number; - }; -} - -export type WorkflowFinishedResponse = { - task_id: string; - workflow_run_id: string; - event: string; - data: { - id: string; - workflow_id: string; - status: string; - outputs: any; - error: string; - elapsed_time: number; - total_tokens: number; - total_steps: number; - created_at: number; - finished_at: number; - }; -} - -export type NodeStartedResponse = { - task_id: string; - workflow_run_id: string; - event: string; - data: { - id: string; - node_id: string; - node_type: string; - index: number; - predecessor_node_id?: string; - inputs: any; - created_at: number; - extras?: any; - }; -} - -export type NodeFinishedResponse = { - task_id: string; - workflow_run_id: string; - event: string; - data: { - id: string; - node_id: string; - node_type: string; - index: number; - predecessor_node_id?: string; - inputs: any; - process_data: any; - outputs: any; - status: string; - error: string; - elapsed_time: number; - execution_metadata: { - total_tokens: number; - total_price: number; - currency: string; - }; - created_at: number; - }; -} - -export type IOnWorkflowStarted = (workflowStarted: WorkflowStartedResponse) => void; -export type IOnWorkflowFinished = (workflowFinished: WorkflowFinishedResponse) => void; -export type IOnNodeStarted = (nodeStarted: NodeStartedResponse) => void; -export type IOnNodeFinished = (nodeFinished: NodeFinishedResponse) => void; - -/** - * 处理服务器发送事件 (SSE) 流式响应 - * - * 这是核心的流式响应处理函数,负责: - * - 解析 SSE 数据流 - * - 处理各种事件类型(消息、思考、文件、工作流等) - * - 错误处理和状态管理 - * - 实时更新 UI - * - * @param response - fetch API 返回的 Response 对象 - * @param onData - 处理消息数据的回调函数 - * @param onCompleted - 流式响应完成时的回调函数 - * @param onThought - 处理 Agent 思考过程的回调函数 - * @param onMessageEnd - 处理消息结束事件的回调函数 - * @param onMessageReplace - 处理消息替换事件的回调函数 - * @param onFile - 处理文件事件的回调函数 - * @param onWorkflowStarted - 处理工作流开始事件的回调函数 - * @param onWorkflowFinished - 处理工作流完成事件的回调函数 - * @param onNodeStarted - 处理节点开始事件的回调函数 - * @param onNodeFinished - 处理节点完成事件的回调函数 - * @param onError - 处理错误的回调函数 - * - * @example - * ```typescript - * const response = await fetch('/api/chat-messages', options); - * handleStream( - * response, - * (message, isFirst, info) => console.log('收到消息:', message), - * () => console.log('流式响应完成'), - * (thought) => console.log('AI思考:', thought), - * // ... 其他回调 - * ); - * ``` - */ -const handleStream = ( - response: Response, - onData: IOnData, - onCompleted?: IOnCompleted, - onThought?: IOnThought, - onMessageEnd?: IOnMessageEnd, - onMessageReplace?: IOnMessageReplace, - onFile?: IOnFile, - onWorkflowStarted?: IOnWorkflowStarted, - onWorkflowFinished?: IOnWorkflowFinished, - onNodeStarted?: IOnNodeStarted, - onNodeFinished?: IOnNodeFinished, - onError?: IOnError, -) => { - if (!response.ok) { - console.error('❌ [handleStream] 响应错误:', response.status, response.statusText); - onError?.('网络响应错误'); - throw new Error('网络响应错误'); - } - - const reader = response.body?.getReader(); - const decoder = new TextDecoder('utf-8'); - let buffer = ''; - let bufferObj: Record; - let isFirstMessage = true; - let messageCount = 0; - - - function read() { - let hasError = false; - reader?.read().then((result: any) => { - - if (result.done) { - onCompleted && onCompleted(); - return; - } - - const chunk = decoder.decode(result.value, { stream: true }); - buffer += chunk; - const lines = buffer.split('\n'); - - - try { - lines.forEach((message, index) => { - if (message.startsWith('data: ')) { - const jsonStr = message.substring(6); - - try { - bufferObj = JSON.parse(jsonStr) as Record; - } - catch (e) { - console.warn('⚠️ [handleStream] JSON解析失败:', e, 'JSON:', jsonStr); - // 处理消息截断 - onData('', isFirstMessage, { - conversationId: bufferObj?.conversation_id, - messageId: bufferObj?.message_id || bufferObj?.id, - }); - return; - } - - if (bufferObj.status === 400 || !bufferObj.event) { - console.error('❌ [handleStream] 错误响应:', { - status: bufferObj.status, - event: bufferObj.event, - message: bufferObj.message, - code: bufferObj.code - }); - onData('', false, { - conversationId: undefined, - messageId: '', - errorMessage: bufferObj?.message, - errorCode: bufferObj?.code, - }); - hasError = true; - onCompleted?.(true); - return; - } - - if (bufferObj.event === 'message' || bufferObj.event === 'agent_message') { - const answer = unicodeToChar(bufferObj.answer); - - - onData(answer, isFirstMessage, { - conversationId: bufferObj.conversation_id, - messageId: bufferObj.id || bufferObj.message_id, - taskId: bufferObj.task_id, - }); - isFirstMessage = false; - } else if (bufferObj.event === 'agent_thought' && onThought) { - // console.log('🤔 [handleStream] 处理思考事件:', bufferObj.event); - onThought(bufferObj as ThoughtItem); - } else if (bufferObj.event === 'message_file' && onFile) { - // console.log('📁 [handleStream] 处理文件事件:', bufferObj.event); - onFile(bufferObj as VisionFile); - } else if (bufferObj.event === 'message_end' && onMessageEnd) { - // console.log('🏁 [handleStream] 处理消息结束事件:', bufferObj.event); - onMessageEnd(bufferObj as MessageEnd); - } else if (bufferObj.event === 'message_replace' && onMessageReplace) { - // console.log('🔄 [handleStream] 处理消息替换事件:', bufferObj.event); - onMessageReplace(bufferObj as MessageReplace); - } else if (bufferObj.event === 'workflow_started' && onWorkflowStarted) { - // console.log('🚀 [handleStream] 处理工作流开始事件:', bufferObj.event); - onWorkflowStarted(bufferObj as WorkflowStartedResponse); - } else if (bufferObj.event === 'workflow_finished' && onWorkflowFinished) { - // console.log('🎯 [handleStream] 处理工作流完成事件:', bufferObj.event); - onWorkflowFinished(bufferObj as WorkflowFinishedResponse); - } else if (bufferObj.event === 'node_started' && onNodeStarted) { - // console.log('🔗 [handleStream] 处理节点开始事件:', bufferObj.event); - onNodeStarted(bufferObj as NodeStartedResponse); - } else if (bufferObj.event === 'node_finished' && onNodeFinished) { - // console.log('✅ [handleStream] 处理节点完成事件:', bufferObj.event); - onNodeFinished(bufferObj as NodeFinishedResponse); - } else { - // console.log('❓ [handleStream] 未知事件类型:', bufferObj.event); - } - } else if (message.trim()) { - // console.log('📝 [handleStream] 非data消息:', message.substring(0, 100)); - } - }); - - // 保留最后一行(可能是不完整的消息) - const lastLine = lines[lines.length - 1]; - buffer = lastLine; - } - catch (err) { - console.error('❌ [handleStream] 解析响应时出错:', err); - onData('', false, { - conversationId: undefined, - messageId: '', - errorMessage: `${err}`, - }); - hasError = true; - onCompleted?.(true); - return; - } - - if (!hasError) { - read(); - } else { - } - }).catch(err => { - console.error('❌ [handleStream] 读取流时出错:', err); - onError?.(err.message); - }); - } - - read(); -}; - -/** - * 基础 HTTP 请求函数 - * - * 提供统一的请求配置和错误处理: - * - 自动添加认证头 - * - 统一的 URL 处理 - * - 错误状态码处理 - * - 自动添加用户 ID - * - * @param url - 请求的 URL 路径(相对于 API 基础 URL) - * @param fetchOptions - fetch API 的配置选项 - * @param needAllResponseContent - 是否需要返回完整的响应内容而不是 JSON - * @returns Promise - 返回解析后的响应数据 - * - * @throws {Error} 当请求失败时抛出错误 - * - * @example - * ```typescript - * // 发送 GET 请求 - * const data = await baseFetch('conversations', { method: 'GET' }); - * - * // 发送 POST 请求 - * const result = await baseFetch('chat-messages', { - * method: 'POST', - * body: { query: 'Hello' } - * }); - * ``` - */ -const baseFetch = async (url: string, fetchOptions: any, needAllResponseContent: boolean = false) => { - const options = Object.assign({}, baseOptions, fetchOptions); - - // 调用Remix API routes(如 /api/conversations) - // 服务端会通过session获取JWT并调用FastAPI代理 - const urlWithPrefix = `${CHAT_CONFIG.API_URL}/${url.replace(/^\//, '')}`; - - const { body } = options; - let data = body; - if (body && typeof body === 'object') { - // 不再添加user参数,服务端会从JWT自动提取 - data = body; - } - - try { - const response = await axios({ - url: urlWithPrefix, - method: options.method || 'GET', - data: data, - headers: options.headers, - withCredentials: true, // 等同于 credentials: 'include' - }); - - if (needAllResponseContent) { - return response.data; - } - - return response.data; - } catch (err) { - if (axios.isAxiosError(err)) { - console.error('❌ Request failed:', { - status: err.response?.status, - statusText: err.response?.statusText, - url: urlWithPrefix - }); - - if (err.response?.status === 422) { - const errorData = err.response.data; - const errorMessage = errorData?.message || errorData?.error || JSON.stringify(errorData); - throw new Error(errorMessage); - } - - throw new Error(`${err.response?.status || 500}: ${err.response?.statusText || err.message}`); - } - - console.error('❌ Request error:', (err as Error).message); - throw err; - } -}; - -/** - * 发送 SSE (Server-Sent Events) POST 请求 - * - * 专门用于处理流式响应的 POST 请求: - * - 配置 SSE 相关的请求头 - * - 设置 AbortController 用于取消请求 - * - 调用 handleStream 处理流式响应 - * - 自动添加用户 ID 到请求体 - * - * @param url - 请求的 URL 路径 - * @param fetchOptions - fetch 配置选项 - * @param callbacks - 包含各种事件回调的对象 - * @param callbacks.onData - 处理消息数据的回调 - * @param callbacks.onCompleted - 流式响应完成时的回调 - * @param callbacks.onThought - 处理思考过程的回调 - * @param callbacks.onFile - 处理文件的回调 - * @param callbacks.onMessageEnd - 处理消息结束的回调 - * @param callbacks.onMessageReplace - 处理消息替换的回调 - * @param callbacks.onError - 处理错误的回调 - * @param callbacks.getAbortController - 获取中止控制器的回调 - * @param callbacks.onWorkflowStarted - 处理工作流开始的回调 - * @param callbacks.onWorkflowFinished - 处理工作流完成的回调 - * @param callbacks.onNodeStarted - 处理节点开始的回调 - * @param callbacks.onNodeFinished - 处理节点完成的回调 - * - * @example - * ```typescript - * ssePost('chat-messages', { - * body: { query: 'Hello', response_mode: 'streaming' } - * }, { - * onData: (message, isFirst, info) => updateUI(message), - * onCompleted: () => setLoading(false), - * onError: (error) => showError(error) - * }); - * ``` - */ -export const ssePost = ( - url: string, - fetchOptions: any, - { - onData, - onCompleted, - onThought, - onFile, - onMessageEnd, - onMessageReplace, - onWorkflowStarted, - onWorkflowFinished, - onNodeStarted, - onNodeFinished, - onError, - getAbortController, - }: { - onData: IOnData; - onCompleted?: IOnCompleted; - onThought?: IOnThought; - onFile?: IOnFile; - onMessageEnd?: IOnMessageEnd; - onMessageReplace?: IOnMessageReplace; - onError?: IOnError; - getAbortController?: (abortController: AbortController) => void; - onWorkflowStarted?: IOnWorkflowStarted; - onWorkflowFinished?: IOnWorkflowFinished; - onNodeStarted?: IOnNodeStarted; - onNodeFinished?: IOnNodeFinished; - }, -) => { - const options = Object.assign({}, baseOptions, { - method: 'POST', - }, fetchOptions); - - // 调用Remix API routes(如 /api/chat-messages) - const urlWithPrefix = `${CHAT_CONFIG.API_URL}/${url.replace(/^\//, '')}`; - - const controller = new AbortController(); - if (getAbortController) - getAbortController(controller); - - options.headers = { - ...options.headers, - 'Content-Type': 'application/json', - 'Accept': ContentType.stream, - // 移除Authorization头,由服务端自动处理 - }; - - options.signal = controller.signal; - - const { body } = options; - if (body && typeof body === 'object') { - // 不再添加user参数,服务端会从JWT自动提取 - options.body = JSON.stringify(body); - } - - return fetch(urlWithPrefix, options) - .then((res: Response) => { - - if (!/^(2|3)\d{2}$/.test(res.status.toString())) { - res.json().then((data: any) => { - console.error('❌ SSE Error:', data.message || 'Server Error'); - onError?.(data.message || 'Server Error'); - }); - return; - } - - handleStream( - res, - onData, - onCompleted, - onThought, - onMessageEnd, - onMessageReplace, - onFile, - onWorkflowStarted, - onWorkflowFinished, - onNodeStarted, - onNodeFinished, - onError - ); - }) - .catch((err) => { - console.error('❌ SSE Request Error:', err); - onError?.(err.message); - }); -}; - -/** - * 获取用户的会话列表 - * - * 从 Dify API 获取当前用户的所有会话: - * - 自动分页获取(最多100条) - * - 包含会话 ID、名称、输入参数等信息 - * - 按时间倒序排列 - * - * @returns Promise - 包含会话列表的响应对象 - * @returns Promise.data - 会话数组 - * @returns Promise.has_more - 是否还有更多数据 - * @returns Promise.limit - 每页限制数量 - * - * @throws {Error} 当获取会话列表失败时抛出错误 - * - * @example - * ```typescript - * const response = await fetchConversations(); - * const conversations = response.data; - * console.log('会话数量:', conversations.length); - * ``` - */ -export const fetchConversations = async () => { - const params = new URLSearchParams({ - limit: '100', - // 不再传递user参数,服务端会从JWT自动提取 - }); - - const url = `${CHAT_CONFIG.API_URL}/conversations?${params}`; - console.log('📋 [API Client] 获取会话列表:', { url, apiUrl: CHAT_CONFIG.API_URL }); - - try { - const response = await axios.get(url, { - withCredentials: true, // 携带cookie - }); - - console.log('📋 [API Client] 会话列表响应:', { status: response.status }); - console.log('📋 [API Client] 会话列表数据:', response.data); - return response.data; - } catch (err) { - if (axios.isAxiosError(err)) { - console.error('❌ [API Client] 获取会话列表失败:', { - status: err.response?.status, - body: err.response?.data - }); - throw new Error(`Failed to fetch conversations: ${err.response?.status} - ${JSON.stringify(err.response?.data)}`); - } - console.error('❌ [API Client] 会话列表请求异常:', err); - throw err; - } -}; - -/** - * 获取指定会话的聊天消息列表 - * - * 从 Dify API 获取特定会话的消息历史: - * - 支持分页加载(最多20条) - * - 包含用户消息和 AI 回复 - * - 按时间顺序排列 - * - * @param conversationId - 会话 ID - * @returns Promise - 包含消息列表的响应对象 - * @returns Promise.data - 消息数组 - * @returns Promise.has_more - 是否还有更多历史消息 - * @returns Promise.limit - 每页限制数量 - * - * @throws {Error} 当获取消息列表失败时抛出错误 - * - * @example - * ```typescript - * const response = await fetchChatList('conv-123'); - * const messages = response.data; - * console.log('消息数量:', messages.length); - * ``` - */ -export const fetchChatList = async (conversationId: string) => { - const params = new URLSearchParams({ - conversation_id: conversationId, - // 不再传递user参数,服务端会从JWT自动提取 - }); - - try { - const response = await axios.get(`${CHAT_CONFIG.API_URL}/messages?${params}`, { - withCredentials: true, // 携带cookie - }); - return response.data; - } catch (err) { - if (axios.isAxiosError(err)) { - throw new Error(`Failed to fetch chat list: ${err.response?.status}`); - } - throw err; - } -}; - -/** - * 获取应用参数配置 - * - * 从 Dify API 获取应用的配置信息: - * - 用户输入表单配置 - * - 开场白设置 - * - 文件上传配置 - * - 其他应用级别设置 - * - * @returns Promise - 包含应用参数的响应对象 - * @returns Promise.user_input_form - 用户输入表单配置 - * @returns Promise.opening_statement - 开场白内容 - * @returns Promise.file_upload - 文件上传配置 - * - * @throws {Error} 当获取应用参数失败时抛出错误 - * - * @example - * ```typescript - * const params = await fetchAppParams(); - * const { user_input_form, opening_statement } = params.data; - * console.log('开场白:', opening_statement); - * ``` - */ -export const fetchAppParams = async () => { - const url = `${CHAT_CONFIG.API_URL}/parameters`; - console.log('⚙️ [API Client] 获取应用参数:', { url, apiUrl: CHAT_CONFIG.API_URL }); - - try { - const response = await axios.get(url, { - withCredentials: true, // 携带cookie - }); - console.log('⚙️ [API Client] 应用参数响应:', { status: response.status }); - console.log('⚙️ [API Client] 应用参数数据:', response.data); - return response.data; - } catch (err) { - if (axios.isAxiosError(err)) { - console.error('❌ [API Client] 获取应用参数失败:', { - status: err.response?.status, - body: err.response?.data - }); - throw new Error(`Failed to fetch app params: ${err.response?.status} - ${JSON.stringify(err.response?.data)}`); - } - console.error('❌ [API Client] 应用参数请求异常:', err); - throw err; - } -}; - -/** - * 更新消息反馈[未使用] - * - * 向 Dify API 提交用户对 AI 回复的反馈: - * - 支持点赞/点踩评价 - * - 可添加文字反馈内容 - * - 用于改进 AI 回复质量 - * - * @param params - 反馈参数对象 - * @param params.url - 包含消息 ID 的 URL - * @param params.body - 反馈内容 - * @param params.body.rating - 评分:'like' | 'dislike' | null - * @param params.body.content - 文字反馈内容(可选) - * @returns Promise - 反馈提交结果 - * - * @throws {Error} 当提交反馈失败时抛出错误 - * - * @example - * ```typescript - * await updateFeedback({ - * url: '/messages/msg-123/feedbacks', - * body: { rating: 'like', content: '回答很好' } - * }); - * ``` - */ -export const updateFeedback = async ({ url, body }: { url: string; body: Feedbacktype }) => { - const messageId = url.split('/').pop(); // 从URL中提取messageId - - try { - const response = await axios.post(`${CHAT_CONFIG.API_URL}/messages/${messageId}/feedbacks`, body, { - headers: { - 'Content-Type': 'application/json', - }, - withCredentials: true, // 携带cookie - }); - return response.data; - } catch (err) { - if (axios.isAxiosError(err)) { - throw new Error(`Failed to update feedback: ${err.response?.status}`); - } - throw err; - } -}; - -/** - * 生成会话名称[未使用] - * - * 让 AI 根据会话内容自动生成合适的会话名称: - * - 基于会话中的消息内容 - * - 生成简洁有意义的标题 - * - 用于替换默认的"新对话"名称 - * - * @param id - 会话 ID - * @returns Promise - 包含生成名称的响应对象 - * @returns Promise.name - 生成的会话名称 - * - * @throws {Error} 当生成名称失败时抛出错误 - * - * @example - * ```typescript - * const result = await generateConversationName('conv-123'); - * console.log('生成的名称:', result.name); - * ``` - */ -export const generateConversationName = async (id: string) => { - try { - const response = await axios.post(`${CHAT_CONFIG.API_URL}/conversations/${id}/name`, { - auto_generate: true, - // 不再添加user参数 - }, { - headers: { - 'Content-Type': 'application/json', - }, - withCredentials: true, // 携带cookie - }); - return response.data; - } catch (err) { - if (axios.isAxiosError(err)) { - throw new Error(`Failed to generate conversation name: ${err.response?.status}`); - } - throw err; - } -}; - -/** - * 重命名会话 - * - * 更新会话的显示名称: - * - 支持手动设置名称 - * - 支持 AI 自动生成名称 - * - 更新后在会话列表中显示新名称 - * - * @param id - 会话 ID - * @param name - 新的会话名称(当 autoGenerate 为 false 时使用) - * @param autoGenerate - 是否使用 AI 自动生成名称,默认为 false - * @returns Promise - 重命名结果 - * @returns Promise.name - 最终的会话名称 - * - * @throws {Error} 当重命名失败时抛出错误 - * - * @example - * ```typescript - * // 手动设置名称 - * await renameConversation('conv-123', '关于编程的讨论'); - * - * // AI 自动生成名称 - * await renameConversation('conv-123', '', true); - * ``` - */ -export const renameConversation = async (id: string, name: string, autoGenerate: boolean = false) => { - try { - const response = await axios.post(`${CHAT_CONFIG.API_URL}/conversations/${id}/name`, { - name: autoGenerate ? undefined : name, - auto_generate: autoGenerate, - // 不再添加user参数 - }, { - headers: { - 'Content-Type': 'application/json', - }, - withCredentials: true, // 携带cookie - }); - return response.data; - } catch (err) { - if (axios.isAxiosError(err)) { - throw new Error(`Failed to rename conversation: ${err.response?.status}`); - } - throw err; - } -}; - -/** - * 删除会话 - * - * 从用户的会话列表中永久删除指定会话: - * - 删除会话及其所有消息 - * - 操作不可逆 - * - 删除后从会话列表中移除 - * - * @param id - 要删除的会话 ID - * @returns Promise - 删除操作结果 - * - * @throws {Error} 当删除会话失败时抛出错误 - * - * @example - * ```typescript - * await deleteConversation('conv-123'); - * console.log('会话已删除'); - * ``` - */ -export const deleteConversation = async (id: string) => { - console.log('🗑️ [API Client] 删除会话:', id); - - try { - const response = await axios.delete(`${CHAT_CONFIG.API_URL}/conversations/${id}`, { - headers: { - 'Content-Type': 'application/json', - }, - withCredentials: true, // 携带cookie - // 不再发送body和user参数 - }); - - console.log('🗑️ [API Client] 删除会话响应:', { - status: response.status, - statusText: response.statusText - }); - - console.log('🗑️ [API Client] 删除会话数据:', response.data); - return response.data; - } catch (err) { - if (axios.isAxiosError(err)) { - console.error('❌ [API Client] 删除会话失败详情:', err.response?.data); - throw new Error(`Failed to delete conversation: ${err.response?.status}`); - } - throw err; - } -}; - -/** - * 上传文件到 Dify API[未使用] - * - * 使用 XMLHttpRequest 上传文件: - * - 支持文件上传进度监控 - * - 自动添加认证头和用户 ID - * - 返回文件 ID 用于后续引用 - * - * @param fetchOptions - 上传配置选项 - * @param fetchOptions.method - HTTP 方法(通常为 'POST') - * @param fetchOptions.url - 上传 URL(可选,会自动构建) - * @param fetchOptions.data - FormData 对象,包含要上传的文件 - * @param fetchOptions.headers - 额外的请求头 - * @param fetchOptions.xhr - XMLHttpRequest 实例 - * @param fetchOptions.onprogress - 上传进度回调函数 - * @returns Promise - 返回包含文件 ID 的对象 - * @returns Promise.id - 上传后的文件 ID - * - * @throws {Error} 当文件上传失败时抛出错误 - * - * @example - * ```typescript - * const formData = new FormData(); - * formData.append('file', fileBlob); - * - * const xhr = new XMLHttpRequest(); - * const result = await upload({ - * data: formData, - * xhr: xhr, - * onprogress: (event) => { - * const progress = (event.loaded / event.total) * 100; - * console.log('上传进度:', progress + '%'); - * } - * }); - * console.log('文件ID:', result.id); - * ``` - */ -export const upload = (fetchOptions: any): Promise => { - const urlWithPrefix = `${CHAT_CONFIG.API_URL}/files/upload`; - - const defaultOptions = { - method: 'POST', - url: urlWithPrefix, - data: {}, - }; - - const options = { - ...defaultOptions, - ...fetchOptions, - }; - - return new Promise((resolve, reject) => { - const xhr = options.xhr; - xhr.open(options.method, options.url); - - for (const key in options.headers) - xhr.setRequestHeader(key, options.headers[key]); - - // 不再手动添加Authorization头,由服务端处理 - - // 不再添加user参数到formData - // 服务端会从JWT自动提取 - - xhr.withCredentials = true; // 改为true以携带cookie - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status === 200) - resolve({ id: xhr.response }); - else - reject(new Error(xhr.responseText || 'Upload failed')); - } - }; - - xhr.upload.onprogress = options.onprogress; - xhr.send(options.data); - }); -}; - -/** - * 通用 HTTP 请求函数 - * - * 基于 baseFetch 的通用请求封装: - * - 合并基础配置和自定义选项 - * - 统一的错误处理 - * - 支持所有 HTTP 方法 - * - * @param url - 请求 URL 路径 - * @param options - 请求配置选项,默认为空对象 - * @param needAllResponseContent - 是否返回完整响应内容,默认为 false - * @returns Promise - 请求响应数据 - * - * @example - * ```typescript - * const data = await request('conversations', { method: 'GET' }); - * ``` - */ -export const request = (url: string, options = {}, needAllResponseContent = false) => { - return baseFetch(url, { ...baseOptions, ...options }, needAllResponseContent); -}; - -/** - * 发送 GET 请求 - * - * @param url - 请求 URL 路径 - * @param options - 额外的请求配置选项,默认为空对象 - * @returns Promise - 请求响应数据 - * - * @example - * ```typescript - * const conversations = await get('conversations'); - * ``` - */ -export const get = (url: string, options = {}) => { - return request(url, { ...options, method: 'GET' }); -}; - -/** - * 发送 POST 请求 - * - * @param url - 请求 URL 路径 - * @param options - 额外的请求配置选项,默认为空对象 - * @returns Promise - 请求响应数据 - * - * @example - * ```typescript - * const result = await post('chat-messages', { - * body: { query: 'Hello' } - * }); - * ``` - */ -export const post = (url: string, options = {}) => { - return request(url, { ...options, method: 'POST' }); -}; - -/** - * 发送 PUT 请求 - * - * @param url - 请求 URL 路径 - * @param options - 额外的请求配置选项,默认为空对象 - * @returns Promise - 请求响应数据 - * - * @example - * ```typescript - * const result = await put('conversations/123', { - * body: { name: '新名称' } - * }); - * ``` - */ -export const put = (url: string, options = {}) => { - return request(url, { ...options, method: 'PUT' }); -}; - -/** - * 发送 DELETE 请求 - * - * @param url - 请求 URL 路径 - * @param options - 额外的请求配置选项,默认为空对象 - * @returns Promise - 请求响应数据 - * - * @example - * ```typescript - * await del('conversations/123'); - * ``` - */ -export const del = (url: string, options = {}) => { - return request(url, { ...options, method: 'DELETE' }); -}; - -/** - * 发送聊天消息 - * - * 向 Dify API 发送聊天消息并处理流式响应: - * - 自动设置流式响应模式 - * - 支持文件附件 - * - 支持会话输入参数 - * - 处理各种类型的响应事件 - * - * @param body - 消息请求体 - * @param body.query - 用户的问题文本 - * @param body.conversation_id - 会话 ID(可选,用于继续现有会话) - * @param body.files - 附件文件列表(可选) - * @param body.inputs - 会话输入参数(可选) - * @param callbacks - 事件回调函数集合 - * @param callbacks.onData - 处理消息数据的回调,必需 - * @param callbacks.onCompleted - 流式响应完成时的回调,必需 - * @param callbacks.onFile - 处理文件的回调(可选) - * @param callbacks.onThought - 处理思考过程的回调(可选) - * @param callbacks.onMessageEnd - 处理消息结束的回调(可选) - * @param callbacks.onMessageReplace - 处理消息替换的回调(可选) - * @param callbacks.onError - 处理错误的回调(可选) - * @param callbacks.getAbortController - 获取中止控制器的回调(可选) - * @param callbacks.onWorkflowStarted - 处理工作流开始的回调(可选) - * @param callbacks.onNodeStarted - 处理节点开始的回调(可选) - * @param callbacks.onNodeFinished - 处理节点完成的回调(可选) - * @param callbacks.onWorkflowFinished - 处理工作流完成的回调(可选) - * @returns Promise - 异步操作完成 - * - * @example - * ```typescript - * await sendChatMessage({ - * query: '你好,请介绍一下自己', - * conversation_id: 'conv-123' - * }, { - * onData: (message, isFirst, info) => { - * console.log('收到消息:', message); - * updateChatUI(message, info.messageId); - * }, - * onCompleted: (hasError) => { - * console.log('对话完成', hasError ? '有错误' : '成功'); - * setLoading(false); - * }, - * onThought: (thought) => { - * console.log('AI思考:', thought.thought); - * }, - * onError: (error) => { - * console.error('发送失败:', error); - * showErrorMessage(error); - * } - * }); - * ``` - */ -export const sendChatMessage = async ( - body: Record, - { - onData, - onCompleted, - onThought, - onFile, - onError, - getAbortController, - onMessageEnd, - onMessageReplace, - onWorkflowStarted, - onNodeStarted, - onNodeFinished, - onWorkflowFinished, - }: { - onData: IOnData; - onCompleted: IOnCompleted; - onFile?: IOnFile; - onThought?: IOnThought; - onMessageEnd?: IOnMessageEnd; - onMessageReplace?: IOnMessageReplace; - onError?: IOnError; - getAbortController?: (abortController: AbortController) => void; - onWorkflowStarted?: IOnWorkflowStarted; - onNodeStarted?: IOnNodeStarted; - onNodeFinished?: IOnNodeFinished; - onWorkflowFinished?: IOnWorkflowFinished; - }, -) => { - return ssePost('chat-messages', { - body: { - ...body, - response_mode: 'streaming', - }, - }, { - onData, - onCompleted, - onThought, - onFile, - onError, - getAbortController, - onMessageEnd, - onMessageReplace, - onNodeStarted, - onWorkflowStarted, - onWorkflowFinished, - onNodeFinished - }); -}; \ No newline at end of file diff --git a/app/services/dify-client.server.ts b/app/services/dify-client.server.ts deleted file mode 100644 index 1fc265a..0000000 --- a/app/services/dify-client.server.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { API_BASE_URL } from '~/config/api-config'; - -// 获取环境变量的服务端函数 -const getServerEnvVar = (name: string, defaultValue: string = '') => { - const value = process.env[name] || defaultValue; - // console.log(`🌐 [DifyClient] 读取环境变量 ${name}:`, { - // hasValue: !!process.env[name], - // value: process.env[name] ? `${process.env[name].substring(0, 20)}...` : 'undefined', - // usingDefault: !process.env[name] - // }); - return value; -}; - -// Dify API 客户端配置 -// 注意:现在通过 FastAPI 后端的 /dify 路由代理访问 Dify,使用 JWT 认证 -const DIFY_CONFIG = { - // API_URL 指向 FastAPI 后端的 /dify 路由 - // API_BASE_URL 来自 api-config.ts,根据环境/端口自动配置 - API_URL: `${API_BASE_URL}/dify`, - // API_KEY 保留用于配置验证(实际不再使用,改用JWT) - API_KEY: getServerEnvVar('NEXT_PUBLIC_APP_KEY', ''), - APP_ID: (() => { - const rawAppId = getServerEnvVar('NEXT_PUBLIC_APP_ID', ''); - // 从完整URL中提取APP ID - const match = rawAppId.match(/\/app\/([a-f0-9-]{36})/); - return match ? match[1] : rawAppId; - })(), -}; - -console.log('🔧 Dify Client Config:', { - apiUrl: DIFY_CONFIG.API_URL, - apiBaseUrl: API_BASE_URL, - fullDifyUrl: `${API_BASE_URL}/dify`, - appId: DIFY_CONFIG.APP_ID, - hasApiKey: !!DIFY_CONFIG.API_KEY, - configComplete: !!(DIFY_CONFIG.API_URL && DIFY_CONFIG.APP_ID) -}); - -// 基础请求函数 - 使用 JWT 认证通过 FastAPI 代理访问 Dify -const difyFetch = async (endpoint: string, options: RequestInit = {}, jwt?: string) => { - const url = `${DIFY_CONFIG.API_URL}/${endpoint.replace(/^\//, '')}`; - - console.log('🌐 [DifyClient] 请求FastAPI代理:', { - endpoint, - fullUrl: url, - baseUrl: API_BASE_URL, - hasJWT: !!jwt - }); - - // 使用 JWT 认证而非 API_KEY - const headers: HeadersInit = { - 'Content-Type': 'application/json', - ...options.headers, - }; - - // 如果提供了 JWT,添加到请求头 - if (jwt) { - (headers as Record)['Authorization'] = `Bearer ${jwt}`; - } else { - console.warn('⚠️ [DifyClient] 没有提供 JWT,请求可能失败'); - } - - console.log('🌐 [DifyClient] Dify API Request:', { - url, - method: options.method || 'GET', - hasJWT: !!jwt, - jwtPreview: jwt ? `${jwt.substring(0, 20)}...` : 'none' - }); - - const response = await fetch(url, { - ...options, - headers, - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error('❌ [DifyClient] Dify API Error:', { - status: response.status, - statusText: response.statusText, - error: errorText - }); - - // 如果是401错误,说明JWT过期或无效 - if (response.status === 401) { - throw new Error('JWT认证失败,请重新登录'); - } - - throw new Error(`Dify API Error: ${response.status} ${response.statusText}`); - } - - return response; -}; - -// Dify API 客户端 - 所有方法都需要传入 JWT -// 注意:user 参数已移除,由后端自动从 JWT 中提取 username -export const difyClient = { - // 获取应用参数 - async getApplicationParameters(jwt?: string) { - const response = await difyFetch('parameters', { - method: 'GET', - }, jwt); - return response.json(); - }, - - // 获取会话列表 - async getConversations(jwt?: string) { - const params = new URLSearchParams({ - limit: '100', - first_id: '', - }); - - const response = await difyFetch(`conversations?${params}`, { - method: 'GET', - }, jwt); - return response.json(); - }, - - // 获取会话消息 - async getConversationMessages(conversationId: string, jwt?: string) { - const params = new URLSearchParams({ - conversation_id: conversationId, - limit: '20', - last_id: '', - }); - - const response = await difyFetch(`messages?${params}`, { - method: 'GET', - }, jwt); - return response.json(); - }, - - // 发送聊天消息 - async createChatMessage( - inputs: Record, - query: string, - responseMode: string = 'streaming', - conversationId?: string, - files?: any[], - jwt?: string - ) { - const body = { - inputs, - query, - // user 字段已移除,后端会自动从 JWT 中提取 username - response_mode: responseMode, - conversation_id: conversationId, - files: files || [], - }; - - console.log('🌐 [DifyClient] 发送聊天消息:', { - queryLength: query.length, - queryPreview: query.substring(0, 100) + (query.length > 100 ? '...' : ''), - responseMode, - conversationId, - hasInputs: !!inputs && Object.keys(inputs).length > 0, - inputsKeys: inputs ? Object.keys(inputs) : [], - hasFiles: !!files && files.length > 0, - filesCount: files?.length || 0, - hasJWT: !!jwt - }); - - const response = await difyFetch('chat-messages', { - method: 'POST', - body: JSON.stringify(body), - }, jwt); - - console.log('📡 [DifyClient] Dify API响应:', { - status: response.status, - statusText: response.statusText, - hasBody: !!response.body, - contentType: response.headers.get('Content-Type'), - responseMode - }); - - // 对于流式响应,直接返回Response对象 - if (responseMode === 'streaming') { - console.log('🌊 [DifyClient] 返回流式响应对象'); - return response; - } - - console.log('📄 [DifyClient] 解析JSON响应'); - return response.json(); - }, - - // 重命名会话 - async renameConversation(conversationId: string, name: string, autoGenerate: boolean = false, jwt?: string) { - const body = { - name, - auto_generate: autoGenerate, - // user 字段已移除,后端会自动从 JWT 中提取 username - }; - - const response = await difyFetch(`conversations/${conversationId}/name`, { - method: 'POST', - body: JSON.stringify(body), - }, jwt); - return response.json(); - }, - - // 删除会话 - async deleteConversation(conversationId: string, jwt?: string) { - // user 字段已移除,后端会自动从 JWT 中提取 username - const body = {}; - - console.log('🗑️ [DifyClient] 删除会话:', conversationId); - - try { - const response = await difyFetch(`conversations/${conversationId}`, { - method: 'DELETE', - body: JSON.stringify(body), - }, jwt); - - console.log('🗑️ [DifyClient] 删除会话响应:', { - status: response.status, - statusText: response.statusText, - contentType: response.headers.get('Content-Type') - }); - - // 检查响应的Content-Type - const contentType = response.headers.get('Content-Type'); - - // 如果是JSON响应,解析JSON - if (contentType && contentType.includes('application/json')) { - const data = await response.json(); - console.log('🗑️ [DifyClient] 删除会话JSON响应:', data); - return data; - } - - // 如果不是JSON,返回成功标识 - const text = await response.text(); - console.log('🗑️ [DifyClient] 删除会话文本响应:', text); - - // 返回标准成功响应 - return { result: 'success' }; - } catch (error: any) { - console.warn('⚠️ [DifyClient] 删除会话请求失败,但可能已成功删除:', error.message); - - // 删除操作的特殊处理: - // 即使API返回错误,实际上会话可能已经被删除 - // 返回成功标识,避免误报错误 - // 如果会话确实不存在,下次加载会话列表时就会发现 - return { result: 'success' }; - } - }, - - // 更新消息反馈 - async updateMessageFeedback(messageId: string, rating: 'like' | 'dislike' | null, jwt?: string) { - const body = { - rating, - // user 字段已移除,后端会自动从 JWT 中提取 username - }; - - const response = await difyFetch(`messages/${messageId}/feedbacks`, { - method: 'POST', - body: JSON.stringify(body), - }, jwt); - return response.json(); - }, -}; - -// 工具函数 -export const difyUtils = { - getConfig: () => DIFY_CONFIG, -}; \ No newline at end of file diff --git a/app/types/dify_chat.ts b/app/types/dify_chat.ts deleted file mode 100644 index 256d6f5..0000000 --- a/app/types/dify_chat.ts +++ /dev/null @@ -1,264 +0,0 @@ -// 应用信息类型 -export interface AppInfo { - title: string; - description: string; - copyright: string; - privacy_policy: string; - default_language: string; -} - -// 会话项类型 -export interface ConversationItem { - id: string; - name: string; - inputs?: Record; - introduction?: string; -} - -// 聊天消息类型 -export interface ChatItem { - id: string; - content: string; - isAnswer: boolean; - feedback?: Feedbacktype; - agent_thoughts?: ThoughtItem[]; - message_files?: VisionFile[]; - isError?: boolean; - workflow_run_id?: string; - workflowProcess?: WorkflowProcess; - more?: MessageMore; - useCurrentUserAvatar?: boolean; - isOpeningStatement?: boolean; - suggestedQuestions?: string[]; -} - -// 消息更多信息类型 -export interface MessageMore { - time: string; - tokens: number; - latency: number | string; -} - -// 反馈类型 -export type Feedbacktype = { - rating: 'like' | 'dislike' | null; - content?: string; -} - -// 思考过程类型 -export interface ThoughtItem { - id?: string; - chain_id?: string; - thought?: string; - observation?: string; - message_files?: VisionFile[]; - tool_name?: string; - tool_input?: string; - tool_output?: string; - tool_finished?: boolean; - parent_id?: string; - children_ids?: string[]; - sort?: number; - message_id?: string; - tool?: string; - position?: number; - files?: string[]; -} - -// 文本类型表单项 -export interface TextTypeFormItem { - label: string; - variable: string; - required: boolean; - max_length: number; -} - -// 选择类型表单项 -export interface SelectTypeFormItem { - label: string; - variable: string; - required: boolean; - options: string[]; -} - -// 用户输入表单项 -export type UserInputFormItem = { - 'text-input': TextTypeFormItem; -} | { - 'select': SelectTypeFormItem; -} | { - 'paragraph': TextTypeFormItem; -} - -// 提示配置类型 -export interface PromptConfig { - prompt_template: string; - prompt_variables: PromptVariable[]; -} - -// 提示变量类型 -export interface PromptVariable { - key: string; - name: string; - type: string; - required: boolean; - options?: string[]; - max_length?: number; - allowed_file_extensions?: string[]; - allowed_file_types?: string[]; - allowed_file_upload_methods?: TransferMethod[]; -} - -// 视觉文件类型 -export interface VisionFile { - id?: string; - type: string; - transfer_method: TransferMethod; - url?: string; - upload_file_id?: string; - belongs_to?: string; - usage?: string; - result?: any; - detail?: Resolution; -} - -// 图片文件类型 -export interface ImageFile { - type: TransferMethod; - _id: string; - fileId: string; - file?: File; - progress: number; - url: string; - base64Url?: string; - deleted?: boolean; -} - -// 视觉设置类型 -export interface VisionSettings { - enabled: boolean; - detail?: Resolution; - number_limits?: number; - transfer_methods?: TransferMethod[]; - image_file_size_limit?: number | string; -} - -// 分辨率枚举 -export enum Resolution { - low = 'low', - high = 'high', -} - -// 传输方法枚举 -export enum TransferMethod { - local_file = 'local_file', - remote_url = 'remote_url', - all = 'all', -} - -// 工作流运行状态枚举 -export enum WorkflowRunningStatus { - init = 'init', - running = 'running', - completed = 'completed', - error = 'error', - waiting = 'waiting', - succeeded = 'succeeded', - failed = 'failed', - stopped = 'stopped', -} - -// 节点运行状态枚举 -export enum NodeRunningStatus { - NotStart = 'not-start', - Waiting = 'waiting', - Running = 'running', - Succeeded = 'succeeded', - Failed = 'failed', -} - -// 块类型枚举 -export enum BlockEnum { - Start = 'start', - End = 'end', - Answer = 'answer', - LLM = 'llm', - KnowledgeRetrieval = 'knowledge-retrieval', - QuestionClassifier = 'question-classifier', - IfElse = 'if-else', - Code = 'code', - TemplateTransform = 'template-transform', - HttpRequest = 'http-request', - VariableAssigner = 'variable-assigner', - Tool = 'tool', -} - -// 节点追踪类型 -export interface NodeTracing { - id: string; - index: number; - predecessor_node_id: string; - node_id: string; - node_type: BlockEnum; - title: string; - inputs: any; - process_data: any; - outputs?: any; - status: string; - error?: string; - elapsed_time: number; - execution_metadata: { - total_tokens: number; - total_price: number; - currency: string; - }; - created_at: number; - created_by: { - id: string; - name: string; - email: string; - }; - finished_at: number; - extras?: any; - expand?: boolean; // for UI -} - -// 工作流进程类型 -export interface WorkflowProcess { - status: WorkflowRunningStatus; - tracing: NodeTracing[]; - expand?: boolean; // for UI -} - -// 代码语言枚举 -export enum CodeLanguage { - python3 = 'python3', - javascript = 'javascript', - json = 'json', -} - -// 消息事件类型 -export interface MessageEvent { - event: string; - task_id: string; - conversation_id: string; - message_id: string; - answer: string; -} - -// 消息替换类型 -export interface MessageReplace { - event: string; - task_id: string; - conversation_id: string; - message_id: string; - answer: string; -} - -// 消息结束类型 -export interface MessageEnd { - event: string; - task_id: string; - conversation_id: string; - message_id: string; -} \ No newline at end of file diff --git a/app/utils/chat-utils.ts b/app/utils/chat-utils.ts index 73c1091..b7e6a0c 100644 --- a/app/utils/chat-utils.ts +++ b/app/utils/chat-utils.ts @@ -1,4 +1,4 @@ -import type { PromptVariable, ThoughtItem, UserInputFormItem, VisionFile } from '../types/dify_chat'; +import type { PromptVariable, ThoughtItem, UserInputFormItem, VisionFile } from '~/api/dify'; /** * 替换提示模板中的变量 From c94cc00138be0ad412a38cdbf12ac8964f3f5023 Mon Sep 17 00:00:00 2001 From: PingChuan <1259732256@qq.com> Date: Sun, 30 Nov 2025 19:27:01 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=E5=89=8D=E7=AB=AF=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=88=9D=E7=89=88=E7=9F=A5=E8=AF=86=E5=BA=93=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client.server.ts => dify-chat/chat.ts} | 128 +-- app/api/dify-chat/client.server.ts | 117 +++ app/api/{dify => dify-chat}/client.ts | 0 app/api/{dify => dify-chat}/index.ts | 0 app/api/{dify => dify-chat}/sse-handler.ts | 0 app/api/{dify => dify-chat}/types.ts | 0 app/api/dify-dataset/client.ts | 300 +++++++ app/api/dify-dataset/index.ts | 36 + app/api/dify-dataset/types.ts | 167 ++++ .../{chat => dify-chat}/chat-input.tsx | 508 +++++------ .../{chat => dify-chat}/chat-message.tsx | 14 +- app/components/{chat => dify-chat}/index.tsx | 13 +- .../{chat => dify-chat}/markdown.tsx | 0 .../{chat => dify-chat}/sidebar.tsx | 794 +++++++++--------- .../{chat => dify-chat}/thinking-block.tsx | 0 .../{chat => dify-chat}/thought-process.tsx | 438 +++++----- .../dify-dataset-manager/document-list.tsx | 373 ++++++++ app/components/dify-dataset-manager/index.tsx | 241 ++++++ .../dify-dataset-manager/sidebar.tsx | 160 ++++ app/components/layout/Sidebar.tsx | 5 +- app/config/chat.ts | 1 - app/hooks/use-chat-message.ts | 4 +- app/hooks/use-conversation.ts | 8 +- app/routes/api.chat-messages.tsx | 3 +- app/routes/api.conversations.$id.name.tsx | 4 +- app/routes/api.conversations.$id.tsx | 4 +- app/routes/api.conversations.tsx | 4 +- ...datasetId.documents.$documentId.status.tsx | 56 ++ ...asets.$datasetId.documents.$documentId.tsx | 118 +++ ....dataset.datasets.$datasetId.documents.tsx | 119 +++ app/routes/api.dataset.datasets.tsx | 60 ++ .../api.messages.$messageId.feedbacks.tsx | 2 +- app/routes/api.parameters.tsx | 4 +- app/routes/chat-with-llm._index.tsx | 2 +- app/routes/dataset-manager._index.tsx | 26 + app/routes/dataset-manager.tsx | 20 + .../dify-dataset-manager/document-list.css | 91 ++ .../components/dify-dataset-manager/index.css | 101 +++ .../dify-dataset-manager/sidebar.css | 135 +++ app/utils/chat-utils.ts | 2 +- 40 files changed, 3034 insertions(+), 1024 deletions(-) rename app/api/{dify/client.server.ts => dify-chat/chat.ts} (54%) create mode 100644 app/api/dify-chat/client.server.ts rename app/api/{dify => dify-chat}/client.ts (100%) rename app/api/{dify => dify-chat}/index.ts (100%) rename app/api/{dify => dify-chat}/sse-handler.ts (100%) rename app/api/{dify => dify-chat}/types.ts (100%) create mode 100644 app/api/dify-dataset/client.ts create mode 100644 app/api/dify-dataset/index.ts create mode 100644 app/api/dify-dataset/types.ts rename app/components/{chat => dify-chat}/chat-input.tsx (94%) rename app/components/{chat => dify-chat}/chat-message.tsx (97%) rename app/components/{chat => dify-chat}/index.tsx (99%) rename app/components/{chat => dify-chat}/markdown.tsx (100%) rename app/components/{chat => dify-chat}/sidebar.tsx (96%) rename app/components/{chat => dify-chat}/thinking-block.tsx (100%) rename app/components/{chat => dify-chat}/thought-process.tsx (93%) create mode 100644 app/components/dify-dataset-manager/document-list.tsx create mode 100644 app/components/dify-dataset-manager/index.tsx create mode 100644 app/components/dify-dataset-manager/sidebar.tsx create mode 100644 app/routes/api.dataset.datasets.$datasetId.documents.$documentId.status.tsx create mode 100644 app/routes/api.dataset.datasets.$datasetId.documents.$documentId.tsx create mode 100644 app/routes/api.dataset.datasets.$datasetId.documents.tsx create mode 100644 app/routes/api.dataset.datasets.tsx create mode 100644 app/routes/dataset-manager._index.tsx create mode 100644 app/routes/dataset-manager.tsx create mode 100644 app/styles/components/dify-dataset-manager/document-list.css create mode 100644 app/styles/components/dify-dataset-manager/index.css create mode 100644 app/styles/components/dify-dataset-manager/sidebar.css diff --git a/app/api/dify/client.server.ts b/app/api/dify-chat/chat.ts similarity index 54% rename from app/api/dify/client.server.ts rename to app/api/dify-chat/chat.ts index 7871bd8..8a8d531 100644 --- a/app/api/dify/client.server.ts +++ b/app/api/dify-chat/chat.ts @@ -1,112 +1,22 @@ /** - * Dify 服务端 API 模块 + * Dify Chat API 模块 * - * 提供 Node.js 服务端调用 FastAPI 后端的函数 + * 提供客户端调用 Dify API 的函数 * 用于 Remix loader/action 中调用 Dify API * - * 调用链路: - * Remix Server → FastAPI /dify/* → Dify - * - * @module api/dify/client.server + * @module api/dify/chat */ -import { API_BASE_URL } from '~/config/api-config'; +import { difyFetch } from './client.server'; // ============================================================================ -// 配置 +// Dify Chat API 客户端 // ============================================================================ /** - * 获取环境变量的服务端函数 - */ -const getServerEnvVar = (name: string, defaultValue: string = ''): string => { - return process.env[name] || defaultValue; -}; - -/** - * Dify API 客户端配置 - * 通过 FastAPI 后端的 /dify 路由代理访问 Dify - */ -const DIFY_CONFIG = { - API_URL: `${API_BASE_URL}/dify`, - API_KEY: getServerEnvVar('NEXT_PUBLIC_APP_KEY', ''), - APP_ID: (() => { - const rawAppId = getServerEnvVar('NEXT_PUBLIC_APP_ID', ''); - const match = rawAppId.match(/\/app\/([a-f0-9-]{36})/); - return match ? match[1] : rawAppId; - })(), -}; - -console.log('🔧 [Dify Server] 配置:', { - apiUrl: DIFY_CONFIG.API_URL, - apiBaseUrl: API_BASE_URL, - appId: DIFY_CONFIG.APP_ID, - configComplete: !!(DIFY_CONFIG.API_URL && DIFY_CONFIG.APP_ID) -}); - -// ============================================================================ -// 基础请求函数 -// ============================================================================ - -/** - * Dify API 基础请求函数 + * Dify Chat API 客户端 * - * 使用 JWT 认证通过 FastAPI 代理访问 Dify - * - * @param endpoint - API 端点路径 - * @param options - fetch 请求选项 * @param jwt - JWT 认证令牌 - * @returns Response 对象 - */ -async function difyFetch( - endpoint: string, - options: RequestInit = {}, - jwt?: string -): Promise { - const url = `${DIFY_CONFIG.API_URL}/${endpoint.replace(/^\//, '')}`; - - const headers: HeadersInit = { - 'Content-Type': 'application/json', - ...options.headers, - }; - - if (jwt) { - (headers as Record)['Authorization'] = `Bearer ${jwt}`; - } else { - console.warn('⚠️ [Dify Server] 没有提供 JWT,请求可能失败'); - } - - const response = await fetch(url, { - ...options, - headers, - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error('❌ [Dify Server] Dify API 错误:', { - status: response.status, - statusText: response.statusText, - error: errorText - }); - - if (response.status === 401) { - throw new Error('JWT认证失败,请重新登录'); - } - - throw new Error(`Dify API Error: ${response.status} ${response.statusText}`); - } - - return response; -} - -// ============================================================================ -// Dify API 客户端 -// ============================================================================ - -/** - * Dify 服务端 API 客户端 - * - * 所有方法都需要传入 JWT 进行认证 * user 参数由后端自动从 JWT 中提取 */ export const difyClient = { @@ -187,7 +97,7 @@ export const difyClient = { return response; } - console.log('[Dify Server] 解析 JSON 响应'); + console.log('[Dify Chat] 解析 JSON 响应'); return response.json(); }, @@ -216,7 +126,7 @@ export const difyClient = { * 删除会话 */ async deleteConversation(conversationId: string, jwt?: string): Promise { - console.log('remix后端接收到删除请求,调用fastapi:', conversationId); + console.log('[Dify Chat] 删除会话:', conversationId); try { const response = await difyFetch(`conversations/${conversationId}`, { @@ -224,10 +134,9 @@ export const difyClient = { body: JSON.stringify({}), }, jwt); - // 对于 204 No Content 响应,直接返回成功 if (response.status === 204) { - console.log('删除会话' + conversationId + '成功'); + console.log('[Dify Chat] 删除会话成功:', conversationId); return { result: 'success' }; } @@ -235,16 +144,15 @@ export const difyClient = { if (contentType && contentType.includes('application/json')) { const data = await response.json(); - console.log('🗑️ [Dify Server] 删除会话 JSON 响应:', data); return data; } const text = await response.text(); - console.log('🗑️ [Dify Server] 删除会话文本响应:', text); + console.log('[Dify Chat] 删除会话文本响应:', text); return { result: 'success' }; } catch (error: any) { - console.warn('⚠️ [Dify Server] 删除会话请求失败,但可能已成功删除:', error.message); + console.warn('[Dify Chat] 删除会话请求失败,但可能已成功删除:', error.message); return { result: 'success' }; } }, @@ -268,17 +176,3 @@ export const difyClient = { return response.json(); }, }; - -// ============================================================================ -// 工具函数 -// ============================================================================ - -/** - * Dify 工具函数 - */ -export const difyUtils = { - /** - * 获取 Dify 配置 - */ - getConfig: () => DIFY_CONFIG, -}; diff --git a/app/api/dify-chat/client.server.ts b/app/api/dify-chat/client.server.ts new file mode 100644 index 0000000..210d6f3 --- /dev/null +++ b/app/api/dify-chat/client.server.ts @@ -0,0 +1,117 @@ +/** + * Dify 服务端基础 API 模块 + * + * 提供 Node.js 服务端调用 FastAPI 后端的基础功能 + * 包括配置管理和基础请求函数 + * + * 调用链路: + * Remix Server → FastAPI /dify/* → Dify + * + * @module api/dify/client.server + */ + +import { API_BASE_URL } from '~/config/api-config'; + +// ============================================================================ +// 配置 +// ============================================================================ + +/** + * 获取环境变量的服务端函数 + */ +const getServerEnvVar = (name: string, defaultValue: string = ''): string => { + return process.env[name] || defaultValue; +}; + +/** + * Dify API 客户端配置 + * 通过 FastAPI 后端的 /dify 路由代理访问 Dify + */ +const DIFY_CONFIG = { + API_URL: `${API_BASE_URL}/dify_chat`, + API_KEY: getServerEnvVar('NEXT_PUBLIC_APP_KEY', ''), + APP_ID: (() => { + const rawAppId = getServerEnvVar('NEXT_PUBLIC_APP_ID', ''); + const match = rawAppId.match(/\/app\/([a-f0-9-]{36})/); + return match ? match[1] : rawAppId; + })(), +}; + +console.log('[Dify Server] 配置:', { + apiUrl: DIFY_CONFIG.API_URL, + apiBaseUrl: API_BASE_URL, + appId: DIFY_CONFIG.APP_ID, + configComplete: !!(DIFY_CONFIG.API_URL && DIFY_CONFIG.APP_ID) +}); + +// ============================================================================ +// 基础请求函数 +// ============================================================================ + +/** + * Dify API 基础请求函数 + * + * 使用 JWT 认证通过 FastAPI 代理访问 Dify + * + * @param endpoint - API 端点路径 + * @param options - fetch 请求选项 + * @param jwt - JWT 认证令牌 + * @returns Response 对象 + */ +export async function difyFetch( + endpoint: string, + options: RequestInit = {}, + jwt?: string +): Promise { + const url = `${DIFY_CONFIG.API_URL}/${endpoint.replace(/^\//, '')}`; + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...options.headers, + }; + + if (jwt) { + (headers as Record)['Authorization'] = `Bearer ${jwt}`; + } else { + console.warn('[Dify Server] 没有提供 JWT,转发fastapi请求可能失败'); + } + + const response = await fetch(url, { + ...options, + headers, + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('[Dify Server] API 转发错误:', { + status: response.status, + statusText: response.statusText, + error: errorText + }); + + if (response.status === 401) { + throw new Error('JWT认证失败,请重新登录'); + } + + throw new Error(`Dify API Error: ${response.status} ${response.statusText}`); + } + + return response; +} + +// ============================================================================ +// 工具函数 +// ============================================================================ + +/** + * Dify 工具函数 + */ +export const difyUtils = { + /** + * 获取 Dify 配置 + */ + getConfig: () => DIFY_CONFIG, +}; + +// 重新导出 chat 模块的 difyClient +export { difyClient } from './chat'; diff --git a/app/api/dify/client.ts b/app/api/dify-chat/client.ts similarity index 100% rename from app/api/dify/client.ts rename to app/api/dify-chat/client.ts diff --git a/app/api/dify/index.ts b/app/api/dify-chat/index.ts similarity index 100% rename from app/api/dify/index.ts rename to app/api/dify-chat/index.ts diff --git a/app/api/dify/sse-handler.ts b/app/api/dify-chat/sse-handler.ts similarity index 100% rename from app/api/dify/sse-handler.ts rename to app/api/dify-chat/sse-handler.ts diff --git a/app/api/dify/types.ts b/app/api/dify-chat/types.ts similarity index 100% rename from app/api/dify/types.ts rename to app/api/dify-chat/types.ts diff --git a/app/api/dify-dataset/client.ts b/app/api/dify-dataset/client.ts new file mode 100644 index 0000000..bc3eda4 --- /dev/null +++ b/app/api/dify-dataset/client.ts @@ -0,0 +1,300 @@ +/** + * Dify Dataset 客户端 API 模块 + * + * 提供浏览器端调用 Dify 知识库 API 的函数 + * 通过 Remix API Routes 代理请求 + * + * @module api/dify-dataset/client + */ + +import axios from 'axios'; +import type { + DatasetsResponse, + DocumentsResponse, + SegmentsResponse, + Document, + OperationResult, +} from './types'; + +// ============================================================================ +// 基础配置 +// ============================================================================ + +/** + * API 基础 URL + * 指向 Remix API Routes(/api/dataset/*) + */ +const API_URL = '/api/dataset'; + +// ============================================================================ +// 知识库 API +// ============================================================================ + +/** + * 获取知识库列表 + * + * @param page - 页码,默认 1 + * @param limit - 每页数量,默认 20 + * @returns 知识库列表响应 + */ +export async function fetchDatasets( + page: number = 1, + limit: number = 20 +): Promise { + const params = new URLSearchParams({ + page: page.toString(), + limit: limit.toString(), + }); + + const response = await axios.get( + `${API_URL}/datasets?${params}`, + { withCredentials: true } + ); + return response.data; +} + +/** + * 获取单个知识库详情 + * + * @param datasetId - 知识库 ID + * @returns 知识库详情 + */ +export async function fetchDataset(datasetId: string): Promise { + const response = await axios.get( + `${API_URL}/datasets/${datasetId}`, + { withCredentials: true } + ); + return response.data; +} + +// ============================================================================ +// 文档 API +// ============================================================================ + +/** + * 获取知识库文档列表 + * + * @param datasetId - 知识库 ID + * @param page - 页码,默认 1 + * @param limit - 每页数量,默认 20 + * @param keyword - 搜索关键词 + * @returns 文档列表响应 + */ +export async function fetchDocuments( + datasetId: string, + page: number = 1, + limit: number = 20, + keyword?: string +): Promise { + const params = new URLSearchParams({ + page: page.toString(), + limit: limit.toString(), + }); + + if (keyword) { + params.append('keyword', keyword); + } + + console.log('[Dataset Client] 获取文档列表:', { datasetId, page, limit, keyword }); + + const response = await axios.get( + `${API_URL}/datasets/${datasetId}/documents?${params}`, + { withCredentials: true } + ); + return response.data; +} + +/** + * 获取单个文档详情 + * + * @param datasetId - 知识库 ID + * @param documentId - 文档 ID + * @returns 文档详情 + */ +export async function fetchDocument( + datasetId: string, + documentId: string +): Promise { + const response = await axios.get( + `${API_URL}/datasets/${datasetId}/documents/${documentId}`, + { withCredentials: true } + ); + return response.data; +} + +/** + * 删除文档 + * + * @param datasetId - 知识库 ID + * @param documentId - 文档 ID + * @returns 操作结果 + */ +export async function deleteDocument( + datasetId: string, + documentId: string +): Promise { + console.log('[Dataset Client] 删除文档:', { datasetId, documentId }); + + const response = await axios.delete( + `${API_URL}/datasets/${datasetId}/documents/${documentId}`, + { withCredentials: true } + ); + return response.data; +} + +/** + * 启用/禁用文档 + * + * @param datasetId - 知识库 ID + * @param documentId - 文档 ID + * @param enabled - 是否启用 + * @returns 操作结果 + */ +export async function toggleDocumentStatus( + datasetId: string, + documentId: string, + enabled: boolean +): Promise { + console.log('[Dataset Client] 切换文档状态:', { datasetId, documentId, enabled }); + + const response = await axios.patch( + `${API_URL}/datasets/${datasetId}/documents/${documentId}/status`, + { enabled }, + { + headers: { 'Content-Type': 'application/json' }, + withCredentials: true, + } + ); + return response.data; +} + +// ============================================================================ +// 文档分段 API +// ============================================================================ + +/** + * 获取文档分段列表 + * + * @param datasetId - 知识库 ID + * @param documentId - 文档 ID + * @param page - 页码,默认 1 + * @param limit - 每页数量,默认 20 + * @param keyword - 搜索关键词 + * @returns 分段列表响应 + */ +export async function fetchSegments( + datasetId: string, + documentId: string, + page: number = 1, + limit: number = 20, + keyword?: string +): Promise { + const params = new URLSearchParams({ + page: page.toString(), + limit: limit.toString(), + }); + + if (keyword) { + params.append('keyword', keyword); + } + + console.log('[Dataset Client] 获取分段列表:', { datasetId, documentId, page, limit }); + + const response = await axios.get( + `${API_URL}/datasets/${datasetId}/documents/${documentId}/segments?${params}`, + { withCredentials: true } + ); + return response.data; +} + +/** + * 删除分段 + * + * @param datasetId - 知识库 ID + * @param documentId - 文档 ID + * @param segmentId - 分段 ID + * @returns 操作结果 + */ +export async function deleteSegment( + datasetId: string, + documentId: string, + segmentId: string +): Promise { + console.log('[Dataset Client] 删除分段:', { datasetId, documentId, segmentId }); + + const response = await axios.delete( + `${API_URL}/datasets/${datasetId}/documents/${documentId}/segments/${segmentId}`, + { withCredentials: true } + ); + return response.data; +} + +/** + * 启用/禁用分段 + * + * @param datasetId - 知识库 ID + * @param documentId - 文档 ID + * @param segmentId - 分段 ID + * @param enabled - 是否启用 + * @returns 操作结果 + */ +export async function toggleSegmentStatus( + datasetId: string, + documentId: string, + segmentId: string, + enabled: boolean +): Promise { + const response = await axios.patch( + `${API_URL}/datasets/${datasetId}/documents/${documentId}/segments/${segmentId}/status`, + { enabled }, + { + headers: { 'Content-Type': 'application/json' }, + withCredentials: true, + } + ); + return response.data; +} + +// ============================================================================ +// 文件上传 API +// ============================================================================ + +/** + * 上传文件到知识库 + * + * @param datasetId - 知识库 ID + * @param file - 文件对象 + * @param onProgress - 上传进度回调 + * @returns 创建的文档信息 + */ +export async function uploadDocument( + datasetId: string, + file: File, + onProgress?: (percent: number) => void +): Promise { + const formData = new FormData(); + formData.append('file', file); + formData.append('data', JSON.stringify({ + indexing_technique: 'high_quality', + process_rule: { + mode: 'automatic', + }, + })); + + console.log('[Dataset Client] 上传文档:', { datasetId, fileName: file.name }); + + const response = await axios.post( + `${API_URL}/datasets/${datasetId}/documents/create-by-file`, + formData, + { + withCredentials: true, + onUploadProgress: (progressEvent) => { + if (progressEvent.total && onProgress) { + const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total); + onProgress(percent); + } + }, + } + ); + return response.data; +} diff --git a/app/api/dify-dataset/index.ts b/app/api/dify-dataset/index.ts new file mode 100644 index 0000000..ade8698 --- /dev/null +++ b/app/api/dify-dataset/index.ts @@ -0,0 +1,36 @@ +/** + * Dify Dataset API 模块统一导出 + * + * @module api/dify-dataset + */ + +// 类型导出 +export type { + Dataset, + DatasetsResponse, + Document, + DocumentsResponse, + Segment, + SegmentsResponse, + IndexingStatus, + OperationResult, + CreateDocumentResponse, + UploadProgress, +} from './types'; + +// 客户端 API 导出 +export { + // 知识库 + fetchDatasets, + fetchDataset, + // 文档 + fetchDocuments, + fetchDocument, + deleteDocument, + toggleDocumentStatus, + uploadDocument, + // 分段 + fetchSegments, + deleteSegment, + toggleSegmentStatus, +} from './client'; diff --git a/app/api/dify-dataset/types.ts b/app/api/dify-dataset/types.ts new file mode 100644 index 0000000..037c275 --- /dev/null +++ b/app/api/dify-dataset/types.ts @@ -0,0 +1,167 @@ +/** + * Dify Dataset API 类型定义 + * + * @module api/dify-dataset/types + */ + +// ============================================================================ +// 知识库类型 +// ============================================================================ + +/** + * 知识库信息 + */ +export interface Dataset { + id: string; + name: string; + description: string; + permission: 'only_me' | 'all_team_members'; + data_source_type: 'upload_file' | 'notion_import' | 'website_crawl'; + indexing_technique: 'high_quality' | 'economy'; + app_count: number; + document_count: number; + word_count: number; + created_by: string; + created_at: number; + updated_by: string; + updated_at: number; +} + +/** + * 知识库列表响应 + */ +export interface DatasetsResponse { + data: Dataset[]; + has_more: boolean; + limit: number; + total: number; + page: number; +} + +// ============================================================================ +// 文档类型 +// ============================================================================ + +/** + * 文档索引状态 + */ +export type IndexingStatus = + | 'waiting' + | 'parsing' + | 'cleaning' + | 'splitting' + | 'indexing' + | 'paused' + | 'error' + | 'completed'; + +/** + * 文档信息 + */ +export interface Document { + id: string; + position: number; + data_source_type: 'upload_file' | 'notion_import' | 'website_crawl'; + data_source_info: { + upload_file_id?: string; + notion_page_id?: string; + website_url?: string; + }; + dataset_process_rule_id: string; + name: string; + created_from: string; + created_by: string; + created_at: number; + tokens: number; + indexing_status: IndexingStatus; + error?: string; + enabled: boolean; + disabled_at?: number; + disabled_by?: string; + archived: boolean; + display_status: string; + word_count: number; + hit_count: number; + doc_form: string; +} + +/** + * 文档列表响应 + */ +export interface DocumentsResponse { + data: Document[]; + has_more: boolean; + limit: number; + total: number; + page: number; +} + +// ============================================================================ +// 文档分段类型 +// ============================================================================ + +/** + * 文档分段 + */ +export interface Segment { + id: string; + position: number; + document_id: string; + content: string; + answer?: string; + word_count: number; + tokens: number; + keywords: string[]; + index_node_id: string; + index_node_hash: string; + hit_count: number; + enabled: boolean; + disabled_at?: number; + disabled_by?: string; + status: 'waiting' | 'completed' | 'error' | 'indexing'; + created_by: string; + created_at: number; + indexing_at?: number; + completed_at?: number; + error?: string; + stopped_at?: number; +} + +/** + * 分段列表响应 + */ +export interface SegmentsResponse { + data: Segment[]; + has_more: boolean; + limit: number; + total: number; +} + +// ============================================================================ +// 操作响应类型 +// ============================================================================ + +/** + * 通用操作结果 + */ +export interface OperationResult { + result: 'success' | 'error'; + message?: string; +} + +/** + * 创建文档响应 + */ +export interface CreateDocumentResponse { + document: Document; + batch: string; +} + +/** + * 文档上传进度 + */ +export interface UploadProgress { + loaded: number; + total: number; + percent: number; +} diff --git a/app/components/chat/chat-input.tsx b/app/components/dify-chat/chat-input.tsx similarity index 94% rename from app/components/chat/chat-input.tsx rename to app/components/dify-chat/chat-input.tsx index 2e3c024..f7d3964 100644 --- a/app/components/chat/chat-input.tsx +++ b/app/components/dify-chat/chat-input.tsx @@ -1,255 +1,255 @@ -import React, { useState, useRef, useCallback, useEffect } from 'react'; -import { Input, Button, Upload, Tooltip, message as antdMessage, Space } from 'antd'; -import { StopOutlined, PictureOutlined, CommentOutlined } from '@ant-design/icons'; -import type { VisionFile } from '~/api/dify'; -import '../../styles/components/chat-with-llm/chat-input.css'; - -const { TextArea } = Input; - -interface ChatInputProps { - onSendMessage: (message: string, files?: VisionFile[]) => void; - disabled?: boolean; - placeholder?: string; - onStop?: () => void; - isResponding?: boolean; - visionConfig?: { - enabled: boolean; - number_limits?: number; - image_file_size_limit?: number; - transfer_methods?: string[]; - }; -} - -/** - * 聊天输入组件 - */ -export default function ChatInput({ - onSendMessage, - disabled = false, - placeholder = '输入消息...', - onStop, - isResponding = false, - visionConfig, -}: ChatInputProps) { - const [message, setMessage] = useState(''); - const [files, setFiles] = useState([]); - const textareaRef = useRef(null); - const isComposing = useRef(false); - - /** - * 提交消息 - */ - const handleSubmit = () => { - if (!message.trim() || disabled) return; - - onSendMessage(message, files.length > 0 ? files : undefined); - setMessage(''); - setFiles([]); - - // 聚焦回输入框 - setTimeout(() => { - textareaRef.current?.focus(); - }, 10); - }; - - /** - * 处理键盘事件 - */ - const handleKeyDown = (e: React.KeyboardEvent) => { - // 处理输入法状态 - if (e.nativeEvent.isComposing) { - isComposing.current = true; - return; - } - - // Enter发送,Shift+Enter换行 - if (e.key === 'Enter' && !e.shiftKey && !isComposing.current) { - e.preventDefault(); - handleSubmit(); - } - }; - - /** - * 处理输入法结束 - */ - const handleCompositionEnd = () => { - isComposing.current = false; - }; - - /** - * 停止响应 - */ - const handleStop = () => { - onStop?.(); - }; - - /** - * 处理文件上传 - */ - const handleFileUpload = (file: File) => { - // 检查文件数量限制 - if (visionConfig?.number_limits && files.length >= visionConfig.number_limits) { - antdMessage.error(`最多只能上传 ${visionConfig.number_limits} 个文件`); - return false; - } - - // 检查文件大小限制 - if (visionConfig?.image_file_size_limit) { - const limitMB = visionConfig.image_file_size_limit; - const fileSizeMB = file.size / (1024 * 1024); - if (fileSizeMB > limitMB) { - antdMessage.error(`文件大小不能超过 ${limitMB}MB`); - return false; - } - } - - // 检查文件类型 - if (!file.type.startsWith('image/')) { - antdMessage.error('只支持图片文件'); - return false; - } - - // 创建文件对象 - const reader = new FileReader(); - reader.onload = (e) => { - const newFile: VisionFile = { - id: `file-${Date.now()}-${Math.random()}`, - type: 'image', - transfer_method: 'local_file' as any, - url: e.target?.result as string, - upload_file_id: '', - }; - - setFiles(prev => [...prev, newFile]); - }; - reader.readAsDataURL(file); - - return false; // 阻止默认上传行为 - }; - - /** - * 移除文件 - */ - const handleRemoveFile = (fileId: string) => { - setFiles(prev => prev.filter(file => file.id !== fileId)); - }; - - /** - * 渲染文件预览 - */ - const renderFilePreview = () => { - if (files.length === 0) return null; - - return ( -
- {files.map((file) => ( -
- 预览 - -
- ))} -
- ); - }; - - /** - * 渲染上传按钮 - */ - const renderUploadButton = () => { - if (!visionConfig?.enabled) return null; - - const isDisabled = disabled || (visionConfig.number_limits ? files.length >= visionConfig.number_limits : false); - - return ( - - -