Files
memind/src/utils/message.ts
T

229 lines
7.8 KiB
TypeScript

import type { Message, MessageContent } from '../types';
import { mergeMessageContent } from '../../message-stream.mjs';
import { stripUserAddressPrefix } from './userAddress';
const IMAGE_URL_LINE_RE = /^\[图片\d+]: (.+)$/;
function formatImageUrlsForAgentText(urls: string[]): string {
return urls
.filter((url) => typeof url === 'string' && url.trim())
.map((url, index) => `[图片${index + 1}]: ${url.trim()}`)
.join('\n');
}
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 ??
message.content
.filter((item): item is Extract<MessageContent, { type: 'text' }> => item.type === 'text')
.map((item) => item.text)
.join('\n')
.replace(/\n*\[图片\d+]: [^\n]+/g, '')
.trim();
const textOnly = message.content
.filter((item): item is Extract<MessageContent, { type: 'text' }> => 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 = [textOnly.replace(/\n*\[图片\d+]: [^\n]+/g, '').trim(), 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[] },
): Message {
const displayText = options?.displayText ?? text;
const imageUrls = (options?.imageUrls ?? []).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 } : {}),
},
};
}
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.
*
* `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. We therefore treat the
* snapshot as authoritative for content/order but keep any locally-displayed
* messages the snapshot hasn't caught up to yet (optimistic / in-flight tail).
* The authoritative full reload on `Finish` corrects any drift afterwards.
*/
export function mergeConversationSnapshot(current: Message[], incoming: Message[]): Message[] {
if (incoming.length === 0) return current;
const incomingIds = new Set(incoming.map((message) => message.id).filter(Boolean));
const localOnly = current.filter((message) => message.id && !incomingIds.has(message.id));
return localOnly.length ? [...incoming, ...localOnly] : incoming;
}
export function getDisplayText(message: Message): string {
if ('displayText' in message.metadata) {
return message.metadata.displayText ?? '';
}
const systemText = getSystemNotificationText(message);
if (systemText) return systemText;
const visible = getVisibleText(message);
return message.role === 'user' ? stripUserAddressPrefix(visible) : visible;
}
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<MessageContent, { type: 'text' }> => c.type === 'text')
.map((c) => c.text)
.join('');
return text.replace(/\n*\[图片\d+]: [^\n]+/g, '').trim();
}
export function getImageUrls(message: Message): string[] {
const fromMetadata = message.metadata.imageUrls?.filter(
(url) => typeof url === 'string' && url.trim(),
);
if (fromMetadata?.length) return fromMetadata;
const fromContent = readLegacyImageUrls(message.content);
if (fromContent.length) return fromContent;
const text = message.content
.filter((item): item is Extract<MessageContent, { type: 'text' }> => item.type === 'text')
.map((item) => item.text)
.join('\n');
return parseImageUrlsFromText(text);
}
export function getSystemNotificationText(message: Message): string | null {
const item = message.content.find(
(c): c is Extract<MessageContent, { type: 'systemNotification' }> =>
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<MessageContent, { type: 'thinking' }> => 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<MessageContent, { type: 'actionRequired' }> =>
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,
};
}