Merge branch 'PingChuan' into shiy-login

This commit is contained in:
2025-11-30 19:33:46 +08:00
43 changed files with 4762 additions and 2634 deletions
+178
View File
@@ -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();
},
};
+117
View File
@@ -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';
+418
View File
@@ -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 };
+124
View File
@@ -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';
+360
View File
@@ -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,
});
};
+524
View File
@@ -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;
};
}
+300
View File
@@ -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;
}
+36
View File
@@ -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';
+167
View File
@@ -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;
}
@@ -1,7 +1,7 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { Input, Button, Upload, Tooltip, message as antdMessage, Space } from 'antd';
import { StopOutlined, PictureOutlined, CommentOutlined } from '@ant-design/icons';
import type { VisionFile } from '../../types/dify_chat';
import { CommentOutlined, PictureOutlined, StopOutlined } from '@ant-design/icons';
import { message as antdMessage, Button, Input, Tooltip, Upload } from 'antd';
import React, { useRef, useState } from 'react';
import type { VisionFile } from '~/api/dify-chat';
import '../../styles/components/chat-with-llm/chat-input.css';
const { TextArea } = Input;
@@ -1,13 +1,11 @@
import React, { useState } from 'react';
import { Button, Card, Spin } from 'antd';
import type { ChatItem, Feedbacktype, ThoughtItem, VisionFile } from '../../types/dify_chat';
import { CHAT_CONFIG } from '../../config/chat';
import Markdown from './markdown';
import ThoughtProcess from './thought-process';
import ThinkingBlock from './thinking-block';
import { parseMessageContent } from '../../utils/message-parser';
import { Dayjs } from 'dayjs';
import { useState } from 'react';
import type { ChatItem, Feedbacktype } from '~/api/dify-chat';
import '../../styles/components/chat-with-llm/chat-message.css';
import { parseMessageContent } from '../../utils/message-parser';
import Markdown from './markdown';
import ThinkingBlock from './thinking-block';
import ThoughtProcess from './thought-process';
interface ChatMessageProps {
message: ChatItem;
@@ -1,16 +1,15 @@
import { useState, useEffect, useRef } from 'react';
import { produce } from 'immer';
import { useBoolean, useGetState } from 'ahooks';
import { Layout, theme } from 'antd';
import ChatMessage from './chat-message';
import { useEffect, useRef, useState } from 'react';
import ChatInput from './chat-input';
import ChatMessage from './chat-message';
import ChatSidebar, { type ChatSidebarRef } from './sidebar';
// import Header from '../layout/Header';
import useConversation from '../../hooks/use-conversation';
import useChatMessage from '../../hooks/use-chat-message';
import type { ChatItem, ConversationItem } from '../../types/dify_chat';
import type { ChatItem, ConversationItem } from '~/api/dify-chat';
import { fetchAppParams, fetchChatList, fetchConversations } from '~/api/dify-chat';
import { CHAT_CONFIG } from '../../config/chat';
import { fetchConversations, fetchAppParams, fetchChatList } from '../../services/api.client';
import useChatMessage from '../../hooks/use-chat-message';
import useConversation from '../../hooks/use-conversation';
import '../../styles/components/chat-with-llm/index.css';
const { Content } = Layout;
@@ -1,18 +1,18 @@
import React, { useState, forwardRef, useImperativeHandle } from 'react';
import { Button, Layout, Menu, theme, Input, Tooltip, Dropdown, Modal, message } from 'antd';
import {
MenuFoldOutlined,
MenuUnfoldOutlined,
MessageOutlined,
PlusOutlined,
SearchOutlined,
MoreOutlined,
DeleteOutlined,
EditOutlined,
ExclamationCircleOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
MessageOutlined,
MoreOutlined,
PlusOutlined,
SearchOutlined,
} from '@ant-design/icons';
import type { ConversationItem } from '../../types/dify_chat';
import { deleteConversation, renameConversation } from '../../services/api.client';
import { Button, Dropdown, Input, Layout, Menu, Modal, Tooltip, message, theme } from 'antd';
import { forwardRef, useImperativeHandle, useState } from 'react';
import type { ConversationItem } from '~/api/dify-chat';
import { deleteConversation, renameConversation } from '~/api/dify-chat';
import '../../styles/components/chat-with-llm/sidebar.css';
const { Sider } = Layout;
@@ -1,9 +1,9 @@
import React, { useState } from 'react';
import { Card, Collapse, Tag, Spin, Typography, Button } from 'antd';
import { ToolOutlined, ThunderboltOutlined, CheckCircleOutlined, LoadingOutlined } from '@ant-design/icons';
import type { ThoughtItem } from '../../types/dify_chat';
import Markdown from './markdown';
import { CheckCircleOutlined, LoadingOutlined, ThunderboltOutlined, ToolOutlined } from '@ant-design/icons';
import { Button, Card, Collapse, Spin, Tag, Typography } from 'antd';
import { useState } from 'react';
import type { ThoughtItem } from '~/api/dify-chat';
import '../../styles/components/chat-with-llm/thought-process.css';
import Markdown from './markdown';
const { Panel } = Collapse;
const { Text, Paragraph } = Typography;
@@ -0,0 +1,373 @@
import { useState } from 'react';
import {
Button,
Input,
Table,
Tag,
Space,
Tooltip,
Popconfirm,
Switch,
message,
Empty,
Spin,
Upload,
} from 'antd';
import {
SearchOutlined,
ReloadOutlined,
DeleteOutlined,
FileTextOutlined,
CloudUploadOutlined,
EyeOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
SyncOutlined,
ExclamationCircleOutlined,
PauseCircleOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import type { Document, IndexingStatus } from '~/api/dify-dataset';
import { deleteDocument, toggleDocumentStatus, uploadDocument } from '~/api/dify-dataset';
import '../../styles/components/dify-dataset-manager/document-list.css';
interface DocumentListProps {
datasetId: string;
datasetName: string;
documents: Document[];
loading: boolean;
total: number;
page: number;
pageSize: number;
onPageChange: (page: number) => void;
onDocumentDeleted: (documentId: string) => void;
onDocumentStatusChanged: (documentId: string, enabled: boolean) => void;
onRefresh: () => void;
}
/**
* 文档列表组件
*/
export default function DocumentList({
datasetId,
datasetName,
documents,
loading,
total,
page,
pageSize,
onPageChange,
onDocumentDeleted,
onDocumentStatusChanged,
onRefresh,
}: DocumentListProps) {
const [searchValue, setSearchValue] = useState('');
const [uploading, setUploading] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
/**
* 获取状态标签配置
*/
const getStatusConfig = (status: IndexingStatus) => {
const configs: Record<IndexingStatus, { color: string; icon: React.ReactNode; text: string }> = {
completed: { color: 'success', icon: <CheckCircleOutlined />, text: '已完成' },
indexing: { color: 'processing', icon: <SyncOutlined spin />, text: '索引中' },
waiting: { color: 'warning', icon: <ClockCircleOutlined />, text: '等待中' },
parsing: { color: 'processing', icon: <SyncOutlined spin />, text: '解析中' },
cleaning: { color: 'processing', icon: <SyncOutlined spin />, text: '清洗中' },
splitting: { color: 'processing', icon: <SyncOutlined spin />, text: '分段中' },
paused: { color: 'default', icon: <PauseCircleOutlined />, text: '已暂停' },
error: { color: 'error', icon: <ExclamationCircleOutlined />, text: '错误' },
};
return configs[status] || { color: 'default', icon: null, text: status };
};
/**
* 格式化日期
*/
const formatDate = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
/**
* 格式化数字
*/
const formatNumber = (num: number) => {
if (num >= 10000) {
return (num / 10000).toFixed(1) + 'w';
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'k';
}
return num.toString();
};
/**
* 处理删除文档
*/
const handleDelete = async (documentId: string) => {
setDeletingId(documentId);
try {
await deleteDocument(datasetId, documentId);
message.success('删除成功');
onDocumentDeleted(documentId);
} catch (err: any) {
console.error('删除文档失败:', err);
message.error(err.message || '删除失败');
} finally {
setDeletingId(null);
}
};
/**
* 处理启用/禁用文档
*/
const handleToggleStatus = async (documentId: string, enabled: boolean) => {
try {
await toggleDocumentStatus(datasetId, documentId, enabled);
message.success(enabled ? '已启用' : '已禁用');
onDocumentStatusChanged(documentId, enabled);
} catch (err: any) {
console.error('切换文档状态失败:', err);
message.error(err.message || '操作失败');
}
};
/**
* 处理文件上传
*/
const handleUpload = async (file: File) => {
if (!datasetId) {
message.error('请先选择知识库');
return false;
}
setUploading(true);
try {
await uploadDocument(datasetId, file, (percent) => {
console.log('上传进度:', percent);
});
message.success('上传成功,正在处理...');
onRefresh();
} catch (err: any) {
console.error('上传文件失败:', err);
message.error(err.message || '上传失败');
} finally {
setUploading(false);
}
return false;
};
// 过滤文档
const filteredDocuments = documents.filter((doc) =>
doc.name.toLowerCase().includes(searchValue.toLowerCase())
);
// 表格列定义
const columns: ColumnsType<Document> = [
{
title: '文档名称',
dataIndex: 'name',
key: 'name',
ellipsis: true,
render: (name: string) => (
<div className="flex items-center gap-2">
<FileTextOutlined className="text-gray-400" />
<span className="font-medium">{name}</span>
</div>
),
},
{
title: '状态',
dataIndex: 'indexing_status',
key: 'indexing_status',
width: 120,
render: (status: IndexingStatus) => {
const config = getStatusConfig(status);
return (
<Tag color={config.color} icon={config.icon}>
{config.text}
</Tag>
);
},
},
{
title: '字数',
dataIndex: 'word_count',
key: 'word_count',
width: 100,
render: (count: number) => formatNumber(count),
},
{
title: '命中次数',
dataIndex: 'hit_count',
key: 'hit_count',
width: 100,
render: (count: number) => formatNumber(count),
},
{
title: '启用',
dataIndex: 'enabled',
key: 'enabled',
width: 80,
render: (enabled: boolean, record) => (
<Switch
size="small"
checked={enabled}
onChange={(checked) => handleToggleStatus(record.id, checked)}
/>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 160,
render: (timestamp: number) => formatDate(timestamp),
},
{
title: '操作',
key: 'action',
width: 120,
render: (_, record) => (
<Space size="small">
<Tooltip title="查看详情">
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => {
// TODO: 查看文档详情/分段
message.info('功能开发中');
}}
/>
</Tooltip>
<Popconfirm
title="确定要删除这个文档吗?"
description="删除后无法恢复"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Tooltip title="删除">
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
loading={deletingId === record.id}
/>
</Tooltip>
</Popconfirm>
</Space>
),
},
];
return (
<div className="dataset-content">
{/* 头部区域 */}
<div className="dataset-header" style={{ marginBottom: 16, padding: 0, height: 'auto', border: 'none' }}>
<h1 style={{ margin: 0 }}>
{datasetName || '请选择知识库'}
</h1>
<div className="dataset-header-actions">
<Tooltip title="刷新">
<Button
icon={<ReloadOutlined />}
onClick={onRefresh}
loading={loading}
/>
</Tooltip>
<Upload
beforeUpload={handleUpload}
showUploadList={false}
accept=".txt,.md,.pdf,.docx,.doc,.csv,.xlsx,.xls"
disabled={!datasetId}
>
<Button
type="primary"
icon={<CloudUploadOutlined />}
loading={uploading}
disabled={!datasetId}
>
</Button>
</Upload>
</div>
</div>
{/* 工具栏 */}
<div className="document-list-toolbar">
<Input
className="document-list-search"
placeholder="搜索文档..."
prefix={<SearchOutlined />}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
allowClear
/>
<div className="document-list-actions">
<span className="text-gray-500 text-sm">
{total}
</span>
</div>
</div>
{/* 文档表格 */}
{!datasetId ? (
<div className="dataset-empty">
<Empty description="请先选择一个知识库" />
</div>
) : loading && documents.length === 0 ? (
<div className="dataset-loading">
<Spin size="large" />
<span className="text-gray-500">...</span>
</div>
) : filteredDocuments.length === 0 ? (
<div className="dataset-empty">
<Empty
description={searchValue ? '未找到匹配的文档' : '暂无文档'}
>
{!searchValue && (
<Upload
beforeUpload={handleUpload}
showUploadList={false}
accept=".txt,.md,.pdf,.docx,.doc,.csv,.xlsx,.xls"
>
<Button type="primary" icon={<CloudUploadOutlined />}>
</Button>
</Upload>
)}
</Empty>
</div>
) : (
<Table
className="document-table"
columns={columns}
dataSource={filteredDocuments}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: pageSize,
total: total,
onChange: onPageChange,
showSizeChanger: false,
showTotal: (total) => `${total}`,
}}
size="middle"
/>
)}
</div>
);
}
@@ -0,0 +1,241 @@
import { Layout, theme, message } from 'antd';
import { useEffect, useState } from 'react';
import DatasetSidebar from './sidebar';
import DocumentList from './document-list';
import type { Dataset, Document } from '~/api/dify-dataset';
import { fetchDatasets, fetchDocuments } from '~/api/dify-dataset';
import '../../styles/components/dify-dataset-manager/index.css';
const { Content } = Layout;
/**
* 知识库管理主组件
*/
export default function DatasetManager() {
// 主题
const {
token: { colorBgContainer },
} = theme.useToken();
// 侧边栏状态
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [isMobile, setIsMobile] = useState(false);
// 知识库状态
const [datasets, setDatasets] = useState<Dataset[]>([]);
const [currentDatasetId, setCurrentDatasetId] = useState<string>('');
const [loadingDatasets, setLoadingDatasets] = useState(true);
// 文档状态
const [documents, setDocuments] = useState<Document[]>([]);
const [loadingDocuments, setLoadingDocuments] = useState(false);
const [documentTotal, setDocumentTotal] = useState(0);
const [documentPage, setDocumentPage] = useState(1);
const [documentPageSize] = useState(20);
// 初始化状态
const [inited, setInited] = useState(false);
const [error, setError] = useState<string | null>(null);
/**
* 加载知识库列表
*/
const loadDatasets = async () => {
setLoadingDatasets(true);
try {
console.log('[DatasetManager] 加载知识库列表...');
const response = await fetchDatasets(1, 100);
console.log('[DatasetManager] 知识库列表响应:', response);
if (response && response.data) {
setDatasets(response.data);
// 如果有知识库,默认选中第一个
if (response.data.length > 0 && !currentDatasetId) {
setCurrentDatasetId(response.data[0].id);
}
}
} catch (err: any) {
console.error('[DatasetManager] 加载知识库列表失败:', err);
setError(err.message || '加载知识库列表失败');
message.error('加载知识库列表失败');
} finally {
setLoadingDatasets(false);
setInited(true);
}
};
/**
* 加载文档列表
*/
const loadDocuments = async (datasetId: string, page: number = 1) => {
if (!datasetId) return;
setLoadingDocuments(true);
try {
console.log('[DatasetManager] 加载文档列表:', { datasetId, page });
const response = await fetchDocuments(datasetId, page, documentPageSize);
console.log('[DatasetManager] 文档列表响应:', response);
if (response && response.data) {
setDocuments(response.data);
setDocumentTotal(response.total);
setDocumentPage(page);
}
} catch (err: any) {
console.error('[DatasetManager] 加载文档列表失败:', err);
message.error('加载文档列表失败');
} finally {
setLoadingDocuments(false);
}
};
/**
* 处理知识库选择
*/
const handleDatasetSelect = (datasetId: string) => {
if (datasetId !== currentDatasetId) {
setCurrentDatasetId(datasetId);
setDocumentPage(1);
}
};
/**
* 处理文档页码变化
*/
const handlePageChange = (page: number) => {
loadDocuments(currentDatasetId, page);
};
/**
* 处理文档删除
*/
const handleDocumentDeleted = (documentId: string) => {
setDocuments((prev) => prev.filter((doc) => doc.id !== documentId));
setDocumentTotal((prev) => prev - 1);
// 更新知识库的文档数量
setDatasets((prev) =>
prev.map((ds) =>
ds.id === currentDatasetId
? { ...ds, document_count: ds.document_count - 1 }
: ds
)
);
};
/**
* 处理文档状态变化
*/
const handleDocumentStatusChanged = (documentId: string, enabled: boolean) => {
setDocuments((prev) =>
prev.map((doc) =>
doc.id === documentId ? { ...doc, enabled } : doc
)
);
};
/**
* 刷新文档列表
*/
const handleRefresh = () => {
loadDocuments(currentDatasetId, documentPage);
};
/**
* 处理侧边栏切换
*/
const handleSidebarToggle = () => {
setSidebarCollapsed(!sidebarCollapsed);
};
// 初始化
useEffect(() => {
loadDatasets();
}, []);
// 当选中的知识库变化时,加载文档列表
useEffect(() => {
if (currentDatasetId) {
loadDocuments(currentDatasetId, 1);
}
}, [currentDatasetId]);
// 检查屏幕尺寸
useEffect(() => {
const checkScreenSize = () => {
setIsMobile(window.innerWidth < 992);
};
checkScreenSize();
window.addEventListener('resize', checkScreenSize);
return () => {
window.removeEventListener('resize', checkScreenSize);
};
}, []);
// 获取当前选中的知识库
const currentDataset = datasets.find((ds) => ds.id === currentDatasetId);
// 如果有错误,显示错误页面
if (error && !inited) {
return (
<div className="dataset-manager-container">
<div className="dataset-empty">
<h3></h3>
<p>{error}</p>
</div>
</div>
);
}
return (
<Layout style={{ height: '100%', display: 'flex', flexDirection: 'row' }}>
{/* 移动端遮罩层 */}
{!sidebarCollapsed && isMobile && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-[999]"
onClick={handleSidebarToggle}
/>
)}
{/* 侧边栏 */}
<DatasetSidebar
collapsed={sidebarCollapsed}
onToggle={handleSidebarToggle}
datasets={datasets}
currentDatasetId={currentDatasetId}
onDatasetSelect={handleDatasetSelect}
loading={loadingDatasets}
/>
{/* 主内容区域 */}
<Layout style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<Content
style={{
background: colorBgContainer,
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
}}
>
<DocumentList
datasetId={currentDatasetId}
datasetName={currentDataset?.name || ''}
documents={documents}
loading={loadingDocuments}
total={documentTotal}
page={documentPage}
pageSize={documentPageSize}
onPageChange={handlePageChange}
onDocumentDeleted={handleDocumentDeleted}
onDocumentStatusChanged={handleDocumentStatusChanged}
onRefresh={handleRefresh}
/>
</Content>
</Layout>
</Layout>
);
}
@@ -0,0 +1,160 @@
import { useState } from 'react';
import { Button, Layout, Menu, theme, Input, Spin } from 'antd';
import {
MenuFoldOutlined,
MenuUnfoldOutlined,
DatabaseOutlined,
SearchOutlined,
FileTextOutlined,
} from '@ant-design/icons';
import type { Dataset } from '~/api/dify-dataset';
import '../../styles/components/dify-dataset-manager/sidebar.css';
const { Sider } = Layout;
interface DatasetSidebarProps {
collapsed: boolean;
onToggle: () => void;
datasets: Dataset[];
currentDatasetId: string;
onDatasetSelect: (datasetId: string) => void;
loading?: boolean;
}
/**
* 知识库侧边栏组件
*/
export default function DatasetSidebar({
collapsed,
onToggle,
datasets,
currentDatasetId,
onDatasetSelect,
loading = false,
}: DatasetSidebarProps) {
const [searchValue, setSearchValue] = useState('');
const {
token: { colorBgContainer },
} = theme.useToken();
// 过滤知识库列表
const filteredDatasets = datasets.filter((ds) =>
ds.name.toLowerCase().includes(searchValue.toLowerCase())
);
// 生成菜单项
const menuItems = filteredDatasets.map((ds) => ({
key: ds.id,
icon: <DatabaseOutlined />,
label: (
<div className="dataset-info">
<span className="dataset-info-name" title={ds.name}>
{ds.name}
</span>
{!collapsed && (
<div className="dataset-info-meta">
<span className="dataset-info-meta-item">
<FileTextOutlined />
{ds.document_count}
</span>
</div>
)}
</div>
),
}));
return (
<Sider
trigger={null}
collapsible
collapsed={collapsed}
width={280}
collapsedWidth={60}
className="dataset-sidebar"
style={{
background: colorBgContainer,
borderRight: '1px solid #f0f0f0',
display: 'flex',
flexDirection: 'column',
height: '100%',
}}
>
{/* 侧边栏头部 */}
<div className="dataset-sidebar-header">
<div className="dataset-sidebar-title">
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={onToggle}
style={{
fontSize: '16px',
width: 32,
height: 32,
color: 'rgb(0, 104, 74)',
}}
/>
{!collapsed && (
<h3></h3>
)}
</div>
{/* 搜索框 */}
{!collapsed && (
<Input
placeholder="搜索知识库..."
prefix={<SearchOutlined />}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
allowClear
/>
)}
</div>
{/* 知识库列表 */}
<div className="dataset-sidebar-list">
{loading ? (
<div className="flex items-center justify-center py-8">
<Spin size="small" />
</div>
) : (
<>
{!collapsed && filteredDatasets.length === 0 && searchValue && (
<div className="p-4 text-center text-gray-500">
<DatabaseOutlined className="text-2xl mb-2" />
<p></p>
</div>
)}
{!collapsed && datasets.length === 0 && !searchValue && (
<div className="p-4 text-center text-gray-500">
<DatabaseOutlined className="text-2xl mb-2" />
<p></p>
</div>
)}
<Menu
mode="inline"
selectedKeys={[currentDatasetId]}
items={menuItems}
onClick={({ key }) => onDatasetSelect(key)}
style={{
border: 'none',
background: 'transparent',
}}
className="dataset-sidebar-menu"
/>
</>
)}
</div>
{/* 侧边栏底部 */}
{!collapsed && datasets.length > 0 && (
<div className="dataset-sidebar-footer">
<div className="stats-text">
{datasets.length}
</div>
</div>
)}
</Sider>
);
}
+3 -2
View File
@@ -212,9 +212,10 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '' }: Sid
// return item.path && item.path.startsWith('/cross-checking')
// }
// 🔑 如果选择了"智慧法务大模型",显示 /chat-with-llm 相关菜单
// 🔑 如果选择了"智慧法务大模型",显示 /chat-with-llm 和 /dataset-manager 相关菜单
if (selectedModuleName === '智慧法务大模型') {
return item.path === '/chat-with-llm' || item.path?.startsWith('/chat-with-llm/');
return item.path === '/chat-with-llm' || item.path?.startsWith('/chat-with-llm/') ||
item.path === '/dataset-manager' || item.path?.startsWith('/dataset-manager/');
}
// 🔑 如果选择了包含"合同"的模块
-1
View File
@@ -1,4 +1,3 @@
import type { AppInfo } from '../types/dify_chat';
// 在客户端获取环境变量的辅助函数
const getEnvVar = (name: string, defaultValue: string = '') => {
+7 -82
View File
@@ -1,9 +1,8 @@
import { useState, useCallback, useRef } from 'react';
import { produce } from 'immer';
import { useBoolean, useGetState } from 'ahooks';
import { sendChatMessage, updateFeedback, generateConversationName } from '../services/api.client';
import type { ChatItem, Feedbacktype, ThoughtItem, VisionFile, MessageEnd, MessageReplace } from '../types/dify_chat';
import { CHAT_CONFIG } from '../config/chat';
import { produce } from 'immer';
import { useCallback, useRef, useState } from 'react';
import type { ChatItem, Feedbacktype, MessageEnd, MessageReplace, VisionFile } from '~/api/dify-chat';
import { generateConversationName, sendChatMessage, updateFeedback } from '~/api/dify-chat';
/**
* 聊天消息处理钩子
@@ -61,22 +60,9 @@ export default function useChatMessage({
questionItem: ChatItem,
originalResponseId?: string
) => {
// console.log('🔄 [useChatMessage] 更新聊天列表:', {
// responseItemId: responseItem.id,
// responseContentLength: responseItem.content.length,
// responsePreview: responseItem.content.substring(0, 50) + (responseItem.content.length > 50 ? '...' : ''),
// originalResponseId,
// questionId,
// placeholderAnswerId
// });
setChatList(produce(getChatList(), (draft) => {
// console.log('📝 [useChatMessage] 当前聊天列表:', draft.map(item => ({
// id: item.id,
// contentLength: item.content.length,
// contentPreview: item.content.substring(0, 20) + (item.content.length > 20 ? '...' : ''),
// isAnswer: item.isAnswer
// })));
// 移除占位符
const placeholderIndex = draft.findIndex(item => item.id === placeholderAnswerId);
@@ -112,26 +98,11 @@ export default function useChatMessage({
}
if (responseIndex !== -1) {
// console.log('✏️ [useChatMessage] 更新现有响应:', {
// responseIndex,
// oldContentLength: draft[responseIndex].content.length,
// newContentLength: responseItem.content.length
// });
draft[responseIndex] = { ...responseItem };
} else {
// console.log(' [useChatMessage] 添加新响应:', {
// responseId: responseItem.id,
// contentLength: responseItem.content.length
// });
draft.push({ ...responseItem });
}
// console.log('📝 [useChatMessage] 更新后聊天列表:', draft.map(item => ({
// id: item.id,
// contentLength: item.content.length,
// contentPreview: item.content.substring(0, 20) + (item.content.length > 20 ? '...' : ''),
// isAnswer: item.isAnswer
// })));
}));
}, [getChatList, setChatList]);
@@ -274,27 +245,11 @@ export default function useChatMessage({
// 发送消息
await sendChatMessage(data, {
onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }) => {
// console.log('📨 [useChatMessage] 收到流式数据:', {
// messageLength: message.length,
// message: message.substring(0, 100) + (message.length > 100 ? '...' : ''),
// isFirstMessage,
// messageId,
// newConversationId,
// taskId,
// isAgentMode,
// currentContentLength: responseItem.content.length
// });
if (!isAgentMode) {
// 累积消息内容
const oldContent = responseItem.content;
responseItem.content = responseItem.content + message;
// console.log('📝 [useChatMessage] 累积消息内容:', {
// oldLength: oldContent.length,
// newLength: responseItem.content.length,
// addedLength: message.length,
// preview: responseItem.content.substring(0, 50) + '...'
// });
} else {
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1];
if (lastThought) {
@@ -323,13 +278,6 @@ export default function useChatMessage({
setMessageTaskId(taskId || '');
// 检查是否切换到其他会话
// console.log('🔍 会话检查:', {
// prevTempNewConversationId,
// conversationId,
// isEqual: prevTempNewConversationId === conversationId
// });
// 修复新会话的匹配逻辑
const isNewConversationMatch = (prevTempNewConversationId === '-1' && conversationId === null) ||
(prevTempNewConversationId === conversationId);
@@ -340,25 +288,6 @@ export default function useChatMessage({
return;
}
// console.log('🔄 准备调用updateCurrentQA:', {
// responseItemId: responseItem.id,
// responseContent: responseItem.content,
// questionId,
// placeholderAnswerId,
// originalResponseId
// });
// console.log('🔄 [useChatMessage] 准备调用updateCurrentQA:', {
// responseItemId: responseItem.id,
// responseContentLength: responseItem.content.length,
// responsePreview: responseItem.content.substring(0, 100) + (responseItem.content.length > 100 ? '...' : ''),
// questionId,
// placeholderAnswerId,
// originalResponseId,
// isAgentMode,
// agentThoughtsCount: responseItem.agent_thoughts?.length || 0
// });
// 更新当前问答(使用防抖)
updateCurrentQA({
responseItem: { ...responseItem }, // 创建副本避免引用问题
@@ -370,7 +299,6 @@ export default function useChatMessage({
},
onCompleted: async (hasError?: boolean) => {
// console.log('✅ 消息发送完成:', { hasError });
// 立即更新最终状态
if (currentResponseRef.current) {
@@ -541,10 +469,7 @@ export default function useChatMessage({
*/
const handleFeedback = useCallback(async (messageId: string, feedback: Feedbacktype) => {
try {
await updateFeedback({
url: `messages/${messageId}/feedbacks`,
body: feedback,
});
await updateFeedback(messageId, feedback);
// 更新聊天列表中的反馈
setChatList(produce(getChatList(), (draft) => {
+3 -3
View File
@@ -1,8 +1,8 @@
import { useState, useCallback } from 'react';
import { useParams } from '@remix-run/react';
import { useGetState } from 'ahooks';
import { produce } from 'immer';
import { useGetState, useLocalStorageState } from 'ahooks';
import type { ConversationItem } from '../types/dify_chat';
import { useState } from 'react';
import type { ConversationItem } from '~/api/dify-chat';
import { CHAT_CONFIG } from '../config/chat';
// 本地存储键名
+64 -13
View File
@@ -1,7 +1,66 @@
import { type ActionFunctionArgs } from '@remix-run/node';
import { difyClient } from '../services/dify-client.server';
import { getSessionInfo } from '../utils/session.server';
import { type ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node';
import { difyClient } from '~/api/dify-chat/client.server';
/**
* GET /api/chat-messages - 获取会话消息列表
*/
export async function loader({ request }: LoaderFunctionArgs) {
try {
// 获取用户会话信息和 JWT
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
// 检查 JWT 是否存在
if (!frontendJWT) {
console.error('❌ [API] Chat Messages GET - JWT不存在');
return new Response(
JSON.stringify({ error: 'JWT认证失败,请重新登录' }),
{
status: 401,
headers: { 'Content-Type': 'application/json' },
}
);
}
// 从 URL 参数获取 conversation_id
const url = new URL(request.url);
const conversationId = url.searchParams.get('conversation_id');
if (!conversationId) {
return new Response(
JSON.stringify({ error: '缺少 conversation_id 参数' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
}
);
}
console.log('客戶端調用remix路由_Chat Messages GET - 获取消息列表:', { conversationId });
const result = await difyClient.getConversationMessages(conversationId, frontendJWT);
return new Response(JSON.stringify(result), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error: any) {
console.error('❌ [API] Chat Messages GET - Error:', error.message);
const status = error.message?.includes('JWT认证失败') ? 401 : 500;
return new Response(
JSON.stringify({ error: error.message || 'Failed to get messages' }),
{
status,
headers: { 'Content-Type': 'application/json' },
}
);
}
}
/**
* POST /api/chat-messages - 发送聊天消息
*/
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
@@ -34,7 +93,7 @@ export async function action({ request }: ActionFunctionArgs) {
response_mode: responseMode,
} = body;
console.log('🚀 [API] Chat Messages API - 收到请求:', {
console.log('客戶端調用remix路由_Chat Messages API - 收到请求:', {
queryLength: query?.length || 0,
queryPreview: query?.substring(0, 100) + (query?.length > 100 ? '...' : ''),
conversationId,
@@ -54,16 +113,9 @@ export async function action({ request }: ActionFunctionArgs) {
frontendJWT // 传递 JWT
);
console.log('📡 [API] Dify响应状态:', {
status: response.status,
statusText: response.statusText,
hasBody: !!response.body,
headers: Object.fromEntries(response.headers.entries())
});
// 对于流式响应,直接返回流
if (responseMode === 'streaming') {
console.log('🌊 [API] 返回流式响应');
console.log('Dify转发fastapi,返回流式响应');
return new Response(response.body, {
status: response.status,
headers: {
@@ -78,7 +130,6 @@ export async function action({ request }: ActionFunctionArgs) {
}
// 对于非流式响应,返回JSON
console.log('📄 [API] 返回JSON响应');
return new Response(JSON.stringify(response), {
status: 200,
headers: {
+3 -5
View File
@@ -1,6 +1,6 @@
import { json, type ActionFunctionArgs } from '@remix-run/node';
import { difyClient } from '../services/dify-client.server';
import { getSessionInfo, commitSession } from '../utils/session.server';
import { difyClient } from '~/api/dify-chat/client.server';
import { commitSession, getSessionInfo } from '../utils/session.server';
export async function action({ request, params }: ActionFunctionArgs) {
try {
@@ -31,7 +31,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
const body = await request.json();
const { auto_generate, name } = body;
console.log('💬 [API] Rename Conversation API - 重命名会话:', {
console.log('客戶端調用remix路由Rename Conversation API - 重命名会话:', {
id,
autoGenerate: auto_generate,
name,
@@ -41,8 +41,6 @@ export async function action({ request, params }: ActionFunctionArgs) {
// 调用服务端API重命名会话
const data = await difyClient.renameConversation(id, name, auto_generate, frontendJWT);
console.log('✅ [API] Rename Conversation API - Success');
return json(data, {
headers: {
'Set-Cookie': await commitSession(session),
+2 -8
View File
@@ -1,6 +1,6 @@
import { json, type ActionFunctionArgs } from '@remix-run/node';
import { difyClient } from '../services/dify-client.server';
import { getSessionInfo, commitSession } from '../utils/session.server';
import { difyClient } from '~/api/dify-chat/client.server';
import { commitSession, getSessionInfo } from '../utils/session.server';
export async function action({ request, params }: ActionFunctionArgs) {
try {
@@ -31,16 +31,10 @@ export async function action({ request, params }: ActionFunctionArgs) {
const method = request.method;
if (method === 'DELETE') {
console.log('🗑️ [API] Delete Conversation API - 删除会话:', {
id,
hasJWT: !!frontendJWT
});
// 调用服务端API删除会话
const data = await difyClient.deleteConversation(id, frontendJWT);
console.log('✅ [API] Delete Conversation API - Success');
return json(data, {
headers: {
'Set-Cookie': await commitSession(session),
+2 -8
View File
@@ -1,6 +1,6 @@
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import { difyClient } from '../services/dify-client.server';
import { getSessionInfo, commitSession } from '../utils/session.server';
import { difyClient } from '~/api/dify-chat/client.server';
import { commitSession, getSessionInfo } from '../utils/session.server';
export async function loader({ request }: LoaderFunctionArgs) {
try {
@@ -23,14 +23,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
);
}
console.log('💬 [API] Conversations API - 获取会话列表:', {
hasJWT: !!frontendJWT
});
const data = await difyClient.getConversations(frontendJWT);
console.log('✅ [API] Conversations API - Success');
return json(data, {
headers: {
'Set-Cookie': await commitSession(session),
@@ -0,0 +1,56 @@
import { type ActionFunctionArgs } from '@remix-run/node';
import { API_BASE_URL } from '~/config/api-config';
/**
* PATCH /api/dataset/datasets/:datasetId/documents/:documentId/status - 切换文档状态
*/
export async function action({ request, params }: ActionFunctionArgs) {
try {
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
if (!frontendJWT) {
return new Response(
JSON.stringify({ error: 'JWT认证失败,请重新登录' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
const { datasetId, documentId } = params;
if (!datasetId || !documentId) {
return new Response(
JSON.stringify({ error: '缺少必要参数' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const body = await request.json();
const { enabled } = body;
console.log('[API] Toggle Document Status:', { datasetId, documentId, enabled });
const apiUrl = `${API_BASE_URL}/dify-dataset/datasets/${datasetId}/documents/${documentId}/status`;
const response = await fetch(apiUrl, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${frontendJWT}`,
},
body: JSON.stringify({ enabled }),
});
const data = await response.json();
return new Response(JSON.stringify(data), {
status: response.status,
headers: { 'Content-Type': 'application/json' },
});
} catch (error: any) {
console.error('[API] Toggle Document Status - Error:', error.message);
return new Response(
JSON.stringify({ error: error.message || 'Failed to toggle status' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
}
@@ -0,0 +1,118 @@
import { type LoaderFunctionArgs, type ActionFunctionArgs } from '@remix-run/node';
import { API_BASE_URL } from '~/config/api-config';
/**
* GET /api/dataset/datasets/:datasetId/documents/:documentId - 获取文档详情
*/
export async function loader({ request, params }: LoaderFunctionArgs) {
try {
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
if (!frontendJWT) {
return new Response(
JSON.stringify({ error: 'JWT认证失败,请重新登录' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
const { datasetId, documentId } = params;
if (!datasetId || !documentId) {
return new Response(
JSON.stringify({ error: '缺少必要参数' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
console.log('[API] Document Detail:', { datasetId, documentId });
const apiUrl = `${API_BASE_URL}/dify-dataset/datasets/${datasetId}/documents/${documentId}`;
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${frontendJWT}`,
},
});
const data = await response.json();
return new Response(JSON.stringify(data), {
status: response.status,
headers: { 'Content-Type': 'application/json' },
});
} catch (error: any) {
console.error('[API] Document Detail - Error:', error.message);
return new Response(
JSON.stringify({ error: error.message || 'Failed to get document' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
}
/**
* DELETE /api/dataset/datasets/:datasetId/documents/:documentId - 删除文档
*/
export async function action({ request, params }: ActionFunctionArgs) {
try {
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
if (!frontendJWT) {
return new Response(
JSON.stringify({ error: 'JWT认证失败,请重新登录' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
const { datasetId, documentId } = params;
if (!datasetId || !documentId) {
return new Response(
JSON.stringify({ error: '缺少必要参数' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const method = request.method;
if (method === 'DELETE') {
console.log('[API] Delete Document:', { datasetId, documentId });
const apiUrl = `${API_BASE_URL}/dify-dataset/datasets/${datasetId}/documents/${documentId}`;
const response = await fetch(apiUrl, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${frontendJWT}`,
},
});
// 处理 204 No Content
if (response.status === 204) {
return new Response(
JSON.stringify({ result: 'success' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
const data = await response.json();
return new Response(JSON.stringify(data), {
status: response.status,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(
JSON.stringify({ error: 'Method not allowed' }),
{ status: 405, headers: { 'Content-Type': 'application/json' } }
);
} catch (error: any) {
console.error('[API] Document Action - Error:', error.message);
return new Response(
JSON.stringify({ error: error.message || 'Failed to process request' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
}
@@ -0,0 +1,119 @@
import { type LoaderFunctionArgs, type ActionFunctionArgs } from '@remix-run/node';
import { API_BASE_URL } from '~/config/api-config';
/**
* GET /api/dataset/datasets/:datasetId/documents - 获取文档列表
*/
export async function loader({ request, params }: LoaderFunctionArgs) {
try {
// 获取用户会话信息和 JWT
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
if (!frontendJWT) {
return new Response(
JSON.stringify({ error: 'JWT认证失败,请重新登录' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
const { datasetId } = params;
if (!datasetId) {
return new Response(
JSON.stringify({ error: '缺少 datasetId 参数' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// 获取查询参数
const url = new URL(request.url);
const page = url.searchParams.get('page') || '1';
const limit = url.searchParams.get('limit') || '20';
const keyword = url.searchParams.get('keyword') || '';
console.log('[API] Documents List:', { datasetId, page, limit, keyword });
// 构建查询参数
const queryParams = new URLSearchParams({ page, limit });
if (keyword) queryParams.append('keyword', keyword);
// 转发请求到 FastAPI
const apiUrl = `${API_BASE_URL}/dify-dataset/datasets/${datasetId}/documents?${queryParams}`;
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${frontendJWT}`,
},
});
const data = await response.json();
return new Response(JSON.stringify(data), {
status: response.status,
headers: { 'Content-Type': 'application/json' },
});
} catch (error: any) {
console.error('[API] Documents List - Error:', error.message);
return new Response(
JSON.stringify({ error: error.message || 'Failed to get documents' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
}
/**
* POST /api/dataset/datasets/:datasetId/documents - 创建文档(上传文件)
*/
export async function action({ request, params }: ActionFunctionArgs) {
try {
// 获取用户会话信息和 JWT
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
if (!frontendJWT) {
return new Response(
JSON.stringify({ error: 'JWT认证失败,请重新登录' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
const { datasetId } = params;
if (!datasetId) {
return new Response(
JSON.stringify({ error: '缺少 datasetId 参数' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// 获取表单数据
const formData = await request.formData();
console.log('[API] Upload Document:', { datasetId });
// 转发请求到 FastAPI
const apiUrl = `${API_BASE_URL}/dify-dataset/datasets/${datasetId}/documents/create-by-file`;
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${frontendJWT}`,
},
body: formData,
});
const data = await response.json();
return new Response(JSON.stringify(data), {
status: response.status,
headers: { 'Content-Type': 'application/json' },
});
} catch (error: any) {
console.error('[API] Upload Document - Error:', error.message);
return new Response(
JSON.stringify({ error: error.message || 'Failed to upload document' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
}
+60
View File
@@ -0,0 +1,60 @@
import { type LoaderFunctionArgs } from '@remix-run/node';
import { API_BASE_URL } from '~/config/api-config';
/**
* GET /api/dataset/datasets - 获取知识库列表
*/
export async function loader({ request }: LoaderFunctionArgs) {
try {
// 获取用户会话信息和 JWT
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
// 检查 JWT 是否存在
if (!frontendJWT) {
console.error('[API] Dataset List - JWT不存在');
return new Response(
JSON.stringify({ error: 'JWT认证失败,请重新登录' }),
{
status: 401,
headers: { 'Content-Type': 'application/json' },
}
);
}
// 获取查询参数
const url = new URL(request.url);
const page = url.searchParams.get('page') || '1';
const limit = url.searchParams.get('limit') || '20';
console.log('[API] Dataset List - 获取知识库列表:', { page, limit });
// 转发请求到 FastAPI
const apiUrl = `${API_BASE_URL}/dify-dataset/datasets?page=${page}&limit=${limit}`;
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${frontendJWT}`,
},
});
const data = await response.json();
console.log('[API] Dataset List - Success');
return new Response(JSON.stringify(data), {
status: response.status,
headers: { 'Content-Type': 'application/json' },
});
} catch (error: any) {
console.error('[API] Dataset List - Error:', error.message);
return new Response(
JSON.stringify({ error: error.message || 'Failed to get datasets' }),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
);
}
}
@@ -0,0 +1,67 @@
import { type ActionFunctionArgs } from '@remix-run/node';
import { difyClient } from '~/api/dify-chat/client.server';
/**
* POST /api/messages/:messageId/feedbacks - 提交消息反馈
*/
export async function action({ request, params }: ActionFunctionArgs) {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
try {
// 获取用户会话信息和 JWT
const { getUserSession } = await import("~/api/login/auth.server");
const { frontendJWT } = await getUserSession(request);
// 检查 JWT 是否存在
if (!frontendJWT) {
console.error('❌ [API] Message Feedback - JWT不存在');
return new Response(
JSON.stringify({ error: 'JWT认证失败,请重新登录' }),
{
status: 401,
headers: { 'Content-Type': 'application/json' },
}
);
}
const { messageId } = params;
if (!messageId) {
return new Response(
JSON.stringify({ error: '缺少 messageId 参数' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
}
);
}
const body = await request.json();
const { rating } = body;
console.log('👍 [API] Message Feedback - 提交反馈:', {
messageId,
rating,
});
const result = await difyClient.updateMessageFeedback(messageId, rating, frontendJWT);
console.log('✅ [API] Message Feedback - Success');
return new Response(JSON.stringify(result), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error: any) {
console.error('❌ [API] Message Feedback - Error:', error.message);
const status = error.message?.includes('JWT认证失败') ? 401 : 500;
return new Response(
JSON.stringify({ error: error.message || 'Failed to submit feedback' }),
{
status,
headers: { 'Content-Type': 'application/json' },
}
);
}
}
+2 -8
View File
@@ -1,6 +1,6 @@
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import { difyClient } from '../services/dify-client.server';
import { getSessionInfo, commitSession } from '../utils/session.server';
import { difyClient } from '~/api/dify-chat/client.server';
import { commitSession, getSessionInfo } from '../utils/session.server';
export async function loader({ request }: LoaderFunctionArgs) {
try {
@@ -23,14 +23,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
);
}
console.log('📋 [API] Parameters API - 获取应用参数:', {
hasJWT: !!frontendJWT
});
const data = await difyClient.getApplicationParameters(frontendJWT);
console.log('✅ [API] Parameters API - Success');
return json(data, {
headers: {
'Set-Cookie': await commitSession(session),
+1 -1
View File
@@ -1,6 +1,6 @@
import { json } from "@remix-run/node";
import { type MetaFunction } from "@remix-run/node";
import Chat from "~/components/chat";
import Chat from "~/components/dify-chat";
import chatIndexStyles from "~/styles/components/chat-with-llm/index.css?url";
import chatMessageStyles from "~/styles/components/chat-with-llm/chat-message.css?url";
import chatInputStyles from "~/styles/components/chat-with-llm/chat-input.css?url";
+26
View File
@@ -0,0 +1,26 @@
import DatasetManager from "~/components/dify-dataset-manager";
import datasetManagerStyles from "~/styles/components/dify-dataset-manager/index.css?url";
import sidebarStyles from "~/styles/components/dify-dataset-manager/sidebar.css?url";
import documentListStyles from "~/styles/components/dify-dataset-manager/document-list.css?url";
/**
* 注册样式
*/
export function links() {
return [
{ rel: "stylesheet", href: datasetManagerStyles },
{ rel: "stylesheet", href: sidebarStyles },
{ rel: "stylesheet", href: documentListStyles },
];
}
/**
* 知识库管理首页
*/
export default function DatasetManagerIndex() {
return (
<div className="dataset-manager-page" style={{ height: '93vh', padding: '16px' }}>
<DatasetManager />
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { Outlet } from "@remix-run/react";
import type { MetaFunction } from "@remix-run/node";
export const meta: MetaFunction = () => {
return [
{ title: "知识库管理 - 智能审核系统" },
{ name: "description", content: "Dify 知识库文档管理" },
];
};
export const handle = {
breadcrumb: "知识库管理",
};
/**
* 知识库管理布局路由
*/
export default function DatasetManagerLayout() {
return <Outlet />;
}
File diff suppressed because it is too large Load Diff
-264
View File
@@ -1,264 +0,0 @@
import { API_BASE_URL } from '~/config/api-config';
// 获取环境变量的服务端函数
const getServerEnvVar = (name: string, defaultValue: string = '') => {
const value = process.env[name] || defaultValue;
// console.log(`🌐 [DifyClient] 读取环境变量 ${name}:`, {
// hasValue: !!process.env[name],
// value: process.env[name] ? `${process.env[name].substring(0, 20)}...` : 'undefined',
// usingDefault: !process.env[name]
// });
return value;
};
// Dify API 客户端配置
// 注意:现在通过 FastAPI 后端的 /dify 路由代理访问 Dify,使用 JWT 认证
const DIFY_CONFIG = {
// API_URL 指向 FastAPI 后端的 /dify 路由
// API_BASE_URL 来自 api-config.ts,根据环境/端口自动配置
API_URL: `${API_BASE_URL}/dify`,
// API_KEY 保留用于配置验证(实际不再使用,改用JWT)
API_KEY: getServerEnvVar('NEXT_PUBLIC_APP_KEY', ''),
APP_ID: (() => {
const rawAppId = getServerEnvVar('NEXT_PUBLIC_APP_ID', '');
// 从完整URL中提取APP ID
const match = rawAppId.match(/\/app\/([a-f0-9-]{36})/);
return match ? match[1] : rawAppId;
})(),
};
console.log('🔧 Dify Client Config:', {
apiUrl: DIFY_CONFIG.API_URL,
apiBaseUrl: API_BASE_URL,
fullDifyUrl: `${API_BASE_URL}/dify`,
appId: DIFY_CONFIG.APP_ID,
hasApiKey: !!DIFY_CONFIG.API_KEY,
configComplete: !!(DIFY_CONFIG.API_URL && DIFY_CONFIG.APP_ID)
});
// 基础请求函数 - 使用 JWT 认证通过 FastAPI 代理访问 Dify
const difyFetch = async (endpoint: string, options: RequestInit = {}, jwt?: string) => {
const url = `${DIFY_CONFIG.API_URL}/${endpoint.replace(/^\//, '')}`;
console.log('🌐 [DifyClient] 请求FastAPI代理:', {
endpoint,
fullUrl: url,
baseUrl: API_BASE_URL,
hasJWT: !!jwt
});
// 使用 JWT 认证而非 API_KEY
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};
// 如果提供了 JWT,添加到请求头
if (jwt) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${jwt}`;
} else {
console.warn('⚠️ [DifyClient] 没有提供 JWT,请求可能失败');
}
console.log('🌐 [DifyClient] Dify API Request:', {
url,
method: options.method || 'GET',
hasJWT: !!jwt,
jwtPreview: jwt ? `${jwt.substring(0, 20)}...` : 'none'
});
const response = await fetch(url, {
...options,
headers,
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ [DifyClient] Dify API Error:', {
status: response.status,
statusText: response.statusText,
error: errorText
});
// 如果是401错误,说明JWT过期或无效
if (response.status === 401) {
throw new Error('JWT认证失败,请重新登录');
}
throw new Error(`Dify API Error: ${response.status} ${response.statusText}`);
}
return response;
};
// Dify API 客户端 - 所有方法都需要传入 JWT
// 注意:user 参数已移除,由后端自动从 JWT 中提取 username
export const difyClient = {
// 获取应用参数
async getApplicationParameters(jwt?: string) {
const response = await difyFetch('parameters', {
method: 'GET',
}, jwt);
return response.json();
},
// 获取会话列表
async getConversations(jwt?: string) {
const params = new URLSearchParams({
limit: '100',
first_id: '',
});
const response = await difyFetch(`conversations?${params}`, {
method: 'GET',
}, jwt);
return response.json();
},
// 获取会话消息
async getConversationMessages(conversationId: string, jwt?: string) {
const params = new URLSearchParams({
conversation_id: conversationId,
limit: '20',
last_id: '',
});
const response = await difyFetch(`messages?${params}`, {
method: 'GET',
}, jwt);
return response.json();
},
// 发送聊天消息
async createChatMessage(
inputs: Record<string, any>,
query: string,
responseMode: string = 'streaming',
conversationId?: string,
files?: any[],
jwt?: string
) {
const body = {
inputs,
query,
// user 字段已移除,后端会自动从 JWT 中提取 username
response_mode: responseMode,
conversation_id: conversationId,
files: files || [],
};
console.log('🌐 [DifyClient] 发送聊天消息:', {
queryLength: query.length,
queryPreview: query.substring(0, 100) + (query.length > 100 ? '...' : ''),
responseMode,
conversationId,
hasInputs: !!inputs && Object.keys(inputs).length > 0,
inputsKeys: inputs ? Object.keys(inputs) : [],
hasFiles: !!files && files.length > 0,
filesCount: files?.length || 0,
hasJWT: !!jwt
});
const response = await difyFetch('chat-messages', {
method: 'POST',
body: JSON.stringify(body),
}, jwt);
console.log('📡 [DifyClient] Dify API响应:', {
status: response.status,
statusText: response.statusText,
hasBody: !!response.body,
contentType: response.headers.get('Content-Type'),
responseMode
});
// 对于流式响应,直接返回Response对象
if (responseMode === 'streaming') {
console.log('🌊 [DifyClient] 返回流式响应对象');
return response;
}
console.log('📄 [DifyClient] 解析JSON响应');
return response.json();
},
// 重命名会话
async renameConversation(conversationId: string, name: string, autoGenerate: boolean = false, jwt?: string) {
const body = {
name,
auto_generate: autoGenerate,
// user 字段已移除,后端会自动从 JWT 中提取 username
};
const response = await difyFetch(`conversations/${conversationId}/name`, {
method: 'POST',
body: JSON.stringify(body),
}, jwt);
return response.json();
},
// 删除会话
async deleteConversation(conversationId: string, jwt?: string) {
// user 字段已移除,后端会自动从 JWT 中提取 username
const body = {};
console.log('🗑️ [DifyClient] 删除会话:', conversationId);
try {
const response = await difyFetch(`conversations/${conversationId}`, {
method: 'DELETE',
body: JSON.stringify(body),
}, jwt);
console.log('🗑️ [DifyClient] 删除会话响应:', {
status: response.status,
statusText: response.statusText,
contentType: response.headers.get('Content-Type')
});
// 检查响应的Content-Type
const contentType = response.headers.get('Content-Type');
// 如果是JSON响应,解析JSON
if (contentType && contentType.includes('application/json')) {
const data = await response.json();
console.log('🗑️ [DifyClient] 删除会话JSON响应:', data);
return data;
}
// 如果不是JSON,返回成功标识
const text = await response.text();
console.log('🗑️ [DifyClient] 删除会话文本响应:', text);
// 返回标准成功响应
return { result: 'success' };
} catch (error: any) {
console.warn('⚠️ [DifyClient] 删除会话请求失败,但可能已成功删除:', error.message);
// 删除操作的特殊处理:
// 即使API返回错误,实际上会话可能已经被删除
// 返回成功标识,避免误报错误
// 如果会话确实不存在,下次加载会话列表时就会发现
return { result: 'success' };
}
},
// 更新消息反馈
async updateMessageFeedback(messageId: string, rating: 'like' | 'dislike' | null, jwt?: string) {
const body = {
rating,
// user 字段已移除,后端会自动从 JWT 中提取 username
};
const response = await difyFetch(`messages/${messageId}/feedbacks`, {
method: 'POST',
body: JSON.stringify(body),
}, jwt);
return response.json();
},
};
// 工具函数
export const difyUtils = {
getConfig: () => DIFY_CONFIG,
};
@@ -0,0 +1,91 @@
/**
* 寓¡h - ‡ch7
*/
/* åw */
.document-list-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
gap: 16px;
}
.document-list-search {
width: 280px;
}
.document-list-actions {
display: flex;
align-items: center;
gap: 12px;
}
/* h<7 */
.document-table {
flex: 1;
}
.document-table .ant-table-thead > tr > th {
background-color: #fafafa;
font-weight: 600;
}
.document-table .ant-table-tbody > tr:hover > td {
background-color: #f5f5f5;
}
/* ‡c
ð */
.document-name-cell {
display: flex;
align-items: center;
gap: 8px;
}
.document-name-cell .anticon {
color: #999;
font-size: 16px;
}
.document-name-cell span {
font-weight: 500;
}
/* ¶~ */
.document-status-tag {
display: inline-flex;
align-items: center;
gap: 4px;
}
/* Í\ ® */
.document-actions {
display: flex;
align-items: center;
gap: 4px;
}
/* z¶ */
.document-list-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
}
/* Í”@ */
@media (max-width: 991px) {
.document-list-toolbar {
flex-direction: column;
align-items: stretch;
}
.document-list-search {
width: 100%;
}
.document-list-actions {
justify-content: space-between;
}
@@ -0,0 +1,101 @@
/**
* 寓¡h - ;@7
*/
.dataset-manager-page {
display: flex;
flex-direction: column;
background-color: #f5f7f9;
overflow: hidden;
}
.dataset-manager-container {
display: flex;
flex-direction: row;
height: 100%;
background-color: #f5f7f9;
overflow: hidden;
}
/* z¶¹h */
.dataset-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 40px;
color: #999;
}
.dataset-empty h3 {
margin-bottom: 8px;
color: #666;
font-size: 16px;
}
.dataset-empty p {
color: #999;
font-size: 14px;
}
/*  ¹h */
.dataset-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 16px;
}
/* ;…¹:ß */
.dataset-content {
display: flex;
flex-direction: column;
flex: 1;
padding: 20px 24px;
overflow: auto;
}
/* 4è:ß */
.dataset-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.dataset-header h1 {
font-size: 20px;
font-weight: 600;
color: #1a1a1a;
margin: 0;
}
.dataset-header-actions {
display: flex;
gap: 12px;
}
/* Í”@ */
@media (max-width: 991px) {
.dataset-manager-container {
flex-direction: column;
}
.dataset-content {
padding: 16px;
}
.dataset-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.dataset-header-actions {
width: 100%;
justify-content: flex-end;
}
}
@@ -0,0 +1,135 @@
/**
* 知识库管理器 - 侧边栏样式
*/
.dataset-sidebar {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.dataset-sidebar .ant-layout-sider-children {
display: flex;
flex-direction: column;
height: 100%;
}
/* 侧边栏头部 */
.dataset-sidebar-header {
display: flex;
flex-direction: column;
padding: 12px;
border-bottom: 1px solid #f0f0f0;
gap: 12px;
}
.dataset-sidebar-title {
display: flex;
align-items: center;
gap: 8px;
}
.dataset-sidebar-title h3 {
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
margin: 0;
}
/* 知识库列表 */
.dataset-sidebar-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
/* 菜单样式覆盖 */
.dataset-sidebar-menu.ant-menu {
border: none;
background: transparent;
}
.dataset-sidebar-menu .ant-menu-item {
margin: 4px 8px;
border-radius: 8px;
height: auto;
line-height: 1.5;
padding: 8px 12px !important;
}
.dataset-sidebar-menu .ant-menu-item-selected {
background-color: rgba(0, 104, 74, 0.1) !important;
}
.dataset-sidebar-menu .ant-menu-item-selected::after {
display: none;
}
.dataset-sidebar-menu .ant-menu-item:hover {
background-color: #f5f5f5;
}
/* 知识库信息 */
.dataset-info {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.dataset-info-name {
font-size: 14px;
font-weight: 500;
color: #1a1a1a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dataset-info-meta {
display: flex;
align-items: center;
gap: 12px;
}
.dataset-info-meta-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #999;
}
.dataset-info-meta-item .anticon {
font-size: 12px;
}
/* 侧边栏底部 */
.dataset-sidebar-footer {
padding: 12px 16px;
border-top: 1px solid #f0f0f0;
background: #fafafa;
}
.stats-text {
font-size: 12px;
color: #999;
text-align: center;
}
/* 响应式布局 */
@media (max-width: 991px) {
.dataset-sidebar {
position: fixed;
left: 0;
top: 0;
z-index: 1000;
height: 100vh;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
}
.dataset-sidebar.ant-layout-sider-collapsed {
transform: translateX(-100%);
}
}
-264
View File
@@ -1,264 +0,0 @@
// 应用信息类型
export interface AppInfo {
title: string;
description: string;
copyright: string;
privacy_policy: string;
default_language: string;
}
// 会话项类型
export interface ConversationItem {
id: string;
name: string;
inputs?: Record<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 TextTypeFormItem {
label: string;
variable: string;
required: boolean;
max_length: number;
}
// 选择类型表单项
export interface SelectTypeFormItem {
label: string;
variable: string;
required: boolean;
options: string[];
}
// 用户输入表单项
export type UserInputFormItem = {
'text-input': TextTypeFormItem;
} | {
'select': SelectTypeFormItem;
} | {
'paragraph': TextTypeFormItem;
}
// 提示配置类型
export interface PromptConfig {
prompt_template: string;
prompt_variables: PromptVariable[];
}
// 提示变量类型
export interface PromptVariable {
key: string;
name: string;
type: string;
required: boolean;
options?: string[];
max_length?: number;
allowed_file_extensions?: string[];
allowed_file_types?: string[];
allowed_file_upload_methods?: TransferMethod[];
}
// 视觉文件类型
export interface VisionFile {
id?: string;
type: string;
transfer_method: TransferMethod;
url?: string;
upload_file_id?: string;
belongs_to?: string;
usage?: string;
result?: any;
detail?: Resolution;
}
// 图片文件类型
export interface ImageFile {
type: TransferMethod;
_id: string;
fileId: string;
file?: File;
progress: number;
url: string;
base64Url?: string;
deleted?: boolean;
}
// 视觉设置类型
export interface VisionSettings {
enabled: boolean;
detail?: Resolution;
number_limits?: number;
transfer_methods?: TransferMethod[];
image_file_size_limit?: number | string;
}
// 分辨率枚举
export enum Resolution {
low = 'low',
high = 'high',
}
// 传输方法枚举
export enum TransferMethod {
local_file = 'local_file',
remote_url = 'remote_url',
all = 'all',
}
// 工作流运行状态枚举
export enum WorkflowRunningStatus {
init = 'init',
running = 'running',
completed = 'completed',
error = 'error',
waiting = 'waiting',
succeeded = 'succeeded',
failed = 'failed',
stopped = 'stopped',
}
// 节点运行状态枚举
export enum NodeRunningStatus {
NotStart = 'not-start',
Waiting = 'waiting',
Running = 'running',
Succeeded = 'succeeded',
Failed = 'failed',
}
// 块类型枚举
export enum BlockEnum {
Start = 'start',
End = 'end',
Answer = 'answer',
LLM = 'llm',
KnowledgeRetrieval = 'knowledge-retrieval',
QuestionClassifier = 'question-classifier',
IfElse = 'if-else',
Code = 'code',
TemplateTransform = 'template-transform',
HttpRequest = 'http-request',
VariableAssigner = 'variable-assigner',
Tool = 'tool',
}
// 节点追踪类型
export interface NodeTracing {
id: string;
index: number;
predecessor_node_id: string;
node_id: string;
node_type: BlockEnum;
title: string;
inputs: any;
process_data: any;
outputs?: any;
status: string;
error?: string;
elapsed_time: number;
execution_metadata: {
total_tokens: number;
total_price: number;
currency: string;
};
created_at: number;
created_by: {
id: string;
name: string;
email: string;
};
finished_at: number;
extras?: any;
expand?: boolean; // for UI
}
// 工作流进程类型
export interface WorkflowProcess {
status: WorkflowRunningStatus;
tracing: NodeTracing[];
expand?: boolean; // for UI
}
// 代码语言枚举
export enum CodeLanguage {
python3 = 'python3',
javascript = 'javascript',
json = 'json',
}
// 消息事件类型
export interface MessageEvent {
event: string;
task_id: string;
conversation_id: string;
message_id: string;
answer: string;
}
// 消息替换类型
export interface MessageReplace {
event: string;
task_id: string;
conversation_id: string;
message_id: string;
answer: string;
}
// 消息结束类型
export interface MessageEnd {
event: string;
task_id: string;
conversation_id: string;
message_id: string;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { PromptVariable, ThoughtItem, UserInputFormItem, VisionFile } from '../types/dify_chat';
import type { PromptVariable, ThoughtItem, UserInputFormItem, VisionFile } from '~/api/dify-chat';
/**
* 替换提示模板中的变量