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
+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,
};
}