Improve WeChat MP replies and ship MindSpace/H5 production updates.
Add WeChat service account routing with sync acks, connectivity tests, and context isolation; document deploy runbooks; and bundle related MindSpace, voice, Plaza, and server gateway changes for production rollout. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
export const CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES = 8 * 1024 * 1024;
|
||||
export const CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES = 1.5 * 1024 * 1024;
|
||||
export const CHAT_IMAGE_MAX_SIDE = 1920;
|
||||
const MAX_PIXELS = 2_500_000;
|
||||
const ACCEPTED_IMAGE_MIME = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||
|
||||
type CompressOptions = {
|
||||
maxInputBytes?: number;
|
||||
maxOutputBytes?: number;
|
||||
maxDimension?: number;
|
||||
};
|
||||
|
||||
function loadImageFromUrl(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('图片读取失败'));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
function computeOutputMime(inputType: string) {
|
||||
if (ACCEPTED_IMAGE_MIME.has(inputType)) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
return 'image/jpeg';
|
||||
}
|
||||
|
||||
function canvasToFile(canvas: HTMLCanvasElement, mime: string, quality: number, fileName: string): Promise<File> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('图片压缩失败'));
|
||||
return;
|
||||
}
|
||||
resolve(new File([blob], fileName, { type: mime }));
|
||||
},
|
||||
mime,
|
||||
quality,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function fitDimensions(width: number, height: number, maxSide: number) {
|
||||
if (width <= maxSide && height <= maxSide && width * height <= MAX_PIXELS) {
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
const scale = Math.min(maxSide / width, maxSide / height, Math.sqrt(MAX_PIXELS / (width * height)));
|
||||
return {
|
||||
width: Math.max(1, Math.round(width * scale)),
|
||||
height: Math.max(1, Math.round(height * scale)),
|
||||
};
|
||||
}
|
||||
|
||||
function drawImageToCanvas(img: HTMLImageElement, width: number, height: number) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('图片处理环境不可用');
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
async function encodeCanvasWithinLimit(
|
||||
canvas: HTMLCanvasElement,
|
||||
outputType: string,
|
||||
targetName: string,
|
||||
maxOutputBytes: number,
|
||||
) {
|
||||
let quality = 0.86;
|
||||
let candidate = await canvasToFile(canvas, outputType, quality, targetName);
|
||||
|
||||
while (candidate.size > maxOutputBytes && quality > 0.5) {
|
||||
quality = Math.max(0.5, quality - 0.08);
|
||||
candidate = await canvasToFile(canvas, outputType, quality, targetName);
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export async function compressImageForUpload(
|
||||
file: File,
|
||||
options: CompressOptions = {},
|
||||
): Promise<File> {
|
||||
const maxInputBytes = options.maxInputBytes ?? CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES;
|
||||
const maxOutputBytes = options.maxOutputBytes ?? CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES;
|
||||
const maxDimension = options.maxDimension ?? CHAT_IMAGE_MAX_SIDE;
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new Error('只支持图片文件');
|
||||
}
|
||||
|
||||
if (file.size > maxInputBytes) {
|
||||
throw new Error(`单张图片大小不能超过 ${(maxInputBytes / (1024 * 1024)).toFixed(0)}MB`);
|
||||
}
|
||||
|
||||
if (file.type === 'image/gif') {
|
||||
if (file.size > maxOutputBytes) {
|
||||
throw new Error(`GIF 当前不支持自动压缩,请使用 JPEG/PNG/WEBP,且单张不超过 ${(maxOutputBytes / (1024 * 1024)).toFixed(0)}MB`);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
try {
|
||||
const img = await loadImageFromUrl(objectUrl);
|
||||
let { width, height } = fitDimensions(img.width, img.height, maxDimension);
|
||||
const needsResize = width !== img.width || height !== img.height;
|
||||
|
||||
if (!needsResize && file.size <= maxOutputBytes) {
|
||||
return file;
|
||||
}
|
||||
|
||||
const outputType = computeOutputMime(file.type);
|
||||
const targetName = `${file.name.replace(/\.[^.]+$/, '') || 'image'}.jpg`;
|
||||
|
||||
let canvas = drawImageToCanvas(img, width, height);
|
||||
let candidate = await encodeCanvasWithinLimit(canvas, outputType, targetName, maxOutputBytes);
|
||||
|
||||
while (candidate.size > maxOutputBytes && Math.max(width, height) > 640) {
|
||||
width = Math.max(1, Math.round(width * 0.85));
|
||||
height = Math.max(1, Math.round(height * 0.85));
|
||||
canvas = drawImageToCanvas(img, width, height);
|
||||
candidate = await encodeCanvasWithinLimit(canvas, outputType, targetName, maxOutputBytes);
|
||||
}
|
||||
|
||||
if (candidate.size > maxOutputBytes) {
|
||||
throw new Error(`图片压缩后仍过大,请上传更小尺寸图片,目标不超过 ${(maxOutputBytes / (1024 * 1024)).toFixed(0)}MB`);
|
||||
}
|
||||
|
||||
return candidate;
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
+115
-6
@@ -1,25 +1,117 @@
|
||||
import type { Message, MessageContent } from '../types';
|
||||
import { mergeMessageContent } from '../../message-stream.mjs';
|
||||
|
||||
export function createUserMessage(
|
||||
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 ? { displayText } : {}),
|
||||
...(imageUrls.length ? { imageUrls } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUserMessage(
|
||||
text: string,
|
||||
options?: { agentText?: string; displayText?: string },
|
||||
options?: { agentText?: string; displayText?: string; imageUrls?: string[] },
|
||||
): Message {
|
||||
const displayText = options?.displayText ?? text;
|
||||
const agentText = options?.agentText ?? 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: [{ type: 'text', text: agentText }],
|
||||
content: agentText ? [{ type: 'text', text: agentText }] : [],
|
||||
metadata: {
|
||||
userVisible: true,
|
||||
agentVisible: true,
|
||||
...(displayText !== agentText ? { displayText } : {}),
|
||||
...(displayText ? { displayText } : {}),
|
||||
...(imageUrls.length ? { imageUrls } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeConversationMessages(messages: Message[]): Message[] {
|
||||
return messages.map((message) =>
|
||||
message.role === 'user' ? normalizeUserMessageForApi(message) : message,
|
||||
);
|
||||
}
|
||||
|
||||
export function getDisplayText(message: Message): string {
|
||||
return (
|
||||
message.metadata.displayText ??
|
||||
@@ -43,10 +135,27 @@ export function pushMessage(messages: Message[], incoming: Message): Message[] {
|
||||
}
|
||||
|
||||
export function getVisibleText(message: Message): string {
|
||||
return message.content
|
||||
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 {
|
||||
|
||||
@@ -60,6 +60,10 @@ export function buildAssetImageUrl(asset: Pick<MindSpaceAsset, 'id' | 'updatedAt
|
||||
return `/api/mindspace/v1/assets/${asset.id}/download?inline=1&v=${asset.updatedAt}`;
|
||||
}
|
||||
|
||||
export function buildAbsoluteAssetImageUrl(asset: Pick<MindSpaceAsset, 'id' | 'updatedAt'>): string {
|
||||
return `${window.location.origin}${buildAssetImageUrl(asset)}`;
|
||||
}
|
||||
|
||||
/** Chrome blocks PDF rendering inside any sandboxed iframe. */
|
||||
export function assetPreviewUsesSandbox(mimeType: string) {
|
||||
return mimeType !== 'application/pdf';
|
||||
|
||||
@@ -13,7 +13,7 @@ export function resolvePlazaPostUrl(postId: string): string {
|
||||
return `${configured.replace(/\/$/, '')}/plaza/p/${encodeURIComponent(postId)}`;
|
||||
}
|
||||
if (import.meta.env.DEV) {
|
||||
return `https://plaza.tkmind.cn/plaza/p/${encodeURIComponent(postId)}`;
|
||||
return `http://127.0.0.1:3001/plaza/p/${encodeURIComponent(postId)}`;
|
||||
}
|
||||
return `${window.location.origin}/plaza/p/${encodeURIComponent(postId)}`;
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export function resolvePlazaHomeUrl(): string {
|
||||
return `${configured.replace(/\/$/, '')}/plaza`;
|
||||
}
|
||||
if (import.meta.env.DEV) {
|
||||
return 'https://plaza.tkmind.cn/plaza';
|
||||
return 'http://127.0.0.1:3001/plaza';
|
||||
}
|
||||
return `${window.location.origin}/plaza`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { resolveMindSpaceHomeUrl } from './publicUrl';
|
||||
import type { MindSpacePage } from '../types';
|
||||
|
||||
export type SharePayload = {
|
||||
title: string;
|
||||
@@ -55,6 +56,17 @@ export const SHARE_CHANNELS: ShareChannel[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export function buildPageSharePayload(page: MindSpacePage, pagePublicUrl?: string): SharePayload {
|
||||
const publicUrl = pagePublicUrl
|
||||
? new URL(pagePublicUrl, window.location.origin).toString()
|
||||
: window.location.href;
|
||||
return {
|
||||
title: page.title || '我的 MindSpace 页面',
|
||||
url: publicUrl,
|
||||
description: page.summary || `查看我在 TKMind 的页面:${page.title || page.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function copyShareText(text: string) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
|
||||
Reference in New Issue
Block a user