本地 RAG 知识库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

602 lines
19 KiB

/**
* API 通信模块 - HTTP 封装 + 错误拦截 + 参数映射
*
* 核心参数映射(SDK → 后端):
* integrateId → roleId(客服角色 ID)
* userId → accountId(客户账号 ID)
* chatId → 自动管理的对话 ID(从 /ai/sdk/conversation/list 获取或自动生成)
*/
import { ResolvedConfig, ApiResponse, CategoryNode } from './types';
import { logger } from './logger';
import { t } from './i18n';
/** 请求超时时间(毫秒) */
const REQUEST_TIMEOUT = 30000;
let currentConfig: ResolvedConfig | null = null;
/** 设置当前配置 */
export function setApiConfig(config: ResolvedConfig): void {
currentConfig = config;
}
/** 更新当前 chatId(对话 ID) */
export function updateChatId(chatId: string): void {
if (currentConfig) {
currentConfig.chatId = chatId;
}
}
/** 获取当前 chatId */
export function getChatId(): string {
return currentConfig?.chatId || '';
}
/** 构建完整请求 URL,自动防御双斜杠 */
export function buildUrl(path: string): string {
if (!currentConfig) {
throw new Error('API 配置未初始化');
}
const domain = currentConfig.requestDomain.replace(/\/+$/, '');
const cleanPath = path.startsWith('/') ? path : `/${path}`;
return `${domain}${cleanPath}`;
}
/**
* 安全设置可选参数:仅当 value 非空时追加
*/
function setIfPresent(params: URLSearchParams, key: string, value: string | number | undefined): void {
if (value === undefined || value === null) return;
if (typeof value === 'string' && value.trim() === '') return;
params.set(key, String(value));
}
// ==================== 对话接口 URL 构建 ====================
/**
* 构建同步对话请求 URL
* - integrateId → roleId
* - userId → accountId
* - chatId → 自动管理的对话 ID
*/
function buildChatUrl(message: string): string {
const params = new URLSearchParams();
params.set('message', message);
params.set('chatId', currentConfig!.chatId);
// integrateId 映射为 roleId
setIfPresent(params, 'roleId', currentConfig!.integrateId);
// userId 映射为 accountId
setIfPresent(params, 'accountId', currentConfig!.userId);
return buildUrl(`/ai/assistant_app/chat/sync?${params.toString()}`);
}
/**
* 构建 SSE 流式请求 URL
*/
function buildChatSSEUrl(message: string, categoryId?: number): string {
const params = new URLSearchParams();
params.set('message', message);
params.set('chatId', currentConfig!.chatId);
setIfPresent(params, 'roleId', currentConfig!.integrateId);
setIfPresent(params, 'accountId', currentConfig!.userId);
setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId);
return buildUrl(`/ai/assistant_app/chat/sse?${params.toString()}`);
}
/**
* 构建 RAG 增强流式请求 URL
*/
function buildChatRAGSSEUrl(message: string, categoryId?: number): string {
const params = new URLSearchParams();
params.set('message', message);
params.set('chatId', currentConfig!.chatId);
params.set('rewriteStrategy', currentConfig!.rewriteStrategy || 'REWRITE');
setIfPresent(params, 'roleId', currentConfig!.integrateId);
setIfPresent(params, 'accountId', currentConfig!.userId);
setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId);
return buildUrl(`/ai/assistant_app/chat/rag/sse?${params.toString()}`);
}
/**
* 构建 RAG 引用来源请求 URL
*/
function buildRagSourcesUrl(message: string, categoryId?: number): string {
const params = new URLSearchParams();
params.set('message', message);
params.set('chatId', currentConfig!.chatId);
params.set('rewriteStrategy', currentConfig!.rewriteStrategy || 'REWRITE');
setIfPresent(params, 'roleId', currentConfig!.integrateId);
setIfPresent(params, 'accountId', currentConfig!.userId);
setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId);
return buildUrl(`/ai/assistant_app/rag/sources?${params.toString()}`);
}
// ==================== HTTP 基础封装 ====================
/** 带超时的 fetch 封装,支持外部 AbortSignal(用于流式主动中断) */
async function safeFetch(
url: string,
options: RequestInit = {},
timeout: number = REQUEST_TIMEOUT,
externalSignal?: AbortSignal
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
// 外部信号触发时同步中断内部请求
if (externalSignal) {
if (externalSignal.aborted) {
controller.abort();
} else {
externalSignal.addEventListener('abort', () => controller.abort(), { once: true });
}
}
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
mode: 'cors',
credentials: 'include',
});
return response;
} catch (err: unknown) {
// 用户主动中断(停止生成),不算错误
if (externalSignal?.aborted) {
throw new CskError('aborted', 'aborted');
}
if (err instanceof DOMException && err.name === 'AbortError') {
throw new CskError(t('error_timeout'), 'timeout');
}
if (err instanceof TypeError && err.message.includes('Failed to fetch')) {
throw new CskError(t('error_cors'), 'cors');
}
throw new CskError(t('error_network'), 'network');
} finally {
clearTimeout(timer);
}
}
/** 自定义错误类型 */
export class CskError extends Error {
type: string;
constructor(message: string, type: string) {
super(message);
this.name = 'CskError';
this.type = type;
}
}
/** 根据 HTTP 状态码返回对应的国际化错误消息 */
function getHttpErrorMessage(status: number): string {
switch (status) {
case 401: return t('error_auth');
case 403: return t('error_forbidden');
case 404: return t('error_not_found');
case 429: return t('error_rate_limit');
case 500: return t('error_server');
case 502:
case 503: return t('error_unavailable');
default: return `${t('error_unknown')}${status}`;
}
}
// ==================== 对话请求 ====================
/**
* 同步对话请求
*/
export async function chatRequest(message: string): Promise<string> {
const url = buildChatUrl(message);
logger.lifecycleSend(currentConfig!.integrateId, message.length);
try {
const response = await safeFetch(url);
if (!response.ok) {
const errorMsg = getHttpErrorMessage(response.status);
logger.lifecycleError(currentConfig!.integrateId, String(response.status), errorMsg);
throw new CskError(errorMsg, `http_${response.status}`);
}
const text = await response.text();
logger.lifecycleReply(currentConfig!.integrateId, text.length);
return text;
} catch (err) {
if (err instanceof CskError) throw err;
logger.lifecycleError(currentConfig!.integrateId, 'unknown', String(err));
throw new CskError(t('error_unknown'), 'unknown');
}
}
/**
* SSE 流式对话请求
* @param useRag 是否使用 RAG 增强对话
* @param categoryId 知识库分类 ID
*/
export async function chatSSERequest(
message: string,
onChunk: (text: string) => void,
onDone: () => void,
onError: (error: CskError) => void,
categoryId?: number,
useRag?: boolean,
signal?: AbortSignal
): Promise<void> {
const url = useRag
? buildChatRAGSSEUrl(message, categoryId)
: buildChatSSEUrl(message, categoryId);
let totalText = '';
logger.lifecycleSend(currentConfig!.integrateId, message.length);
try {
const response = await safeFetch(url, {}, REQUEST_TIMEOUT * 2, signal);
if (!response.ok) {
const errorMsg = getHttpErrorMessage(response.status);
logger.lifecycleError(currentConfig!.integrateId, String(response.status), errorMsg);
onError(new CskError(errorMsg, `http_${response.status}`));
return;
}
const reader = response.body?.getReader();
if (!reader) {
onError(new CskError(t('error_stream_unsupported'), 'stream_unsupported'));
return;
}
const decoder = new TextDecoder('utf-8', { stream: true } as TextDecoderOptions);
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith(':')) continue;
if (trimmed.startsWith('data:')) {
const data = trimmed.substring(5).trim();
if (data) { totalText += data; onChunk(data); }
} else if (trimmed === '[DONE]') {
break;
} else if (!trimmed.startsWith('event:') && !trimmed.startsWith('id:') && !trimmed.startsWith('retry:')) {
totalText += trimmed;
onChunk(trimmed);
}
}
}
if (buffer.trim()) {
const trimmed = buffer.trim();
if (trimmed.startsWith('data:')) {
const data = trimmed.substring(5).trim();
if (data) { totalText += data; onChunk(data); }
} else if (trimmed !== '[DONE]') {
totalText += trimmed;
onChunk(trimmed);
}
}
} catch (readErr: unknown) {
// 用户主动中断:视为正常结束,保留已生成内容
if (signal?.aborted) {
logger.info(`流式被用户中断,保留已生成内容 length=${totalText.length}`);
} else if (totalText.length > 0) {
onChunk('\n\n' + t('stream_unstable'));
} else {
throw readErr;
}
} finally {
reader.releaseLock();
}
logger.lifecycleStreamDone(currentConfig!.integrateId, totalText.length);
onDone();
} catch (err) {
// 用户主动中断不触发 onError,走 onDone
if (signal?.aborted || (err instanceof CskError && err.type === 'aborted')) {
onDone();
return;
}
if (err instanceof CskError) {
onError(err);
} else {
logger.lifecycleError(currentConfig!.integrateId, 'unknown', String(err));
onError(new CskError(t('error_network'), 'network'));
}
}
}
// ==================== P1: 知识库分类 ====================
/**
* 获取知识库分类树
*/
export async function fetchCategoryTree(): Promise<CategoryNode[]> {
const url = buildUrl('/category/tree');
try {
const response = await safeFetch(url);
if (!response.ok) throw new CskError(getHttpErrorMessage(response.status), `http_${response.status}`);
const json: ApiResponse<CategoryNode[]> = await response.json();
if (json.success && Array.isArray(json.data)) {
logger.info(`加载分类树成功 count=${json.data.length}`);
return json.data;
}
return [];
} catch (err) {
if (err instanceof CskError) logger.error(`加载分类树失败: ${err.message}`);
else logger.error('加载分类树失败', err);
return [];
}
}
// ==================== P1: RAG 引用来源 ====================
/** RAG 来源响应数据项 */
interface RagSourceData {
documentId?: string;
title?: string;
sourceName?: string;
chunkIndex?: number;
score?: number;
snippet?: string;
}
/**
* 获取 RAG 引用来源
*/
export async function fetchRagSources(message: string, categoryId?: number): Promise<RagSourceData[]> {
const url = buildRagSourcesUrl(message, categoryId);
try {
const response = await safeFetch(url);
if (!response.ok) throw new CskError(getHttpErrorMessage(response.status), `http_${response.status}`);
const json: ApiResponse<RagSourceData[]> = await response.json();
if (json.success && Array.isArray(json.data)) {
logger.info(`获取引用来源 count=${json.data.length}`);
return json.data;
}
return [];
} catch (err) {
logger.error('获取引用来源失败', err);
return [];
}
}
// ==================== P0-002: 消息反馈 ====================
/**
* 提交消息反馈(点赞/点踩)
*/
export async function submitFeedbackApi(
messageId: string,
feedbackType: 'THUMBS_UP' | 'THUMBS_DOWN'
): Promise<boolean> {
if (!currentConfig) return false;
const url = buildUrl('/feedback');
try {
const response = await safeFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messageId: String(messageId),
conversationId: currentConfig.chatId,
feedbackType,
}),
});
if (!response.ok) {
logger.error(`反馈提交失败 status=${response.status}`);
return false;
}
const json: ApiResponse = await response.json();
return json.success || false;
} catch (err) {
logger.error('反馈提交异常', err);
return false;
}
}
// ==================== P2: 会话管理 + chatId 初始化 ====================
/** 会话列表项 */
interface ConversationItem {
conversationId?: string;
chatId?: string;
accountId?: string;
roleId?: number;
messageCount?: number;
lastMessageTime?: string;
firstMessageTime?: string;
lastMessagePreview?: string;
createdAt?: string;
}
/**
* 获取会话列表(SDK 安全端点)
* 使用 /ai/sdk/conversation/list,强制按 accountId + roleId 过滤
*/
export async function fetchConversationList(
page: number = 1,
size: number = 20,
accountId?: string,
roleId?: string
): Promise<{
list: ConversationItem[];
total: number;
pages: number;
}> {
const params = new URLSearchParams();
params.set('page', String(page));
params.set('size', String(size));
if (accountId) params.set('accountId', accountId);
if (roleId) params.set('roleId', roleId);
const url = buildUrl(`/ai/sdk/conversation/list?${params.toString()}`);
try {
const response = await safeFetch(url);
if (!response.ok) throw new CskError(getHttpErrorMessage(response.status), `http_${response.status}`);
const json: ApiResponse<ConversationItem[]> = await response.json();
return {
list: json.success && Array.isArray(json.data) ? json.data : [],
total: json.total || 0,
pages: json.pages || 0,
};
} catch (err) {
logger.error('加载会话列表失败', err);
return { list: [], total: 0, pages: 0 };
}
}
/**
* 获取会话消息(SDK 安全端点)
* 使用 /ai/sdk/conversation/{id}/messages,含账户归属校验
*/
export async function fetchConversationMessages(conversationId: string): Promise<{
messages: Array<{ messageType: string; content: string; createTime: string }>;
total: number;
}> {
const params = new URLSearchParams();
if (currentConfig?.userId) params.set('accountId', currentConfig.userId);
if (currentConfig?.integrateId) params.set('roleId', currentConfig.integrateId);
const url = buildUrl(`/ai/sdk/conversation/${conversationId}/messages?${params.toString()}`);
try {
const response = await safeFetch(url);
if (!response.ok) throw new CskError(getHttpErrorMessage(response.status), `http_${response.status}`);
const json = await response.json();
return {
messages: json.success && Array.isArray(json.data) ? json.data : [],
total: json.total || 0,
};
} catch (err) {
logger.error('加载会话消息失败', err);
return { messages: [], total: 0 };
}
}
/**
* 删除会话(SDK 安全端点)
* 使用 DELETE /ai/sdk/conversation/{id},含账户归属校验
*/
export async function deleteConversation(conversationId: string): Promise<boolean> {
const params = new URLSearchParams();
if (currentConfig?.userId) params.set('accountId', currentConfig.userId);
if (currentConfig?.integrateId) params.set('roleId', currentConfig.integrateId);
const url = buildUrl(`/ai/sdk/conversation/${conversationId}?${params.toString()}`);
try {
const response = await safeFetch(url, { method: 'DELETE' });
if (!response.ok) throw new CskError(getHttpErrorMessage(response.status), `http_${response.status}`);
const json: ApiResponse = await response.json();
logger.info(`删除会话 id=${conversationId} success=${json.success}`);
return json.success || false;
} catch (err) {
logger.error('删除会话失败', err);
return false;
}
}
/**
* 导出会话 URL(SDK 安全端点)
* 使用 /ai/sdk/conversation/{id}/export,含账户归属参数
*/
export function getConversationExportUrl(conversationId: string): string {
const params = new URLSearchParams();
if (currentConfig?.userId) params.set('accountId', currentConfig.userId);
if (currentConfig?.integrateId) params.set('roleId', currentConfig.integrateId);
return buildUrl(`/ai/sdk/conversation/${conversationId}/export?${params.toString()}`);
}
// ==================== chatId 自动初始化 ====================
/**
* 初始化 chatId:查询后端已有会话,找到则复用,否则生成新的
*
* 逻辑:
* 1. 先查 localStorage 缓存的 chatId(同一 integrateId + userId 可能复用)
* 2. 查 /ai/sdk/conversation/list?accountId=X&roleId=Y 看是否有匹配的会话
* 3. 有会话 → 使用最新会话的 conversationId 作为 chatId
* 4. 无会话 → 自动生成 chatId(格式:sdk_timestamp_random)
*/
export async function initChatId(): Promise<string> {
if (!currentConfig) return '';
// 1. 先尝试从 localStorage 恢复
const cachedChatId = loadCachedChatId(currentConfig.integrateId, currentConfig.userId);
if (cachedChatId) {
currentConfig.chatId = cachedChatId;
logger.info(`从缓存恢复 chatId=${cachedChatId}`);
return cachedChatId;
}
// 2. 查询后端会话列表
try {
const result = await fetchConversationList(1, 5, currentConfig.userId, currentConfig.integrateId);
if (result.list.length > 0) {
// 使用最新会话的 conversationId 作为 chatId
const latestConv = result.list[0];
const chatId = latestConv.conversationId || latestConv.chatId || '';
if (chatId) {
currentConfig.chatId = chatId;
saveCachedChatId(currentConfig.integrateId, currentConfig.userId, chatId);
logger.info(`从后端恢复会话 chatId=${chatId} messageCount=${latestConv.messageCount}`);
return chatId;
}
}
} catch (err) {
logger.warn('查询后端会话列表失败,将生成新 chatId', err);
}
// 3. 生成新的 chatId
const newChatId = generateChatId();
currentConfig.chatId = newChatId;
saveCachedChatId(currentConfig.integrateId, currentConfig.userId, newChatId);
logger.info(`生成新 chatId=${newChatId}`);
return newChatId;
}
/** 生成 chatId(格式:sdk_timestamp_random) */
function generateChatId(): string {
const random = typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID().substring(0, 8)
: Math.random().toString(36).substring(2, 10);
return `sdk_${Date.now()}_${random}`;
}
/** localStorage key 格式 */
function chatIdStorageKey(integrateId: string, userId?: string): string {
return `csk_chatId_${integrateId}${userId ? '_' + userId : ''}`;
}
/** 从 localStorage 加载 chatId */
function loadCachedChatId(integrateId: string, userId?: string): string {
try {
return localStorage.getItem(chatIdStorageKey(integrateId, userId)) || '';
} catch {
return '';
}
}
/** 保存 chatId 到 localStorage */
export function saveCachedChatId(integrateId: string, userId?: string, chatId?: string): void {
try {
if (chatId) {
localStorage.setItem(chatIdStorageKey(integrateId, userId), chatId);
} else {
localStorage.removeItem(chatIdStorageKey(integrateId, userId));
}
} catch {
// localStorage 不可用则忽略
}
}