Files
zk-data-agent/frontend/app/app/assistant.tsx
T
hupenglong1 2acbd0b509 fix: eliminate training panel flash on page load
- Don't restore applied state from localStorage on mount (was causing
  first-frame flash when skill was already enabled)
- Always start with applied=false; only set true on user click

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 17:41:35 +08:00

432 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import type { ExportedMessageRepository } from "@assistant-ui/core";
import {
AssistantRuntimeProvider,
useAui,
} from "@assistant-ui/react";
import {
AssistantChatTransport,
useChatRuntime,
} from "@assistant-ui/react-ai-sdk";
import type { UIMessage } from "ai";
import { PanelRightOpenIcon, XIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActivityPanel,
ActivityProvider,
useActivityPanel,
} from "@/components/assistant-ui/activity-panel";
import { Thread } from "@/components/assistant-ui/thread";
import {
fetchLatestRunStatus,
toReplayRepository,
} from "@/components/assistant-ui/thread-list";
import { ThreadListSidebar } from "@/components/assistant-ui/threadlist-sidebar";
import { TrainingPipelinePanel } from "@/components/training/training-pipeline-panel";
import { Button } from "@/components/ui/button";
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import {
ACTIVE_SESSION_CHANGED_EVENT,
consumeFreshLocalId,
readActiveSessionId,
readPendingWorkspaceSessionId,
writeActiveSessionId,
} from "@/lib/claw-active-session";
import { ClawSessionReplayProvider } from "@/lib/claw-session-replay";
import { pushSessionUrl } from "@/lib/claw-session-url";
import { cn } from "@/lib/utils";
import {
useAppliedTraining,
useModelIterationEnabled,
} from "@/lib/use-training-mode";
import { ClawAccountGate, useClawAccount } from "./claw-account-gate";
type AssistantProps = {
initialSessionId?: string;
};
export const Assistant = ({ initialSessionId }: AssistantProps = {}) => {
const { account, isLoading, setAccount } = useClawAccount();
const loadedInitialSessionRef = useRef<string | null>(null);
const transport = useMemo(
() =>
new AssistantChatTransport({
api: "/api/chat",
prepareSendMessagesRequest: async (options) => {
const body = options.body as Record<string, unknown>;
const messages = options.messages as UIMessage[];
const selectedSessionId = readActiveSessionId();
const lastSessionId = getLastSessionId(messages);
const pendingWorkspaceSessionId =
messages.length <= 1 ? readPendingWorkspaceSessionId() : null;
const selectedResumeSessionId =
messages.length > 1 ? selectedSessionId : null;
const outgoingSessionId =
lastSessionId ??
selectedResumeSessionId ??
pendingWorkspaceSessionId ??
options.id;
const lastUserMessage = [...messages]
.reverse()
.find((message) => message.role === "user");
writeActiveSessionId(outgoingSessionId);
pushSessionUrl(outgoingSessionId);
return {
body: {
...body,
id: outgoingSessionId,
messages: lastUserMessage ? [lastUserMessage] : [],
resumeSessionId:
lastSessionId ?? selectedResumeSessionId ?? undefined,
},
};
},
}),
[],
);
const runtime = useChatRuntime({
transport,
});
const replaySession = useCallback(
(sessionId: string, repository: ExportedMessageRepository) => {
console.log("[replay-session] called", { sessionId });
writeActiveSessionId(sessionId);
pushSessionUrl(sessionId);
// 后端已经落盘/结束后,用回放结果接管当前会话。
// 先取消本地仍挂起的 assistant-ui run,避免 UI 残留“运行中”且无法停止。
if (runtime.thread.getState().isRunning) {
runtime.thread.cancelRun();
}
runtime.thread.import(repository);
},
[runtime],
);
useEffect(() => {
const sessionId = initialSessionId?.trim();
if (!account || !sessionId) return;
if (loadedInitialSessionRef.current === sessionId) return;
loadedInitialSessionRef.current = sessionId;
const targetSessionId = sessionId;
let cancelled = false;
async function loadSession() {
try {
writeActiveSessionId(targetSessionId);
const response = await fetch(`/api/claw/sessions/${targetSessionId}`, {
cache: "no-store",
});
if (!response.ok || cancelled) return;
const payload = await response.json();
const runStatus = await fetchLatestRunStatus(targetSessionId);
if (cancelled) return;
replaySession(
targetSessionId,
toReplayRepository(payload, targetSessionId, runStatus),
);
pushSessionUrl(targetSessionId);
} catch {
if (!cancelled) {
loadedInitialSessionRef.current = null;
}
}
}
void loadSession();
return () => {
cancelled = true;
};
}, [account, initialSessionId, replaySession]);
if (isLoading) {
return (
<div className="flex min-h-dvh items-center justify-center bg-background text-muted-foreground text-sm">
...
</div>
);
}
if (!account) {
return <ClawAccountGate onAccount={setAccount} />;
}
return (
<AssistantRuntimeProvider runtime={runtime}>
<ClawSessionReplayProvider value={{ replaySession }}>
<ActivityProvider>
<SidebarProvider>
<div className="flex h-dvh w-full">
<SidebarTrigger className="fixed top-3 left-3 z-40 size-9 rounded-lg bg-background/90 shadow-sm ring-1 ring-border backdrop-blur sm:hidden" />
<ThreadListSidebar
account={account}
onLogout={() => setAccount(null)}
/>
<SidebarInset>
<AssistantWorkspace />
</SidebarInset>
</div>
</SidebarProvider>
</ActivityProvider>
</ClawSessionReplayProvider>
</AssistantRuntimeProvider>
);
};
// 共享 composer 草稿缓存里 newTask(无 sessionId)使用的 key。
const NEWTASK_KEY = "__newtask__";
// AssistantWorkspace cache effect 通知 ImeComposerInput 直接更新 textarea
// 内容(绕过 store→local 间接路径,避免 switchToNewThread 时序问题)。
export const COMPOSER_RESTORE_EVENT = "claw-composer-restore-draft";
// __LOCALID_xxx 是侧栏在 newTask 状态下点工作区时临时分配的 placeholder
// session id(让 jupyter bind 有 id 可传)。每个 LOCALID 拥有独立的 draft
// key,避免多个 LOCALID 会话在 cache 里互相覆盖。newTask → 首次生成 LOCALID
// 这一瞬的 textarea 闪空,由下面 save/restore effect 里的一次性继承处理。
function getDraftKey(activeSessionId: string | null): string {
if (!activeSessionId) return NEWTASK_KEY;
return activeSessionId;
}
function AssistantWorkspace() {
const aui = useAui();
const [activeSessionId, setActiveSessionId] = useState<string | null>(() =>
readActiveSessionId(),
);
const skill = useModelIterationEnabled();
const { applied, setApplied } = useAppliedTraining(activeSessionId);
const { close: closeActivityPanel } = useActivityPanel();
const showPipeline = skill.enabled && applied;
const pipelineContainerRef = useRef<HTMLDivElement>(null);
const lastPipelineYRef = useRef<number | null>(null);
const [exitButtonVisible, setExitButtonVisible] = useState(false);
// Composer 是 per-thread 的,但本应用所有 backend session 都共用同一个
// assistant-ui thread(从不调 switchToThread),因此 composer.text 跨 session
// 共享。在 activeSessionId 切换时手动 save+restore,给每个 session 一份
// 独立的 draft。null sessionIdnewTask)用 NEWTASK_KEY 落盘。
const composerCacheRef = useRef<Map<string, string>>(new Map());
const prevSessionKeyRef = useRef<string>(getDraftKey(activeSessionId));
const handlePipelineMouseMove = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
const node = pipelineContainerRef.current;
if (!node) return;
const rect = node.getBoundingClientRect();
const relativeY = event.clientY - rect.top;
const inTopThird = relativeY >= 0 && relativeY <= rect.height / 3;
const lastY = lastPipelineYRef.current;
const movingUp = lastY !== null && event.clientY < lastY;
const movingDown = lastY !== null && event.clientY > lastY;
lastPipelineYRef.current = event.clientY;
setExitButtonVisible((prev) => {
if (!inTopThird) return false;
if (movingUp) return true;
if (movingDown) return false;
return prev;
});
},
[],
);
const handlePipelineMouseLeave = useCallback(() => {
lastPipelineYRef.current = null;
setExitButtonVisible(false);
}, []);
useEffect(() => {
if (!showPipeline) {
lastPipelineYRef.current = null;
setExitButtonVisible(false);
}
}, [showPipeline]);
useEffect(() => {
const update = () => {
const next = readActiveSessionId();
console.log("[active-session] focus-update", { next });
setActiveSessionId(next);
};
const handleChanged = (event: Event) => {
const detail = (event as CustomEvent<{ sessionId?: string | null }>)
.detail;
const next = detail?.sessionId ?? readActiveSessionId();
console.log("[active-session] event-changed", {
detailSessionId: detail?.sessionId,
next,
});
setActiveSessionId(next);
};
window.addEventListener(ACTIVE_SESSION_CHANGED_EVENT, handleChanged);
window.addEventListener("focus", update);
update();
return () => {
window.removeEventListener(ACTIVE_SESSION_CHANGED_EVENT, handleChanged);
window.removeEventListener("focus", update);
};
}, []);
// 切 session 时保存当前 composer 草稿到旧 key、恢复新 key 的草稿。
useEffect(() => {
const cache = composerCacheRef.current;
const nextKey = getDraftKey(activeSessionId);
const prevKey = prevSessionKeyRef.current;
console.log("[draft-cache] effect-fire", {
prevKey,
nextKey,
activeSessionId,
willSkip: prevKey === nextKey,
});
if (prevKey === nextKey) return;
// 不能信任 aui store 的 composer.text——assistant-ui 内部 reducer 在
// 切换 / isEditing 翻转时会让 store 与 textarea 脱钩(典型现象:textarea
// 显示着 "hello" 但 store 已经是 ""),后果是这里把空串存进 cache、
// 用户的草稿被静默吞掉。直接读 DOM 拿 textarea 当前真实值。
// EditComposer 用的是 aui-edit-composer-input,不会撞 selector。
const composerEl = document.querySelector<HTMLTextAreaElement>(
"textarea.aui-composer-input",
);
const composerState = aui.composer().getState();
const savingText =
composerEl?.value ?? composerState.text ?? "";
cache.set(prevKey, savingText);
// newTask 里点工作区会**新生成**一个 LOCALID 并切过去,用户视角里
// 这是同一份草稿的延续——把 newTask 的文本继承到 LOCALID 名下,
// 避免 textarea 闪空。consumeFreshLocalId 只对前端刚生成的 LOCALID
// 命中,点击侧栏老 LOCALID 不命中,确保历史会话之间互不串。
const inheritFromNewTask =
prevKey === NEWTASK_KEY && consumeFreshLocalId(activeSessionId);
if (inheritFromNewTask) {
cache.set(nextKey, savingText);
}
const restored = cache.get(nextKey) ?? "";
console.log("[draft-cache] do-switch", {
prevKey,
nextKey,
savingText,
savingTextSource: composerEl ? "dom" : "store",
storeText: composerState.text,
restored,
inheritFromNewTask,
});
aui.composer().setText(restored);
window.dispatchEvent(
new CustomEvent(COMPOSER_RESTORE_EVENT, {
detail: { text: restored },
}),
);
console.log("[draft-cache] dispatched", { restored });
prevSessionKeyRef.current = nextKey;
}, [activeSessionId, aui]);
useEffect(() => {
closeActivityPanel();
}, [showPipeline, closeActivityPanel]);
// Skill 开关联动 applied:只有用户在本次页面会话中「主动点击」开启 skill
// 才展开面板。页面加载时 skill 已经是 enabled 的情况不自动展开(避免闪烁)。
// 判断依据:skill.loading 从 true→false 是初始加载完成,之后 enabled 变化
// 才是用户主动操作。
const skillLoadedOnce = useRef(false);
const prevSkillEnabled = useRef<boolean | null>(null);
useEffect(() => {
if (skill.loading) return;
if (!skillLoadedOnce.current) {
skillLoadedOnce.current = true;
prevSkillEnabled.current = skill.enabled;
// 初始加载:无条件清除 applied(清 localStorage 残留),面板只从用户点击打开
if (applied) {
setApplied(false);
}
return;
}
// 初始加载之后的变化 = 用户主动操作
if (skill.enabled && !prevSkillEnabled.current) {
setApplied(true);
} else if (!skill.enabled && prevSkillEnabled.current) {
setApplied(false);
}
prevSkillEnabled.current = skill.enabled;
}, [skill.enabled, skill.loading, applied, setApplied]);
// 注意:用同一棵树 + 条件渲染,保证 <Thread /> 和 <ActivityPanel /> 在
// pipeline 切换前后都保持挂载,否则它们会 remount,
// useJupyterWorkspaceStatus 的内部 state 会被清空,UI 上 Jupyter 工作区
// indicator 会闪一下变成"切换工作区"。
return (
<div className="flex min-h-0 flex-1 overflow-hidden">
{showPipeline ? (
<div
ref={pipelineContainerRef}
onMouseMove={handlePipelineMouseMove}
onMouseLeave={handlePipelineMouseLeave}
className="relative flex min-w-0 flex-1 flex-col"
>
<button
type="button"
onClick={() => setApplied(false)}
title="退出训练模式"
aria-label="退出训练模式"
className={cn(
"-translate-x-1/2 absolute top-0 left-1/2 z-20 inline-flex items-center gap-1 rounded-b-md border-x border-b border-border bg-background px-3 py-1 text-muted-foreground text-xs shadow-md ring-offset-background transition-transform duration-200 hover:bg-muted hover:text-foreground focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
exitButtonVisible ? "translate-y-0" : "-translate-y-full",
)}
>
<XIcon className="size-3.5" />
<span>退</span>
</button>
<TrainingPipelinePanel sessionId={activeSessionId} />
</div>
) : null}
<div
className={cn(
"flex flex-col overflow-hidden",
showPipeline ? "w-[28rem] shrink-0 border-l" : "min-w-0 flex-1",
)}
>
<div className="min-h-0 flex-1 overflow-hidden">
<Thread />
</div>
</div>
<ActivityPanel asDrawer={showPipeline} />
{showPipeline ? <ActivityDrawerTrigger /> : null}
</div>
);
}
function ActivityDrawerTrigger() {
const { open, openActivity } = useActivityPanel();
if (open) return null;
return (
<Button
type="button"
variant="outline"
size="icon"
onClick={openActivity}
className="fixed top-3 right-3 z-30 size-9 rounded-lg bg-background/90 shadow-sm backdrop-blur"
aria-label="打开活动面板"
>
<PanelRightOpenIcon className="size-4" />
</Button>
);
}
function getLastSessionId(messages: UIMessage[]) {
for (const message of [...messages].reverse()) {
if (message.role !== "assistant") continue;
const metadata = message.metadata;
if (metadata && typeof metadata === "object" && "sessionId" in metadata) {
const sessionId = (metadata as { sessionId?: unknown }).sessionId;
if (typeof sessionId === "string" && sessionId) return sessionId;
}
}
return undefined;
}