Merge branch 'PingChuan' into shiy-login
This commit is contained in:
@@ -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<any> {
|
||||
const response = await difyFetch('parameters', {
|
||||
method: 'GET',
|
||||
}, jwt);
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取会话列表
|
||||
*/
|
||||
async getConversations(jwt?: string): Promise<any> {
|
||||
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<any> {
|
||||
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<string, any>,
|
||||
query: string,
|
||||
responseMode: string = 'streaming',
|
||||
conversationId?: string,
|
||||
files?: any[],
|
||||
jwt?: string
|
||||
): Promise<Response | any> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
const body = {
|
||||
rating,
|
||||
};
|
||||
|
||||
const response = await difyFetch(`messages/${messageId}/feedbacks`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}, jwt);
|
||||
return response.json();
|
||||
},
|
||||
};
|
||||
@@ -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<Response> {
|
||||
const url = `${DIFY_CONFIG.API_URL}/${endpoint.replace(/^\//, '')}`;
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
if (jwt) {
|
||||
(headers as Record<string, string>)['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';
|
||||
@@ -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<ConversationsResponse> {
|
||||
const params = new URLSearchParams({
|
||||
limit: '100',
|
||||
});
|
||||
|
||||
const url = `${API_URL}/conversations?${params}`;
|
||||
console.log('📋 [Dify Client] 获取会话列表:', { url });
|
||||
|
||||
try {
|
||||
const response = await axios.get<ConversationsResponse>(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<MessagesResponse> {
|
||||
const params = new URLSearchParams({
|
||||
conversation_id: conversationId,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await axios.get<MessagesResponse>(
|
||||
`${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<AppParametersResponse> {
|
||||
const url = `${API_URL}/parameters`;
|
||||
console.log('⚙️ [Dify Client] 获取应用参数:', { url });
|
||||
|
||||
try {
|
||||
const response = await axios.get<AppParametersResponse>(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<void> {
|
||||
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<any> {
|
||||
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 };
|
||||
@@ -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';
|
||||
@@ -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<Uint8Array> | 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<void> {
|
||||
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<void> {
|
||||
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<string, any>;
|
||||
|
||||
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<string, any>): 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<string, any>): 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<SSECallbacks> & { 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,
|
||||
});
|
||||
};
|
||||
@@ -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<string, any>;
|
||||
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<string, any>;
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -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<DatasetsResponse> {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
});
|
||||
|
||||
const response = await axios.get<DatasetsResponse>(
|
||||
`${API_URL}/datasets?${params}`,
|
||||
{ withCredentials: true }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个知识库详情
|
||||
*
|
||||
* @param datasetId - 知识库 ID
|
||||
* @returns 知识库详情
|
||||
*/
|
||||
export async function fetchDataset(datasetId: string): Promise<any> {
|
||||
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<DocumentsResponse> {
|
||||
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<DocumentsResponse>(
|
||||
`${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<Document> {
|
||||
const response = await axios.get<Document>(
|
||||
`${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<OperationResult> {
|
||||
console.log('[Dataset Client] 删除文档:', { datasetId, documentId });
|
||||
|
||||
const response = await axios.delete<OperationResult>(
|
||||
`${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<OperationResult> {
|
||||
console.log('[Dataset Client] 切换文档状态:', { datasetId, documentId, enabled });
|
||||
|
||||
const response = await axios.patch<OperationResult>(
|
||||
`${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<SegmentsResponse> {
|
||||
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<SegmentsResponse>(
|
||||
`${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<OperationResult> {
|
||||
console.log('[Dataset Client] 删除分段:', { datasetId, documentId, segmentId });
|
||||
|
||||
const response = await axios.delete<OperationResult>(
|
||||
`${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<OperationResult> {
|
||||
const response = await axios.patch<OperationResult>(
|
||||
`${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<any> {
|
||||
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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user