fix(web): v0.2 round-6 workbench: message actions panel (cancel/retry/replay) (HWLAB #775 round 3) (#784)
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/<id>` | `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` — `<ConversationPanel>` 接 `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 <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} live={store.state.live} 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 ?? "")} onCancelMessage={(id) => void store.cancelAgentMessage(id)} onRetryMessage={(id) => void store.retryAgentMessage(id)} onReplayTrace={(id) => void store.replayAgentTrace(id)} /> : null}
|
||||
{route === "skills" ? <SkillsView /> : null}
|
||||
{route === "gate" ? <GateView /> : null}
|
||||
{route === "help" ? <HelpView help={help} /> : null}
|
||||
|
||||
@@ -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<ChatMessage[]>(() => [
|
||||
{
|
||||
id: "intro-mode",
|
||||
@@ -52,7 +56,7 @@ export function ConversationPanel({ messages, availability, live, chatPending, o
|
||||
</div>
|
||||
<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} />)}
|
||||
{[...intro, ...messages].map((message) => <MessageCard key={message.id} message={message} onCancel={() => onCancelMessage(message.id)} onRetry={() => onRetryMessage(message.id)} onReplayTrace={() => onReplayTrace(message.id)} />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 {
|
||||
<span>{formatBeijingTime(message.updatedAt ?? message.createdAt, { withSeconds: true })}</span>
|
||||
</footer>
|
||||
{message.runnerTrace ? <MessageTracePanel trace={message.runnerTrace} storageKey={traceKey} /> : null}
|
||||
<MessageActions message={message} onCancel={onCancel} onRetry={onRetry} onReplayTrace={onReplayTrace} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className={`message-actions tone-border-${toneClass(message.status)}`} role="toolbar" aria-label="消息操作">
|
||||
{buttons}
|
||||
{message.traceReplayStatus ? <span className="message-action-status">{message.traceReplayStatus}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function actionButton(label: string, action: string, onClick: () => void, title: string): ReactElement {
|
||||
return <button type="button" className={`message-action message-action-${action}`} title={title} onClick={onClick}>{label}</button>;
|
||||
}
|
||||
|
||||
function canRetryTerminal(message: ChatMessage): boolean {
|
||||
return ["failed", "timeout", "error", "canceled", "blocked"].includes(String(message.status ?? "").toLowerCase());
|
||||
}
|
||||
@@ -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<ChatMessage["runnerTrace"]>): void;
|
||||
submitMessage(text: string): Promise<void>;
|
||||
}
|
||||
|
||||
export async function cancelAgentMessageAction(
|
||||
messageId: string,
|
||||
options: RunnerActionsOptions
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 ?? ""}`);
|
||||
}
|
||||
@@ -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<ChatMessage["runnerTrace"]> }
|
||||
| { 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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ChatMessage["runnerTrace"]> }
|
||||
| { 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<void>;
|
||||
deleteCurrentSession(): Promise<void>;
|
||||
submitMessage(text: string): Promise<void>;
|
||||
cancelAgentMessage(messageId: string): Promise<void>;
|
||||
retryAgentMessage(messageId: string): Promise<void>;
|
||||
replayAgentTrace(messageId: string): Promise<void>;
|
||||
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<DevicePodListResponse>): 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";
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
Reference in New Issue
Block a user