Add MindSpace page live edit, chat skills, and H5 deploy tooling.

Introduce page edit sessions with draft preview and patch API, chat skill picker, user memory profile, h5ApiBase resolution, voice WAV transport, and scripts for 105/g2 deployment and Plaza local dev.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
John
2026-06-15 22:09:38 -07:00
parent 3cd322ccfe
commit 6ee6fd64dd
94 changed files with 7015 additions and 2136 deletions
+47
View File
@@ -0,0 +1,47 @@
import {
buildChatSkillPrompt,
CHAT_SKILL_DEFINITIONS,
filterChatSkills as filterChatSkillDefinitions,
} from '../../chat-skills.mjs';
import { buildPublishSkillPrompt, PUBLISH_SKILL_NAME } from './publishSkill';
export type ChatSkillIconId =
| 'spark'
| 'page'
| 'search'
| 'web'
| 'form'
| 'table'
| 'summary'
| 'analyze';
export type ChatSkillOption = {
id: string;
label: string;
icon: ChatSkillIconId;
skillName?: string;
buildPrompt: (skillName?: string) => string;
requiresPublish?: boolean;
requiresSkill?: string;
prefillOnly?: boolean;
};
export const CHAT_SKILL_OPTIONS: ChatSkillOption[] = CHAT_SKILL_DEFINITIONS.map((def) => ({
id: def.id,
label: def.label,
icon: def.icon,
skillName: def.skillName,
requiresPublish: def.requiresPublish,
requiresSkill: def.requiresSkill,
prefillOnly: def.prefillOnly,
buildPrompt: (skillName) => buildChatSkillPrompt(def.promptKey, skillName ?? def.skillName),
}));
export function filterChatSkills(
options: ChatSkillOption[],
ctx: { grantedSkills?: string[]; canPublish: boolean },
): ChatSkillOption[] {
return filterChatSkillDefinitions(options, ctx);
}
export { buildPublishSkillPrompt, PUBLISH_SKILL_NAME };
+7
View File
@@ -0,0 +1,7 @@
export function resolveH5ApiBase() {
const configured = import.meta.env.VITE_H5_API_ORIGIN;
if (configured) return String(configured).replace(/\/$/, '');
if (import.meta.env.DEV) return 'http://127.0.0.1:8081';
if (typeof window !== 'undefined') return window.location.origin;
return '';
}
+8
View File
@@ -52,6 +52,14 @@ export function isWorkAsset(asset: MindSpaceAsset) {
return asset.assetType === 'html' || asset.mimeType === 'text/html';
}
export function isImageAsset(asset: MindSpaceAsset) {
return asset.assetType === 'image' || asset.mimeType.startsWith('image/');
}
export function buildAssetImageUrl(asset: Pick<MindSpaceAsset, 'id' | 'updatedAt'>) {
return `/api/mindspace/v1/assets/${asset.id}/download?inline=1&v=${asset.updatedAt}`;
}
/** Chrome blocks PDF rendering inside any sandboxed iframe. */
export function assetPreviewUsesSandbox(mimeType: string) {
return mimeType !== 'application/pdf';
+4
View File
@@ -29,4 +29,8 @@ export type BuildMindSpaceChatContextInput = {
assets?: MindSpaceAsset[];
focusedAsset?: MindSpaceAsset | null;
route: string;
agentSessionId?: string | null;
h5ApiBase?: string | null;
pageEditMode?: boolean;
parentAgentSessionId?: string | null;
};
+155
View File
@@ -0,0 +1,155 @@
const HIGHLIGHT_STYLE_ID = 'mindspace-change-highlight-style';
export function stripHtmlForHighlight(value: string): string {
return String(value ?? '')
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
export function findChangedTextSegment(before: string, after: string): string | null {
const previous = stripHtmlForHighlight(before);
const next = stripHtmlForHighlight(after);
if (!next || previous === next) return null;
let start = 0;
while (start < previous.length && start < next.length && previous[start] === next[start]) {
start += 1;
}
let endPrevious = previous.length;
let endNext = next.length;
while (
endPrevious > start &&
endNext > start &&
previous[endPrevious - 1] === next[endNext - 1]
) {
endPrevious -= 1;
endNext -= 1;
}
const segment = next.slice(start, endNext).trim();
if (segment.length < 2) return null;
return segment.slice(0, 160);
}
function ensureHighlightStyles(doc: Document) {
if (doc.getElementById(HIGHLIGHT_STYLE_ID)) return;
const style = doc.createElement('style');
style.id = HIGHLIGHT_STYLE_ID;
style.textContent = `
.mindspace-change-highlight {
background: rgba(255, 214, 102, 0.58);
box-shadow: 0 0 0 2px rgba(255, 170, 0, 0.42);
border-radius: 4px;
animation: mindspace-change-pulse 1.1s ease-in-out 2;
}
.mindspace-change-flash {
animation: mindspace-page-flash 0.85s ease-in-out 2;
}
@keyframes mindspace-change-pulse {
0%, 100% { filter: brightness(1); }
50% { filter: brightness(1.08); }
}
@keyframes mindspace-page-flash {
0%, 100% { box-shadow: inset 0 0 0 0 rgba(255, 190, 60, 0); }
50% { box-shadow: inset 0 0 0 4px rgba(255, 190, 60, 0.35); }
}
`;
doc.head?.appendChild(style);
}
function wrapFirstTextMatch(doc: Document, root: ParentNode, snippet: string): boolean {
const needle = snippet.trim();
if (!needle) return false;
const candidates = [needle, needle.slice(0, 80), needle.slice(0, 40)].filter(
(value, index, list) => value.length >= 2 && list.indexOf(value) === index,
);
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let node = walker.nextNode() as Text | null;
while (node) {
const raw = node.textContent ?? '';
for (const candidate of candidates) {
const index = raw.indexOf(candidate);
if (index === -1) continue;
const mark = doc.createElement('mark');
mark.className = 'mindspace-change-highlight';
const range = doc.createRange();
range.setStart(node, index);
range.setEnd(node, index + candidate.length);
range.surroundContents(mark);
return true;
}
node = walker.nextNode() as Text | null;
}
return false;
}
function highlightTitleChange(doc: Document): boolean {
const selectors = ['h1', 'header h1', '.page-title', '.hero-title', 'title'];
for (const selector of selectors) {
const element = doc.querySelector(selector);
if (!element) continue;
element.classList.add('mindspace-change-highlight');
return true;
}
return false;
}
export function applyPageChangeHighlight(
doc: Document,
input: {
previousTitle: string;
nextTitle: string;
previousContent: string;
nextContent: string;
},
): number {
ensureHighlightStyles(doc);
let count = 0;
const titleChanged = input.previousTitle.trim() !== input.nextTitle.trim();
const contentChanged = input.previousContent !== input.nextContent;
if (titleChanged && highlightTitleChange(doc)) {
count += 1;
}
if (contentChanged) {
const segment = findChangedTextSegment(input.previousContent, input.nextContent);
if (segment && doc.body && wrapFirstTextMatch(doc, doc.body, segment)) {
count += 1;
} else if (doc.body) {
doc.body.classList.add('mindspace-change-flash');
count += 1;
}
} else if (titleChanged && count === 0 && doc.body) {
doc.body.classList.add('mindspace-change-flash');
count += 1;
}
const first = doc.querySelector('.mindspace-change-highlight');
first?.scrollIntoView({ behavior: 'smooth', block: 'center' });
window.setTimeout(() => {
doc.querySelectorAll('mark.mindspace-change-highlight').forEach((element) => {
const parent = element.parentNode;
if (!parent) return;
while (element.firstChild) {
parent.insertBefore(element.firstChild, element);
}
parent.removeChild(element);
});
doc.querySelectorAll('.mindspace-change-highlight').forEach((element) => {
element.classList.remove('mindspace-change-highlight');
});
doc.body?.classList.remove('mindspace-change-flash');
}, 3200);
return count;
}
+14
View File
@@ -0,0 +1,14 @@
export type MindSpacePagePatch = {
title?: string;
summary?: string;
content?: string;
};
export {
extractMindSpacePagePatch,
mergeMindSpacePagePatch,
} from '../../mindspace-page-patch.mjs';
export function patchKey(patch: MindSpacePagePatch): string {
return JSON.stringify(patch);
}
+57
View File
@@ -45,6 +45,63 @@ export function getSessionDisplayName(session: Session): string {
return session.name;
}
function sameLocalDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
export function getSessionDateKey(session: Session): string {
const iso = session.updated_at ?? session.created_at;
if (!iso) return '';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '';
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
}
export function formatSessionDateLabel(session: Session): string {
const iso = session.updated_at ?? session.created_at;
if (!iso) return '';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '';
const now = new Date();
if (sameLocalDay(date, now)) return '今天';
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (sameLocalDay(date, yesterday)) return '昨天';
if (date.getFullYear() === now.getFullYear()) {
return `${date.getMonth() + 1}${date.getDate()}`;
}
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`;
}
export type SessionDateGroup = {
dateKey: string;
label: string;
sessions: Session[];
};
export function groupSessionsByDate(sessions: Session[]): SessionDateGroup[] {
const groups: SessionDateGroup[] = [];
for (const session of sessions) {
const dateKey = getSessionDateKey(session);
const label = formatSessionDateLabel(session) || '未知日期';
const last = groups[groups.length - 1];
if (last && last.dateKey === dateKey) {
last.sessions.push(session);
} else {
groups.push({ dateKey, label, sessions: [session] });
}
}
return groups;
}
export function getSessionListLabel(session: Session): string {
const displayName = getSessionDisplayName(session);
if (displayName !== DEFAULT_CHAT_TITLE && displayName !== '新对话') {
+16
View File
@@ -32,6 +32,22 @@ export function buildPageUrlForWechatOpen(
return `${window.location.origin}${pathname}${query ? `?${query}` : ''}${window.location.hash}`;
}
/** PC 兜底二维码:扫码后直接进入微信 OAuth,手机上会出现授权/确认页 */
export function buildWechatAuthorizePageUrl(
search = window.location.search,
) {
const params = new URLSearchParams(search);
const returnTo = params.get('return_to');
const authPath = buildWechatAuthorizeUrl({
returnTo: returnTo ?? undefined,
utmSource: params.get('utm_source') ?? params.get('from') ?? 'wechat',
utmMedium: params.get('utm_medium') ?? undefined,
utmCampaign: params.get('utm_campaign') ?? undefined,
intent: 'login',
});
return `${window.location.origin}${authPath}`;
}
export async function copyWechatOpenLink(): Promise<boolean> {
const url = buildPageUrlForWechatOpen();
try {