Files
pikasTech-HWLAB/web/hwlab-cloud-web/src/utils/workbench-performance.ts
T
2026-06-17 18:45:19 +08:00

438 lines
18 KiB
TypeScript

// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0
// Browser-side Workbench journey probe. Prometheus label shaping is enforced again by cloud-api.
import type { ChatMessage, TraceEvent } from "@/types";
type WorkbenchEventKind = "workbench_journey" | "workbench_event_phase" | "workbench_backend_event_visible";
type WorkbenchOutcome = "ok" | "timeout" | "error" | "dropped" | "stale" | "partial" | "empty" | "network" | "unknown";
interface WorkbenchPerformanceEvent {
kind: WorkbenchEventKind;
valueMs: number;
route?: string;
journey?: string;
phase?: string;
eventType?: string;
backend?: string;
transport?: string;
entry?: string;
source?: string;
targetState?: string;
cache?: string;
authState?: string;
visibility?: string;
outcome?: WorkbenchOutcome;
}
interface TraceEventTimingInput {
traceId: string | null | undefined;
events: TraceEvent[];
transport: "sse" | "rest_gap" | "poll";
serverSentAt?: string | null | undefined;
eventCreatedAt?: string | null | undefined;
traceSeq?: number | string | null | undefined;
}
interface OpenJourneyState {
startAt: number;
route: string;
cache: string;
authState: string;
firstVisibleReported: boolean;
fullLoadReported: boolean;
}
interface SessionSwitchState {
conversationId: string;
startAt: number;
route: string;
source: string;
targetState: string;
cache: string;
firstVisibleReported: boolean;
fullLoadReported: boolean;
}
interface SubmitJourneyState {
traceId: string;
conversationId: string;
startAt: number;
route: string;
entry: string;
backend: string;
transport: string;
firstVisibleReported: boolean;
apiAcceptedReported: boolean;
}
interface TraceEventState {
traceId: string;
receivedAt: number;
projectedAt: number | null;
eventCreatedAt: number | null;
appendedAt: number | null;
serverSentAt: number | null;
traceSeq: number | null;
eventType: string;
backend: string;
transport: string;
visibleReported: boolean;
}
const SCHEMA_VERSION = "hwlab-web-performance-v2";
const FLUSH_INTERVAL_MS = 2500;
const MAX_QUEUE = 120;
const submitJourneys = new Map<string, SubmitJourneyState>();
const sessionSwitches = new Map<string, SessionSwitchState>();
const traceEvents = new Map<string, TraceEventState>();
let openJourney: OpenJourneyState | null = null;
let queue: WorkbenchPerformanceEvent[] = [];
let installed = false;
let flushTimer: number | null = null;
export function startWorkbenchOpenJourney(input: { route?: string; cache?: "warm" | "cold" | "unknown"; authState?: "warm" | "login_redirect" | "unknown" } = {}): void {
ensureInstalled();
openJourney = {
startAt: monotonicNow(),
route: routeTemplate(input.route ?? pageRoute()),
cache: input.cache ?? "unknown",
authState: input.authState ?? "unknown",
firstVisibleReported: false,
fullLoadReported: false
};
}
export function finishWorkbenchOpenFullLoad(outcome: WorkbenchOutcome = "ok"): void {
if (!openJourney || openJourney.fullLoadReported) return;
openJourney.fullLoadReported = true;
enqueue({
kind: "workbench_journey",
journey: "workbench_open_full_load",
route: openJourney.route,
cache: openJourney.cache,
authState: openJourney.authState,
visibility: visibilityState(),
outcome,
valueMs: elapsedSince(openJourney.startAt)
});
}
export function startWorkbenchSessionSwitch(input: { conversationId: string | null | undefined; source: "rail" | "deeplink" | "history" | "direct"; targetState?: string | null; cache?: "warm" | "cold" | "unknown" }): void {
const conversationId = safeText(input.conversationId);
if (!conversationId) return;
ensureInstalled();
sessionSwitches.set(conversationId, {
conversationId,
startAt: monotonicNow(),
route: pageRoute(),
source: input.source,
targetState: normalizeTargetState(input.targetState),
cache: input.cache ?? "unknown",
firstVisibleReported: false,
fullLoadReported: false
});
}
export function finishWorkbenchSessionSwitchFullLoad(conversationId: string | null | undefined, outcome: WorkbenchOutcome = "ok"): void {
const state = sessionSwitches.get(safeText(conversationId));
if (!state || state.fullLoadReported) return;
state.fullLoadReported = true;
enqueue({
kind: "workbench_journey",
journey: "session_switch_full_load",
route: state.route,
source: state.source,
targetState: state.targetState,
cache: state.cache,
visibility: visibilityState(),
outcome,
valueMs: elapsedSince(state.startAt)
});
}
export function failWorkbenchSessionSwitch(conversationId: string | null | undefined): void {
finishWorkbenchSessionSwitchFullLoad(conversationId, "error");
}
export function startWorkbenchSubmitJourney(input: { traceId: string; conversationId: string; entry: "new" | "existing" | "steer" | "retry"; backend?: string | null; transport?: string | null }): void {
if (!input.traceId || !input.conversationId) return;
ensureInstalled();
submitJourneys.set(input.traceId, {
traceId: input.traceId,
conversationId: input.conversationId,
startAt: monotonicNow(),
route: pageRoute(),
entry: input.entry,
backend: normalizeBackend(input.backend),
transport: normalizeTransport(input.transport ?? "sse"),
firstVisibleReported: false,
apiAcceptedReported: false
});
}
export function markWorkbenchSubmitApiAccepted(traceId: string | null | undefined): void {
const state = submitJourneys.get(safeText(traceId));
if (!state || state.apiAcceptedReported) return;
state.apiAcceptedReported = true;
enqueue({
kind: "workbench_event_phase",
phase: "user_submit_to_api_accepted",
eventType: "request",
backend: state.backend,
transport: state.transport,
outcome: "ok",
valueMs: elapsedSince(state.startAt)
});
}
export function failWorkbenchSubmitJourney(traceId: string | null | undefined, outcome: WorkbenchOutcome = "error"): void {
const state = submitJourneys.get(safeText(traceId));
if (!state || state.firstVisibleReported) return;
state.firstVisibleReported = true;
enqueue({
kind: "workbench_journey",
journey: "submit_to_first_visible",
route: state.route,
entry: state.entry,
backend: state.backend,
transport: state.transport,
visibility: visibilityState(),
outcome,
valueMs: elapsedSince(state.startAt)
});
submitJourneys.delete(state.traceId);
}
export function markWorkbenchTraceEventsReceived(input: TraceEventTimingInput): void {
const traceId = safeText(input.traceId);
if (!traceId) return;
const event = input.events.find(isUserVisibleTraceEvent) ?? input.events[0];
if (!event) return;
const eventType = eventTypeFromTraceEvent(event);
const backend = backendFromTraceEvent(event);
const eventCreatedAt = traceEventCreatedAt(event, input.eventCreatedAt);
const appendedAt = timestampMs(event.appendedAt);
const serverSentAt = timestampMs(input.serverSentAt);
const receivedAt = monotonicNow();
const receivedWallAt = wallNow();
const traceSeq = Number(input.traceSeq ?? event.seq ?? event.sourceSeq ?? NaN);
traceEvents.set(traceId, { traceId, receivedAt, projectedAt: null, eventCreatedAt, appendedAt, serverSentAt, traceSeq: Number.isFinite(traceSeq) ? Math.trunc(traceSeq) : null, eventType, backend, transport: input.transport, visibleReported: false });
if (eventCreatedAt !== null && appendedAt !== null) enqueue({ kind: "workbench_event_phase", phase: "created_to_append", eventType, backend, transport: input.transport, outcome: "ok", valueMs: Math.max(0, appendedAt - eventCreatedAt) });
if (appendedAt !== null && serverSentAt !== null) enqueue({ kind: "workbench_event_phase", phase: "append_to_sse", eventType, backend, transport: input.transport, outcome: "ok", valueMs: Math.max(0, serverSentAt - appendedAt) });
if (serverSentAt !== null) enqueue({ kind: "workbench_event_phase", phase: "sse_to_receive", eventType, backend, transport: input.transport, outcome: "ok", valueMs: Math.max(0, receivedWallAt - serverSentAt) });
else if (eventCreatedAt !== null) enqueue({ kind: "workbench_event_phase", phase: "sse_to_receive", eventType, backend, transport: input.transport, outcome: "ok", valueMs: Math.max(0, receivedWallAt - eventCreatedAt) });
}
export function markWorkbenchTraceProjected(traceId: string | null | undefined): void {
const state = traceEvents.get(safeText(traceId));
if (!state || state.projectedAt !== null) return;
const projectedAt = monotonicNow();
state.projectedAt = projectedAt;
enqueue({ kind: "workbench_event_phase", phase: "receive_to_project", eventType: state.eventType, backend: state.backend, transport: state.transport, outcome: "ok", valueMs: Math.max(0, projectedAt - state.receivedAt) });
}
export function acknowledgeWorkbenchVisibleAfterPaint(input: { messages: ChatMessage[]; activeConversationId?: string | null; detailLoading?: boolean }): void {
afterNextPaint(() => acknowledgeWorkbenchVisible(input));
}
export function acknowledgeWorkbenchVisible(input: { messages: ChatMessage[]; activeConversationId?: string | null; detailLoading?: boolean }): void {
const now = monotonicNow();
const route = pageRoute();
if (openJourney && !openJourney.firstVisibleReported) {
openJourney.firstVisibleReported = true;
enqueue({ kind: "workbench_journey", journey: "workbench_open_first_visible", route: openJourney.route || route, cache: openJourney.cache, authState: openJourney.authState, visibility: visibilityState(), outcome: "ok", valueMs: Math.max(0, now - openJourney.startAt) });
}
const activeConversationId = safeText(input.activeConversationId);
const session = activeConversationId ? sessionSwitches.get(activeConversationId) : null;
if (session && !session.firstVisibleReported && !input.detailLoading) {
session.firstVisibleReported = true;
enqueue({ kind: "workbench_journey", journey: "session_switch_first_visible", route: session.route, source: session.source, targetState: session.targetState, cache: session.cache, visibility: visibilityState(), outcome: input.messages.length > 0 ? "ok" : "empty", valueMs: Math.max(0, now - session.startAt) });
}
for (const message of input.messages) {
const traceId = safeText(message.traceId ?? message.runnerTrace?.traceId);
if (!traceId || message.role !== "agent" || !isVisibleAgentMessage(message)) continue;
const submit = submitJourneys.get(traceId);
if (submit && !submit.firstVisibleReported) {
submit.firstVisibleReported = true;
enqueue({ kind: "workbench_journey", journey: "submit_to_first_visible", route: submit.route, entry: submit.entry, backend: submit.backend, transport: submit.transport, visibility: visibilityState(), outcome: "ok", valueMs: Math.max(0, now - submit.startAt) });
submitJourneys.delete(traceId);
}
const event = traceEvents.get(traceId);
if (event && !event.visibleReported) {
event.visibleReported = true;
if (event.projectedAt !== null) enqueue({ kind: "workbench_event_phase", phase: "project_to_paint", eventType: event.eventType, backend: event.backend, transport: event.transport, outcome: "ok", valueMs: Math.max(0, now - event.projectedAt) });
if (event.eventCreatedAt !== null) enqueue({ kind: "workbench_backend_event_visible", eventType: event.eventType, backend: event.backend, transport: event.transport, outcome: "ok", valueMs: Math.max(0, wallNow() - event.eventCreatedAt) });
traceEvents.delete(traceId);
}
}
}
export function resetWorkbenchPerformanceForTest(): void {
submitJourneys.clear();
sessionSwitches.clear();
traceEvents.clear();
openJourney = null;
queue = [];
if (flushTimer !== null && typeof window !== "undefined") window.clearTimeout(flushTimer);
flushTimer = null;
installed = false;
}
export function drainWorkbenchPerformanceEventsForTest(): WorkbenchPerformanceEvent[] {
const events = queue;
queue = [];
return events;
}
function enqueue(event: WorkbenchPerformanceEvent): void {
if (!Number.isFinite(event.valueMs) || event.valueMs < 0) return;
queue.push({ ...event, valueMs: Math.round(event.valueMs * 1000) / 1000 });
if (queue.length > MAX_QUEUE) queue.splice(0, queue.length - MAX_QUEUE);
scheduleFlush();
}
function scheduleFlush(): void {
if (typeof window === "undefined" || flushTimer !== null) return;
flushTimer = window.setTimeout(() => flushWorkbenchPerformance(false), FLUSH_INTERVAL_MS);
}
function flushWorkbenchPerformance(useBeacon: boolean): void {
if (flushTimer !== null && typeof window !== "undefined") {
window.clearTimeout(flushTimer);
flushTimer = null;
}
if (!queue.length || typeof window === "undefined") return;
const events = queue.splice(0, queue.length);
const body = JSON.stringify({ schemaVersion: SCHEMA_VERSION, serviceId: "hwlab-cloud-web", page: pageRoute(), events });
if (useBeacon && navigator.sendBeacon) {
navigator.sendBeacon("/v1/web-performance", new Blob([body], { type: "application/json" }));
return;
}
void fetch("/v1/web-performance", { method: "POST", headers: { "content-type": "application/json" }, body, credentials: "same-origin", keepalive: true }).catch(() => undefined);
}
function ensureInstalled(): void {
if (installed || typeof document === "undefined") return;
installed = true;
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") flushWorkbenchPerformance(true);
});
}
function afterNextPaint(callback: () => void): void {
if (typeof requestAnimationFrame !== "function") {
callback();
return;
}
requestAnimationFrame(() => requestAnimationFrame(callback));
}
function isVisibleAgentMessage(message: ChatMessage): boolean {
if (message.runnerTrace && ((message.runnerTrace.events?.length ?? 0) > 0 || message.runnerTrace.eventCount)) return true;
if (String(message.text ?? "").trim()) return true;
return !["pending", "running"].includes(String(message.status ?? "").toLowerCase());
}
function isUserVisibleTraceEvent(event: TraceEvent): boolean {
const type = eventTypeFromTraceEvent(event);
return type === "assistant" || type === "tool_call" || type === "backend" || type === "terminal" || type === "error";
}
function eventTypeFromTraceEvent(event: TraceEvent | null | undefined): string {
const raw = safeText(event?.eventType ?? event?.type ?? event?.kind ?? event?.label ?? event?.status).toLowerCase();
if (/tool/u.test(raw)) return "tool_call";
if (/assistant|message|delta|reply/u.test(raw)) return "assistant";
if (/terminal|complete|final/u.test(raw)) return "terminal";
if (/error|fail|timeout/u.test(raw)) return "error";
if (/backend|runner|agentrun/u.test(raw)) return "backend";
if (/request|accepted/u.test(raw)) return "request";
return "status";
}
function backendFromTraceEvent(event: TraceEvent | null | undefined): string {
const direct = safeText(event?.backend ?? event?.backendProfile ?? event?.provider ?? event?.runnerKind);
return normalizeBackend(direct);
}
function normalizeBackend(value: unknown): string {
const text = safeText(value).toLowerCase();
if (!text) return "unknown";
if (text.includes("/")) return text;
if (/^(?:codex|deepseek|openai|claude|hyue)/u.test(text)) return `agentrun-v01/${text}`;
return text;
}
function normalizeTransport(value: unknown): string {
const text = safeText(value).toLowerCase();
return text === "rest_gap" || text === "poll" || text === "sse" ? text : "unknown";
}
function normalizeTargetState(value: unknown): string {
const text = safeText(value).toLowerCase();
if (text === "running" || text === "terminal" || text === "empty") return text;
if (["completed", "failed", "timeout", "canceled"].includes(text)) return "terminal";
return "unknown";
}
function traceEventCreatedAt(event: TraceEvent, fallback?: unknown): number | null {
const raw = safeText(event.createdAt ?? fallback ?? event.ts ?? event.timestamp);
const parsed = raw ? Date.parse(raw) : NaN;
return Number.isFinite(parsed) ? parsed : null;
}
function timestampMs(value: unknown): number | null {
const raw = safeText(value);
const parsed = raw ? Date.parse(raw) : NaN;
return Number.isFinite(parsed) ? parsed : null;
}
function pageRoute(): string {
if (typeof window === "undefined") return "/workbench";
return routeTemplate(`${window.location.pathname}${window.location.hash || ""}` || "/workbench");
}
function routeTemplate(input: string): string {
const raw = safeText(input) || "/workbench";
const pathOnly = raw.startsWith("#") ? raw.slice(1) || "/" : raw.split(/[?#]/u, 1)[0] || "/";
const segments = pathOnly.split("/").filter(Boolean).map((part) => routeSegmentTemplate(part));
return `/${segments.join("/")}`;
}
function routeSegmentTemplate(segment: string): string {
const decoded = safeDecode(segment);
if (/^(?:trc|trace|thread|run|cmd|job|ses|cnv|conv|gws|box|res|pod|dp|usr)[_-]/iu.test(decoded)) return ":id";
if (/^[0-9a-f]{8,}(?:-[0-9a-f]{4,}){2,}$/iu.test(decoded)) return ":uuid";
if (/^[A-Za-z0-9_-]{20,}$/u.test(decoded)) return ":id";
if (/^\d{5,}$/u.test(decoded)) return ":id";
return decoded.replace(/[^A-Za-z0-9_.:-]/gu, "_").slice(0, 80) || "_";
}
function safeDecode(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function visibilityState(): string {
if (typeof document === "undefined") return "unknown";
return document.visibilityState === "hidden" ? "background" : "foreground";
}
function monotonicNow(): number {
return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
}
function wallNow(): number {
return Date.now();
}
function elapsedSince(startAt: number): number {
return Math.max(0, monotonicNow() - startAt);
}
function safeText(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}