import webConfig from "@/constants/common-env"; export type ChatRole = "user" | "assistant"; export type ChatAttachment = { id: string; name: string; type: string; size: number; dataUrl: string; }; const CHAT_RASTER_IMAGE_MIME_TYPES = new Set([ "image/png", "image/jpeg", "image/webp", "image/gif", ]); export function isRasterImageMimeType(value: string) { return CHAT_RASTER_IMAGE_MIME_TYPES.has(String(value || "").split(";", 1)[0].trim().toLowerCase()); } export function isImageAttachment(attachment: ChatAttachment) { const dataUrlMime = attachment.dataUrl.match(/^data:([^;,]+)/i)?.[1] || ""; return isRasterImageMimeType(attachment.type) || isRasterImageMimeType(dataUrlMime); } export function resolveChatArtifactUrl(value: string) { const url = String(value || "").trim(); if (!url.startsWith("/files/")) return url; const apiBase = webConfig.apiUrl.replace(/\/$/, ""); return apiBase ? `${apiBase}${url}` : url; } export type ChatCitation = { title: string; url: string; }; export type ChatToolPart = { type: "tool"; toolId: string; invocationId: string; status: "pending" | "running" | "success" | "error"; title: string; input?: unknown; output?: unknown; error?: string; }; export type ChatGeneratedImage = { id: string; src: string; prompt: string; revisedPrompt?: string; size?: string; model?: string; origin?: "generated" | "search"; sourceUrl?: string; }; export type ChatPlace = { id: string; title: string; address: string; }; export type ChatPlacePart = { type: "place"; places: ChatPlace[]; }; export type ChatDilCardRow = { title: string; badge?: string; badgeTone?: "success" | "warning" | "info"; subtitle?: string; description?: string; }; export type ChatInlineSegment = | { type: "text"; content: string } | { type: "place"; places: ChatPlace[] } | { type: "image_carousel"; queries: string[]; aspectRatio: string } | { type: "question_card"; questions: { question: string; options: string[] }[] } | { type: "card_list"; rows: ChatDilCardRow[] }; export type ChatRichTextPart = { type: "rich_text"; segments: ChatInlineSegment[]; }; function jsonValueAt(text: string, start: number): { value: unknown; end: number } | null { const opening = text[start]; const closing = opening === "[" ? "]" : opening === "{" ? "}" : ""; if (!closing) return null; let depth = 0; let quote = ""; let escaped = false; for (let index = start; index < text.length; index += 1) { const character = text[index]; if (quote) { if (escaped) escaped = false; else if (character === "\\") escaped = true; else if (character === quote) quote = ""; continue; } if (character === '"' || character === "'") { quote = character; continue; } if (character === opening) depth += 1; if (character === closing) { depth -= 1; if (depth === 0) { try { return { value: JSON.parse(text.slice(start, index + 1)), end: index + 1 }; } catch { return null; } } } } return null; } /** * ChatGPT 网页端新上的 DIL 声明式卡片标记(///// * + {@body} 状态 + GenUI.issueNewTurn)。这套协议只有官方前端认识,直接渲染 * 会漏出原始标签。这里做一层降级转换: * - {@body} 状态声明、MapWidgetV2/Cite/AsyncImage 等无法本地复刻的组件 → 剥掉; * - badge/Entity/text/divider → 等价 markdown; * - radio-group 问卷(含前面的 `N. 问题` 标题)→ 抽成 * question_card,复用站内可点选项卡片。 */ function convertDilMarkup(raw: string): { text: string; questions: { question: string; options: string[] }[]; cards: ChatDilCardRow[]; } { const text = String(raw || ""); if (!/<{@body|]/.test(text)) { return { text, questions: [], cards: [] }; } const questions: { question: string; options: string[] }[] = []; const cards: ChatDilCardRow[] = []; const cardMarker = (index: number) => `\u0000DILCARD${index}\u0000`; // 0) 抽卡片列表:``(推荐条目,行间以 divider 分隔)。行内含标题(bold+Entity)、 // badge、灰色副标题(text color=secondary)、描述正文。转成结构化 rows,原位置留标记。 const withRows = text.replace( /]*>([\s\S]*?)<\/row>/g, (rowMatch: string, rowBody: string) => { const titleMatch = rowBody.match(/\*\*([\s\S]*?)\*\*/); const title = titleMatch ? titleMatch[1] .replace(/]*?value="([^"]*)"[^>]*\/>/g, "$1") .replace(/<[^>]+>/g, "") .trim() : ""; if (!title) return rowMatch; const badgeMatch = rowBody.match(/]*color="([^"]*)"[^>]*>([\s\S]*?)<\/badge>/); const subtitleMatch = rowBody.match(/]*color="secondary"[^>]*>([\s\S]*?)<\/text>/); const description = rowBody .replace(/\*\*[\s\S]*?\*\*/g, "") .replace(/]*\/>/g, "") .replace(/]*\/>/g, "") .replace(//g, "") .replace(/]*?\/>/g, "") .replace(/]*color="secondary"[^>]*>[\s\S]*?<\/text>/g, "") .replace(/<[^>]+>/g, " ") .replace(/[ \t]+/g, " ") .replace(/\s*\n\s*/g, " ") .trim(); const tone = String(badgeMatch?.[1] || ""); cards.push({ title, badge: badgeMatch ? badgeMatch[2].trim() : undefined, badgeTone: tone === "success" ? "success" : tone === "warning" ? "warning" : tone ? "info" : undefined, subtitle: subtitleMatch ? subtitleMatch[1].trim() : undefined, description: description || undefined, }); return cardMarker(cards.length - 1); }, ); // 1) 抽问卷:`N. 问题` 紧跟 `…label…`。 const withQuestions = withRows.replace( /]*weight="medium"[^>]*>\s*([^<]+?)\s*<\/text>\s*]*>([\s\S]*?)<\/radio-group>/g, (_match, questionText: string, groupBody: string) => { const options: string[] = []; const radioPattern = /]*>([\s\S]*?)<\/radio>/g; let radioMatch: RegExpExecArray | null; while ((radioMatch = radioPattern.exec(groupBody))) { const label = radioMatch[1].trim(); if (label) options.push(label); } const question = String(questionText || "").trim(); if (question && options.length) questions.push({ question, options }); return ""; }, ); let working = withQuestions // 行间 divider(marker 夹着 divider)不转成 `---`,卡片组件自带分隔线;须在通用 divider 规则之前。 .replace(/(\u0000DILCARD\d+\u0000)\s*]*\/>/g, "$1") .replace(/]*\/>\s*(?=\u0000DILCARD)/g, "") .replace(/\{@body[^}]*\}\n?/g, "") // 状态声明 .replace(//g, "") // 官方地图组件(无本地等价物) .replace(/<\/?MapWidgetV2[^>]*>/g, "") .replace(/]*\/>/g, "") // 官方引用角标(ref 只在官方会话里有意义) .replace(/]*\/>/g, "") // 官方图片搜索占位(本站无按 query 拉图管道) .replace(/]*>[\s\S]*?<\/button>/g, "") // GenUI.issueNewTurn 按钮(交互已由 question_card 承担) .replace(/]*>([\s\S]*?)<\/badge>/g, "「$1」") .replace(/]*?value="([^"]*)"[^>]*\/>/g, "$1") .replace(/]*>([\s\S]*?)<\/text>/g, "$1") .replace(/]*\/>/g, "\n---\n") .replace(/<\/?(?:box|row|button|radio-group|radio|text|badge|entity|MapWidgetV2)[^>]*>/g, "") .replace(/\n{3,}/g, "\n\n") .trim(); if (!working && (questions.length || cards.length)) working = ""; return { text: working, questions, cards }; } /** Convert ChatGPT web-client place/card directives into portable UI data. */ export function parseInlineRichContent(value: string): { content: string; places: ChatPlace[]; segments: ChatInlineSegment[]; hasWidgets: boolean } { const dil = convertDilMarkup(value); const text = dil.text; const places: ChatPlace[] = []; const seen = new Set(); const segments: ChatInlineSegment[] = []; const cardMarkerPattern = /\u0000DILCARD(\d+)\u0000/g; const addText = (content: string) => { if (!content) return; // DIL 卡片列表在文本里的占位标记 → 拆开成 text/card_list 段(保留在原文中的位置)。 cardMarkerPattern.lastIndex = 0; let last = 0; let markerMatch: RegExpExecArray | null; while ((markerMatch = cardMarkerPattern.exec(content))) { if (markerMatch.index > last) segments.push({ type: "text", content: content.slice(last, markerMatch.index) }); const rows = dil.cards[Number(markerMatch[1])]; if (rows) segments.push({ type: "card_list", rows: [rows] }); last = markerMatch.index + markerMatch[0].length; } if (last < content.length) segments.push({ type: "text", content: content.slice(last) }); }; let cursor = 0; while (cursor < text.length) { const match = /[\[{]/.exec(text.slice(cursor)); if (!match) { addText(text.slice(cursor)); break; } const start = cursor + match.index; addText(text.slice(cursor, start)); const parsed = jsonValueAt(text, start); if (!parsed) { addText(text[start]); cursor = start + 1; continue; } const { value: item, end } = parsed; const isPlace = Array.isArray(item) && String(item[0] || "").toLowerCase() === "point_of_interest"; const isCarousel = !Array.isArray(item) && Boolean(item) && typeof item === "object" && Array.isArray((item as { query?: unknown }).query); if (isPlace) { const title = String(item[1] || "").trim(); const address = String(item[2] || "").trim(); const key = `${title}\u0000${address}`; if (title && !seen.has(key)) { seen.add(key); const place = { id: `inline-place-${places.length + 1}`, title, address }; places.push(place); segments.push({ type: "place", places: [place] }); } cursor = end; continue; } if (isCarousel) { const metadata = item as { query?: unknown; aspect_ratio?: unknown }; const queries = (metadata.query as unknown[]).map((query) => String(query || "").trim()).filter(Boolean); if (queries.length) { segments.push({ type: "image_carousel", queries, aspectRatio: String(metadata.aspect_ratio || "16:9").trim() || "16:9" }); } cursor = end; continue; } // 上游模型偶尔会按 ChatGPT 官方客户端的「小程序块」习惯输出 {"app_block":{title,variant,icon,content}} // (content 是一段可交互 HTML,通常带 /