Files
zk-data-agent/frontend/app/app/assistant.tsx
T
hupenglong1 1a94cec822 修改
2026-05-20 15:04:19 +08:00

371 lines
12 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, 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;
}