feat(ui): Enhanced agent thinking detection for Ollama models
- Added semantic detection module (agent-status-detection.ts) that analyzes message content for keywords like 'standby', 'processing', 'analyzing' - Updated isAgentThinking() in multi-task-chat to use semantic detection when streaming has technically ended but agent is conceptually working - Added dynamic status messages (PROCESSING, AGENT PROCESSING, etc.) instead of static THINKING/STREAMING labels - Enhanced session-status.ts to check semantic content before returning idle - Fixes issue where Ollama models output status messages and pause, causing UI to incorrectly show ready-to-send state
This commit is contained in:
@@ -10,6 +10,7 @@ import { addToTaskQueue, getSoloState, setActiveTaskId, toggleAutonomous, toggle
|
|||||||
import { getLogger } from "@/lib/logger";
|
import { getLogger } from "@/lib/logger";
|
||||||
import { clearCompactionSuggestion, getCompactionSuggestion } from "@/stores/session-compaction";
|
import { clearCompactionSuggestion, getCompactionSuggestion } from "@/stores/session-compaction";
|
||||||
import { emitSessionSidebarRequest } from "@/lib/session-sidebar-events";
|
import { emitSessionSidebarRequest } from "@/lib/session-sidebar-events";
|
||||||
|
import { detectAgentWorkingState, getAgentStatusMessage } from "@/lib/agent-status-detection";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -216,7 +217,36 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
|||||||
|
|
||||||
const store = messageStore();
|
const store = messageStore();
|
||||||
const lastMsg = store.getMessage(ids[ids.length - 1]);
|
const lastMsg = store.getMessage(ids[ids.length - 1]);
|
||||||
return lastMsg?.role === "assistant" && (lastMsg.status === "streaming" || lastMsg.status === "sending");
|
|
||||||
|
// Basic check: streaming or sending status
|
||||||
|
if (lastMsg?.role === "assistant" && (lastMsg.status === "streaming" || lastMsg.status === "sending")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced check: semantic detection for "standby", "processing" messages
|
||||||
|
// This catches Ollama models that output status messages and pause
|
||||||
|
if (lastMsg?.role === "assistant") {
|
||||||
|
const workingState = detectAgentWorkingState(lastMsg);
|
||||||
|
return workingState.isWorking;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get dynamic status message for display
|
||||||
|
const agentStatusMessage = createMemo(() => {
|
||||||
|
const ids = filteredMessageIds();
|
||||||
|
if (ids.length === 0) return "THINKING";
|
||||||
|
|
||||||
|
const store = messageStore();
|
||||||
|
const lastMsg = store.getMessage(ids[ids.length - 1]);
|
||||||
|
|
||||||
|
if (!lastMsg || lastMsg.role !== "assistant") {
|
||||||
|
return "THINKING";
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusMsg = getAgentStatusMessage(lastMsg);
|
||||||
|
return statusMsg?.toUpperCase() || "THINKING";
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-scroll during streaming - DISABLED for performance testing
|
// Auto-scroll during streaming - DISABLED for performance testing
|
||||||
@@ -539,7 +569,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
|||||||
<Show when={isAgentThinking()}>
|
<Show when={isAgentThinking()}>
|
||||||
<div class="flex items-center space-x-2 px-3 py-1.5 bg-violet-500/15 border border-violet-500/30 rounded-lg animate-pulse shadow-[0_0_20px_rgba(139,92,246,0.2)]">
|
<div class="flex items-center space-x-2 px-3 py-1.5 bg-violet-500/15 border border-violet-500/30 rounded-lg animate-pulse shadow-[0_0_20px_rgba(139,92,246,0.2)]">
|
||||||
<Sparkles size={12} class="text-violet-400 animate-spin" style={{ "animation-duration": "3s" }} />
|
<Sparkles size={12} class="text-violet-400 animate-spin" style={{ "animation-duration": "3s" }} />
|
||||||
<span class="text-[10px] font-black text-violet-400 uppercase tracking-tight">Streaming</span>
|
<span class="text-[10px] font-black text-violet-400 uppercase tracking-tight">{agentStatusMessage()}</span>
|
||||||
<span class="text-[10px] font-bold text-violet-300">{formatTokenTotal(tokenStats().used)}</span>
|
<span class="text-[10px] font-bold text-violet-300">{formatTokenTotal(tokenStats().used)}</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -846,7 +876,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
|||||||
<div class="w-1 h-1 bg-indigo-400 rounded-full animate-bounce" style={{ "animation-delay": "150ms" }} />
|
<div class="w-1 h-1 bg-indigo-400 rounded-full animate-bounce" style={{ "animation-delay": "150ms" }} />
|
||||||
<div class="w-1 h-1 bg-indigo-400 rounded-full animate-bounce" style={{ "animation-delay": "300ms" }} />
|
<div class="w-1 h-1 bg-indigo-400 rounded-full animate-bounce" style={{ "animation-delay": "300ms" }} />
|
||||||
</div>
|
</div>
|
||||||
<span class="text-[9px] font-bold text-indigo-400">{isAgentThinking() ? "THINKING" : "SENDING"}</span>
|
<span class="text-[9px] font-bold text-indigo-400">{isSending() ? "SENDING" : agentStatusMessage()}</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
249
packages/ui/src/lib/agent-status-detection.ts
Normal file
249
packages/ui/src/lib/agent-status-detection.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* Agent Status Detection Module
|
||||||
|
*
|
||||||
|
* Provides intelligent detection of when an agent is still "working" even after
|
||||||
|
* streaming has technically completed. This handles cases where:
|
||||||
|
* 1. Agent outputs "standby", "processing", "working" messages
|
||||||
|
* 2. Agent is in multi-step reasoning mode
|
||||||
|
* 3. Ollama models pause between thinking and output phases
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
|
|
||||||
|
// Keywords that indicate the agent is still processing
|
||||||
|
const WORKING_KEYWORDS = [
|
||||||
|
"standby",
|
||||||
|
"stand by",
|
||||||
|
"processing",
|
||||||
|
"please wait",
|
||||||
|
"working on",
|
||||||
|
"analyzing",
|
||||||
|
"thinking",
|
||||||
|
"computing",
|
||||||
|
"calculating",
|
||||||
|
"evaluating",
|
||||||
|
"generating",
|
||||||
|
"preparing",
|
||||||
|
"loading",
|
||||||
|
"fetching",
|
||||||
|
"retrieving",
|
||||||
|
"in progress",
|
||||||
|
"one moment",
|
||||||
|
"hold on",
|
||||||
|
"just a sec",
|
||||||
|
"give me a moment",
|
||||||
|
"let me",
|
||||||
|
"i'll",
|
||||||
|
"i will",
|
||||||
|
"checking",
|
||||||
|
"scanning",
|
||||||
|
"searching",
|
||||||
|
"looking",
|
||||||
|
"finding"
|
||||||
|
] as const
|
||||||
|
|
||||||
|
// Keywords that indicate the agent has finished
|
||||||
|
const COMPLETION_KEYWORDS = [
|
||||||
|
"here is",
|
||||||
|
"here's",
|
||||||
|
"here are",
|
||||||
|
"done",
|
||||||
|
"complete",
|
||||||
|
"finished",
|
||||||
|
"result",
|
||||||
|
"solution",
|
||||||
|
"answer",
|
||||||
|
"output",
|
||||||
|
"summary",
|
||||||
|
"conclusion",
|
||||||
|
"final",
|
||||||
|
"successfully",
|
||||||
|
"implemented",
|
||||||
|
"fixed",
|
||||||
|
"resolved",
|
||||||
|
"created",
|
||||||
|
"updated"
|
||||||
|
] as const
|
||||||
|
|
||||||
|
// Patterns that strongly indicate agent is still working
|
||||||
|
const WORKING_PATTERNS = [
|
||||||
|
/stand\s*by/i,
|
||||||
|
/processing\s*(complete\s*)?data/i,
|
||||||
|
/please\s+wait/i,
|
||||||
|
/working\s+on/i,
|
||||||
|
/analyzing/i,
|
||||||
|
/\bwait\b/i,
|
||||||
|
/\bone\s+moment\b/i,
|
||||||
|
/\bhold\s+on\b/i,
|
||||||
|
/\.\.\.\s*$/, // Ends with ellipsis
|
||||||
|
/…\s*$/, // Ends with unicode ellipsis
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts text content from a message's parts
|
||||||
|
*/
|
||||||
|
function extractMessageText(message: MessageRecord): string {
|
||||||
|
const textParts: string[] = []
|
||||||
|
|
||||||
|
for (const partId of message.partIds) {
|
||||||
|
const part = message.parts[partId]
|
||||||
|
if (part?.data) {
|
||||||
|
const data = part.data as Record<string, unknown>
|
||||||
|
if (data.type === "text" && typeof data.text === "string") {
|
||||||
|
textParts.push(data.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return textParts.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the last N characters of a message for keyword detection
|
||||||
|
*/
|
||||||
|
function getRecentContent(message: MessageRecord, charLimit = 500): string {
|
||||||
|
const fullText = extractMessageText(message)
|
||||||
|
if (fullText.length <= charLimit) {
|
||||||
|
return fullText.toLowerCase()
|
||||||
|
}
|
||||||
|
return fullText.slice(-charLimit).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the message content indicates the agent is still working
|
||||||
|
*/
|
||||||
|
export function detectAgentWorkingState(message: MessageRecord | null | undefined): {
|
||||||
|
isWorking: boolean
|
||||||
|
reason?: string
|
||||||
|
confidence: "high" | "medium" | "low"
|
||||||
|
} {
|
||||||
|
if (!message) {
|
||||||
|
return { isWorking: false, confidence: "high" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// If message status is streaming or sending, definitely working
|
||||||
|
if (message.status === "streaming" || message.status === "sending") {
|
||||||
|
return { isWorking: true, reason: "Active streaming", confidence: "high" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recent content to analyze
|
||||||
|
const recentContent = getRecentContent(message)
|
||||||
|
|
||||||
|
if (!recentContent) {
|
||||||
|
return { isWorking: false, confidence: "high" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for working patterns with high confidence
|
||||||
|
for (const pattern of WORKING_PATTERNS) {
|
||||||
|
if (pattern.test(recentContent)) {
|
||||||
|
return {
|
||||||
|
isWorking: true,
|
||||||
|
reason: `Pattern match: ${pattern.source}`,
|
||||||
|
confidence: "high"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if recent content ends with working keywords
|
||||||
|
const lastLine = recentContent.split("\n").pop()?.trim() || ""
|
||||||
|
|
||||||
|
for (const keyword of WORKING_KEYWORDS) {
|
||||||
|
if (lastLine.includes(keyword)) {
|
||||||
|
// Check if there's also a completion keyword nearby
|
||||||
|
const hasCompletionNearby = COMPLETION_KEYWORDS.some(ck =>
|
||||||
|
recentContent.slice(-200).includes(ck)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!hasCompletionNearby) {
|
||||||
|
return {
|
||||||
|
isWorking: true,
|
||||||
|
reason: `Working keyword: "${keyword}"`,
|
||||||
|
confidence: "medium"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check message age - if very recent and short, might still be working
|
||||||
|
const now = Date.now()
|
||||||
|
const messageAge = now - message.updatedAt
|
||||||
|
const contentLength = extractMessageText(message).length
|
||||||
|
|
||||||
|
// If message was updated very recently (< 2s) and content is short
|
||||||
|
if (messageAge < 2000 && contentLength < 100) {
|
||||||
|
return {
|
||||||
|
isWorking: true,
|
||||||
|
reason: "Recently updated with short content",
|
||||||
|
confidence: "low"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isWorking: false, confidence: "high" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the last assistant message indicates agent is still conceptually working
|
||||||
|
*/
|
||||||
|
export function isAgentConceptuallyThinking(
|
||||||
|
messages: MessageRecord[],
|
||||||
|
lastAssistantMessage: MessageRecord | null | undefined
|
||||||
|
): boolean {
|
||||||
|
if (!lastAssistantMessage) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if message status indicates active work
|
||||||
|
if (lastAssistantMessage.status === "streaming" ||
|
||||||
|
lastAssistantMessage.status === "sending") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use semantic detection
|
||||||
|
const workingState = detectAgentWorkingState(lastAssistantMessage)
|
||||||
|
return workingState.isWorking
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a user-friendly status message for the current agent state
|
||||||
|
*/
|
||||||
|
export function getAgentStatusMessage(
|
||||||
|
message: MessageRecord | null | undefined
|
||||||
|
): string | null {
|
||||||
|
if (!message) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const workingState = detectAgentWorkingState(message)
|
||||||
|
|
||||||
|
if (!workingState.isWorking) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.status === "streaming") {
|
||||||
|
return "Streaming..."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.status === "sending") {
|
||||||
|
return "Sending..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Based on reason
|
||||||
|
if (workingState.reason?.includes("standby") ||
|
||||||
|
workingState.reason?.includes("stand by")) {
|
||||||
|
return "Agent processing..."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workingState.reason?.includes("processing")) {
|
||||||
|
return "Processing..."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workingState.reason?.includes("analyzing")) {
|
||||||
|
return "Analyzing..."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workingState.reason?.includes("ellipsis")) {
|
||||||
|
return "Thinking..."
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Working..."
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { MessageRecord } from "./message-v2/types"
|
|||||||
import { sessions } from "./sessions"
|
import { sessions } from "./sessions"
|
||||||
import { getSessionCompactionState } from "./session-compaction"
|
import { getSessionCompactionState } from "./session-compaction"
|
||||||
import { messageStoreBus } from "./message-v2/bus"
|
import { messageStoreBus } from "./message-v2/bus"
|
||||||
|
import { detectAgentWorkingState } from "../lib/agent-status-detection"
|
||||||
|
|
||||||
function getSession(instanceId: string, sessionId: string): Session | null {
|
function getSession(instanceId: string, sessionId: string): Session | null {
|
||||||
const instanceSessions = sessions().get(instanceId)
|
const instanceSessions = sessions().get(instanceId)
|
||||||
@@ -159,6 +160,15 @@ export function getSessionStatus(instanceId: string, sessionId: string): Session
|
|||||||
return "working"
|
return "working"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enhanced: Check if last assistant message content suggests agent is still working
|
||||||
|
// This catches Ollama models that output "standby", "processing" messages and pause
|
||||||
|
if (lastRecord && lastRecord.role === "assistant") {
|
||||||
|
const workingState = detectAgentWorkingState(lastRecord)
|
||||||
|
if (workingState.isWorking && workingState.confidence !== "low") {
|
||||||
|
return "working"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return "idle"
|
return "idle"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user