From 8bdb6e24420495a08452c428102e5635729174b0 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 27 Aug 2026 09:33:50 +0800 Subject: [PATCH] feat(h5): show rotating loading tips during agent-run execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface backend loading_tip SSE events in chat UI with shared tip pool and styles for long-running TKMind 智趣 tasks. Co-authored-by: Cursor --- src/api/client.ts | 26 ++++++++++++++++++ src/components/ChatView.tsx | 2 ++ src/components/MessageList.tsx | 12 ++++++--- src/components/SpaceChatPanel.tsx | 3 ++- src/hooks/useTKMindChat.ts | 45 ++++++++++++++++++++++++++++++- src/hooks/useTaskLoadingTip.ts | 28 +++++++++++++++++++ src/index.css | 40 +++++++++++++++++++++++++-- src/utils/agentRunMode.ts | 4 +-- src/utils/tkmindLoadingTips.ts | 40 +++++++++++++++++++++++++++ 9 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 src/hooks/useTaskLoadingTip.ts create mode 100644 src/utils/tkmindLoadingTips.ts diff --git a/src/api/client.ts b/src/api/client.ts index 01019b6..abef7ff 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -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) { diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index 4c1fc32..05035b2 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -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, diff --git a/src/components/MessageList.tsx b/src/components/MessageList.tsx index b238419..8a3d93a 100644 --- a/src/components/MessageList.tsx +++ b/src/components/MessageList.tsx @@ -145,7 +145,7 @@ function isToolOnlyAssistantMessage(message: Message) { ); } -function TypingBubble() { +function TypingBubble({ tip }: { tip?: string | null }) { return (
@@ -153,6 +153,7 @@ function TypingBubble() { + {tip ?

{tip}

: null}
); } @@ -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; messageIndex?: number; }) { @@ -362,7 +365,7 @@ function MessageRow({
{showInlineTyping ? ( - + ) : ( <> @@ -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; + 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({
- +
)} diff --git a/src/components/SpaceChatPanel.tsx b/src/components/SpaceChatPanel.tsx index 17b737c..3368990 100644 --- a/src/components/SpaceChatPanel.tsx +++ b/src/components/SpaceChatPanel.tsx @@ -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(); 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( diff --git a/src/hooks/useTKMindChat.ts b/src/hooks/useTKMindChat.ts index b7dcfa2..356d636 100644 --- a/src/hooks/useTKMindChat.ts +++ b/src/hooks/useTKMindChat.ts @@ -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(null); + const [taskLoadingTip, setTaskLoadingTip] = useState(null); const seenNotificationIdsRef = useRef>(new Set()); const activeRequestId = useRef(null); @@ -424,11 +431,43 @@ export function useTKMindChat( const pendingSubmitQueueRef = useRef([]); const flushingPendingSubmitRef = useRef(false); const pendingToolRef = useRef(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, diff --git a/src/hooks/useTaskLoadingTip.ts b/src/hooks/useTaskLoadingTip.ts new file mode 100644 index 0000000..d20b351 --- /dev/null +++ b/src/hooks/useTaskLoadingTip.ts @@ -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(null); + const recentTipsRef = useRef([]); + + 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; +} diff --git a/src/index.css b/src/index.css index f0f9abc..b5d2f93 100644 --- a/src/index.css +++ b/src/index.css @@ -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); } diff --git a/src/utils/agentRunMode.ts b/src/utils/agentRunMode.ts index 06253f1..9fd27fa 100644 --- a/src/utils/agentRunMode.ts +++ b/src/utils/agentRunMode.ts @@ -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; diff --git a/src/utils/tkmindLoadingTips.ts b/src/utils/tkmindLoadingTips.ts new file mode 100644 index 0000000..fb59a4e --- /dev/null +++ b/src/utils/tkmindLoadingTips.ts @@ -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]; +}