298 lines
10 KiB
TypeScript
298 lines
10 KiB
TypeScript
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<MessageContent, { type: 'text' }> => item.type === 'text')
|
|
.map((item) => item.text)
|
|
.join('\n'),
|
|
);
|
|
|
|
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 = [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<MessageContent, { type: 'text' }> => 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<MessageContent, { type: 'text' }> => 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<MessageContent, { type: 'text' }> => 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<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,
|
|
};
|
|
}
|