chore: stabilize Zhinian pilot delivery

This commit is contained in:
inman
2026-05-12 19:44:44 +08:00
parent 45389855e1
commit 20b5aff4ad
174 changed files with 41428 additions and 784 deletions

View File

@@ -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

View File

@@ -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" />

View File

@@ -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]