/** * 移动端聊天服务层 * 基于 @icos-react/largemodel 的 apiService + messageService 适配 * 纯浏览器 API,无第三方依赖 */ import type { ApiConfig, ChatMessage, EventAction, ParaSource, SuggestedQuestion } from '../types'; // ─── Token ─────────────────────────────────────────────────────────────────── /** 解析 Taro / mobile-app 写入的 access_token 信封(含 H5 的 data 包装层) */ function parseTokenEnvelope(raw: string): Record | null { try { const parsed: unknown = JSON.parse(raw); if (typeof parsed !== 'object' || parsed === null) return null; const envelope = parsed as Record; // Taro H5 localStorage:{ "data": "{\"c\":...,\"v\":\"Bearer ...\"}" } if (typeof envelope.data === 'string') { try { const inner: unknown = JSON.parse(envelope.data); if (typeof inner === 'object' && inner !== null) { return inner as Record; } } catch { return null; } } return envelope; } catch { return null; } } /** 从信封字段 v 取出 Authorization 值(与 mobile-app request.ts 一致) */ function extractTokenFromEnvelopeValue(v: unknown): string | null { if (typeof v !== 'string') return null; const trimmed = v.trim(); if (!trimmed) return null; // 兼容 v 被二次 JSON 编码:"{\"v\":\"\\\"Bearer xxx\\\"\"}" if (trimmed.startsWith('"')) { try { const decoded = JSON.parse(trimmed); if (typeof decoded === 'string' && decoded.trim()) { return decoded.trim(); } } catch { // 非 JSON 字符串,按原值使用 } } return trimmed; } export function getAuthToken(): string | null { try { const raw = localStorage.getItem('access_token') || localStorage.getItem('token') || sessionStorage.getItem('access_token') || sessionStorage.getItem('token'); if (!raw) return null; const trimmed = raw.trim(); const envelope = parseTokenEnvelope(trimmed); if (envelope && envelope.v != null) { return extractTokenFromEnvelopeValue(envelope.v); } // 已是纯 token 字符串(Bearer xxx 或裸 JWT) if (trimmed && !trimmed.startsWith('{')) { return trimmed; } return null; } catch { return null; } } // ─── 请求 ──────────────────────────────────────────────────────────────────── export async function sendChatRequest( config: ApiConfig, message: string, sessionId?: string, signal?: AbortSignal, ): Promise { const token = getAuthToken(); const headers: Record = { 'Content-Type': 'application/json', ...(config.headers ?? {}), }; if (token) { headers['Authorization'] = token.startsWith('Bearer ') ? token : `Bearer ${token}`; } const body = { ...(sessionId ? { bizConversationId: sessionId } : {}), conversation_id: '', data: { inputs: { user: message, }, }, ...(config.body ?? {}), }; return fetch(config.url, { method: config.method ?? 'POST', headers, body: JSON.stringify(body), signal, }); } export async function fetchSessionId( url = '/mobile/api/cityos/ai/v1/conversation/genBizConversationId', ): Promise { const token = getAuthToken(); const headers: Record = { 'Content-Type': 'application/json' }; if (token) headers['Authorization'] = token.startsWith('Bearer ') ? token : `Bearer ${token}`; const res = await fetch(url, { method: 'GET', headers }); if (!res.ok) throw new Error(`获取会话 ID 失败: ${res.status}`); const data = await res.json(); const id = data?.data ?? data?.bizConversationId ?? data?.sessionId; if (!id) throw new Error('服务端返回的会话 ID 为空'); return id as string; } // ─── SSE 流处理 ─────────────────────────────────────────────────────────────── export interface StreamCallbacks { onChunk: (text: string) => void; onDone: (fullText: string) => void; onError: (err: Error) => void; onSuggestedQuestions?: (questions: SuggestedQuestion[]) => void; onAction?: (actions: EventAction[]) => void; onPara?: (para: ParaSource[]) => void; } /** * 解析单行 SSE data 字段,返回增量文本和可选的推荐问题 */ function parseSseLine(line: string): { text?: string; done?: boolean; suggestedQuestions?: SuggestedQuestion[]; actions?: EventAction[]; para?: ParaSource[]; } { if (!line.startsWith('data:')) return {}; const raw = line.slice(5).trim(); if (raw === '[DONE]') return { done: true }; try { const json = JSON.parse(raw); const isEnd = json?.status === 'END'; const text: string = json?.data?.outputs?.content ?? json?.answer ?? json?.content ?? json?.text ?? json?.choices?.[0]?.delta?.content ?? ''; const suggestedQuestions: SuggestedQuestion[] | undefined = json?.suggestedQuestions?.map((q: string, i: number) => ({ id: `sq-${Date.now()}-${i}`, text: q, })); const rawActions: any[] | undefined = json?.data?.outputs?.action ?? json?.data?.actions; const actions: EventAction[] | undefined = Array.isArray(rawActions) ? rawActions.map((a: any, i: number) => ({ ...a, anchorId: a.anchorId ?? `action-${Date.now()}-${i}`, })) : undefined; const rawPara: any[] | undefined = json?.data?.para; const para: ParaSource[] | undefined = Array.isArray(rawPara) && rawPara.length > 0 ? rawPara.filter((p: any) => p?.title) : undefined; return { text, suggestedQuestions, actions, para, ...(isEnd ? { done: true } : {}) }; } catch { return { text: raw }; } } /** * 消费 SSE 流,通过回调逐步更新 UI */ export async function consumeStream( response: Response, callbacks: StreamCallbacks, ): Promise { if (!response.body) { callbacks.onError(new Error('Response body is null')); return; } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let fullText = ''; 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 { text, done: isDone, suggestedQuestions, actions, para } = parseSseLine(line.trim()); if (text) { fullText += text; callbacks.onChunk(fullText); } if (suggestedQuestions?.length && callbacks.onSuggestedQuestions) { callbacks.onSuggestedQuestions(suggestedQuestions); } if (actions?.length) { for (const a of actions) { if (a.anchorId) { fullText += `\n[ANCHOR:${a.anchorId}]`; } } callbacks.onAction?.(actions); } if (para?.length && callbacks.onPara) { callbacks.onPara(para); } if (isDone) { callbacks.onDone(fullText); return; } } } } catch (err) { callbacks.onError(err instanceof Error ? err : new Error(String(err))); return; } finally { reader.releaseLock(); } callbacks.onDone(fullText); } // ─── 消息工厂 ───────────────────────────────────────────────────────────────── export function createUserMessage(content: string): ChatMessage { return { id: `user-${Date.now()}`, role: 'user', content, timestamp: Date.now(), status: 'success', }; } export function createAssistantMessage(partial = ''): ChatMessage { return { id: `ai-${Date.now()}`, role: 'assistant', content: partial, timestamp: Date.now(), status: 'loading', }; }