import type { Message, MessageContent } from '../types'; import { mergeMessageContent } from '../../message-stream.mjs'; import { mergeConversationSnapshot as mergeConversationSnapshotCore } from '../../chat-finish-sync.mjs'; import { deriveUserFacingText, deriveAssistantFacingText } from '../../conversation-display.mjs'; import { stripUserAddressPrefix } from './userAddress'; const IMAGE_URL_LINE_RE = /^\[图片\d+]: (.+)$/; const IMAGE_URL_LINES_RE = /\n*\[图片\d+]: [^\n]+/g; const USER_IDENTITY_BLOCK_RE = /^\[用户身份\][\s\S]*?(?:\n{2,}|$)/; const TKMIND_VISION_NOTE_RE = /\n*【TKMind 图片分析结果[\s\S]*$/; function normalizeImageUrl(url: string): string { const value = String(url ?? '').trim(); if (!value) return ''; if (!/\/api\/mindspace\/v1\/assets\/[^/]+\/download\b/.test(value)) return value; try { const parsed = value.startsWith('http://') || value.startsWith('https://') ? new URL(value) : new URL(value, window.location.origin); if (parsed.searchParams.get('inline') === '1' && !parsed.searchParams.has('viewer')) { parsed.searchParams.set('viewer', '0'); } return parsed.origin === window.location.origin ? `${parsed.pathname}${parsed.search}${parsed.hash}` : parsed.toString(); } catch { const separator = value.includes('?') ? '&' : '?'; return /(?:\?|&)viewer=/.test(value) ? value : `${value}${separator}viewer=0`; } } function formatImageUrlsForAgentText(urls: string[]): string { return urls .filter((url) => typeof url === 'string' && url.trim()) .map((url, index) => `[图片${index + 1}]: ${normalizeImageUrl(url)}`) .join('\n'); } function stripImageUrlLines(text: string): string { return String(text ?? '') .replace(USER_IDENTITY_BLOCK_RE, '') .replace(TKMIND_VISION_NOTE_RE, '') .replace(IMAGE_URL_LINES_RE, '') .trim(); } function parseImageUrlsFromText(text: string): string[] { const urls: string[] = []; for (const line of text.split('\n')) { const match = line.trim().match(IMAGE_URL_LINE_RE); if (match?.[1]) urls.push(match[1]); } return urls; } function readLegacyImageUrls(content: MessageContent[]): string[] { const urls: string[] = []; for (const item of content) { if (item.type === 'text') { urls.push(...parseImageUrlsFromText(item.text)); continue; } const legacy = item as MessageContent & { type?: string; image_url?: { url?: string }; data?: string; mimeType?: string; }; if (legacy.type === 'image_url' && legacy.image_url?.url) { urls.push(legacy.image_url.url); } if (legacy.type === 'image' && legacy.data && legacy.mimeType) { urls.push(`data:${legacy.mimeType};base64,${legacy.data}`); } } return urls; } /** Keep only text blocks so upstream relay providers accept the payload. */ export function normalizeUserMessageForApi(message: Message): Message { if (message.role !== 'user') return message; const imageUrls = getImageUrls(message); const displayText = message.metadata.displayText ?? stripImageUrlLines( message.content .filter((item): item is Extract => item.type === 'text') .map((item) => item.text) .join('\n'), ); const textOnly = message.content .filter((item): item is Extract => item.type === 'text') .map((item) => item.text) .join('\n') .trim(); if (imageUrls.length === 0 && message.content.every((item) => item.type === 'text')) { return message; } const imageText = formatImageUrlsForAgentText(imageUrls); const agentText = [stripImageUrlLines(textOnly), imageText] .filter(Boolean) .join('\n\n'); return { ...message, content: agentText ? [{ type: 'text', text: agentText }] : [], metadata: { ...message.metadata, displayText, ...(imageUrls.length ? { imageUrls } : {}), }, }; } export function buildUserMessage( text: string, options?: { agentText?: string; displayText?: string; imageUrls?: string[]; previewImageUrls?: string[] }, ): Message { const displayText = stripImageUrlLines(options?.displayText ?? text); const imageUrls = (options?.imageUrls ?? []).filter((value) => typeof value === 'string' && value.trim()); const previewImageUrls = (options?.previewImageUrls ?? []).filter( (value) => typeof value === 'string' && value.trim(), ); const baseAgentText = options?.agentText ?? text; const imageText = formatImageUrlsForAgentText(imageUrls); const agentText = [baseAgentText.trim(), imageText].filter(Boolean).join('\n\n'); return { id: crypto.randomUUID(), role: 'user', created: Math.floor(Date.now() / 1000), content: agentText ? [{ type: 'text', text: agentText }] : [], metadata: { userVisible: true, agentVisible: true, displayText, ...(imageUrls.length ? { imageUrls } : {}), ...(previewImageUrls.length ? { previewImageUrls } : {}), }, }; } export function normalizeConversationMessages(messages: Message[]): Message[] { return messages.map((message) => message.role === 'user' ? normalizeUserMessageForApi(message) : message, ); } /** * Merge an authoritative server conversation snapshot into the currently * displayed messages without ever erasing what the user can already see. * * Used by UpdateConversation SSE and Finish sync. Implementation lives in * chat-finish-sync.mjs (shared with regression tests — do not inline elsewhere). * * `UpdateConversation` snapshots arrive on every SSE (re)connect — which on * mobile happens constantly via the page visibility cycle. A mid-turn snapshot * can be shorter than the live view (e.g. assistant messages not yet flagged * userVisible), so a blind replace would blank an active conversation even * though the backend session is alive and persisted. Finish sync also merges * because Goose may persist assistant turns after the Finish event. */ export function mergeConversationSnapshot(current: Message[], incoming: Message[]): Message[] { return mergeConversationSnapshotCore(current, incoming) as Message[]; } export function getDisplayText(message: Message): string { // REGRESSION GUARD: never show agent-only routing/skill prefixes in the chat UI. if (message.role === 'user') { if ('displayText' in message.metadata && message.metadata.displayText != null) { return stripImageUrlLines(message.metadata.displayText); } const raw = message.content .filter((c): c is Extract => c.type === 'text') .map((c) => c.text) .join('\n'); return stripImageUrlLines(deriveUserFacingText(raw)); } if ('displayText' in message.metadata) { return stripImageUrlLines(deriveAssistantFacingText(message.metadata.displayText ?? '')); } const systemText = getSystemNotificationText(message); if (systemText) return systemText; const visible = getVisibleText(message); return stripImageUrlLines(deriveAssistantFacingText(visible)); } export function shouldShowChatMessage(message: Message): boolean { if (getDisplayText(message).trim()) return true; if (message.role === 'user' && getImageUrls(message).length > 0) return true; if (getThinking(message)) return true; if (getSystemNotificationText(message)) return true; if (message.role !== 'assistant') return false; return message.content.some( (item) => item.type === 'toolRequest' || item.type === 'toolResponse' || item.type === 'actionRequired', ); } export function pushMessage(messages: Message[], incoming: Message): Message[] { const last = messages[messages.length - 1]; if (last?.id && incoming.id && last.id === incoming.id) { return [ ...messages.slice(0, -1), { ...last, content: mergeMessageContent(last.content, incoming.content) as MessageContent[], }, ]; } return [...messages, incoming]; } export function getVisibleText(message: Message): string { const text = message.content .filter((c): c is Extract => c.type === 'text') .map((c) => c.text) .join(''); return stripImageUrlLines(text); } export function getImageUrls(message: Message): string[] { const fromMetadata = message.metadata.imageUrls?.filter( (url) => typeof url === 'string' && url.trim(), ); if (fromMetadata?.length) return fromMetadata.map(normalizeImageUrl); const fromContent = readLegacyImageUrls(message.content); if (fromContent.length) return fromContent.map(normalizeImageUrl); const text = message.content .filter((item): item is Extract => item.type === 'text') .map((item) => item.text) .join('\n'); return parseImageUrlsFromText(text).map(normalizeImageUrl); } export function getRenderableImageUrls(message: Message): Array<{ src: string; href: string }> { const imageUrls = getImageUrls(message); const previewImageUrls = message.metadata.previewImageUrls?.filter( (url) => typeof url === 'string' && url.trim(), ) ?? []; return imageUrls.map((href, index) => ({ href, src: previewImageUrls[index] || href, })); } export function getSystemNotificationText(message: Message): string | null { const item = message.content.find( (c): c is Extract => c.type === 'systemNotification', ); return item?.msg ?? null; } export function isCreditsExhaustedNotification(message: Message): boolean { return message.content.some( (c) => c.type === 'systemNotification' && c.notificationType === 'creditsExhausted', ); } /** Goose surfaces upstream relay 500 as a visible assistant error message. */ export function isRelayServerErrorMessage(message: Message): boolean { if (message.role !== 'assistant') return false; const text = getVisibleText(message); if (!text) return false; return ( /Server error \(500 Internal Server Error\)/i.test(text) && /relay|andu\.tkmind\.cn/i.test(text) ); } export function getThinking(message: Message): string | null { const parts = message.content .filter((c): c is Extract => c.type === 'thinking') .map((c) => c.thinking); return parts.length > 0 ? parts.join('') : null; } export function getToolConfirmation(message: Message) { const item = message.content.find( (c): c is Extract => c.type === 'actionRequired' && c.data.actionType === 'toolConfirmation', ); if (!item) return null; return { id: item.data.id, toolName: item.data.toolName, arguments: item.data.arguments, prompt: item.data.prompt, }; }