fix:修复麦麦观察的一些不一致问题(混乱!?

This commit is contained in:
SengokuCola
2026-05-07 21:49:25 +08:00
parent 827cdbd441
commit 9584f13f16
11 changed files with 261 additions and 63 deletions

View File

@@ -175,9 +175,12 @@ export interface PlannerFinalizedEvent {
request: MaisakaRequestBlock | null
planner: MaisakaPlannerBlock | null
tools: MaisakaFinalizedToolResult[]
interrupted?: boolean
final_state: {
time_records: Record<string, number>
agent_state: string
end_reason?: string
end_detail?: string
}
}
@@ -186,6 +189,8 @@ export interface CycleEndEvent {
cycle_id: number
time_records: Record<string, number>
agent_state: string
end_reason?: string
end_detail?: string
timestamp: number
}

View File

@@ -86,7 +86,7 @@ export interface LocalCacheStats {
export interface LocalCacheCleanupResult {
success: boolean
message: string
target: 'images' | 'emoji' | 'logs'
target: 'images' | 'emoji' | 'log_files' | 'database_logs'
removed_files: number
removed_bytes: number
removed_records: number

View File

@@ -6,6 +6,7 @@
*/
import {
Activity,
AlertCircle,
ArrowRight,
Bot,
Brain,
@@ -315,6 +316,38 @@ function openPromptHtml(uri: string) {
window.open(normalized, '_blank', 'noopener,noreferrer')
}
function isPlannerInterrupted(data: PlannerFinalizedEvent) {
const content = data.planner?.content?.trim() ?? ''
return data.interrupted === true || (
content.startsWith('Planner ') &&
data.planner?.prompt_tokens === 0 &&
data.planner?.completion_tokens === 0 &&
data.planner?.tool_calls.length === 0
)
}
function PlannerInterruptedCard({ data }: { data: PlannerFinalizedEvent }) {
const planner = data.planner
return (
<div className="rounded-md border border-amber-500/35 bg-amber-500/5 px-3 py-2">
<div className="flex items-center gap-2 text-sm">
<AlertCircle className="h-4 w-4 shrink-0 text-amber-500" />
<span className="font-medium">Planner </span>
<Badge variant="outline" className="ml-auto text-[10px]">
#{data.cycle_id}
</Badge>
{planner && planner.duration_ms > 0 && (
<span className="text-xs text-muted-foreground">{formatMs(planner.duration_ms)}</span>
)}
</div>
<p className="mt-1 text-sm text-muted-foreground">
{planner?.content || '收到新消息,已停止当前思考并准备重新决策。'}
</p>
</div>
)
}
function PlannerResponseCard({ data }: { data: PlannerResponseEvent }) {
return (
<div className="flex items-start gap-3">
@@ -501,26 +534,62 @@ function ToolExecutionCard({ data }: { data: ToolExecutionEvent }) {
)
}
function getCycleEndReasonText(data: CycleEndEvent) {
const reason = data.end_reason ?? ''
const detail = data.end_detail?.trim()
if (detail) {
return detail
}
if (reason === 'finish') return 'Planner 调用 finish结束本轮思考并等待新消息。'
if (reason === 'timing_no_reply') return 'Timing Gate 选择 no_reply本轮不会进入 Planner。'
if (reason === 'max_rounds') return '已达到内部思考轮次上限,本轮处理结束。'
if (reason === 'planner_interrupted') return 'Planner 被新消息打断,当前轮结束。'
if (reason.startsWith('tool_pause:')) return `工具 ${reason.slice('tool_pause:'.length)} 要求暂停当前思考循环。`
if (reason === 'tool_pause') return '工具要求暂停当前思考循环。'
if (reason === 'empty_planner_response') return 'Planner 没有返回文本或工具调用,本轮思考结束。'
if (reason === 'tool_continue') return 'Planner 工具执行完成,继续下一轮内部思考。'
return '本轮思考完成。'
}
function getCycleEndReasonLabel(data: CycleEndEvent) {
const reason = data.end_reason ?? ''
if (reason === 'finish') return 'finish 结束'
if (reason === 'timing_no_reply') return 'no_reply 结束'
if (reason === 'max_rounds') return '轮次上限'
if (reason === 'planner_interrupted') return 'Planner 打断'
if (reason.startsWith('tool_pause:')) return '工具暂停'
if (reason === 'tool_pause') return '工具暂停'
if (reason === 'empty_planner_response') return '空响应'
if (reason === 'tool_continue') return '继续下一轮'
return '循环结束'
}
function CycleEndCard({ data }: { data: CycleEndEvent }) {
const totalTime = Object.values(data.time_records).reduce((a, b) => a + b, 0)
return (
<div className="my-1 flex items-center gap-3">
<Separator className="flex-1" />
<div className="flex items-center gap-2 rounded-full border bg-background px-3 py-1">
<CircleDot className="h-3.5 w-3.5 text-slate-500" />
<span className="text-xs text-muted-foreground"></span>
<Badge variant="outline" className="text-[10px]">
#{data.cycle_id}
</Badge>
<span className="text-[10px] text-muted-foreground">{formatMs(totalTime * 1000)}</span>
<Badge
variant={data.agent_state === 'running' ? 'default' : 'secondary'}
className="text-[10px]"
>
{data.agent_state}
</Badge>
<div className="my-1 space-y-1.5">
<div className="flex items-center gap-3">
<Separator className="flex-1" />
<div className="flex items-center gap-2 rounded-full border bg-background px-3 py-1">
<CircleDot className="h-3.5 w-3.5 text-slate-500" />
<span className="text-xs text-muted-foreground">{getCycleEndReasonLabel(data)}</span>
<Badge variant="outline" className="text-[10px]">
#{data.cycle_id}
</Badge>
<span className="text-[10px] text-muted-foreground">{formatMs(totalTime * 1000)}</span>
<Badge
variant={data.agent_state === 'running' ? 'default' : 'secondary'}
className="text-[10px]"
>
{data.agent_state}
</Badge>
</div>
<Separator className="flex-1" />
</div>
<Separator className="flex-1" />
<p className="text-center text-xs text-muted-foreground">{getCycleEndReasonText(data)}</p>
</div>
)
}
@@ -648,7 +717,10 @@ function TimelineEventRenderer({
case 'planner.response':
return <PlannerResponseCard data={entry.data as PlannerResponseEvent} />
case 'planner.finalized':
if ((entry.data as PlannerFinalizedEvent).timing_gate?.result?.action !== 'continue') {
if (isPlannerInterrupted(entry.data as PlannerFinalizedEvent)) {
return <PlannerInterruptedCard data={entry.data as PlannerFinalizedEvent} />
}
if ((entry.data as PlannerFinalizedEvent).timing_gate?.result?.action === 'no_reply') {
return null
}
return (
@@ -856,23 +928,32 @@ export function MaisakaMonitor() {
</div>
) : (
(() => {
const continuedTimingGateCycles = new Set<string>()
const stoppedTimingGateCycles = new Set<string>()
const noReplyTimingGateCycles = new Set<string>()
return timeline.map((entry) => {
if (entry.type === 'timing_gate.result') {
const data = entry.data as TimingGateResultEvent
if (data.action === 'continue') {
continuedTimingGateCycles.add(buildCycleKey(data.session_id, data.cycle_id))
} else {
stoppedTimingGateCycles.add(buildCycleKey(data.session_id, data.cycle_id))
if (data.action === 'no_reply') {
noReplyTimingGateCycles.add(buildCycleKey(data.session_id, data.cycle_id))
}
}
if (entry.type === 'planner.response' || entry.type === 'planner.finalized') {
const data = entry.data as PlannerResponseEvent | PlannerFinalizedEvent
const cycleKey = buildCycleKey(data.session_id, data.cycle_id)
if (stoppedTimingGateCycles.has(cycleKey) || !continuedTimingGateCycles.has(cycleKey)) {
if (entry.type === 'planner.finalized' && isPlannerInterrupted(data as PlannerFinalizedEvent)) {
const rendered = <TimelineEventRenderer entry={entry} showCycleMarkers={showCycleMarkers} />
if (!rendered) return null
return (
<div
key={entry.id}
className="animate-in fade-in-0 slide-in-from-bottom-2 duration-300"
>
{rendered}
</div>
)
}
if (noReplyTimingGateCycles.has(cycleKey)) {
return null
}
}
@@ -897,3 +978,4 @@ export function MaisakaMonitor() {
</div>
)
}

View File

@@ -389,6 +389,14 @@ function updateSessionInfo(event: MaisakaMonitorEvent, sessionId: string, timest
}
function updateStageStatus(event: MaisakaMonitorEvent) {
const applyStatusIfFresh = (next: Map<string, StageStatusInfo>, status: StageStatusInfo) => {
const existing = next.get(status.sessionId)
if (existing && status.updatedAt < existing.updatedAt) {
return
}
next.set(status.sessionId, status)
}
if (event.type === 'stage.snapshot') {
const rawEntries = (event.data as unknown as Record<string, unknown>).entries
if (!Array.isArray(rawEntries)) {
@@ -401,7 +409,7 @@ function updateStageStatus(event: MaisakaMonitorEvent) {
}
const status = toStageStatusInfo(rawEntry as Record<string, unknown>)
if (status) {
next.set(status.sessionId, status)
applyStatusIfFresh(next, status)
}
}
cachedStageStatuses = next
@@ -414,7 +422,7 @@ function updateStageStatus(event: MaisakaMonitorEvent) {
return
}
const next = new Map(cachedStageStatuses)
next.set(status.sessionId, status)
applyStatusIfFresh(next, status)
cachedStageStatuses = next
return
}

View File

@@ -19,6 +19,7 @@ import {
cleanupLocalCache,
getLocalCacheStats,
type CacheDirectoryStats,
type LocalCacheCleanupTarget,
type LocalCacheStats,
type LogCleanupTable,
} from '@/lib/system-api'
@@ -60,9 +61,12 @@ function DirectoryCard({
}: {
item: CacheDirectoryStats
cleanupDisabled: boolean
onCleanup: (target: 'images' | 'emoji') => void
onCleanup: (target: 'images' | 'emoji' | 'log_files') => void
}) {
const cleanupTarget = item.key === 'images' ? 'images' : item.key === 'emoji' ? 'emoji' : null
const cleanupTarget = item.key === 'images' ? 'images' : item.key === 'emoji' ? 'emoji' : item.key === 'logs' ? 'log_files' : null
const cleanupDescription = cleanupTarget === 'log_files'
? '这会删除 logs 目录中的日志文件。操作不可撤销。'
: '这会删除对应目录中的文件,并移除数据库里的相关记录。操作不可撤销。'
return (
<div className="rounded-lg border bg-card p-4">
@@ -86,7 +90,7 @@ function DirectoryCard({
<AlertDialogHeader>
<AlertDialogTitle>{item.label}</AlertDialogTitle>
<AlertDialogDescription>
{cleanupDescription}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -124,7 +128,7 @@ export function LocalCacheTab() {
const { toast } = useToast()
const [stats, setStats] = useState<LocalCacheStats | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [cleanupTarget, setCleanupTarget] = useState<string | null>(null)
const [cleanupTarget, setCleanupTarget] = useState<LocalCacheCleanupTarget | null>(null)
const [selectedLogTables, setSelectedLogTables] = useState<LogCleanupTable[]>([])
const tableRows = useMemo(() => {
@@ -152,7 +156,7 @@ export function LocalCacheTab() {
}
}, [toast])
const handleDirectoryCleanup = async (target: 'images' | 'emoji') => {
const handleDirectoryCleanup = async (target: 'images' | 'emoji' | 'log_files') => {
setCleanupTarget(target)
try {
const result = await cleanupLocalCache(target)
@@ -173,18 +177,18 @@ export function LocalCacheTab() {
}
const handleLogCleanup = async () => {
setCleanupTarget('logs')
setCleanupTarget('database_logs')
try {
const result = await cleanupLocalCache('logs', selectedLogTables)
const result = await cleanupLocalCache('database_logs', selectedLogTables)
setSelectedLogTables([])
await refreshStats()
toast({
title: result.message,
description: `已清理 ${result.removed_records}日志记录。`,
description: `已清理 ${result.removed_records}数据库记录。`,
})
} catch (error) {
toast({
title: '日志清理失败',
title: '数据库清理失败',
description: error instanceof Error ? error.message : '请稍后重试',
variant: 'destructive',
})
@@ -242,22 +246,22 @@ export function LocalCacheTab() {
<div>
<h3 className="flex items-center gap-2 text-base font-semibold sm:text-lg">
<Database className="h-5 w-5" />
</h3>
<p className="mt-1 text-xs text-muted-foreground sm:text-sm">
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="gap-2" disabled={cleanupTarget !== null || isLoading}>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{formatBytes(stats?.database.total_size ?? 0)}
</AlertDialogDescription>