feat(h5): show rotating loading tips during agent-run execution
Surface backend loading_tip SSE events in chat UI with shared tip pool and styles for long-running TKMind 智趣 tasks. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1573,10 +1573,22 @@ type SubscribeOptions = {
|
||||
onBalance?: (update: BalanceUpdate) => void;
|
||||
};
|
||||
|
||||
export type AgentRunStreamEvent = {
|
||||
eventId?: string;
|
||||
eventType: string;
|
||||
data: unknown;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
type SubscribeAgentRunEventsOptions = {
|
||||
onRunEvent?: (event: AgentRunStreamEvent) => void;
|
||||
};
|
||||
|
||||
export function subscribeAgentRunEvents(
|
||||
runId: string,
|
||||
onRun: (run: AgentRun) => void,
|
||||
onError: (error: Error) => void,
|
||||
options: SubscribeAgentRunEventsOptions = {},
|
||||
): () => void {
|
||||
let closed = false;
|
||||
let activeController: AbortController | null = null;
|
||||
@@ -1634,6 +1646,20 @@ export function subscribeAgentRunEvents(
|
||||
const payload = JSON.parse(data) as { message?: string };
|
||||
throw new Error(payload.message || '后台任务失败');
|
||||
}
|
||||
if (eventName === 'run_event') {
|
||||
const payload = JSON.parse(data) as {
|
||||
eventId?: string;
|
||||
eventType?: string;
|
||||
data?: unknown;
|
||||
createdAt?: string;
|
||||
};
|
||||
options.onRunEvent?.({
|
||||
eventId: payload.eventId,
|
||||
eventType: String(payload.eventType ?? ''),
|
||||
data: payload.data,
|
||||
createdAt: payload.createdAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -198,6 +198,7 @@ export function ChatView({
|
||||
error,
|
||||
notice,
|
||||
pendingTool,
|
||||
taskLoadingTip,
|
||||
submit,
|
||||
stop,
|
||||
approveTool,
|
||||
@@ -577,6 +578,7 @@ export function ChatView({
|
||||
onBalanceUpdate={(nextBalance) => completeRecharge(nextBalance)}
|
||||
onGrantedSkillsUpdate={onGrantedSkillsUpdate}
|
||||
onOpenRecharge={() => openRecharge(false)}
|
||||
taskLoadingTip={taskLoadingTip}
|
||||
onSubmit={(text, imageUrls, previewImageUrls, options) =>
|
||||
void submit(
|
||||
text,
|
||||
|
||||
@@ -145,7 +145,7 @@ function isToolOnlyAssistantMessage(message: Message) {
|
||||
);
|
||||
}
|
||||
|
||||
function TypingBubble() {
|
||||
function TypingBubble({ tip }: { tip?: string | null }) {
|
||||
return (
|
||||
<div className="msg-bubble msg-bubble-assistant msg-typing">
|
||||
<span className="typing-dots">
|
||||
@@ -153,6 +153,7 @@ function TypingBubble() {
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
{tip ? <p className="msg-typing-tip">{tip}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -324,6 +325,7 @@ function MessageRow({
|
||||
compact = false,
|
||||
activeToolMessage = false,
|
||||
showInlineTyping = false,
|
||||
typingTip = null,
|
||||
memoryRecallByMessageId = {},
|
||||
messageIndex = 0,
|
||||
}: {
|
||||
@@ -341,6 +343,7 @@ function MessageRow({
|
||||
compact?: boolean;
|
||||
activeToolMessage?: boolean;
|
||||
showInlineTyping?: boolean;
|
||||
typingTip?: string | null;
|
||||
memoryRecallByMessageId?: Record<string, { count: number; previews: string[] }>;
|
||||
messageIndex?: number;
|
||||
}) {
|
||||
@@ -362,7 +365,7 @@ function MessageRow({
|
||||
<TKMindAvatar />
|
||||
<div className="msg-content">
|
||||
{showInlineTyping ? (
|
||||
<TypingBubble />
|
||||
<TypingBubble tip={typingTip} />
|
||||
) : (
|
||||
<>
|
||||
<ToolBadge message={message} active={activeToolMessage} />
|
||||
@@ -537,6 +540,7 @@ export function MessageList({
|
||||
publishUsername,
|
||||
compact = false,
|
||||
memoryRecallByMessageId = {},
|
||||
typingTip = null,
|
||||
}: {
|
||||
messages: Message[];
|
||||
streaming: boolean;
|
||||
@@ -549,6 +553,7 @@ export function MessageList({
|
||||
publishUsername?: string;
|
||||
compact?: boolean;
|
||||
memoryRecallByMessageId?: Record<string, { count: number; previews: string[] }>;
|
||||
typingTip?: string | null;
|
||||
}) {
|
||||
const { avatarUrl } = useUserAvatar();
|
||||
const renderableMessages = messages.filter(shouldShowChatMessage);
|
||||
@@ -605,6 +610,7 @@ export function MessageList({
|
||||
compact={compact}
|
||||
activeToolMessage={message === activeToolMessage}
|
||||
showInlineTyping={showEndTyping && message === inlineTypingMessage}
|
||||
typingTip={showEndTyping && message === inlineTypingMessage ? typingTip : null}
|
||||
memoryRecallByMessageId={memoryRecallByMessageId}
|
||||
messageIndex={index}
|
||||
/>
|
||||
@@ -615,7 +621,7 @@ export function MessageList({
|
||||
<div className="msg-row msg-row-assistant" role="status" aria-live="polite" aria-label="助手正在回复">
|
||||
<TKMindAvatar />
|
||||
<div className="msg-content">
|
||||
<TypingBubble />
|
||||
<TypingBubble tip={typingTip} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -58,7 +58,7 @@ export function SpaceChatPanel({
|
||||
uploadChatAttachment,
|
||||
retryConnect,
|
||||
} = chat;
|
||||
const { capabilities, grantedSkills, followAgentRun, goalRunEnabled, balanceCents } = mainChat;
|
||||
const { capabilities, grantedSkills, followAgentRun, goalRunEnabled, balanceCents, taskLoadingTip } = mainChat;
|
||||
const [localGrantedSkills, setLocalGrantedSkills] = useState<string[] | undefined>();
|
||||
const effectiveGrantedSkills = localGrantedSkills ?? grantedSkills;
|
||||
|
||||
@@ -180,6 +180,7 @@ export function SpaceChatPanel({
|
||||
onBalanceUpdate={(nextBalance) => completeRecharge(nextBalance)}
|
||||
onGrantedSkillsUpdate={handleGrantedSkillsUpdate}
|
||||
onOpenRecharge={() => openRecharge(false)}
|
||||
taskLoadingTip={taskLoadingTip}
|
||||
onSubmit={(text, imageUrls, previewImageUrls, options) =>
|
||||
chatBridge
|
||||
? void submit(
|
||||
|
||||
@@ -121,6 +121,12 @@ function createAgentRunFailedError(message: string) {
|
||||
return error;
|
||||
}
|
||||
|
||||
function extractLoadingTipFromRunEvent(data: unknown): string | null {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const text = (data as { text?: unknown }).text;
|
||||
return typeof text === 'string' && text.trim() ? text.trim() : null;
|
||||
}
|
||||
|
||||
function errorCode(error: unknown) {
|
||||
if (error instanceof ApiError) return error.code;
|
||||
if (error && typeof error === 'object' && 'code' in error) {
|
||||
@@ -396,6 +402,7 @@ export function useTKMindChat(
|
||||
const [rechargeForced, setRechargeForced] = useState(false);
|
||||
const [subscribePrompt, setSubscribePrompt] = useState(false);
|
||||
const [activeNotification, setActiveNotification] = useState<UserNotification | null>(null);
|
||||
const [taskLoadingTip, setTaskLoadingTip] = useState<string | null>(null);
|
||||
const seenNotificationIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const activeRequestId = useRef<string | null>(null);
|
||||
@@ -424,11 +431,43 @@ export function useTKMindChat(
|
||||
const pendingSubmitQueueRef = useRef<PendingChatSubmitEntry[]>([]);
|
||||
const flushingPendingSubmitRef = useRef(false);
|
||||
const pendingToolRef = useRef<ToolConfirmation | null>(null);
|
||||
const taskLoadingTipUnsubRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
chatStateRef.current = chatState;
|
||||
}, [chatState]);
|
||||
|
||||
const clearTaskLoadingTipSubscription = useCallback(() => {
|
||||
taskLoadingTipUnsubRef.current?.();
|
||||
taskLoadingTipUnsubRef.current = null;
|
||||
setTaskLoadingTip(null);
|
||||
}, []);
|
||||
|
||||
const startTaskLoadingTipSubscription = useCallback((runId: string, isCancelled: () => boolean) => {
|
||||
clearTaskLoadingTipSubscription();
|
||||
taskLoadingTipUnsubRef.current = subscribeAgentRunEvents(
|
||||
runId,
|
||||
() => {},
|
||||
() => {},
|
||||
{
|
||||
onRunEvent: (event) => {
|
||||
if (isCancelled()) return;
|
||||
if (event.eventType !== 'loading_tip') return;
|
||||
const tip = extractLoadingTipFromRunEvent(event.data);
|
||||
if (tip) setTaskLoadingTip(tip);
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [clearTaskLoadingTipSubscription]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatState === 'idle' || chatState === 'error' || chatState === 'loading') {
|
||||
clearTaskLoadingTipSubscription();
|
||||
}
|
||||
}, [chatState, clearTaskLoadingTipSubscription]);
|
||||
|
||||
useEffect(() => () => clearTaskLoadingTipSubscription(), [clearTaskLoadingTipSubscription]);
|
||||
|
||||
useEffect(() => {
|
||||
pendingToolRef.current = pendingTool;
|
||||
}, [pendingTool]);
|
||||
@@ -1560,6 +1599,7 @@ export function useTKMindChat(
|
||||
...(options?.goalRunId ? { goalRunId: options.goalRunId } : {}),
|
||||
},
|
||||
);
|
||||
startTaskLoadingTipSubscription(createdRun.id, () => submitToken !== connectTokenRef.current);
|
||||
const runSessionId = createdRun.sessionId ?? activeSessionId;
|
||||
if (runSessionId && !isDirectChatSessionId(runSessionId)) {
|
||||
activeSessionId = runSessionId;
|
||||
@@ -1729,6 +1769,7 @@ export function useTKMindChat(
|
||||
ensureProvider,
|
||||
loadProjectMemory,
|
||||
refreshSessions,
|
||||
startTaskLoadingTipSubscription,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1902,6 +1943,7 @@ export function useTKMindChat(
|
||||
chatStateRef.current = 'waiting';
|
||||
setError(null);
|
||||
agentRunPendingRef.current = true;
|
||||
startTaskLoadingTipSubscription(createdRun.id, () => submitToken !== connectTokenRef.current);
|
||||
|
||||
try {
|
||||
const finishedRun =
|
||||
@@ -1960,7 +2002,7 @@ export function useTKMindChat(
|
||||
activeRequestId.current = null;
|
||||
}
|
||||
},
|
||||
[clearActiveRequestMissingTimer, scheduleReplyRecoverySync, subscribeToSession],
|
||||
[clearActiveRequestMissingTimer, scheduleReplyRecoverySync, subscribeToSession, startTaskLoadingTipSubscription],
|
||||
);
|
||||
|
||||
const newSession = useCallback(async () => {
|
||||
@@ -2159,6 +2201,7 @@ export function useTKMindChat(
|
||||
error,
|
||||
notice,
|
||||
pendingTool,
|
||||
taskLoadingTip,
|
||||
memoryLoading,
|
||||
userMemoryLoading,
|
||||
canUseProjectMemory,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { pickTkmindLoadingTip } from '../utils/tkmindLoadingTips';
|
||||
|
||||
const ROTATE_MS = 4_500;
|
||||
|
||||
export function useTaskLoadingTip(active: boolean, externalTip?: string | null) {
|
||||
const [localTip, setLocalTip] = useState<string | null>(null);
|
||||
const recentTipsRef = useRef<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || externalTip) {
|
||||
setLocalTip(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const rotate = () => {
|
||||
const picked = pickTkmindLoadingTip({ exclude: recentTipsRef.current.slice(-3) });
|
||||
recentTipsRef.current = [...recentTipsRef.current, picked.text].slice(-6);
|
||||
setLocalTip(picked.text);
|
||||
};
|
||||
|
||||
rotate();
|
||||
const timer = window.setInterval(rotate, ROTATE_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [active, externalTip]);
|
||||
|
||||
return externalTip ?? localTip;
|
||||
}
|
||||
+38
-2
@@ -2322,6 +2322,16 @@ body,
|
||||
|
||||
.msg-typing {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.msg-typing-tip {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.typing-dots {
|
||||
@@ -3077,8 +3087,9 @@ body,
|
||||
|
||||
.chat-input-task-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
@@ -3086,10 +3097,35 @@ body,
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chat-input-task-status-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-input-task-status-text {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.chat-input-task-status-tip {
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-text-faint);
|
||||
animation: task-loading-tip-fade 0.35s ease;
|
||||
}
|
||||
|
||||
@keyframes task-loading-tip-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input-task-status-dots span {
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { MindSpaceChatContext, AgentCodeRunClientPolicy } from '../types';
|
||||
export type AgentRunCreateOptions = {
|
||||
toolMode?: 'chat' | 'code';
|
||||
taskType?: string | null;
|
||||
executor?: 'aider' | 'openhands';
|
||||
executor?: 'aider' | 'openhands' | 'cursor';
|
||||
forceDeepReasoning?: boolean;
|
||||
validation?: AgentRunValidation | null;
|
||||
validationInstruction?: string | null;
|
||||
@@ -302,7 +302,7 @@ export function resolveAgentRunOptions(
|
||||
}: {
|
||||
taskType?: string;
|
||||
forceCode?: boolean;
|
||||
requiredExecutor?: 'aider' | 'openhands';
|
||||
requiredExecutor?: 'aider' | 'openhands' | 'cursor';
|
||||
allowAutodetect?: boolean;
|
||||
allowPageDataDevAutodetect?: boolean;
|
||||
userId?: string | null;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
export type TkmindLoadingTipCategory = 'intro' | 'news' | 'joke' | 'poem';
|
||||
|
||||
export type TkmindLoadingTip = {
|
||||
category: TkmindLoadingTipCategory;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export const TKMIND_LOADING_TIPS: TkmindLoadingTip[] = [
|
||||
{ category: 'intro', text: 'TKMind 智趣:把聊天变成可交付的页面、问卷与分析。' },
|
||||
{ category: 'intro', text: '智趣正在帮你落盘 MindSpace 页面,稍等片刻就好。' },
|
||||
{ category: 'intro', text: 'TKMind 会把对话里的想法,变成能分享的链接。' },
|
||||
{ category: 'intro', text: '页面、问卷、Excel 分析——智趣一条指令就能开工。' },
|
||||
{ category: 'intro', text: 'MindSpace 是你的创作工作台,TKMind 是懂你的搭档。' },
|
||||
{ category: 'news', text: '今日小贴士:先让智趣出草稿,再微调细节,效率翻倍。' },
|
||||
{ category: 'news', text: '热点观察:越来越多团队用 AI 助手做「可点击」的交付物。' },
|
||||
{ category: 'news', text: '趋势速览:问卷 + Page Data 可以直接沉淀到数据库。' },
|
||||
{ category: 'news', text: '轻新闻:一杯咖啡的时间,页面可能就写好了。' },
|
||||
{ category: 'joke', text: '程序员笑话:Bug 不是消失,只是换了个地方躲猫猫。' },
|
||||
{ category: 'joke', text: '冷笑话:为什么页面加载慢?因为它在认真排版。' },
|
||||
{ category: 'joke', text: '今日一笑:产品经理说「就改一行」,智趣默默打开了十个文件。' },
|
||||
{ category: 'joke', text: '段子时间:代码写得好,头发剩多少?' },
|
||||
{ category: 'poem', text: '「采菊东篱下,悠然见南山。」——陶渊明' },
|
||||
{ category: 'poem', text: '「欲把西湖比西子,淡妆浓抹总相宜。」——苏轼' },
|
||||
{ category: 'poem', text: '「海上生明月,天涯共此时。」——张九龄' },
|
||||
{ category: 'poem', text: '「春风得意马蹄疾,一日看尽长安花。」——孟郊' },
|
||||
{ category: 'poem', text: '「行到水穷处,坐看云起时。」——王维' },
|
||||
];
|
||||
|
||||
export function pickTkmindLoadingTip(options: { seed?: string | number; exclude?: string[] } = {}): TkmindLoadingTip {
|
||||
const exclude = new Set((options.exclude ?? []).map((item) => String(item ?? '').trim()).filter(Boolean));
|
||||
const pool = TKMIND_LOADING_TIPS.filter((item) => !exclude.has(item.text));
|
||||
const candidates = pool.length > 0 ? pool : TKMIND_LOADING_TIPS;
|
||||
const seed = String(options.seed ?? `${Date.now()}-${Math.random()}`);
|
||||
let hash = 0;
|
||||
for (let index = 0; index < seed.length; index += 1) {
|
||||
hash = ((hash << 5) - hash + seed.charCodeAt(index)) | 0;
|
||||
}
|
||||
const picked = candidates[Math.abs(hash) % candidates.length];
|
||||
return picked ?? candidates[0];
|
||||
}
|
||||
Reference in New Issue
Block a user