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