chore: stabilize Zhinian pilot delivery
This commit is contained in:
@@ -505,6 +505,7 @@ export function ChatInput({
|
||||
type="button"
|
||||
onClick={() => toggleQuickTask(task.id)}
|
||||
disabled={disabled || sending}
|
||||
data-testid={`chat-quick-task-${task.id}`}
|
||||
className={cn(
|
||||
'inline-flex max-w-[220px] items-center gap-1.5 rounded-lg border px-2.5 py-1 text-[12px] font-medium shadow-sm transition-colors',
|
||||
selected
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AlertCircle, Loader2, Sparkles } from 'lucide-react';
|
||||
import { useChatStore, type RawMessage } from '@/stores/chat';
|
||||
import { sanitizeAssistantMessages } from '@/stores/chat/assistant-output-sanitizer';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { useAgentsStore } from '@/stores/agents';
|
||||
import { useYinianStore } from '@/stores/yinian';
|
||||
@@ -85,6 +86,7 @@ export function Chat() {
|
||||
const streamingTools = useChatStore((s) => s.streamingTools);
|
||||
const pendingFinal = useChatStore((s) => s.pendingFinal);
|
||||
const activeRunId = useChatStore((s) => s.activeRunId);
|
||||
const activeRunSessionKey = useChatStore((s) => s.activeRunSessionKey);
|
||||
const sendMessage = useChatStore((s) => s.sendMessage);
|
||||
const abortRun = useChatStore((s) => s.abortRun);
|
||||
const clearError = useChatStore((s) => s.clearError);
|
||||
@@ -166,7 +168,7 @@ export function Chat() {
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return { sessionId: completion.sessionId, messages: result.messages || [] };
|
||||
return { sessionId: completion.sessionId, messages: sanitizeAssistantMessages(result.messages || []) };
|
||||
} catch (error) {
|
||||
console.warn('Failed to load child transcript:', {
|
||||
agentId: completion.agentId,
|
||||
@@ -193,24 +195,33 @@ export function Chat() {
|
||||
};
|
||||
}, [messages, childTranscripts]);
|
||||
|
||||
const streamMsg = streamingMessage && typeof streamingMessage === 'object'
|
||||
? streamingMessage as unknown as { role?: string; content?: unknown; timestamp?: number }
|
||||
const currentSessionRunning = sending && activeRunSessionKey === currentSessionKey;
|
||||
const visibleStreamingMessage = currentSessionRunning ? streamingMessage : null;
|
||||
const visibleStreamingTools = currentSessionRunning ? streamingTools : [];
|
||||
const visiblePendingFinal = currentSessionRunning ? pendingFinal : false;
|
||||
const visibleActiveRunId = currentSessionRunning ? activeRunId : null;
|
||||
const visibleError = sending && activeRunSessionKey && activeRunSessionKey !== currentSessionKey
|
||||
? null
|
||||
: error;
|
||||
|
||||
const streamMsg = visibleStreamingMessage && typeof visibleStreamingMessage === 'object'
|
||||
? visibleStreamingMessage as unknown as { role?: string; content?: unknown; timestamp?: number }
|
||||
: null;
|
||||
const streamTimestamp = typeof streamMsg?.timestamp === 'number' ? streamMsg.timestamp : 0;
|
||||
useEffect(() => {
|
||||
if (!sending) {
|
||||
if (!currentSessionRunning) {
|
||||
streamingTimestampStore.delete(currentSessionKey);
|
||||
return;
|
||||
}
|
||||
if (!streamingTimestampStore.has(currentSessionKey)) {
|
||||
streamingTimestampStore.set(currentSessionKey, streamTimestamp || Date.now() / 1000);
|
||||
}
|
||||
}, [currentSessionKey, sending, streamTimestamp]);
|
||||
}, [currentSessionKey, currentSessionRunning, streamTimestamp]);
|
||||
|
||||
const streamingTimestamp = sending
|
||||
const streamingTimestamp = currentSessionRunning
|
||||
? (streamingTimestampStore.get(currentSessionKey) ?? streamTimestamp)
|
||||
: 0;
|
||||
const streamText = streamMsg ? extractText(streamMsg) : (typeof streamingMessage === 'string' ? streamingMessage : '');
|
||||
const streamText = streamMsg ? extractText(streamMsg) : (typeof visibleStreamingMessage === 'string' ? visibleStreamingMessage : '');
|
||||
const hasStreamText = streamText.trim().length > 0;
|
||||
// Whether the streaming chunk currently carries a `thinking` block. Used as
|
||||
// a liveness signal so the run stays "active" (and the ExecutionGraphCard
|
||||
@@ -225,12 +236,12 @@ export function Chat() {
|
||||
const hasStreamTools = streamTools.length > 0;
|
||||
const streamImages = streamMsg ? extractImages(streamMsg) : [];
|
||||
const hasStreamImages = streamImages.length > 0;
|
||||
const hasStreamToolStatus = streamingTools.length > 0;
|
||||
const hasRunningStreamToolStatus = streamingTools.some((tool) => tool.status === 'running');
|
||||
const shouldRenderStreaming = sending && (hasStreamText || hasStreamTools || hasStreamImages || hasStreamToolStatus);
|
||||
const hasStreamToolStatus = visibleStreamingTools.length > 0;
|
||||
const hasRunningStreamToolStatus = visibleStreamingTools.some((tool) => tool.status === 'running');
|
||||
const shouldRenderStreaming = currentSessionRunning && (hasStreamText || hasStreamTools || hasStreamImages || hasStreamToolStatus);
|
||||
const hasAnyStreamContent = hasStreamText || hasStreamThinking || hasStreamTools || hasStreamImages || hasStreamToolStatus;
|
||||
|
||||
const isEmpty = messages.length === 0 && !sending;
|
||||
const isEmpty = messages.length === 0 && !currentSessionRunning;
|
||||
const subagentCompletionInfos = messages.map((message) => parseSubagentCompletionInfo(message));
|
||||
// Build an index of the *next* real user message after each position.
|
||||
// Gateway history may contain `role: 'user'` messages that are actually
|
||||
@@ -311,15 +322,15 @@ export function Chat() {
|
||||
// (which clears activeRunId), we must NOT keep the run "open" — so we
|
||||
// gate it on activeRunId being present.
|
||||
const isLatestOpenRun = nextUserIndex === -1
|
||||
&& (sending || pendingFinal || hasAnyStreamContent || (runStillExecutingTools && !!activeRunId));
|
||||
&& (currentSessionRunning || visiblePendingFinal || hasAnyStreamContent || (runStillExecutingTools && !!visibleActiveRunId));
|
||||
const replyIndexOffset = findReplyMessageIndex(segmentMessages, isLatestOpenRun);
|
||||
const replyIndex = replyIndexOffset === -1 ? null : idx + 1 + replyIndexOffset;
|
||||
|
||||
const buildSteps = (omitLastStreamingMessageSegment: boolean): TaskStep[] => {
|
||||
let builtSteps = deriveTaskSteps({
|
||||
messages: segmentMessages,
|
||||
streamingMessage: isLatestOpenRun ? streamingMessage : null,
|
||||
streamingTools: isLatestOpenRun ? streamingTools : [],
|
||||
streamingMessage: isLatestOpenRun ? visibleStreamingMessage : null,
|
||||
streamingTools: isLatestOpenRun ? visibleStreamingTools : [],
|
||||
omitLastStreamingMessageSegment: isLatestOpenRun ? omitLastStreamingMessageSegment : false,
|
||||
});
|
||||
|
||||
@@ -384,9 +395,9 @@ export function Chat() {
|
||||
// `suppressThinking` coupling below — not here. With the coupling
|
||||
// fixed, the three-signal gate gives the correct bubble placement for
|
||||
// both narration and final reply.
|
||||
const allToolsCompleted = streamingTools.length > 0 && !hasRunningStreamToolStatus;
|
||||
const allToolsCompleted = visibleStreamingTools.length > 0 && !hasRunningStreamToolStatus;
|
||||
const rawStreamingReplyCandidate = isLatestOpenRun
|
||||
&& (pendingFinal || allToolsCompleted || hasToolActivity)
|
||||
&& (visiblePendingFinal || allToolsCompleted || hasToolActivity)
|
||||
&& (hasStreamText || hasStreamImages)
|
||||
&& streamTools.length === 0
|
||||
&& !hasRunningStreamToolStatus;
|
||||
@@ -701,17 +712,17 @@ export function Chat() {
|
||||
})()}
|
||||
textOverride={streamingReplyText ?? undefined}
|
||||
isStreaming
|
||||
streamingTools={streamingReplyText != null ? [] : streamingTools}
|
||||
streamingTools={streamingReplyText != null ? [] : visibleStreamingTools}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Activity indicator: waiting for next AI turn after tool execution */}
|
||||
{sending && pendingFinal && !shouldRenderStreaming && !hasActiveExecutionGraph && (
|
||||
{currentSessionRunning && visiblePendingFinal && !shouldRenderStreaming && !hasActiveExecutionGraph && (
|
||||
<ActivityIndicator phase="tool_processing" />
|
||||
)}
|
||||
|
||||
{/* Typing indicator when sending but no stream content yet */}
|
||||
{sending && !pendingFinal && !hasAnyStreamContent && !hasActiveExecutionGraph && (
|
||||
{currentSessionRunning && !visiblePendingFinal && !hasAnyStreamContent && !hasActiveExecutionGraph && (
|
||||
<TypingIndicator />
|
||||
)}
|
||||
</>
|
||||
@@ -723,12 +734,12 @@ export function Chat() {
|
||||
</div>
|
||||
|
||||
{/* Error bar */}
|
||||
{error && (
|
||||
{visibleError && (
|
||||
<div className="px-4 py-2 bg-destructive/10 border-t border-destructive/20">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between">
|
||||
<p className="text-sm text-destructive flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{error}
|
||||
{visibleError}
|
||||
</p>
|
||||
<button
|
||||
onClick={clearError}
|
||||
@@ -745,14 +756,14 @@ export function Chat() {
|
||||
onSend={sendMessage}
|
||||
onStop={abortRun}
|
||||
disabled={!isGatewayRunning}
|
||||
sending={sending || hasActiveExecutionGraph}
|
||||
sending={currentSessionRunning || hasActiveExecutionGraph}
|
||||
isEmpty={isEmpty}
|
||||
knowledgeDocuments={knowledgeDocuments}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
|
||||
{/* Transparent loading overlay */}
|
||||
{minLoading && !sending && (
|
||||
{minLoading && !currentSessionRunning && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/20 backdrop-blur-[1px] rounded-xl pointer-events-auto">
|
||||
<div className="bg-background shadow-lg rounded-full p-2.5 border border-border">
|
||||
<LoadingSpinner size="md" />
|
||||
|
||||
@@ -10,8 +10,16 @@ import type { RawMessage, ContentBlock } from '@/stores/chat';
|
||||
* Strips: [media attached: ... | ...], [message_id: ...],
|
||||
* and the timestamp prefix [Day Date Time Timezone].
|
||||
*/
|
||||
const KNOWLEDGE_CONTEXT_MARKER = '[知识库上下文]';
|
||||
|
||||
function stripInjectedKnowledgeContext(text: string): string {
|
||||
const markerIndex = text.indexOf(KNOWLEDGE_CONTEXT_MARKER);
|
||||
if (markerIndex < 0) return text;
|
||||
return text.slice(0, markerIndex).trimEnd();
|
||||
}
|
||||
|
||||
function cleanUserText(text: string): string {
|
||||
return text
|
||||
return stripInjectedKnowledgeContext(text)
|
||||
// Remove [media attached: path (mime) | path] references
|
||||
.replace(/\s*\[media attached:[^\]]*\]/g, '')
|
||||
// Remove [message_id: uuid]
|
||||
|
||||
Reference in New Issue
Block a user