Fix live activity session tracking
This commit is contained in:
@@ -31,6 +31,10 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import {
|
||||||
|
type ClawRunStatus,
|
||||||
|
fetchLatestRunStatus,
|
||||||
|
} from "@/components/assistant-ui/thread-list";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
@@ -38,6 +42,7 @@ import {
|
|||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
import {
|
import {
|
||||||
|
ACTIVE_SESSION_CHANGED_EVENT,
|
||||||
readActiveSessionId,
|
readActiveSessionId,
|
||||||
writeActiveSessionId,
|
writeActiveSessionId,
|
||||||
} from "@/lib/claw-active-session";
|
} from "@/lib/claw-active-session";
|
||||||
@@ -133,6 +138,8 @@ type ActivityItem = {
|
|||||||
fileLinks?: string[];
|
fileLinks?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LiveRunEvent = NonNullable<ClawRunStatus["events"]>[number];
|
||||||
|
|
||||||
export function ActivityPanel() {
|
export function ActivityPanel() {
|
||||||
const {
|
const {
|
||||||
open,
|
open,
|
||||||
@@ -145,13 +152,49 @@ export function ActivityPanel() {
|
|||||||
close,
|
close,
|
||||||
} = useActivityPanel();
|
} = useActivityPanel();
|
||||||
const messages = useAuiState((s) => s.thread.messages);
|
const messages = useAuiState((s) => s.thread.messages);
|
||||||
const items = useMemo(() => collectActivityItems(messages), [messages]);
|
const [activeSessionId, setActiveSessionId] = useState<string | null>(() =>
|
||||||
|
readActiveSessionId(),
|
||||||
|
);
|
||||||
|
const liveRunStatus = useLiveRunStatus(activeSessionId, mode);
|
||||||
|
const replayedSessionId = useMemo(
|
||||||
|
() => findLatestSessionIdInMessages(messages),
|
||||||
|
[messages],
|
||||||
|
);
|
||||||
|
const messageItems = useMemo(
|
||||||
|
() => collectActivityItems(messages),
|
||||||
|
[messages],
|
||||||
|
);
|
||||||
|
const liveItems = useMemo(
|
||||||
|
() => collectLiveRunItems(liveRunStatus),
|
||||||
|
[liveRunStatus],
|
||||||
|
);
|
||||||
|
const shouldUseLiveItems =
|
||||||
|
liveItems.length > 0 &&
|
||||||
|
activeSessionId !== null &&
|
||||||
|
(activeSessionId !== replayedSessionId || hasActiveRun(liveRunStatus));
|
||||||
|
const items = shouldUseLiveItems ? liveItems : messageItems;
|
||||||
const selectedMessageId = selectedId ? activityMessageId(selectedId) : null;
|
const selectedMessageId = selectedId ? activityMessageId(selectedId) : null;
|
||||||
const visibleItems = selectedMessageId
|
const visibleItems = selectedMessageId
|
||||||
? items.filter((item) => activityMessageId(item.id) === selectedMessageId)
|
? items.filter((item) => activityMessageId(item.id) === selectedMessageId)
|
||||||
: items;
|
: items;
|
||||||
const prevLatestIdRef = useRef<string | null>(null);
|
const prevLatestIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const update = () => setActiveSessionId(readActiveSessionId());
|
||||||
|
const handleChanged = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<{ sessionId?: string | null }>)
|
||||||
|
.detail;
|
||||||
|
setActiveSessionId(detail?.sessionId ?? readActiveSessionId());
|
||||||
|
};
|
||||||
|
window.addEventListener(ACTIVE_SESSION_CHANGED_EVENT, handleChanged);
|
||||||
|
window.addEventListener("focus", update);
|
||||||
|
update();
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(ACTIVE_SESSION_CHANGED_EVENT, handleChanged);
|
||||||
|
window.removeEventListener("focus", update);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const latestId = items.at(-1)?.id ?? null;
|
const latestId = items.at(-1)?.id ?? null;
|
||||||
if (
|
if (
|
||||||
@@ -657,6 +700,60 @@ function activityMessageId(activityId: string) {
|
|||||||
return activityId.split(":", 1)[0] ?? activityId;
|
return activityId.split(":", 1)[0] ?? activityId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useLiveRunStatus(
|
||||||
|
sessionId: string | null,
|
||||||
|
mode: ActivityContextValue["mode"],
|
||||||
|
) {
|
||||||
|
const [status, setStatus] = useState<ClawRunStatus | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId || mode !== "activity") {
|
||||||
|
setStatus(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const safeSessionId = sessionId;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const nextStatus = await fetchLatestRunStatus(safeSessionId);
|
||||||
|
if (cancelled) return;
|
||||||
|
setStatus(hasActiveRun(nextStatus) ? nextStatus : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
const interval = window.setInterval(refresh, 1000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [sessionId, mode]);
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasActiveRun(
|
||||||
|
status?: ClawRunStatus | null,
|
||||||
|
): status is ClawRunStatus & { status: "queued" | "running" } {
|
||||||
|
return status?.status === "queued" || status?.status === "running";
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLatestSessionIdInMessages(messages: readonly MessageState[]) {
|
||||||
|
for (const message of [...messages].reverse()) {
|
||||||
|
const metadata = message.metadata;
|
||||||
|
const custom =
|
||||||
|
metadata && typeof metadata === "object" && "custom" in metadata
|
||||||
|
? (metadata as { custom?: unknown }).custom
|
||||||
|
: null;
|
||||||
|
if (custom && typeof custom === "object" && "sessionId" in custom) {
|
||||||
|
const sessionId = (custom as { sessionId?: unknown }).sessionId;
|
||||||
|
if (typeof sessionId === "string" && sessionId.trim()) {
|
||||||
|
return sessionId.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function ActivityPanelItem({
|
function ActivityPanelItem({
|
||||||
item,
|
item,
|
||||||
open,
|
open,
|
||||||
@@ -770,6 +867,118 @@ function ActivityResultSummary({ value }: { value: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectLiveRunItems(runStatus: ClawRunStatus | null) {
|
||||||
|
if (!hasActiveRun(runStatus)) return [];
|
||||||
|
const items: ActivityItem[] = [];
|
||||||
|
const events = runStatus.events ?? [];
|
||||||
|
const summaryLines = summarizeLiveRunEvents(runStatus, events);
|
||||||
|
items.push({
|
||||||
|
id: `live:${runStatus.run_id ?? runStatus.session_id ?? "active"}:reasoning`,
|
||||||
|
kind: "reasoning",
|
||||||
|
title: runStatus.status === "queued" ? "排队中" : "思考中",
|
||||||
|
summary: summaryLines.join("\n"),
|
||||||
|
status: "running",
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultById = new Map<string, LiveRunEvent>();
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.type === "tool_result" && event.tool_call_id) {
|
||||||
|
resultById.set(event.tool_call_id, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const seenToolCalls = new Set<string>();
|
||||||
|
for (const event of events) {
|
||||||
|
if (
|
||||||
|
event.type !== "tool_start" ||
|
||||||
|
!event.tool_call_id ||
|
||||||
|
!event.tool_name ||
|
||||||
|
seenToolCalls.has(event.tool_call_id)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenToolCalls.add(event.tool_call_id);
|
||||||
|
const result = resultById.get(event.tool_call_id);
|
||||||
|
const resultSummary =
|
||||||
|
typeof result?.metadata?.output_preview === "string"
|
||||||
|
? result.metadata.output_preview
|
||||||
|
: result
|
||||||
|
? result.ok === false
|
||||||
|
? "工具执行失败,等待最终结果汇总。"
|
||||||
|
: "工具调用完成,等待最终结果汇总。"
|
||||||
|
: undefined;
|
||||||
|
const input = stripClawToolMetadata(
|
||||||
|
attachStageNote(event.arguments ?? {}, event.assistant_content),
|
||||||
|
);
|
||||||
|
const argsText = formatToolArgs(input);
|
||||||
|
items.push({
|
||||||
|
id: `live:${runStatus.run_id ?? runStatus.session_id ?? "active"}:${event.tool_call_id}`,
|
||||||
|
kind: "tool",
|
||||||
|
title: event.tool_name,
|
||||||
|
summary: result
|
||||||
|
? result.ok === false
|
||||||
|
? "工具调用失败"
|
||||||
|
: "工具调用完成"
|
||||||
|
: "工具调用中",
|
||||||
|
status: result
|
||||||
|
? result.ok === false
|
||||||
|
? "incomplete"
|
||||||
|
: "complete"
|
||||||
|
: "running",
|
||||||
|
argsText,
|
||||||
|
resultSummary,
|
||||||
|
rawResult: resultSummary,
|
||||||
|
fileLinks: extractLocalPaths(
|
||||||
|
[argsText, resultSummary].filter(Boolean) as string[],
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeLiveRunEvents(
|
||||||
|
runStatus: ClawRunStatus,
|
||||||
|
events: readonly LiveRunEvent[],
|
||||||
|
) {
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.type === "run_queued") {
|
||||||
|
lines.push("当前会话已有任务在执行,本轮正在排队。");
|
||||||
|
}
|
||||||
|
if (event.type === "run_started") {
|
||||||
|
lines.push("后端已开始执行本轮任务。");
|
||||||
|
}
|
||||||
|
if (event.type === "tool_start") {
|
||||||
|
const stageNote =
|
||||||
|
typeof event.assistant_content === "string"
|
||||||
|
? event.assistant_content.trim()
|
||||||
|
: "";
|
||||||
|
lines.push(
|
||||||
|
stageNote ||
|
||||||
|
(event.tool_name ? `调用工具 ${event.tool_name}` : "正在调用工具"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (event.type === "tool_result") {
|
||||||
|
lines.push(
|
||||||
|
event.tool_name ? `工具完成 ${event.tool_name}` : "工具调用完成",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (event.type === "final_text_start") {
|
||||||
|
lines.push("正在整理回复");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (runStatus.current_stage && lines.at(-1) !== runStatus.current_stage) {
|
||||||
|
lines.push(runStatus.current_stage);
|
||||||
|
}
|
||||||
|
if (!lines.length) {
|
||||||
|
lines.push(
|
||||||
|
runStatus.status === "queued"
|
||||||
|
? "当前会话已有任务在执行,本轮正在排队。"
|
||||||
|
: "后端正在执行这个会话。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lines.slice(-40);
|
||||||
|
}
|
||||||
|
|
||||||
function collectActivityItems(messages: readonly MessageState[]) {
|
function collectActivityItems(messages: readonly MessageState[]) {
|
||||||
const items: ActivityItem[] = [];
|
const items: ActivityItem[] = [];
|
||||||
|
|
||||||
@@ -841,6 +1050,15 @@ function getStageNote(value: unknown) {
|
|||||||
return typeof note === "string" ? note.trim() : "";
|
return typeof note === "string" ? note.trim() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attachStageNote(input: unknown, stageNote?: unknown) {
|
||||||
|
const note = typeof stageNote === "string" ? stageNote.trim() : "";
|
||||||
|
if (!note) return input;
|
||||||
|
if (input && typeof input === "object" && !Array.isArray(input)) {
|
||||||
|
return { ...(input as Record<string, unknown>), __claw_stage_note: note };
|
||||||
|
}
|
||||||
|
return { value: input, __claw_stage_note: note };
|
||||||
|
}
|
||||||
|
|
||||||
function stripClawToolMetadata(value: unknown): unknown {
|
function stripClawToolMetadata(value: unknown): unknown {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
const ACTIVE_SESSION_ID_KEY = "claw.activeSessionId";
|
const ACTIVE_SESSION_ID_KEY = "claw.activeSessionId";
|
||||||
|
export const ACTIVE_SESSION_CHANGED_EVENT = "claw-active-session-changed";
|
||||||
|
|
||||||
export function readActiveSessionId() {
|
export function readActiveSessionId() {
|
||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
@@ -19,12 +20,22 @@ export function writeActiveSessionId(sessionId: string | null | undefined) {
|
|||||||
}
|
}
|
||||||
window.sessionStorage.setItem(ACTIVE_SESSION_ID_KEY, normalized);
|
window.sessionStorage.setItem(ACTIVE_SESSION_ID_KEY, normalized);
|
||||||
window.localStorage.setItem(ACTIVE_SESSION_ID_KEY, normalized);
|
window.localStorage.setItem(ACTIVE_SESSION_ID_KEY, normalized);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(ACTIVE_SESSION_CHANGED_EVENT, {
|
||||||
|
detail: { sessionId: normalized },
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearActiveSessionId() {
|
export function clearActiveSessionId() {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
window.sessionStorage.removeItem(ACTIVE_SESSION_ID_KEY);
|
window.sessionStorage.removeItem(ACTIVE_SESSION_ID_KEY);
|
||||||
window.localStorage.removeItem(ACTIVE_SESSION_ID_KEY);
|
window.localStorage.removeItem(ACTIVE_SESSION_ID_KEY);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(ACTIVE_SESSION_CHANGED_EVENT, {
|
||||||
|
detail: { sessionId: null },
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSessionId(value: unknown) {
|
function normalizeSessionId(value: unknown) {
|
||||||
|
|||||||
Reference in New Issue
Block a user