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.
747 lines
25 KiB
747 lines
25 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;
|
|
|
|
/** 运行时动态设置的角色 ID(通过角色选择器切换),优先于 currentConfig.integrateId */
|
|
let activeIntegrateId: string | null = null;
|
|
|
|
/** 设置当前配置 */
|
|
export function setApiConfig(config: ResolvedConfig): void {
|
|
currentConfig = config;
|
|
}
|
|
|
|
/** 清空当前配置(销毁 SDK 时调用) */
|
|
export function clearApiConfig(): void {
|
|
currentConfig = null;
|
|
activeIntegrateId = null;
|
|
}
|
|
|
|
/** 设置当前活跃角色 ID(角色切换时调用),影响后续所有 API 调用的 roleId 参数 */
|
|
export function setActiveRoleId(roleId: string | null): void {
|
|
activeIntegrateId = roleId;
|
|
}
|
|
|
|
/** 获取当前有效的 integrateId(优先使用运行时设置,回退到配置默认值) */
|
|
export function getActiveIntegrateId(): string {
|
|
return activeIntegrateId ?? currentConfig?.integrateId ?? '';
|
|
}
|
|
|
|
/** 更新当前 chatId(对话 ID) */
|
|
export function updateChatId(chatId: string): void {
|
|
if (currentConfig) {
|
|
currentConfig.chatId = chatId;
|
|
}
|
|
}
|
|
|
|
/** 获取当前 chatId */
|
|
export function getChatId(): string {
|
|
return currentConfig?.chatId || '';
|
|
}
|
|
|
|
/** 构建完整请求 URL,自动防御双斜杠 */
|
|
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', getActiveIntegrateId());
|
|
// 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', getActiveIntegrateId());
|
|
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', getActiveIntegrateId());
|
|
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', getActiveIntegrateId());
|
|
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 {
|
|
// 注入 SDK Token 认证头(仅对 /ai/ 路径注入,避免将 SDK Token 发送到管理接口)
|
|
const headers: Record<string, string> = {};
|
|
if (options.headers) {
|
|
if (options.headers instanceof Headers) {
|
|
options.headers.forEach((value, key) => { headers[key] = value; });
|
|
} else if (Array.isArray(options.headers)) {
|
|
options.headers.forEach(([key, value]) => { headers[key] = value; });
|
|
} else {
|
|
Object.assign(headers, options.headers as Record<string, string>);
|
|
}
|
|
}
|
|
// 为受 SdkAuthFilter 保护的路径自动注入 Bearer Token(/ai/** 和 /feedback)
|
|
if (currentConfig?.token && (url.includes('/ai/') || url.endsWith('/feedback'))) {
|
|
headers['Authorization'] = `Bearer ${currentConfig.token}`;
|
|
}
|
|
|
|
const response = await fetch(url, {
|
|
...options,
|
|
headers,
|
|
signal: controller.signal,
|
|
mode: 'cors',
|
|
credentials: 'include',
|
|
});
|
|
if (response.status === 401 && (url.includes('/ai/') || url.endsWith('/feedback'))) {
|
|
logger.error('SDK 认证失败:Token 缺失或已过期,请重新调用 /open-api/auth/token 换取 Token');
|
|
}
|
|
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(getActiveIntegrateId(), message.length);
|
|
|
|
try {
|
|
const response = await safeFetch(url);
|
|
if (!response.ok) {
|
|
const errorMsg = getHttpErrorMessage(response.status);
|
|
logger.lifecycleError(getActiveIntegrateId(), String(response.status), errorMsg);
|
|
throw new CskError(errorMsg, `http_${response.status}`);
|
|
}
|
|
|
|
const text = await response.text();
|
|
logger.lifecycleReply(getActiveIntegrateId(), text.length);
|
|
return text;
|
|
} catch (err) {
|
|
if (err instanceof CskError) throw err;
|
|
logger.lifecycleError(getActiveIntegrateId(), '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(getActiveIntegrateId(), message.length);
|
|
|
|
try {
|
|
const response = await safeFetch(url, {}, REQUEST_TIMEOUT * 2, signal);
|
|
|
|
if (!response.ok) {
|
|
const errorMsg = getHttpErrorMessage(response.status);
|
|
logger.lifecycleError(getActiveIntegrateId(), 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 = '';
|
|
let eventLines: string[] = [];
|
|
/** 当前 SSE 事件类型(event:xxx 行声明的类型),空=默认 message 事件 */
|
|
let currentEventType = '';
|
|
|
|
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();
|
|
|
|
// 空行 = SSE 事件边界,刷新当前事件
|
|
if (!trimmed) {
|
|
if (eventLines.length > 0) {
|
|
// 非内容事件(status 等)不回调 onChunk,避免污染 AI 回复渲染
|
|
if (currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
|
|
const chunk = eventLines.join('\n');
|
|
totalText += chunk;
|
|
onChunk(chunk);
|
|
}
|
|
eventLines = [];
|
|
}
|
|
currentEventType = '';
|
|
continue;
|
|
}
|
|
|
|
// 忽略注释行 (: ...)
|
|
if (trimmed.startsWith(':')) {
|
|
// TODO: 信号行 ":ok" 或其他
|
|
continue;
|
|
}
|
|
|
|
// [DONE] 信号(结束整次 SSE 流)
|
|
if (trimmed === '[DONE]') {
|
|
if (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
|
|
const chunk = eventLines.join('\n');
|
|
totalText += chunk;
|
|
onChunk(chunk);
|
|
eventLines = [];
|
|
}
|
|
currentEventType = '';
|
|
break;
|
|
}
|
|
|
|
// SSE 协议字段:event / id / retry
|
|
if (trimmed.startsWith('event:')) {
|
|
// 记录当前事件类型,用于决定是否将后续 data 行传递给 AI 渲染
|
|
currentEventType = trimmed.substring('event:'.length).trim();
|
|
continue;
|
|
}
|
|
if (trimmed.startsWith('id:') || trimmed.startsWith('retry:')) continue;
|
|
|
|
// 提取 content(data: 前缀行去掉前缀,裸行为 Flux 元素内换行的续行)
|
|
let content: string;
|
|
if (trimmed.startsWith('data:')) {
|
|
content = trimmed.substring('data:'.length);
|
|
// 去掉 data: 后面紧跟的一个空格(SSE 标准允许)
|
|
if (content.startsWith(' ')) content = content.substring(1);
|
|
} else {
|
|
content = trimmed;
|
|
}
|
|
eventLines.push(content);
|
|
}
|
|
}
|
|
|
|
if (buffer.trim()) {
|
|
const trimmed = buffer.trim();
|
|
|
|
if (!trimmed.startsWith(':')) {
|
|
if (trimmed.startsWith('data:')) {
|
|
let content = trimmed.substring('data:'.length);
|
|
if (content.startsWith(' ')) content = content.substring(1);
|
|
eventLines.push(content);
|
|
} else if (trimmed !== '[DONE]' && !trimmed.startsWith('event:') && !trimmed.startsWith('id:') && !trimmed.startsWith('retry:')) {
|
|
eventLines.push(trimmed);
|
|
}
|
|
}
|
|
}
|
|
// 处理缓冲区剩余
|
|
if (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
|
|
const chunk = eventLines.join('\n');
|
|
totalText += chunk;
|
|
onChunk(chunk);
|
|
eventLines = [];
|
|
}
|
|
} 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(getActiveIntegrateId(), 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(getActiveIntegrateId(), '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',
|
|
reasonCategory?: string,
|
|
reasonComment?: string,
|
|
): Promise<boolean> {
|
|
if (!currentConfig) return false;
|
|
const url = buildUrl('/feedback');
|
|
try {
|
|
const body: Record<string, string> = {
|
|
messageId: String(messageId),
|
|
conversationId: currentConfig.chatId,
|
|
feedbackType,
|
|
};
|
|
if (reasonCategory) body.reasonCategory = reasonCategory;
|
|
if (reasonComment) body.reasonComment = reasonComment;
|
|
const response = await safeFetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
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;
|
|
roleName?: string;
|
|
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 (getActiveIntegrateId()) params.set('roleId', getActiveIntegrateId());
|
|
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 (getActiveIntegrateId()) params.set('roleId', getActiveIntegrateId());
|
|
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 (getActiveIntegrateId()) params.set('roleId', getActiveIntegrateId());
|
|
return buildUrl(`/ai/sdk/conversation/${conversationId}/export?${params.toString()}`);
|
|
}
|
|
|
|
// ==================== chatId 自动初始化 ====================
|
|
|
|
/**
|
|
* 初始化 chatId:查询后端已有会话,找到则复用,否则生成新的
|
|
*
|
|
* @param forceRefresh 为 true 时跳过 localStorage 缓存,强制查询后端
|
|
*/
|
|
export async function initChatId(forceRefresh: boolean = false): Promise<string> {
|
|
if (!currentConfig) return '';
|
|
|
|
const activeId = getActiveIntegrateId();
|
|
|
|
// 1. 先从 localStorage 恢复(非强制刷新时)
|
|
if (!forceRefresh) {
|
|
const cachedChatId = loadCachedChatId(activeId, currentConfig.userId);
|
|
if (cachedChatId) {
|
|
currentConfig.chatId = cachedChatId;
|
|
logger.info(`从缓存恢复 chatId=${cachedChatId}`);
|
|
return cachedChatId;
|
|
}
|
|
}
|
|
|
|
// 2. 查询后端会话列表
|
|
try {
|
|
const result = await fetchConversationList(1, 5, currentConfig.userId, activeId);
|
|
if (result.list.length > 0) {
|
|
// 使用最新会话的 conversationId 作为 chatId
|
|
const latestConv = result.list[0];
|
|
const chatId = latestConv.conversationId || latestConv.chatId || '';
|
|
if (chatId) {
|
|
currentConfig.chatId = chatId;
|
|
saveCachedChatId(activeId, 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(activeId, 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 不可用则忽略
|
|
}
|
|
}
|
|
|
|
// ==================== 系统配置(SDK 公开接口) ====================
|
|
|
|
/**
|
|
* 获取系统配置值(供 SDK 动态拉取声明等配置)
|
|
* 路径 /ai/system-config/{key},safeFetch 自动注入 Bearer Token;
|
|
* 无 Token 的 SDK 环境将被 SdkAuthFilter 拦截返回 401,此处静默降级为空字符串。
|
|
*/
|
|
export async function fetchSystemConfig(key: string): Promise<string> {
|
|
if (!currentConfig) return '';
|
|
try {
|
|
const url = buildUrl(`/ai/system-config/${encodeURIComponent(key)}`);
|
|
const response = await safeFetch(url);
|
|
if (!response.ok) return '';
|
|
const json: ApiResponse<{ configKey: string; configValue: string }> = await response.json();
|
|
return json.success && json.data ? (json.data.configValue || '') : '';
|
|
} catch {
|
|
logger.warn(`获取系统配置 ${key} 失败,降级为空`);
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 获取 AI 推荐问题列表(suggest-message-list)
|
|
* 在 AI 回复结束后调用,从后端缓存中读取本次对话生成的建议问题
|
|
* @param chatId 当前对话 ID
|
|
* @returns 推荐问题数组(最多 3 条),接口失败或功能关闭时返回空数组
|
|
*/
|
|
export async function fetchSuggestions(chatId: string): Promise<string[]> {
|
|
if (!currentConfig || !chatId) return [];
|
|
try {
|
|
const params = new URLSearchParams();
|
|
params.set('chatId', chatId);
|
|
const url = buildUrl(`/ai/suggestions?${params.toString()}`);
|
|
const response = await safeFetch(url);
|
|
if (!response.ok) return [];
|
|
const json: ApiResponse<{ suggestions: string[] }> = await response.json();
|
|
if (json.success && json.data && Array.isArray(json.data.suggestions)) {
|
|
return json.data.suggestions.filter(s => typeof s === 'string' && s.trim().length > 0);
|
|
}
|
|
return [];
|
|
} catch {
|
|
// 静默降级,不阻塞对话
|
|
return [];
|
|
}
|
|
}
|