371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import type { ExportedMessageRepository } from "@assistant-ui/core";
|
||
import { AssistantRuntimeProvider, useAuiState } 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,
|
||
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) => {
|
||
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>
|
||
);
|
||
};
|
||
|
||
// 用户在对话里表达「我要开始/进行模型训练(这一轮迭代)」时命中的关键短语。
|
||
// 要求动词或专属触发词必须存在;避免 "什么是模型训练" 之类无意义匹配。
|
||
const APPLY_INTENT_PATTERNS: RegExp[] = [
|
||
// 触发短语:开始,xxx / 开始, xxx
|
||
/(?:^|\s|[,,。.!?])开始\s*[,,]\s*\S+/,
|
||
// 单纯训练动作:进行/开始/启动/开启/执行/发起/做 + (模型) + 训练
|
||
/(?:进行|开始|启动|开启|执行|发起|做)\s*(?:模型)?\s*训练/i,
|
||
// 训练 + 目标集合
|
||
/训练[\s\S]{0,60}?(?:目标集合|需求集合|specific[\s_-]?test|集合)/i,
|
||
// 跟着 program.md 的旧表达保留
|
||
/(?:应用|加载|载入|启用|装载|使用|跑|按照?|根据|用)\s*\S*program\.?md/i,
|
||
/program\.?md[\s\S]{0,120}?(?:训练|应用|启用|跑起来|跑一下)/i,
|
||
/apply\s+program(?:\.md)?/i,
|
||
/load\s+program(?:\.md)?/i,
|
||
/(?:start|begin|run)\s+(?:the\s+)?training/i,
|
||
];
|
||
|
||
function detectApplyIntent(text: string): boolean {
|
||
return APPLY_INTENT_PATTERNS.some((re) => re.test(text));
|
||
}
|
||
|
||
function AssistantWorkspace() {
|
||
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 messages = useAuiState((s) => s.thread.messages);
|
||
const lastProcessedUserMsgId = 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(() => {
|
||
closeActivityPanel();
|
||
}, [showPipeline, closeActivityPanel]);
|
||
|
||
// Skill 关掉时撤销 applied,避免重新启用就立刻弹开。
|
||
useEffect(() => {
|
||
if (!skill.enabled && applied) {
|
||
setApplied(false);
|
||
}
|
||
}, [skill.enabled, applied, setApplied]);
|
||
|
||
// 自动应用:skill 启用 + 当前 session 后端的 pipeline 已经有进展(任何
|
||
// 卡 running 或 complete)→ applied=true。这样关 tab 重开 / 刷新 / 切回
|
||
// 已在跑的会话都能自动恢复 monitor 面板,不依赖触发词。
|
||
useEffect(() => {
|
||
if (!skill.enabled) return;
|
||
if (applied) return;
|
||
if (!activeSessionId) return;
|
||
let cancelled = false;
|
||
const url = `/api/claw/training/pipeline?session_id=${encodeURIComponent(activeSessionId)}`;
|
||
fetch(url, { cache: "no-store" })
|
||
.then((res) => (res.ok ? res.json() : null))
|
||
.then((payload) => {
|
||
if (cancelled || !payload) return;
|
||
const phases = Array.isArray(payload.phases) ? payload.phases : [];
|
||
const hasProgress = phases.some(
|
||
(ph: { cards?: Array<{ status?: string }> }) =>
|
||
Array.isArray(ph.cards) &&
|
||
ph.cards.some(
|
||
(c) => c.status === "running" || c.status === "complete",
|
||
),
|
||
);
|
||
if (hasProgress) setApplied(true);
|
||
})
|
||
.catch(() => {
|
||
/* ignore */
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [skill.enabled, applied, activeSessionId, setApplied]);
|
||
|
||
// 监听用户消息:skill 启用 + 检测到训练触发短语 → applied = true。
|
||
useEffect(() => {
|
||
if (!skill.enabled) return;
|
||
if (applied) return;
|
||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||
// 找最新一条 user 消息
|
||
let latestUser: (typeof messages)[number] | null = null;
|
||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||
const m = messages[i];
|
||
if (m.role === "user") {
|
||
latestUser = m;
|
||
break;
|
||
}
|
||
}
|
||
if (!latestUser) return;
|
||
if (lastProcessedUserMsgId.current === latestUser.id) return;
|
||
lastProcessedUserMsgId.current = latestUser.id;
|
||
const text = collectUserMessageText(latestUser);
|
||
if (text && detectApplyIntent(text)) {
|
||
setApplied(true);
|
||
}
|
||
}, [messages, skill.enabled, applied, setApplied]);
|
||
|
||
// 注意:用同一棵树 + 条件渲染,保证 <Thread /> 和 <ActivityPanel /> 在
|
||
// pipeline 切换前后都保持挂载,否则它们会 remount,
|
||
// useJupyterWorkspaceStatus 的内部 state 会被清空,UI 上 Jupyter 工作区
|
||
// indicator 会闪一下变成"切换工作区"。
|
||
return (
|
||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||
{showPipeline ? (
|
||
<div className="relative flex min-w-0 flex-1 flex-col">
|
||
<button
|
||
type="button"
|
||
onClick={() => setApplied(false)}
|
||
title="退出训练模式"
|
||
aria-label="退出训练模式"
|
||
className="absolute top-3 left-3 z-20 inline-flex size-7 items-center justify-center rounded-full border bg-background/90 text-muted-foreground shadow-sm backdrop-blur transition-colors hover:bg-muted"
|
||
>
|
||
<XIcon className="size-3.5" />
|
||
</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 collectUserMessageText(message: { content?: unknown }): string {
|
||
const content = message.content;
|
||
if (typeof content === "string") return content;
|
||
if (!Array.isArray(content)) return "";
|
||
const parts: string[] = [];
|
||
for (const part of content) {
|
||
if (typeof part === "string") {
|
||
parts.push(part);
|
||
} else if (part && typeof part === "object") {
|
||
const text = (part as { text?: unknown }).text;
|
||
if (typeof text === "string") parts.push(text);
|
||
}
|
||
}
|
||
return parts.join("\n");
|
||
}
|
||
|
||
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;
|
||
}
|