diff --git a/web/hwlab-cloud-web/src/App.tsx b/web/hwlab-cloud-web/src/App.tsx index bd9e2e6b..7567254e 100644 --- a/web/hwlab-cloud-web/src/App.tsx +++ b/web/hwlab-cloud-web/src/App.tsx @@ -107,7 +107,7 @@ export function App(): ReactElement { onKeyDown={sessionResize.onKeyDown} />
- {route === "workspace" ? void navigator.clipboard?.writeText(store.activeConversationId ?? "")} /> : null} + {route === "workspace" ? void navigator.clipboard?.writeText(store.activeConversationId ?? "")} /> : null} {route === "skills" ? : null} {route === "gate" ? : null} {route === "help" ? : null} diff --git a/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx b/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx index 17c599e6..c4493d03 100644 --- a/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx +++ b/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx @@ -2,18 +2,21 @@ import type { ReactElement } from "react"; import { useMemo } from "react"; import { renderMessageMarkdown } from "../../services/markdown/render"; -import type { ChatMessage, CodeAgentAvailability } from "../../types/domain"; -import { formatTimestamp, jsonPreview, shortToken, toneClass } from "../../utils"; +import type { ChatMessage, CodeAgentAvailability, LiveSurface } from "../../types/domain"; +import { formatBeijingTime, jsonPreview, shortToken, toneClass } from "../../utils"; +import { codeAgentSummaryRows, classifyCodeAgentStatusSummary } from "../../state/code-agent-status"; import { StateTag } from "../shared/StateTag"; +import { MessageTracePanel } from "./MessageTracePanel"; interface ConversationPanelProps { messages: ChatMessage[]; availability: CodeAgentAvailability | null; + live: LiveSurface | null; chatPending: boolean; onCopySession(): void; } -export function ConversationPanel({ messages, availability, chatPending, onCopySession }: ConversationPanelProps): ReactElement { +export function ConversationPanel({ messages, availability, live, chatPending, onCopySession }: ConversationPanelProps): ReactElement { const intro = useMemo(() => [ { id: "intro-mode", @@ -47,7 +50,7 @@ export function ConversationPanel({ messages, availability, chatPending, onCopyS {chatPending ? "处理中" : "等待输入"} - +
{[...intro, ...messages].map((message) => )}
@@ -57,18 +60,35 @@ export function ConversationPanel({ messages, availability, chatPending, onCopyS ); } -function CodeAgentSummary({ availability }: { availability: CodeAgentAvailability | null }): ReactElement { - const tone = availability?.ready === true ? "ok" : toneClass(availability?.status); +function latestAgentMessage(messages: ChatMessage[]): Record | null { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message?.role === "agent") return { ...message }; + } + return null; +} + +function CodeAgentSummary({ availability, live, latestMessage }: { availability: CodeAgentAvailability | null; live: LiveSurface | null; latestMessage: Record | null }): ReactElement { + const summary = useMemo(() => classifyCodeAgentStatusSummary({ availability, live, latestMessage }), [availability, live, latestMessage]); + const rows = useMemo(() => codeAgentSummaryRows(summary), [summary]); return ( -
+
- - {availability?.ready === true ? "长会话可用" : "探测中"} - {availability?.capabilityLevel ?? "未观测"} - {availability?.session && typeof availability.session === "object" ? "session 已观测" : "trace 未观测"} + + {summary.label} + {summary.capabilityLevel} + trace {summary.lastTraceId}
-
{jsonPreview(availability ?? { status: "unverified" }, 1200)}
+
+ {rows.map((row) => ( +
+ {row.key} + {row.value} +
+ ))} +
+ {summary.readinessBlockers.length > 0 ?
展开 readiness 原始 JSON
{jsonPreview(availability ?? { status: "unverified" }, 1200)}
: null}
); @@ -76,6 +96,7 @@ function CodeAgentSummary({ availability }: { availability: CodeAgentAvailabilit function MessageCard({ message }: { message: ChatMessage }): ReactElement { const html = renderMessageMarkdown(message.text); + const traceKey = `hwlab.workbench.trace-open.${message.traceId ?? message.id}`; return (
@@ -89,25 +110,13 @@ function MessageCard({ message }: { message: ChatMessage }): ReactElement {
trace {shortToken(message.traceId, 10)} session {shortToken(message.sessionId, 10)} - {formatTimestamp(message.updatedAt ?? message.createdAt)} + {formatBeijingTime(message.updatedAt ?? message.createdAt, { withSeconds: true })}
- {message.runnerTrace ? : null} + {message.runnerTrace ? : null}
); } -function TracePanel({ trace }: { trace: NonNullable }): ReactElement { - const events = trace.events ?? []; - return ( -
- 运行 trace · {trace.status ?? "unknown"} · {events.length || trace.eventCount || 0} events -
- {events.slice(-16).map((event, index) =>
{jsonPreview(event, 500)}
)} -
-
- ); -} - function statusLabel(status: string): string { const map: Record = { source: "SOURCE", sent: "已发送", running: "处理中", completed: "完成", failed: "失败", blocked: "阻塞", timeout: "超时", canceled: "已取消" }; return map[status] ?? status; diff --git a/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx b/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx new file mode 100644 index 00000000..ab2b6af6 --- /dev/null +++ b/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx @@ -0,0 +1,135 @@ +import type { ReactElement } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import type { RunnerTrace, TraceEvent } from "../../types/domain"; +import { formatBeijingTime, formatDuration, shortToken, toneClass } from "../../utils"; +import { summarizeLastEvent } from "../../state/code-agent-status"; + +interface MessageTracePanelProps { + trace: RunnerTrace; + defaultOpen?: boolean; + storageKey?: string; +} + +const MAX_VISIBLE_ROWS = 32; + +interface DisplayRow { + key: string; + label: string; + body: string; + tone: string; + ts: string | null; +} + +export function MessageTracePanel({ trace, defaultOpen, storageKey }: MessageTracePanelProps): ReactElement { + const storedOpen = readStoredOpen(storageKey, defaultOpen ?? defaultTraceDetailsOpen(trace)); + const [open, setOpen] = useState(storedOpen); + const [follow, setFollow] = useState(true); + const listRef = useRef(null); + + useEffect(() => { + if (storageKey) { + try { window.localStorage.setItem(storageKey, open ? "1" : "0"); } catch { /* ignore */ } + } + }, [open, storageKey]); + + useEffect(() => { + if (follow && listRef.current) { + listRef.current.scrollTop = listRef.current.scrollHeight; + } + }, [trace, follow, open]); + + const events = Array.isArray(trace.events) ? trace.events : []; + const last = summarizeLastEvent(trace); + const rows = useMemo(() => displayRows(events), [events]); + + return ( +
setOpen((event.currentTarget as HTMLDetailsElement).open)}> + + 运行 trace · {trace.status ?? "unknown"} · {rows.length || trace.eventCount || 0} events + last · {last.label} + {last.ts ? {formatBeijingTime(last.ts, { withSeconds: true })} : null} + +
+ {traceCountText(trace, events.length, rows.length)} + + + + {shortToken(trace.traceId, 10)} +
+
{ + const node = listRef.current; + if (!node) return; + if (node.scrollHeight - node.clientHeight - node.scrollTop > 24) setFollow(false); + }} + > + {rows.length === 0 ? ( +

trace={trace.traceId ?? "pending"};等待后端事件。

+ ) : ( +
    + {rows.map((row) => ( +
  1. +
    + + {row.label} + {row.ts ? {formatBeijingTime(row.ts, { withSeconds: true })} : null} +
    +
    {row.body}
    +
  2. + ))} +
+ )} +
+
+ ); +} + +function displayRows(events: TraceEvent[]): DisplayRow[] { + if (events.length === 0) return []; + const tail = events.slice(-MAX_VISIBLE_ROWS); + return tail.map((event, index) => { + const key = `${event.traceId ?? "event"}-${event.seq ?? index}-${event.ts ?? index}`; + const label = String(event.label ?? event.kind ?? event.type ?? "event"); + const body = eventSummary(event); + const tone = String(event.status ?? event.level ?? "source"); + const ts = event.ts ?? null; + return { key, label, body, tone, ts }; + }); +} + +function eventSummary(event: TraceEvent): string { + if (typeof event.text === "string" && event.text.trim()) return event.text; + if (typeof event.message === "string" && event.message.trim()) return event.message; + if (event.payload && typeof event.payload === "object") { + try { return JSON.stringify(event.payload, null, 2); } catch { return String(event.payload); } + } + if (event.evidence && Array.isArray(event.evidence)) return event.evidence.join("\n"); + return "(empty event)"; +} + +function traceCountText(trace: RunnerTrace, events: number, rows: number): string { + const rawTotal = Number.isInteger(trace.eventCount) ? trace.eventCount : events; + if (rawTotal === events) return `${rows} 条事件`; + return `${rows}/${rawTotal} 条事件(已折叠)`; +} + +function defaultTraceDetailsOpen(trace: RunnerTrace): boolean { + return ["running", "completed", "source", "failed", "timeout", "canceled", "error"].includes(String(trace.status ?? "").toLowerCase()); +} + +function readStoredOpen(key: string | undefined, fallback: boolean): boolean { + if (!key || typeof window === "undefined") return fallback; + try { + const value = window.localStorage.getItem(key); + if (value === "1") return true; + if (value === "0") return false; + } catch { /* ignore */ } + return fallback; +} + +export { formatDuration }; diff --git a/web/hwlab-cloud-web/src/services/api/client.ts b/web/hwlab-cloud-web/src/services/api/client.ts index 91c4ce1a..6321b614 100644 --- a/web/hwlab-cloud-web/src/services/api/client.ts +++ b/web/hwlab-cloud-web/src/services/api/client.ts @@ -13,14 +13,68 @@ import type { WorkspaceRecord } from "../../types/domain"; +export interface ActivityRef { + lastActivityAt: number; + lastActivityIso?: string; + waitingFor?: string | null; + lastEventLabel?: string | null; +} + +export type ActivityRefSource = (() => ActivityRef | null) | null; + export interface ApiRequestOptions extends RequestInit { timeoutMs?: number; timeoutName?: string; + /** + * Inactivity-timeout reference. When supplied, the abort is re-evaluated + * against `Date.now() - lastActivityAt`, so a long-running fetch stays + * alive as long as upstream keeps emitting trace events. Mirrors the + * pre-React `app-device-pod.ts:fetchJson` activityRef contract so the + * Code Agent / trace / probe endpoints share the same model. + */ + activityRef?: ActivityRef | ActivityRefSource; } +const ACTIVITY_RESCHEDULER_MS = 1000; +const DEFAULT_TIMEOUT_MS = 4500; + export async function fetchJson(path: string, options: ApiRequestOptions = {}): Promise> { const controller = new AbortController(); - const timeout = window.setTimeout(() => controller.abort(), options.timeoutMs ?? 4500); + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const activitySource = resolveActivitySource(options.activityRef); + let timer: number | null = null; + const inactivityRef: { current: { idleMs: number; lastActivityAt: number; lastActivityIso?: string; waitingFor?: string | null; lastEventLabel?: string | null } | null } = { current: null }; + const startedAt = Date.now(); + + function readActivity(): { lastActivityAt: number; lastActivityIso?: string; waitingFor?: string | null; lastEventLabel?: string | null } { + const fallback = { lastActivityAt: startedAt, lastActivityIso: new Date(startedAt).toISOString() }; + if (!activitySource) return fallback; + const ref = activitySource(); + if (!ref || !Number.isFinite(ref.lastActivityAt) || ref.lastActivityAt <= 0) return fallback; + return ref; + } + + function schedule(): void { + if (timer !== null) { + window.clearTimeout(timer); + timer = null; + } + if (!activitySource) { + timer = window.setTimeout(() => controller.abort(), timeoutMs); + return; + } + const activity = readActivity(); + const idleMs = Math.max(0, Date.now() - activity.lastActivityAt); + const remaining = timeoutMs - idleMs; + if (remaining <= 0) { + inactivityRef.current = { idleMs, lastActivityAt: activity.lastActivityAt, lastActivityIso: activity.lastActivityIso, waitingFor: activity.waitingFor, lastEventLabel: activity.lastEventLabel }; + controller.abort(); + return; + } + timer = window.setTimeout(schedule, Math.max(250, Math.min(remaining, ACTIVITY_RESCHEDULER_MS))); + } + schedule(); + try { const response = await fetch(path, { ...options, @@ -40,30 +94,85 @@ export async function fetchJson(path: string, options: ApiRequestOptions = {} error: response.ok ? null : errorMessage(payload, `HTTP ${response.status}`) }; } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + if (inactivityRef.current) { + const detail = inactivityRef.current; + const idleSeconds = Math.round(detail.idleMs / 1000); + return { + ok: false, + status: 0, + data: null, + error: `${options.timeoutName ?? path} 超过 ${timeoutMs}ms 无新活动(idle ${idleSeconds}s;waitingFor=${detail.waitingFor ?? "unknown"};lastEventLabel=${detail.lastEventLabel ?? "none"})` + }; + } + return { + ok: false, + status: 0, + data: null, + error: `${options.timeoutName ?? path} 超时` + }; + } return { ok: false, status: 0, data: null, - error: error instanceof DOMException && error.name === "AbortError" - ? `${options.timeoutName ?? path} 超时` - : error instanceof Error ? error.message : String(error) + error: error instanceof Error ? error.message : String(error) }; } finally { - window.clearTimeout(timeout); + if (timer !== null) window.clearTimeout(timer); } } +function resolveActivitySource(ref: ApiRequestOptions["activityRef"]): ActivityRefSource { + if (typeof ref === "function") return ref; + if (ref && typeof ref === "object") return () => ref; + return null; +} + export async function fetchText(path: string, options: ApiRequestOptions = {}): Promise> { const controller = new AbortController(); - const timeout = window.setTimeout(() => controller.abort(), options.timeoutMs ?? 4500); + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const activitySource = resolveActivitySource(options.activityRef); + let timer: number | null = null; + const inactivityRef: { current: { idleMs: number; lastActivityAt: number } | null } = { current: null }; + const startedAt = Date.now(); + function readActivity(): number { + if (!activitySource) return startedAt; + const ref = activitySource(); + if (!ref || !Number.isFinite(ref.lastActivityAt) || ref.lastActivityAt <= 0) return startedAt; + return ref.lastActivityAt; + } + function schedule(): void { + if (timer !== null) window.clearTimeout(timer); + if (!activitySource) { + timer = window.setTimeout(() => controller.abort(), timeoutMs); + return; + } + const idleMs = Math.max(0, Date.now() - readActivity()); + const remaining = timeoutMs - idleMs; + if (remaining <= 0) { + inactivityRef.current = { idleMs, lastActivityAt: readActivity() }; + controller.abort(); + return; + } + timer = window.setTimeout(schedule, Math.max(250, Math.min(remaining, ACTIVITY_RESCHEDULER_MS))); + } + schedule(); try { const response = await fetch(path, { ...options, credentials: options.credentials ?? "same-origin", signal: controller.signal }); const text = await response.text(); return { ok: response.ok, status: response.status, data: text, error: response.ok ? null : `HTTP ${response.status}` }; } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + if (inactivityRef.current) { + const detail = inactivityRef.current; + return { ok: false, status: 0, data: null, error: `${options.timeoutName ?? path} 超过 ${timeoutMs}ms 无新活动(idle ${Math.round(detail.idleMs / 1000)}s)` }; + } + return { ok: false, status: 0, data: null, error: `${options.timeoutName ?? path} 超时` }; + } return { ok: false, status: 0, data: null, error: error instanceof Error ? error.message : String(error) }; } finally { - window.clearTimeout(timeout); + if (timer !== null) window.clearTimeout(timer); } } @@ -98,11 +207,41 @@ export const api = { conversations: (projectId: string): Promise> => fetchJson(`/v1/agent/conversations?projectId=${encodeURIComponent(projectId)}`, { timeoutMs: 8000, timeoutName: "conversations" }), deleteConversation: (conversationId: string, payload: Record): Promise> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "DELETE", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "delete conversation" }), saveConversation: (conversationId: string, payload: Record): Promise> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "PUT", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "save conversation" }), - sendAgentMessage: (payload: Record, timeoutMs: number): Promise> => fetchJson("/v1/agent/chat", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent" }), - getAgentChatResult: (resultUrl: string, timeoutMs = 8000): Promise> => fetchJson(resultUrl, { timeoutMs, timeoutName: "Code Agent result" }), - steerAgentMessage: (payload: Record, timeoutMs: number): Promise> => fetchJson("/v1/agent/chat/steer", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent steer" }), + sendAgentMessage: (payload: Record, timeoutMs: number, activityRef?: ApiRequestOptions["activityRef"]): Promise> => fetchJson("/v1/agent/chat", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent", activityRef }), + steerAgentMessage: (payload: Record, timeoutMs: number, activityRef?: ApiRequestOptions["activityRef"]): Promise> => fetchJson("/v1/agent/chat/steer", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent steer", activityRef }), + getAgentChatResult: (resultUrl: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"]): Promise> => fetchJson(resultUrl, { timeoutMs, timeoutName: "Code Agent result", activityRef }), + cancelAgentMessage: (payload: Record): Promise> => fetchJson("/v1/agent/chat/cancel", { method: "POST", body: JSON.stringify(payload), timeoutMs: 30000, timeoutName: "Code Agent cancel" }), + gateDiagnostics: (): Promise> => fetchJson("/v1/diagnostics/gate", { timeoutMs: 12000, timeoutName: "gate diagnostics" }), skills: (): Promise> => fetchJson("/v1/skills", { timeoutMs: 12000, timeoutName: "skills" }), uploadSkills: (files: Array<{ relativePath: string; sizeBytes: number; contentBase64: string }>): Promise> => fetchJson("/v1/skills/uploads", { method: "POST", body: JSON.stringify({ files }), timeoutMs: 60000, timeoutName: "skill upload" }), skillTree: (skillId: string): Promise> => fetchJson(`/v1/skills/${encodeURIComponent(skillId)}/tree`, { timeoutMs: 12000, timeoutName: "skill tree" }), skillFile: (skillId: string, relativePath: string): Promise> => fetchJson(`/v1/skills/${encodeURIComponent(skillId)}/files?path=${encodeURIComponent(relativePath)}`, { timeoutMs: 12000, timeoutName: "skill file" }) }; + +export interface GateDiagnosticsResponse { + serviceId?: string; + route?: string; + contractVersion?: string; + status?: string; + sourceKind?: string; + liveBackend?: boolean; + observedAt?: string; + rowCount?: number; + rowsCount?: number; + columns?: string[]; + rows?: GateDiagnosticsRow[]; + error?: string; +} + +export interface GateDiagnosticsRow { + category?: string; + check?: string; + status?: string; + statusKey?: string; + owner?: string; + detail?: string; + evidence?: string[]; + updatedAt?: string; + next?: string; + sourceKind?: string; +} diff --git a/web/hwlab-cloud-web/src/state/code-agent-status.ts b/web/hwlab-cloud-web/src/state/code-agent-status.ts new file mode 100644 index 00000000..7c4e9221 --- /dev/null +++ b/web/hwlab-cloud-web/src/state/code-agent-status.ts @@ -0,0 +1,398 @@ +import type { AgentChatReply, AgentRunProvenance, CodeAgentAvailability, LiveSurface, RunnerTrace, TraceEvent } from "../types/domain"; +import { firstNonEmptyString, jsonPreview, nonEmptyString, shortToken } from "../utils"; + +export interface CodeAgentStatusSummary { + kind: "long-lived-session" | "blocked" | "skill-cli-api-blocked" | "read-only-session-tools" | "stateless-one-shot" | "fallback-text-chat-only" | "degraded" | "unverified"; + tone: "ok" | "warn" | "blocked" | "source" | "dev-live"; + label: string; + icon: string; + provider: string; + mode: string; + backend: string; + providerModeBackend: string; + codeAgentStatus: string; + capabilityLevel: string; + sessionMode: string; + sessionId: string; + threadId: string; + sessionStatus: string; + sessionLifecycleStatus: string; + sessionLifecycleHint: string; + sessionLifecycleLabel: string; + sessionSummary: string; + sessionIdStatus: string; + workspace: string; + sandbox: string; + runnerKind: string; + protocol: string; + implementationType: string; + providerTraceCommand: string; + providerTraceTerminalStatus: string; + providerTraceFailureKind: string; + runtimePathLabel: string; + runtimePathSummary: string; + runtimePathTone: "ok" | "warn" | "blocked" | "source" | "dev-live"; + toolCalls: string; + skills: string; + runnerTrace: string; + readinessBlockers: string[]; + blockersLabel: string; + lastTraceId: string; + deploymentRevision: string; + fields: Record; +} + +export interface CodeAgentRuntimePath { + protocol: string; + implementationType: string; + command: string; + terminalStatus: string; + failureKind: string; + label: string; + summary: string; + tone: "ok" | "warn" | "blocked" | "source" | "dev-live"; + rows: { label: string; value: string }[]; + missingFields: string[]; +} + +const CODEX_APP_SERVER_RUNNER = "codex-app-server-stdio-runner"; +const CODEX_APP_SERVER_SESSION_MODE = "codex-app-server-stdio-long-lived"; +const CODEX_APP_SERVER_IMPLEMENTATION = "repo-owned-codex-app-server-stdio-session"; +const CODEX_APP_SERVER_PROTOCOL = "codex-app-server-jsonrpc-stdio"; + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function pickString(...values: unknown[]): string | null { + for (const value of values) { + const text = asString(value); + if (text) return text; + } + return null; +} + +function readField(payload: Record | null, path: readonly string[]): string | null { + let cursor: unknown = payload; + for (const key of path) { + if (!cursor || typeof cursor !== "object") return null; + cursor = (cursor as Record)[key]; + } + return asString(cursor); +} + +function safeField(value: string | null): string { + return value ?? "未观测"; +} + +function compactJoin(values: ReadonlyArray, separator: string): string { + const parts = values.filter((value): value is string => Boolean(value && value !== "未观测" && value !== "字段缺失:证据不足")); + return parts.length > 0 ? parts.join(separator) : "未观测"; +} + +function unsafeGreenForNonReady(tone: CodeAgentStatusSummary["tone"], blockers: string[]): boolean { + return tone === "ok" && blockers.length > 0; +} + +export function classifyCodeAgentStatusSummary({ availability = null, latestMessage = null, live = null }: { availability?: CodeAgentAvailability | null; latestMessage?: Record | null; live?: LiveSurface | null } = {}): CodeAgentStatusSummary { + const observedAvailability = availability ?? (live?.healthLive.data?.codeAgent as CodeAgentAvailability | undefined) ?? null; + const messageRecord = latestMessage ? asRecord(latestMessage) : null; + const availabilityRecord = observedAvailability ? asRecord(observedAvailability) : null; + const messageStatus = pickString(messageRecord?.status as string | undefined, availabilityRecord?.status as string | undefined); + const blockers = readinessBlockers(availabilityRecord, messageRecord); + const classification = classifyPayload(availabilityRecord, messageRecord, blockers, messageStatus); + const safeTone = unsafeGreenForNonReady(classification.tone, blockers) ? "warn" : classification.tone; + + const provider = pickString(availabilityRecord?.provider as string | undefined, observedAvailability?.provider, messageRecord?.provider as string | undefined); + const mode = pickString(availabilityRecord?.mode as string | undefined, messageRecord?.providerTrace && (messageRecord.providerTrace as Record).mode as string | undefined, observedAvailability?.mode); + const backend = pickString(availabilityRecord?.backend as string | undefined, observedAvailability?.backend, messageRecord?.backend as string | undefined); + const codeAgentStatus = pickString(messageStatus, availabilityRecord?.readinessStatus as string | undefined, observedAvailability?.status); + const capabilityLevel = pickString(availabilityRecord?.capabilityLevel as string | undefined, messageRecord?.capabilityLevel as string | undefined, observedAvailability?.capabilityLevel); + const sessionMode = pickString(availabilityRecord?.sessionMode as string | undefined, messageRecord?.sessionMode as string | undefined, observedAvailability?.sessionMode); + const sessionId = pickString(messageRecord?.sessionId as string | undefined, observedAvailability?.sessionId); + const threadId = pickString(messageRecord?.threadId as string | undefined, observedAvailability?.threadId); + const sessionStatus = pickString(messageRecord?.status as string | undefined, messageRecord?.sessionStatus as string | undefined); + const lifecycle = sessionLifecycleSummary(messageStatus, blockers); + const workspace = pickString(messageRecord?.workspace as string | undefined, messageRecord?.workspaceId as string | undefined); + const sandbox = pickString(messageRecord?.sandbox as string | undefined, messageRecord?.runner && (messageRecord.runner as Record).sandbox as string | undefined); + const runnerKind = pickString(messageRecord?.runnerKind as string | undefined, messageRecord?.runner && (messageRecord.runner as Record).kind as string | undefined); + const runtimePath = codeAgentRuntimePathFromMessage({ role: "agent", ...(messageRecord ?? {}) }); + const lastTraceId = pickString( + messageRecord?.traceId as string | undefined, + messageRecord?.lastTraceId as string | undefined + ) ?? "未观测"; + const deploymentRevision = currentDeploymentRevision(live); + + const toolCalls = compactToolCallsSummary(messageRecord?.toolCalls); + const skills = compactSkillsSummary(messageRecord?.skills); + const runnerTrace = compactRunnerTraceSummary(messageRecord?.runnerTrace); + + return { + ...classification, + tone: safeTone, + provider: safeField(provider), + mode: safeField(mode), + backend: safeField(backend), + providerModeBackend: compactJoin([provider, mode, backend], " / "), + codeAgentStatus: safeField(codeAgentStatus), + capabilityLevel: safeField(capabilityLevel), + sessionMode: safeField(sessionMode), + sessionId: safeField(sessionId), + threadId: safeField(threadId), + sessionStatus: safeField(sessionStatus), + sessionLifecycleStatus: lifecycle.status, + sessionLifecycleHint: lifecycle.hint, + sessionLifecycleLabel: lifecycle.label, + sessionSummary: compactJoin([sessionMode, sessionStatus, lifecycle.status], " / "), + sessionIdStatus: compactJoin([sessionId, threadId, sessionStatus, lifecycle.status], " / "), + workspace: safeField(workspace), + sandbox: safeField(sandbox), + runnerKind: safeField(runnerKind), + protocol: runtimePath.protocol, + implementationType: runtimePath.implementationType, + providerTraceCommand: runtimePath.command, + providerTraceTerminalStatus: runtimePath.terminalStatus, + providerTraceFailureKind: runtimePath.failureKind, + runtimePathLabel: runtimePath.label, + runtimePathSummary: runtimePath.summary, + runtimePathTone: runtimePath.tone, + toolCalls, + skills, + runnerTrace, + readinessBlockers: blockers, + blockersLabel: blockers.length > 0 ? blockers.join(", ") : "无", + lastTraceId: lastTraceId || "未观测", + deploymentRevision, + fields: { + provider: safeField(provider), + mode: safeField(mode), + backend: safeField(backend), + codeAgentStatus: safeField(codeAgentStatus), + capabilityLevel: safeField(capabilityLevel), + sessionMode: safeField(sessionMode), + sessionId: safeField(sessionId), + threadId: safeField(threadId), + sessionStatus: safeField(sessionStatus), + runnerKind: safeField(runnerKind), + protocol: runtimePath.protocol, + implementationType: runtimePath.implementationType, + providerTraceCommand: runtimePath.command, + providerTraceTerminalStatus: runtimePath.terminalStatus, + providerTraceFailureKind: runtimePath.failureKind, + toolCalls, + skills, + runnerTrace + } + }; +} + +function classifyPayload(availability: Record | null, message: Record | null, blockers: string[], messageStatus: string | null): Pick { + const providerBackend = pickString(availability?.provider as string | undefined, message?.provider as string | undefined); + const sessionRunnerField = availability ? (availability as Record).sessionRunner : null; + const sessionRunnerKind = pickString( + availability?.sessionRunnerKind as string | undefined, + typeof sessionRunnerField === "string" ? sessionRunnerField : asString(sessionRunnerField) + ); + const sessionMode = pickString(availability?.sessionMode as string | undefined, message?.sessionMode as string | undefined); + if (blockers.length > 0) { + return { kind: "blocked", tone: "blocked", label: "Code Agent 阻塞", icon: "×" }; + } + if (providerBackend && (sessionRunnerKind === CODEX_APP_SERVER_RUNNER || sessionMode === CODEX_APP_SERVER_SESSION_MODE)) { + return { kind: "long-lived-session", tone: "ok", label: "长会话可用", icon: "●" }; + } + if (providerBackend && /codex-stdio|codex-app-server/iu.test(providerBackend)) { + return { kind: "stateless-one-shot", tone: "warn", label: "单次 Code Agent", icon: "○" }; + } + if (providerBackend) { + return { kind: "read-only-session-tools", tone: "warn", label: "Provider 受限", icon: "!" }; + } + if (messageStatus === "running" || messageStatus === "pending") { + return { kind: "degraded", tone: "source", label: "等待后端", icon: "…" }; + } + return { kind: "unverified", tone: "source", label: "未观测", icon: "○" }; +} + +function readinessBlockers(availability: Record | null, message: Record | null): string[] { + const blockers: string[] = []; + const seen = new Set(); + function add(value: unknown, prefix = ""): void { + if (typeof value === "string" && value.trim() && value !== "ready" && value !== "ok") { + const key = `${prefix}${value}`; + if (!seen.has(key)) { + seen.add(key); + blockers.push(key); + } + } + } + add((availability?.currentBlocker as string | undefined) ?? (availability?.blocker as string | undefined)); + for (const blocker of asArray(availability?.currentBlockers)) add(blocker); + for (const blocker of asArray(availability?.blockerCodes)) add(blocker); + for (const blocker of asArray(availability?.blockers)) { + if (blocker && typeof blocker === "object") { + const code = (blocker as Record).code; + const message = (blocker as Record).message; + if (typeof code === "string") add(code); + if (typeof message === "string") add(message); + } + } + if (message) { + const error = asRecord(message.error); + add(error?.code as string | undefined); + add(error?.message as string | undefined); + } + return blockers; +} + +function sessionLifecycleSummary(messageStatus: string | null, blockers: string[]): { status: string; hint: string; label: string } { + if (blockers.length > 0) return { status: "阻塞", hint: "等待补齐 provider/session 阻断条件", label: "会话受阻" }; + if (messageStatus === "running" || messageStatus === "pending") return { status: "运行", hint: "正在等待后端响应", label: "会话运行中" }; + if (messageStatus === "completed") return { status: "完成", hint: "可继续 steer 或新建 session", label: "会话已就绪" }; + if (messageStatus === "failed" || messageStatus === "blocked" || messageStatus === "timeout") return { status: "失败", hint: "可新建 session 后重试", label: "会话已失败" }; + if (messageStatus === "canceled") return { status: "已取消", hint: "可重新发起", label: "会话已取消" }; + return { status: "未观测", hint: "尚未发起 Code Agent 请求", label: "会话未观测" }; +} + +function asArray(value: unknown): T[] { + return Array.isArray(value) ? value as T[] : []; +} + +function currentDeploymentRevision(live: LiveSurface | null): string { + const liveRevision = live?.healthLive.data?.revision ?? live?.health.data?.revision ?? null; + return liveRevision ? String(liveRevision).slice(0, 12) : "未观测"; +} + +export function codeAgentRuntimePathFromMessage(message: Record | { role?: string; [key: string]: unknown }): CodeAgentRuntimePath { + const record = asRecord(message) ?? {}; + const provider = pickString(record.provider as string | undefined, record && asString((record as Record).provider)); + const runnerKind = pickString(record.runnerKind as string | undefined, record.runner && (record.runner as Record).kind as string | undefined, record.agentRun && (record.agentRun as AgentRunProvenance).adapter as string | undefined); + const protocol = pickString(record.protocol as string | undefined, record.protocol as string | undefined); + const implementationType = pickString(record.implementationType as string | undefined, record.implementationType as string | undefined); + const providerTrace = asRecord(record.providerTrace) ?? {}; + const command = pickString(providerTrace.command as string | undefined, providerTrace.commandId as string | undefined, record.agentRun && (record.agentRun as AgentRunProvenance).commandId as string | undefined); + const terminalStatus = pickString(providerTrace.terminalStatus as string | undefined, providerTrace.terminal as string | undefined, record.agentRun && (record.agentRun as AgentRunProvenance).terminalStatus as string | undefined); + const failureKind = pickString(providerTrace.failureKind as string | undefined, providerTrace.failure as string | undefined); + + const isCodexAppServer = runnerKind === CODEX_APP_SERVER_RUNNER || provider === "codex-stdio" || /codex-app-server/iu.test(protocol ?? "") || /codex-app-server/iu.test(implementationType ?? ""); + const label = isCodexAppServer ? "repo-owned codex app-server stdio" : (provider ?? "运行路径证据不足"); + const tone: CodeAgentRuntimePath["tone"] = isCodexAppServer ? "ok" : provider ? "warn" : "blocked"; + const summary = isCodexAppServer + ? "provider / runner / protocol / implementationType 与 codex app-server stdio 长会话一致;trace 链路可见。" + : provider + ? `provider=${provider} 但 runner / protocol 字段未对齐;runtime 路径证据不足。` + : "providerTrace 未观测;不会推断完整 Code Agent。"; + const rows: { label: string; value: string }[] = [ + { label: "provider", value: safeField(provider) }, + { label: "runnerKind", value: safeField(runnerKind) }, + { label: "protocol", value: safeField(protocol ?? (isCodexAppServer ? CODEX_APP_SERVER_PROTOCOL : null)) }, + { label: "implementationType", value: safeField(implementationType ?? (isCodexAppServer ? CODEX_APP_SERVER_IMPLEMENTATION : null)) }, + { label: "providerTrace.command", value: safeField(command) }, + { label: "providerTrace.terminalStatus", value: safeField(terminalStatus) }, + { label: "providerTrace.failureKind", value: safeField(failureKind) } + ]; + const missingFields = rows.filter((row) => row.value === "未观测" || row.value === "字段缺失:证据不足").map((row) => row.label); + return { + protocol: rows[2]?.value ?? "未观测", + implementationType: rows[3]?.value ?? "未观测", + command: rows[4]?.value ?? "未观测", + terminalStatus: rows[5]?.value ?? "未观测", + failureKind: rows[6]?.value ?? "未观测", + label, + summary, + tone, + rows, + missingFields + }; +} + +function compactToolCallsSummary(toolCalls: unknown): string { + if (!toolCalls) return "none"; + if (typeof toolCalls === "string") return toolCalls; + if (Array.isArray(toolCalls)) { + if (toolCalls.length === 0) return "none"; + return toolCalls.map((tool) => { + if (typeof tool === "string") return tool; + const record = asRecord(tool); + return record?.name ? String(record.name) : jsonPreview(tool, 60); + }).join(", "); + } + const record = asRecord(toolCalls); + if (!record) return "none"; + return record.name ? String(record.name) : jsonPreview(record, 120); +} + +function compactSkillsSummary(skills: unknown): string { + if (!skills) return "none"; + if (typeof skills === "string") return skills; + if (Array.isArray(skills)) { + if (skills.length === 0) return "none"; + return skills.map((skill) => asString(skill) ?? jsonPreview(skill, 60)).join(", "); + } + const record = asRecord(skills); + if (!record) return "none"; + return record.id ? String(record.id) : jsonPreview(record, 120); +} + +function compactRunnerTraceSummary(runnerTrace: unknown): string { + if (!runnerTrace) return "未观测"; + const record = asRecord(runnerTrace); + if (!record) return "未观测"; + const eventCount = Array.isArray(record.events) ? (record.events as unknown[]).length : Number(record.eventCount ?? 0); + const status = asString(record.status); + const sessionId = shortToken(asString(record.sessionId), 8); + const lastLabel = asString(record.lastEventLabel) ?? asString(record.lastEvent) ?? asString(record.waitingFor) ?? "无"; + const parts: string[] = []; + if (status) parts.push(`status=${status}`); + parts.push(`events=${eventCount || 0}`); + if (sessionId !== "unknown") parts.push(`session=${sessionId}`); + parts.push(`last=${lastLabel}`); + return parts.join(" / "); +} + +export function codeAgentSummaryRows(summary: CodeAgentStatusSummary): { key: string; value: string; tone: CodeAgentStatusSummary["tone"] | "source" }[] { + return [ + { key: "codeAgent.status", value: summary.codeAgentStatus, tone: summary.tone }, + { key: "provider/mode/backend", value: summary.providerModeBackend, tone: "source" }, + { key: "capabilityLevel", value: summary.capabilityLevel, tone: summary.tone }, + { key: "session", value: summary.sessionSummary, tone: sessionSummaryTone(summary) }, + { key: "sessionId/status", value: summary.sessionIdStatus, tone: sessionSummaryTone(summary) }, + { key: "会话提示", value: summary.sessionLifecycleHint, tone: sessionSummaryTone(summary) }, + { key: "workspace", value: summary.workspace, tone: "source" }, + { key: "sandbox", value: summary.sandbox, tone: "source" }, + { key: "runnerKind", value: summary.runnerKind, tone: summary.kind === "long-lived-session" ? "ok" : "source" }, + { key: "protocol", value: summary.protocol, tone: summary.runtimePathTone }, + { key: "implementationType", value: summary.implementationType, tone: summary.runtimePathTone }, + { key: "providerTrace.command", value: summary.providerTraceCommand, tone: summary.runtimePathTone }, + { key: "providerTrace.terminalStatus", value: summary.providerTraceTerminalStatus, tone: summary.runtimePathTone }, + { key: "providerTrace.failureKind", value: summary.providerTraceFailureKind, tone: summary.runtimePathTone }, + { key: "运行路径语义", value: summary.runtimePathLabel, tone: summary.runtimePathTone }, + { key: "toolCalls", value: summary.toolCalls, tone: summary.toolCalls === "none" ? "source" : summary.tone }, + { key: "skills", value: summary.skills, tone: summary.skills === "none" ? "source" : summary.tone }, + { key: "conversation facts", value: summary.fields.runnerTrace ?? "未观测", tone: "source" }, + { key: "runnerTrace", value: summary.runnerTrace, tone: "source" }, + { key: "readiness blockers", value: summary.blockersLabel, tone: summary.readinessBlockers.length > 0 ? "blocked" : "source" }, + { key: "last traceId", value: summary.lastTraceId, tone: "source" }, + { key: "当前部署 revision", value: summary.deploymentRevision, tone: "source" } + ]; +} + +function sessionSummaryTone(summary: CodeAgentStatusSummary): CodeAgentStatusSummary["tone"] { + if (summary.kind === "long-lived-session") return "ok"; + if (summary.kind === "blocked") return "blocked"; + if (["read-only-session-tools", "stateless-one-shot", "fallback-text-chat-only", "degraded"].includes(summary.kind)) return "warn"; + return "source"; +} + +export function summarizeLastEvent(trace: RunnerTrace | null | undefined): { label: string; ts: string | null; tone: CodeAgentStatusSummary["tone"] } { + if (!trace) return { label: "等待后端事件", ts: null, tone: "source" }; + const events = Array.isArray(trace.events) ? trace.events as TraceEvent[] : []; + const last = events[events.length - 1] ?? null; + const lastLabel = String(last?.label ?? last?.type ?? trace.lastEventLabel ?? trace.lastEvent ?? trace.waitingFor ?? "等待后端事件"); + const ts: string | null = typeof last?.ts === "string" ? last.ts : (typeof trace.updatedAt === "string" ? trace.updatedAt : null); + const status = String(last?.status ?? ""); + const tone: CodeAgentStatusSummary["tone"] = status === "completed" ? "ok" : status === "running" || status === "pending" ? "warn" : status === "failed" ? "blocked" : "source"; + return { label: lastLabel, ts, tone }; +} diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index a174299c..811bdace 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -84,6 +84,26 @@ button:disabled { cursor: not-allowed; opacity: 0.55; } .code-agent-summary { border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; background: #fbfcf8; } .code-agent-summary summary { display: flex; gap: 10px; align-items: center; cursor: pointer; min-width: 0; } .code-agent-summary-detail pre, .message-trace pre, .skill-preview, .help-content, #device-detail-body { white-space: pre-wrap; overflow-wrap: anywhere; } +.code-agent-summary-rows { display: grid; gap: 4px 14px; grid-template-columns: minmax(180px, max-content) 1fr; align-items: baseline; } +.code-agent-summary-row { display: contents; } +.code-agent-summary-key { color: var(--muted); font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; } +.code-agent-summary-value { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; } +.code-agent-summary-raw { margin-top: 8px; font-size: 12px; color: var(--muted); } +.code-agent-summary-raw pre { margin: 4px 0 0; max-height: 220px; overflow: auto; background: #f3f5f1; padding: 8px; border-radius: 6px; } +.message-trace-toolbar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); background: #f7f8f4; } +.message-trace-toolbar-spacer { flex: 1; } +.message-trace-toolbar button { padding: 4px 10px; min-height: 28px; } +.message-trace-count, .message-trace-storage-key { color: var(--muted); font-size: 12px; } +.message-trace-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; padding: 8px 10px; } +.message-trace-row { border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; background: #fff; } +.message-trace-line { display: flex; align-items: center; gap: 8px; min-width: 0; } +.message-trace-tone { color: var(--accent); } +.message-trace-label { font-weight: 700; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; } +.message-trace-time { color: var(--muted); font-size: 12px; } +.message-trace-body { margin: 6px 0 0; max-height: 220px; overflow: auto; background: #f3f5f1; padding: 6px 8px; border-radius: 6px; font-size: 12px; } +.message-trace-events { min-height: 0; max-height: 360px; overflow: auto; } +.message-trace-empty { color: var(--muted); font-size: 12px; padding: 10px; } +.message-trace-last { color: var(--muted); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .conversation-list { min-height: 0; display: grid; align-content: start; gap: 12px; overflow: auto; padding-right: 4px; } .message-card { border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: 12px; } .message-head { display: flex; justify-content: space-between; gap: 10px; } diff --git a/web/hwlab-cloud-web/src/utils.ts b/web/hwlab-cloud-web/src/utils.ts index ac38d48d..b79c6fb8 100644 --- a/web/hwlab-cloud-web/src/utils.ts +++ b/web/hwlab-cloud-web/src/utils.ts @@ -34,13 +34,57 @@ export function formatTimestamp(value: unknown): string { if (!text) return "未知"; const date = new Date(text); if (Number.isNaN(date.getTime())) return "未知"; - return new Intl.DateTimeFormat("zh-CN", { - timeZone: "Asia/Shanghai", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit" - }).format(date); + return formatBeijingTime(date); +} + +const BEIJING_FORMATTER = new Intl.DateTimeFormat("zh-CN", { + timeZone: "Asia/Shanghai", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false +}); + +const BEIJING_SHORT_FORMATTER = new Intl.DateTimeFormat("zh-CN", { + timeZone: "Asia/Shanghai", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false +}); + +function toBeijingParts(value: Date | string | number, withSeconds: boolean): Intl.DateTimeFormatPart[] | null { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return null; + return (withSeconds ? BEIJING_FORMATTER : BEIJING_SHORT_FORMATTER).formatToParts(date); +} + +export function formatBeijingTime(value: unknown, options: { withSeconds?: boolean; includeYear?: boolean } = {}): string { + const parts = toBeijingParts(value as Date | string | number, Boolean(options.withSeconds)); + if (!parts) return "未知"; + const lookup = new Map(parts.map((part) => [part.type, part.value])); + const year = lookup.get("year") ?? ""; + const month = lookup.get("month") ?? "00"; + const day = lookup.get("day") ?? "00"; + const hour = lookup.get("hour") ?? "00"; + const minute = lookup.get("minute") ?? "00"; + const second = lookup.get("second") ?? "00"; + const datePart = options.includeYear === false ? `${month}-${day}` : `${year}-${month}-${day}`; + return options.withSeconds ? `${datePart} ${hour}:${minute}:${second}` : `${datePart} ${hour}:${minute}`; +} + +export function formatDuration(ms: unknown): string { + const value = Number(ms); + if (!Number.isFinite(value) || value < 0) return "未知"; + if (value < 1000) return `${Math.round(value)}ms`; + if (value < 60_000) return `${(value / 1000).toFixed(value < 10_000 ? 2 : 1)}s`; + const minutes = Math.floor(value / 60_000); + const seconds = Math.floor((value % 60_000) / 1000); + return `${minutes}m${seconds.toString().padStart(2, "0")}s`; } export function relativeUpdatedLabel(timestampMs: number): string {