feat(h5): web 联网能力、实时查询路由与 session Finish 对齐

- 新增 web 能力并挂载 platform/web(web_search/fetch_url)
- 实时查询强制 web skill,router fallback 与 await session Finish
- Session Broker 覆盖率/指标、stream replay 与相关单测/E2E 脚本

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-06 16:06:26 +08:00
parent 08feae8bef
commit 14a00774d9
41 changed files with 3501 additions and 126 deletions
+12 -5
View File
@@ -192,6 +192,7 @@ export function ChatPanel({
remainingHeight: number;
}>(null);
const prevMessageCountRef = useRef(messages.length);
const showAssistantTyping = chatState === 'waiting' || chatState === 'streaming';
inputRef.current = input;
pendingImagesRef.current = pendingImages;
@@ -293,13 +294,14 @@ export function ChatPanel({
}
const messageCountIncreased = messages.length > prevMessageCountRef.current;
if ((nearBottomRef.current || chatState === 'streaming') && messageCountIncreased) {
if ((nearBottomRef.current || showAssistantTyping) && messageCountIncreased) {
container.scrollTop = container.scrollHeight;
nearBottomRef.current = true;
}
prevMessageCountRef.current = messages.length;
}, [
chatState,
showAssistantTyping,
historyHasMore,
historyLoadingMore,
messages.length,
@@ -308,6 +310,13 @@ export function ChatPanel({
session?.id,
]);
useLayoutEffect(() => {
if (!showAssistantTyping) return;
const container = mainRef.current;
if (!container || !nearBottomRef.current) return;
container.scrollTop = container.scrollHeight;
}, [showAssistantTyping, messages.length]);
const busy =
chatState === 'streaming' ||
chatState === 'loading' ||
@@ -331,9 +340,7 @@ export function ChatPanel({
? '正在上传图片,发出后会自动继续…'
: chatState === 'connecting'
? '正在创建会话…'
: chatState === 'waiting'
? '请求已发出,正在等待后台开始处理…'
: null;
: null;
const sendButtonLabel =
uploadingImage
? '上传中…'
@@ -672,7 +679,7 @@ export function ChatPanel({
)}
<MessageList
messages={messages}
streaming={chatState === 'streaming'}
streaming={showAssistantTyping}
onAvatarClick={compact ? undefined : openAvatarPicker}
onSaveAsPage={openSaveActions}
onShareToPlaza={openPlazaPublish}
+50 -10
View File
@@ -144,6 +144,31 @@ function isToolOnlyAssistantMessage(message: Message) {
);
}
function TypingBubble() {
return (
<div className="msg-bubble msg-bubble-assistant msg-typing">
<span className="typing-dots">
<span />
<span />
<span />
</span>
</div>
);
}
function shouldShowEndTypingIndicator(messages: Message[], streaming: boolean) {
if (!streaming) return false;
const lastUserIndex = messages.findLastIndex((message) => message.role === 'user');
if (lastUserIndex < 0) return true;
const assistantReply = messages
.slice(lastUserIndex + 1)
.find((message) => message.role === 'assistant');
if (!assistantReply) return true;
if (getDisplayText(assistantReply).trim() || getThinking(assistantReply)) return false;
if (hasToolActivity(assistantReply)) return false;
return true;
}
function ToolBadge({ message, active }: { message: Message; active: boolean }) {
if (message.role !== 'assistant') return null;
const tools = message.content.filter((c) => c.type === 'toolRequest' || c.type === 'toolResponse');
@@ -265,6 +290,7 @@ function MessageRow({
sessionId,
compact = false,
activeToolMessage = false,
showInlineTyping = false,
}: {
message: Message;
avatarUrl: string | null;
@@ -279,6 +305,7 @@ function MessageRow({
sessionId?: string;
compact?: boolean;
activeToolMessage?: boolean;
showInlineTyping?: boolean;
}) {
const [actionsOpen, setActionsOpen] = useState(false);
const rawText = getDisplayText(message);
@@ -295,7 +322,11 @@ function MessageRow({
<div className={`msg-row msg-row-assistant`}>
<TKMindAvatar />
<div className="msg-content">
<ToolBadge message={message} active={activeToolMessage} />
{showInlineTyping ? (
<TypingBubble />
) : (
<ToolBadge message={message} active={activeToolMessage} />
)}
</div>
</div>
);
@@ -471,6 +502,20 @@ export function MessageList({
const visibleMessages = renderableMessages.filter(
(message) => !isToolOnlyAssistantMessage(message) || message === activeToolMessage,
);
const showEndTyping = shouldShowEndTypingIndicator(renderableMessages, streaming);
const lastUserIndex = renderableMessages.findLastIndex((message) => message.role === 'user');
const inlineTypingMessage =
lastUserIndex >= 0
? renderableMessages
.slice(lastUserIndex + 1)
.find(
(message) =>
message.role === 'assistant' &&
!getDisplayText(message).trim() &&
!getThinking(message) &&
!hasToolActivity(message),
) ?? null
: null;
return (
<div className="message-list">
@@ -499,21 +544,16 @@ export function MessageList({
publishUsername={publishUsername}
compact={compact}
activeToolMessage={message === activeToolMessage}
showInlineTyping={showEndTyping && message === inlineTypingMessage}
/>
</div>
);
})}
{streaming && (
<div className="msg-row msg-row-assistant">
{showEndTyping && !inlineTypingMessage && (
<div className="msg-row msg-row-assistant" role="status" aria-live="polite" aria-label="助手正在回复">
<TKMindAvatar />
<div className="msg-content">
<div className="msg-bubble msg-bubble-assistant msg-typing">
<span className="typing-dots">
<span />
<span />
<span />
</span>
</div>
<TypingBubble />
</div>
</div>
)}
+1 -1
View File
@@ -112,7 +112,7 @@ export function MindSpaceSpaceChat({
) : null}
<SpaceChatFab
open={open}
streaming={chatState === 'streaming'}
streaming={chatState === 'waiting' || chatState === 'streaming'}
pendingTool={Boolean(pendingTool)}
onClick={() => setOpen(!open)}
/>