This commit is contained in:
hupenglong1
2026-05-22 19:48:47 +08:00
parent 20690cdef3
commit 5311e6d97c
31 changed files with 4620 additions and 472 deletions
@@ -0,0 +1,20 @@
import { loginByEmail } from "@/lib/claw-auth";
export async function POST(req: Request) {
try {
const payload = (await req.json()) as {
email?: string;
password?: string;
};
const account = await loginByEmail(
payload.email ?? "",
payload.password ?? "",
);
return Response.json({ account });
} catch (err) {
return Response.json(
{ error: err instanceof Error ? err.message : "登录失败" },
{ status: 400 },
);
}
}
@@ -0,0 +1,20 @@
import { registerByEmail } from "@/lib/claw-auth";
export async function POST(req: Request) {
try {
const payload = (await req.json()) as {
email?: string;
password?: string;
};
const account = await registerByEmail(
payload.email ?? "",
payload.password ?? "",
);
return Response.json({ account });
} catch (err) {
return Response.json(
{ error: err instanceof Error ? err.message : "注册失败" },
{ status: 400 },
);
}
}
@@ -0,0 +1,49 @@
import { getCurrentAccount } from "@/lib/claw-auth";
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
type BindBody = {
session_id?: string;
};
export async function POST(
req: Request,
{ params }: { params: Promise<{ workspaceId: string }> },
) {
const account = await getCurrentAccount();
if (!account)
return Response.json({ error: "请先登录账号" }, { status: 401 });
const { workspaceId } = await params;
const payload = (await req.json()) as BindBody;
try {
const target = `${CLAW_API_URL}/api/jupyter/workspaces/${encodeURIComponent(workspaceId)}/bind`;
const response = await fetch(target, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
session_id: payload.session_id,
account_id: account.id,
}),
cache: "no-store",
});
const body = await response.text();
return new Response(body, {
status: response.status,
headers: {
"content-type":
response.headers.get("content-type") ?? "application/json",
},
});
} catch (err) {
return Response.json(
{
error:
err instanceof Error
? `Unable to reach Claw backend: ${err.message}`
: "Unable to reach Claw backend",
},
{ status: 502 },
);
}
}
@@ -0,0 +1,42 @@
import { getCurrentAccount } from "@/lib/claw-auth";
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ workspaceId: string }> },
) {
const account = await getCurrentAccount();
if (!account)
return Response.json({ error: "请先登录账号" }, { status: 401 });
const { workspaceId } = await params;
try {
const target = new URL(
`${CLAW_API_URL}/api/jupyter/workspaces/${encodeURIComponent(workspaceId)}`,
);
target.searchParams.set("account_id", account.id);
const response = await fetch(target, {
method: "DELETE",
cache: "no-store",
});
const body = await response.text();
return new Response(body, {
status: response.status,
headers: {
"content-type":
response.headers.get("content-type") ?? "application/json",
},
});
} catch (err) {
return Response.json(
{
error:
err instanceof Error
? `Unable to reach Claw backend: ${err.message}`
: "Unable to reach Claw backend",
},
{ status: 502 },
);
}
}
@@ -0,0 +1,33 @@
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() {
const account = await getCurrentAccount();
if (!account)
return Response.json({ error: "请先登录账号" }, { status: 401 });
try {
const target = new URL(`${CLAW_API_URL}/api/jupyter/workspaces`);
target.searchParams.set("account_id", account.id);
const response = await fetch(target, { cache: "no-store" });
const body = await response.text();
return new Response(body, {
status: response.status,
headers: {
"content-type":
response.headers.get("content-type") ?? "application/json",
},
});
} catch (err) {
return Response.json(
{
error:
err instanceof Error
? `Unable to reach Claw backend: ${err.message}`
: "Unable to reach Claw backend",
},
{ status: 502 },
);
}
}
+85 -19
View File
@@ -182,6 +182,8 @@ const APPLY_INTENT_PATTERNS: RegExp[] = [
/(?:进行|开始|启动|开启|执行|发起|做)\s*(?:模型)?\s*训练/i,
// 训练 + 目标集合
/训练[\s\S]{0,60}?(?:目标集合|需求集合|specific[\s_-]?test|集合)/i,
// 「(模型)训练,目标是 X」「训练,基模 X」「训练 ... csv」之类自然表达
/(?:^|[\s,。.!?])(?:模型)?训练[\s,][\s\S]{0,80}?(?:目标|基模|specific[\s_-]?test|\.csv)/i,
// 跟着 program.md 的旧表达保留
/(?:应用|加载|载入|启用|装载|使用|跑|按照?|根据|用)\s*\S*program\.?md/i,
/program\.?md[\s\S]{0,120}?(?:训练|应用|启用|跑起来|跑一下)/i,
@@ -204,6 +206,42 @@ function AssistantWorkspace() {
const showPipeline = skill.enabled && applied;
const messages = useAuiState((s) => s.thread.messages);
const lastProcessedUserMsgId = useRef<string | null>(null);
const pipelineContainerRef = useRef<HTMLDivElement>(null);
const lastPipelineYRef = useRef<number | null>(null);
const [exitButtonVisible, setExitButtonVisible] = useState(false);
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 = () => setActiveSessionId(readActiveSessionId());
@@ -235,31 +273,52 @@ function AssistantWorkspace() {
// 自动应用:skill 启用 + 当前 session 后端的 pipeline 已经有进展(任何
// 卡 running 或 complete)→ applied=true。这样关 tab 重开 / 刷新 / 切回
// 已在跑的会话都能自动恢复 monitor 面板,不依赖触发词。
// 30 秒轮询一次,覆盖「页面打开时 pipeline 还没起、之后才被外部脚本启动」
// 这类情形——一次性 fetch 容易错过。
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) => {
const tick = async () => {
try {
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) return;
const payload = await res.json();
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",
),
const items = Array.isArray(payload.items) ? payload.items : [];
const hasItemProgress = items.some(
(it: {
type?: string;
status?: string;
cards?: Array<{ status?: string }>;
}) => {
if (it.type === "gate") {
return it.status === "running" || it.status === "complete";
}
return (
Array.isArray(it.cards) &&
it.cards.some(
(c) =>
c.status === "running" || c.status === "complete",
)
);
},
);
if (hasProgress) setApplied(true);
})
.catch(() => {
// items[] 走 phase-complete-only 过滤;R0 只有一张卡在跑时
// items 是空的,进展信号在 in_flight 字段里——也算进展。
const hasInFlight = Boolean(payload.in_flight);
if (hasItemProgress || hasInFlight) setApplied(true);
} catch {
/* ignore */
});
}
};
tick();
const id = window.setInterval(tick, 30000);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, [skill.enabled, applied, activeSessionId, setApplied]);
@@ -293,15 +352,24 @@ function AssistantWorkspace() {
return (
<div className="flex min-h-0 flex-1 overflow-hidden">
{showPipeline ? (
<div className="relative flex min-w-0 flex-1 flex-col">
<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="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"
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>
@@ -309,9 +377,7 @@ function AssistantWorkspace() {
<div
className={cn(
"flex flex-col overflow-hidden",
showPipeline
? "w-[28rem] shrink-0 border-l"
: "min-w-0 flex-1",
showPipeline ? "w-[28rem] shrink-0 border-l" : "min-w-0 flex-1",
)}
>
<div className="min-h-0 flex-1 overflow-hidden">
+37 -11
View File
@@ -36,36 +36,59 @@ export function useClawAccount() {
return { account, isLoading, refresh, setAccount };
}
const XIAOMI_EMAIL_RE = /^[\w.-]+@xiaomi\.com$/i;
export function ClawAccountGate({
onAccount,
}: {
onAccount: (account: ClawAccount) => void;
}) {
const [mode, setMode] = useState<"login" | "register">("login");
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
async function submit() {
setError("");
const trimmed = email.trim();
if (!XIAOMI_EMAIL_RE.test(trimmed)) {
setError("请使用小米邮箱(@xiaomi.com)登录");
return;
}
if (password.length < 6) {
setError("密码至少 6 位");
return;
}
setIsSubmitting(true);
try {
const response = await fetch(`/api/claw/auth/${mode}`, {
const endpoint =
mode === "register"
? "/api/claw/auth/email-register"
: "/api/claw/auth/email-login";
const response = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ username, password }),
body: JSON.stringify({ email: trimmed, password }),
});
const payload = (await response.json()) as {
account?: ClawAccount;
error?: string;
};
if (!response.ok || !payload.account) {
throw new Error(payload.error ?? "账号操作失败");
throw new Error(
payload.error ?? (mode === "register" ? "注册失败" : "登录失败"),
);
}
onAccount(payload.account);
} catch (err) {
setError(err instanceof Error ? err.message : "账号操作失败");
setError(
err instanceof Error
? err.message
: mode === "register"
? "注册失败"
: "登录失败",
);
} finally {
setIsSubmitting(false);
}
@@ -77,22 +100,25 @@ export function ClawAccountGate({
<div className="mb-6">
<h1 className="font-semibold text-xl">ZK Data Agent</h1>
<p className="mt-1 text-muted-foreground text-sm">
{mode === "register"
? "创建账号:使用小米邮箱注册,前缀作为 autoresearch 工作根目录。"
: "使用小米邮箱登录。会话/上传/产物按邮箱前缀和 chat 二级隔离。"}
</p>
</div>
<div className="grid gap-3">
<Input
autoFocus
placeholder="账号"
value={username}
onChange={(event) => setUsername(event.target.value)}
type="email"
placeholder="your_name@xiaomi.com"
value={email}
onChange={(event) => setEmail(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") submit();
}}
/>
<Input
type="password"
placeholder="密码"
placeholder={mode === "register" ? "设置密码(≥6 位)" : "密码"}
value={password}
onChange={(event) => setPassword(event.target.value)}
onKeyDown={(event) => {
@@ -101,7 +127,7 @@ export function ClawAccountGate({
/>
{error ? <p className="text-destructive text-sm">{error}</p> : null}
<Button onClick={submit} disabled={isSubmitting}>
{isSubmitting ? "处理中..." : mode === "login" ? "登录" : "注册"}
{isSubmitting ? "处理中..." : mode === "register" ? "注册" : "登录"}
</Button>
<button
type="button"
+6 -6
View File
@@ -100,15 +100,15 @@
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);
0 0 0 1px rgb(56 189 248 / 0.3),
0 0 0 0 rgb(56 189 248 / 0);
border-color: rgb(56 189 248 / 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);
0 0 0 1px rgb(56 189 248 / 0.7),
0 0 10px 2px rgb(56 189 248 / 0.35);
border-color: rgb(56 189 248 / 0.85);
}
}
--animate-pipeline-dot-ping: pipeline-dot-ping 1400ms ease-out infinite;