ai-chatbot/components/protocol-debug-panel.tsx
Your Name 6532cf6ebe
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: improve protocol debug panel
2026-03-16 11:57:54 +08:00

320 lines
13 KiB
TypeScript

"use client";
import { useMemo } from "react";
type ProtocolDebugEvent = {
id: string;
at: number;
payload: Record<string, unknown>;
};
type TaskBoundaryDecision = {
taskType?: string;
taskMode?: string;
reason?: string;
confidence?: number;
shouldUseCanvasContext?: boolean;
shouldUseHistoryImages?: boolean;
shouldUseWorkflowMemory?: boolean;
shouldAllowExecution?: boolean;
};
type AgentModelSlot = {
slot?: string;
modelId?: string;
displayName?: string;
providerType?: string;
providerKey?: string;
source?: string;
reason?: string;
locked?: boolean;
};
type ProviderHealthSnapshot = {
providerKey?: string;
providerType?: string;
lastEventType?: string;
lastError?: string;
consecutiveFailures?: number;
updatedAt?: number;
};
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function toTaskBoundaryDecision(value: unknown): TaskBoundaryDecision | null {
const record = asRecord(value);
if (!record) return null;
return {
taskType: typeof record.taskType === "string" ? record.taskType : undefined,
taskMode: typeof record.taskMode === "string" ? record.taskMode : undefined,
reason: typeof record.reason === "string" ? record.reason : undefined,
confidence:
typeof record.confidence === "number" && Number.isFinite(record.confidence)
? record.confidence
: undefined,
shouldUseCanvasContext:
typeof record.shouldUseCanvasContext === "boolean"
? record.shouldUseCanvasContext
: undefined,
shouldUseHistoryImages:
typeof record.shouldUseHistoryImages === "boolean"
? record.shouldUseHistoryImages
: undefined,
shouldUseWorkflowMemory:
typeof record.shouldUseWorkflowMemory === "boolean"
? record.shouldUseWorkflowMemory
: undefined,
shouldAllowExecution:
typeof record.shouldAllowExecution === "boolean"
? record.shouldAllowExecution
: undefined,
};
}
function toAgentModelSlots(value: unknown) {
const record = asRecord(value);
if (!record) return [] as AgentModelSlot[];
const slots: AgentModelSlot[] = [];
for (const [slotName, slotValue] of Object.entries(record)) {
const slotRecord = asRecord(slotValue);
if (!slotRecord) continue;
slots.push({
slot: slotName,
modelId: typeof slotRecord.modelId === "string" ? slotRecord.modelId : undefined,
displayName:
typeof slotRecord.displayName === "string" ? slotRecord.displayName : undefined,
providerType:
typeof slotRecord.providerType === "string" ? slotRecord.providerType : undefined,
providerKey:
typeof slotRecord.providerKey === "string" ? slotRecord.providerKey : undefined,
source: typeof slotRecord.source === "string" ? slotRecord.source : undefined,
reason: typeof slotRecord.reason === "string" ? slotRecord.reason : undefined,
locked: typeof slotRecord.locked === "boolean" ? slotRecord.locked : undefined,
});
}
return slots;
}
function toProviderHealthSnapshot(value: unknown): ProviderHealthSnapshot | null {
const record = asRecord(value);
if (!record) return null;
return {
providerKey: typeof record.providerKey === "string" ? record.providerKey : undefined,
providerType: typeof record.providerType === "string" ? record.providerType : undefined,
lastEventType:
typeof record.lastEventType === "string" ? record.lastEventType : undefined,
lastError: typeof record.lastError === "string" ? record.lastError : undefined,
consecutiveFailures:
typeof record.consecutiveFailures === "number" ? record.consecutiveFailures : undefined,
updatedAt: typeof record.updatedAt === "number" ? record.updatedAt : undefined,
};
}
export function ProtocolDebugPanel({
enabled,
events,
onClear,
}: {
enabled: boolean;
events: ProtocolDebugEvent[];
onClear?: () => void;
}) {
if (!enabled) return null;
const groupedEvents = useMemo(() => {
const groups = new Map<string, ProtocolDebugEvent[]>();
for (const event of events) {
const key = String(event.payload.phase || event.payload.type || "unknown");
const list = groups.get(key) || [];
list.push(event);
groups.set(key, list);
}
return Array.from(groups.entries()).map(([phase, items]) => ({
phase,
items,
}));
}, [events]);
const latestTaskBoundary = useMemo(() => {
for (let index = events.length - 1; index >= 0; index -= 1) {
const payload = events[index]?.payload;
const boundary = toTaskBoundaryDecision(payload?.taskBoundary);
if (boundary?.taskType) return boundary;
}
return null;
}, [events]);
const latestModelSlotSummary = useMemo(() => {
for (let index = events.length - 1; index >= 0; index -= 1) {
const payload = events[index]?.payload;
const slots = toAgentModelSlots(payload?.modelSlots);
if (slots.length > 0) {
return {
slots,
providerHealth: toProviderHealthSnapshot(payload?.providerHealth),
};
}
}
return null;
}, [events]);
return (
<div className="mx-auto mt-2 w-full max-w-4xl px-2 md:px-4">
<details className="rounded-xl border border-border/60 bg-muted/30 p-3 text-xs">
<summary className="cursor-pointer select-none font-medium text-foreground">
Protocol Debug
</summary>
<div className="mt-3 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-border/60 bg-background px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-md bg-muted px-2 py-1 text-[11px] text-muted-foreground">
总事件 {events.length}
</span>
<span className="rounded-md bg-muted px-2 py-1 text-[11px] text-muted-foreground">
阶段 {groupedEvents.length}
</span>
</div>
<button
type="button"
className="rounded-md border border-border px-2 py-1 text-[11px] text-foreground hover:bg-muted"
onClick={(event) => {
event.preventDefault();
onClear?.();
}}
>
清空
</button>
</div>
{(latestTaskBoundary || latestModelSlotSummary) && (
<div className="grid gap-2 md:grid-cols-2">
{latestTaskBoundary && (
<div className="rounded-lg border border-border/60 bg-background px-3 py-2">
<div className="mb-2 text-[11px] font-medium text-foreground">当前任务边界</div>
<div className="flex flex-wrap gap-2 text-[11px] text-muted-foreground">
<span className="rounded-md bg-muted px-2 py-1">
type: {latestTaskBoundary.taskType || "unknown"}
</span>
<span className="rounded-md bg-muted px-2 py-1">
mode: {latestTaskBoundary.taskMode || "unknown"}
</span>
{typeof latestTaskBoundary.confidence === "number" && (
<span className="rounded-md bg-muted px-2 py-1">
confidence: {latestTaskBoundary.confidence}
</span>
)}
</div>
<div className="mt-2 text-[11px] leading-relaxed text-muted-foreground">
{latestTaskBoundary.reason || "无原因说明"}
</div>
<div className="mt-2 flex flex-wrap gap-2 text-[11px] text-muted-foreground">
<span className="rounded-md border border-border px-2 py-1">
canvas {latestTaskBoundary.shouldUseCanvasContext ? "on" : "off"}
</span>
<span className="rounded-md border border-border px-2 py-1">
history {latestTaskBoundary.shouldUseHistoryImages ? "on" : "off"}
</span>
<span className="rounded-md border border-border px-2 py-1">
workflow {latestTaskBoundary.shouldUseWorkflowMemory ? "on" : "off"}
</span>
<span className="rounded-md border border-border px-2 py-1">
execution {latestTaskBoundary.shouldAllowExecution ? "yes" : "no"}
</span>
</div>
</div>
)}
{latestModelSlotSummary && (
<div className="rounded-lg border border-border/60 bg-background px-3 py-2">
<div className="mb-2 text-[11px] font-medium text-foreground">当前模型槽位</div>
<div className="space-y-2">
{latestModelSlotSummary.slots.map((slot) => (
<div
key={`${slot.slot}-${slot.modelId}`}
className="rounded-md border border-border/60 px-2 py-2"
>
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-md bg-muted px-2 py-1 text-[11px] font-medium text-foreground">
{slot.slot}
</span>
<span className="text-[11px] text-foreground">
{slot.displayName || slot.modelId || "unknown"}
</span>
{slot.locked && (
<span className="rounded-md border border-border px-2 py-1 text-[11px] text-muted-foreground">
locked
</span>
)}
</div>
<div className="mt-1 text-[11px] text-muted-foreground">
provider: {slot.providerType || "unknown"} · source: {slot.source || "unknown"}
</div>
{slot.reason && (
<div className="mt-1 text-[11px] text-muted-foreground">
reason: {slot.reason}
</div>
)}
</div>
))}
{latestModelSlotSummary.providerHealth && (
<div className="rounded-md border border-border/60 px-2 py-2 text-[11px] text-muted-foreground">
provider health: {latestModelSlotSummary.providerHealth.lastEventType || "unknown"}
{typeof latestModelSlotSummary.providerHealth.consecutiveFailures === "number" &&
` · failures ${latestModelSlotSummary.providerHealth.consecutiveFailures}`}
{latestModelSlotSummary.providerHealth.providerType &&
` · ${latestModelSlotSummary.providerHealth.providerType}`}
{latestModelSlotSummary.providerHealth.lastError && (
<div className="mt-1 break-words">
{latestModelSlotSummary.providerHealth.lastError}
</div>
)}
</div>
)}
</div>
</div>
)}
</div>
)}
{events.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/60 px-3 py-2 text-muted-foreground">
暂无协议调试事件
</div>
) : (
groupedEvents.map((group) => (
<details
key={group.phase}
className="overflow-hidden rounded-lg border border-border/60 bg-background"
open
>
<summary className="cursor-pointer border-b border-border/60 px-3 py-2 font-medium">
{group.phase}
{" · "}
{group.items.length} 条
</summary>
<div className="space-y-2 px-2 py-2">
{group.items.map((event) => (
<div
key={event.id}
className="overflow-hidden rounded-md border border-border/60"
>
<div className="border-b border-border/60 px-3 py-1.5 text-[11px] font-medium text-foreground">
{new Date(event.at).toLocaleTimeString()}
</div>
<pre className="overflow-x-auto px-3 py-2 whitespace-pre-wrap break-words text-[11px] leading-relaxed text-muted-foreground">
{JSON.stringify(event.payload, null, 2)}
</pre>
</div>
))}
</div>
</details>
))
)}
</div>
</details>
</div>
);
}