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.
641 lines
20 KiB
641 lines
20 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>);
|
|
}
|
|
}
|
|
if (currentConfig?.token && url.includes('/ai/')) {
|
|
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/')) {
|
|
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 = '';
|
|
|
|
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(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'
|
|
): 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;
|
|
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 不可用则忽略
|
|
}
|
|
}
|