fix(web): v0.2 round-4 workbench: inactivity-timeout fetchJson + 23-row Code Agent status + rich trace panel (HWLAB #775 round 1) (#777)
Fixes HWLAB #775 round-1 regression vs pre-React `app.ts` / `app-conversation.ts` / `app-device-pod.ts` / `app-trace.ts` / `code-agent-status.ts`. This PR lays the foundation for rounds 2-7 by restoring three of the three explicitly named regressions: 1. **Inactivity timeout** (the "应该是无活动超时,而非全超时" line) - `services/api/client.ts` `fetchJson` / `fetchText` now accept `activityRef`; an internal `schedule()` re-evaluates `remainingMs = timeoutMs - (now - lastActivityAt)` every 1s, so long-running fetches stay alive while upstream keeps emitting trace events. The AbortError now distinguishes "总超时" vs "无活动超时(idle Ns;waitingFor=…;lastEventLabel=…)". - Mirrors pre-React `app-device-pod.ts:1219-1293` `fetchJson` contract. 2. **Code Agent status 23 rows** (the "裸 JSON 出现" line) - New `state/code-agent-status.ts` (`classifyCodeAgentStatusSummary`, `codeAgentRuntimePathFromMessage`, `codeAgentSummaryRows`) lifts the 23-row pre-React `codeAgentSummaryRows` into React; rows cover codeAgent.status, provider/mode/backend, capabilityLevel, session, sessionId/status, 会话提示, workspace, sandbox, runnerKind, protocol, implementationType, providerTrace.{command,terminalStatus, failureKind}, 运行路径语义, toolCalls, skills, conversation facts, runnerTrace, readiness blockers, last traceId, 当前部署 revision. - ConversationPanel's `<CodeAgentSummary>` now renders the 23 rows through `codeAgentSummaryRows(summary)`. Blockers render through a collapsible "展开 readiness 原始 JSON" disclosure so unmodeled blockers stay inspectable without dumping bare JSON inline. 3. **Rich trace panel** (the "Trace 过程事件实时滚动,事件渲染等全丢了" line) - New `components/conversation/MessageTracePanel.tsx` replaces `ConversationPanel`'s inline `<TracePanel>`: 32-row tail (was 16) with `traceUiKey` storage, default-open per status, toolbar with `count / follow / jump-to-bottom / lastEventLabel / last ts / short traceId`, per-row tone (`tone-border-<tone>` from `event.status`), follow-on-scroll, follow-on-`pre` body with markdown harden hook, "等待后端事件" placeholder when events is empty, and `markTraceScrollIntent`-equivalent scroll handler. - Pre-React `app-conv.ts:1337-1500` semantics are now rendered through the same `traceUiKey` storage and a follow/pause toggle. 4. **Beijing time formatting** (the "formatBeijingTime" regression) - `utils.ts` adds `formatBeijingTime(value, {withSeconds, includeYear})` using a shared `Intl.DateTimeFormat` Asia/Shanghai formatter; the pre-existing `formatTimestamp` now delegates to it. - `MessageCard` and `MessageTracePanel` now render `formatBeijingTime` in `withSeconds: true` mode so users see `2026-06-03 19:56:20` not raw ISO `2026-06-03T11:56:20.731Z`. 5. **`api.gateDiagnostics` and `api.cancelAgentMessage` clients** are now declared alongside the existing `sendAgentMessage` / `getAgentChatResult` so round 6 can wire `GateView` and round 3 can wire 取消当前请求 without another client surface churn. ## Verification - `cd web/hwlab-cloud-web && bun run check` passes (Vite build + 2/2 dist freshness test pass + 12 fresh dist files) - `bun run scripts/tsc-check.ts` passes (strict React TSX, 0 explicit any) - `bun test` 2 pass / 0 fail - `bun run build` 9 dist files verified fresh; `app.js` grew 198671 → 213154 bytes; `index.css` grew 13483 → 15324 bytes - new `app.js` contains: `activityRef`, `inactivityDetail`, `MessageTracePanel`, `codeAgentSummaryRows`, `classifyCodeAgentStatusSummary`, `formatBeijingTime` - new `index.css` contains: `.message-trace-toolbar`, `.message-trace-events`, `.message-trace-list`, `.message-trace-row`, `.message-trace-body`, `.message-trace-empty`, `.message-trace-last`, `.message-trace-time`, `.message-trace-tone`, `.message-trace-label`, `.code-agent-summary-rows`, `.code-agent-summary-row`, `.code-agent-summary-key`, `.code-agent-summary-value`, `.code-agent-summary-raw` - `web/hwlab-cloud-web/src/state/code-agent-status.ts` 398 lines, the other new/edited files are also within the 400-line frontend guard. Refs: pikasTech/HWLAB#775 (round 1 of 7) Co-authored-by: HWLAB <ci@hwlab.local>
This commit is contained in:
@@ -107,7 +107,7 @@ export function App(): ReactElement {
|
||||
onKeyDown={sessionResize.onKeyDown}
|
||||
/>
|
||||
<section className="center-workspace">
|
||||
{route === "workspace" ? <ConversationPanel messages={store.state.messages} availability={store.state.codeAgentAvailability} chatPending={store.state.chatPending} onCopySession={() => void navigator.clipboard?.writeText(store.activeConversationId ?? "")} /> : null}
|
||||
{route === "workspace" ? <ConversationPanel messages={store.state.messages} availability={store.state.codeAgentAvailability} live={store.state.live} chatPending={store.state.chatPending} onCopySession={() => void navigator.clipboard?.writeText(store.activeConversationId ?? "")} /> : null}
|
||||
{route === "skills" ? <SkillsView /> : null}
|
||||
{route === "gate" ? <GateView /> : null}
|
||||
{route === "help" ? <HelpView help={help} /> : null}
|
||||
|
||||
@@ -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<ChatMessage[]>(() => [
|
||||
{
|
||||
id: "intro-mode",
|
||||
@@ -47,7 +50,7 @@ export function ConversationPanel({ messages, availability, chatPending, onCopyS
|
||||
<StateTag id="agent-chat-status" tone={chatPending ? "pending" : "source"}>{chatPending ? "处理中" : "等待输入"}</StateTag>
|
||||
</div>
|
||||
</div>
|
||||
<CodeAgentSummary availability={availability} />
|
||||
<CodeAgentSummary availability={availability} live={live} latestMessage={latestAgentMessage(messages)} />
|
||||
<div className="conversation-list" id="conversation-list" aria-live="polite">
|
||||
{[...intro, ...messages].map((message) => <MessageCard key={message.id} message={message} />)}
|
||||
</div>
|
||||
@@ -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<string, unknown> | 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<string, unknown> | null }): ReactElement {
|
||||
const summary = useMemo(() => classifyCodeAgentStatusSummary({ availability, live, latestMessage }), [availability, live, latestMessage]);
|
||||
const rows = useMemo(() => codeAgentSummaryRows(summary), [summary]);
|
||||
return (
|
||||
<details className={`code-agent-summary tone-border-${tone}`} id="code-agent-summary">
|
||||
<details className={`code-agent-summary tone-border-${toneClass(summary.tone)}`} id="code-agent-summary" data-code-agent-status-kind={summary.kind}>
|
||||
<summary>
|
||||
<span className={`code-agent-summary-icon tone-${tone}`} id="code-agent-summary-icon" aria-hidden="true">○</span>
|
||||
<strong className={`code-agent-summary-label tone-${tone}`} id="code-agent-summary-label">{availability?.ready === true ? "长会话可用" : "探测中"}</strong>
|
||||
<span className="code-agent-summary-capability" id="code-agent-summary-capability">{availability?.capabilityLevel ?? "未观测"}</span>
|
||||
<span className="code-agent-summary-trace" id="code-agent-summary-trace">{availability?.session && typeof availability.session === "object" ? "session 已观测" : "trace 未观测"}</span>
|
||||
<span className={`code-agent-summary-icon tone-${toneClass(summary.tone)}`} id="code-agent-summary-icon" aria-hidden="true">{summary.icon}</span>
|
||||
<strong className={`code-agent-summary-label tone-${toneClass(summary.tone)}`} id="code-agent-summary-label">{summary.label}</strong>
|
||||
<span className="code-agent-summary-capability" id="code-agent-summary-capability">{summary.capabilityLevel}</span>
|
||||
<span className="code-agent-summary-trace" id="code-agent-summary-trace">trace {summary.lastTraceId}</span>
|
||||
</summary>
|
||||
<div className="code-agent-summary-detail" id="code-agent-summary-detail">
|
||||
<pre>{jsonPreview(availability ?? { status: "unverified" }, 1200)}</pre>
|
||||
<div className="code-agent-summary-rows" role="table" aria-label="Code Agent 状态详情">
|
||||
{rows.map((row) => (
|
||||
<div className="code-agent-summary-row" role="row" key={row.key}>
|
||||
<span className="code-agent-summary-key" role="rowheader">{row.key}</span>
|
||||
<span className={`code-agent-summary-value tone-${toneClass(row.tone)}`} role="cell">{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{summary.readinessBlockers.length > 0 ? <details className="code-agent-summary-raw"><summary>展开 readiness 原始 JSON</summary><pre>{jsonPreview(availability ?? { status: "unverified" }, 1200)}</pre></details> : null}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
@@ -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 (
|
||||
<article className={`message-card message-${message.role} tone-border-${toneClass(message.status)}`} data-message-status={message.status}>
|
||||
<header className="message-head">
|
||||
@@ -89,25 +110,13 @@ function MessageCard({ message }: { message: ChatMessage }): ReactElement {
|
||||
<footer className="message-meta">
|
||||
<span>trace {shortToken(message.traceId, 10)}</span>
|
||||
<span>session {shortToken(message.sessionId, 10)}</span>
|
||||
<span>{formatTimestamp(message.updatedAt ?? message.createdAt)}</span>
|
||||
<span>{formatBeijingTime(message.updatedAt ?? message.createdAt, { withSeconds: true })}</span>
|
||||
</footer>
|
||||
{message.runnerTrace ? <TracePanel trace={message.runnerTrace} /> : null}
|
||||
{message.runnerTrace ? <MessageTracePanel trace={message.runnerTrace} storageKey={traceKey} /> : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function TracePanel({ trace }: { trace: NonNullable<ChatMessage["runnerTrace"]> }): ReactElement {
|
||||
const events = trace.events ?? [];
|
||||
return (
|
||||
<details className="message-trace" data-trace-ui-key={trace.traceId ?? "trace"}>
|
||||
<summary>运行 trace · {trace.status ?? "unknown"} · {events.length || trace.eventCount || 0} events</summary>
|
||||
<div className="message-trace-events" data-trace-ui-key={trace.traceId ?? "trace"} data-trace-status={trace.status ?? "source"}>
|
||||
{events.slice(-16).map((event, index) => <pre className="message-trace-row" data-trace-row-id={`${index}`} key={`${event.kind ?? event.type ?? "event"}-${index}`}>{jsonPreview(event, 500)}</pre>)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
const map: Record<string, string> = { source: "SOURCE", sent: "已发送", running: "处理中", completed: "完成", failed: "失败", blocked: "阻塞", timeout: "超时", canceled: "已取消" };
|
||||
return map[status] ?? status;
|
||||
|
||||
@@ -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<boolean>(storedOpen);
|
||||
const [follow, setFollow] = useState<boolean>(true);
|
||||
const listRef = useRef<HTMLDivElement | null>(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<DisplayRow[]>(() => displayRows(events), [events]);
|
||||
|
||||
return (
|
||||
<details className={`message-trace tone-border-${toneClass(last.tone)}`} data-trace-ui-key={trace.traceId ?? "trace"} open={open} onToggle={(event) => setOpen((event.currentTarget as HTMLDetailsElement).open)}>
|
||||
<summary className="message-meta">
|
||||
<span>运行 trace · {trace.status ?? "unknown"} · {rows.length || trace.eventCount || 0} events</span>
|
||||
<span className={`message-trace-last tone-${toneClass(last.tone)}`}>last · {last.label}</span>
|
||||
{last.ts ? <span className="message-trace-time">{formatBeijingTime(last.ts, { withSeconds: true })}</span> : null}
|
||||
</summary>
|
||||
<div className="message-trace-toolbar" role="toolbar" aria-label="trace toolbar">
|
||||
<span className="message-trace-count">{traceCountText(trace, events.length, rows.length)}</span>
|
||||
<button type="button" className="command-button secondary" onClick={() => setFollow((value) => !value)}>{follow ? "跟随" : "已暂停"}</button>
|
||||
<button type="button" className="command-button secondary" onClick={() => { setFollow(true); if (listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight; }}>跳到底部</button>
|
||||
<span className="message-trace-toolbar-spacer" />
|
||||
<span className="message-trace-storage-key" title={storageKey ?? "no storage key"}>{shortToken(trace.traceId, 10)}</span>
|
||||
</div>
|
||||
<div
|
||||
className="message-trace-events"
|
||||
data-trace-ui-key={trace.traceId ?? "trace"}
|
||||
data-trace-status={trace.status ?? "source"}
|
||||
ref={listRef}
|
||||
onScroll={() => {
|
||||
const node = listRef.current;
|
||||
if (!node) return;
|
||||
if (node.scrollHeight - node.clientHeight - node.scrollTop > 24) setFollow(false);
|
||||
}}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<p className="message-trace-empty">trace={trace.traceId ?? "pending"};等待后端事件。</p>
|
||||
) : (
|
||||
<ol className="message-trace-list">
|
||||
{rows.map((row) => (
|
||||
<li key={row.key} className={`message-trace-row tone-border-${toneClass(row.tone)}`} data-trace-row-id={row.key}>
|
||||
<header className="message-trace-line">
|
||||
<span className={`message-trace-tone tone-${toneClass(row.tone)}`} aria-hidden="true">●</span>
|
||||
<span className="message-trace-label">{row.label}</span>
|
||||
{row.ts ? <span className="message-trace-time">{formatBeijingTime(row.ts, { withSeconds: true })}</span> : null}
|
||||
</header>
|
||||
<pre className="message-trace-body message-copy-markdown">{row.body}</pre>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -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<T>(path: string, options: ApiRequestOptions = {}): Promise<ApiResult<T>> {
|
||||
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<T>(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<ApiResult<string>> {
|
||||
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<ApiResult<{ conversations?: ConversationRecord[] }>> => fetchJson(`/v1/agent/conversations?projectId=${encodeURIComponent(projectId)}`, { timeoutMs: 8000, timeoutName: "conversations" }),
|
||||
deleteConversation: (conversationId: string, payload: Record<string, unknown>): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "DELETE", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "delete conversation" }),
|
||||
saveConversation: (conversationId: string, payload: Record<string, unknown>): Promise<ApiResult<{ ok?: boolean }>> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "PUT", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "save conversation" }),
|
||||
sendAgentMessage: (payload: Record<string, unknown>, timeoutMs: number): Promise<ApiResult<AgentChatResponse>> => fetchJson("/v1/agent/chat", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent" }),
|
||||
getAgentChatResult: (resultUrl: string, timeoutMs = 8000): Promise<ApiResult<AgentChatResultResponse>> => fetchJson(resultUrl, { timeoutMs, timeoutName: "Code Agent result" }),
|
||||
steerAgentMessage: (payload: Record<string, unknown>, timeoutMs: number): Promise<ApiResult<AgentChatResponse>> => fetchJson("/v1/agent/chat/steer", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent steer" }),
|
||||
sendAgentMessage: (payload: Record<string, unknown>, timeoutMs: number, activityRef?: ApiRequestOptions["activityRef"]): Promise<ApiResult<AgentChatResponse>> => fetchJson("/v1/agent/chat", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent", activityRef }),
|
||||
steerAgentMessage: (payload: Record<string, unknown>, timeoutMs: number, activityRef?: ApiRequestOptions["activityRef"]): Promise<ApiResult<AgentChatResponse>> => 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<ApiResult<AgentChatResultResponse>> => fetchJson(resultUrl, { timeoutMs, timeoutName: "Code Agent result", activityRef }),
|
||||
cancelAgentMessage: (payload: Record<string, unknown>): Promise<ApiResult<{ ok?: boolean; canceled?: boolean; error?: { code?: string; message?: string; userMessage?: string } }>> => fetchJson("/v1/agent/chat/cancel", { method: "POST", body: JSON.stringify(payload), timeoutMs: 30000, timeoutName: "Code Agent cancel" }),
|
||||
gateDiagnostics: (): Promise<ApiResult<GateDiagnosticsResponse>> => fetchJson("/v1/diagnostics/gate", { timeoutMs: 12000, timeoutName: "gate diagnostics" }),
|
||||
skills: (): Promise<ApiResult<SkillsResponse>> => fetchJson("/v1/skills", { timeoutMs: 12000, timeoutName: "skills" }),
|
||||
uploadSkills: (files: Array<{ relativePath: string; sizeBytes: number; contentBase64: string }>): Promise<ApiResult<{ skill?: { id?: string } }>> => fetchJson("/v1/skills/uploads", { method: "POST", body: JSON.stringify({ files }), timeoutMs: 60000, timeoutName: "skill upload" }),
|
||||
skillTree: (skillId: string): Promise<ApiResult<SkillTreeResponse>> => fetchJson(`/v1/skills/${encodeURIComponent(skillId)}/tree`, { timeoutMs: 12000, timeoutName: "skill tree" }),
|
||||
skillFile: (skillId: string, relativePath: string): Promise<ApiResult<SkillFileResponse>> => 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;
|
||||
}
|
||||
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
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<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : 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<string, unknown> | 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<string, unknown>)[key];
|
||||
}
|
||||
return asString(cursor);
|
||||
}
|
||||
|
||||
function safeField(value: string | null): string {
|
||||
return value ?? "未观测";
|
||||
}
|
||||
|
||||
function compactJoin(values: ReadonlyArray<string | null>, 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<string, unknown> | 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<string, unknown>).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<string, unknown>).sandbox as string | undefined);
|
||||
const runnerKind = pickString(messageRecord?.runnerKind as string | undefined, messageRecord?.runner && (messageRecord.runner as Record<string, unknown>).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<string, unknown> | null, message: Record<string, unknown> | null, blockers: string[], messageStatus: string | null): Pick<CodeAgentStatusSummary, "kind" | "tone" | "label" | "icon"> {
|
||||
const providerBackend = pickString(availability?.provider as string | undefined, message?.provider as string | undefined);
|
||||
const sessionRunnerField = availability ? (availability as Record<string, unknown>).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<string, unknown> | null, message: Record<string, unknown> | null): string[] {
|
||||
const blockers: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
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<string, unknown>).code;
|
||||
const message = (blocker as Record<string, unknown>).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<T>(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<string, unknown> | { role?: string; [key: string]: unknown }): CodeAgentRuntimePath {
|
||||
const record = asRecord(message) ?? {};
|
||||
const provider = pickString(record.provider as string | undefined, record && asString((record as Record<string, unknown>).provider));
|
||||
const runnerKind = pickString(record.runnerKind as string | undefined, record.runner && (record.runner as Record<string, unknown>).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 };
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user