2acbd0b509
- 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>
432 lines
15 KiB
TypeScript
432 lines
15 KiB
TypeScript
"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 sessionId(newTask)用 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;
|
||
}
|