精简动作审查输入并支持审批来源定位 (#125)
* fix: 精简审查背景并区分人工消息与调度输入 * fix: 模型审查仅提交当前调用与选定背景 * feat: 审批来源定位原会话并移除 Worker 审查背景
This commit is contained in:
1 parent
0d6fa880b8
commit
04d25eb133
30 files changed
+1143
-465
No files matched your search
@@ -24,6 +24,7 @@ import { toast } from "sonner";
|
||||
|
||||
import { SideQuestionButton, SideQuestionWorkspace } from "@/components/side-question-workspace";
|
||||
import { TodoPopover } from "@/components/todo-popover";
|
||||
import { ApprovalExecutionFocus, useApprovalFocus, useApprovalHistory } from "@/components/approval-execution-focus";
|
||||
import { Transcript } from "@/components/transcript";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -490,6 +491,7 @@ function ChatView({
|
||||
onTitleMaybeChanged: () => void;
|
||||
onConvUpdated: () => void;
|
||||
}) {
|
||||
const approvalFocus = useApprovalFocus({ conversationId: conv.id });
|
||||
const [messages, setMessages] = React.useState<Activity[]>([]);
|
||||
const [running, setRunning] = React.useState(false);
|
||||
const [input, setInput] = React.useState(initial?.input ?? "");
|
||||
@@ -502,6 +504,7 @@ function ChatView({
|
||||
const earliestRef = React.useRef(0); // earliest loaded id — reverse-pagination anchor
|
||||
const hasMoreRef = React.useRef(false); // older history remains above the loaded window
|
||||
const loadingMoreRef = React.useRef(false); // guard: one scroll-up load at a time
|
||||
const [historyLoaded, setHistoryLoaded] = React.useState(false);
|
||||
const [hasMore, setHasMore] = React.useState(false); // drives the "load earlier" hint
|
||||
const agent = agents.find((a) => a.key === conv.agent_key);
|
||||
const currentProfileId = conv.llm_profile_id ?? null;
|
||||
@@ -539,6 +542,7 @@ function ChatView({
|
||||
hasMoreRef.current = false;
|
||||
setHasMore(false);
|
||||
setMessages([]);
|
||||
setHistoryLoaded(false);
|
||||
setRunning(false);
|
||||
let live = true;
|
||||
api
|
||||
@@ -551,9 +555,12 @@ function ChatView({
|
||||
hasMoreRef.current = r.hasMore;
|
||||
setHasMore(r.hasMore);
|
||||
setRunning((current) => current || r.running);
|
||||
setHistoryLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
/* The empty state remains usable when history loading fails. */
|
||||
// A source link can retry history through its locator; ordinary chats
|
||||
// retain the existing usable empty state on a transient failure.
|
||||
if (live) setHistoryLoaded(true);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
@@ -597,6 +604,15 @@ function ChatView({
|
||||
};
|
||||
}, [running, conv.id, onTitleMaybeChanged]);
|
||||
|
||||
const loadFocusPage = React.useCallback((before: number) => api.conversationHistory(conv.id, before, HISTORY_PAGE), [conv.id]);
|
||||
const mergeFocusPage = React.useCallback((page: { items: Activity[]; hasMore: boolean }) => {
|
||||
setMessages((prev) => mergeActivities(page.items, prev));
|
||||
earliestRef.current = page.items[0]?.seq ?? earliestRef.current;
|
||||
hasMoreRef.current = page.hasMore;
|
||||
setHasMore(page.hasMore);
|
||||
}, []);
|
||||
const focusHistory = useApprovalHistory(approvalFocus.state?.source, historyLoaded, messages, loadFocusPage, mergeFocusPage);
|
||||
|
||||
// ---- transcript auto-scroll (open → bottom; stick to bottom unless scrolled up) ----
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const atBottomRef = React.useRef(true);
|
||||
@@ -635,12 +651,13 @@ function ChatView({
|
||||
const vp = viewport();
|
||||
if (!vp) return;
|
||||
const onScroll = () => {
|
||||
if (approvalFocus.state && !focusHistory.ready) return;
|
||||
atBottomRef.current = vp.scrollTop + vp.clientHeight >= vp.scrollHeight - 60;
|
||||
if (vp.scrollTop <= 80) void loadEarlier(); // near top → pull an older page
|
||||
};
|
||||
vp.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => vp.removeEventListener("scroll", onScroll);
|
||||
}, [viewport, loadEarlier]);
|
||||
}, [viewport, loadEarlier, approvalFocus.state, focusHistory.ready]);
|
||||
// open/switch a conversation → jump to the latest (bottom)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: changing conversations intentionally retriggers the scroll reset.
|
||||
React.useLayoutEffect(() => {
|
||||
@@ -653,10 +670,10 @@ function ChatView({
|
||||
// new activity → stick to bottom only if the user is already pinned there
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: message and running changes intentionally retrigger bottom anchoring.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!atBottomRef.current) return;
|
||||
if (approvalFocus.state || !atBottomRef.current) return;
|
||||
const vp = viewport();
|
||||
if (vp) vp.scrollTop = vp.scrollHeight;
|
||||
}, [messages, running, viewport]);
|
||||
}, [messages, running, viewport, approvalFocus.state]);
|
||||
|
||||
// Per-conversation token total, live — same accounting as the main-agent
|
||||
// console: completed runs' `result` sum + the in-progress run's latest `usage`.
|
||||
@@ -770,6 +787,8 @@ function ChatView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ApprovalExecutionFocus focus={approvalFocus} history={focusHistory} />
|
||||
|
||||
{/* messages */}
|
||||
<ScrollArea type="auto" className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!">
|
||||
<div className="min-w-0 max-w-full px-4 py-3" ref={contentRef}>
|
||||
@@ -782,7 +801,7 @@ function ChatView({
|
||||
{hasMore && (
|
||||
<div className="text-muted-foreground/70 pb-2 text-center text-[11px]">向上滚动加载更早的消息…</div>
|
||||
)}
|
||||
<Transcript activity={messages} live={running} chat fetchDetail={fetchDetail} />
|
||||
<Transcript activity={messages} live={running} chat fetchDetail={fetchDetail} focusedSeq={focusHistory.ready ? approvalFocus.state?.source?.seq : undefined} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1026,6 +1045,18 @@ export default function ChatPage() {
|
||||
const [convs, setConvs] = React.useState<Conversation[]>([]);
|
||||
const [agentFilter, setAgentFilter] = React.useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = React.useState<number | null>(null);
|
||||
const [sourceRequested, setSourceRequested] = React.useState(false);
|
||||
const [convsLoaded, setConvsLoaded] = React.useState(false);
|
||||
const selectConversation = React.useCallback((id: number | null) => {
|
||||
if (id !== selectedId) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("approval");
|
||||
setSourceRequested(false);
|
||||
window.history.replaceState(null, "", url);
|
||||
}
|
||||
setSelectedId(id);
|
||||
}, [selectedId]);
|
||||
|
||||
const [renamingId, setRenamingId] = React.useState<number | null>(null);
|
||||
const [renameText, setRenameText] = React.useState("");
|
||||
const [selectedConversationIds, setSelectedConversationIds] = React.useState<Set<number>>(() => new Set());
|
||||
@@ -1051,7 +1082,7 @@ export default function ChatPage() {
|
||||
const seq = ++conversationListSeq.current;
|
||||
try {
|
||||
const items = await api.conversations();
|
||||
if (seq === conversationListSeq.current) setConvs(items);
|
||||
if (seq === conversationListSeq.current) { setConvs(items); setConvsLoaded(true); }
|
||||
} catch {
|
||||
// Preserve the selected transcript and list on a transient poll failure.
|
||||
}
|
||||
@@ -1122,7 +1153,9 @@ export default function ChatPage() {
|
||||
// returns to the same thread instead of the empty draft view. Runs after
|
||||
// hydration (not a lazy useState init) to avoid a server/client mismatch.
|
||||
React.useEffect(() => {
|
||||
const c = new URLSearchParams(window.location.search).get("c");
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
setSourceRequested(params.has("approval"));
|
||||
const c = params.get("c");
|
||||
const id = c ? Number(c) : NaN;
|
||||
if (Number.isFinite(id)) setSelectedId(id);
|
||||
}, []);
|
||||
@@ -1249,7 +1282,7 @@ export default function ChatPage() {
|
||||
else failed.push({ id: item.id, error: item.error ?? "对话不存在" });
|
||||
}
|
||||
}
|
||||
if (deleted.has(selectedId ?? -1)) setSelectedId(null);
|
||||
if (deleted.has(selectedId ?? -1)) selectConversation(null);
|
||||
setSelectedConversationIds((current) => {
|
||||
const next = new Set(current);
|
||||
for (const id of deleted) next.delete(id);
|
||||
@@ -1318,7 +1351,7 @@ export default function ChatPage() {
|
||||
{/* left: conversation list */}
|
||||
<div className="bg-card flex flex-col overflow-hidden rounded-lg border">
|
||||
<div className="flex flex-col gap-2 border-b p-2">
|
||||
<Button size="sm" className="w-full" onClick={() => setSelectedId(null)}>
|
||||
<Button size="sm" className="w-full" onClick={() => selectConversation(null)}>
|
||||
<PlusIcon /> 新建对话
|
||||
</Button>
|
||||
<Select
|
||||
@@ -1405,7 +1438,7 @@ export default function ChatPage() {
|
||||
active={selectedId === c.id}
|
||||
renaming={renamingId === c.id}
|
||||
renameText={renamingId === c.id ? renameText : ""}
|
||||
onSelect={setSelectedId}
|
||||
onSelect={selectConversation}
|
||||
onStartRename={startRename}
|
||||
onRenameText={setRenameText}
|
||||
onCommitRename={commitRename}
|
||||
@@ -1437,7 +1470,7 @@ export default function ChatPage() {
|
||||
active={selectedId === c.id}
|
||||
renaming={renamingId === c.id}
|
||||
renameText={renamingId === c.id ? renameText : ""}
|
||||
onSelect={setSelectedId}
|
||||
onSelect={selectConversation}
|
||||
onStartRename={startRename}
|
||||
onRenameText={setRenameText}
|
||||
onCommitRename={commitRename}
|
||||
@@ -1479,6 +1512,10 @@ export default function ChatPage() {
|
||||
onTitleMaybeChanged={reloadConvs}
|
||||
onConvUpdated={reloadConvs}
|
||||
/>
|
||||
) : sourceRequested ? (
|
||||
<div role="status" className="p-6 text-sm text-muted-foreground">
|
||||
{convsLoaded ? "对话已被删除" : "正在加载对应对话…"}
|
||||
</div>
|
||||
) : (
|
||||
<DraftChat
|
||||
agents={chatAgents}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { toast } from "sonner";
|
||||
|
||||
import { SideQuestionButton, SideQuestionWorkspace } from "@/components/side-question-workspace";
|
||||
import { TodoPopover } from "@/components/todo-popover";
|
||||
import { ApprovalExecutionFocus, useApprovalFocus, useApprovalHistory } from "@/components/approval-execution-focus";
|
||||
import { Transcript } from "@/components/transcript";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -469,7 +470,37 @@ function WorkerAssetBadge({ assets }: { assets: IntentAsset[] }) {
|
||||
}
|
||||
|
||||
export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
const [activeId, setActiveId] = React.useState(MAIN_ID);
|
||||
const approvalFocus = useApprovalFocus({ taskId });
|
||||
const [selectedSessionId, setActiveId] = React.useState(MAIN_ID);
|
||||
const focusSession = React.useMemo<Session | undefined>(() => {
|
||||
const source = approvalFocus.state?.source;
|
||||
if (!source) return undefined;
|
||||
if (source.session.startsWith("main:")) {
|
||||
const seg = Number(source.session.slice(5));
|
||||
return { ...MAIN_SESSION, id: mainSessionId(seg), seg, title: mainSessionTitle(seg), live: false };
|
||||
}
|
||||
if (source.session === "plan") return { ...PLANNER_SESSION, live: false };
|
||||
if (source.session.startsWith("intent:")) {
|
||||
const id = source.session.slice(7);
|
||||
return {
|
||||
id,
|
||||
role: "worker",
|
||||
intent_id: id,
|
||||
title: `Worker #${id}`,
|
||||
status: "done",
|
||||
live: false,
|
||||
last_activity: source.items[0]?.ts ?? "",
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [approvalFocus.state?.source]);
|
||||
// Keep a located archived/older session selectable after leaving focus mode.
|
||||
const [locatedSession, setLocatedSession] = React.useState<{ taskId: string; session: Session }>();
|
||||
React.useEffect(() => {
|
||||
if (focusSession) setLocatedSession({ taskId, session: focusSession });
|
||||
}, [taskId, focusSession]);
|
||||
const retainedSession = locatedSession?.taskId === taskId ? locatedSession.session : undefined;
|
||||
const activeId = focusSession?.id ?? selectedSessionId;
|
||||
// Main-agent conversation segments (newest-first); currentSeg is the writable one.
|
||||
const [mainSegs, setMainSegs] = React.useState<{ seq: number; created_at: string }[]>([{ seq: 0, created_at: "" }]);
|
||||
const [currentSeg, setCurrentSeg] = React.useState(0);
|
||||
@@ -1198,10 +1229,12 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
[mainSegs, liveMainSeg],
|
||||
);
|
||||
|
||||
const sessions = React.useMemo(
|
||||
() => [...mainSessions, { ...PLANNER_SESSION, live: plannerLive }, ...workerSessions, SYSTEM_SESSION],
|
||||
[mainSessions, workerSessions, plannerLive],
|
||||
);
|
||||
const sessions = React.useMemo(() => {
|
||||
const items = [...mainSessions, { ...PLANNER_SESSION, live: plannerLive }, ...workerSessions, SYSTEM_SESSION];
|
||||
const located = focusSession ?? retainedSession;
|
||||
if (located && !items.some((s) => s.id === located.id)) items.push(located);
|
||||
return items;
|
||||
}, [mainSessions, workerSessions, plannerLive, focusSession, retainedSession]);
|
||||
|
||||
const grouped = {
|
||||
mainagent: sessions.filter((s) => s.role === "mainagent"),
|
||||
@@ -1253,6 +1286,21 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
}
|
||||
}, [activeKey]);
|
||||
|
||||
const focusKey = approvalFocus.state?.source?.session;
|
||||
const loadFocusPage = React.useCallback((before: number) => api.activityHistory(taskId, focusKey ?? "main", before, PAGE), [taskId, focusKey]);
|
||||
const mergeFocusPage = React.useCallback((page: { items: Activity[]; hasMore: boolean }) => {
|
||||
if (!focusKey) return;
|
||||
patchStore(focusKey, (s) => {
|
||||
const items = mergeBySeq(page.items, s.items);
|
||||
return { ...s, items, hasMore: page.hasMore, earliestSeq: items[0]?.seq ?? s.earliestSeq };
|
||||
});
|
||||
}, [focusKey, patchStore]);
|
||||
const focusHistory = useApprovalHistory(approvalFocus.state?.source, !!focusKey && !!store[focusKey]?.loaded,
|
||||
focusKey ? store[focusKey]?.items ?? [] : [], loadFocusPage, mergeFocusPage);
|
||||
React.useEffect(() => {
|
||||
if (approvalFocus.state) atBottomRef.current = false;
|
||||
}, [approvalFocus.state]);
|
||||
|
||||
// Main agent is the human↔orchestrator CONSOLE: only the conversation (user msgs +
|
||||
// the main agent's own replies/steps). Planner session shows planner steps; a
|
||||
// worker session shows only its intent's activity, led by the intent objective.
|
||||
@@ -1367,28 +1415,29 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
const vp = viewport();
|
||||
if (!vp) return;
|
||||
const onScroll = () => {
|
||||
if (approvalFocus.state && !focusHistory.ready) return;
|
||||
atBottomRef.current = vp.scrollTop + vp.clientHeight >= vp.scrollHeight - 60;
|
||||
if (vp.scrollTop <= 80) loadEarlier(activeKeyRef.current, viewport); // near top → older page
|
||||
};
|
||||
vp.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => vp.removeEventListener("scroll", onScroll);
|
||||
}, [viewport, activeId, loadEarlier]);
|
||||
}, [viewport, activeId, loadEarlier, approvalFocus.state, focusHistory.ready]);
|
||||
// open/switch a session → jump to the latest (bottom)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: activeId intentionally scrolls a newly selected session.
|
||||
React.useLayoutEffect(() => {
|
||||
const vp = viewport();
|
||||
if (vp) {
|
||||
if (vp && !approvalFocus.state) {
|
||||
vp.scrollTop = vp.scrollHeight;
|
||||
atBottomRef.current = true;
|
||||
}
|
||||
}, [activeId, viewport]);
|
||||
}, [activeId, viewport, approvalFocus.state]);
|
||||
// new activity → stick to bottom only if the user is already pinned there
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: activity growth intentionally drives live-edge scrolling.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!atBottomRef.current) return;
|
||||
if (approvalFocus.state || !atBottomRef.current) return;
|
||||
const vp = viewport();
|
||||
if (vp) vp.scrollTop = vp.scrollHeight;
|
||||
}, [activity, viewport]);
|
||||
}, [activity, viewport, approvalFocus.state]);
|
||||
// Lazy detail loads (AnswerBlock / ToolBlock / Markdown) grow the content AFTER the
|
||||
// activity array settles, WITHOUT changing its reference — so the layout effects
|
||||
// above never re-fire and a freshly opened session would leave its last message
|
||||
@@ -1402,13 +1451,13 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
const el = contentRef.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (!atBottomRef.current) return;
|
||||
if (approvalFocus.state || !atBottomRef.current) return;
|
||||
const vp = viewport();
|
||||
if (vp) vp.scrollTop = vp.scrollHeight;
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [activeId, viewport]);
|
||||
}, [activeId, viewport, approvalFocus.state]);
|
||||
|
||||
function stop() {
|
||||
if (stopping) return;
|
||||
@@ -1616,6 +1665,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
hasPending={hasPendingForSession(s)}
|
||||
unread={store[keyForSession(s)]?.unread}
|
||||
onClick={() => {
|
||||
approvalFocus.close();
|
||||
setActiveId(s.id);
|
||||
setListOpen(false); // 手机端选完即收起,把高度还给会话记录
|
||||
setWorkerMessage("");
|
||||
@@ -1777,6 +1827,10 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<ApprovalExecutionFocus focus={{ ...approvalFocus, close: () => {
|
||||
setActiveId(activeId);
|
||||
approvalFocus.close();
|
||||
} }} history={focusHistory} />
|
||||
{/* Force Radix's internal viewport wrapper (display:table, sizes to content)
|
||||
to block so wide/unbreakable steps (long commands, code, URLs) can't blow
|
||||
out the width and defeat the truncation below — the transcript wraps to
|
||||
@@ -1808,7 +1862,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
</Button>
|
||||
</div>
|
||||
) : activity.length ? (
|
||||
<Transcript activity={activity} live={active.live} taskId={taskId} chat={isMain} />
|
||||
<Transcript activity={activity} live={active.live} taskId={taskId} chat={isMain} focusedSeq={focusHistory.ready ? approvalFocus.state?.source?.seq : undefined} />
|
||||
) : (
|
||||
<div className="pl-9 text-xs text-muted-foreground">
|
||||
{isMain ? "还没有对话。在下方给主 Agent 发消息,引导探索方向或介入流程。" : "暂无活动记录。"}
|
||||
|
||||
@@ -301,7 +301,7 @@ function TaskDetailInner() {
|
||||
if (!task) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-10 text-center">
|
||||
<p className="text-muted-foreground">{loaded ? `未找到任务 ${id}` : "加载中…"}</p>
|
||||
<p className="text-muted-foreground">{loaded ? `任务 ${id} 已被删除、归档或不存在` : "加载中…"}</p>
|
||||
{loaded && (
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/function/tasks">
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Activity, InterceptExecution } from "@/lib/types";
|
||||
|
||||
// Resolve one exact persisted call, independently of transcript pagination.
|
||||
export function useApprovalFocus({ taskId, conversationId }: { taskId?: string; conversationId?: number }) {
|
||||
const [state, setState] = React.useState<{
|
||||
id: number;
|
||||
source?: InterceptExecution;
|
||||
error?: string;
|
||||
loading: boolean;
|
||||
} | null>(null);
|
||||
const [revision, setRevision] = React.useState(0);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: revision is the explicit retry trigger.
|
||||
React.useEffect(() => {
|
||||
const q = new URLSearchParams(window.location.search);
|
||||
const id = Number(q.get("approval"));
|
||||
const matches = conversationId ? q.get("c") === String(conversationId) : q.get("id") === taskId;
|
||||
if (!matches || !Number.isSafeInteger(id) || id <= 0) {
|
||||
setState(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setState({ id, loading: true });
|
||||
api
|
||||
.interceptExecution(id, conversationId)
|
||||
.then((source) => {
|
||||
if (cancelled) return;
|
||||
if (
|
||||
conversationId
|
||||
? source.conversation_id !== conversationId
|
||||
: source.task_id !== taskId || source.conversation_id != null
|
||||
) {
|
||||
throw new Error("审批来源与当前会话不一致");
|
||||
}
|
||||
setState({ id, source, loading: false });
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setState({ id, error: (e as Error).message || "无法定位原始执行", loading: false });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [taskId, conversationId, revision]);
|
||||
const close = React.useCallback(() => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("approval");
|
||||
window.history.replaceState(null, "", url);
|
||||
setState(null);
|
||||
}, []);
|
||||
return { state, close, retry: () => setRevision((v) => v + 1) };
|
||||
}
|
||||
|
||||
type HistoryPage = { items: Activity[]; hasMore: boolean };
|
||||
|
||||
// Fill the existing transcript continuously back to the call, preserving all
|
||||
// intervening messages. Never splice an isolated call into a paginated history.
|
||||
export function useApprovalHistory(
|
||||
source: InterceptExecution | undefined,
|
||||
loaded: boolean,
|
||||
items: Activity[],
|
||||
loadPage: (before: number) => Promise<HistoryPage>,
|
||||
mergePage: (page: HistoryPage) => void,
|
||||
) {
|
||||
const itemsRef = React.useRef(items);
|
||||
itemsRef.current = items;
|
||||
const [result, setResult] = React.useState<{ source: InterceptExecution; error?: string }>();
|
||||
React.useEffect(() => {
|
||||
if (!source || !loaded) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
let current = itemsRef.current;
|
||||
let before = current[0]?.seq ?? 0;
|
||||
while (!current.some((a) => a.seq === source.seq && a.kind === "tool_use")) {
|
||||
const page = await loadPage(before);
|
||||
if (cancelled) return;
|
||||
if (!page.items.length || (before > 0 && page.items[0].seq >= before)) {
|
||||
throw new Error("会话中未找到对应工具调用,记录可能已删除");
|
||||
}
|
||||
mergePage(page);
|
||||
current = page.items;
|
||||
before = current[0].seq;
|
||||
if (!page.hasMore && !current.some((a) => a.seq === source.seq)) {
|
||||
throw new Error("会话中未找到对应工具调用");
|
||||
}
|
||||
}
|
||||
if (!cancelled) setResult({ source });
|
||||
})().catch((error) => {
|
||||
if (!cancelled) setResult({ source, error: (error as Error).message });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [source, loaded, loadPage, mergePage]);
|
||||
return {
|
||||
ready: !!source && result?.source === source && !result.error,
|
||||
error: result?.source === source ? result?.error : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function ApprovalExecutionFocus({
|
||||
focus,
|
||||
history,
|
||||
}: {
|
||||
focus: ReturnType<typeof useApprovalFocus>;
|
||||
history: ReturnType<typeof useApprovalHistory>;
|
||||
}) {
|
||||
const { state } = focus;
|
||||
if (!state) return null;
|
||||
const error = state.error || history.error;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b px-4 py-2 text-xs"
|
||||
>
|
||||
<span className={error ? "text-destructive" : "text-muted-foreground"}>
|
||||
{error
|
||||
? `无法定位:${error}`
|
||||
: history.ready
|
||||
? `已展开审批 #${state.id} 对应的工具调用`
|
||||
: `正在加载审批 #${state.id} 所在的对话位置…`}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
{error ? (
|
||||
<Button size="sm" variant="outline" onClick={focus.retry}>
|
||||
重试定位
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" variant="ghost" onClick={focus.close}>
|
||||
取消定位
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,46 @@ function originLabel(row: InterceptApprovalRow) {
|
||||
return "—";
|
||||
}
|
||||
|
||||
function ApprovalOrigin({ row, detail = false }: { row: InterceptApprovalRow; detail?: boolean }) {
|
||||
const [locating, setLocating] = React.useState(false);
|
||||
const label = detail && row.task_id ? `任务 ${row.task_id}` : originLabel(row);
|
||||
const query = new URLSearchParams({ approval: String(row.id) });
|
||||
let href: string | undefined;
|
||||
if (row.conversation_id) {
|
||||
query.set("c", String(row.conversation_id));
|
||||
href = `/chat?${query}`;
|
||||
} else if (row.task_id) {
|
||||
query.set("id", row.task_id);
|
||||
href = `/function/tasks/detail?${query}`;
|
||||
}
|
||||
return href ? (
|
||||
<a
|
||||
href={href}
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
aria-label={`定位审批 #${row.id} 的来源:${label}`}
|
||||
aria-busy={locating}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
e.preventDefault();
|
||||
if (locating) return;
|
||||
setLocating(true);
|
||||
try {
|
||||
await api.interceptExecution(row.id, row.conversation_id ?? undefined);
|
||||
window.location.assign(href);
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || "无法定位对应执行");
|
||||
setLocating(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
<span>{label}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const labels: Record<string, string> = { pending: "待审批", allowed: "已允许", denied: "已拒绝", timeout: "已超时" };
|
||||
let variant: "default" | "destructive" | "secondary" | "outline" = "outline";
|
||||
@@ -131,45 +171,73 @@ function ModelReviewContext({ input }: { input: InterceptReviewInput }) {
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="font-medium text-sm">模型审查上下文</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
以下为本次实际发送给审查模型的输入快照,包含当时可用的任务背景与执行记录。
|
||||
{input.task ? "当前轮输入可能由调度器生成,不等同于原始用户消息。" : "当前轮输入为本次 Agent 收到的消息。"}
|
||||
以下为本次实际发送给审查模型的输入快照。背景仅用于理解当前动作,裁决依据为审查策略。
|
||||
{input.version < 4 ? "此记录使用旧版输入,保留当时实际发送的内容。" : null}
|
||||
</p>
|
||||
</div>
|
||||
{input.turn_input ? (
|
||||
<CodeBlock label="当前轮输入" text={input.turn_input} truncated={input.background_truncated} />
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">本次审查输入未包含当前轮消息。</p>
|
||||
)}
|
||||
{input.task ? (
|
||||
<>
|
||||
<CodeBlock label="任务描述" text={input.task.description} truncated={input.task.truncated} />
|
||||
<CodeBlock label="任务目标" text={input.task.goal} truncated={input.task.truncated} />
|
||||
<CodeBlock label="任务操作约束" text={JSON.stringify(input.task.constraints, null, 2)} />
|
||||
</>
|
||||
) : null}
|
||||
{input.worker_intent ? (
|
||||
<CodeBlock label="Worker 意图" text={input.worker_intent} truncated={input.background_truncated} />
|
||||
) : null}
|
||||
{input.working_directory ? <CodeBlock label="工作目录" text={input.working_directory} /> : null}
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<h4 className="font-medium text-muted-foreground text-xs">提供给模型的近期工具执行记录</h4>
|
||||
{input.history?.length ? (
|
||||
input.history.map((entry) => (
|
||||
<div key={entry.tool_use_id} className="flex min-w-0 flex-col gap-2 rounded-lg border p-3">
|
||||
<p className="break-words font-medium text-xs">
|
||||
{entry.tool} · {entry.status === "succeeded" ? "成功" : "失败(可能有部分副作用)"}
|
||||
</p>
|
||||
<CodeBlock label="历史调用参数" text={entry.arguments_preview} truncated={entry.truncated} />
|
||||
<CodeBlock label="历史执行结果" text={entry.result} truncated={entry.truncated} />
|
||||
</div>
|
||||
))
|
||||
<CodeBlock
|
||||
label="当前待审查调用"
|
||||
text={JSON.stringify({ tool_name: input.tool_name, arguments: input.arguments }, null, 2)}
|
||||
/>
|
||||
{input.version >= 2 ? (
|
||||
input.background ? (
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<CodeBlock
|
||||
label={input.background.source === "user_message" ? "背景 · 用户消息" : "背景 · Worker 意图摘要(旧版)"}
|
||||
text={input.background.text}
|
||||
truncated={input.background.truncated}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{input.background.source === "user_message"
|
||||
? "取自当前用户消息。"
|
||||
: "这是旧版发送的 Worker 意图摘要;新版 Worker 审查不再发送此内容。"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">本次未提供可配对的历史工具执行记录。</p>
|
||||
)}
|
||||
{input.history_truncated ? (
|
||||
<p className="text-muted-foreground text-xs">历史为有限窗口,部分内容已截断。</p>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">本次审查未附带背景消息。</p>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{input.turn_input ? (
|
||||
<CodeBlock label="当前轮输入(旧版)" text={input.turn_input} truncated={input.background_truncated} />
|
||||
) : null}
|
||||
{input.task ? (
|
||||
<>
|
||||
<CodeBlock label="任务描述(旧版)" text={input.task.description} truncated={input.task.truncated} />
|
||||
<CodeBlock label="任务目标(旧版)" text={input.task.goal} truncated={input.task.truncated} />
|
||||
<CodeBlock label="任务操作约束(旧版)" text={JSON.stringify(input.task.constraints, null, 2)} />
|
||||
</>
|
||||
) : null}
|
||||
{input.worker_intent ? (
|
||||
<CodeBlock label="Worker 意图(旧版)" text={input.worker_intent} truncated={input.background_truncated} />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{input.working_directory ? <CodeBlock label="工作目录" text={input.working_directory} /> : null}
|
||||
{input.version >= 3 ? (
|
||||
<p className="text-muted-foreground text-xs">本次审查未发送历史调用或执行结果。</p>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<h4 className="font-medium text-muted-foreground text-xs">当时提供给模型的历史调用(旧版)</h4>
|
||||
{input.history?.length ? (
|
||||
input.history.map((entry) => (
|
||||
<div key={entry.tool_use_id} className="flex min-w-0 flex-col gap-2 rounded-lg border p-3">
|
||||
<p className="break-words font-medium text-xs">
|
||||
{entry.tool} · {entry.status === "succeeded" ? "成功" : "失败(可能有部分副作用)"}
|
||||
</p>
|
||||
<CodeBlock label="历史调用参数" text={entry.arguments_preview} truncated={entry.truncated} />
|
||||
<CodeBlock label="历史执行结果" text={entry.result} truncated={entry.truncated} />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">本次未提供可配对的历史工具执行记录。</p>
|
||||
)}
|
||||
{input.history_truncated ? (
|
||||
<p className="text-muted-foreground text-xs">历史为有限窗口,部分内容已截断。</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="self-start">
|
||||
@@ -299,7 +367,9 @@ export function ApprovalDetail({
|
||||
{audit?.effective_action ? <p className="text-sm">最终动作:{actionLabels[audit.effective_action]}</p> : null}
|
||||
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 text-xs">
|
||||
<dt className="text-muted-foreground">来源</dt>
|
||||
<dd className="break-words">{current.task_id ? `任务 ${current.task_id}` : originLabel(current)}</dd>
|
||||
<dd className="break-words">
|
||||
<ApprovalOrigin row={current} detail />
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">申请时间</dt>
|
||||
<dd>{fmtTime(row.created_at)}</dd>
|
||||
<dt className="text-muted-foreground">决定时间</dt>
|
||||
@@ -364,7 +434,9 @@ export function ApprovalDetail({
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ChevronDownIcon data-icon="inline-start" />
|
||||
查看会话审计片段
|
||||
{audit.model_input && audit.model_input.version >= 3
|
||||
? "查看会话审计片段(未发送模型)"
|
||||
: "查看会话审计片段"}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-3">
|
||||
@@ -526,7 +598,7 @@ function ApprovalTable({
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="truncate" title={originLabel(row)}>
|
||||
{originLabel(row)}
|
||||
<ApprovalOrigin row={row} />
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground text-xs">
|
||||
{row.agent_name || row.conv_agent_key}
|
||||
|
||||
@@ -355,12 +355,15 @@ function ToolBlock({
|
||||
group,
|
||||
getDetail,
|
||||
showWorker,
|
||||
focused = false,
|
||||
}: {
|
||||
group: Extract<Group, { type: "tool" }>;
|
||||
getDetail: (seq: number) => Promise<string>;
|
||||
showWorker?: boolean;
|
||||
focused?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [open, setOpen] = React.useState(focused);
|
||||
const targetRef = React.useRef<HTMLElement>(null);
|
||||
const [detail, setDetail] = React.useState<string | null>(null);
|
||||
// what we last loaded, keyed by the underlying step seqs. When the tool result
|
||||
// arrives after we expanded mid-run (command only), this key changes and the
|
||||
@@ -377,7 +380,7 @@ function ToolBlock({
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-red-600 dark:text-red-400";
|
||||
const rawCmd =
|
||||
use && use.summary.startsWith(toolName) ? use.summary.slice(toolName.length).trimStart() : use?.summary ?? "";
|
||||
use && use.summary.startsWith(toolName) ? use.summary.slice(toolName.length).trimStart() : (use?.summary ?? "");
|
||||
const cmd = toolInputText(toolName, rawCmd);
|
||||
// status only — the full result lives behind the expand (【输出】), not previewed inline
|
||||
const statusText = running ? "执行中…" : ok ? "✓" : "✕ 失败";
|
||||
@@ -410,12 +413,53 @@ function ToolBlock({
|
||||
};
|
||||
}, [open, detailKey, use, result, getDetail, toolName]);
|
||||
|
||||
const scrolledRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
scrolledRef.current = false;
|
||||
if (focused) setOpen(true);
|
||||
}, [focused]);
|
||||
React.useEffect(() => {
|
||||
if (!focused || !open || detail === null || scrolledRef.current) return;
|
||||
const el = targetRef.current;
|
||||
const viewport = el?.closest('[data-slot="scroll-area-viewport"]');
|
||||
if (!el || !viewport) return;
|
||||
scrolledRef.current = true;
|
||||
const center = () => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const bounds = viewport.getBoundingClientRect();
|
||||
viewport.scrollTop += rect.top + rect.height / 2 - bounds.top - bounds.height / 2;
|
||||
};
|
||||
// User bubbles load their full text lazily. Allow their initial layout to
|
||||
// settle, but stop anchoring immediately if the user interacts or after 2s.
|
||||
const observer = new ResizeObserver(center);
|
||||
observer.observe(el.parentElement ?? el);
|
||||
observer.observe(viewport);
|
||||
const stop = () => observer.disconnect();
|
||||
viewport.addEventListener("wheel", stop, { passive: true, once: true });
|
||||
viewport.addEventListener("touchstart", stop, { passive: true, once: true });
|
||||
viewport.addEventListener("pointerdown", stop, { once: true });
|
||||
const frame = requestAnimationFrame(center);
|
||||
const timer = setTimeout(stop, 2000);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
clearTimeout(timer);
|
||||
stop();
|
||||
viewport.removeEventListener("wheel", stop);
|
||||
viewport.removeEventListener("touchstart", stop);
|
||||
viewport.removeEventListener("pointerdown", stop);
|
||||
};
|
||||
}, [focused, open, detail]);
|
||||
|
||||
function toggle() {
|
||||
setOpen((o) => !o);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-xs">
|
||||
<section
|
||||
ref={targetRef}
|
||||
aria-label={focused ? `定位的工具调用 #${use?.seq}` : undefined}
|
||||
className={focused ? "rounded-lg border-2 border-primary bg-primary/5 p-3 text-xs" : "text-xs"}
|
||||
>
|
||||
<button type="button" onClick={toggle} className="flex w-full items-start gap-2 py-1 text-left hover:bg-muted/40">
|
||||
<span className="mt-0.5 text-muted-foreground">
|
||||
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
||||
@@ -431,7 +475,7 @@ function ToolBlock({
|
||||
{detail ?? "加载中…"}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -637,18 +681,19 @@ function ExecView({
|
||||
taskId,
|
||||
chat,
|
||||
fetchDetail,
|
||||
focusedSeq,
|
||||
}: {
|
||||
activity: Activity[];
|
||||
taskId?: string;
|
||||
chat?: boolean;
|
||||
fetchDetail?: (seq: number) => Promise<string>;
|
||||
focusedSeq?: number;
|
||||
}) {
|
||||
const showWorker = new Set(activity.map((a) => a.worker)).size > 1;
|
||||
// default detail fetcher: the task-scoped activity endpoint. The chat page passes
|
||||
// its own (conversation-scoped) fetcher instead.
|
||||
const getDetail = React.useCallback(
|
||||
(seq: number) =>
|
||||
fetchDetail ? fetchDetail(seq) : api.activityDetail(seq, taskId).then((r) => r.detail ?? ""),
|
||||
(seq: number) => (fetchDetail ? fetchDetail(seq) : api.activityDetail(seq, taskId).then((r) => r.detail ?? "")),
|
||||
[fetchDetail, taskId],
|
||||
);
|
||||
return (
|
||||
@@ -665,7 +710,13 @@ function ExecView({
|
||||
) : g.type === "answer" ? (
|
||||
<AnswerBlock key={"a" + g.key} step={g.step} getDetail={getDetail} />
|
||||
) : g.type === "tool" ? (
|
||||
<ToolBlock key={"t" + g.key} group={g} getDetail={getDetail} showWorker={showWorker} />
|
||||
<ToolBlock
|
||||
key={"t" + g.key}
|
||||
group={g}
|
||||
getDetail={getDetail}
|
||||
showWorker={showWorker}
|
||||
focused={focusedSeq != null && g.use?.seq === focusedSeq}
|
||||
/>
|
||||
) : g.type === "intercept" ? (
|
||||
<InterceptCard key={"ic" + g.key} step={g.step} getDetail={getDetail} />
|
||||
) : (
|
||||
@@ -685,16 +736,30 @@ export function Transcript({
|
||||
taskId,
|
||||
chat,
|
||||
fetchDetail,
|
||||
focusedSeq,
|
||||
}: {
|
||||
activity: Activity[];
|
||||
live?: boolean;
|
||||
taskId?: string;
|
||||
chat?: boolean;
|
||||
fetchDetail?: (seq: number) => Promise<string>;
|
||||
focusedSeq?: number;
|
||||
}) {
|
||||
const transcriptRef = React.useRef<HTMLDivElement>(null);
|
||||
const [focusPadding, setFocusPadding] = React.useState(0);
|
||||
React.useLayoutEffect(() => {
|
||||
if (focusedSeq == null) { setFocusPadding(0); return; }
|
||||
const viewport = transcriptRef.current?.closest('[data-slot="scroll-area-viewport"]');
|
||||
if (!viewport) return;
|
||||
const measure = () => setFocusPadding(viewport.clientHeight / 2);
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(viewport);
|
||||
return () => observer.disconnect();
|
||||
}, [focusedSeq]);
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<ExecView activity={activity} taskId={taskId} chat={chat} fetchDetail={fetchDetail} />
|
||||
<div ref={transcriptRef} className="flex flex-col gap-1" style={focusPadding ? { paddingBlock: focusPadding } : undefined}>
|
||||
<ExecView activity={activity} taskId={taskId} chat={chat} fetchDetail={fetchDetail} focusedSeq={focusedSeq} />
|
||||
{live && (
|
||||
<div className="flex items-center gap-2 pl-2 pt-1 text-xs text-muted-foreground">
|
||||
<span className="flex gap-1">
|
||||
|
||||
@@ -1061,6 +1061,8 @@ export const api = {
|
||||
interceptGetOne: (id: number) => get<InterceptPending>(`/intercept/pending/${id}`),
|
||||
interceptDecide: (id: number, decision: "allowed" | "denied") =>
|
||||
post<{ ok: boolean }>(`/intercept/pending/${id}/decide`, { decision }),
|
||||
interceptExecution: (id: number, conversationId?: number) =>
|
||||
get<import("@/lib/types").InterceptExecution>(`/intercept/history/${id}/execution${conversationId ? `?conversation=${conversationId}` : ""}`),
|
||||
interceptDetail: (id: number) => get<InterceptDetail>(`/intercept/history/${id}`),
|
||||
interceptHistory: () => get<{ items: InterceptApprovalRow[] }>("/intercept/history").then((r) => arr(r.items)),
|
||||
interceptHistoryPage: (page = 1, size = 20) =>
|
||||
|
||||
+19
-2
@@ -1283,6 +1283,13 @@ export interface LLMTask {
|
||||
// The exact JSON sent to the review model, retained for all model verdicts.
|
||||
export interface InterceptReviewInput {
|
||||
version: number;
|
||||
background?: {
|
||||
// worker_summary is retained only for immutable v2/v3 snapshots.
|
||||
source: "user_message" | "worker_summary";
|
||||
text: string;
|
||||
truncated?: boolean;
|
||||
};
|
||||
// Version 1 snapshots are immutable and remain readable in historical audits.
|
||||
task?: {
|
||||
task_id: number;
|
||||
description: string;
|
||||
@@ -1294,7 +1301,8 @@ export interface InterceptReviewInput {
|
||||
worker_intent?: string;
|
||||
turn_input?: string;
|
||||
background_truncated?: boolean;
|
||||
history: {
|
||||
// Legacy v1/v2 snapshots only; v3 never sends execution history.
|
||||
history?: {
|
||||
tool_use_id: string;
|
||||
tool: string;
|
||||
arguments_preview: string;
|
||||
@@ -1303,7 +1311,7 @@ export interface InterceptReviewInput {
|
||||
truncated?: boolean;
|
||||
}[];
|
||||
history_truncated?: boolean;
|
||||
correlation: "exact" | "ambiguous" | "unavailable";
|
||||
correlation?: "exact" | "ambiguous" | "unavailable";
|
||||
tool_name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
}
|
||||
@@ -1431,3 +1439,12 @@ export interface UpdateProgress {
|
||||
version?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Original execution selected from an approval, never submitted to the reviewer.
|
||||
export interface InterceptExecution {
|
||||
conversation_id: number | null;
|
||||
task_id: string | null;
|
||||
session: string;
|
||||
seq: number;
|
||||
items: Activity[];
|
||||
}
|
||||
Reference in new issue
Block a user