This commit is contained in:
hupenglong1
2026-05-20 15:04:19 +08:00
parent c4b935692c
commit 1a94cec822
27 changed files with 4831 additions and 1693 deletions
@@ -0,0 +1,26 @@
import { getCurrentAccount } from "@/lib/claw-auth";
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
export async function GET(request: Request) {
const account = await getCurrentAccount();
if (!account) return Response.json({ error: "unauthorized" }, { status: 401 });
const incoming = new URL(request.url);
const sessionId = incoming.searchParams.get("session_id") ?? "";
const url = new URL(`${CLAW_API_URL}/api/training/pipeline`);
url.searchParams.set("account_id", account.id);
if (sessionId) url.searchParams.set("session_id", sessionId);
const response = await fetch(url, { cache: "no-store" });
const payload = await response.text();
return new Response(payload, {
status: response.status,
headers: {
"content-type":
response.headers.get("content-type") ?? "application/json",
"cache-control": "no-store",
},
});
}
@@ -0,0 +1,38 @@
import { getCurrentAccount } from "@/lib/claw-auth";
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const account = await getCurrentAccount();
if (!account) {
return new Response("unauthorized", { status: 401 });
}
const incoming = new URL(request.url);
const sessionId = incoming.searchParams.get("session_id") ?? "";
const url = new URL(`${CLAW_API_URL}/api/training/pipeline/stream`);
url.searchParams.set("account_id", account.id);
if (sessionId) url.searchParams.set("session_id", sessionId);
const upstream = await fetch(url, {
cache: "no-store",
signal: request.signal,
});
if (!upstream.body) {
return new Response("upstream has no body", { status: 502 });
}
return new Response(upstream.body, {
status: upstream.status,
headers: {
"content-type": "text/event-stream",
"cache-control": "no-store",
connection: "keep-alive",
"x-accel-buffering": "no",
},
});
}
@@ -0,0 +1,34 @@
import { getCurrentAccount } from "@/lib/claw-auth";
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
export async function GET(request: Request) {
const account = await getCurrentAccount();
if (!account) return Response.json({ error: "unauthorized" }, { status: 401 });
const incoming = new URL(request.url);
const sessionId = incoming.searchParams.get("session_id") ?? "";
const step = incoming.searchParams.get("step") ?? "";
if (!sessionId || !step) {
return Response.json(
{ error: "session_id and step are required" },
{ status: 400 },
);
}
const url = new URL(`${CLAW_API_URL}/api/training/step-detail`);
url.searchParams.set("account_id", account.id);
url.searchParams.set("session_id", sessionId);
url.searchParams.set("step", step);
const response = await fetch(url, { cache: "no-store" });
const payload = await response.text();
return new Response(payload, {
status: response.status,
headers: {
"content-type":
response.headers.get("content-type") ?? "application/json",
"cache-control": "no-store",
},
});
}
+197 -8
View File
@@ -1,16 +1,18 @@
"use client";
import type { ExportedMessageRepository } from "@assistant-ui/core";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { AssistantRuntimeProvider, useAuiState } from "@assistant-ui/react";
import {
AssistantChatTransport,
useChatRuntime,
} from "@assistant-ui/react-ai-sdk";
import type { UIMessage } from "ai";
import { useCallback, useEffect, useMemo, useRef } from "react";
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 {
@@ -18,18 +20,26 @@ import {
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 = {
@@ -153,12 +163,7 @@ export const Assistant = ({ initialSessionId }: AssistantProps = {}) => {
onLogout={() => setAccount(null)}
/>
<SidebarInset>
<div className="flex min-h-0 flex-1 overflow-hidden">
<div className="min-w-0 flex-1 overflow-hidden">
<Thread />
</div>
<ActivityPanel />
</div>
<AssistantWorkspace />
</SidebarInset>
</div>
</SidebarProvider>
@@ -168,6 +173,190 @@ export const Assistant = ({ initialSessionId }: AssistantProps = {}) => {
);
};
// 用户在对话里表达「我要开始/进行模型训练(这一轮迭代)」时命中的关键短语。
// 要求动词或专属触发词必须存在;避免 "什么是模型训练" 之类无意义匹配。
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;
+80
View File
@@ -51,6 +51,86 @@
background-position: -100% 0;
}
}
--animate-pipeline-running: pipeline-running-glow 1800ms ease-in-out infinite;
@keyframes pipeline-running-glow {
0%,
100% {
box-shadow:
0 0 0 1px rgb(52 211 153 / 0.45),
0 0 0 0 rgb(52 211 153 / 0);
border-color: rgb(52 211 153 / 0.6);
}
50% {
box-shadow:
0 0 0 1px rgb(52 211 153 / 0.95),
0 0 18px 2px rgb(52 211 153 / 0.4);
border-color: rgb(52 211 153 / 1);
}
}
--animate-pipeline-running-blue: pipeline-running-glow-blue 1800ms ease-in-out
infinite;
@keyframes pipeline-running-glow-blue {
0%,
100% {
box-shadow:
0 0 0 1px rgb(56 189 248 / 0.45),
0 0 0 0 rgb(56 189 248 / 0);
border-color: rgb(56 189 248 / 0.7);
}
50% {
box-shadow:
0 0 0 1px rgb(56 189 248 / 0.95),
0 0 18px 2px rgb(56 189 248 / 0.45);
border-color: rgb(56 189 248 / 1);
}
}
--animate-pipeline-progress-shimmer: pipeline-progress-shimmer 1400ms linear
infinite;
@keyframes pipeline-progress-shimmer {
from {
transform: translateX(-100%);
}
to {
transform: translateX(250%);
}
}
--animate-pipeline-pill-glow: pipeline-pill-glow 1800ms ease-in-out infinite;
@keyframes pipeline-pill-glow {
0%,
100% {
box-shadow:
0 0 0 1px rgb(52 211 153 / 0.3),
0 0 0 0 rgb(52 211 153 / 0);
border-color: rgb(52 211 153 / 0.45);
}
50% {
box-shadow:
0 0 0 1px rgb(52 211 153 / 0.7),
0 0 10px 2px rgb(52 211 153 / 0.35);
border-color: rgb(52 211 153 / 0.85);
}
}
--animate-pipeline-dot-ping: pipeline-dot-ping 1400ms ease-out infinite;
@keyframes pipeline-dot-ping {
0% {
transform: scale(0.6);
opacity: 0.9;
}
80%,
100% {
transform: scale(2.2);
opacity: 0;
}
}
--animate-icon-spin: icon-spin 3200ms linear infinite;
@keyframes icon-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
}
.doc-page-v2 {