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
+2
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { checkAuth, logout, setUnauthorizedHandler } from './api/client';
import { loadBlockedWords } from './utils/wordFilter';
import { AuthView } from './components/AuthView';
import { ChatView } from './components/ChatView';
import { MindSpaceView } from './components/MindSpaceView';
@@ -130,6 +131,7 @@ export function App() {
setUser(status.user ?? null);
setCapabilities(status.capabilities);
setGrantedSkills(status.grantedSkills);
if (status.authenticated) void loadBlockedWords();
});
return () => setUnauthorizedHandler(null);
}, [mindSpacePreview, navigate]);
+166 -4
View File
@@ -42,6 +42,9 @@ import type {
SessionListResponse,
UsageRecord,
BalanceUpdate,
UserNotification,
PlanDefinition,
ActiveSubscription,
} from '../types';
import { CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES } from '../utils/imageUpload';
import { normalizeConversationMessages, normalizeUserMessageForApi } from '../utils/message';
@@ -209,7 +212,15 @@ async function portalFetch<T>(path: string, init?: RequestInit): Promise<T> {
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
const text = await res.text().catch(() => '');
if (text.trimStart().startsWith('<')) {
throw new ApiError(res.status, '服务器返回了意外的页面,请确认后端服务已启动并已重启');
}
try {
return JSON.parse(text) as T;
} catch {
throw new ApiError(res.status, '服务器响应格式错误');
}
}
function formatNetworkError(err: unknown) {
@@ -259,7 +270,15 @@ async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
const rawText = await res.text().catch(() => '');
if (rawText.trimStart().startsWith('<')) {
throw new ApiError(res.status, '服务器返回了意外的页面,请确认后端服务已启动并已重启');
}
try {
return JSON.parse(rawText) as T;
} catch {
throw new ApiError(res.status, '服务器响应格式错误');
}
}
export async function startSession(): Promise<Session> {
@@ -407,6 +426,31 @@ export async function getBillingConfig(): Promise<BillingConfig> {
return portalFetch('/auth/billing/config');
}
export async function getAvailablePlans(): Promise<{
plans: PlanDefinition[];
subscription: ActiveSubscription | null;
balanceCents: number;
}> {
return portalFetch('/auth/billing/plans');
}
export async function purchaseSubscription(planType: string, autoRenew = false): Promise<{
subscription: ActiveSubscription;
balanceCents: number;
}> {
return portalFetch('/auth/billing/subscribe', {
method: 'POST',
body: JSON.stringify({ planType, autoRenew }),
});
}
export async function setAutoRenew(enabled: boolean): Promise<{ ok: boolean; updated: boolean }> {
return portalFetch('/auth/billing/auto-renew', {
method: 'POST',
body: JSON.stringify({ enabled }),
});
}
export async function createRechargeOrder(input: {
amountCents: number;
payScene: 'native' | 'h5' | 'jsapi';
@@ -425,6 +469,18 @@ export async function getRechargeOrder(orderId: string): Promise<{
return portalFetch(`/auth/billing/recharge-orders/${orderId}`);
}
export async function purchaseSpaceQuota(sizeMb: number): Promise<{
quota: MindSpaceQuota;
balanceCents: number;
purchasedMb: number;
costCents: number;
}> {
return portalFetch('/auth/billing/space-purchase', {
method: 'POST',
body: JSON.stringify({ sizeMb }),
});
}
export async function getMindSpace(): Promise<MindSpace> {
const result = await apiFetch<{ data: MindSpace }>('/mindspace/v1/space');
return result.data;
@@ -965,6 +1021,32 @@ export async function redactMindSpacePage(
return result.data;
}
export async function fixMindSpacePagePublication(
pageId: string,
input: {
pageVersionId: string;
expectedVersion: number;
title: string;
summary: string;
content: string;
},
): Promise<MindSpaceRedactedCopyResult> {
const result = await apiFetch<{ data: MindSpaceRedactedCopyResult }>(
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/publish-fix`,
{
method: 'POST',
body: JSON.stringify({
page_version_id: input.pageVersionId,
expected_version: input.expectedVersion,
title: input.title,
summary: input.summary,
content: input.content,
}),
},
);
return result.data;
}
/** @deprecated use redactMindSpacePage */
export async function createMindSpaceRedactedCopy(
pageId: string,
@@ -1418,6 +1500,79 @@ export async function getWechatBindingStatus(): Promise<WechatBindingStatus> {
}
}
export async function listNotifications(status = 'unread', limit = 20): Promise<UserNotification[]> {
const params = new URLSearchParams();
if (status && status !== 'all') params.set('status', status);
params.set('limit', String(limit));
const response = await fetch(`/auth/notifications?${params}`);
if (!response.ok) {
throw new ApiError(response.status, '读取通知失败');
}
const body = (await response.json()) as { notifications?: UserNotification[] };
return body.notifications ?? [];
}
export function subscribeNotificationEvents({
onNotification,
onSync,
}: {
onNotification: (notification: UserNotification) => void;
onSync?: () => void;
}): () => void {
const source = new EventSource('/auth/notifications/events', { withCredentials: true });
source.addEventListener('notification', (event) => {
try {
const payload = JSON.parse((event as MessageEvent).data) as {
notification?: UserNotification;
};
if (payload.notification) onNotification(payload.notification);
} catch {
onSync?.();
}
});
source.addEventListener('sync', () => onSync?.());
source.onerror = () => {
// EventSource reconnects automatically; consumers can keep their last state.
};
return () => source.close();
}
export async function markNotificationRead(notificationId: string): Promise<void> {
const response = await fetch(`/auth/notifications/${encodeURIComponent(notificationId)}/read`, {
method: 'POST',
});
if (!response.ok) {
throw new ApiError(response.status, '通知状态更新失败');
}
}
export async function markAllNotificationsRead(): Promise<void> {
const response = await fetch('/auth/notifications/read-all', {
method: 'POST',
});
if (!response.ok) {
throw new ApiError(response.status, '全部已读失败');
}
}
export async function deleteNotification(notificationId: string): Promise<void> {
const response = await fetch(`/auth/notifications/${encodeURIComponent(notificationId)}`, {
method: 'DELETE',
});
if (!response.ok && response.status !== 204) {
throw new ApiError(response.status, '删除通知失败');
}
}
export async function clearNotifications(status = 'all'): Promise<void> {
const response = await fetch(`/auth/notifications?status=${encodeURIComponent(status)}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new ApiError(response.status, '清空通知失败');
}
}
export async function getWechatAgentRouteStatus(): Promise<WechatAgentRouteStatus> {
try {
const response = await fetch('/auth/wechat/agent-route');
@@ -1820,11 +1975,18 @@ export async function deleteChatSession(sessionId: string): Promise<void> {
await apiFetch(`/sessions/${sessionId}`, { method: 'DELETE' });
}
export async function loadSessionDetail(sessionId: string): Promise<{
export async function loadSessionDetail(
sessionId: string,
hints?: { messageCount?: number; updatedAt?: string },
): Promise<{
session: Session;
messages: Message[];
}> {
const detail = await getSession(sessionId);
const params = new URLSearchParams();
if (hints?.messageCount != null) params.set('hint_mc', String(hints.messageCount));
if (hints?.updatedAt) params.set('hint_ua', hints.updatedAt);
const qs = params.size ? `?${params.toString()}` : '';
const detail = await apiFetch<Session>(`/sessions/${sessionId}${qs}`);
const messages = normalizeConversationMessages(
(detail.conversation ?? []).filter((m) => m.metadata?.userVisible),
);
+229 -42
View File
@@ -3,6 +3,8 @@ import { useEffect, useRef, useState, type CSSProperties } from 'react';
const RING_R = 16;
const CIRC = 2 * Math.PI * RING_R;
const POPOVER_WIDTH = 260;
const HEADER_POPOVER_OPEN_EVENT = 'tkmind:header-popover-open';
const HEADER_POPOVER_ID = 'balance-ring';
function formatYuan(cents: number) {
return `¥${(cents / 100).toFixed(2)}`;
@@ -21,33 +23,101 @@ function formatTokenCount(tokens: number) {
return tokens.toLocaleString('zh-CN');
}
function formatCallsApprox(tokens: number) {
const calls = Math.floor(tokens / 3000);
return `${calls}`;
}
type ActiveSubscription = {
planType: string;
periodTokensLimit: number;
periodTokensUsed: number;
periodEnd: number;
overageRate: number;
};
type BalanceRingProps = {
balanceCents: number;
totalCreditCents?: number;
tokensUsed?: number;
subscription?: ActiveSubscription | null;
onRecharge: (force?: boolean) => void;
onSubscribe?: () => void;
};
const PLAN_LABELS: Record<string, string> = {
free: '免费版',
lite: '轻量版',
standard: '标准版',
pro: '专业版',
};
export function BalanceRing({
balanceCents,
totalCreditCents,
tokensUsed = 0,
subscription,
onRecharge,
onSubscribe,
}: BalanceRingProps) {
const [open, setOpen] = useState(false);
const [popoverStyle, setPopoverStyle] = useState<CSSProperties>({});
const wrapRef = useRef<HTMLDivElement>(null);
// Subscription quota mode: only paid plans change the ring display.
// Free plan runs silently in the background — balance ring stays primary.
const isPaidPlan = Boolean(subscription) && subscription!.planType !== 'free';
const hasSub = isPaidPlan;
const subUnlimited = hasSub && subscription!.periodTokensLimit === 0;
const subLimit = subscription?.periodTokensLimit ?? 0;
const subUsed = subscription?.periodTokensUsed ?? 0;
const subRemaining = Math.max(0, subLimit - subUsed);
const subPct = subLimit > 0 ? Math.round((subRemaining / subLimit) * 100) : 100;
const subLow = hasSub && !subUnlimited && subPct <= 15;
const subEmpty = hasSub && !subUnlimited && subRemaining <= 0;
// Balance mode (used when no active subscription or overage).
const total = Math.max(totalCreditCents ?? balanceCents, balanceCents, 0);
const spent = Math.max(0, total - balanceCents);
const pct = total > 0 ? Math.round((balanceCents / total) * 100) : 0;
const balancePct = total > 0 ? Math.round((balanceCents / total) * 100) : 0;
const spentLen = total > 0 ? (spent / total) * CIRC : 0;
const remainLen = total > 0 ? (balanceCents / total) * CIRC : 0;
const spentPct = total > 0 ? (spent / total) * 100 : 0;
const remainPct = total > 0 ? (balanceCents / total) * 100 : 0;
const balanceLow = balanceCents > 0 && balanceCents <= 100;
const balanceEmpty = balanceCents <= 0;
const low = balanceCents > 0 && balanceCents <= 100;
const empty = balanceCents <= 0;
// Ring display: subscription quota takes priority over balance.
let ringRemainLen: number;
let ringSpentLen: number;
let centerLabel: string;
let ariaLabel: string;
let low: boolean;
let empty: boolean;
if (hasSub && !subUnlimited) {
const usedLen = subLimit > 0 ? (subUsed / subLimit) * CIRC : 0;
ringRemainLen = subLimit > 0 ? (subRemaining / subLimit) * CIRC : CIRC;
ringSpentLen = usedLen;
centerLabel = subEmpty ? '0%' : `${subPct}%`;
ariaLabel = `本月额度,剩余 ${subPct}%`;
low = subLow;
empty = subEmpty;
} else if (hasSub && subUnlimited) {
ringRemainLen = CIRC;
ringSpentLen = 0;
centerLabel = '∞';
ariaLabel = '专业版,不限额度';
low = false;
empty = false;
} else {
ringRemainLen = remainLen;
ringSpentLen = spentLen;
centerLabel = total <= 0 ? '—' : balanceEmpty ? '0%' : `${balancePct}%`;
ariaLabel = total > 0 ? `账户余额,剩余 ${balancePct}%` : '账户余额';
low = balanceLow;
empty = balanceEmpty;
}
useEffect(() => {
if (!open) return;
@@ -60,6 +130,17 @@ export function BalanceRing({
return () => document.removeEventListener('click', handleClick);
}, [open]);
useEffect(() => {
const handleOtherPopoverOpen = (event: Event) => {
const detail = (event as CustomEvent<{ id?: string }>).detail;
if (detail?.id && detail.id !== HEADER_POPOVER_ID) {
setOpen(false);
}
};
window.addEventListener(HEADER_POPOVER_OPEN_EVENT, handleOtherPopoverOpen);
return () => window.removeEventListener(HEADER_POPOVER_OPEN_EVENT, handleOtherPopoverOpen);
}, []);
useEffect(() => {
if (!open) return;
@@ -87,8 +168,9 @@ export function BalanceRing({
};
}, [open]);
const centerLabel = total <= 0 ? '—' : empty ? '0%' : `${pct}%`;
const ariaLabel = total > 0 ? `账户额度,剩余 ${pct}%` : '账户额度';
const periodEndDate = subscription
? new Date(subscription.periodEnd).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
: null;
return (
<div className={`balance-popover-wrap${open ? ' open' : ''}`} ref={wrapRef}>
@@ -99,7 +181,17 @@ export function BalanceRing({
aria-expanded={open}
onClick={(event) => {
event.stopPropagation();
setOpen((value) => !value);
setOpen((value) => {
const next = !value;
if (next) {
window.dispatchEvent(
new CustomEvent(HEADER_POPOVER_OPEN_EVENT, {
detail: { id: HEADER_POPOVER_ID },
}),
);
}
return next;
});
}}
>
<svg className="balance-ring-svg" viewBox="0 0 40 40" aria-hidden="true">
@@ -109,7 +201,7 @@ export function BalanceRing({
cx="20"
cy="20"
r={RING_R}
strokeDasharray={`${spentLen} ${CIRC}`}
strokeDasharray={`${ringSpentLen} ${CIRC}`}
strokeDashoffset={0}
/>
<circle
@@ -117,37 +209,116 @@ export function BalanceRing({
cx="20"
cy="20"
r={RING_R}
strokeDasharray={`${remainLen} ${CIRC}`}
strokeDashoffset={-spentLen}
strokeDasharray={`${ringRemainLen} ${CIRC}`}
strokeDashoffset={-ringSpentLen}
/>
</svg>
<span className="balance-ring-center">{centerLabel}</span>
</button>
<div className="balance-popover" role="dialog" aria-label="账户额度" style={popoverStyle}>
<h4></h4>
<div className="balance-popover-row">
<span className="balance-popover-label">
<span className="balance-dot balance-dot-remain" aria-hidden="true" />
</span>
<strong>{formatYuan(balanceCents)}</strong>
</div>
<div className="balance-popover-row">
<span className="balance-popover-label">
<span className="balance-dot balance-dot-spent" aria-hidden="true" />
</span>
<strong>{formatYuan(spent)}</strong>
</div>
<div className="balance-popover-row">
<span className="balance-popover-label balance-popover-label-muted"></span>
<span>{formatYuan(total)}</span>
</div>
<div className="balance-popover-bar" aria-hidden="true">
<div className="balance-popover-bar-spent" style={{ width: `${spentPct}%` }} />
<div className="balance-popover-bar-remain" style={{ width: `${remainPct}%` }} />
</div>
{hasSub ? (
<>
<h4>
{PLAN_LABELS[subscription!.planType] ?? subscription!.planType}
{subscription!.planType !== 'free' && (
<span className="balance-plan-badge"></span>
)}
</h4>
{subUnlimited ? (
<div className="balance-popover-row">
<span className="balance-popover-label balance-popover-label-muted"></span>
<span></span>
</div>
) : (
<>
<div className="balance-popover-row">
<span className="balance-popover-label">
<span className="balance-dot balance-dot-remain" aria-hidden="true" />
</span>
<strong>{formatCallsApprox(subRemaining)}</strong>
</div>
<div className="balance-popover-row">
<span className="balance-popover-label">
<span className="balance-dot balance-dot-spent" aria-hidden="true" />
使
</span>
<strong>{formatCallsApprox(subUsed)}</strong>
</div>
<div className="balance-popover-bar" aria-hidden="true">
<div
className="balance-popover-bar-spent"
style={{ width: `${subLimit > 0 ? (subUsed / subLimit) * 100 : 0}%` }}
/>
<div
className="balance-popover-bar-remain"
style={{ width: `${subLimit > 0 ? (subRemaining / subLimit) * 100 : 0}%` }}
/>
</div>
</>
)}
{periodEndDate && (
<p className="balance-popover-hint"> {periodEndDate}</p>
)}
<div className="balance-popover-section">
<h5></h5>
<div className="balance-popover-row">
<span className="balance-popover-label balance-popover-label-muted"></span>
<span>{formatYuan(balanceCents)}</span>
</div>
{subscription!.overageRate < 1 && (
<p className="balance-popover-hint">
{Math.round(subscription!.overageRate * 10)}
</p>
)}
</div>
{(subEmpty || subLow) && (
<p className="balance-popover-warning">
{subEmpty ? '本月额度已用完' : '额度即将耗尽'}
{balanceCents > 0 ? ',将从余额扣费' : ',请充值继续使用'}
</p>
)}
</>
) : (
<>
<h4></h4>
<div className="balance-popover-row">
<span className="balance-popover-label">
<span className="balance-dot balance-dot-remain" aria-hidden="true" />
</span>
<strong>{formatYuan(balanceCents)}</strong>
</div>
<div className="balance-popover-row">
<span className="balance-popover-label">
<span className="balance-dot balance-dot-spent" aria-hidden="true" />
</span>
<strong>{formatYuan(spent)}</strong>
</div>
<div className="balance-popover-row">
<span className="balance-popover-label balance-popover-label-muted"></span>
<span>{formatYuan(total)}</span>
</div>
<div className="balance-popover-bar" aria-hidden="true">
<div className="balance-popover-bar-spent" style={{ width: `${spentPct}%` }} />
<div className="balance-popover-bar-remain" style={{ width: `${remainPct}%` }} />
</div>
{(balanceLow || balanceEmpty) && (
<p className="balance-popover-warning"></p>
)}
{subscription?.planType === 'free' && subscription.periodTokensLimit > 0 && (
<p className="balance-popover-hint">
{formatCallsApprox(Math.max(0, subscription.periodTokensLimit - subscription.periodTokensUsed))}
</p>
)}
</>
)}
<div className="balance-popover-section">
<h5>AI </h5>
@@ -158,16 +329,32 @@ export function BalanceRing({
<p className="balance-popover-hint"></p>
</div>
<button
type="button"
className="balance-popover-cta"
onClick={() => {
setOpen(false);
onRecharge(empty);
}}
>
</button>
<div style={{ display: 'flex', gap: 8 }}>
{onSubscribe && (
<button
type="button"
className="balance-popover-cta"
onClick={() => {
setOpen(false);
onSubscribe();
}}
style={{ flex: 1 }}
>
{hasSub && subscription!.planType !== 'free' ? '管理套餐' : '升级套餐'}
</button>
)}
<button
type="button"
className="balance-popover-cta"
onClick={() => {
setOpen(false);
onRecharge(empty);
}}
style={onSubscribe ? { flex: 1 } : {}}
>
</button>
</div>
</div>
</div>
);
+26 -1
View File
@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { WechatAccountButton } from './WechatAccountButton';
const HEADER_POPOVER_OPEN_EVENT = 'tkmind:header-popover-open';
const HEADER_POPOVER_ID = 'header-more-menu';
type MenuItem = {
id: string;
@@ -39,6 +41,17 @@ export function ChatHeaderMoreMenu({
};
}, [open]);
useEffect(() => {
const handleOtherPopoverOpen = (event: Event) => {
const detail = (event as CustomEvent<{ id?: string }>).detail;
if (detail?.id && detail.id !== HEADER_POPOVER_ID) {
setOpen(false);
}
};
window.addEventListener(HEADER_POPOVER_OPEN_EVENT, handleOtherPopoverOpen);
return () => window.removeEventListener(HEADER_POPOVER_OPEN_EVENT, handleOtherPopoverOpen);
}, []);
return (
<div className={`header-more-menu${open ? ' open' : ''}`} ref={wrapRef}>
<button
@@ -47,7 +60,19 @@ export function ChatHeaderMoreMenu({
aria-label="更多操作"
aria-expanded={open}
aria-haspopup="menu"
onClick={() => setOpen((prev) => !prev)}
onClick={() =>
setOpen((prev) => {
const next = !prev;
if (next) {
window.dispatchEvent(
new CustomEvent(HEADER_POPOVER_OPEN_EVENT, {
detail: { id: HEADER_POPOVER_ID },
}),
);
}
return next;
})
}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="12" cy="5" r="1.75" />
+1
View File
@@ -325,6 +325,7 @@ export function ChatPanel({
streaming={chatState === 'streaming'}
onAvatarClick={compact ? undefined : openAvatarPicker}
onSaveAsPage={(message) => setPageSource(message)}
publishUserId={user?.id}
publishUsername={user?.username}
compact={compact}
/>
+10 -1
View File
@@ -12,6 +12,7 @@ import { ChatPanel } from './ChatPanel';
import { WechatBindPrompt } from './WechatBindPrompt';
import { WechatAccountButton } from './WechatAccountButton';
import { ChatHeaderMoreMenu } from './ChatHeaderMoreMenu';
import { NotificationCenter } from './NotificationCenter';
import type { MindSpaceSaveCategory } from '../types';
export function ChatView({
@@ -56,7 +57,9 @@ export function ChatView({
balanceCents,
totalCreditCents,
tokensUsed,
subscription,
openRecharge,
openSubscribe,
uploadChatImage,
} = useChat();
const online = useNetworkStatus();
@@ -148,9 +151,12 @@ export function ChatView({
balanceCents={balanceCents}
totalCreditCents={totalCreditCents}
tokensUsed={tokensUsed}
subscription={subscription}
onRecharge={openRecharge}
onSubscribe={openSubscribe}
/>
)}
<NotificationCenter onOpenRecharge={() => openRecharge(false)} />
{onOpenAdmin && (
<button type="button" className="ghost-btn" onClick={onOpenAdmin}>
@@ -196,9 +202,12 @@ export function ChatView({
balanceCents={balanceCents}
totalCreditCents={totalCreditCents}
tokensUsed={tokensUsed}
subscription={subscription}
onRecharge={openRecharge}
onSubscribe={openSubscribe}
/>
)}
<NotificationCenter onOpenRecharge={() => openRecharge(false)} />
{onOpenSpace && (
<button
type="button"
@@ -251,7 +260,7 @@ export function ChatView({
{notice && (
<div className={`banner${notice === INSUFFICIENT_BALANCE_NOTICE ? ' banner-warning' : ' banner-info'}`}>
<span>{notice}</span>
<span style={{ whiteSpace: 'pre-line' }}>{notice}</span>
{notice === INSUFFICIENT_BALANCE_NOTICE ? (
<>
<button type="button" className="banner-action" onClick={() => openRecharge(false)}>
+9 -2
View File
@@ -4,6 +4,7 @@ import type { Message } from '../types';
import { getDisplayText, getImageUrls, getThinking } from '../utils/message';
import { getMessageSaveActions } from '../utils/messageSave';
import { renderMarkdown } from '../utils/markdown';
import { filterText } from '../utils/wordFilter';
import { formatChatTime, shouldShowTimestamp } from '../utils/time';
import { TKMindAvatar } from './TKMindAvatar';
import { UserAvatar } from './UserAvatar';
@@ -123,6 +124,7 @@ function MessageRow({
onAvatarClick,
onSaveAsPage,
saveDisabled,
publishUserId,
publishUsername,
compact = false,
}: {
@@ -131,12 +133,14 @@ function MessageRow({
onAvatarClick?: () => void;
onSaveAsPage?: (message: Message) => void;
saveDisabled?: boolean;
publishUserId?: string;
publishUsername?: string;
compact?: boolean;
}) {
const [actionsOpen, setActionsOpen] = useState(false);
const text = getDisplayText(message);
const saveActions = getMessageSaveActions(text, publishUsername);
const rawText = getDisplayText(message);
const text = filterText(rawText);
const saveActions = getMessageSaveActions(text, { userId: publishUserId, username: publishUsername });
const thinking = getThinking(message);
const isUser = message.role === 'user';
const imageUrls = getImageUrls(message);
@@ -283,6 +287,7 @@ export function MessageList({
streaming,
onAvatarClick,
onSaveAsPage,
publishUserId,
publishUsername,
compact = false,
}: {
@@ -290,6 +295,7 @@ export function MessageList({
streaming: boolean;
onAvatarClick?: () => void;
onSaveAsPage?: (message: Message) => void;
publishUserId?: string;
publishUsername?: string;
compact?: boolean;
}) {
@@ -316,6 +322,7 @@ export function MessageList({
onAvatarClick={onAvatarClick}
onSaveAsPage={onSaveAsPage}
saveDisabled={streaming}
publishUserId={publishUserId}
publishUsername={publishUsername}
compact={compact}
/>
+21 -9
View File
@@ -2,8 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { usePageDraftHistory, type PageDraftSnapshot } from '../hooks/usePageDraftHistory';
import {
checkMindSpacePagePublication,
redactMindSpacePage,
deleteMindSpacePage,
fixMindSpacePagePublication,
getMindSpacePageDeletePreview,
getMindSpacePage,
getMindSpacePublicationStats,
@@ -11,6 +11,7 @@ import {
offlineMindSpacePublication,
publishMindSpacePage,
publishPageToPlaza,
redactMindSpacePage,
updateMindSpacePage,
} from '../api/client';
import type {
@@ -628,13 +629,24 @@ export function MindSpacePageDetail({
setError(null);
setFixNotice(null);
try {
const result = await redactMindSpacePage(page.id, {
pageVersionId: page.currentVersionId,
expectedVersion: page.versionNo,
title,
summary,
content,
});
let result;
try {
result = await fixMindSpacePagePublication(page.id, {
pageVersionId: page.currentVersionId,
expectedVersion: page.versionNo,
title,
summary,
content,
});
} catch {
result = await redactMindSpacePage(page.id, {
pageVersionId: page.currentVersionId,
expectedVersion: page.versionNo,
title,
summary,
content,
});
}
const updatedPage = result.page;
applyPageRecord(updatedPage, { recordHistory: true });
setFixNotice(result.changes);
@@ -1333,7 +1345,7 @@ export function MindSpacePageDetail({
onFrameReady={setPreviewFrame}
reloadKey={previewKey}
minHeight={680}
maxHeight={12000}
maxHeight={Number.POSITIVE_INFINITY}
/>
)}
{page.contentFormat === 'html' && (
@@ -130,7 +130,7 @@ export function MindSpacePageEditablePreviewFrame({
ref={iframeRef}
title={`${title || '页面'} 预览`}
srcDoc={srcdoc}
sandbox="allow-same-origin allow-scripts"
sandbox="allow-scripts"
scrolling={scrollInsideFrame ? 'yes' : 'no'}
className={frameClassName}
style={scrollInsideFrame ? { height: '100%' } : { height: `${height}px` }}
@@ -51,6 +51,8 @@ export function MindSpacePagePreviewPanel({
const [thumbNotice, setThumbNotice] = useState<string | null>(null);
const [thumbError, setThumbError] = useState<string | null>(null);
const uploadInputRef = useRef<HTMLInputElement>(null);
const thumbnailRefreshTokenRef = useRef(0);
const latestDraftRef = useRef({ title, summary, content });
const thumbnailUrl = `/api/mindspace/v1/pages/${pageId}/thumbnail?v=${thumbnailVersion}`;
@@ -62,6 +64,38 @@ export function MindSpacePagePreviewPanel({
setThumbnailFailed(false);
}, [thumbnailUrl]);
useEffect(() => {
latestDraftRef.current = { title, summary, content };
}, [content, summary, title]);
useEffect(() => {
let cancelled = false;
const refreshToken = ++thumbnailRefreshTokenRef.current;
const { title: draftTitle, summary: draftSummary, content: draftContent } = latestDraftRef.current;
const syncThumbnail = async () => {
try {
const result = await regenerateMindSpacePageThumbnail(pageId, {
title: draftTitle,
summary: draftSummary,
content: draftContent,
});
if (cancelled || thumbnailRefreshTokenRef.current !== refreshToken) return;
setThumbnailVersion(result.updatedAt ?? Date.now());
setThumbnailFailed(false);
} catch {
if (cancelled || thumbnailRefreshTokenRef.current !== refreshToken) return;
setThumbnailFailed(false);
}
};
void syncThumbnail();
return () => {
cancelled = true;
};
}, [pageId, reloadKey]);
const bumpThumbnail = () => {
setThumbnailVersion(Date.now());
setThumbnailFailed(false);
+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}
+270
View File
@@ -0,0 +1,270 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import {
clearNotifications,
deleteNotification,
listNotifications,
markAllNotificationsRead,
markNotificationRead,
subscribeNotificationEvents,
} from '../api/client';
import type { UserNotification } from '../types';
const POPOVER_WIDTH = 340;
const HEADER_POPOVER_OPEN_EVENT = 'tkmind:header-popover-open';
const HEADER_POPOVER_ID = 'notification-center';
function formatTime(timestamp: number) {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return '';
return `${date.getMonth() + 1}/${date.getDate()} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
}
type Filter = 'all' | 'unread' | 'read';
function parseNotificationLines(body: string) {
return body
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
}
export function NotificationCenter({
onOpenRecharge,
}: {
onOpenRecharge?: () => void;
}) {
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState<Filter>('all');
const [items, setItems] = useState<UserNotification[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [popoverStyle, setPopoverStyle] = useState<CSSProperties>({});
const wrapRef = useRef<HTMLDivElement>(null);
const unreadCount = useMemo(
() => items.reduce((count, item) => count + (item.status === 'unread' ? 1 : 0), 0),
[items],
);
const load = async (nextFilter = filter) => {
setLoading(true);
setError(null);
try {
const notifications = await listNotifications(nextFilter, 50);
setItems(notifications);
} catch (err) {
setError(err instanceof Error ? err.message : '读取通知失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
const syncAll = () => {
if (document.visibilityState === 'visible') {
void load('all');
}
};
void load('all');
const unsubscribe = subscribeNotificationEvents({
onNotification: () => void load('all'),
onSync: () => void load('all'),
});
window.addEventListener('focus', syncAll);
document.addEventListener('visibilitychange', syncAll);
return () => {
unsubscribe();
window.removeEventListener('focus', syncAll);
document.removeEventListener('visibilitychange', syncAll);
};
}, []);
useEffect(() => {
if (!open) return;
const handleClick = (event: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
setOpen(false);
}
};
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, [open]);
useEffect(() => {
const handleOtherPopoverOpen = (event: Event) => {
const detail = (event as CustomEvent<{ id?: string }>).detail;
if (detail?.id && detail.id !== HEADER_POPOVER_ID) {
setOpen(false);
}
};
window.addEventListener(HEADER_POPOVER_OPEN_EVENT, handleOtherPopoverOpen);
return () => window.removeEventListener(HEADER_POPOVER_OPEN_EVENT, handleOtherPopoverOpen);
}, []);
useEffect(() => {
if (!open) return;
const updatePosition = () => {
const anchor = wrapRef.current;
if (!anchor) return;
const rect = anchor.getBoundingClientRect();
const width = Math.min(POPOVER_WIDTH, window.innerWidth - 24);
let left = rect.left + rect.width / 2 - width / 2;
left = Math.max(12, Math.min(left, window.innerWidth - width - 12));
setPopoverStyle({
position: 'fixed',
top: rect.bottom + 10,
left,
width,
});
};
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [open]);
const visibleItems = filter === 'all' ? items : items.filter((item) => item.status === filter);
return (
<div className={`notification-center${open ? ' open' : ''}`} ref={wrapRef}>
<button
type="button"
className="header-icon-btn notification-center-trigger"
aria-label="通知中心"
aria-expanded={open}
onClick={() => {
setOpen((value) => {
const next = !value;
if (next) {
window.dispatchEvent(
new CustomEvent(HEADER_POPOVER_OPEN_EVENT, {
detail: { id: HEADER_POPOVER_ID },
}),
);
}
return next;
});
if (!open) void load(filter);
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M12 4a5 5 0 00-5 5v2.7c0 .5-.18.97-.5 1.33L5 15h14l-1.5-1.97a2.1 2.1 0 01-.5-1.33V9a5 5 0 00-5-5z"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M10 18a2 2 0 004 0" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
</svg>
{unreadCount > 0 && <span className="notification-center-badge">{unreadCount > 99 ? '99+' : unreadCount}</span>}
</button>
{open && (
<div className="notification-center-popover" role="dialog" aria-label="通知中心" style={popoverStyle}>
<div className="notification-center-head">
<div>
<h4></h4>
<p>{unreadCount > 0 ? `${unreadCount} 条未读` : '通知已处理完'}</p>
</div>
<div className="notification-center-actions">
<button type="button" className="ghost-btn" onClick={() => void markAllNotificationsRead().then(() => load(filter))}>
</button>
<button
type="button"
className="notification-center-clear-all-btn"
onClick={() => void clearNotifications(filter).then(() => load(filter))}
>
</button>
</div>
</div>
<div className="notification-center-filters">
{(['all', 'unread', 'read'] as Filter[]).map((value) => (
<button
key={value}
type="button"
className={`notification-center-filter${filter === value ? ' is-active' : ''}`}
onClick={() => {
setFilter(value);
void load(value);
}}
>
{value === 'all' ? '全部' : value === 'unread' ? '未读' : '已读'}
</button>
))}
</div>
{loading ? <p className="notification-center-state"></p> : null}
{error ? <p className="notification-center-state is-error">{error}</p> : null}
{!loading && !error && visibleItems.length === 0 ? <p className="notification-center-state"></p> : null}
<div className="notification-center-list">
{visibleItems.map((item) => (
<article key={item.id} className={`notification-center-item${item.status === 'unread' ? ' is-unread' : ''}`}>
<div className="notification-center-item-head">
<strong>{item.title}</strong>
<span>{formatTime(item.createdAt)}</span>
</div>
{item.notificationType === 'todo_digest' ? (
<div className="notification-center-digest">
{parseNotificationLines(item.body).map((line, index) => (
<div
key={`${item.id}-${index}`}
className={`notification-center-digest-line${index === 0 ? ' is-title' : ''}`}
>
{line}
</div>
))}
</div>
) : (
<p>{item.body}</p>
)}
<div className="notification-center-item-actions">
{item.status === 'unread' ? (
<button
type="button"
className="notification-center-read-btn"
onClick={() => void markNotificationRead(item.id).then(() => load(filter))}
>
</button>
) : (
<span className="notification-center-read-tag"></span>
)}
{item.notificationType === 'balance_low' && onOpenRecharge ? (
<button
type="button"
className="ghost-btn"
onClick={() => {
if (item.status === 'unread') {
void markNotificationRead(item.id).then(() => load(filter));
}
setOpen(false);
onOpenRecharge();
}}
>
</button>
) : null}
<button
type="button"
className="notification-center-clear-btn"
onClick={() => void deleteNotification(item.id).then(() => load(filter))}
>
</button>
</div>
</article>
))}
</div>
</div>
)}
</div>
);
}
+1 -1
View File
@@ -112,7 +112,7 @@ export function PageSavePreviewPanel({
title="页面预览"
src={pagePreviewUrl}
className="page-save-mini-page-frame"
sandbox="allow-same-origin allow-scripts"
sandbox="allow-scripts"
scrolling="yes"
onError={() => setPreviewFailed(true)}
/>
+1 -1
View File
@@ -194,7 +194,7 @@ export function RechargeModal({
<div className="recharge-dialog-bar">
<div>
<p className="recharge-eyebrow"></p>
<h3 id="recharge-title"></h3>
<h3 id="recharge-title">{balanceCents <= 0 ? '余额不足,请充值后继续' : '为账户充值'}</h3>
</div>
{!force && (
<button type="button" className="recharge-close" onClick={onClose} aria-label="关闭">
+287
View File
@@ -0,0 +1,287 @@
import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { getAvailablePlans, purchaseSubscription, setAutoRenew } from '../api/client';
import type { ActiveSubscription, PlanDefinition } from '../types';
// Plan tier order (matches PLAN_ORDER in billing-subscription.mjs)
const PLAN_ORDER: Record<string, number> = { free: 0, lite: 1, standard: 2, pro: 3 };
function formatYuan(cents: number) {
return `¥${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2)}`;
}
function formatTokens(tokens: number) {
if (tokens === 0) return '不限';
if (tokens >= 10_000) return `${(tokens / 10_000).toFixed(0)}`;
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(0)}k`;
return tokens.toLocaleString('zh-CN');
}
function callsApprox(tokens: number) {
if (tokens === 0) return '不限';
return `${Math.floor(tokens / 3_000).toLocaleString()}`;
}
function imageCallsApprox(images: number) {
if (images === 0) return '不限';
return `${images.toLocaleString()}`;
}
const MODEL_TIER_LABELS: Record<string, string> = {
basic: '基础模型',
standard: '标准模型',
premium: '旗舰模型',
};
const PLAN_HIGHLIGHT: Record<string, string> = {
lite: '',
standard: '推荐',
pro: '旗舰',
};
type SubscribeModalProps = {
open: boolean;
onClose: () => void;
onSuccess: (subscription: ActiveSubscription, balanceCents: number) => void;
onRechargeNeeded: (shortfallCents: number) => void;
};
export function SubscribeModal({ open, onClose, onSuccess, onRechargeNeeded }: SubscribeModalProps) {
const [plans, setPlans] = useState<PlanDefinition[]>([]);
const [currentSub, setCurrentSub] = useState<ActiveSubscription | null>(null);
const [balanceCents, setBalanceCents] = useState(0);
const [loading, setLoading] = useState(false);
const [purchasing, setPurchasing] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [shortfall, setShortfall] = useState<{ planType: string; shortfallCents: number } | null>(null);
const [autoRenew, setAutoRenewState] = useState(false);
const [togglingAutoRenew, setTogglingAutoRenew] = useState(false);
const reset = useCallback(() => {
setPurchasing(null);
setError(null);
setSuccessMessage(null);
setShortfall(null);
}, []);
useEffect(() => {
if (!open) { reset(); return; }
setLoading(true);
setError(null);
void getAvailablePlans()
.then((data) => {
setPlans(data.plans);
setCurrentSub(data.subscription);
setBalanceCents(data.balanceCents);
setAutoRenewState(data.subscription?.autoRenew ?? false);
})
.catch((err) => setError(err instanceof Error ? err.message : '无法加载套餐列表'))
.finally(() => setLoading(false));
}, [open, reset]);
const handlePurchase = async (planType: string) => {
setPurchasing(planType);
setError(null);
setShortfall(null);
setSuccessMessage(null);
try {
const result = await purchaseSubscription(planType, autoRenew);
const plan = plans.find((p) => p.key === planType);
const renewNote = autoRenew ? ',已开启自动续费' : '';
setSuccessMessage(`已开通 ${plan?.name ?? planType}${renewNote},余额剩余 ${formatYuan(result.balanceCents)}`);
setCurrentSub(result.subscription);
setBalanceCents(result.balanceCents);
setAutoRenewState(result.subscription.autoRenew);
window.setTimeout(() => onSuccess(result.subscription, result.balanceCents), 1000);
} catch (err: unknown) {
type E = { status?: number; code?: string; message?: string; details?: Record<string, unknown> };
const e = err as E;
if (e.status === 402 || e.code === 'INSUFFICIENT_BALANCE') {
const plan = plans.find((p) => p.key === planType);
const sf = Math.max(0, (plan?.priceCents ?? 0) - balanceCents);
setShortfall({ planType, shortfallCents: sf });
} else {
setError(e.message ?? '订阅失败,请稍后重试');
}
} finally {
setPurchasing(null);
}
};
const handleToggleAutoRenew = async (enabled: boolean) => {
setTogglingAutoRenew(true);
try {
await setAutoRenew(enabled);
setAutoRenewState(enabled);
setCurrentSub((s) => s ? { ...s, autoRenew: enabled } : s);
} catch (err: unknown) {
const e = err as { message?: string };
setError(e.message ?? '设置失败');
} finally {
setTogglingAutoRenew(false);
}
};
if (!open) return null;
return createPortal(
<div className="recharge-backdrop" role="presentation" onClick={onClose}>
<div
className="subscribe-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="subscribe-title"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="subscribe-dialog-head">
<div>
<p className="recharge-eyebrow"></p>
<h3 id="subscribe-title"></h3>
</div>
<button type="button" className="recharge-close" onClick={onClose} aria-label="关闭"></button>
</div>
{/* Balance row */}
<div className="subscribe-balance-row">
<span></span>
<strong>{formatYuan(balanceCents)}</strong>
{currentSub && currentSub.planType !== 'free' && (
<span className="subscribe-current-plan-tag">
{currentSub.planType} · {new Date(currentSub.expiresAt).toLocaleDateString('zh-CN')}
</span>
)}
</div>
{/* Plan grid */}
{loading ? (
<p className="recharge-muted" style={{ padding: '24px 0', textAlign: 'center' }}></p>
) : (
<div className="subscribe-plan-grid">
{plans.map((plan) => {
const canAfford = balanceCents >= plan.priceCents;
const isActive = currentSub?.planType === plan.key;
const badge = PLAN_HIGHLIGHT[plan.key];
const isBuying = purchasing === plan.key;
const currentOrder = PLAN_ORDER[currentSub?.planType ?? 'free'] ?? 0;
const planOrder = PLAN_ORDER[plan.key] ?? 0;
const isDowngrade = !!currentSub && planOrder < currentOrder;
const isUpgrade = !!currentSub && !isActive && planOrder > currentOrder;
return (
<div
key={plan.key}
className={[
'subscribe-plan-card',
isActive ? 'subscribe-plan-card--active' : '',
badge === '推荐' ? 'subscribe-plan-card--highlight' : '',
isDowngrade ? 'subscribe-plan-card--locked' : '',
].filter(Boolean).join(' ')}
>
{badge && <span className="subscribe-plan-badge-top">{badge}</span>}
<div className="subscribe-plan-card-head">
<span className="subscribe-plan-name">{plan.name}</span>
<div className="subscribe-plan-price">
<span className="subscribe-plan-price-num">{formatYuan(plan.priceCents)}</span>
<span className="subscribe-plan-price-unit">/</span>
</div>
</div>
<ul className="subscribe-plan-features">
<li>
<span className="subscribe-feat-label"></span>
<span className="subscribe-feat-val">{callsApprox(plan.periodTokens)}</span>
</li>
<li>
<span className="subscribe-feat-label"></span>
<span className="subscribe-feat-val">{imageCallsApprox(plan.periodImages)}</span>
</li>
<li>
<span className="subscribe-feat-label">Token</span>
<span className="subscribe-feat-val">{formatTokens(plan.periodTokens)}</span>
</li>
<li>
<span className="subscribe-feat-label"></span>
<span className="subscribe-feat-val">{MODEL_TIER_LABELS[plan.modelTier] ?? plan.modelTier}</span>
</li>
{plan.overageRate < 1 && (
<li>
<span className="subscribe-feat-label"></span>
<span className="subscribe-feat-val">{Math.round(plan.overageRate * 10)} </span>
</li>
)}
</ul>
{isActive ? (
<button className="subscribe-plan-btn subscribe-plan-btn--current" disabled></button>
) : isDowngrade ? (
<button className="subscribe-plan-btn subscribe-plan-btn--locked" disabled></button>
) : (
<button
className={`subscribe-plan-btn ${canAfford ? 'subscribe-plan-btn--buy' : 'subscribe-plan-btn--short'}`}
disabled={!!purchasing}
onClick={() => void handlePurchase(plan.key)}
>
{isBuying
? '处理中…'
: canAfford
? isUpgrade ? `升级 ${formatYuan(plan.priceCents)}` : `订阅 ${formatYuan(plan.priceCents)}`
: `差额 ${formatYuan(plan.priceCents - balanceCents)}`}
</button>
)}
</div>
);
})}
</div>
)}
{/* Feedback messages */}
{shortfall && !successMessage && (
<div className="subscribe-shortfall">
<span> <strong>{formatYuan(shortfall.shortfallCents)}</strong> </span>
<button
className="recharge-pay-btn"
onClick={() => { onClose(); onRechargeNeeded(shortfall.shortfallCents); }}
>
</button>
</div>
)}
{error && <p className="recharge-error">{error}</p>}
{successMessage && <p className="recharge-success"> {successMessage}</p>}
{/* Auto-renew toggle */}
<div className="subscribe-autorenew-row">
<label className="subscribe-autorenew-label">
<input
type="checkbox"
checked={autoRenew}
disabled={togglingAutoRenew}
onChange={(e) => {
if (currentSub && currentSub.planType !== 'free') {
void handleToggleAutoRenew(e.target.checked);
} else {
setAutoRenewState(e.target.checked);
}
}}
/>
<span></span>
</label>
<span className="subscribe-autorenew-hint">
{currentSub && currentSub.planType !== 'free'
? autoRenew
? '到期自动扣费续订,余额不足时将不续订'
: '到期后自动降回免费版'
: autoRenew
? '订阅后将自动续费'
: '订阅后每月需手动续订'}
</span>
</div>
<p className="subscribe-legal"> · </p>
</div>
</div>,
document.body,
);
}
+12
View File
@@ -1,5 +1,6 @@
import { createContext, useContext, type ReactNode } from 'react';
import { RechargeModal } from '../components/RechargeModal';
import { SubscribeModal } from '../components/SubscribeModal';
import { useTKMindChat } from '../hooks/useTKMindChat';
import type { CapabilityMap, PortalUser } from '../types';
@@ -36,6 +37,17 @@ export function ChatProvider({
}}
/>
)}
<SubscribeModal
open={chat.subscribePrompt}
onClose={chat.dismissSubscribe}
onSuccess={(subscription, balanceCents) => {
chat.completeSubscribe(balanceCents, subscription);
}}
onRechargeNeeded={() => {
chat.dismissSubscribe();
chat.openRecharge(false);
}}
/>
</ChatContext.Provider>
);
}
+1 -1
View File
@@ -25,7 +25,7 @@ export const PREVIEW_SPACE: MindSpace = {
usedBytes: 1.2 * 1024 * 1024,
reservedBytes: 0,
availableBytes: 3.8 * 1024 * 1024,
maxFileBytes: 2 * 1024 * 1024,
maxFileBytes: 5 * 1024 * 1024,
publicPageLimit: 3,
publicPageUsed: 1,
aiDailyLimit: 20,
+128 -12
View File
@@ -8,6 +8,8 @@ import {
deleteChatSession,
getMindSpace,
getMe,
listNotifications,
markNotificationRead,
getSession,
listSessions,
loadSessionDetail,
@@ -17,6 +19,7 @@ import {
resumeSession,
sendReply,
startSession,
subscribeNotificationEvents,
subscribeSessionEvents,
updateProvider,
} from '../api/client';
@@ -30,6 +33,7 @@ import type {
Session,
SessionEvent,
ToolConfirmation,
UserNotification,
} from '../types';
import { buildContextPrefix } from '../utils/mindspaceChatContext';
import { buildUserAddressPrefix } from '../utils/userAddress';
@@ -105,12 +109,16 @@ export function useTKMindChat(
const [memoryLoading, setMemoryLoading] = useState(false);
const [rechargePrompt, setRechargePrompt] = useState(false);
const [rechargeForced, setRechargeForced] = useState(false);
const [subscribePrompt, setSubscribePrompt] = useState(false);
const [activeNotification, setActiveNotification] = useState<UserNotification | null>(null);
const seenNotificationIdsRef = useRef<Set<string>>(new Set());
const activeRequestId = useRef<string | null>(null);
const unsubscribeRef = useRef<(() => void) | null>(null);
const connectTokenRef = useRef(0);
const messagesRef = useRef<Message[]>([]);
const sessionRef = useRef<Session | null>(null);
const sessionsRef = useRef<Session[]>([]);
const rememberedContextRef = useRef<string | null>(null);
const rememberInFlightRef = useRef(false);
const fallbackRetriedRef = useRef(new Set<string>());
@@ -118,7 +126,14 @@ export function useTKMindChat(
const onUserUpdateRef = useRef(onUserUpdate);
const chatImageCategoryIdRef = useRef<string | null>(null);
const dismissNotice = useCallback(() => setNotice(null), []);
const dismissNotice = useCallback(() => {
const currentNotification = activeNotification;
setNotice(null);
setActiveNotification(null);
if (currentNotification) {
void markNotificationRead(currentNotification.id).catch(() => {});
}
}, [activeNotification]);
const notifyInsufficientBalance = useCallback(() => {
setNotice(INSUFFICIENT_BALANCE_NOTICE);
@@ -152,6 +167,28 @@ export function useTKMindChat(
}
}, []);
const openSubscribe = useCallback(() => {
setSubscribePrompt(true);
}, []);
const dismissSubscribe = useCallback(() => {
setSubscribePrompt(false);
}, []);
const completeSubscribe = useCallback((nextBalanceCents: number, subscription: import('../types').ActiveSubscription) => {
setSubscribePrompt(false);
const currentUser = userRef.current;
const updateUser = onUserUpdateRef.current;
if (currentUser && updateUser) {
updateUser({
...currentUser,
balanceCents: nextBalanceCents,
subscription,
planType: subscription.planType,
});
}
}, []);
useEffect(() => {
userRef.current = user;
}, [user]);
@@ -168,6 +205,63 @@ export function useTKMindChat(
sessionRef.current = session;
}, [session]);
useEffect(() => {
sessionsRef.current = sessions;
}, [sessions]);
useEffect(() => {
if (!user?.id) return;
let cancelled = false;
const showNotification = (notification: UserNotification) => {
if (cancelled) return;
const hasSeen = seenNotificationIdsRef.current.has(notification.id);
if (activeNotification?.id === notification.id && hasSeen) return;
seenNotificationIdsRef.current.add(notification.id);
setActiveNotification(notification);
setNotice(`${notification.title}\n${notification.body}`.trim());
};
const pullNotifications = async () => {
try {
const notifications = await listNotifications('unread', 10);
if (cancelled) return;
if (notifications.length === 0) {
seenNotificationIdsRef.current.clear();
return;
}
const currentIds = new Set(notifications.map((item) => item.id));
seenNotificationIdsRef.current.forEach((id) => {
if (!currentIds.has(id)) seenNotificationIdsRef.current.delete(id);
});
showNotification(notifications[0]);
} catch {
// Ignore notification polling failures to avoid disrupting chat.
}
};
const handleForegroundSync = () => {
if (document.visibilityState === 'visible') {
void pullNotifications();
}
};
void pullNotifications();
const unsubscribe = subscribeNotificationEvents({
onNotification: showNotification,
onSync: () => void pullNotifications(),
});
window.addEventListener('focus', handleForegroundSync);
document.addEventListener('visibilitychange', handleForegroundSync);
return () => {
cancelled = true;
unsubscribe();
window.removeEventListener('focus', handleForegroundSync);
document.removeEventListener('visibilitychange', handleForegroundSync);
};
}, [user?.id, activeNotification?.id]);
const loadProjectMemory = useCallback(async (sessionId: string, force: boolean) => {
if (!canUseProjectMemory) return null;
setMemoryLoading(true);
@@ -313,6 +407,18 @@ export function useTKMindChat(
});
}, [resolveChatImageUploadCategoryId]);
const syncSessionMessages = useCallback(async (sessionId: string) => {
try {
const detail = await loadSessionDetail(sessionId);
if (sessionRef.current?.id !== sessionId) return;
messagesRef.current = detail.messages;
setMessages(detail.messages);
setSession((current) => (current?.id === sessionId ? detail.session : current));
} catch {
// Keep the optimistic streamed state if the follow-up sync fails.
}
}, []);
const processEvent = useCallback(
(event: SessionEvent, requestId: string, sessionId: string) => {
const raw = event as SessionEvent & { chat_request_id?: string; request_id?: string };
@@ -407,27 +513,28 @@ export function useTKMindChat(
.then(({ user: fresh }) => onUserUpdateRef.current?.(fresh))
.catch(() => {});
}
const recentContext = messagesRef.current
.filter((message) => getDisplayText(message).trim())
.slice(-6)
.map((message) => `${message.role === 'user' ? 'User' : 'Assistant'}: ${getDisplayText(message)}`)
.join('\n\n')
.slice(0, 6_000);
const finishedSession = sessionRef.current;
window.setTimeout(() => {
void (async () => {
await syncSessionMessages(sessionId);
const recentContext = messagesRef.current
.filter((message) => getDisplayText(message).trim())
.slice(-6)
.map((message) => `${message.role === 'user' ? 'User' : 'Assistant'}: ${getDisplayText(message)}`)
.join('\n\n')
.slice(0, 6_000);
const finishedSession = sessionRef.current;
void rememberRecentContext({
silent: true,
sessionId,
sessionName: finishedSession?.name,
recentContext,
});
}, 0);
})();
return;
default:
return;
}
},
[rememberRecentContext],
[rememberRecentContext, syncSessionMessages],
);
const subscribeToSession = useCallback(
@@ -506,7 +613,11 @@ export function useTKMindChat(
: await resumeSession(sessionId);
if (token !== connectTokenRef.current) return;
const { session: detail, messages: history } = await loadSessionDetail(sessionId);
const knownSession = sessionsRef.current.find((s) => s.id === sessionId);
const hints = knownSession
? { messageCount: knownSession.message_count, updatedAt: knownSession.updated_at }
: undefined;
const { session: detail, messages: history } = await loadSessionDetail(sessionId, hints);
if (token !== connectTokenRef.current) return;
writeStoredSessionId(userRef.current?.id, sessionId);
@@ -826,10 +937,15 @@ export function useTKMindChat(
balanceCents: user?.balanceCents,
totalCreditCents: user?.totalCreditCents,
tokensUsed: user?.tokensUsed ?? 0,
subscription: user?.subscription,
rechargePrompt,
rechargeForced,
openRecharge,
dismissRecharge,
completeRecharge,
subscribePrompt,
openSubscribe,
dismissSubscribe,
completeSubscribe,
};
}
+777
View File
@@ -3076,6 +3076,17 @@ body,
color: var(--color-text-faint);
}
.balance-popover-warning {
margin: 12px 0 0;
padding: 8px 10px;
border-radius: var(--radius-sm);
background: rgba(239, 68, 68, 0.08);
color: #ef4444;
font-size: 12px;
line-height: 1.45;
text-align: center;
}
.balance-popover-cta {
width: 100%;
padding: 8px;
@@ -3092,6 +3103,217 @@ body,
filter: brightness(1.08);
}
.notification-center {
position: relative;
}
.notification-center-trigger {
position: relative;
}
.notification-center-badge {
position: absolute;
top: -4px;
right: -6px;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: #d9485f;
color: #fff;
font-size: 10px;
line-height: 18px;
text-align: center;
box-shadow: 0 6px 16px rgba(217, 72, 95, 0.3);
}
.notification-center-popover {
z-index: 120;
border: 1px solid rgba(106, 176, 255, 0.18);
border-radius: 20px;
color: #e7ecf3;
background:
radial-gradient(circle at 100% 0, rgba(61, 139, 253, 0.16), transparent 16rem),
linear-gradient(180deg, #1a2433 0%, #121820 100%);
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.38);
padding: 14px;
}
.notification-center-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.notification-center-head h4 {
margin: 0;
font-size: 16px;
}
.notification-center-head p {
margin: 4px 0 0;
color: var(--color-text-muted);
font-size: 12px;
}
.notification-center-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.notification-center-filters {
display: flex;
gap: 8px;
margin-top: 14px;
}
.notification-center-filter {
border: 1px solid var(--color-border);
border-radius: 999px;
background: transparent;
color: var(--color-text-secondary);
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
}
.notification-center-filter.is-active {
border-color: rgba(106, 176, 255, 0.45);
color: #fff;
background: rgba(106, 176, 255, 0.16);
}
.notification-center-state {
margin: 14px 0 0;
color: var(--color-text-muted);
font-size: 13px;
}
.notification-center-state.is-error {
color: #ff9aa9;
}
.notification-center-list {
display: grid;
gap: 10px;
margin-top: 14px;
max-height: 420px;
overflow: auto;
}
.notification-center-item {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 12px;
background: rgba(255, 255, 255, 0.03);
}
.notification-center-item.is-unread {
border-color: rgba(106, 176, 255, 0.35);
background: rgba(106, 176, 255, 0.09);
}
.notification-center-item-head {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: baseline;
}
.notification-center-item-head strong {
font-size: 14px;
}
.notification-center-item-head span {
color: var(--color-text-muted);
font-size: 11px;
}
.notification-center-item p {
margin: 8px 0 0;
color: var(--color-text-secondary);
white-space: pre-wrap;
font-size: 13px;
line-height: 1.5;
}
.notification-center-digest {
display: grid;
gap: 6px;
margin-top: 8px;
}
.notification-center-digest-line {
color: var(--color-text-secondary);
font-size: 13px;
line-height: 1.5;
}
.notification-center-digest-line.is-title {
color: #fff;
font-weight: 600;
}
.notification-center-item-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-top: 10px;
}
.notification-center-read-tag {
color: var(--color-text-muted);
font-size: 12px;
}
.notification-center-read-btn {
border: 1px solid rgba(106, 176, 255, 0.42);
border-radius: 8px;
padding: 6px 10px;
color: #f7fbff;
background: linear-gradient(180deg, rgba(106, 176, 255, 0.32), rgba(54, 125, 218, 0.34));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16);
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
}
.notification-center-read-btn:hover {
border-color: rgba(148, 199, 255, 0.66);
background: linear-gradient(180deg, rgba(130, 191, 255, 0.42), rgba(65, 139, 234, 0.46));
}
.notification-center-clear-btn,
.notification-center-clear-all-btn {
border: 1px solid rgba(255, 138, 128, 0.34);
border-radius: 8px;
padding: 6px 10px;
color: #ffeceb;
background: rgba(255, 107, 96, 0.13);
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
}
.notification-center-clear-btn:hover,
.notification-center-clear-all-btn:hover {
border-color: rgba(255, 166, 158, 0.58);
background: rgba(255, 107, 96, 0.22);
}
.notification-center-clear-all-btn {
padding: 5px 9px;
}
.recharge-backdrop {
position: fixed;
z-index: 200;
@@ -3300,6 +3522,262 @@ body,
line-height: 1.6;
}
/* ── Subscribe modal ───────────────────────────────── */
.subscribe-dialog {
background: var(--color-surface, #1a2320);
border: 1px solid var(--color-border);
border-radius: 16px;
padding: 24px;
width: min(92vw, 780px);
max-height: 92vh;
overflow-y: auto;
box-shadow: 0 24px 64px rgba(0,0,0,.45);
}
.subscribe-dialog-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.subscribe-dialog-head h3 {
margin: 4px 0 0;
font-size: 20px;
}
.subscribe-balance-row {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
padding: 10px 14px;
background: rgba(255,255,255,.04);
border-radius: 8px;
margin-bottom: 20px;
}
.subscribe-balance-row strong {
font-size: 16px;
font-weight: 700;
margin-left: 2px;
}
.subscribe-current-plan-tag {
margin-left: auto;
font-size: 12px;
color: var(--color-text-secondary);
background: rgba(76,175,125,.12);
border-radius: 4px;
padding: 2px 8px;
}
/* ── Plan grid ────────────────────────────────────── */
.subscribe-plan-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 16px;
}
@media (max-width: 560px) {
.subscribe-plan-grid {
grid-template-columns: 1fr;
}
.subscribe-dialog {
padding: 18px 16px;
}
}
.subscribe-plan-card {
position: relative;
display: flex;
flex-direction: column;
padding: 16px 14px 14px;
border-radius: 12px;
border: 1px solid var(--color-border);
background: rgba(255,255,255,.03);
transition: border-color .15s, background .15s;
}
.subscribe-plan-card--active {
border-color: var(--color-accent, #4caf7d);
background: rgba(76,175,125,.07);
}
.subscribe-plan-card--highlight {
border-color: rgba(76,175,125,.5);
}
.subscribe-plan-badge-top {
position: absolute;
top: -1px;
right: 12px;
font-size: 11px;
font-weight: 600;
background: var(--color-accent, #4caf7d);
color: #0d1a13;
border-radius: 0 0 6px 6px;
padding: 2px 8px;
letter-spacing: .3px;
}
.subscribe-plan-card-head {
margin-bottom: 12px;
}
.subscribe-plan-name {
display: block;
font-weight: 700;
font-size: 15px;
margin-bottom: 4px;
}
.subscribe-plan-price {
display: flex;
align-items: baseline;
gap: 2px;
}
.subscribe-plan-price-num {
font-size: 22px;
font-weight: 700;
color: var(--color-accent, #4caf7d);
}
.subscribe-plan-price-unit {
font-size: 12px;
color: var(--color-text-secondary);
}
/* Feature list */
.subscribe-plan-features {
list-style: none;
margin: 0 0 14px;
padding: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.subscribe-plan-features li {
display: flex;
justify-content: space-between;
font-size: 12px;
line-height: 1.4;
}
.subscribe-feat-label {
color: var(--color-text-secondary);
}
.subscribe-feat-val {
font-weight: 500;
}
/* Buttons */
.subscribe-plan-btn {
width: 100%;
padding: 8px 10px;
border-radius: 8px;
border: none;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity .15s;
}
.subscribe-plan-btn:disabled {
cursor: not-allowed;
opacity: .6;
}
.subscribe-plan-btn--buy {
background: var(--color-accent, #4caf7d);
color: #0d1a13;
}
.subscribe-plan-btn--buy:hover:not(:disabled) {
opacity: .88;
}
.subscribe-plan-btn--short {
background: rgba(255,255,255,.07);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.subscribe-plan-btn--current {
background: rgba(255,255,255,.04);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.subscribe-plan-btn--locked {
background: transparent;
color: var(--color-text-faint);
border: 1px dashed var(--color-border);
cursor: default;
}
.subscribe-plan-card--locked {
opacity: .55;
}
/* Shortfall banner */
.subscribe-shortfall {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: rgba(255,180,0,.07);
border: 1px solid rgba(255,180,0,.2);
border-radius: 8px;
padding: 10px 14px;
font-size: 13px;
margin-bottom: 10px;
}
.subscribe-shortfall .recharge-pay-btn {
flex-shrink: 0;
padding: 6px 14px;
font-size: 13px;
}
.subscribe-autorenew-row {
display: flex;
align-items: center;
gap: 10px;
margin: 12px 0 4px;
padding: 8px 12px;
border-radius: 8px;
background: rgba(255,255,255,.04);
border: 1px solid var(--color-border);
}
.subscribe-autorenew-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
}
.subscribe-autorenew-hint {
font-size: 12px;
color: var(--color-text-faint);
}
.subscribe-legal {
margin: 6px 0 0;
font-size: 11px;
color: var(--color-text-faint);
text-align: center;
}
.admin-page {
min-height: 100%;
padding: 0;
@@ -4026,6 +4504,36 @@ body,
color: rgba(255, 255, 255, 0.72);
}
.mindspace-hero-quota-label {
display: inline-flex;
align-items: center;
gap: 6px;
}
.mindspace-hero-quota-buy-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 22px;
padding: 0 8px;
border: 1px solid rgba(238, 176, 78, 0.3);
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: #eeb04e;
cursor: pointer;
font-size: 11px;
font-weight: 700;
line-height: 1;
transition: transform 0.18s ease, background 0.18s ease, border-color 0.18s ease;
}
.mindspace-hero-quota-buy-trigger:hover {
border-color: rgba(238, 176, 78, 0.58);
background: rgba(238, 176, 78, 0.14);
transform: translateY(-1px);
}
.mindspace-hero-quota-top strong {
font-size: 11px;
font-weight: 600;
@@ -4055,6 +4563,72 @@ body,
color: rgba(255, 255, 255, 0.58);
}
.mindspace-hero-purchase {
margin-top: 10px;
}
.mindspace-hero-purchase-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
}
.mindspace-hero-purchase-chip,
.mindspace-hero-purchase-btn {
border: 1px solid rgba(238, 176, 78, 0.35);
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
color: #fff3d7;
cursor: pointer;
}
.mindspace-hero-purchase-chip {
padding: 6px 10px;
font-size: 12px;
}
.mindspace-hero-purchase-chip.is-active {
border-color: rgba(238, 176, 78, 0.72);
background: rgba(238, 176, 78, 0.18);
color: #fff7ea;
}
.mindspace-hero-purchase-btn {
padding: 8px 12px;
font-size: 12px;
}
.mindspace-hero-purchase-chip:disabled,
.mindspace-hero-purchase-btn:disabled {
opacity: 0.6;
cursor: default;
}
.mindspace-hero-purchase-input {
min-width: 96px;
padding: 8px 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 12px;
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.mindspace-hero-purchase-meta,
.mindspace-hero-purchase-message {
margin: 8px 0 0;
font-size: 10px;
line-height: 1.45;
}
.mindspace-hero-purchase-meta {
color: rgba(255, 255, 255, 0.58);
}
.mindspace-hero-purchase-message {
color: #eeb04e;
}
.mindspace-hero-quota-cleanup {
margin-top: 6px;
padding: 0;
@@ -4069,6 +4643,126 @@ body,
text-decoration: underline;
}
.mindspace-space-purchase-dialog {
width: min(430px, 100%);
max-width: 430px;
gap: 14px;
padding: 18px;
color: #18211d;
}
.mindspace-space-purchase-dialog .mindspace-upload-dialog-bar {
gap: 10px;
}
.mindspace-space-purchase-dialog h3 {
font-size: 22px;
}
.mindspace-space-purchase-dialog .mindspace-upload-dialog-desc {
color: #44504a;
font-size: 13px;
line-height: 1.5;
}
.mindspace-space-purchase-dialog .mindspace-hero-purchase-meta {
color: #5a655f;
font-size: 11px;
}
.mindspace-space-purchase-dialog .mindspace-hero-purchase-chip {
border-color: rgba(24, 33, 29, 0.14);
background: #f6efe2;
color: #24312c;
font-weight: 600;
}
.mindspace-space-purchase-dialog .mindspace-hero-purchase-chip:hover {
border-color: rgba(155, 101, 24, 0.38);
background: #f1e4c7;
}
.mindspace-space-purchase-dialog .mindspace-hero-purchase-chip.is-active {
border-color: rgba(155, 101, 24, 0.52);
background: #eeb04e;
color: #2d1c00;
}
.mindspace-space-purchase-dialog .mindspace-hero-purchase-input {
min-width: 140px;
border-color: rgba(24, 33, 29, 0.18);
background: #fffaf2;
color: #18211d;
}
.mindspace-space-purchase-dialog .mindspace-hero-purchase-input::placeholder {
color: #7b817c;
}
.mindspace-space-purchase-dialog .mindspace-hero-purchase-message {
color: #9b6518;
font-size: 12px;
font-weight: 600;
}
.mindspace-space-purchase-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin: 10px 0 2px;
overflow-x: auto;
scrollbar-width: none;
}
.mindspace-space-purchase-summary::-webkit-scrollbar {
display: none;
}
.mindspace-space-purchase-summary div {
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 4px;
padding: 9px 10px;
border: 1px solid rgba(24, 33, 29, 0.14);
border-radius: 12px;
background: #efe7d8;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.48);
}
.mindspace-space-purchase-summary span {
display: inline-block;
margin-bottom: 0;
font-size: 11px;
color: #564f45;
font-weight: 600;
}
.mindspace-space-purchase-summary strong {
display: block;
color: #18211d;
font-size: 13px;
text-align: left;
white-space: nowrap;
}
.mindspace-space-purchase-recharge {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(183, 128, 19, 0.22);
background: #f5dfad;
color: #5e4308;
font-size: 12px;
font-weight: 600;
}
.mindspace-hero-cleanup {
grid-column: 1 / -1;
padding: 16px 18px;
@@ -5236,6 +5930,36 @@ body,
background: #2f6f57;
}
.mindspace-asset-bulkbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin: -4px 0 18px;
color: #52605a;
font-size: 13px;
}
.mindspace-asset-bulkbar button {
padding: 7px 11px;
border: 0;
border-radius: 999px;
color: #18211d;
background: rgba(24, 33, 29, 0.08);
cursor: pointer;
font: inherit;
}
.mindspace-asset-bulkbar button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.mindspace-asset-bulkbar .mindspace-asset-bulk-delete {
color: #fffaf0;
background: #8b2d20;
}
.mindspace-section-subheading {
display: flex;
align-items: baseline;
@@ -5269,6 +5993,7 @@ body,
}
.mindspace-image-card {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
@@ -5349,7 +6074,36 @@ body,
font-size: 12px;
}
.mindspace-asset-select {
position: absolute;
z-index: 2;
top: 8px;
left: 8px;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 8px;
border-radius: 999px;
color: #18211d;
background: rgba(255, 252, 244, 0.92);
box-shadow: 0 4px 12px rgba(24, 33, 29, 0.1);
font-size: 12px;
font-weight: 700;
}
.mindspace-asset-select.is-card {
position: static;
justify-self: start;
}
.mindspace-asset-select input {
width: 14px;
height: 14px;
accent-color: #2f6f57;
}
.mindspace-item-card {
position: relative;
display: grid;
gap: 12px;
padding: 16px;
@@ -6586,6 +7340,29 @@ body,
width: 100%;
}
.mindspace-space-purchase-summary {
grid-template-columns: repeat(3, minmax(88px, 1fr));
gap: 6px;
}
.mindspace-space-purchase-summary div {
padding: 8px 9px;
border-radius: 10px;
}
.mindspace-space-purchase-summary span {
font-size: 10px;
}
.mindspace-space-purchase-summary strong {
font-size: 11px;
}
.mindspace-space-purchase-recharge {
flex-direction: column;
align-items: stretch;
}
.mindspace-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
+81 -1
View File
@@ -144,6 +144,20 @@ export type BillingConfig = {
balanceCents: number;
};
export type UserNotification = {
id: string;
userId: string;
channel: 'web' | 'wechat';
notificationType: string;
title: string;
body: string;
data?: Record<string, unknown> | null;
status: 'unread' | 'read';
readAt: number | null;
createdAt: number;
updatedAt: number;
};
export type JsapiPayParams = {
appId: string;
timeStamp: string;
@@ -171,6 +185,29 @@ export type InsufficientBalanceDetails = {
suggestedTiers: number[];
};
export type ActiveSubscription = {
id: string;
planType: string;
status: 'active' | 'expired' | 'cancelled';
periodTokensLimit: number;
periodTokensUsed: number;
periodStart: number;
periodEnd: number;
expiresAt: number;
overageRate: number;
autoRenew: boolean;
};
export type PlanDefinition = {
key: string;
name: string;
priceCents: number;
periodDays: number;
periodTokens: number;
modelTier: string;
overageRate: number;
};
export type PortalUser = {
id: string;
username: string;
@@ -179,11 +216,16 @@ export type PortalUser = {
displayName: string;
role: 'user' | 'admin';
status: 'active' | 'suspended' | 'disabled';
planType?: 'free' | 'growth' | 'pro' | 'enterprise';
planType?: string;
workspaceRoot: string;
balanceCents: number;
totalCreditCents?: number;
tokensUsed: number;
subscription?: ActiveSubscription | null;
spaceQuotaBytes?: number;
spaceUsedBytes?: number;
spaceReservedBytes?: number;
spaceAvailableBytes?: number;
publishSlug?: string;
publishUrl?: string;
publishSkillName?: string;
@@ -215,6 +257,42 @@ export type MindSpaceCategory = {
itemCount: number;
};
export type MindSpaceScheduleItem = {
id: string;
kind: 'task' | 'event';
title: string;
description?: string | null;
status: 'active' | 'completed' | 'cancelled' | 'deleted';
startAt?: number | null;
endAt?: number | null;
dueAt?: number | null;
allDay: boolean;
timezone: string;
location?: string | null;
sourceChannel?: 'h5' | 'wechat' | 'agent' | 'api';
sourceMessageId?: string | null;
sourceText?: string | null;
createdAt: number;
updatedAt: number;
};
export type MindSpaceDigestSubscription = {
id: string;
hour: number;
minute: number;
timezone: string;
channel: 'wechat' | 'in_app';
status: 'active' | 'locked' | 'failed' | 'cancelled';
nextRunAt: number;
lastRunAt?: number | null;
attempts?: number;
};
export type MindSpaceSchedule = {
todayTodoItems: MindSpaceScheduleItem[];
digestSubscriptions: MindSpaceDigestSubscription[];
};
export type MindSpace = {
id: string;
userId: string;
@@ -222,6 +300,7 @@ export type MindSpace = {
status: 'active' | 'locked' | 'deleted';
quota: MindSpaceQuota;
categories: MindSpaceCategory[];
schedule?: MindSpaceSchedule | null;
createdAt: number;
updatedAt: number;
};
@@ -242,6 +321,7 @@ export type MindSpaceAsset = {
scanStatus?: 'pending' | 'passed' | 'warned' | 'blocked';
sourceType: 'upload' | 'chat' | 'agent' | 'template' | 'generated' | 'workspace';
hasThumbnail?: boolean;
publicUrl?: string | null;
sourcePageId?: string | null;
createdAt: number;
updatedAt: number;
+60 -10
View File
@@ -1,8 +1,13 @@
const URL_PATTERN =
/https?:\/\/[^\s<>"')\]]+\/(?:MindSpace|temp)\/([a-z0-9._-]+)\/([^\s<>"')\]]+\.html)/gi;
/https?:\/\/[^\s<>"')\]]+\/(?:MindSpace|temp)\/([0-9a-f-]{36}|[a-z0-9._-]+)\/([^\s<>"')\]]+\.html)/gi;
export type MessageSaveKind = 'page' | 'article';
export type MessageSaveOwner = {
userId?: string | null;
username?: string | null;
};
export type MessageSaveActions = {
kind: MessageSaveKind;
previewUrl: string | null;
@@ -17,22 +22,67 @@ function decodeSegment(segment: string) {
}
}
export function extractStaticPageLinks(content: string, username?: string) {
function normalizeOwnerOptions(owner?: string | MessageSaveOwner): MessageSaveOwner {
if (typeof owner === 'string') return { username: owner };
return owner ?? {};
}
function normalizeStaticHtmlRelativePath(relativePath: string) {
const parts = String(relativePath ?? '')
.replace(/^\/+/, '')
.split('/')
.filter((part) => part && part !== '.' && part !== '..');
if (parts.length === 0) return '';
if (parts[0]?.toLowerCase() === 'public') return ['public', ...parts.slice(1)].join('/');
if (parts.length === 1 && parts[0].toLowerCase().endsWith('.html')) return `public/${parts[0]}`;
return parts.join('/');
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function encodeUrlPath(relativePath: string) {
return relativePath
.split('/')
.filter(Boolean)
.map((part) => encodeURIComponent(part))
.join('/');
}
function canonicalizeStaticPageUrl(publicUrl: string, originalRelativePath: string) {
const canonicalRelativePath = normalizeStaticHtmlRelativePath(originalRelativePath);
const originalClean = originalRelativePath.replace(/^\/+/, '');
if (!canonicalRelativePath || canonicalRelativePath === originalClean) return publicUrl;
const suffix = escapeRegExp(encodeUrlPath(originalClean));
return publicUrl.replace(new RegExp(`${suffix}$`), encodeUrlPath(canonicalRelativePath));
}
export function extractStaticPageLinks(content: string, owner?: string | MessageSaveOwner) {
const { userId, username } = normalizeOwnerOptions(owner);
const normalizedUserId = userId?.trim().toLowerCase();
const normalizedUsername = username?.trim().toLowerCase();
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 });
const linkOwner = decodeSegment(match[1]).toLowerCase();
const relativePath = decodeSegment(match[2]);
const filename = relativePath.split('/').pop() ?? relativePath;
if (normalizedUserId) {
if (linkOwner !== normalizedUserId && linkOwner !== normalizedUsername) continue;
} else if (normalizedUsername && linkOwner !== normalizedUsername) {
continue;
}
const publicUrl = canonicalizeStaticPageUrl(match[0], relativePath);
if (seen.has(publicUrl)) continue;
seen.add(publicUrl);
links.push({ publicUrl, filename });
}
return links;
}
export function getMessageSaveActions(content: string, username?: string): MessageSaveActions {
const links = extractStaticPageLinks(content, username);
export function getMessageSaveActions(content: string, owner?: string | MessageSaveOwner): MessageSaveActions {
const links = extractStaticPageLinks(content, owner);
return {
kind: links.length > 0 ? 'page' : 'article',
previewUrl: links[0]?.publicUrl ?? null,
+6 -2
View File
@@ -56,11 +56,15 @@ export function isImageAsset(asset: MindSpaceAsset) {
return asset.assetType === 'image' || asset.mimeType.startsWith('image/');
}
export function buildAssetImageUrl(asset: Pick<MindSpaceAsset, 'id' | 'updatedAt'>) {
export function buildAssetImageUrl(asset: Pick<MindSpaceAsset, 'id' | 'updatedAt' | 'publicUrl'>) {
if (asset.publicUrl) return asset.publicUrl;
return `/api/mindspace/v1/assets/${asset.id}/download?inline=1&v=${asset.updatedAt}`;
}
export function buildAbsoluteAssetImageUrl(asset: Pick<MindSpaceAsset, 'id' | 'updatedAt'>): string {
export function buildAbsoluteAssetImageUrl(
asset: Pick<MindSpaceAsset, 'id' | 'updatedAt' | 'publicUrl'>,
): string {
if (asset.publicUrl) return new URL(asset.publicUrl, window.location.origin).toString();
return `${window.location.origin}${buildAssetImageUrl(asset)}`;
}
+9 -1
View File
@@ -95,9 +95,17 @@ export async function nativeShare(payload: SharePayload) {
}
export function buildAssetSharePayload(
asset: { displayName: string },
asset: { displayName: string; publicUrl?: string | null },
categoryCode?: string,
): SharePayload {
if (asset.publicUrl) {
const publicUrl = new URL(asset.publicUrl, window.location.origin).toString();
return {
title: asset.displayName,
url: publicUrl,
description: `看看我在 TKMind 空间里的图片「${asset.displayName}`,
};
}
const url = new URL(resolveMindSpaceHomeUrl());
if (categoryCode) {
url.searchParams.set('category', categoryCode);
+5 -1
View File
@@ -1,12 +1,16 @@
import type { PortalUser } from '../types';
function isInternalWechatUsername(value?: string | null): boolean {
return /^wx_[a-z0-9_]{4,64}$/i.test(String(value ?? '').trim());
}
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;
if (username && !isInternalWechatUsername(username)) return username;
return '用户';
}
+29
View File
@@ -0,0 +1,29 @@
type WordEntry = { word: string; replacement: string };
let blockedWords: WordEntry[] = [];
export function setBlockedWords(words: WordEntry[]) {
blockedWords = words ?? [];
}
export function filterText(text: string): string {
if (!text || !blockedWords.length) return text;
let result = text;
for (const entry of blockedWords) {
if (!entry.word) continue;
const escaped = entry.word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
result = result.replace(new RegExp(escaped, 'gi'), entry.replacement);
}
return result;
}
export async function loadBlockedWords(): Promise<void> {
try {
const res = await fetch('/api/config/blocked-words', { credentials: 'include' });
if (!res.ok) return;
const data = (await res.json()) as { words?: WordEntry[] };
setBlockedWords(data.words ?? []);
} catch {
// fail silently — filtering is best-effort
}
}