Add smart ACK provider for WeChat MP replies

Replace fixed ackText with a rule-based AckProvider that picks
response templates by message type and intent (translate, summary,
rewrite, poster, ppt, mindmap, code, search, schedule). Pure sync,
zero I/O, auto-falls back to config.ackText on any error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
john
2026-06-26 15:19:03 +08:00
parent 9ed4fd48d7
commit 9b4a25799f
162 changed files with 17276 additions and 2054 deletions
+377 -14
View File
@@ -20,6 +20,7 @@ import {
retryMindSpaceAgentJob,
runMindSpaceAgentJob,
runMindSpaceCleanup,
purchaseSpaceQuota,
uploadMindSpaceAsset,
ApiError,
} from '../api/client';
@@ -58,6 +59,7 @@ import { buildAssetSharePayload, type SharePayload } from '../utils/shareChannel
import { resolvePublicPageUrl, resolvePlazaHomeUrl } from '../utils/publicUrl';
import { PagePreviewFrame } from './PagePreviewFrame';
import { BalanceRing } from './BalanceRing';
import { NotificationCenter } from './NotificationCenter';
const CATEGORY_DESCRIPTIONS: Record<MindSpaceCategory['code'], string> = {
oa: '上传文档、表格和资料,让 AI 生成报告与页面',
@@ -77,7 +79,7 @@ const CATEGORY_ACTIONS: Partial<Record<MindSpaceCategory['code'], string>> = {
const AGENT_JOBS_PAGE_SIZE = 10;
const RECENT_PAGES_PAGE_SIZE = 6;
const IMAGE_PAGE_SIZE = 10;
const MAX_UPLOAD_FILE_BYTES = 2 * 1024 * 1024;
const DEFAULT_MAX_UPLOAD_FILE_BYTES = 5 * 1024 * 1024;
const UPLOAD_FILE_EXTENSIONS = [
'.doc',
'.docx',
@@ -135,6 +137,35 @@ const PREVIEW_SCHEDULE_ITEMS = [
},
] as const;
function formatScheduleClock(value: number | null | undefined, timezone?: string | null) {
if (typeof value !== 'number' || Number.isNaN(value)) return '—';
try {
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: timezone || undefined,
}).format(new Date(value));
} catch {
return new Date(value).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
}
function formatDigestSchedule(digest: {
hour: number;
minute: number;
timezone?: string | null;
nextRunAt?: number | null;
}) {
const time = `${String(digest.hour).padStart(2, '0')}:${String(digest.minute).padStart(2, '0')}`;
const nextRun = formatScheduleClock(digest.nextRunAt, digest.timezone);
return nextRun === '—' ? time : `${time} · 下次 ${nextRun}`;
}
const PREVIEW_PAGE_READERS: Record<string, { kicker: string; body: string[] }> = {
'page-1': {
kicker: '市场趋势',
@@ -182,8 +213,13 @@ const LONG_IMAGE_VIEWPORT_WIDTH = 1280;
const LONG_IMAGE_VIEWPORT_HEIGHT = 720;
const LONG_IMAGE_RENDER_SCALE = 2;
const LONG_IMAGE_MAX_PIXELS = 64_000_000;
const SPACE_PURCHASE_PRESETS_MB = [5, 10, 20];
let html2CanvasLoader: Promise<Html2CanvasRenderer> | null = null;
function formatYuan(cents: number) {
return `¥${(cents / 100).toFixed(2)}`;
}
async function loadHtml2CanvasRenderer(): Promise<Html2CanvasRenderer> {
const existing = (window as Html2CanvasWindow).html2canvas;
if (existing) return existing;
@@ -246,10 +282,10 @@ function fileExtension(filename: string) {
return dotIndex >= 0 ? filename.slice(dotIndex).toLowerCase() : '';
}
function validateSelectedUploadFile(file: File) {
function validateSelectedUploadFile(file: File, maxBytes: number) {
if (file.size <= 0) return '文件不能为空,请重新选择。';
if (file.size > MAX_UPLOAD_FILE_BYTES) {
return `文件不能超过 ${formatBytes(MAX_UPLOAD_FILE_BYTES)},当前为 ${formatBytes(file.size)}。`;
if (file.size > maxBytes) {
return `文件不能超过 ${formatBytes(maxBytes)},当前为 ${formatBytes(file.size)}。`;
}
const extension = fileExtension(file.name);
if (!UPLOAD_FILE_EXTENSIONS.includes(extension as (typeof UPLOAD_FILE_EXTENSIONS)[number])) {
@@ -388,18 +424,25 @@ type MindSpaceRouteSync = {
};
function MindSpaceBalanceRing() {
const { balanceCents, totalCreditCents, tokensUsed, openRecharge } = useChat();
const { balanceCents, totalCreditCents, tokensUsed, subscription, openRecharge, openSubscribe } = useChat();
if (typeof balanceCents !== 'number') return null;
return (
<BalanceRing
balanceCents={balanceCents}
totalCreditCents={totalCreditCents}
tokensUsed={tokensUsed}
subscription={subscription}
onRecharge={openRecharge}
onSubscribe={openSubscribe}
/>
);
}
function MindSpaceNotificationCenter() {
const { openRecharge } = useChat();
return <NotificationCenter onOpenRecharge={() => openRecharge(false)} />;
}
export function MindSpaceView({
user,
previewMode = false,
@@ -431,6 +474,8 @@ export function MindSpaceView({
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploadError, setUploadError] = useState<string | null>(null);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
const [bulkDeletingAssets, setBulkDeletingAssets] = useState(false);
const [newPageOpen, setNewPageOpen] = useState(false);
const [newPageTitle, setNewPageTitle] = useState('');
const [newPageContent, setNewPageContent] = useState('');
@@ -477,7 +522,11 @@ export function MindSpaceView({
} | null>(null);
const [pageRefreshTrigger, setPageRefreshTrigger] = useState(0);
const [pageFullscreenPreviewOpen, setPageFullscreenPreviewOpen] = useState(false);
const { chatState, messages, session } = useChat();
const [spacePurchaseOpen, setSpacePurchaseOpen] = useState(false);
const [spacePurchaseMb, setSpacePurchaseMb] = useState('5');
const [spacePurchasePending, setSpacePurchasePending] = useState(false);
const [spacePurchaseMessage, setSpacePurchaseMessage] = useState<string | null>(null);
const { chatState, messages, session, balanceCents, completeRecharge, openRecharge } = useChat();
const prevChatStateRef = useRef(chatState);
const h5ApiBase = useMemo(() => resolveH5ApiBase(), []);
const location = useLocation();
@@ -489,6 +538,7 @@ export function MindSpaceView({
const params = new URLSearchParams(location.search);
return params.get('return_to') || resolvePlazaHomeUrl();
}, [location.search]);
const maxUploadFileBytes = space?.quota.maxFileBytes ?? DEFAULT_MAX_UPLOAD_FILE_BYTES;
useEffect(() => {
if (!selectedPageId) {
@@ -688,6 +738,7 @@ export function MindSpaceView({
setSelectedCategory(null);
setUploadOpen(false);
setPendingDeleteId(null);
setSelectedAssetIds([]);
setImagePage(0);
routeSync?.pushHome();
};
@@ -953,7 +1004,7 @@ export function MindSpaceView({
const submitUpload = async () => {
if (!selectedCategory || !selectedFile) return;
const validationError = validateSelectedUploadFile(selectedFile);
const validationError = validateSelectedUploadFile(selectedFile, maxUploadFileBytes);
if (validationError) {
setUploadError(validationError);
return;
@@ -968,8 +1019,8 @@ export function MindSpaceView({
try {
const fileToUpload = selectedFile.type.startsWith('image/')
? await compressImageForUpload(selectedFile, {
maxInputBytes: MAX_UPLOAD_FILE_BYTES,
maxOutputBytes: MAX_UPLOAD_FILE_BYTES,
maxInputBytes: maxUploadFileBytes,
maxOutputBytes: maxUploadFileBytes,
maxDimension: 1600,
})
: selectedFile;
@@ -985,6 +1036,34 @@ export function MindSpaceView({
}
};
const submitSpacePurchase = async (requestedMb?: number) => {
const sizeMb = Math.floor(Number(requestedMb ?? spacePurchaseMb));
if (!Number.isFinite(sizeMb) || sizeMb <= 0) {
setSpacePurchaseMessage('请输入大于 0 的空间大小(MB)');
return;
}
setSpacePurchasePending(true);
setSpacePurchaseMessage(null);
setError(null);
try {
const result = await purchaseSpaceQuota(sizeMb);
completeRecharge(result.balanceCents);
await refreshMindSpaceSnapshot({ quota: result.quota });
setSpacePurchaseMb(String(sizeMb));
setSpacePurchaseMessage(
`已扩容 ${sizeMb} MB,支付 ¥${(result.costCents / 100).toFixed(2)}。`,
);
} catch (err) {
if (err instanceof ApiError && err.code === 'INSUFFICIENT_BALANCE') {
setSpacePurchaseMessage('余额不足,请先到余额里充值,再回来购买空间。');
} else {
setSpacePurchaseMessage(err instanceof Error ? err.message : '购买空间失败');
}
} finally {
setSpacePurchasePending(false);
}
};
const handleUploadFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] ?? null;
if (!file) {
@@ -992,7 +1071,7 @@ export function MindSpaceView({
setUploadError(null);
return;
}
const validationError = validateSelectedUploadFile(file);
const validationError = validateSelectedUploadFile(file, maxUploadFileBytes);
if (validationError) {
setSelectedFile(null);
setUploadError(validationError);
@@ -1014,6 +1093,7 @@ export function MindSpaceView({
try {
await deleteMindSpaceAsset(assetId);
setPendingDeleteId(null);
setSelectedAssetIds((ids) => ids.filter((id) => id !== assetId));
await load();
await openCategory(selectedCategory);
} catch (err) {
@@ -1050,6 +1130,10 @@ export function MindSpaceView({
const usedPercent = space
? Math.min(100, (occupiedBytes / space.quota.quotaBytes) * 100)
: 0;
const currentBalanceCents = typeof balanceCents === 'number' ? balanceCents : user.balanceCents;
const purchaseMbValue = Math.max(0, Math.floor(Number(spacePurchaseMb) || 0));
const purchaseCostCents = purchaseMbValue * 200;
const canPurchaseSpace = purchaseMbValue > 0 && currentBalanceCents >= purchaseCostCents;
const openAssetEditor = async (asset: MindSpaceAsset) => {
if (previewMode) {
@@ -1186,6 +1270,70 @@ export function MindSpaceView({
: assetFilter === 'pages'
? workAssets
: [];
const visibleSelectableAssets = [...pagedImageAssets, ...visibleCardAssets];
const selectedAssetSet = useMemo(() => new Set(selectedAssetIds), [selectedAssetIds]);
const allVisibleAssetsSelected =
visibleSelectableAssets.length > 0 &&
visibleSelectableAssets.every((asset) => selectedAssetSet.has(asset.id));
useEffect(() => {
const availableIds = new Set(assets.map((asset) => asset.id));
setSelectedAssetIds((ids) => ids.filter((id) => availableIds.has(id)));
}, [assets]);
const toggleAssetSelected = (assetId: string) => {
setSelectedAssetIds((ids) =>
ids.includes(assetId) ? ids.filter((id) => id !== assetId) : [...ids, assetId],
);
};
const toggleVisibleAssetsSelected = () => {
const visibleIds = visibleSelectableAssets.map((asset) => asset.id);
if (allVisibleAssetsSelected) {
setSelectedAssetIds((ids) => ids.filter((id) => !visibleIds.includes(id)));
return;
}
setSelectedAssetIds((ids) => Array.from(new Set([...ids, ...visibleIds])));
};
const removeSelectedAssets = async () => {
if (!selectedCategory || selectedAssetIds.length === 0) return;
if (previewMode) {
setError(previewBlocked().message);
return;
}
setBulkDeletingAssets(true);
setError(null);
setAssetDeleteDialog(null);
const deletingIds = [...selectedAssetIds];
const failedIds: string[] = [];
const failedNames: string[] = [];
try {
for (const assetId of deletingIds) {
try {
await deleteMindSpaceAsset(assetId);
} catch (err) {
const assetName = assets.find((asset) => asset.id === assetId)?.displayName ?? assetId;
failedIds.push(assetId);
failedNames.push(assetName);
}
}
setSelectedAssetIds((ids) => ids.filter((id) => failedIds.includes(id)));
await load();
await openCategory(selectedCategory);
if (failedNames.length > 0) {
setAssetDeleteDialog({
message: `有 ${failedNames.length} 个资产未删除,可能正在被页面使用。`,
references: failedNames.slice(0, 6).map((title, index) => ({
id: `${index}`,
title,
})),
});
}
} finally {
setBulkDeletingAssets(false);
}
};
const previewAsset = useMemo(
() => assets.find((asset) => asset.id === previewAssetId) ?? null,
@@ -1219,6 +1367,16 @@ export function MindSpaceView({
AI 生成页面
</button>
)}
{asset.publicUrl && (
<a
className="mindspace-asset-share"
href={asset.publicUrl}
target="_blank"
rel="noreferrer"
>
公网图
</a>
)}
{asset.status === 'ready' && asset.scanStatus !== 'blocked' ? (
<button
type="button"
@@ -1315,7 +1473,19 @@ export function MindSpaceView({
const showHomeModules =
space && !selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen;
const scheduleItems = previewMode ? PREVIEW_SCHEDULE_ITEMS : [];
const scheduleItems = previewMode
? PREVIEW_SCHEDULE_ITEMS
: (space?.schedule?.todayTodoItems ?? []).map((item) => ({
id: item.id,
time: item.allDay ? '全天' : formatScheduleClock(item.dueAt ?? item.startAt, item.timezone),
kind: item.kind === 'event' ? '日程' : '待办',
title: item.title,
meta:
item.description?.trim() ||
(item.sourceChannel === 'wechat' ? '来自服务号' : '来自空间同步'),
tone: item.kind === 'event' ? 'event' : 'task',
}));
const scheduleDigests = previewMode ? [] : space?.schedule?.digestSubscriptions ?? [];
const selectedPreviewPage = selectedPageId
? pages.find((page) => page.id === selectedPageId) ?? null
: null;
@@ -1369,6 +1539,11 @@ export function MindSpaceView({
) : (
<MindSpaceBalanceRing />
)}
{previewMode ? (
<NotificationCenter onOpenRecharge={() => {}} />
) : (
<MindSpaceNotificationCenter />
)}
<span>{user.displayName}</span>
<button type="button" className="ghost-btn logout-btn" onClick={onLogout}>
登出
@@ -1417,7 +1592,28 @@ export function MindSpaceView({
{space && (
<aside className="mindspace-hero-quota">
<div className="mindspace-hero-quota-top">
<span>免费版空间</span>
<span className="mindspace-hero-quota-label">
<span>空间容量</span>
<button
type="button"
className="mindspace-hero-quota-buy-trigger"
aria-label="购买空间扩容"
onClick={() => {
setSpacePurchaseMessage(null);
setSpacePurchaseOpen(true);
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M12 5v14M5 12h14"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
<span>扩容</span>
</button>
</span>
<strong>
{formatBytes(occupiedBytes)} / {formatBytes(space.quota.quotaBytes)}
</strong>
@@ -1438,6 +1634,7 @@ export function MindSpaceView({
含上传预留 {formatBytes(space.quota.reservedBytes)} ·{' '}
</>
)}
剩余 {formatBytes(space.quota.availableBytes)} ·{' '}
单文件上限 {formatBytes(space.quota.maxFileBytes)} · 可发布页面{' '}
{space.quota.publicPageUsed}/{space.quota.publicPageLimit} · 今日 AI{' '}
{space.quota.aiDailyUsed}/{space.quota.aiDailyLimit}
@@ -1541,8 +1738,30 @@ export function MindSpaceView({
</div>
<div className="mindspace-schedule-summary">
<strong>今天 {scheduleItems.length || 0} 项</strong>
<span>{scheduleItems.length ? '1 个提醒即将推送' : '还没有安排'}</span>
<span>
{scheduleItems.length > 0
? '已有待办记录'
: scheduleDigests.length > 0
? `${scheduleDigests.length} 个服务号待办推送`
: '还没有安排'}
</span>
</div>
{scheduleDigests.length > 0 && (
<div className="mindspace-schedule-list">
{scheduleDigests.map((digest) => (
<article className="mindspace-schedule-item" key={digest.id}>
<time>{String(digest.hour).padStart(2, '0')}:{String(digest.minute).padStart(2, '0')}</time>
<span className={`mindspace-schedule-kind is-${digest.status === 'active' ? 'event' : 'reminder'}`}>
订阅
</span>
<div>
<strong>每天待办摘要</strong>
<small>{formatDigestSchedule(digest)}</small>
</div>
</article>
))}
</div>
)}
{scheduleItems.length > 0 ? (
<div className="mindspace-schedule-list">
{scheduleItems.map((item) => (
@@ -1859,6 +2078,33 @@ export function MindSpaceView({
</button>
))}
</div>
<div className="mindspace-asset-bulkbar">
<button
type="button"
onClick={toggleVisibleAssetsSelected}
disabled={visibleSelectableAssets.length === 0 || bulkDeletingAssets}
>
{allVisibleAssetsSelected ? '取消当前列表' : '选择当前列表'}
</button>
<span>已选 {selectedAssetIds.length} 项</span>
{selectedAssetIds.length > 0 && (
<button
type="button"
onClick={() => setSelectedAssetIds([])}
disabled={bulkDeletingAssets}
>
清空
</button>
)}
<button
type="button"
className="mindspace-asset-bulk-delete"
onClick={() => void removeSelectedAssets()}
disabled={selectedAssetIds.length === 0 || bulkDeletingAssets}
>
{bulkDeletingAssets ? '删除中…' : '删除选中'}
</button>
</div>
{visibleImageAssets.length > 0 && (
<>
{assetFilter === 'all' && (
@@ -1875,6 +2121,14 @@ export function MindSpaceView({
className={`mindspace-image-card${imagePaginationEnabled ? ' is-compact' : ''}`}
key={asset.id}
>
<label className="mindspace-asset-select">
<input
type="checkbox"
checked={selectedAssetSet.has(asset.id)}
onChange={() => toggleAssetSelected(asset.id)}
/>
<span>选择</span>
</label>
<button
type="button"
className="mindspace-image-thumb"
@@ -1941,6 +2195,14 @@ export function MindSpaceView({
className={`mindspace-item-card${isWorkAsset(asset) ? ' is-work' : ' is-file'}`}
key={asset.id}
>
<label className="mindspace-asset-select is-card">
<input
type="checkbox"
checked={selectedAssetSet.has(asset.id)}
onChange={() => toggleAssetSelected(asset.id)}
/>
<span>选择</span>
</label>
<button
type="button"
className="mindspace-item-card-main"
@@ -2184,7 +2446,7 @@ export function MindSpaceView({
</button>
</div>
<p className="mindspace-upload-dialog-desc">
支持 {UPLOAD_FILE_TYPE_LABEL},单文件最大 {formatBytes(MAX_UPLOAD_FILE_BYTES)}。
支持 {UPLOAD_FILE_TYPE_LABEL},单文件最大 {formatBytes(maxUploadFileBytes)}。
</p>
<input
type="file"
@@ -2337,6 +2599,107 @@ export function MindSpaceView({
</div>
) : null}
<MindSpaceModal
open={spacePurchaseOpen && Boolean(space)}
onClose={() => setSpacePurchaseOpen(false)}
title="购买空间"
eyebrow="SPACE UPGRADE"
className="mindspace-space-purchase-dialog"
disableClose={spacePurchasePending}
>
{space && (
<>
<p className="mindspace-upload-dialog-desc">
仅支持使用余额支付。购买成功后会立即同步后台空间配额,并在当前页面实时生效。
</p>
<div className="mindspace-space-purchase-summary">
<div>
<span>当前余额</span>
<strong>{formatYuan(currentBalanceCents)}</strong>
</div>
<div>
<span>当前剩余空间</span>
<strong>{formatBytes(space.quota.availableBytes)}</strong>
</div>
<div>
<span>购买价格</span>
<strong>{purchaseMbValue > 0 ? `${formatYuan(purchaseCostCents)} / ${purchaseMbValue} MB` : '请选择容量'}</strong>
</div>
</div>
<div className="mindspace-hero-purchase">
<div className="mindspace-hero-purchase-row">
{SPACE_PURCHASE_PRESETS_MB.map((sizeMb) => (
<button
key={sizeMb}
type="button"
className={`mindspace-hero-purchase-chip${purchaseMbValue === sizeMb ? ' is-active' : ''}`}
disabled={spacePurchasePending}
onClick={() => {
setSpacePurchaseMb(String(sizeMb));
setSpacePurchaseMessage(null);
}}
>
{sizeMb} MB
</button>
))}
</div>
<div className="mindspace-hero-purchase-row">
<input
className="mindspace-hero-purchase-input"
type="number"
min="1"
step="1"
value={spacePurchaseMb}
onChange={(event) => {
setSpacePurchaseMb(event.target.value);
setSpacePurchaseMessage(null);
}}
placeholder="自定义 MB"
/>
</div>
<p className="mindspace-hero-purchase-meta">
价格为每 1 MB = ¥2,购买后直接增加总容量,不展示在首页主区。
</p>
{spacePurchaseMessage && (
<p className="mindspace-hero-purchase-message">{spacePurchaseMessage}</p>
)}
</div>
{!canPurchaseSpace && purchaseMbValue > 0 && (
<div className="mindspace-space-purchase-recharge">
<span>当前余额不足以支付这次扩容。</span>
<button
type="button"
className="mindspace-secondary"
onClick={() => {
setSpacePurchaseOpen(false);
openRecharge(false);
}}
>
去余额充值
</button>
</div>
)}
<div className="mindspace-page-editor-actions">
<button
type="button"
onClick={() => setSpacePurchaseOpen(false)}
disabled={spacePurchasePending}
>
取消
</button>
<button
type="button"
className="mindspace-primary"
onClick={() => void submitSpacePurchase()}
disabled={spacePurchasePending || purchaseMbValue <= 0 || !canPurchaseSpace}
>
{spacePurchasePending ? '支付中…' : '确认购买'}
</button>
</div>
</>
)}
</MindSpaceModal>
<MindSpaceModal
open={Boolean(pageDeleteTarget)}
onClose={closePageDeleteDialog}