Initial commit: Memind H5 portal with MindSpace, Plaza, and agent jobs.
Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 轻量级 Markdown 渲染,用于聊天气泡中的文本。
|
||||
* 支持:链接、粗体、斜体、行内代码、代码块、换行。
|
||||
*/
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function renderInline(text: string): string {
|
||||
// 行内代码 `code` — 优先处理,避免干扰其他标记
|
||||
let html = text.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
|
||||
// 图片 
|
||||
html = html.replace(
|
||||
/!\[([^\]]*)]\(([^)]+)\)/g,
|
||||
'<img src="$2" alt="$1" loading="lazy" class="md-img" />',
|
||||
);
|
||||
|
||||
// 链接 [text](url)
|
||||
html = html.replace(
|
||||
/\[([^\]]+)]\(([^)]+)\)/g,
|
||||
(_, label, url) => {
|
||||
const safeUrl = url.startsWith('http://') || url.startsWith('https://') || url.startsWith('mailto:')
|
||||
? url
|
||||
: `http://${url}`;
|
||||
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
||||
},
|
||||
);
|
||||
|
||||
// 粗体 **text** 或 __text__
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
||||
|
||||
// 斜体 *text* 或 _text_
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
|
||||
|
||||
// 删除线 ~~text~~
|
||||
html = html.replace(/~~(.+?)~~/g, '<s>$1</s>');
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
export function renderMarkdown(text: string): string {
|
||||
const lines = text.split('\n');
|
||||
const result: string[] = [];
|
||||
let inCodeBlock = false;
|
||||
let codeBuf: string[] = [];
|
||||
let codeLang = '';
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.startsWith('```')) {
|
||||
if (inCodeBlock) {
|
||||
// 关闭代码块
|
||||
const langClass = codeLang ? ` class="lang-${escapeHtml(codeLang)}"` : '';
|
||||
result.push(`<pre${langClass}><code>${escapeHtml(codeBuf.join('\n'))}</code></pre>`);
|
||||
codeBuf = [];
|
||||
codeLang = '';
|
||||
inCodeBlock = false;
|
||||
} else {
|
||||
inCodeBlock = true;
|
||||
codeLang = line.slice(3).trim();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inCodeBlock) {
|
||||
codeBuf.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 空行
|
||||
if (line.trim() === '') {
|
||||
result.push('<br>');
|
||||
continue;
|
||||
}
|
||||
|
||||
// 标题 # ~ ######
|
||||
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headingMatch) {
|
||||
const level = headingMatch[1].length;
|
||||
const content = renderInline(escapeHtml(headingMatch[2]));
|
||||
result.push(`<h${level} class="md-h${level}">${content}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 无序列表 - 或 *
|
||||
if (/^[\s]*[-*+]\s+(.+)$/.test(line)) {
|
||||
const content = renderInline(escapeHtml(line.replace(/^[\s]*[-*+]\s+/, '')));
|
||||
result.push(`<li class="md-li">${content}</li>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 有序列表 1. 2.
|
||||
const orderedMatch = line.match(/^(\s*)\d+\.\s+(.+)$/);
|
||||
if (orderedMatch) {
|
||||
const content = renderInline(escapeHtml(orderedMatch[2]));
|
||||
result.push(`<li class="md-li">${content}</li>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 普通段落
|
||||
const content = renderInline(escapeHtml(line));
|
||||
result.push(`<p class="md-p">${content}</p>`);
|
||||
}
|
||||
|
||||
// 未闭合的代码块
|
||||
if (inCodeBlock) {
|
||||
result.push(`<pre><code>${escapeHtml(codeBuf.join('\n'))}</code></pre>`);
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Message, MessageContent } from '../types';
|
||||
import { mergeMessageContent } from '../../message-stream.mjs';
|
||||
|
||||
export function createUserMessage(
|
||||
text: string,
|
||||
options?: { agentText?: string; displayText?: string },
|
||||
): Message {
|
||||
const displayText = options?.displayText ?? text;
|
||||
const agentText = options?.agentText ?? text;
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
content: [{ type: 'text', text: agentText }],
|
||||
metadata: {
|
||||
userVisible: true,
|
||||
agentVisible: true,
|
||||
...(displayText !== agentText ? { displayText } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getDisplayText(message: Message): string {
|
||||
return (
|
||||
message.metadata.displayText ??
|
||||
getSystemNotificationText(message) ??
|
||||
getVisibleText(message)
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
return message.content
|
||||
.filter((c): c is Extract<MessageContent, { type: 'text' }> => c.type === 'text')
|
||||
.map((c) => c.text)
|
||||
.join('');
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
const URL_PATTERN =
|
||||
/https?:\/\/[^\s<>"')\]]+\/(?:MindSpace|temp)\/([a-z0-9._-]+)\/([^\s<>"')\]]+\.html)/gi;
|
||||
|
||||
export type MessageSaveKind = 'page' | 'article';
|
||||
|
||||
export type MessageSaveActions = {
|
||||
kind: MessageSaveKind;
|
||||
previewUrl: string | null;
|
||||
links: Array<{ publicUrl: string; filename: string }>;
|
||||
};
|
||||
|
||||
function decodeSegment(segment: string) {
|
||||
try {
|
||||
return decodeURIComponent(segment);
|
||||
} catch {
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractStaticPageLinks(content: string, username?: string) {
|
||||
const links: Array<{ publicUrl: string; filename: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
for (const match of content.matchAll(URL_PATTERN)) {
|
||||
const owner = decodeSegment(match[1]).toLowerCase();
|
||||
const filename = decodeSegment(match[2]).split('/').pop() ?? match[2];
|
||||
if (username && owner !== username.trim().toLowerCase()) continue;
|
||||
if (seen.has(match[0])) continue;
|
||||
seen.add(match[0]);
|
||||
links.push({ publicUrl: match[0], filename });
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
export function getMessageSaveActions(content: string, username?: string): MessageSaveActions {
|
||||
const links = extractStaticPageLinks(content, username);
|
||||
return {
|
||||
kind: links.length > 0 ? 'page' : 'article',
|
||||
previewUrl: links[0]?.publicUrl ?? null,
|
||||
links,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { MindSpaceAsset } from '../types';
|
||||
|
||||
const ASSET_ICONS: Record<string, string> = {
|
||||
pdf: '📄',
|
||||
word: '📝',
|
||||
excel: '📊',
|
||||
ppt: '📽️',
|
||||
image: '🖼️',
|
||||
markdown: 'MD',
|
||||
html: '🌐',
|
||||
file: '📁',
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<MindSpaceAsset['sourceType'], string> = {
|
||||
upload: '上传',
|
||||
chat: '聊天',
|
||||
agent: 'AI 生成',
|
||||
workspace: '工作区',
|
||||
template: '模板',
|
||||
generated: '系统',
|
||||
};
|
||||
|
||||
const PREVIEWABLE_MIME_TYPES = new Set([
|
||||
'text/html',
|
||||
'text/plain',
|
||||
'text/markdown',
|
||||
'text/csv',
|
||||
'application/pdf',
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
]);
|
||||
|
||||
export function assetIcon(asset: MindSpaceAsset) {
|
||||
return ASSET_ICONS[asset.assetType] ?? ASSET_ICONS.file;
|
||||
}
|
||||
|
||||
export function assetSourceLabel(asset: MindSpaceAsset) {
|
||||
return SOURCE_LABELS[asset.sourceType] ?? '资料';
|
||||
}
|
||||
|
||||
export function isPreviewableAsset(asset: MindSpaceAsset) {
|
||||
return (
|
||||
asset.status === 'ready' &&
|
||||
asset.scanStatus !== 'blocked' &&
|
||||
PREVIEWABLE_MIME_TYPES.has(asset.mimeType)
|
||||
);
|
||||
}
|
||||
|
||||
export function isWorkAsset(asset: MindSpaceAsset) {
|
||||
return asset.assetType === 'html' || asset.mimeType === 'text/html';
|
||||
}
|
||||
|
||||
/** Chrome blocks PDF rendering inside any sandboxed iframe. */
|
||||
export function assetPreviewUsesSandbox(mimeType: string) {
|
||||
return mimeType !== 'application/pdf';
|
||||
}
|
||||
|
||||
export const ASSET_PREVIEW_IFRAME_SANDBOX = 'allow-same-origin';
|
||||
|
||||
export function buildAssetPreviewFrameConfig(
|
||||
asset: Pick<MindSpaceAsset, 'id' | 'mimeType'>,
|
||||
) {
|
||||
if (asset.mimeType === 'application/pdf') {
|
||||
return {
|
||||
src: `/api/mindspace/v1/assets/${asset.id}/download?inline=1`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
src: `/api/mindspace/v1/assets/${asset.id}/preview`,
|
||||
sandbox: ASSET_PREVIEW_IFRAME_SANDBOX,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type {
|
||||
MindSpace,
|
||||
MindSpaceAsset,
|
||||
MindSpaceCategory,
|
||||
MindSpaceChatContext,
|
||||
MindSpacePage,
|
||||
} from '../types';
|
||||
|
||||
export type { MindSpaceChatContext } from '../types';
|
||||
|
||||
export {
|
||||
buildContextPrefix,
|
||||
buildMindSpaceChatContext,
|
||||
formatContextChip,
|
||||
} from '../../mindspace-chat-context.mjs';
|
||||
|
||||
export type BuildMindSpaceChatContextInput = {
|
||||
space: MindSpace;
|
||||
ownerUsername?: string;
|
||||
selectedCategory: MindSpaceCategory | null;
|
||||
selectedPageId: string | null;
|
||||
pages: MindSpacePage[];
|
||||
pageLive?: {
|
||||
page: MindSpacePage;
|
||||
title: string;
|
||||
summary: string;
|
||||
content: string;
|
||||
} | null;
|
||||
assets?: MindSpaceAsset[];
|
||||
focusedAsset?: MindSpaceAsset | null;
|
||||
route: string;
|
||||
};
|
||||
@@ -0,0 +1,298 @@
|
||||
export const MINDSPACE_PAGE_CONTENT_MESSAGE = 'mindspace:page-content';
|
||||
|
||||
const EDITOR_STYLE = `<style id="mindspace-visual-editor-style">
|
||||
[data-mindspace-editing="true"] [contenteditable="true"] {
|
||||
outline: 1px dashed rgba(61, 139, 253, 0.45);
|
||||
outline-offset: 2px;
|
||||
cursor: text;
|
||||
}
|
||||
[data-mindspace-editing="true"] [contenteditable="true"]:focus {
|
||||
outline: 2px solid rgba(61, 139, 253, 0.95);
|
||||
background: rgba(61, 139, 253, 0.06);
|
||||
}
|
||||
[data-mindspace-editing="true"] img {
|
||||
cursor: pointer;
|
||||
outline: 1px dashed transparent;
|
||||
outline-offset: 2px;
|
||||
transition: outline-color 0.15s ease;
|
||||
}
|
||||
[data-mindspace-editing="true"] img:hover {
|
||||
outline-color: rgba(238, 176, 78, 0.95);
|
||||
}
|
||||
[data-mindspace-editing="true"] [data-mindspace-bg-target="true"] {
|
||||
cursor: copy;
|
||||
}
|
||||
[data-mindspace-editing="true"] [data-mindspace-bg-target="true"]:hover {
|
||||
box-shadow: inset 0 0 0 2px rgba(238, 176, 78, 0.45);
|
||||
}
|
||||
.mindspace-editor-popover {
|
||||
position: fixed;
|
||||
z-index: 2147483646;
|
||||
width: min(280px, calc(100vw - 24px));
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(24, 33, 29, 0.14);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 252, 244, 0.98);
|
||||
color: #18211d;
|
||||
box-shadow: 0 18px 48px rgba(24, 33, 29, 0.18);
|
||||
font: 12px/1.45 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
.mindspace-editor-popover p {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.mindspace-editor-popover label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.mindspace-editor-popover input[type='url'],
|
||||
.mindspace-editor-popover input[type='text'],
|
||||
.mindspace-editor-popover input[type='color'],
|
||||
.mindspace-editor-popover input[type='file'] {
|
||||
width: 100%;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid rgba(24, 33, 29, 0.14);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
.mindspace-editor-popover-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.mindspace-editor-popover-actions button {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.mindspace-editor-popover-actions button[data-action='apply'] {
|
||||
color: #fffaf0;
|
||||
background: #18211d;
|
||||
}
|
||||
.mindspace-editor-popover-actions button[data-action='cancel'] {
|
||||
color: #52605a;
|
||||
background: rgba(24, 33, 29, 0.08);
|
||||
}
|
||||
</style>`;
|
||||
|
||||
const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
|
||||
(function () {
|
||||
var MESSAGE = ${JSON.stringify(MINDSPACE_PAGE_CONTENT_MESSAGE)};
|
||||
var TEXT_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,figcaption,blockquote,span,a,label,small,strong,em,.sub,.tagline';
|
||||
var popover = null;
|
||||
var emitTimer = null;
|
||||
|
||||
function debounceEmit() {
|
||||
if (emitTimer) window.clearTimeout(emitTimer);
|
||||
emitTimer = window.setTimeout(emitChange, 350);
|
||||
}
|
||||
|
||||
function stripEditorArtifacts(root) {
|
||||
root.querySelectorAll('#mindspace-visual-editor-style,#mindspace-visual-editor-script,.mindspace-editor-popover').forEach(function (node) {
|
||||
node.remove();
|
||||
});
|
||||
root.querySelectorAll('[contenteditable]').forEach(function (node) {
|
||||
node.removeAttribute('contenteditable');
|
||||
});
|
||||
root.querySelectorAll('[data-mindspace-bg-target]').forEach(function (node) {
|
||||
node.removeAttribute('data-mindspace-bg-target');
|
||||
});
|
||||
if (root.body) root.body.removeAttribute('data-mindspace-editing');
|
||||
}
|
||||
|
||||
function serializeDocument() {
|
||||
var clone = document.documentElement.cloneNode(true);
|
||||
stripEditorArtifacts(clone);
|
||||
var doctype = document.doctype;
|
||||
var prefix = doctype
|
||||
? '<!DOCTYPE ' + doctype.name + (doctype.publicId ? ' PUBLIC "' + doctype.publicId + '"' : '') + (doctype.systemId ? ' "' + doctype.systemId + '"' : '') + '>'
|
||||
: '<!DOCTYPE html>';
|
||||
return prefix + '\\n' + clone.outerHTML;
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
parent.postMessage({ type: MESSAGE, html: serializeDocument() }, '*');
|
||||
}
|
||||
|
||||
function closePopover() {
|
||||
if (popover && popover.parentNode) popover.parentNode.removeChild(popover);
|
||||
popover = null;
|
||||
}
|
||||
|
||||
function openPopover(title, fieldsHtml, onApply, anchor) {
|
||||
closePopover();
|
||||
popover = document.createElement('div');
|
||||
popover.className = 'mindspace-editor-popover';
|
||||
popover.innerHTML =
|
||||
'<p>' + title + '</p>' +
|
||||
fieldsHtml +
|
||||
'<div class="mindspace-editor-popover-actions">' +
|
||||
'<button type="button" data-action="cancel">取消</button>' +
|
||||
'<button type="button" data-action="apply">应用</button>' +
|
||||
'</div>';
|
||||
document.body.appendChild(popover);
|
||||
var rect = anchor.getBoundingClientRect();
|
||||
var top = Math.min(rect.bottom + 8, window.innerHeight - popover.offsetHeight - 8);
|
||||
var left = Math.min(rect.left, window.innerWidth - popover.offsetWidth - 8);
|
||||
popover.style.top = Math.max(8, top) + 'px';
|
||||
popover.style.left = Math.max(8, left) + 'px';
|
||||
popover.querySelector('[data-action="cancel"]').addEventListener('click', closePopover);
|
||||
popover.querySelector('[data-action="apply"]').addEventListener('click', function () {
|
||||
var pending = onApply(popover);
|
||||
closePopover();
|
||||
if (pending !== 'async') emitChange();
|
||||
});
|
||||
}
|
||||
|
||||
function readFileAsDataUrl(file, callback) {
|
||||
var reader = new FileReader();
|
||||
reader.onload = function () { callback(String(reader.result || '')); };
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function openImageEditor(img) {
|
||||
openPopover(
|
||||
'替换图片',
|
||||
'<label>图片链接<input type="url" data-role="url" value="' + (img.src.indexOf('data:') === 0 ? '' : img.src.replace(/"/g, '"')) + '" placeholder="https://..." /></label>' +
|
||||
'<label>本地图片<input type="file" accept="image/*" data-role="file" /></label>',
|
||||
function (panel) {
|
||||
var fileInput = panel.querySelector('[data-role="file"]');
|
||||
var urlInput = panel.querySelector('[data-role="url"]');
|
||||
if (fileInput.files && fileInput.files[0]) {
|
||||
readFileAsDataUrl(fileInput.files[0], function (dataUrl) {
|
||||
img.src = dataUrl;
|
||||
emitChange();
|
||||
});
|
||||
return 'async';
|
||||
}
|
||||
var next = String(urlInput.value || '').trim();
|
||||
if (next) img.src = next;
|
||||
},
|
||||
img,
|
||||
);
|
||||
}
|
||||
|
||||
function rgbToHex(color) {
|
||||
var match = String(color || '').match(/rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)/i);
|
||||
if (!match) return '#ffffff';
|
||||
function hex(n) { return Number(n).toString(16).padStart(2, '0'); }
|
||||
return '#' + hex(match[1]) + hex(match[2]) + hex(match[3]);
|
||||
}
|
||||
|
||||
function openBackgroundEditor(el) {
|
||||
var style = window.getComputedStyle(el);
|
||||
openPopover(
|
||||
'替换背景',
|
||||
'<label>背景颜色<input type="color" data-role="color" value="' + rgbToHex(style.backgroundColor) + '" /></label>' +
|
||||
'<label>背景图片链接<input type="url" data-role="bg-url" placeholder="留空则仅使用颜色" /></label>' +
|
||||
'<label>本地背景图<input type="file" accept="image/*" data-role="bg-file" /></label>',
|
||||
function (panel) {
|
||||
var color = panel.querySelector('[data-role="color"]').value;
|
||||
var bgUrl = String(panel.querySelector('[data-role="bg-url"]').value || '').trim();
|
||||
var bgFile = panel.querySelector('[data-role="bg-file"]');
|
||||
function applyBackground(url) {
|
||||
if (url) {
|
||||
el.style.backgroundImage = 'url("' + url.replace(/"/g, '\\"') + '")';
|
||||
el.style.backgroundSize = el.style.backgroundSize || 'cover';
|
||||
el.style.backgroundPosition = el.style.backgroundPosition || 'center';
|
||||
} else {
|
||||
el.style.backgroundImage = 'none';
|
||||
}
|
||||
if (color) el.style.backgroundColor = color;
|
||||
}
|
||||
if (bgFile.files && bgFile.files[0]) {
|
||||
readFileAsDataUrl(bgFile.files[0], function (url) {
|
||||
applyBackground(url);
|
||||
emitChange();
|
||||
});
|
||||
return 'async';
|
||||
}
|
||||
applyBackground(bgUrl);
|
||||
},
|
||||
el,
|
||||
);
|
||||
}
|
||||
|
||||
function markBackgroundTargets() {
|
||||
var nodes = document.querySelectorAll('body, main, section, article, header, footer, div');
|
||||
nodes.forEach(function (el) {
|
||||
if (el.closest('.mindspace-editor-popover')) return;
|
||||
var style = window.getComputedStyle(el);
|
||||
var cls = String(el.className || '');
|
||||
var structural = /\\b(page|hero|cover|banner|bg|background|section|card|panel|wrap|container)\\b/i.test(cls);
|
||||
var painted = style.backgroundImage !== 'none' || (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor !== 'transparent');
|
||||
if (el.tagName === 'BODY' || structural || (painted && el.offsetWidth > 64 && el.offsetHeight > 64)) {
|
||||
el.setAttribute('data-mindspace-bg-target', 'true');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function enableTextEditing() {
|
||||
document.querySelectorAll(TEXT_SELECTOR).forEach(function (el) {
|
||||
if (el.closest('.mindspace-editor-popover')) return;
|
||||
if (el.querySelector && el.querySelector('img,video,iframe,svg,canvas')) return;
|
||||
el.setAttribute('contenteditable', 'true');
|
||||
el.setAttribute('spellcheck', 'true');
|
||||
});
|
||||
}
|
||||
|
||||
function onClick(event) {
|
||||
var target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
if (target.closest('.mindspace-editor-popover')) return;
|
||||
if (target.tagName === 'IMG') {
|
||||
event.preventDefault();
|
||||
openImageEditor(target);
|
||||
return;
|
||||
}
|
||||
if (target.isContentEditable) return;
|
||||
if (target.matches('[data-mindspace-bg-target="true"]') || target.tagName === 'BODY') {
|
||||
event.preventDefault();
|
||||
openBackgroundEditor(target.tagName === 'BODY' ? document.body : target);
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
document.body.setAttribute('data-mindspace-editing', 'true');
|
||||
enableTextEditing();
|
||||
markBackgroundTargets();
|
||||
document.addEventListener('input', debounceEmit);
|
||||
document.addEventListener('blur', debounceEmit, true);
|
||||
document.addEventListener('click', onClick, true);
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Escape') closePopover();
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
</script>`;
|
||||
|
||||
export function buildEditablePreviewDocument(html: string): string {
|
||||
const source = String(html ?? '').trim();
|
||||
const injection = `${EDITOR_STYLE}${EDITOR_SCRIPT}`;
|
||||
|
||||
if (!source) {
|
||||
return buildEditablePreviewDocument(
|
||||
'<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"></head><body><p>空白页面</p></body></html>',
|
||||
);
|
||||
}
|
||||
|
||||
if (/<\/body>/i.test(source)) {
|
||||
return source.replace(/<\/body>/i, `${injection}</body>`);
|
||||
}
|
||||
if (/<\/html>/i.test(source)) {
|
||||
return source.replace(/<\/html>/i, `${injection}</html>`);
|
||||
}
|
||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"></head><body>${source}${injection}</body></html>`;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export function resolvePublicPageUrl(publicUrl: string): string {
|
||||
const value = String(publicUrl ?? '').trim();
|
||||
if (!value) return value;
|
||||
if (/^https?:\/\//i.test(value)) return value;
|
||||
if (value.startsWith('//')) return `${window.location.protocol}${value}`;
|
||||
if (value.startsWith('/')) return `${window.location.origin}${value}`;
|
||||
return `${window.location.origin}/${value.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
export function resolvePlazaPostUrl(postId: string): string {
|
||||
const configured = String(import.meta.env.VITE_PLAZA_BASE ?? '').trim();
|
||||
if (configured) {
|
||||
return `${configured.replace(/\/$/, '')}/plaza/p/${encodeURIComponent(postId)}`;
|
||||
}
|
||||
if (import.meta.env.DEV) {
|
||||
return `https://plaza.tkmind.cn/plaza/p/${encodeURIComponent(postId)}`;
|
||||
}
|
||||
return `${window.location.origin}/plaza/p/${encodeURIComponent(postId)}`;
|
||||
}
|
||||
|
||||
export function resolvePlazaHomeUrl(): string {
|
||||
const configured = String(import.meta.env.VITE_PLAZA_BASE ?? '').trim();
|
||||
if (configured) {
|
||||
return `${configured.replace(/\/$/, '')}/plaza`;
|
||||
}
|
||||
if (import.meta.env.DEV) {
|
||||
return 'https://plaza.tkmind.cn/plaza';
|
||||
}
|
||||
return `${window.location.origin}/plaza`;
|
||||
}
|
||||
|
||||
export function resolveMindSpaceHomeUrl(): string {
|
||||
const configured = String(import.meta.env.VITE_MINDSPACE_BASE ?? '').trim();
|
||||
if (configured) {
|
||||
return `${configured.replace(/\/$/, '')}/space`;
|
||||
}
|
||||
return `${window.location.origin}/space`;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const PUBLISH_SKILL_NAME = 'static-page-publish';
|
||||
|
||||
export function buildPublishSkillPrompt(skillName = PUBLISH_SKILL_NAME) {
|
||||
return `请使用 ${skillName} 技能:在我的专属 MindSpace 发布目录生成静态 HTML 页面,并给出可公网访问的完整链接。`;
|
||||
}
|
||||
|
||||
export const PUBLISH_SKILL_PROMPT = buildPublishSkillPrompt();
|
||||
@@ -0,0 +1,57 @@
|
||||
import { appConfig } from '../config';
|
||||
import type { Session } from '../types';
|
||||
|
||||
export const DEFAULT_CHAT_TITLE = 'New Chat';
|
||||
|
||||
export function shouldShowNewChatTitle(session: Session): boolean {
|
||||
if (session.recipe) return false;
|
||||
return !session.user_set_name && session.message_count === 0;
|
||||
}
|
||||
|
||||
export function sortAndTrim(sessions: Session[]): Session[] {
|
||||
return [...sessions]
|
||||
.sort((a, b) => {
|
||||
const aTime = new Date(a.updated_at ?? a.created_at ?? 0).getTime();
|
||||
const bTime = new Date(b.updated_at ?? b.created_at ?? 0).getTime();
|
||||
return bTime - aTime;
|
||||
})
|
||||
.slice(0, appConfig.sessionMaxCount);
|
||||
}
|
||||
|
||||
export function prependUnique(prev: Session[], session: Session): Session[] {
|
||||
if (prev.some((s) => s.id === session.id)) return sortAndTrim(prev);
|
||||
return sortAndTrim([session, ...prev.filter((s) => s.id !== session.id)]);
|
||||
}
|
||||
|
||||
export function touchSession(sessions: Session[], sessionId: string, messageDelta = 0): Session[] {
|
||||
const now = new Date().toISOString();
|
||||
return sortAndTrim(
|
||||
sessions.map((s) =>
|
||||
s.id === sessionId
|
||||
? {
|
||||
...s,
|
||||
updated_at: now,
|
||||
message_count: Math.max(0, s.message_count + messageDelta),
|
||||
}
|
||||
: s,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getSessionDisplayName(session: Session): string {
|
||||
if (session.user_set_name) return session.name;
|
||||
if (session.recipe?.title) return session.recipe.title;
|
||||
if (shouldShowNewChatTitle(session)) return DEFAULT_CHAT_TITLE;
|
||||
return session.name;
|
||||
}
|
||||
|
||||
export function getSessionListLabel(session: Session): string {
|
||||
const displayName = getSessionDisplayName(session);
|
||||
if (displayName !== DEFAULT_CHAT_TITLE && displayName !== '新对话') {
|
||||
return displayName;
|
||||
}
|
||||
if (session.message_count > 0) {
|
||||
return `对话 · ${session.message_count} 条消息`;
|
||||
}
|
||||
return `会话 ${session.id.slice(0, 8)}`;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { resolveMindSpaceHomeUrl } from './publicUrl';
|
||||
|
||||
export type SharePayload = {
|
||||
title: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type ShareChannel = {
|
||||
id: string;
|
||||
label: string;
|
||||
hint: string;
|
||||
buildText: (payload: SharePayload) => string;
|
||||
};
|
||||
|
||||
export const SHARE_CHANNELS: ShareChannel[] = [
|
||||
{
|
||||
id: 'wechat_moments',
|
||||
label: '微信朋友圈',
|
||||
hint: '文案已复制,请打开微信粘贴到朋友圈',
|
||||
buildText: ({ title, url, description }) =>
|
||||
[title, description, url].filter(Boolean).join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'xiaohongshu',
|
||||
label: '小红书',
|
||||
hint: '文案已复制,请打开小红书发布笔记并粘贴',
|
||||
buildText: ({ title, url, description }) =>
|
||||
[`${title}`, description ?? '分享自我的 MindSpace', url, '#TKMind #创作分享'].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'toutiao',
|
||||
label: '今日头条',
|
||||
hint: '文案已复制,请打开今日头条发布并粘贴',
|
||||
buildText: ({ title, url, description }) =>
|
||||
[`【${title}】`, description, url].filter(Boolean).join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'weibo',
|
||||
label: '微博',
|
||||
hint: '文案已复制,请打开微博发布并粘贴',
|
||||
buildText: ({ title, url }) => `${title} ${url} #TKMind#`,
|
||||
},
|
||||
{
|
||||
id: 'douyin',
|
||||
label: '抖音',
|
||||
hint: '链接已复制,可粘贴到抖音个人简介或私信',
|
||||
buildText: ({ title, url }) => `${title}\n${url}`,
|
||||
},
|
||||
{
|
||||
id: 'copy_link',
|
||||
label: '复制链接',
|
||||
hint: '链接已复制',
|
||||
buildText: ({ url }) => url,
|
||||
},
|
||||
];
|
||||
|
||||
export async function copyShareText(text: string) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
export function canUseNativeShare() {
|
||||
return typeof navigator.share === 'function';
|
||||
}
|
||||
|
||||
export async function nativeShare(payload: SharePayload) {
|
||||
await navigator.share({
|
||||
title: payload.title,
|
||||
text: payload.description ?? payload.title,
|
||||
url: payload.url,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildAssetSharePayload(
|
||||
asset: { displayName: string },
|
||||
categoryCode?: string,
|
||||
): SharePayload {
|
||||
const url = new URL(resolveMindSpaceHomeUrl());
|
||||
if (categoryCode) {
|
||||
url.searchParams.set('category', categoryCode);
|
||||
}
|
||||
return {
|
||||
title: asset.displayName,
|
||||
url: url.toString(),
|
||||
description: `看看我在 TKMind 空间里的作品「${asset.displayName}」`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
function sameDay(a: Date, b: Date) {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
export function formatChatTime(timestampSec: number): string {
|
||||
const date = new Date(timestampSec * 1000);
|
||||
const now = new Date();
|
||||
const time = `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
|
||||
if (sameDay(date, now)) {
|
||||
return time;
|
||||
}
|
||||
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
if (sameDay(date, yesterday)) {
|
||||
return `昨天 ${time}`;
|
||||
}
|
||||
|
||||
if (date.getFullYear() === now.getFullYear()) {
|
||||
return `${date.getMonth() + 1}月${date.getDate()}日 ${time}`;
|
||||
}
|
||||
|
||||
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日 ${time}`;
|
||||
}
|
||||
|
||||
export function shouldShowTimestamp(currentSec: number, previousSec?: number): boolean {
|
||||
if (previousSec === undefined) return true;
|
||||
return currentSec - previousSec > 5 * 60;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { PortalUser } from '../types';
|
||||
|
||||
export function resolveUserAddressName(
|
||||
user?: Pick<PortalUser, 'displayName' | 'username'> | null,
|
||||
): string {
|
||||
const preferred = user?.displayName?.trim();
|
||||
if (preferred) return preferred;
|
||||
const username = user?.username?.trim();
|
||||
if (username) return username;
|
||||
return '用户';
|
||||
}
|
||||
|
||||
/** Hidden agent prefix so replies greet the user by name, not "TKMind". */
|
||||
export function buildUserAddressPrefix(
|
||||
user?: Pick<PortalUser, 'displayName' | 'username'> | null,
|
||||
): string {
|
||||
const name = resolveUserAddressName(user);
|
||||
return `[用户身份]
|
||||
- 当前登录用户称呼:${name}
|
||||
- 你是 TKMind 助手;与用户对话时用「${name}」称呼对方(如「${name},你好」),禁止把用户叫作 TKMind。
|
||||
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
const STORAGE_KEY = 'tkmind_h5_user_avatar';
|
||||
const MAX_BYTES = 2 * 1024 * 1024;
|
||||
const OUTPUT_SIZE = 128;
|
||||
|
||||
const ACCEPTED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
||||
|
||||
export const AVATAR_CHANGED_EVENT = 'tkmind-avatar-changed';
|
||||
export const AVATAR_PICKER_OPEN_EVENT = 'tkmind-avatar-picker-open';
|
||||
|
||||
let avatarPickerInput: HTMLInputElement | null = null;
|
||||
|
||||
export function registerAvatarPickerInput(input: HTMLInputElement | null): void {
|
||||
avatarPickerInput = input;
|
||||
}
|
||||
|
||||
export function getAvatarPickerInput(): HTMLInputElement | null {
|
||||
return avatarPickerInput;
|
||||
}
|
||||
|
||||
export function openAvatarPicker(): void {
|
||||
if (avatarPickerInput) {
|
||||
avatarPickerInput.click();
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new Event(AVATAR_PICKER_OPEN_EVENT));
|
||||
}
|
||||
|
||||
export function loadUserAvatar(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveUserAvatar(dataUrl: string): void {
|
||||
localStorage.setItem(STORAGE_KEY, dataUrl);
|
||||
}
|
||||
|
||||
export function clearUserAvatar(): void {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function notifyAvatarChanged(): void {
|
||||
window.dispatchEvent(new Event(AVATAR_CHANGED_EVENT));
|
||||
}
|
||||
|
||||
function loadImage(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 cropToSquare(img: HTMLImageElement): HTMLCanvasElement {
|
||||
const side = Math.min(img.width, img.height);
|
||||
const sx = (img.width - side) / 2;
|
||||
const sy = (img.height - side) / 2;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = OUTPUT_SIZE;
|
||||
canvas.height = OUTPUT_SIZE;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('无法处理图片');
|
||||
ctx.drawImage(img, sx, sy, side, side, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export async function processAvatarFile(file: File): Promise<string> {
|
||||
if (!ACCEPTED_TYPES.has(file.type)) {
|
||||
throw new Error('仅支持 JPG、PNG、WebP 或 GIF');
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
throw new Error('图片不能超过 2MB');
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
try {
|
||||
const img = await loadImage(objectUrl);
|
||||
const canvas = cropToSquare(img);
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.88);
|
||||
if (dataUrl.length > 120_000) {
|
||||
throw new Error('图片过大,请换一张更小的图片');
|
||||
}
|
||||
return dataUrl;
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export function isWechatBrowser(userAgent = navigator.userAgent) {
|
||||
const ua = userAgent || '';
|
||||
return /MicroMessenger/i.test(ua) || /WindowsWechat/i.test(ua);
|
||||
}
|
||||
|
||||
/** 从服务号链接进入时带的参数(即使 UA 识别失败也展示微信登录) */
|
||||
export function isWechatEntryUrl(search = window.location.search) {
|
||||
const params = new URLSearchParams(search);
|
||||
const from = (params.get('from') || '').toLowerCase();
|
||||
const utm = (params.get('utm_source') || '').toLowerCase();
|
||||
return from === 'wechat' || utm === 'wechat';
|
||||
}
|
||||
|
||||
export function isWechatContext(options?: {
|
||||
userAgent?: string;
|
||||
search?: string;
|
||||
serverInWechat?: boolean;
|
||||
}) {
|
||||
if (options?.serverInWechat) return true;
|
||||
if (isWechatBrowser(options?.userAgent)) return true;
|
||||
return isWechatEntryUrl(options?.search);
|
||||
}
|
||||
|
||||
export function buildWechatAuthorizeUrl(options?: {
|
||||
returnTo?: string;
|
||||
utmSource?: string;
|
||||
utmMedium?: string;
|
||||
utmCampaign?: string;
|
||||
}) {
|
||||
const params = new URLSearchParams();
|
||||
const returnTo =
|
||||
options?.returnTo ??
|
||||
`${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
if (returnTo && returnTo !== '/') {
|
||||
params.set('return_to', returnTo);
|
||||
}
|
||||
if (options?.utmSource) params.set('utm_source', options.utmSource);
|
||||
if (options?.utmMedium) params.set('utm_medium', options.utmMedium);
|
||||
if (options?.utmCampaign) params.set('utm_campaign', options.utmCampaign);
|
||||
const query = params.toString();
|
||||
return query ? `/auth/wechat/authorize?${query}` : '/auth/wechat/authorize';
|
||||
}
|
||||
|
||||
export function readWechatAuthError(): string | null {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const error = params.get('wechat_error');
|
||||
if (!error) return null;
|
||||
params.delete('wechat_error');
|
||||
const nextSearch = params.toString();
|
||||
const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ''}${window.location.hash}`;
|
||||
window.history.replaceState({}, '', nextUrl);
|
||||
try {
|
||||
return decodeURIComponent(error);
|
||||
} catch {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user