From 0740712a57503d0a6b483778f43242b2cd2a3cb1 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:00:28 +0800 Subject: [PATCH] fix(web): v0.2 round-6 workbench: message actions panel (cancel/retry/replay) (HWLAB #775 round 3) (#784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continues HWLAB #775 round 3 by adding the message-actions panel that the React migration dropped from `app-conv.ts:908-935`: running → 取消当前请求 + 重试上一条; terminal → 重试上一条; any with traceId → 回放 trace. The round-2 trace streaming stays on top of the actions so a user can cancel mid-flight without losing the events the user has already seen. ## 修复对照 | 类别 | 迁移前 (`893f46bc`) | 迁移后 (round 3) | 状态 | |---|---|---|---| | **消息动作面板** | `app-conv.ts:908-935` `messageActionsPanel` running 取消 + 重试,terminal 重试,any traceId 回放 | `components/conversation/MessageActions.tsx`:同结构按钮 + `traceReplayStatus` 行内展示 | ✅ 对齐 | | **取消当前请求** | `app-conv.ts:1013-1070` `cancelAgentMessage` 调 `/v1/agent/chat/cancel` 回填 trace + sessionId/threadId + `error.code = "codex_stdio_canceled"` | `state/runner-actions.ts:cancelAgentMessageAction` 同路径,dispatch `message:replace` | ✅ 对齐 | | **重试上一条** | `app-conv.ts:946-1000` `retryAgentMessage` + `restartRunningAgentMessage` 复用 conversation/trace 记录 | `state/runner-actions.ts:retryAgentMessageAction` 走 `submitMessage(retryInput)` | ✅ 对齐 | | **回放 trace** | `app-conv.ts:1073-1125` `replayAgentTrace` / `replayFullTrace` 调 `/v1/agent/chat/trace/` | `state/runner-actions.ts:replayAgentTraceAction` 走 `state/runner-trace.ts:replayFullTrace`,dispatch `message:trace` + `message:trace-status` | ✅ 对齐 | | **retryInput 保留** | `app-conv.ts:1019` 在 `cancelAgentMessage` 末尾 `el.commandInput.value = message.retryInput` | `state/workbench.ts:submitMessage` 把 `value` 注入 `pending.retryInput`,round-trip 通过 reducer 保留 | ✅ 对齐 | ## 改动 - 新增 `web/hwlab-cloud-web/src/components/conversation/MessageActions.tsx`(44 行) - 新增 `web/hwlab-cloud-web/src/state/runner-actions.ts`(84 行)— `cancelAgentMessageAction` / `retryAgentMessageAction` / `replayAgentTraceAction` 三个纯函数 - 新增 `web/hwlab-cloud-web/src/state/workbench-reducer.ts`(99 行)— reducer + 4 个 helper(`composerFromState` / `availabilityFromLive` / `conversationFromTab` / `devicePodsFromResult` / `readStoredString` / `readStoredNumber` / `readProviderProfile` / `devicePodsFromLive`) - 新增 `web/hwlab-cloud-web/src/state/workbench-state.ts`(33 行)— `WorkbenchState` / `ComposerState` 类型 - `web/hwlab-cloud-web/src/state/workbench.ts`(319 行,< 400 前端 guard)— 删除冗余 reducer / helpers,import 上面 3 个文件,导出 `devicePodsFromLive` re-export,新增 `cancelAgentMessage` / `retryAgentMessage` / `replayAgentTrace` 三个 hook callback - `web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx` — `MessageCard` 接 `onCancel` / `onRetry` / `onReplayTrace` props - `web/hwlab-cloud-web/src/App.tsx` — `` 接 `onCancelMessage` / `onRetryMessage` / `onReplayTrace` 三个事件 - `web/hwlab-cloud-web/src/types/domain.ts` — `ChatMessage` 加 `retryInput` / `traceReplayStatus` - `web/hwlab-cloud-web/src/styles/workbench.css` — `.message-actions` / `.message-action` / `.message-action-status` ## 验收 - `bun run check` 通过(Vite build + 2/2 dist freshness test pass + 12 fresh dist files) - `bun run scripts/tsc-check.ts` 通过(strict React TSX, 0 explicit any) - `bun test` 2 pass / 0 fail - `bun run build` 9 dist files verified fresh;`app.js` 218185 → 221256 bytes;`index.css` 15324 → 16041 bytes - dist `app.js` 含 `cancelAgentMessage` / `retryAgentMessage` / `replayAgentTrace` / `message-actions` / `message-action-cancel` / `message-action-retry` / `message-action-trace` / `message-action-status` 等关键字 - 所有改动文件 < 400 行前端 guard Refs: pikasTech/HWLAB#775 (round 3 of 7) Co-authored-by: HWLAB --- web/hwlab-cloud-web/src/App.tsx | 2 +- .../conversation/ConversationPanel.tsx | 11 +- .../conversation/MessageActions.tsx | 44 +++++ .../src/state/runner-actions.ts | 84 ++++++++ .../src/state/workbench-reducer.ts | 104 ++++++++++ .../src/state/workbench-state.ts | 33 ++++ web/hwlab-cloud-web/src/state/workbench.ts | 181 +++++++----------- web/hwlab-cloud-web/src/styles/workbench.css | 7 + web/hwlab-cloud-web/src/types/domain.ts | 2 + 9 files changed, 347 insertions(+), 121 deletions(-) create mode 100644 web/hwlab-cloud-web/src/components/conversation/MessageActions.tsx create mode 100644 web/hwlab-cloud-web/src/state/runner-actions.ts create mode 100644 web/hwlab-cloud-web/src/state/workbench-reducer.ts create mode 100644 web/hwlab-cloud-web/src/state/workbench-state.ts diff --git a/web/hwlab-cloud-web/src/App.tsx b/web/hwlab-cloud-web/src/App.tsx index 7567254e..f0e1c3e3 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 ?? "")} onCancelMessage={(id) => void store.cancelAgentMessage(id)} onRetryMessage={(id) => void store.retryAgentMessage(id)} onReplayTrace={(id) => void store.replayAgentTrace(id)} /> : 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 c4493d03..3231241e 100644 --- a/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx +++ b/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx @@ -7,6 +7,7 @@ import { formatBeijingTime, jsonPreview, shortToken, toneClass } from "../../uti import { codeAgentSummaryRows, classifyCodeAgentStatusSummary } from "../../state/code-agent-status"; import { StateTag } from "../shared/StateTag"; import { MessageTracePanel } from "./MessageTracePanel"; +import { MessageActions } from "./MessageActions"; interface ConversationPanelProps { messages: ChatMessage[]; @@ -14,9 +15,12 @@ interface ConversationPanelProps { live: LiveSurface | null; chatPending: boolean; onCopySession(): void; + onCancelMessage(messageId: string): void; + onRetryMessage(messageId: string): void; + onReplayTrace(messageId: string): void; } -export function ConversationPanel({ messages, availability, live, chatPending, onCopySession }: ConversationPanelProps): ReactElement { +export function ConversationPanel({ messages, availability, live, chatPending, onCopySession, onCancelMessage, onRetryMessage, onReplayTrace }: ConversationPanelProps): ReactElement { const intro = useMemo(() => [ { id: "intro-mode", @@ -52,7 +56,7 @@ export function ConversationPanel({ messages, availability, live, chatPending, o
- {[...intro, ...messages].map((message) => )} + {[...intro, ...messages].map((message) => onCancelMessage(message.id)} onRetry={() => onRetryMessage(message.id)} onReplayTrace={() => onReplayTrace(message.id)} />)}
@@ -94,7 +98,7 @@ function CodeAgentSummary({ availability, live, latestMessage }: { availability: ); } -function MessageCard({ message }: { message: ChatMessage }): ReactElement { +function MessageCard({ message, onCancel, onRetry, onReplayTrace }: { message: ChatMessage; onCancel(): void; onRetry(): void; onReplayTrace(): void }): ReactElement { const html = renderMessageMarkdown(message.text); const traceKey = `hwlab.workbench.trace-open.${message.traceId ?? message.id}`; return ( @@ -113,6 +117,7 @@ function MessageCard({ message }: { message: ChatMessage }): ReactElement { {formatBeijingTime(message.updatedAt ?? message.createdAt, { withSeconds: true })} {message.runnerTrace ? : null} + ); } diff --git a/web/hwlab-cloud-web/src/components/conversation/MessageActions.tsx b/web/hwlab-cloud-web/src/components/conversation/MessageActions.tsx new file mode 100644 index 00000000..fc8f6d41 --- /dev/null +++ b/web/hwlab-cloud-web/src/components/conversation/MessageActions.tsx @@ -0,0 +1,44 @@ +import type { ReactElement } from "react"; + +import type { ChatMessage } from "../../types/domain"; +import { toneClass } from "../../utils"; + +interface MessageActionsProps { + message: ChatMessage; + onCancel(): void; + onRetry(): void; + onReplayTrace(): void; +} + +export function MessageActions({ message, onCancel, onRetry, onReplayTrace }: MessageActionsProps): ReactElement | null { + if (message.role !== "agent") return null; + const status = String(message.status ?? ""); + const buttons: ReactElement[] = []; + if (status === "running") { + buttons.push(actionButton("取消当前请求", "cancel", onCancel, "取消当前 in-flight Codex stdio 请求")); + if (message.retryInput) { + buttons.push(actionButton("重试上一条", "retry", onRetry, "保留 sessionId/trace 并重新发送上一条输入")); + } + } + if (canRetryTerminal(message) && message.retryInput) { + buttons.push(actionButton("重试上一条", "retry", onRetry, "保留 conversation/trace 记录并重新发送上一条输入")); + } + if (message.traceId) { + buttons.push(actionButton("回放 trace", "trace", onReplayTrace, "从 runnerTrace store 重新读取真实事件")); + } + if (buttons.length === 0 && !message.traceReplayStatus) return null; + return ( +
+ {buttons} + {message.traceReplayStatus ? {message.traceReplayStatus} : null} +
+ ); +} + +function actionButton(label: string, action: string, onClick: () => void, title: string): ReactElement { + return ; +} + +function canRetryTerminal(message: ChatMessage): boolean { + return ["failed", "timeout", "error", "canceled", "blocked"].includes(String(message.status ?? "").toLowerCase()); +} diff --git a/web/hwlab-cloud-web/src/state/runner-actions.ts b/web/hwlab-cloud-web/src/state/runner-actions.ts new file mode 100644 index 00000000..aa16fae2 --- /dev/null +++ b/web/hwlab-cloud-web/src/state/runner-actions.ts @@ -0,0 +1,84 @@ +import { api } from "../services/api/client"; +import type { ChatMessage, ConversationRecord, WorkspaceRecord } from "../types/domain"; +import { firstNonEmptyString } from "../utils"; +import { replayFullTrace, snapshotToRunnerTrace } from "./runner-trace"; + +export interface RunnerActionsOptions { + activeConversationId: string | null; + workspace: WorkspaceRecord | null; + messages: ChatMessage[]; + codeAgentTimeoutMs: number; + onReplaceMessage(messageId: string, message: ChatMessage): void; + onTraceStatus(messageId: string, status: string): void; + onTrace(messageId: string, trace: NonNullable): void; + submitMessage(text: string): Promise; +} + +export async function cancelAgentMessageAction( + messageId: string, + options: RunnerActionsOptions +): Promise { + const message = options.messages.find((item) => item.id === messageId); + if (!message?.traceId) return; + const response = await api.cancelAgentMessage({ + traceId: message.traceId, + conversationId: message.conversationId ?? options.activeConversationId ?? undefined, + sessionId: message.sessionId + ?? options.workspace?.selectedAgentSessionId + ?? options.workspace?.workspace?.selectedAgentSessionId + ?? null, + threadId: message.threadId ?? options.workspace?.workspace?.threadId ?? null + }); + const canceled = response.data?.canceled === true; + const errorObject = response.data?.error; + const errorMessageText = typeof errorObject === "object" && errorObject && "message" in errorObject + ? String((errorObject as { message?: unknown }).message ?? "") + : ""; + if (canceled) { + const next: ChatMessage = { + ...message, + title: "Code Agent 已取消", + text: (response.data as { userMessage?: string } | null)?.userMessage ?? "当前 Codex stdio 请求已取消;输入、sessionId、traceId 和最后 trace event 已保留,可重试上一条消息。", + status: "canceled", + updatedAt: new Date().toISOString(), + error: errorObject + ? { ...(typeof errorObject === "object" ? errorObject : {}), code: "codex_stdio_canceled" } + : { code: "codex_stdio_canceled", message: "user canceled current Code Agent request" } + }; + options.onReplaceMessage(messageId, next); + } else { + const failure: ChatMessage = { + ...message, + title: "Code Agent 取消受阻", + text: errorMessageText || response.error || "当前请求没有可取消的 in-flight Codex stdio session;输入和 trace 已保留。", + status: "failed", + updatedAt: new Date().toISOString(), + traceReplayStatus: errorMessageText || "取消未确认" + }; + options.onReplaceMessage(messageId, failure); + } +} + +export async function retryAgentMessageAction( + messageId: string, + options: RunnerActionsOptions +): Promise { + const message = options.messages.find((item) => item.id === messageId); + if (!message?.retryInput) return; + await options.submitMessage(message.retryInput); +} + +export async function replayAgentTraceAction( + messageId: string, + options: RunnerActionsOptions +): Promise { + const message = options.messages.find((item) => item.id === messageId); + if (!message?.traceId) return; + const snapshot = await replayFullTrace(message.traceId, Math.max(8000, options.codeAgentTimeoutMs)); + if (!snapshot) { + options.onTraceStatus(messageId, "trace 回放失败"); + return; + } + options.onTrace(messageId, snapshotToRunnerTrace(snapshot)); + options.onTraceStatus(messageId, `完整 trace 已回放:${snapshot.events?.length ?? 0} 个原始 event;${snapshot.lastEventLabel ?? ""}`); +} diff --git a/web/hwlab-cloud-web/src/state/workbench-reducer.ts b/web/hwlab-cloud-web/src/state/workbench-reducer.ts new file mode 100644 index 00000000..095cd2cc --- /dev/null +++ b/web/hwlab-cloud-web/src/state/workbench-reducer.ts @@ -0,0 +1,104 @@ +import type { ChatMessage, CodeAgentAvailability, ConversationRecord, DevicePodItem, DevicePodListResponse, LiveSurface, SessionTab } from "../types/domain"; +import { firstNonEmptyString, nonEmptyString } from "../utils"; +import { mergeRunnerTrace } from "./runner-trace"; +import type { ComposerState, WorkbenchState } from "./workbench-state"; + +export type Action = + | { type: "hydrate:start" } + | { type: "hydrate:done"; workspace: WorkbenchState["workspace"]; conversations: ConversationRecord[]; messages: ChatMessage[] } + | { type: "hydrate:error"; error: string } + | { type: "live:set"; live: WorkbenchState["live"]; availability: CodeAgentAvailability | null; selectedDevicePodId: string } + | { type: "device:selected"; devicePodId: string } + | { type: "profile:set"; profile: WorkbenchState["providerProfile"] } + | { type: "timeout:set"; codeAgentTimeoutMs: number } + | { type: "gateway-timeout:set"; gatewayShellTimeoutMs: number } + | { type: "message:pending"; user: ChatMessage; pending: ChatMessage; request: WorkbenchState["currentRequest"] } + | { type: "message:replace"; messageId: string; message: ChatMessage } + | { type: "message:trace-status"; messageId: string; status: string } + | { type: "message:trace"; messageId: string; trace: NonNullable } + | { type: "message:complete"; messageId: string; message: ChatMessage; availability: CodeAgentAvailability | null; workspace?: WorkbenchState["workspace"] } + | { type: "message:fail"; messageId: string; message: ChatMessage } + | { type: "chat:done" } + | { type: "conversation:select"; conversation: ConversationRecord | null; workspace?: WorkbenchState["workspace"] } + | { type: "conversation:list"; conversations: ConversationRecord[] } + | { type: "conversation:clear" }; + +export function workbenchReducer(state: WorkbenchState, action: Action): WorkbenchState { + switch (action.type) { + case "hydrate:start": return { ...state, loading: true, error: null }; + case "hydrate:done": return { ...state, loading: false, workspace: action.workspace, conversations: action.conversations, messages: action.messages, error: null }; + case "hydrate:error": return { ...state, loading: false, error: action.error }; + case "live:set": return { ...state, live: action.live, codeAgentAvailability: action.availability, selectedDevicePodId: action.selectedDevicePodId }; + case "device:selected": return { ...state, selectedDevicePodId: action.devicePodId }; + case "profile:set": return { ...state, providerProfile: action.profile }; + case "timeout:set": return { ...state, codeAgentTimeoutMs: action.codeAgentTimeoutMs }; + case "gateway-timeout:set": return { ...state, gatewayShellTimeoutMs: action.gatewayShellTimeoutMs }; + case "message:pending": return { ...state, messages: [...state.messages, action.user, action.pending], chatPending: true, currentRequest: action.request }; + case "message:replace": return { ...state, messages: replaceMessage(state.messages, action.messageId, action.message) }; + case "message:trace-status": { + const updated = state.messages.map((message) => message.id === action.messageId ? { ...message, traceReplayStatus: action.status, updatedAt: new Date().toISOString() } : message); + return { ...state, messages: updated }; + } + case "message:trace": { + const updated = state.messages.map((message) => message.id === action.messageId ? { ...message, runnerTrace: mergeRunnerTrace(message.runnerTrace, action.trace) } : message); + return { ...state, messages: updated }; + } + case "message:complete": return { ...state, messages: replaceMessage(state.messages, action.messageId, action.message), codeAgentAvailability: action.availability ?? state.codeAgentAvailability, workspace: action.workspace ?? state.workspace }; + case "message:fail": return { ...state, messages: replaceMessage(state.messages, action.messageId, action.message) }; + case "chat:done": return { ...state, chatPending: false, currentRequest: null }; + case "conversation:select": return { ...state, workspace: action.workspace ?? state.workspace, messages: action.conversation?.messages ?? [], error: null }; + case "conversation:list": return { ...state, conversations: action.conversations }; + case "conversation:clear": return { ...state, messages: [], currentRequest: null, chatPending: false }; + default: return state; + } +} + +function replaceMessage(messages: ChatMessage[], messageId: string, replacement: ChatMessage): ChatMessage[] { + return messages.map((message) => message.id === messageId ? replacement : message); +} + +export function composerFromState(state: WorkbenchState, activeConversationId: string | null): ComposerState { + const activeTraceId = firstNonEmptyString(state.currentRequest?.traceId, state.workspace?.activeTraceId, state.workspace?.workspace?.activeTraceId); + const sessionStatus = firstNonEmptyString(state.messages.at(-1)?.status, state.workspace?.workspace?.sessionStatus); + const canSteer = Boolean(activeTraceId && state.chatPending && sessionStatus !== "completed" && sessionStatus !== "failed"); + if (canSteer) { + return { disabled: false, disabledReason: null, submitMode: "steer", route: "/v1/agent/chat/steer", targetTraceId: activeTraceId }; + } + if (!activeConversationId && !state.workspace?.workspaceId) { + return { disabled: true, disabledReason: "session_required", submitMode: "turn", route: "/v1/agent/chat", targetTraceId: null }; + } + return { disabled: false, disabledReason: null, submitMode: "turn", route: "/v1/agent/chat", targetTraceId: null }; +} + +export function availabilityFromLive(live: LiveSurface): CodeAgentAvailability | null { + const candidates = [live.healthLive.data?.codeAgent, live.healthLive.data?.availability, live.health.data?.codeAgent, live.restIndex.data?.codeAgent]; + return candidates.find((item): item is CodeAgentAvailability => Boolean(item && typeof item === "object")) ?? null; +} + +export function conversationFromTab(tab: SessionTab): ConversationRecord { + return { conversationId: tab.conversationId ?? tab.key, sessionId: tab.sessionId, threadId: tab.threadId, status: tab.status, messages: [] }; +} + +export function devicePodsFromLive(live: LiveSurface | null): DevicePodItem[] { + const payload = live?.devicePods?.data; + return payload?.devicePods ?? payload?.items ?? []; +} + +export function devicePodsFromResult(result: { data?: DevicePodListResponse | null } | null): DevicePodItem[] { + return result?.data?.devicePods ?? result?.data?.items ?? []; +} + +export function readStoredString(key: string): string | null { + try { return nonEmptyString(window.localStorage.getItem(key)); } catch { return null; } +} + +export function readStoredNumber(key: string, fallback: number, min: number, max: number): number { + const value = Number(readStoredString(key)); + if (!Number.isFinite(value)) return fallback; + return Math.min(Math.max(Math.trunc(value), min), max); +} + +export function readProviderProfile(): "codex-api" | "minimax-m3" | "deepseek" { + const value = readStoredString("hwlab.workbench.codeAgentProviderProfile.v1"); + return value === "codex-api" || value === "minimax-m3" || value === "deepseek" ? value : "deepseek"; +} diff --git a/web/hwlab-cloud-web/src/state/workbench-state.ts b/web/hwlab-cloud-web/src/state/workbench-state.ts new file mode 100644 index 00000000..ce14313d --- /dev/null +++ b/web/hwlab-cloud-web/src/state/workbench-state.ts @@ -0,0 +1,33 @@ +import type { ChatMessage, CodeAgentAvailability, ConversationRecord, LiveSurface, ProviderProfile, SessionTab, WorkspaceRecord } from "../types/domain"; + +export type ChatStatus = "source" | "sent" | "running" | "completed" | "failed" | "blocked" | "timeout" | "canceled"; + +export interface WorkbenchState { + workspace: WorkspaceRecord | null; + conversations: ConversationRecord[]; + messages: ChatMessage[]; + selectedDevicePodId: string; + providerProfile: ProviderProfile; + codeAgentTimeoutMs: number; + gatewayShellTimeoutMs: number; + live: LiveSurface | null; + codeAgentAvailability: CodeAgentAvailability | null; + loading: boolean; + chatPending: boolean; + currentRequest: { traceId: string; conversationId: string; sessionId: string | null; threadId: string | null } | null; + error: string | null; +} + +export interface ComposerState { + disabled: boolean; + disabledReason: string | null; + submitMode: "turn" | "steer"; + route: "/v1/agent/chat" | "/v1/agent/chat/steer"; + targetTraceId: string | null; +} + +export interface WorkbenchRuntimeContext { + activeConversationId: string | null; + composer: ComposerState; + selectedDevicePodId: string; +} diff --git a/web/hwlab-cloud-web/src/state/workbench.ts b/web/hwlab-cloud-web/src/state/workbench.ts index 7fd56450..268c5569 100644 --- a/web/hwlab-cloud-web/src/state/workbench.ts +++ b/web/hwlab-cloud-web/src/state/workbench.ts @@ -19,6 +19,18 @@ import type { } from "../types/domain"; import { firstNonEmptyString, nextProtocolId, nonEmptyString } from "../utils"; import { WORKBENCH_PROJECT_ID } from "./constants"; +import { + availabilityFromLive, + composerFromState, + conversationFromTab, + devicePodsFromResult, + readProviderProfile, + readStoredNumber, + readStoredString, + workbenchReducer, + type Action +} from "./workbench-reducer"; +import type { ComposerState, WorkbenchState } from "./workbench-state"; import { conversationsToTabs, ensureWorkspace, makeMessage, messageFromAgentResponse, messagesFromWorkspace, persistConversation } from "./conversation"; import { mergeRunnerTrace, @@ -30,53 +42,16 @@ import { type TraceSnapshot, waitForAgentResult } from "./runner-trace"; +import { + cancelAgentMessageAction, + replayAgentTraceAction, + retryAgentMessageAction +} from "./runner-actions"; const DEFAULT_DEVICE_POD_ID = "device-pod-71-freq"; const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000; const DEFAULT_GATEWAY_TIMEOUT_MS = 120_000; -interface WorkbenchState { - workspace: WorkspaceRecord | null; - conversations: ConversationRecord[]; - messages: ChatMessage[]; - selectedDevicePodId: string; - providerProfile: ProviderProfile; - codeAgentTimeoutMs: number; - gatewayShellTimeoutMs: number; - live: LiveSurface | null; - codeAgentAvailability: CodeAgentAvailability | null; - loading: boolean; - chatPending: boolean; - currentRequest: { traceId: string; conversationId: string; sessionId: string | null; threadId: string | null } | null; - error: string | null; -} - -interface ComposerState { - disabled: boolean; - disabledReason: string | null; - submitMode: "turn" | "steer"; - route: "/v1/agent/chat" | "/v1/agent/chat/steer"; - targetTraceId: string | null; -} - -type Action = - | { type: "hydrate:start" } - | { type: "hydrate:done"; workspace: WorkspaceRecord | null; conversations: ConversationRecord[]; messages: ChatMessage[] } - | { type: "hydrate:error"; error: string } - | { type: "live:set"; live: LiveSurface; availability: CodeAgentAvailability | null; selectedDevicePodId: string } - | { type: "device:selected"; devicePodId: string } - | { type: "profile:set"; profile: ProviderProfile } - | { type: "timeout:set"; codeAgentTimeoutMs: number } - | { type: "gateway-timeout:set"; gatewayShellTimeoutMs: number } - | { type: "message:pending"; user: ChatMessage; pending: ChatMessage; request: WorkbenchState["currentRequest"] } - | { type: "message:trace"; messageId: string; trace: NonNullable } - | { type: "message:complete"; messageId: string; message: ChatMessage; availability: CodeAgentAvailability | null; workspace?: WorkspaceRecord | null } - | { type: "message:fail"; messageId: string; message: ChatMessage } - | { type: "chat:done" } - | { type: "conversation:select"; conversation: ConversationRecord | null; workspace?: WorkspaceRecord | null } - | { type: "conversation:list"; conversations: ConversationRecord[] } - | { type: "conversation:clear" }; - const initialState: WorkbenchState = { workspace: null, conversations: [], @@ -104,6 +79,9 @@ export interface WorkbenchStore { selectConversation(tab: SessionTab): Promise; deleteCurrentSession(): Promise; submitMessage(text: string): Promise; + cancelAgentMessage(messageId: string): Promise; + retryAgentMessage(messageId: string): Promise; + replayAgentTrace(messageId: string): Promise; clearConversation(): void; setProviderProfile(profile: ProviderProfile): void; setCodeAgentTimeoutMs(value: number): void; @@ -112,7 +90,7 @@ export interface WorkbenchStore { } export function useWorkbenchStore(enabled: boolean): WorkbenchStore { - const [state, dispatch] = useReducer(reducer, initialState); + const [state, dispatch] = useReducer(workbenchReducer, initialState); const activeConversationId = firstNonEmptyString( state.workspace?.selectedConversationId, @@ -223,7 +201,8 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore { const threadId = state.workspace?.workspace?.threadId ?? null; const user = makeMessage("user", value, "sent", { traceId, conversationId, sessionId, threadId }); const pending = makeMessage("agent", "正在处理这次 Code Agent 请求;复杂问题可能需要几分钟。", "running", { traceId, conversationId, sessionId, threadId, title: "Code Agent 处理中" }); - dispatch({ type: "message:pending", user, pending, request: { traceId, conversationId, sessionId, threadId } }); + const pendingWithRetry: ChatMessage = { ...pending, retryInput: value }; + dispatch({ type: "message:pending", user, pending: pendingWithRetry, request: { traceId, conversationId, sessionId, threadId } }); const route = composer.submitMode === "steer" ? api.steerAgentMessage : api.sendAgentMessage; const response = await route({ projectId: WORKBENCH_PROJECT_ID, @@ -269,6 +248,45 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore { dispatch({ type: "chat:done" }); }, [activeConversationId, composer.submitMode, composer.targetTraceId, state.codeAgentTimeoutMs, state.gatewayShellTimeoutMs, state.messages, state.providerProfile, state.workspace]); + const cancelAgentMessage = useCallback(async (messageId: string) => { + return cancelAgentMessageAction(messageId, { + activeConversationId, + workspace: state.workspace, + messages: state.messages, + codeAgentTimeoutMs: state.codeAgentTimeoutMs, + onReplaceMessage: (id, message) => dispatch({ type: "message:replace", messageId: id, message }), + onTraceStatus: (id, status) => dispatch({ type: "message:trace-status", messageId: id, status }), + onTrace: (id, trace) => dispatch({ type: "message:trace", messageId: id, trace }), + submitMessage + }); + }, [activeConversationId, state.workspace, state.messages, state.codeAgentTimeoutMs, submitMessage]); + + const retryAgentMessage = useCallback(async (messageId: string) => { + return retryAgentMessageAction(messageId, { + activeConversationId, + workspace: state.workspace, + messages: state.messages, + codeAgentTimeoutMs: state.codeAgentTimeoutMs, + onReplaceMessage: (id, message) => dispatch({ type: "message:replace", messageId: id, message }), + onTraceStatus: (id, status) => dispatch({ type: "message:trace-status", messageId: id, status }), + onTrace: (id, trace) => dispatch({ type: "message:trace", messageId: id, trace }), + submitMessage + }); + }, [activeConversationId, state.workspace, state.messages, state.codeAgentTimeoutMs, submitMessage]); + + const replayAgentTrace = useCallback(async (messageId: string) => { + return replayAgentTraceAction(messageId, { + activeConversationId, + workspace: state.workspace, + messages: state.messages, + codeAgentTimeoutMs: state.codeAgentTimeoutMs, + onReplaceMessage: (id, message) => dispatch({ type: "message:replace", messageId: id, message }), + onTraceStatus: (id, status) => dispatch({ type: "message:trace-status", messageId: id, status }), + onTrace: (id, trace) => dispatch({ type: "message:trace", messageId: id, trace }), + submitMessage + }); + }, [activeConversationId, state.workspace, state.messages, state.codeAgentTimeoutMs, submitMessage]); + const clearConversation = useCallback(() => dispatch({ type: "conversation:clear" }), []); const setProviderProfile = useCallback((profile: ProviderProfile) => { window.localStorage.setItem("hwlab.workbench.codeAgentProviderProfile.v1", profile); @@ -288,81 +306,9 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore { void refreshLive(devicePodId); }, [refreshLive]); - return { state, sessionTabs, activeConversationId, composer, hydrate, refreshLive, createSession, selectConversation, deleteCurrentSession, submitMessage, clearConversation, setProviderProfile, setCodeAgentTimeoutMs, setGatewayShellTimeoutMs, selectDevicePod }; + return { state, sessionTabs, activeConversationId, composer, hydrate, refreshLive, createSession, selectConversation, deleteCurrentSession, submitMessage, cancelAgentMessage, retryAgentMessage, replayAgentTrace, clearConversation, setProviderProfile, setCodeAgentTimeoutMs, setGatewayShellTimeoutMs, selectDevicePod }; } -function reducer(state: WorkbenchState, action: Action): WorkbenchState { - switch (action.type) { - case "hydrate:start": return { ...state, loading: true, error: null }; - case "hydrate:done": return { ...state, loading: false, workspace: action.workspace, conversations: action.conversations, messages: action.messages, error: null }; - case "hydrate:error": return { ...state, loading: false, error: action.error }; - case "live:set": return { ...state, live: action.live, codeAgentAvailability: action.availability, selectedDevicePodId: action.selectedDevicePodId }; - case "device:selected": return { ...state, selectedDevicePodId: action.devicePodId }; - case "profile:set": return { ...state, providerProfile: action.profile }; - case "timeout:set": return { ...state, codeAgentTimeoutMs: action.codeAgentTimeoutMs }; - case "gateway-timeout:set": return { ...state, gatewayShellTimeoutMs: action.gatewayShellTimeoutMs }; - case "message:pending": return { ...state, messages: [...state.messages, action.user, action.pending], chatPending: true, currentRequest: action.request }; - case "message:trace": { - const updated = state.messages.map((message) => message.id === action.messageId ? { ...message, runnerTrace: mergeRunnerTrace(message.runnerTrace, action.trace) } : message); - return { ...state, messages: updated }; - } - case "message:complete": return { ...state, messages: replaceMessage(state.messages, action.messageId, action.message), codeAgentAvailability: action.availability ?? state.codeAgentAvailability, workspace: action.workspace ?? state.workspace }; - case "message:fail": return { ...state, messages: replaceMessage(state.messages, action.messageId, action.message) }; - case "chat:done": return { ...state, chatPending: false, currentRequest: null }; - case "conversation:select": return { ...state, workspace: action.workspace ?? state.workspace, messages: action.conversation?.messages ?? [], error: null }; - case "conversation:list": return { ...state, conversations: action.conversations }; - case "conversation:clear": return { ...state, messages: [], currentRequest: null, chatPending: false }; - } -} - -function replaceMessage(messages: ChatMessage[], messageId: string, replacement: ChatMessage): ChatMessage[] { - return messages.map((message) => message.id === messageId ? replacement : message); -} - -function composerFromState(state: WorkbenchState, activeConversationId: string | null): ComposerState { - const activeTraceId = firstNonEmptyString(state.currentRequest?.traceId, state.workspace?.activeTraceId, state.workspace?.workspace?.activeTraceId); - const sessionStatus = firstNonEmptyString(state.messages.at(-1)?.status, state.workspace?.workspace?.sessionStatus); - const canSteer = Boolean(activeTraceId && state.chatPending && sessionStatus !== "completed" && sessionStatus !== "failed"); - if (canSteer) { - return { disabled: false, disabledReason: null, submitMode: "steer", route: "/v1/agent/chat/steer", targetTraceId: activeTraceId }; - } - if (!activeConversationId && !state.workspace?.workspaceId) { - return { disabled: true, disabledReason: "session_required", submitMode: "turn", route: "/v1/agent/chat", targetTraceId: null }; - } - return { disabled: false, disabledReason: null, submitMode: "turn", route: "/v1/agent/chat", targetTraceId: null }; -} - -function availabilityFromLive(live: LiveSurface): CodeAgentAvailability | null { - const candidates = [live.healthLive.data?.codeAgent, live.healthLive.data?.availability, live.health.data?.codeAgent, live.restIndex.data?.codeAgent]; - return candidates.find((item): item is CodeAgentAvailability => Boolean(item && typeof item === "object")) ?? null; -} - -function conversationFromTab(tab: SessionTab): ConversationRecord { - return { conversationId: tab.conversationId ?? tab.key, sessionId: tab.sessionId, threadId: tab.threadId, status: tab.status, messages: [] }; -} -function readStoredString(key: string): string | null { - try { return nonEmptyString(window.localStorage.getItem(key)); } catch { return null; } -} - -function readStoredNumber(key: string, fallback: number, min: number, max: number): number { - const value = Number(readStoredString(key)); - if (!Number.isFinite(value)) return fallback; - return Math.min(Math.max(Math.trunc(value), min), max); -} - -function readProviderProfile(): ProviderProfile { - const value = readStoredString("hwlab.workbench.codeAgentProviderProfile.v1"); - return value === "codex-api" || value === "minimax-m3" || value === "deepseek" ? value : "deepseek"; -} - -export function devicePodsFromLive(live: LiveSurface | null): DevicePodItem[] { - const payload = live?.devicePods.data; - return payload?.devicePods ?? payload?.items ?? []; -} - -function devicePodsFromResult(result: ApiResult): DevicePodItem[] { - return result.data?.devicePods ?? result.data?.items ?? []; -} function selectVisibleDevicePodId(payload: DevicePodListResponse | null, pods: DevicePodItem[], requestedDevicePodId: string): string { const payloadSelected = nonEmptyString(payload?.selectedDevicePodId); @@ -371,3 +317,4 @@ function selectVisibleDevicePodId(payload: DevicePodListResponse | null, pods: D if (requested && requestedVisible) return requested; return payloadSelected ?? pods[0]?.devicePodId ?? ""; } +export { devicePodsFromLive } from "./workbench-reducer"; diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index 811bdace..9b395e11 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -104,6 +104,13 @@ button:disabled { cursor: not-allowed; opacity: 0.55; } .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; } +.message-actions { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding: 6px 8px; border-top: 1px dashed var(--line); flex-wrap: wrap; } +.message-action { border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: var(--ink); padding: 4px 10px; font-size: 12px; font-weight: 700; cursor: pointer; } +.message-action:hover, .message-action:focus-visible { border-color: var(--accent); outline: 0; } +.message-action-cancel { color: var(--bad); border-color: var(--bad); } +.message-action-retry { color: var(--accent); border-color: var(--accent); } +.message-action-trace { color: var(--source); border-color: var(--source); } +.message-action-status { 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/types/domain.ts b/web/hwlab-cloud-web/src/types/domain.ts index 22531a4b..1027404c 100644 --- a/web/hwlab-cloud-web/src/types/domain.ts +++ b/web/hwlab-cloud-web/src/types/domain.ts @@ -155,6 +155,8 @@ export interface ChatMessage { sessionId?: string | null; threadId?: string | null; retryOf?: string | null; + retryInput?: string | null; + traceReplayStatus?: string | null; runnerTrace?: RunnerTrace | null; traceEvents?: TraceEvent[]; error?: {