diff --git a/app/api/dify-chat/chat.ts b/app/api/dify-chat/chat.ts new file mode 100644 index 0000000..8a8d531 --- /dev/null +++ b/app/api/dify-chat/chat.ts @@ -0,0 +1,178 @@ +/** + * Dify Chat API 模块 + * + * 提供客户端调用 Dify API 的函数 + * 用于 Remix loader/action 中调用 Dify API + * + * @module api/dify/chat + */ + +import { difyFetch } from './client.server'; + +// ============================================================================ +// Dify Chat API 客户端 +// ============================================================================ + +/** + * Dify Chat API 客户端 + * + * @param jwt - 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 Chat] 解析 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('[Dify Chat] 删除会话:', conversationId); + + try { + const response = await difyFetch(`conversations/${conversationId}`, { + method: 'DELETE', + body: JSON.stringify({}), + }, jwt); + + // 对于 204 No Content 响应,直接返回成功 + if (response.status === 204) { + console.log('[Dify Chat] 删除会话成功:', conversationId); + return { result: 'success' }; + } + + const contentType = response.headers.get('Content-Type'); + + if (contentType && contentType.includes('application/json')) { + const data = await response.json(); + return data; + } + + const text = await response.text(); + console.log('[Dify Chat] 删除会话文本响应:', text); + + return { result: 'success' }; + } catch (error: any) { + console.warn('[Dify Chat] 删除会话请求失败,但可能已成功删除:', 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(); + }, +}; 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-chat/client.ts b/app/api/dify-chat/client.ts new file mode 100644 index 0000000..99d6243 --- /dev/null +++ b/app/api/dify-chat/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-chat/index.ts b/app/api/dify-chat/index.ts new file mode 100644 index 0000000..f690c41 --- /dev/null +++ b/app/api/dify-chat/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-chat/sse-handler.ts b/app/api/dify-chat/sse-handler.ts new file mode 100644 index 0000000..73e776b --- /dev/null +++ b/app/api/dify-chat/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-chat/types.ts b/app/api/dify-chat/types.ts new file mode 100644 index 0000000..8fa79de --- /dev/null +++ b/app/api/dify-chat/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/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 93% rename from app/components/chat/chat-input.tsx rename to app/components/dify-chat/chat-input.tsx index 70ca555..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 '../../types/dify_chat'; -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 ( - - -