Merge pull request #1158 from pikasTech/fix/v03-r1-workbench-parity-1151
R1: 补齐 v0.3 Vue Workbench parity foundation
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { AgentRunProvenance, ChatMessage, TraceEvent } from "../src/types/index.ts";
|
||||
import { canCancelMessage, canReplayTrace, canRetryMessage, messageTraceId, renderSafeMarkdown, traceEventBody, traceEventLabel, traceIdentityText, visibleTraceEvents } from "../src/components/workbench/message-rendering.ts";
|
||||
|
||||
test("R1 markdown rendering keeps structure and strips unsafe HTML", () => {
|
||||
const html = renderSafeMarkdown("# 标题\n\n- 项目\n\n<script>alert('x')</script>\n\n[link](https://example.com)");
|
||||
assert.match(html, /<h1>标题<\/h1>/u);
|
||||
assert.match(html, /<li>项目<\/li>/u);
|
||||
assert.match(html, /href="https:\/\/example.com"/u);
|
||||
assert.doesNotMatch(html, /<script|alert/u);
|
||||
});
|
||||
|
||||
test("R1 message actions follow per-message status and trace rules", () => {
|
||||
const running = agentMessage({ status: "running", traceId: "trc_running", retryInput: "retry me" });
|
||||
assert.equal(messageTraceId(running), "trc_running");
|
||||
assert.equal(canCancelMessage(running), true);
|
||||
assert.equal(canRetryMessage(running), true);
|
||||
assert.equal(canReplayTrace(running), true);
|
||||
|
||||
const completed = agentMessage({ status: "completed", traceId: "trc_done", retryInput: "retry me" });
|
||||
assert.equal(canCancelMessage(completed), false);
|
||||
assert.equal(canRetryMessage(completed), false);
|
||||
assert.equal(canReplayTrace(completed), true);
|
||||
|
||||
const failed = agentMessage({ status: "failed", traceId: "trc_failed", retryInput: "retry me" });
|
||||
assert.equal(canCancelMessage(failed), false);
|
||||
assert.equal(canRetryMessage(failed), true);
|
||||
assert.equal(canReplayTrace(failed), true);
|
||||
|
||||
const user: ChatMessage = { id: "msg_user", role: "user", title: "用户", text: "hi", status: "sent", createdAt: "2026-01-01T00:00:00.000Z", traceId: "trc_user" };
|
||||
assert.equal(canReplayTrace(user), false);
|
||||
});
|
||||
|
||||
test("R1 trace rendering hides chunk noise without dropping meaningful events", () => {
|
||||
const events: TraceEvent[] = [
|
||||
{ label: "assistant:delta", message: "token" },
|
||||
{ label: "command:completed", command: "npm test", exitCode: 0, elapsedMs: 42 },
|
||||
{ type: "assistant_message", text: "final", final: true }
|
||||
];
|
||||
const compact = visibleTraceEvents(events, true);
|
||||
assert.equal(compact.length, 2);
|
||||
assert.equal(traceEventLabel(compact[0]!), "command:completed");
|
||||
assert.equal(traceEventBody(compact[0]!), "npm test");
|
||||
assert.equal(traceEventLabel(compact[1]!), "assistant_message");
|
||||
assert.equal(traceEventBody(compact[1]!), "final");
|
||||
assert.equal(visibleTraceEvents(events, false).length, 3);
|
||||
});
|
||||
|
||||
test("R1 status summary keeps the restored 23-row floor", () => {
|
||||
const message = agentMessage({
|
||||
status: "failed",
|
||||
traceId: "trc_summary",
|
||||
conversationId: "cnv_summary",
|
||||
sessionId: "ses_summary",
|
||||
threadId: "thr_summary",
|
||||
runnerTrace: {
|
||||
traceId: "trc_summary",
|
||||
status: "failed",
|
||||
eventCount: 2,
|
||||
eventsCompacted: false,
|
||||
fullTraceLoaded: true,
|
||||
lastEventLabel: "assistant:completed",
|
||||
waitingFor: "result",
|
||||
runnerKind: "agentrun",
|
||||
sessionMode: "long-lived",
|
||||
agentRun: {
|
||||
runId: "run_1",
|
||||
commandId: "cmd_1",
|
||||
attemptId: "att_1",
|
||||
runnerId: "runner_1",
|
||||
jobName: "job_1",
|
||||
namespace: "hwlab-v03",
|
||||
terminalStatus: "failed"
|
||||
}
|
||||
},
|
||||
error: { code: "provider_timeout", category: "provider", providerStatus: 504 }
|
||||
});
|
||||
const values = [
|
||||
message.status,
|
||||
message.runnerTrace?.status,
|
||||
message.traceId,
|
||||
message.conversationId,
|
||||
message.sessionId,
|
||||
message.threadId,
|
||||
message.runnerTrace?.runnerKind,
|
||||
message.runnerTrace?.sessionMode,
|
||||
message.runnerTrace?.waitingFor,
|
||||
message.runnerTrace?.lastEventLabel,
|
||||
String(message.runnerTrace?.eventCount),
|
||||
String(message.runnerTrace?.eventsCompacted),
|
||||
message.runnerTrace?.fullTraceLoaded === true ? "loaded" : "unverified",
|
||||
asAgentRun(message.runnerTrace?.agentRun)?.runId,
|
||||
asAgentRun(message.runnerTrace?.agentRun)?.commandId,
|
||||
asAgentRun(message.runnerTrace?.agentRun)?.attemptId,
|
||||
asAgentRun(message.runnerTrace?.agentRun)?.runnerId,
|
||||
asAgentRun(message.runnerTrace?.agentRun)?.jobName,
|
||||
asAgentRun(message.runnerTrace?.agentRun)?.namespace,
|
||||
asAgentRun(message.runnerTrace?.agentRun)?.terminalStatus,
|
||||
message.error?.code,
|
||||
message.error?.category,
|
||||
String(message.error?.providerStatus)
|
||||
];
|
||||
assert.equal(values.length, 23);
|
||||
assert.equal(values.every((value) => value != null && value !== ""), true);
|
||||
assert.match(traceIdentityText(message), /traceId=trc_summary/u);
|
||||
assert.match(traceIdentityText(message), /sessionId=ses_summary/u);
|
||||
});
|
||||
|
||||
function agentMessage(patch: Partial<ChatMessage>): ChatMessage {
|
||||
return {
|
||||
id: "msg_agent",
|
||||
role: "agent",
|
||||
title: "Code Agent",
|
||||
text: "done",
|
||||
status: "completed",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
...patch
|
||||
} as ChatMessage;
|
||||
}
|
||||
|
||||
function asAgentRun(value: unknown): AgentRunProvenance | null {
|
||||
return value && typeof value === "object" ? value as AgentRunProvenance : null;
|
||||
}
|
||||
@@ -1,21 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import type { RunnerTrace } from "@/types";
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import type { RunnerTrace, TraceEvent } from "@/types";
|
||||
import { firstNonEmptyString } from "@/utils";
|
||||
import { traceEventBody, traceEventLabel, traceEventMeta, visibleTraceEvents } from "@/components/workbench/message-rendering";
|
||||
|
||||
const props = defineProps<{ trace?: RunnerTrace | null }>();
|
||||
|
||||
const listRef = ref<HTMLOListElement | null>(null);
|
||||
const following = ref(true);
|
||||
const expanded = ref(true);
|
||||
const compact = ref(true);
|
||||
|
||||
const events = computed(() => Array.isArray(props.trace?.events) ? props.trace.events : []);
|
||||
const eventCount = computed(() => props.trace?.eventCount ?? events.value.length);
|
||||
const visibleEvents = computed(() => visibleTraceEvents(events.value, compact.value));
|
||||
const noiseCount = computed(() => Math.max(0, events.value.length - visibleEvents.value.length));
|
||||
const lastEvent = computed(() => events.value.at(-1) ?? null);
|
||||
const lastEventLabel = computed(() => firstNonEmptyString(props.trace?.lastEventLabel, lastEvent.value?.label, lastEvent.value?.type, lastEvent.value?.kind) ?? "未观测");
|
||||
|
||||
watch(() => [eventCount.value, props.trace?.status, following.value, expanded.value], async () => {
|
||||
if (!following.value || !expanded.value) return;
|
||||
await nextTick();
|
||||
jumpToBottom();
|
||||
});
|
||||
|
||||
function jumpToBottom(): void {
|
||||
const list = listRef.value;
|
||||
if (!list) return;
|
||||
list.scrollTop = list.scrollHeight;
|
||||
}
|
||||
|
||||
function onScroll(): void {
|
||||
const list = listRef.value;
|
||||
if (!list) return;
|
||||
following.value = list.scrollTop + list.clientHeight >= list.scrollHeight - 24;
|
||||
}
|
||||
|
||||
function eventKey(event: TraceEvent, index: number): string {
|
||||
return `${event.ts ?? "no-ts"}-${event.label ?? event.type ?? event.kind ?? "event"}-${index}`;
|
||||
}
|
||||
|
||||
defineProps<{ trace?: RunnerTrace | null }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="trace" class="trace-timeline" :data-status="trace.status">
|
||||
<header>
|
||||
<section v-if="trace" class="trace-timeline" :data-status="trace.status" :data-following="following ? 'true' : 'false'">
|
||||
<header class="trace-toolbar">
|
||||
<button class="trace-toggle" type="button" @click="expanded = !expanded">{{ expanded ? "收起" : "展开" }}</button>
|
||||
<strong>Trace</strong>
|
||||
<span>{{ trace.traceId }}</span>
|
||||
<small>{{ trace.eventCount ?? trace.events?.length ?? 0 }} events</small>
|
||||
<span class="trace-id">{{ trace.traceId || "pending" }}</span>
|
||||
<small>{{ visibleEvents.length }}/{{ eventCount }} events</small>
|
||||
<small>last={{ lastEventLabel }}</small>
|
||||
<button class="btn btn-secondary btn-sm" type="button" @click="following = !following">{{ following ? "跟随" : "暂停" }}</button>
|
||||
<button class="btn btn-secondary btn-sm" type="button" @click="jumpToBottom">到底部</button>
|
||||
<button class="btn btn-secondary btn-sm" type="button" @click="compact = !compact">{{ compact ? "显示全部" : "低噪声" }}</button>
|
||||
</header>
|
||||
<ol>
|
||||
<li v-for="(event, index) in trace.events || []" :key="`${event.ts || index}-${event.label || event.type || event.kind}`">
|
||||
<code>{{ event.label || event.type || event.kind || "event" }}</code>
|
||||
<span>{{ event.message || event.text || event.outputSummary || event.status || "已记录" }}</span>
|
||||
<p v-if="trace.eventsCompacted" class="trace-warning">当前 result 返回被压缩,已通过 trace replay 入口请求完整事件;完成前不要把压缩窗口当完整 trace。</p>
|
||||
<p v-if="noiseCount > 0 && compact" class="trace-warning">已聚合隐藏 {{ noiseCount }} 条 chunk/delta/token 噪声事件。</p>
|
||||
<ol v-show="expanded" ref="listRef" @scroll="onScroll">
|
||||
<li v-for="(event, index) in visibleEvents" :key="eventKey(event, index)" :data-event-status="event.status || 'source'">
|
||||
<code>{{ traceEventLabel(event) }}</code>
|
||||
<span>{{ traceEventBody(event) }}</span>
|
||||
<small v-if="traceEventMeta(event)">{{ traceEventMeta(event) }}</small>
|
||||
</li>
|
||||
</ol>
|
||||
<p v-if="expanded && visibleEvents.length === 0" class="trace-empty">trace={{ trace.traceId || "pending" }};等待后端事件。</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import StatusBadge from "@/components/common/StatusBadge.vue";
|
||||
import type { AgentRunProvenance, ChatMessage } from "@/types";
|
||||
import { firstNonEmptyString } from "@/utils";
|
||||
|
||||
const props = defineProps<{ message: ChatMessage }>();
|
||||
|
||||
const trace = computed(() => props.message.runnerTrace ?? null);
|
||||
const agentRun = computed(() => asAgentRun(trace.value?.agentRun));
|
||||
const error = computed(() => props.message.error ?? null);
|
||||
const eventCount = computed(() => trace.value?.eventCount ?? trace.value?.events?.length ?? 0);
|
||||
|
||||
const rows = computed(() => [
|
||||
{ key: "messageStatus", label: "消息状态", value: props.message.status },
|
||||
{ key: "traceStatus", label: "Trace 状态", value: firstNonEmptyString(trace.value?.status, trace.value?.traceStatus) },
|
||||
{ key: "traceId", label: "Trace", value: props.message.traceId ?? trace.value?.traceId },
|
||||
{ key: "conversationId", label: "Conversation", value: props.message.conversationId },
|
||||
{ key: "sessionId", label: "Session", value: props.message.sessionId ?? trace.value?.sessionId },
|
||||
{ key: "threadId", label: "Thread", value: props.message.threadId ?? trace.value?.threadId },
|
||||
{ key: "runnerKind", label: "Runner", value: trace.value?.runnerKind ?? agentRun.value?.adapter },
|
||||
{ key: "sessionMode", label: "Session Mode", value: trace.value?.sessionMode ?? agentRun.value?.backendProfile },
|
||||
{ key: "waitingFor", label: "Waiting For", value: trace.value?.waitingFor },
|
||||
{ key: "lastEvent", label: "Last Event", value: trace.value?.lastEventLabel },
|
||||
{ key: "eventCount", label: "Events", value: String(eventCount.value) },
|
||||
{ key: "eventsCompacted", label: "Compacted", value: trace.value?.eventsCompacted === true ? "true" : "false" },
|
||||
{ key: "fullTraceLoaded", label: "Full Trace", value: trace.value?.fullTraceLoaded === true ? "loaded" : "unverified" },
|
||||
{ key: "runId", label: "AgentRun", value: agentRun.value?.runId },
|
||||
{ key: "commandId", label: "Command", value: agentRun.value?.commandId },
|
||||
{ key: "attemptId", label: "Attempt", value: agentRun.value?.attemptId },
|
||||
{ key: "runnerId", label: "Runner ID", value: agentRun.value?.runnerId },
|
||||
{ key: "jobName", label: "Job", value: agentRun.value?.jobName },
|
||||
{ key: "namespace", label: "Namespace", value: agentRun.value?.namespace },
|
||||
{ key: "terminalStatus", label: "Terminal", value: agentRun.value?.terminalStatus },
|
||||
{ key: "errorCode", label: "Error Code", value: error.value?.code },
|
||||
{ key: "errorCategory", label: "Error Category", value: error.value?.category },
|
||||
{ key: "providerStatus", label: "Provider Status", value: error.value?.providerStatus == null ? null : String(error.value.providerStatus) }
|
||||
].map((row) => ({ ...row, value: row.value == null || row.value === "" ? "未观测" : String(row.value) })));
|
||||
|
||||
function asAgentRun(value: unknown): AgentRunProvenance | null {
|
||||
return value && typeof value === "object" ? value as AgentRunProvenance : null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<details v-if="message.role === 'agent'" class="code-agent-status-summary" :open="message.status !== 'completed'">
|
||||
<summary>
|
||||
<span>Code Agent 状态摘要</span>
|
||||
<StatusBadge :status="message.status" />
|
||||
</summary>
|
||||
<dl>
|
||||
<div v-for="row in rows" :key="row.key" class="code-agent-status-row" :data-status-row="row.key">
|
||||
<dt>{{ row.label }}</dt>
|
||||
<dd>{{ row.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</details>
|
||||
</template>
|
||||
@@ -1,9 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChatMessage } from "@/types";
|
||||
import StatusBadge from "@/components/common/StatusBadge.vue";
|
||||
import TraceTimeline from "@/components/agent/TraceTimeline.vue";
|
||||
import CodeAgentStatusSummary from "@/components/workbench/CodeAgentStatusSummary.vue";
|
||||
import MessageActions from "@/components/workbench/MessageActions.vue";
|
||||
import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue";
|
||||
import { useClipboard } from "@/composables/useClipboard";
|
||||
import { useWorkbenchStore } from "@/stores/workbench";
|
||||
import { traceIdentityText } from "./message-rendering";
|
||||
|
||||
const workbench = useWorkbenchStore();
|
||||
const clipboard = useClipboard();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -13,12 +20,20 @@ const workbench = useWorkbenchStore();
|
||||
<strong>{{ message.title }}</strong>
|
||||
<StatusBadge :status="message.status" />
|
||||
</header>
|
||||
<p class="message-text">{{ message.text }}</p>
|
||||
<MessageMarkdown class="message-text" :source="message.text" />
|
||||
<TraceTimeline :trace="message.runnerTrace" />
|
||||
<CodeAgentStatusSummary :message="message" />
|
||||
<MessageActions
|
||||
:message="message"
|
||||
@cancel="workbench.cancelAgentMessage"
|
||||
@retry="workbench.retryAgentMessage"
|
||||
@replay-trace="workbench.replayAgentTrace"
|
||||
@copy-trace="(target: ChatMessage) => clipboard.copy(traceIdentityText(target))"
|
||||
/>
|
||||
</article>
|
||||
<article v-if="workbench.messages.length === 0" class="message-card empty-message">
|
||||
<header><strong>等待会话</strong><StatusBadge status="source" label="SOURCE" /></header>
|
||||
<p class="message-text">请先显式新建或选择 session。Code Agent 不会自动滚动新 session 掩盖失败。</p>
|
||||
<MessageMarkdown class="message-text" source="请先显式新建或选择 session。Code Agent 不会自动滚动新 session 掩盖失败。" />
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { ChatMessage } from "@/types";
|
||||
import { canCancelMessage, canReplayTrace, canRetryMessage, messageTraceId } from "./message-rendering";
|
||||
|
||||
const props = defineProps<{ message: ChatMessage }>();
|
||||
const emit = defineEmits<{
|
||||
cancel: [message: ChatMessage];
|
||||
retry: [message: ChatMessage];
|
||||
replayTrace: [message: ChatMessage];
|
||||
copyTrace: [message: ChatMessage];
|
||||
}>();
|
||||
|
||||
const traceId = computed(() => messageTraceId(props.message));
|
||||
const traceHref = computed(() => traceId.value ? `/v1/agent/chat/trace/${encodeURIComponent(traceId.value)}` : "#");
|
||||
const canCancel = computed(() => canCancelMessage(props.message));
|
||||
const canRetry = computed(() => canRetryMessage(props.message));
|
||||
const canReplay = computed(() => canReplayTrace(props.message));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="message.role === 'agent'" class="message-actions" role="toolbar" aria-label="消息操作">
|
||||
<button v-if="canCancel" class="btn btn-secondary btn-sm" type="button" @click="emit('cancel', message)">取消</button>
|
||||
<button v-if="canRetry" class="btn btn-secondary btn-sm" type="button" @click="emit('retry', message)">重试</button>
|
||||
<button v-if="canReplay" class="btn btn-secondary btn-sm" type="button" @click="emit('replayTrace', message)">回放 Trace</button>
|
||||
<button v-if="canReplay" class="btn btn-secondary btn-sm" type="button" @click="emit('copyTrace', message)">复制 Trace</button>
|
||||
<a v-if="canReplay" class="btn btn-secondary btn-sm" :href="traceHref" target="_blank" rel="noreferrer">打开 Trace</a>
|
||||
<span v-if="message.traceReplayStatus" class="message-action-status">{{ message.traceReplayStatus }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { renderSafeMarkdown } from "./message-rendering";
|
||||
|
||||
const props = defineProps<{ source?: string | null }>();
|
||||
const html = computed(() => renderSafeMarkdown(props.source));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="html" class="message-markdown" v-html="html" />
|
||||
</template>
|
||||
@@ -0,0 +1,100 @@
|
||||
import createDOMPurify from "dompurify";
|
||||
import { marked } from "marked";
|
||||
import type { ChatMessage, TraceEvent } from "@/types";
|
||||
import { firstNonEmptyString } from "@/utils";
|
||||
|
||||
marked.setOptions({ async: false, breaks: true, gfm: true });
|
||||
|
||||
export function renderSafeMarkdown(source: string | null | undefined): string {
|
||||
const text = source?.trim();
|
||||
if (!text) return "";
|
||||
const rendered = marked.parse(text) as string;
|
||||
return sanitizeHtml(rendered, {
|
||||
ADD_ATTR: ["target", "rel"],
|
||||
USE_PROFILES: { html: true }
|
||||
});
|
||||
}
|
||||
|
||||
interface HtmlSanitizeConfig {
|
||||
ADD_ATTR?: string[];
|
||||
USE_PROFILES?: { html?: boolean };
|
||||
}
|
||||
|
||||
interface HtmlSanitizer {
|
||||
sanitize(dirty: string, config?: HtmlSanitizeConfig): string;
|
||||
}
|
||||
|
||||
type DomPurifyFactory = (targetWindow: Window) => HtmlSanitizer;
|
||||
type DomPurifyExport = HtmlSanitizer | DomPurifyFactory;
|
||||
|
||||
function sanitizeHtml(html: string, config: HtmlSanitizeConfig): string {
|
||||
const purifierExport = createDOMPurify as unknown as DomPurifyExport;
|
||||
if (hasSanitize(purifierExport)) return purifierExport.sanitize(html, config);
|
||||
if (typeof window !== "undefined" && typeof purifierExport === "function") {
|
||||
const purifier = purifierExport(window);
|
||||
if (hasSanitize(purifier)) return purifier.sanitize(html, config);
|
||||
}
|
||||
return conservativeSanitize(html);
|
||||
}
|
||||
|
||||
function hasSanitize(value: unknown): value is HtmlSanitizer {
|
||||
if (typeof value !== "function" && (value === null || typeof value !== "object")) return false;
|
||||
return typeof (value as { sanitize?: unknown }).sanitize === "function";
|
||||
}
|
||||
|
||||
function conservativeSanitize(html: string): string {
|
||||
return html
|
||||
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/giu, "")
|
||||
.replace(/\son[a-z]+=("[^"]*"|'[^']*'|[^\s>]+)/giu, "")
|
||||
.replace(/\s(href|src)=("javascript:[^"]*"|'javascript:[^']*'|javascript:[^\s>]+)/giu, "");
|
||||
}
|
||||
|
||||
export function messageTraceId(message: ChatMessage): string | null {
|
||||
return firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
|
||||
}
|
||||
|
||||
export function canCancelMessage(message: ChatMessage): boolean {
|
||||
return message.role === "agent" && String(message.status ?? "").toLowerCase() === "running" && Boolean(messageTraceId(message));
|
||||
}
|
||||
|
||||
export function canRetryMessage(message: ChatMessage): boolean {
|
||||
if (message.role !== "agent" || !message.retryInput) return false;
|
||||
return ["running", "failed", "timeout", "canceled", "cancelled", "blocked"].includes(String(message.status ?? "").toLowerCase());
|
||||
}
|
||||
|
||||
export function canReplayTrace(message: ChatMessage): boolean {
|
||||
return message.role === "agent" && Boolean(messageTraceId(message));
|
||||
}
|
||||
|
||||
export function traceIdentityText(message: ChatMessage): string {
|
||||
return [
|
||||
messageTraceId(message) ? `traceId=${messageTraceId(message)}` : null,
|
||||
message.conversationId ? `conversationId=${message.conversationId}` : null,
|
||||
message.sessionId ? `sessionId=${message.sessionId}` : null,
|
||||
message.threadId ? `threadId=${message.threadId}` : null
|
||||
].filter((item): item is string => Boolean(item)).join("\n");
|
||||
}
|
||||
|
||||
export function isNoiseTraceEvent(event: TraceEvent): boolean {
|
||||
const label = String(event.label ?? event.type ?? event.kind ?? "").toLowerCase();
|
||||
return label.includes("delta") || label.includes("chunk") || label.includes("token");
|
||||
}
|
||||
|
||||
export function visibleTraceEvents(events: TraceEvent[], compact: boolean): TraceEvent[] {
|
||||
return compact ? events.filter((event) => !isNoiseTraceEvent(event)) : events;
|
||||
}
|
||||
|
||||
export function traceEventLabel(event: TraceEvent): string {
|
||||
return firstNonEmptyString(event.label, event.type, event.kind) ?? "event";
|
||||
}
|
||||
|
||||
export function traceEventBody(event: TraceEvent): string {
|
||||
const body = firstNonEmptyString(event.message, event.text, event.outputSummary, event.stdoutSummary, event.stderrSummary, event.command, event.status);
|
||||
if (body) return body;
|
||||
if (event.exitCode != null) return `exitCode=${event.exitCode}`;
|
||||
return "已记录";
|
||||
}
|
||||
|
||||
export function traceEventMeta(event: TraceEvent): string {
|
||||
return [event.ts, event.elapsedMs == null ? null : `${event.elapsedMs}ms`, event.exitCode == null ? null : `exit=${event.exitCode}`].filter((item): item is string => Boolean(item)).join(" / ");
|
||||
}
|
||||
@@ -119,11 +119,60 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
subscribe(traceId, response.data);
|
||||
}
|
||||
|
||||
async function cancelAgentMessage(message: ChatMessage): Promise<void> {
|
||||
const traceId = message.traceId ?? message.runnerTrace?.traceId;
|
||||
if (!traceId) return;
|
||||
await api.agent.cancelAgentMessage({ traceId, projectId: activeProjectId.value, sessionId: message.sessionId ?? selectedSessionId.value, threadId: message.threadId ?? selectedThreadId.value, conversationId: message.conversationId ?? activeConversationId.value });
|
||||
markMessage(traceId, { status: "canceled", text: "用户已取消该 turn。" });
|
||||
if (message.status === "running") chatPending.value = false;
|
||||
}
|
||||
|
||||
async function cancelRunningTrace(): Promise<void> {
|
||||
const traceId = messages.value.find((message) => message.status === "running" && message.traceId)?.traceId;
|
||||
if (!traceId) return;
|
||||
await api.agent.cancelAgentMessage({ traceId, projectId: activeProjectId.value, sessionId: selectedSessionId.value });
|
||||
markMessage(traceId, { status: "canceled", text: "用户已取消该 turn。" });
|
||||
const message = messages.value.find((item) => item.traceId === traceId && item.role === "agent");
|
||||
if (message) await cancelAgentMessage(message);
|
||||
}
|
||||
|
||||
async function retryAgentMessage(message: ChatMessage): Promise<void> {
|
||||
const retryInput = firstNonEmptyString(message.retryInput, messages.value.find((item) => item.role === "user" && item.traceId === message.traceId)?.text);
|
||||
if (!retryInput) {
|
||||
if (message.traceId) markMessage(message.traceId, { traceReplayStatus: "没有可重试的上一条输入。" });
|
||||
return;
|
||||
}
|
||||
await submitMessage(retryInput);
|
||||
}
|
||||
|
||||
async function replayAgentTrace(message: ChatMessage): Promise<void> {
|
||||
const traceId = message.traceId ?? message.runnerTrace?.traceId;
|
||||
if (!traceId) return;
|
||||
markMessage(traceId, { traceReplayStatus: "Trace 回放中" });
|
||||
const result = await api.agent.getAgentChatResult(`/v1/agent/chat/trace/${encodeURIComponent(traceId)}?projectId=${encodeURIComponent(activeProjectId.value)}`, codeAgentTimeoutMs.value, () => activityRef.value);
|
||||
if (!result.ok || !result.data) {
|
||||
markMessage(traceId, { traceReplayStatus: result.error ?? "Trace 回放失败" });
|
||||
return;
|
||||
}
|
||||
const events = Array.isArray(result.data.events) ? result.data.events : Array.isArray(result.data.traceEvents) ? result.data.traceEvents : [];
|
||||
const runnerTrace = mergeRunnerTrace(message.runnerTrace, {
|
||||
...(result.data.runnerTrace ?? {}),
|
||||
traceId: result.data.traceId ?? traceId,
|
||||
status: result.data.status ?? result.data.traceStatus ?? message.runnerTrace?.status,
|
||||
sessionId: result.data.sessionId ?? message.runnerTrace?.sessionId,
|
||||
threadId: result.data.threadId ?? message.runnerTrace?.threadId,
|
||||
events,
|
||||
eventCount: result.data.eventCount ?? events.length,
|
||||
eventsCompacted: result.data.runnerTrace?.eventsCompacted ?? false,
|
||||
fullTraceLoaded: true,
|
||||
traceStatus: result.data.traceStatus,
|
||||
retention: result.data.retention,
|
||||
terminalEvidence: result.data.terminalEvidence,
|
||||
finalResponse: result.data.finalResponse,
|
||||
traceSummary: result.data.traceSummary,
|
||||
agentRun: result.data.agentRun,
|
||||
lastEventLabel: result.data.lastEventLabel ?? events.at(-1)?.label ?? events.at(-1)?.type,
|
||||
updatedAt: new Date().toISOString()
|
||||
});
|
||||
markMessage(traceId, { runnerTrace, traceReplayStatus: "Trace 已回放" });
|
||||
}
|
||||
|
||||
function setProviderProfile(value: ProviderProfile): void {
|
||||
@@ -170,7 +219,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
return workspace.value;
|
||||
}
|
||||
|
||||
return { projectId, workspace, conversations, messages, providerProfile, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, chatPending, error, activeConversationId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectConversation, refreshConversations, submitMessage, cancelRunningTrace, setProviderProfile, recordActivity };
|
||||
return { projectId, workspace, conversations, messages, providerProfile, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, chatPending, error, activeConversationId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectConversation, refreshConversations, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, replayAgentTrace, setProviderProfile, recordActivity };
|
||||
});
|
||||
|
||||
function messagesFromWorkspace(workspace: WorkspaceRecord | null): ChatMessage[] {
|
||||
|
||||
@@ -304,13 +304,61 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
.message-text,
|
||||
.message-markdown {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
color: #1f2937;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.message-markdown {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-markdown > * {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.message-markdown ul,
|
||||
.message-markdown ol {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.message-markdown pre,
|
||||
.message-markdown code {
|
||||
border-radius: 6px;
|
||||
background: #0f172a;
|
||||
color: #dbeafe;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.message-markdown pre {
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.message-markdown code {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.message-markdown pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.message-markdown a {
|
||||
color: #0e7490;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.trace-timeline {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -319,6 +367,57 @@
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.trace-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.trace-toolbar strong,
|
||||
.trace-id {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trace-id {
|
||||
max-width: 220px;
|
||||
color: #475569;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.trace-toggle {
|
||||
min-height: 28px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
color: #334155;
|
||||
padding: 0 9px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.trace-warning,
|
||||
.trace-empty {
|
||||
margin: 0;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.trace-warning {
|
||||
border: 1px solid #fde68a;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.trace-empty {
|
||||
border: 1px solid #d8e1eb;
|
||||
background: white;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.trace-timeline ol {
|
||||
display: grid;
|
||||
max-height: 170px;
|
||||
@@ -331,12 +430,89 @@
|
||||
|
||||
.trace-timeline li {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(96px, 160px) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(96px, 160px) minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.trace-timeline li span,
|
||||
.trace-timeline li small {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.message-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.message-actions a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.message-action-status {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.code-agent-status-summary {
|
||||
border: 1px solid #d8e1eb;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.code-agent-status-summary summary {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 9px 10px;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.code-agent-status-summary dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
border-top: 1px solid #d8e1eb;
|
||||
}
|
||||
|
||||
.code-agent-status-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(110px, 0.8fr) minmax(0, 1.2fr);
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 7px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.code-agent-status-row dt,
|
||||
.code-agent-status-row dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.code-agent-status-row dt {
|
||||
color: #64748b;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.code-agent-status-row dd {
|
||||
overflow-wrap: anywhere;
|
||||
color: #1f2937;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
.trace-timeline code,
|
||||
.mini-log,
|
||||
.json-panel,
|
||||
|
||||
@@ -162,6 +162,7 @@ export interface ChatMessage {
|
||||
sessionId?: string | null;
|
||||
threadId?: string | null;
|
||||
retryInput?: string | null;
|
||||
traceReplayStatus?: string | null;
|
||||
runnerTrace?: RunnerTrace | null;
|
||||
traceEvents?: TraceEvent[];
|
||||
error?: { code?: string; message?: string; category?: string; providerStatus?: number; [key: string]: unknown } | null;
|
||||
|
||||
Reference in New Issue
Block a user