Merge branch 'wsh_dev' into 'main'
model-iteration: runDic 单一可信源 + label-master 逐条调用 See merge request wuyang6/zk-data-agent!1
This commit is contained in:
@@ -21,6 +21,7 @@ archive/
|
||||
scratchpad/
|
||||
tasks/
|
||||
router_session_parquet/
|
||||
.logs/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
INFO: Started server process [2637646]
|
||||
INFO: Waiting for application startup.
|
||||
INFO: Application startup complete.
|
||||
ERROR: [Errno 98] error while attempting to bind on address ('127.0.0.1', 8975): address already in use
|
||||
INFO: Waiting for application shutdown.
|
||||
INFO: Application shutdown complete.
|
||||
Claw Code GUI listening on http://127.0.0.1:8975
|
||||
cwd : /home/mi/zk-data-agent-wsh
|
||||
model : xiaomi/mimo-v2-flash
|
||||
base-url : http://model.mify.ai.srv/v1
|
||||
timeout : 3600s
|
||||
sessions : /home/mi/zk-data-agent-wsh/.port_sessions/agent
|
||||
shell : on
|
||||
write : on
|
||||
+3733
-13
File diff suppressed because it is too large
Load Diff
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,36 @@
|
||||
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") ?? "";
|
||||
const runId = incoming.searchParams.get("run_id");
|
||||
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);
|
||||
if (runId) url.searchParams.set("run_id", runId);
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,16 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import type { ExportedMessageRepository } from "@assistant-ui/core";
|
||||
import { AssistantRuntimeProvider } from "@assistant-ui/react";
|
||||
import {
|
||||
AssistantRuntimeProvider,
|
||||
useAui,
|
||||
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 +24,27 @@ 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,
|
||||
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 = {
|
||||
@@ -81,6 +96,7 @@ export const Assistant = ({ initialSessionId }: AssistantProps = {}) => {
|
||||
});
|
||||
const replaySession = useCallback(
|
||||
(sessionId: string, repository: ExportedMessageRepository) => {
|
||||
console.log("[replay-session] called", { sessionId });
|
||||
writeActiveSessionId(sessionId);
|
||||
pushSessionUrl(sessionId);
|
||||
// 后端已经落盘/结束后,用回放结果接管当前会话。
|
||||
@@ -153,12 +169,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 +179,363 @@ export const Assistant = ({ initialSessionId }: AssistantProps = {}) => {
|
||||
);
|
||||
};
|
||||
|
||||
// 共享 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;
|
||||
}
|
||||
|
||||
// 用户在对话里表达「我要开始/进行模型训练(这一轮迭代)」时命中的关键短语。
|
||||
// 要求动词或专属触发词必须存在;避免 "什么是模型训练" 之类无意义匹配。
|
||||
const APPLY_INTENT_PATTERNS: RegExp[] = [
|
||||
// 触发短语:开始,xxx / 开始, xxx
|
||||
/(?:^|\s|[,,。.!?])开始\s*[,,]\s*\S+/,
|
||||
// 单纯训练动作:进行/开始/启动/开启/执行/发起/做 + (模型) + 训练
|
||||
/(?:进行|开始|启动|开启|执行|发起|做)\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,
|
||||
/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 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 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);
|
||||
// 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,避免重新启用就立刻弹开。
|
||||
useEffect(() => {
|
||||
if (!skill.enabled && applied) {
|
||||
setApplied(false);
|
||||
}
|
||||
}, [skill.enabled, applied, setApplied]);
|
||||
|
||||
// 自动应用: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 pipelineUrl = `/api/claw/training/pipeline?session_id=${encodeURIComponent(activeSessionId)}`;
|
||||
const runUrl = `/api/claw/runs/latest?session_id=${encodeURIComponent(activeSessionId)}`;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const res = await fetch(pipelineUrl, { cache: "no-store" });
|
||||
if (res.ok) {
|
||||
const payload = await res.json();
|
||||
if (cancelled) return;
|
||||
if (payload) {
|
||||
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",
|
||||
)
|
||||
);
|
||||
},
|
||||
);
|
||||
// items[] 走 phase-complete-only 过滤;R0 只有一张卡在跑时
|
||||
// items 是空的,进展信号在 in_flight 字段里——也算进展。
|
||||
const hasInFlight = Boolean(payload.in_flight);
|
||||
if (hasItemProgress || hasInFlight) {
|
||||
setApplied(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore, fall through to runs/latest fallback */
|
||||
}
|
||||
// Pipeline 还没写出 items/in_flight(agent 处于 prep 阶段,比如在 watcher
|
||||
// 里 sleep 等远端文件)时,pipeline 端点会返回 state=pending、items=[],
|
||||
// 但后端 run 其实已经在跑。直接看 run 状态兜底,免得用户起了训练但
|
||||
// monitor 面板等到第一个 phase 才弹。
|
||||
try {
|
||||
const res = await fetch(runUrl, { cache: "no-store" });
|
||||
if (!res.ok) return;
|
||||
const payload = await res.json();
|
||||
if (cancelled || !payload) return;
|
||||
if (payload.status === "running" || payload.status === "queued") {
|
||||
setApplied(true);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, 30000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [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
|
||||
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 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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -51,6 +52,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(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(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;
|
||||
@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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.project-doc-page {
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import {
|
||||
ACTIVE_SESSION_CHANGED_EVENT,
|
||||
readActiveSessionId,
|
||||
@@ -140,7 +141,7 @@ type ActivityItem = {
|
||||
|
||||
type LiveRunEvent = NonNullable<ClawRunStatus["events"]>[number];
|
||||
|
||||
export function ActivityPanel() {
|
||||
export function ActivityPanel({ asDrawer = false }: { asDrawer?: boolean } = {}) {
|
||||
const {
|
||||
open,
|
||||
mode,
|
||||
@@ -211,6 +212,7 @@ export function ActivityPanel() {
|
||||
useEffect(() => {
|
||||
const latestId = items.at(-1)?.id ?? null;
|
||||
if (
|
||||
!asDrawer &&
|
||||
mode === "activity" &&
|
||||
!selectedId &&
|
||||
latestId &&
|
||||
@@ -219,22 +221,14 @@ export function ActivityPanel() {
|
||||
openActivity();
|
||||
}
|
||||
prevLatestIdRef.current = latestId;
|
||||
}, [items, mode, openActivity, selectedId]);
|
||||
}, [asDrawer, items, mode, openActivity, selectedId]);
|
||||
|
||||
if (!open) return null;
|
||||
if (!asDrawer && !open) return null;
|
||||
|
||||
if (mode === "files") {
|
||||
return (
|
||||
<SessionFilesPanel
|
||||
key={`${sessionId ?? "active"}:${filesRefreshToken}`}
|
||||
sessionId={sessionId}
|
||||
onClose={close}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const isFilesMode = mode === "files";
|
||||
|
||||
return (
|
||||
<aside className="fixed inset-y-0 right-0 z-30 flex w-[min(23rem,calc(100vw-1rem))] flex-col border-l bg-background shadow-xl lg:static lg:z-auto lg:h-full lg:w-92 lg:shrink-0 lg:shadow-none">
|
||||
const activityBody = (
|
||||
<>
|
||||
<header className="flex h-16 shrink-0 items-center justify-between border-b px-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-semibold text-base">活动</h2>
|
||||
@@ -244,6 +238,7 @@ export function ActivityPanel() {
|
||||
: "暂无链路"}
|
||||
</p>
|
||||
</div>
|
||||
{!asDrawer ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -254,6 +249,7 @@ export function ActivityPanel() {
|
||||
>
|
||||
<PanelRightCloseIcon className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||
{visibleItems.length === 0 ? (
|
||||
@@ -274,12 +270,59 @@ export function ActivityPanel() {
|
||||
open={selectedId === item.id}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (nextOpen) openItem(item.id);
|
||||
else if (selectedId === item.id) openActivity();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (asDrawer) {
|
||||
return (
|
||||
<Sheet
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) close();
|
||||
}}
|
||||
>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex w-[min(25rem,calc(100vw-1rem))] flex-col gap-0 p-0 sm:max-w-md"
|
||||
>
|
||||
<SheetTitle className="sr-only">
|
||||
{isFilesMode ? "聊天中的文件" : "活动"}
|
||||
</SheetTitle>
|
||||
{isFilesMode ? (
|
||||
<SessionFilesPanel
|
||||
key={`drawer:${sessionId ?? "active"}:${filesRefreshToken}`}
|
||||
sessionId={sessionId}
|
||||
onClose={close}
|
||||
embedded
|
||||
/>
|
||||
) : (
|
||||
activityBody
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
if (isFilesMode) {
|
||||
return (
|
||||
<SessionFilesPanel
|
||||
key={`${sessionId ?? "active"}:${filesRefreshToken}`}
|
||||
sessionId={sessionId}
|
||||
onClose={close}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="fixed inset-y-0 right-0 z-30 flex w-[min(23rem,calc(100vw-1rem))] flex-col border-l bg-background shadow-xl lg:static lg:z-auto lg:h-full lg:w-92 lg:shrink-0 lg:shadow-none">
|
||||
{activityBody}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -308,9 +351,11 @@ type SessionFilesPayload = {
|
||||
function SessionFilesPanel({
|
||||
sessionId,
|
||||
onClose,
|
||||
embedded = false,
|
||||
}: {
|
||||
sessionId: string | null;
|
||||
onClose: () => void;
|
||||
embedded?: boolean;
|
||||
}) {
|
||||
const [payload, setPayload] = useState<SessionFilesPayload | null>(null);
|
||||
const [status, setStatus] = useState("正在读取聊天中的文件...");
|
||||
@@ -377,8 +422,8 @@ function SessionFilesPanel({
|
||||
const outputFiles = payload?.output ?? [];
|
||||
const total = inputFiles.length + outputFiles.length;
|
||||
|
||||
return (
|
||||
<aside className="fixed inset-y-0 right-0 z-30 flex w-[min(25rem,calc(100vw-1rem))] flex-col border-l bg-background shadow-xl lg:static lg:z-auto lg:h-full lg:w-96 lg:shrink-0 lg:shadow-none">
|
||||
const body = (
|
||||
<>
|
||||
<header className="flex h-16 shrink-0 items-center justify-between border-b px-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-semibold text-base">聊天中的文件</h2>
|
||||
@@ -386,6 +431,7 @@ function SessionFilesPanel({
|
||||
{status || `${total} 个文件`}
|
||||
</p>
|
||||
</div>
|
||||
{!embedded ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -396,6 +442,7 @@ function SessionFilesPanel({
|
||||
>
|
||||
<PanelRightCloseIcon className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||
{!status && total === 0 ? (
|
||||
@@ -406,6 +453,16 @@ function SessionFilesPanel({
|
||||
<FileSection title="输入" files={inputFiles} />
|
||||
<FileSection title="输出" files={outputFiles} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return <div className="flex min-h-0 flex-1 flex-col">{body}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="fixed inset-y-0 right-0 z-30 flex w-[min(25rem,calc(100vw-1rem))] flex-col border-l bg-background shadow-xl lg:static lg:z-auto lg:h-full lg:w-96 lg:shrink-0 lg:shadow-none">
|
||||
{body}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,10 +6,13 @@ import {
|
||||
import { ThreadListPrimitive } from "@assistant-ui/react";
|
||||
import type { UIMessage } from "ai";
|
||||
import {
|
||||
Loader2Icon,
|
||||
MoreHorizontalIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
ServerIcon,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type FC,
|
||||
@@ -21,8 +24,10 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
clearActiveSessionId,
|
||||
markFreshLocalId,
|
||||
readActiveSessionId,
|
||||
writeActiveSessionId,
|
||||
writePendingWorkspaceSessionId,
|
||||
} from "@/lib/claw-active-session";
|
||||
import { useClawSessionReplay } from "@/lib/claw-session-replay";
|
||||
import { pushHomeUrl, pushSessionUrl } from "@/lib/claw-session-url";
|
||||
@@ -112,6 +117,7 @@ export const ThreadList: FC = () => {
|
||||
return (
|
||||
<ThreadListPrimitive.Root className="aui-root aui-thread-list-root flex min-h-0 flex-1 flex-col gap-1">
|
||||
<ThreadListNew />
|
||||
<JupyterWorkspaceList />
|
||||
<ClawSessionList />
|
||||
</ThreadListPrimitive.Root>
|
||||
);
|
||||
@@ -139,6 +145,178 @@ const ThreadListNew: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
type JupyterWorkspaceEntry = {
|
||||
id: string;
|
||||
label?: string;
|
||||
base_url?: string;
|
||||
workspace_root?: string;
|
||||
created_at?: number;
|
||||
last_used_at?: number;
|
||||
};
|
||||
|
||||
function ensureSidebarWorkspaceSessionId(): {
|
||||
sessionId: string;
|
||||
created: boolean;
|
||||
} {
|
||||
const existing = readActiveSessionId();
|
||||
if (existing) return { sessionId: existing, created: false };
|
||||
const generated =
|
||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? `__LOCALID_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
|
||||
: `__LOCALID_${Math.random().toString(36).slice(2, 10)}`;
|
||||
markFreshLocalId(generated);
|
||||
writeActiveSessionId(generated);
|
||||
return { sessionId: generated, created: true };
|
||||
}
|
||||
|
||||
function workspaceDisplayLabel(entry: JupyterWorkspaceEntry): string {
|
||||
if (entry.label && entry.label.trim()) return entry.label.trim();
|
||||
if (entry.base_url) {
|
||||
try {
|
||||
return new URL(entry.base_url).host;
|
||||
} catch {
|
||||
return entry.base_url;
|
||||
}
|
||||
}
|
||||
return entry.id;
|
||||
}
|
||||
|
||||
const JupyterWorkspaceList: FC = () => {
|
||||
const [entries, setEntries] = useState<JupyterWorkspaceEntry[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [bindingId, setBindingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const refresh = () => {
|
||||
fetch("/api/claw/jupyter/workspaces", { cache: "no-store" })
|
||||
.then((res) => (res.ok ? res.json() : []))
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
if (Array.isArray(payload)) {
|
||||
setEntries(payload as JupyterWorkspaceEntry[]);
|
||||
} else {
|
||||
setEntries([]);
|
||||
}
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setEntries([]);
|
||||
setLoaded(true);
|
||||
});
|
||||
};
|
||||
refresh();
|
||||
const onChanged = () => refresh();
|
||||
window.addEventListener("claw-workspaces-changed", onChanged);
|
||||
window.addEventListener("focus", refresh);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener("claw-workspaces-changed", onChanged);
|
||||
window.removeEventListener("focus", refresh);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!loaded || entries.length === 0) return null;
|
||||
|
||||
const bind = async (entry: JupyterWorkspaceEntry) => {
|
||||
setError(null);
|
||||
setBindingId(entry.id);
|
||||
const { sessionId, created } = ensureSidebarWorkspaceSessionId();
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/claw/jupyter/workspaces/${encodeURIComponent(entry.id)}/bind`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ session_id: sessionId }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
detail?: string;
|
||||
error?: string;
|
||||
};
|
||||
setError(payload.detail ?? payload.error ?? "切换工作区失败");
|
||||
return;
|
||||
}
|
||||
if (created) writePendingWorkspaceSessionId(sessionId);
|
||||
window.dispatchEvent(new Event("claw-sessions-changed"));
|
||||
window.dispatchEvent(new Event("claw-workspaces-changed"));
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "切换工作区时网络异常",
|
||||
);
|
||||
} finally {
|
||||
setBindingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (entry: JupyterWorkspaceEntry) => {
|
||||
if (!window.confirm(`移除工作区「${workspaceDisplayLabel(entry)}」?`)) return;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/claw/jupyter/workspaces/${encodeURIComponent(entry.id)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!response.ok) return;
|
||||
setEntries((current) => current.filter((e) => e.id !== entry.id));
|
||||
} catch {
|
||||
// best effort; next refresh will reconcile
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-1 flex flex-col gap-1 border-y border-dashed py-2">
|
||||
<div className="flex items-center gap-1.5 px-2 text-muted-foreground text-xs uppercase tracking-wide">
|
||||
<ServerIcon className="size-3" />
|
||||
<span>工作区</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{entries.map((entry) => {
|
||||
const label = workspaceDisplayLabel(entry);
|
||||
const subtitle = entry.workspace_root ?? "";
|
||||
const isBinding = bindingId === entry.id;
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="group relative flex h-9 items-center gap-1 rounded-lg transition-colors hover:bg-muted"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBinding}
|
||||
onClick={() => void bind(entry)}
|
||||
className="flex h-full min-w-0 flex-1 items-center gap-2 rounded-lg px-3 text-left text-sm disabled:cursor-wait"
|
||||
title={`${entry.base_url ?? ""}${subtitle ? ` · ${subtitle}` : ""}`}
|
||||
>
|
||||
{isBinding ? (
|
||||
<Loader2Icon className="size-3.5 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<ServerIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="移除工作区"
|
||||
title="移除工作区"
|
||||
onClick={() => void remove(entry)}
|
||||
className="mr-1 hidden size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-background hover:text-foreground group-hover:flex"
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="px-2 text-destructive text-xs">{error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ClawSessionList: FC = () => {
|
||||
const { replaySession } = useClawSessionReplay();
|
||||
const [sessions, setSessions] = useState<ClawSession[]>([]);
|
||||
@@ -262,6 +440,10 @@ const ClawSessionList: FC = () => {
|
||||
type="button"
|
||||
className="aui-thread-list-item-trigger flex h-full min-w-0 flex-1 items-center truncate px-3 text-start text-sm"
|
||||
onClick={async () => {
|
||||
console.log("[session-list] click", {
|
||||
sessionId: session.session_id,
|
||||
preview: session.preview,
|
||||
});
|
||||
setOpenMenuSessionId(null);
|
||||
writeActiveSessionId(session.session_id);
|
||||
pushSessionUrl(session.session_id);
|
||||
|
||||
@@ -84,6 +84,7 @@ import {
|
||||
ACTIVE_SESSION_CHANGED_EVENT,
|
||||
clearActiveSessionId,
|
||||
clearPendingWorkspaceSessionId,
|
||||
markFreshLocalId,
|
||||
PENDING_WORKSPACE_SESSION_CHANGED_EVENT,
|
||||
readActiveSessionId,
|
||||
readPendingWorkspaceSessionId,
|
||||
@@ -261,8 +262,14 @@ function useRefreshCurrentRun() {
|
||||
includePending: true,
|
||||
includeActive: true,
|
||||
});
|
||||
const activeRunRef = useRef<string | null>(null);
|
||||
const appliedTerminalRef = useRef<string | null>(null);
|
||||
// Per-session bookkeeping: switching sessions (sidebar history) and coming
|
||||
// back must NOT re-trigger replaySession on a session that has already been
|
||||
// applied — replaySession rebuilds the thread tree, which causes a layout
|
||||
// shift / scroll jitter in the chat UI. Single-value refs would get
|
||||
// overwritten when visiting another session and forget that this session
|
||||
// was already settled.
|
||||
const activeRunRefMap = useRef<Map<string, string>>(new Map());
|
||||
const appliedTerminalRefMap = useRef<Map<string, string>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
@@ -273,17 +280,15 @@ function useRefreshCurrentRun() {
|
||||
const runStatus = await fetchLatestRunStatus(sessionId);
|
||||
if (cancelled) return;
|
||||
if (runStatus?.status === "queued" || runStatus?.status === "running") {
|
||||
activeRunRef.current = runStatus.run_id ?? "active";
|
||||
return;
|
||||
}
|
||||
if (!runtimeRunning && !activeRunRef.current) return;
|
||||
const terminalKey = [
|
||||
runStatus?.run_id ?? activeRunRef.current ?? "unknown",
|
||||
runStatus?.status ?? "idle",
|
||||
runStatus?.finished_at ?? "",
|
||||
runStatus?.elapsed_ms ?? "",
|
||||
].join(":");
|
||||
if (appliedTerminalRef.current === terminalKey) return;
|
||||
const runKey = runStatus.run_id ?? "active";
|
||||
const previousRunKey = activeRunRefMap.current.get(sessionId) ?? null;
|
||||
activeRunRefMap.current.set(sessionId, runKey);
|
||||
// Watcher auto-resume: backend started a new run via _maybe_auto_resume
|
||||
// without a local SSE stream driving the UI. We need to replay once so
|
||||
// the active-run placeholder (pending prompt + 思考中 bubble) appears.
|
||||
// useRefreshReplayedRun will take over polling once replayedRun.active
|
||||
// becomes true.
|
||||
if (!runtimeRunning && previousRunKey !== runKey) {
|
||||
const response = await fetch(`/api/claw/sessions/${sessionId}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
@@ -293,8 +298,29 @@ function useRefreshCurrentRun() {
|
||||
sessionId,
|
||||
toReplayRepository(payload, sessionId, runStatus),
|
||||
);
|
||||
activeRunRef.current = null;
|
||||
appliedTerminalRef.current = terminalKey;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const sessionActiveRun = activeRunRefMap.current.get(sessionId) ?? null;
|
||||
if (!runtimeRunning && !sessionActiveRun) return;
|
||||
const terminalKey = [
|
||||
runStatus?.run_id ?? sessionActiveRun ?? "unknown",
|
||||
runStatus?.status ?? "idle",
|
||||
runStatus?.finished_at ?? "",
|
||||
runStatus?.elapsed_ms ?? "",
|
||||
].join(":");
|
||||
if (appliedTerminalRefMap.current.get(sessionId) === terminalKey) return;
|
||||
const response = await fetch(`/api/claw/sessions/${sessionId}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok || cancelled) return;
|
||||
const payload = await response.json();
|
||||
replaySession(
|
||||
sessionId,
|
||||
toReplayRepository(payload, sessionId, runStatus),
|
||||
);
|
||||
activeRunRefMap.current.delete(sessionId);
|
||||
appliedTerminalRefMap.current.set(sessionId, terminalKey);
|
||||
scheduleSessionListRefresh();
|
||||
}
|
||||
|
||||
@@ -425,17 +451,16 @@ async function cancelLatestRun(sessionId: string, runId?: string | null) {
|
||||
const ChatTopActions: FC = () => {
|
||||
const { openFiles } = useActivityPanel();
|
||||
const [workspaceOpen, setWorkspaceOpen] = useState(false);
|
||||
const actionSessionId = useCurrentThreadSessionId({
|
||||
includePending: true,
|
||||
// 工作区切换必须绑定当前可见会话;空白新会话不能回退到上一个
|
||||
// activeSessionId,否则会把 Jupyter 工作区切到旧会话上。
|
||||
includeActive: false,
|
||||
});
|
||||
const displaySessionId = useCurrentThreadSessionId({
|
||||
// "切换工作区" dialog 与 indicator 必须用同一个 sessionId,否则会出现
|
||||
// "绑给 A、查的是 B" 的错位(agent 的 bash 路由也是用 active session id,
|
||||
// 错位时表面 indicator 显示已连接,实际 agent 跑在本地)。
|
||||
// 新建空会话场景由 thread-list.tsx 的 clearActiveSessionId() 兜底,
|
||||
// 此处不再需要刻意排除 activeSessionId。
|
||||
const sessionId = useCurrentThreadSessionId({
|
||||
includePending: true,
|
||||
includeActive: true,
|
||||
});
|
||||
const workspaceStatus = useJupyterWorkspaceStatus(displaySessionId);
|
||||
const workspaceStatus = useJupyterWorkspaceStatus(sessionId);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none sticky top-3 z-20 mr-4 flex justify-end">
|
||||
@@ -462,7 +487,7 @@ const ChatTopActions: FC = () => {
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-muted"
|
||||
onClick={() => {
|
||||
openFiles(displaySessionId);
|
||||
openFiles(sessionId);
|
||||
}}
|
||||
>
|
||||
<FileTextIcon className="size-4 text-muted-foreground" />
|
||||
@@ -482,7 +507,7 @@ const ChatTopActions: FC = () => {
|
||||
<WorkspaceSwitchDialog
|
||||
open={workspaceOpen}
|
||||
onOpenChange={setWorkspaceOpen}
|
||||
sessionId={actionSessionId}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -633,6 +658,7 @@ function WorkspaceSwitchDialog({
|
||||
if (needsFirstMessageSession) {
|
||||
writePendingWorkspaceSessionId(sessionIdForRequest);
|
||||
}
|
||||
window.dispatchEvent(new Event("claw-workspaces-changed"));
|
||||
setStatus(payload as JupyterWorkspaceStatus);
|
||||
setProgress({ percent: 100, label: "切换完成" });
|
||||
setMessage("工作区切换成功,后续工具会在远端 Jupyter 工作区执行。");
|
||||
@@ -757,6 +783,7 @@ function ensureWorkspaceSessionId(current: string | null) {
|
||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? `__LOCALID_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
|
||||
: `__LOCALID_${Math.random().toString(36).slice(2, 10)}`;
|
||||
markFreshLocalId(generated);
|
||||
writeActiveSessionId(generated);
|
||||
scheduleSessionListRefresh();
|
||||
return generated;
|
||||
@@ -1187,6 +1214,7 @@ function ModelPickerButton({
|
||||
};
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!normalizedQuery) return status.models;
|
||||
@@ -1200,7 +1228,7 @@ function ModelPickerButton({
|
||||
}, [normalizedQuery, status.models]);
|
||||
|
||||
return (
|
||||
<PopoverPrimitive.Root>
|
||||
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
|
||||
<PopoverPrimitive.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1241,7 +1269,10 @@ function ModelPickerButton({
|
||||
key={model.id}
|
||||
type="button"
|
||||
className="flex h-8 w-full items-center justify-between gap-2 rounded-lg px-2 text-left text-sm hover:bg-muted"
|
||||
onClick={() => status.setModel(model.id)}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
void status.setModel(model.id);
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{model.id}</span>
|
||||
<span className="shrink-0 text-muted-foreground text-xs">
|
||||
@@ -2371,6 +2402,11 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
|
||||
const justSubmittedRef = useRef(false);
|
||||
const isDisabled = runtimeDisabled || disabled;
|
||||
|
||||
// Pull store→local on external store changes (session switch, paste-from-attachment,
|
||||
// submit-clear, etc). The reverse direction (local→store) is pushed eagerly by
|
||||
// onChange/handleInsert/handleKeyDown/onCompositionEnd via syncComposerText, so we
|
||||
// don't need a symmetric effect — and adding one creates a feedback loop that
|
||||
// re-renders the composer at ~1ms intervals (visible as shake).
|
||||
useEffect(() => {
|
||||
if (!isComposingRef.current) {
|
||||
if (justSubmittedRef.current && storeText) return;
|
||||
@@ -2379,16 +2415,6 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
|
||||
}
|
||||
}, [storeText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isComposingRef.current &&
|
||||
localText !== storeText &&
|
||||
aui.composer().getState().isEditing
|
||||
) {
|
||||
aui.composer().setText(localText);
|
||||
}
|
||||
}, [aui, localText, storeText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFocus || isDisabled) return;
|
||||
const textarea = textareaRef.current;
|
||||
@@ -2411,6 +2437,21 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
|
||||
};
|
||||
}, [aui]);
|
||||
|
||||
// AssistantWorkspace 切 session 时通过事件通知我们恢复 draft——直接
|
||||
// 控制 localText,绕过 store→local 路径上的 justSubmittedRef 屏蔽和
|
||||
// switchToNewThread 引起的异步覆盖。
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ text?: string }>).detail;
|
||||
const text = detail?.text ?? "";
|
||||
justSubmittedRef.current = false;
|
||||
setLocalText(text);
|
||||
};
|
||||
window.addEventListener("claw-composer-restore-draft", handler);
|
||||
return () =>
|
||||
window.removeEventListener("claw-composer-restore-draft", handler);
|
||||
}, []);
|
||||
|
||||
const syncComposerText = useCallback(
|
||||
(value: string) => {
|
||||
if (!aui.composer().getState().isEditing) return;
|
||||
|
||||
@@ -72,6 +72,7 @@ type ClawSessionSummary = {
|
||||
preview?: string;
|
||||
model?: string;
|
||||
usage?: UsageSummary;
|
||||
is_training?: boolean;
|
||||
};
|
||||
|
||||
type UsageSummary = {
|
||||
@@ -365,9 +366,16 @@ function SessionResultButton({
|
||||
className="flex w-full flex-col rounded-lg px-2 py-2 text-left transition-colors hover:bg-muted"
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="truncate font-medium text-sm">
|
||||
{loading ? "回放中..." : session.preview || session.session_id}
|
||||
</span>
|
||||
{session.is_training ? (
|
||||
<span className="shrink-0 rounded bg-amber-100 px-1.5 text-[10px] text-amber-800">
|
||||
训练
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="mt-0.5 text-muted-foreground text-xs">
|
||||
{session.turns ?? 0} 轮 · {session.tool_calls ?? 0} 次工具
|
||||
</span>
|
||||
@@ -382,6 +390,7 @@ function useSidebarSessions(open: boolean) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
const reload = () => {
|
||||
fetch("/api/claw/sessions", { cache: "no-store" })
|
||||
.then((res) => (res.ok ? res.json() : []))
|
||||
.then((payload) => {
|
||||
@@ -391,8 +400,13 @@ function useSidebarSessions(open: boolean) {
|
||||
.catch(() => {
|
||||
if (!cancelled) setSessions([]);
|
||||
});
|
||||
};
|
||||
reload();
|
||||
const handleUpdate = () => reload();
|
||||
window.addEventListener("claw-session-updated", handleUpdate);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener("claw-session-updated", handleUpdate);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type Round = {
|
||||
iteration: number;
|
||||
runDic: number;
|
||||
label: string;
|
||||
timestamp: string;
|
||||
values: Record<string, number | null>;
|
||||
};
|
||||
|
||||
export type MetricsHistory = {
|
||||
metrics: string[];
|
||||
rounds: Round[];
|
||||
};
|
||||
|
||||
const METRIC_LABELS: Record<string, string> = {
|
||||
req_set_car: "需求集合(车载)",
|
||||
dapan_car: "大盘车载",
|
||||
specific_test: "Specific Test",
|
||||
triage_err_rate: "Triage 错误率",
|
||||
bvt_nav: "导航BVT",
|
||||
talkable_controllable: "可聊可控",
|
||||
target_subset_count: "目标子集数量",
|
||||
target_subset_wrong: "目标子集错误数",
|
||||
target_subset_correct: "目标子集正确数",
|
||||
target_subset_acc: "目标子集准确率",
|
||||
icl_test_overall: "ICL 测试整体",
|
||||
specific_test_overall: "专项测试整体",
|
||||
overrall: "大盘集",
|
||||
overrall_che_zai: "大盘集车载",
|
||||
overrall_shou_ji: "大盘集手机",
|
||||
overrall_dian_shi: "大盘集电视",
|
||||
overrall_yan_jing: "大盘集眼镜",
|
||||
overrall_you_ping: "大盘集有屏",
|
||||
overrall_wu_ping: "大盘集无屏",
|
||||
};
|
||||
|
||||
const LINE_COLORS = [
|
||||
"#34d399", // emerald
|
||||
"#38bdf8", // sky
|
||||
"#a78bfa", // violet
|
||||
"#fbbf24", // amber
|
||||
"#f472b6", // rose
|
||||
"#22d3ee", // cyan
|
||||
"#a3e635", // lime
|
||||
"#fb923c", // orange
|
||||
];
|
||||
|
||||
function labelOf(key: string): string {
|
||||
return METRIC_LABELS[key] ?? key;
|
||||
}
|
||||
|
||||
function formatValue(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined || Number.isNaN(v)) return "—";
|
||||
return v.toFixed(4);
|
||||
}
|
||||
|
||||
function formatDelta(curr: number | null | undefined, base: number | null | undefined): {
|
||||
text: string;
|
||||
tone: "up" | "down" | "flat";
|
||||
} {
|
||||
if (
|
||||
curr === null ||
|
||||
curr === undefined ||
|
||||
base === null ||
|
||||
base === undefined ||
|
||||
Number.isNaN(curr) ||
|
||||
Number.isNaN(base)
|
||||
) {
|
||||
return { text: "—", tone: "flat" };
|
||||
}
|
||||
const d = curr - base;
|
||||
if (Math.abs(d) < 1e-6) return { text: "±0", tone: "flat" };
|
||||
const sign = d > 0 ? "+" : "";
|
||||
return { text: `${sign}${d.toFixed(4)}`, tone: d > 0 ? "up" : "down" };
|
||||
}
|
||||
|
||||
function chartRowsFor(history: MetricsHistory, key: string): Array<Record<string, unknown>> {
|
||||
return history.rounds.map((r) => {
|
||||
const v = r.values[key];
|
||||
return {
|
||||
label: r.label,
|
||||
[key]: v === undefined || v === null ? null : v,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function MetricsChartPanel({ history }: { history: MetricsHistory | null }) {
|
||||
if (!history || history.rounds.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-zinc-500 text-xs">
|
||||
暂无指标数据,等首轮分析完成
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const latest = history.rounds[history.rounds.length - 1];
|
||||
const baseline = history.rounds[0];
|
||||
const prev =
|
||||
history.rounds.length >= 2 ? history.rounds[history.rounds.length - 2] : baseline;
|
||||
|
||||
const colorFor = (key: string): string => {
|
||||
const idx = history.metrics.indexOf(key);
|
||||
return LINE_COLORS[idx >= 0 ? idx % LINE_COLORS.length : 0];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-y-auto pr-1">
|
||||
<div className="grid gap-2 [grid-template-columns:repeat(auto-fit,minmax(280px,1fr))]">
|
||||
{history.metrics.map((k) => {
|
||||
const curr = latest.values[k];
|
||||
const dBase = formatDelta(curr, baseline.values[k]);
|
||||
const dPrev = formatDelta(curr, prev.values[k]);
|
||||
const color = colorFor(k);
|
||||
const rows = chartRowsFor(history, k);
|
||||
return (
|
||||
<div
|
||||
key={k}
|
||||
className="flex flex-col rounded-md border border-zinc-800 bg-zinc-900/40 p-2"
|
||||
>
|
||||
<div className="flex shrink-0 items-baseline justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span
|
||||
className="size-1.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="truncate font-medium text-[11px] text-zinc-200">
|
||||
{labelOf(k)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono font-semibold text-[14px] text-zinc-50 leading-none">
|
||||
{formatValue(curr)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex shrink-0 gap-2 font-mono text-[10px]">
|
||||
<span className={deltaClass(dBase.tone)}>
|
||||
Δbase {dBase.text}
|
||||
</span>
|
||||
<span className={deltaClass(dPrev.tone)}>
|
||||
Δprev {dPrev.text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 h-32">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={rows}
|
||||
margin={{ top: 4, right: 8, left: -12, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
stroke="#a1a1aa"
|
||||
tick={{ fontSize: 10 }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#a1a1aa"
|
||||
tick={{ fontSize: 10 }}
|
||||
tickFormatter={(v: number) =>
|
||||
typeof v === "number" ? v.toFixed(4) : v
|
||||
}
|
||||
domain={["auto", "auto"]}
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: "#18181b",
|
||||
border: "1px solid #3f3f46",
|
||||
borderRadius: 6,
|
||||
fontSize: 11,
|
||||
}}
|
||||
labelStyle={{ color: "#e4e4e7" }}
|
||||
itemStyle={{ color: "#e4e4e7" }}
|
||||
formatter={(value: number, name: string) => [
|
||||
typeof value === "number" ? value.toFixed(4) : value,
|
||||
labelOf(name),
|
||||
]}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={k}
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
dot={{ r: 2.5 }}
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function deltaClass(tone: "up" | "down" | "flat"): string {
|
||||
if (tone === "up") return "text-emerald-400";
|
||||
if (tone === "down") return "text-rose-400";
|
||||
return "text-zinc-500";
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { GitBranchIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Props = {
|
||||
sessionId: string | null;
|
||||
isTraining: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
disabled?: boolean;
|
||||
lockedReason?: string;
|
||||
};
|
||||
|
||||
export function TrainingModeToggle({
|
||||
sessionId,
|
||||
isTraining,
|
||||
onChange,
|
||||
disabled,
|
||||
lockedReason,
|
||||
}: Props) {
|
||||
const isDisabled = disabled || !sessionId;
|
||||
const tooltip = !sessionId
|
||||
? "发送首条消息后开启"
|
||||
: lockedReason
|
||||
? lockedReason
|
||||
: "切换训练模式";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
onClick={() => onChange(!isTraining)}
|
||||
title={tooltip}
|
||||
aria-pressed={isTraining}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs transition-colors",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
isTraining
|
||||
? "border-amber-300 bg-amber-50 text-amber-800 hover:bg-amber-100"
|
||||
: "border-border bg-background text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<GitBranchIcon className="size-3.5" />
|
||||
<span>训练模式</span>
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1 inline-flex h-3.5 w-7 shrink-0 items-center rounded-full transition-colors",
|
||||
isTraining ? "bg-amber-400" : "bg-muted",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block size-3 rounded-full bg-background shadow-sm transition-transform",
|
||||
isTraining ? "translate-x-3.5" : "translate-x-0.5",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,965 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
BarChart3Icon,
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
ClipboardListIcon,
|
||||
ClockIcon,
|
||||
DnaIcon,
|
||||
FileTextIcon,
|
||||
FlaskConicalIcon,
|
||||
GraduationCapIcon,
|
||||
HourglassIcon,
|
||||
Loader2Icon,
|
||||
type LucideIcon,
|
||||
Maximize2Icon,
|
||||
MicroscopeIcon,
|
||||
Minimize2Icon,
|
||||
RefreshCwIcon,
|
||||
ShieldCheckIcon,
|
||||
TrendingUpIcon,
|
||||
UserCheckIcon,
|
||||
} from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
MetricsChartPanel,
|
||||
type MetricsHistory,
|
||||
} from "@/components/training/metrics-chart-panel";
|
||||
import { StepDetailSheet } from "@/components/training/step-detail-sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CardStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "waiting"
|
||||
| "complete"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
type SectionType = "baseline" | "train" | "analysis";
|
||||
type GateKind = "human-check" | "human-review";
|
||||
|
||||
type PipelineCard = {
|
||||
key: string;
|
||||
step: string;
|
||||
icon?: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
status: CardStatus;
|
||||
progress?: number;
|
||||
};
|
||||
|
||||
type RoundItem = {
|
||||
type: "round";
|
||||
index: number;
|
||||
run_id: string;
|
||||
run_index: number;
|
||||
section_type: SectionType;
|
||||
label: string;
|
||||
description: string;
|
||||
status: CardStatus;
|
||||
cards: PipelineCard[];
|
||||
};
|
||||
|
||||
type GateItem = {
|
||||
type: "gate";
|
||||
index: number;
|
||||
key: string;
|
||||
run_id: string;
|
||||
run_index: number;
|
||||
gate_kind: GateKind;
|
||||
title: string;
|
||||
description: string;
|
||||
status: CardStatus;
|
||||
reason?: string | null;
|
||||
summary?: string | null;
|
||||
proposal?: string | null;
|
||||
ask?: string | null;
|
||||
};
|
||||
|
||||
type PipelineItem = RoundItem | GateItem;
|
||||
|
||||
type InFlight = {
|
||||
run_id: string;
|
||||
section_type: SectionType;
|
||||
section_label: string;
|
||||
cards: PipelineCard[];
|
||||
};
|
||||
|
||||
type Kpi = {
|
||||
label: string;
|
||||
value: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
type LogLine = {
|
||||
ts: string;
|
||||
iter: string | number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type PipelinePayload = {
|
||||
session_id: string;
|
||||
generated_at_ms: number;
|
||||
available?: boolean;
|
||||
status: { mode: string; state: CardStatus };
|
||||
kpis: Kpi[];
|
||||
items: PipelineItem[];
|
||||
in_flight?: InFlight | null;
|
||||
logs: LogLine[];
|
||||
metrics_history?: MetricsHistory | null;
|
||||
};
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
"alert-triangle": AlertTriangleIcon,
|
||||
"bar-chart": BarChart3Icon,
|
||||
microscope: MicroscopeIcon,
|
||||
"trending-up": TrendingUpIcon,
|
||||
"file-text": FileTextIcon,
|
||||
dna: DnaIcon,
|
||||
flask: FlaskConicalIcon,
|
||||
shield: ShieldCheckIcon,
|
||||
"graduation-cap": GraduationCapIcon,
|
||||
"clipboard-list": ClipboardListIcon,
|
||||
"refresh-cw": RefreshCwIcon,
|
||||
clock: ClockIcon,
|
||||
hourglass: HourglassIcon,
|
||||
"user-check": UserCheckIcon,
|
||||
};
|
||||
|
||||
const SECTION_PHASE_TONE: Record<SectionType, string> = {
|
||||
baseline: "from-violet-500/15 to-sky-500/10 border-violet-400/40",
|
||||
train: "from-emerald-500/15 to-amber-500/10 border-emerald-400/40",
|
||||
analysis: "from-sky-500/15 to-violet-500/10 border-sky-400/40",
|
||||
};
|
||||
|
||||
const SECTION_BADGE_TONE: Record<SectionType, string> = {
|
||||
baseline: "bg-violet-500/10 text-violet-700 dark:text-violet-300",
|
||||
train: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||
analysis: "bg-sky-500/10 text-sky-700 dark:text-sky-300",
|
||||
};
|
||||
|
||||
export function TrainingPipelinePanel({
|
||||
sessionId,
|
||||
}: {
|
||||
sessionId: string | null;
|
||||
}) {
|
||||
const [data, setData] = useState<PipelinePayload | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [openCard, setOpenCard] = useState<{
|
||||
key: string;
|
||||
title: string;
|
||||
status: string;
|
||||
runId?: string;
|
||||
} | null>(null);
|
||||
const [bottomTab, setBottomTab] = useState<"log" | "metrics">("log");
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const url = sessionId
|
||||
? `/api/claw/training/pipeline/stream?session_id=${encodeURIComponent(sessionId)}`
|
||||
: "/api/claw/training/pipeline/stream";
|
||||
const es = new EventSource(url);
|
||||
es.onmessage = (ev) => {
|
||||
try {
|
||||
const payload = JSON.parse(ev.data);
|
||||
if (payload && payload.available === false) {
|
||||
setData(null);
|
||||
} else {
|
||||
setData(payload);
|
||||
}
|
||||
setLoading(false);
|
||||
} catch {
|
||||
/* ignore malformed events */
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
setLoading(false);
|
||||
};
|
||||
return () => {
|
||||
es.close();
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
const stripNamespace = (key: string): string => {
|
||||
// "R1:cml" → "cml" — backend uses round-namespaced keys, but the
|
||||
// step-detail endpoint is keyed only by step name.
|
||||
const idx = key.indexOf(":");
|
||||
return idx >= 0 ? key.slice(idx + 1) : key;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden border-r bg-muted/20">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||
<HeaderRow status={data?.status ?? null} loading={loading} />
|
||||
<KpiRow kpis={data?.kpis ?? []} />
|
||||
<div className="mt-5 space-y-3">
|
||||
{(() => {
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed p-8 text-center text-muted-foreground text-xs">
|
||||
暂无 pipeline 数据
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const items = data.items ?? [];
|
||||
if (items.length === 0) {
|
||||
const isRunning = data.status?.state === "running";
|
||||
return (
|
||||
<div className="rounded-md border border-dashed p-8 text-center text-muted-foreground text-xs">
|
||||
{isRunning
|
||||
? "首个阶段进行中,卡片即将出现……"
|
||||
: "等待 agent 进入第一个步骤……"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{items.map((item, idx) => {
|
||||
const showConnector = idx < items.length - 1;
|
||||
if (item.type === "round") {
|
||||
return (
|
||||
<div
|
||||
key={`round-${item.run_id}-${item.section_type}`}
|
||||
>
|
||||
<RoundSection
|
||||
item={item}
|
||||
onCardClick={(card) =>
|
||||
setOpenCard({
|
||||
key: stripNamespace(card.key),
|
||||
title: card.title,
|
||||
status: card.status,
|
||||
runId: item.run_id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{showConnector ? <ItemConnector /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={`gate-${item.key}`}>
|
||||
<GateCard
|
||||
item={item}
|
||||
onClick={() =>
|
||||
setOpenCard({
|
||||
key: stripNamespace(item.key),
|
||||
title: item.title,
|
||||
status: item.status,
|
||||
runId: item.run_id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{showConnector ? <ItemConnector /> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<BottomPanel
|
||||
logs={data?.logs ?? []}
|
||||
history={data?.metrics_history ?? null}
|
||||
tab={bottomTab}
|
||||
onTabChange={setBottomTab}
|
||||
/>
|
||||
<StepDetailSheet
|
||||
sessionId={sessionId}
|
||||
stepKey={openCard?.key ?? null}
|
||||
runId={openCard?.runId ?? null}
|
||||
stepTitle={openCard?.title}
|
||||
stepStatus={openCard?.status}
|
||||
open={openCard !== null}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setOpenCard(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderRow({
|
||||
status,
|
||||
loading,
|
||||
}: {
|
||||
status: PipelinePayload["status"] | null;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const statusLabel = loading
|
||||
? "加载中"
|
||||
: status
|
||||
? `${status.mode} · ${statusText(status.state)}`
|
||||
: "暂无运行";
|
||||
const isRunning = status?.state === "running";
|
||||
const isWaiting = status?.state === "waiting";
|
||||
const isFailed = status?.state === "failed";
|
||||
const isCancelled = status?.state === "cancelled";
|
||||
const isErrored = isFailed || isCancelled;
|
||||
return (
|
||||
<header className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MicroscopeIcon className="size-5 text-muted-foreground" />
|
||||
<h2 className="font-semibold text-sm uppercase tracking-[0.2em] text-foreground/90">
|
||||
Training Pipeline Monitor
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs",
|
||||
isErrored
|
||||
? "border-rose-500/50 bg-rose-500/10 text-rose-700 dark:text-rose-300"
|
||||
: isWaiting
|
||||
? "border-amber-500/50 bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
||||
: isRunning
|
||||
? "animate-pipeline-pill-glow border-sky-400 bg-sky-500/10 text-sky-700 dark:text-sky-300"
|
||||
: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||
)}
|
||||
>
|
||||
<span className="relative inline-flex size-2 items-center justify-center">
|
||||
<span
|
||||
className={cn(
|
||||
"size-2 rounded-full",
|
||||
isErrored
|
||||
? "bg-rose-500"
|
||||
: isWaiting
|
||||
? "bg-amber-500"
|
||||
: isRunning
|
||||
? "bg-sky-500"
|
||||
: status?.state === "complete"
|
||||
? "bg-emerald-500"
|
||||
: "bg-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
{isRunning && !isErrored ? (
|
||||
<span className="pointer-events-none absolute inset-0 animate-pipeline-dot-ping rounded-full bg-sky-400" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="font-medium">{statusLabel}</span>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiRow({ kpis }: { kpis: Kpi[] }) {
|
||||
if (!kpis.length) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-7">
|
||||
{kpis.map((kpi) => {
|
||||
const Icon = kpi.icon ? ICON_MAP[kpi.icon] : null;
|
||||
return (
|
||||
<div
|
||||
key={kpi.label}
|
||||
className="rounded-lg border bg-background px-3 py-2 min-h-[58px]"
|
||||
>
|
||||
<div className="text-[10px] font-medium tracking-wider text-muted-foreground truncate">
|
||||
{kpi.label}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-start gap-1.5 font-mono text-sm leading-tight text-foreground">
|
||||
<span className="break-all line-clamp-2">{kpi.value}</span>
|
||||
{Icon ? (
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground mt-0.5" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemConnector() {
|
||||
return (
|
||||
<div className="flex justify-center py-1">
|
||||
<div className="h-5 w-0.5 bg-gradient-to-b from-border to-muted-foreground/30" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status: CardStatus }) {
|
||||
const map: Record<
|
||||
CardStatus,
|
||||
{ label: string; cls: string; icon: LucideIcon | null }
|
||||
> = {
|
||||
complete: {
|
||||
label: "COMPLETED",
|
||||
cls: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30",
|
||||
icon: CheckIcon,
|
||||
},
|
||||
running: {
|
||||
label: "IN PROGRESS",
|
||||
cls: "bg-sky-500/15 text-sky-700 dark:text-sky-300 border-sky-500/30",
|
||||
icon: Loader2Icon,
|
||||
},
|
||||
waiting: {
|
||||
label: "WAITING FOR YOU",
|
||||
cls: "bg-amber-500/15 text-amber-700 dark:text-amber-300 border-amber-500/40",
|
||||
icon: HourglassIcon,
|
||||
},
|
||||
pending: {
|
||||
label: "PENDING",
|
||||
cls: "bg-muted text-muted-foreground border-border",
|
||||
icon: null,
|
||||
},
|
||||
failed: {
|
||||
label: "FAILED",
|
||||
cls: "bg-rose-500/15 text-rose-700 dark:text-rose-300 border-rose-500/30",
|
||||
icon: AlertTriangleIcon,
|
||||
},
|
||||
cancelled: {
|
||||
label: "CANCELLED",
|
||||
cls: "bg-muted text-muted-foreground border-border",
|
||||
icon: null,
|
||||
},
|
||||
};
|
||||
const { label, cls, icon: Icon } = map[status];
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-[10px] font-semibold tracking-wider",
|
||||
cls,
|
||||
)}
|
||||
>
|
||||
{Icon ? (
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-3",
|
||||
status === "running" ? "animate-spin" : "",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RoundSection({
|
||||
item,
|
||||
onCardClick,
|
||||
}: {
|
||||
item: RoundItem;
|
||||
onCardClick?: (card: PipelineCard) => void;
|
||||
}) {
|
||||
const numLabel = item.index.toString().padStart(2, "0");
|
||||
const phaseTone = SECTION_PHASE_TONE[item.section_type];
|
||||
const badgeTone = SECTION_BADGE_TONE[item.section_type];
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"rounded-2xl border bg-gradient-to-br p-4 shadow-sm",
|
||||
phaseTone,
|
||||
"bg-background/80",
|
||||
)}
|
||||
>
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-9 shrink-0 items-center justify-center rounded-full font-mono text-sm font-semibold",
|
||||
badgeTone,
|
||||
)}
|
||||
>
|
||||
{numLabel}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-foreground text-sm">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill status={item.status} />
|
||||
</div>
|
||||
{item.cards.length > 0 ? (
|
||||
<div className="flex flex-wrap items-stretch justify-center gap-2">
|
||||
{item.cards.map((card, idx) => (
|
||||
<div key={card.key} className="flex items-stretch gap-2">
|
||||
<PipelineCardView
|
||||
card={card}
|
||||
onClick={onCardClick ? () => onCardClick(card) : undefined}
|
||||
/>
|
||||
{idx < item.cards.length - 1 ? <CardArrow /> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CardArrow() {
|
||||
return (
|
||||
<div className="flex items-center px-1 text-muted-foreground/50">
|
||||
<ChevronRightIcon className="size-5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseGateReason(reason: string | null | undefined): {
|
||||
summary: string | null;
|
||||
proposal: string | null;
|
||||
ask: string | null;
|
||||
} {
|
||||
const text = (reason ?? "").trim();
|
||||
if (!text) return { summary: null, proposal: null, ask: null };
|
||||
// Find the ask: greedy from any "是否/要不要/能否/还是/请..." question phrase
|
||||
// to end. Fallback: the trailing sentence containing ?/?.
|
||||
const askPatterns = [
|
||||
/(是否[^。]*$)/,
|
||||
/(要不要[^。]*$)/,
|
||||
/(能否[^。]*$)/,
|
||||
/(请[^。]{0,40}[??][^。]*$)/,
|
||||
];
|
||||
let ask: string | null = null;
|
||||
let body = text;
|
||||
for (const pat of askPatterns) {
|
||||
const m = text.match(pat);
|
||||
if (m && m.index !== undefined) {
|
||||
ask = m[0].trim().replace(/^[。;;,,\s]+/, "");
|
||||
body = text.slice(0, m.index).trim().replace(/[。;;,,\s]+$/, "");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ask) {
|
||||
// Fallback: split off trailing sentence(s) containing ? or ?
|
||||
const qIdx = Math.max(text.lastIndexOf("?"), text.lastIndexOf("?"));
|
||||
if (qIdx >= 0) {
|
||||
// expand back to previous 。 or start of string
|
||||
const prevDot = Math.max(
|
||||
text.lastIndexOf("。", qIdx - 1),
|
||||
text.lastIndexOf(";", qIdx - 1),
|
||||
text.lastIndexOf(";", qIdx - 1),
|
||||
);
|
||||
ask = text.slice(prevDot + 1).trim().replace(/^[。;;,,\s]+/, "");
|
||||
body = text.slice(0, prevDot + 1).trim().replace(/[。;;,,\s]+$/, "");
|
||||
}
|
||||
}
|
||||
const sentences = body
|
||||
.split(/。/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (sentences.length === 0) {
|
||||
return { summary: null, proposal: null, ask };
|
||||
}
|
||||
if (sentences.length === 1) {
|
||||
return { summary: sentences[0], proposal: null, ask };
|
||||
}
|
||||
const proposalKw =
|
||||
/(H\d|H[1-3]\b|拟|要做|计划|提议|方案|增强|仿写|修改|修\s*\d|fix|patch|候选|逐条审|relabel|sft|train)/i;
|
||||
let splitIdx = sentences.findIndex((s) => proposalKw.test(s));
|
||||
if (splitIdx <= 0) splitIdx = Math.max(1, Math.floor(sentences.length / 2));
|
||||
const summary = sentences.slice(0, splitIdx).join("。 ");
|
||||
const proposal = sentences.slice(splitIdx).join("。 ");
|
||||
return { summary, proposal, ask };
|
||||
}
|
||||
|
||||
function GateBodyRow({
|
||||
label,
|
||||
text,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
text: string;
|
||||
tone: "status" | "proposal" | "ask";
|
||||
}) {
|
||||
const labelTone =
|
||||
tone === "ask"
|
||||
? "bg-amber-500/25 text-amber-800 dark:bg-amber-400/30 dark:text-amber-100"
|
||||
: tone === "proposal"
|
||||
? "bg-sky-500/15 text-sky-700 dark:bg-sky-400/20 dark:text-sky-200"
|
||||
: "bg-zinc-500/15 text-zinc-700 dark:bg-zinc-400/20 dark:text-zinc-200";
|
||||
const textTone = tone === "ask" ? "text-foreground font-medium" : "text-foreground/85";
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px] font-semibold tracking-wider",
|
||||
labelTone,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<p className={cn("text-sm leading-relaxed", textTone)}>{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GateCard({
|
||||
item,
|
||||
onClick,
|
||||
}: {
|
||||
item: GateItem;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const numLabel = item.index.toString().padStart(2, "0");
|
||||
const isWaiting = item.status === "waiting";
|
||||
const isComplete = item.status === "complete";
|
||||
const Tag = onClick ? "button" : "div";
|
||||
const hint =
|
||||
item.gate_kind === "human-review"
|
||||
? "在右侧对话里给出 OK / 调整后的指令,agent 才会继续。"
|
||||
: "在右侧对话里回 OK / 修改建议,确认后进入下一轮训练。";
|
||||
// Prefer structured fields; fall back to heuristic parsing of `reason`.
|
||||
const parsed = parseGateReason(item.reason);
|
||||
const summary = item.summary ?? parsed.summary;
|
||||
const proposal = item.proposal ?? parsed.proposal;
|
||||
const ask = item.ask ?? parsed.ask;
|
||||
const hasStructured = Boolean(summary || proposal || ask);
|
||||
return (
|
||||
<Tag
|
||||
type={onClick ? "button" : undefined}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-4 rounded-2xl border-2 border-dashed bg-amber-500/5 p-5 text-left transition-colors",
|
||||
isWaiting
|
||||
? "border-amber-400 bg-amber-500/10 ring-2 ring-amber-300/40 dark:ring-amber-500/30 animate-pipeline-pill-glow"
|
||||
: isComplete
|
||||
? "border-emerald-400/60 bg-emerald-500/5"
|
||||
: "border-amber-400/40",
|
||||
onClick &&
|
||||
"cursor-pointer hover:bg-amber-500/15 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-amber-500/15 font-mono text-amber-700 text-base font-semibold dark:text-amber-300">
|
||||
{numLabel}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<UserCheckIcon className="size-5 shrink-0 text-amber-700 dark:text-amber-300" />
|
||||
<span className="font-semibold text-foreground text-base">
|
||||
{item.title}
|
||||
</span>
|
||||
<span className="rounded bg-amber-500/15 px-1.5 py-0.5 font-mono text-xs text-amber-700 dark:text-amber-300">
|
||||
{item.run_id}
|
||||
</span>
|
||||
</div>
|
||||
{hasStructured ? (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{summary ? (
|
||||
<GateBodyRow label="现状" text={summary} tone="status" />
|
||||
) : null}
|
||||
{proposal ? (
|
||||
<GateBodyRow label="提议" text={proposal} tone="proposal" />
|
||||
) : null}
|
||||
{ask ? (
|
||||
<GateBodyRow label="请选" text={ask} tone="ask" />
|
||||
) : null}
|
||||
</div>
|
||||
) : item.reason ? (
|
||||
<p className="mt-1.5 text-sm text-foreground/85 leading-relaxed">
|
||||
{item.reason}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1.5 text-sm text-muted-foreground leading-relaxed">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
{isWaiting ? (
|
||||
<p className="mt-2 text-xs tracking-wide text-amber-700/90 dark:text-amber-400/90">
|
||||
{hint}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill status={item.status} />
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineCardView({
|
||||
card,
|
||||
onClick,
|
||||
}: {
|
||||
card: PipelineCard;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const isRunning = card.status === "running";
|
||||
const isComplete = card.status === "complete";
|
||||
const Icon = card.icon ? ICON_MAP[card.icon] : null;
|
||||
|
||||
const tone = isComplete
|
||||
? "border-emerald-500/40 bg-emerald-50/40 dark:bg-emerald-950/20"
|
||||
: isRunning
|
||||
? "border-sky-400 bg-sky-50/60 dark:bg-sky-950/30 ring-2 ring-sky-300/50 dark:ring-sky-500/30 animate-pipeline-running-blue"
|
||||
: card.status === "failed"
|
||||
? "border-rose-500/40 bg-rose-50/40 dark:bg-rose-950/20"
|
||||
: "border-border bg-background";
|
||||
|
||||
const Tag = onClick ? "button" : "div";
|
||||
|
||||
return (
|
||||
<Tag
|
||||
type={onClick ? "button" : undefined}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"relative flex w-48 flex-col rounded-xl border px-3 pt-3 pb-2.5 text-left transition-colors",
|
||||
tone,
|
||||
onClick &&
|
||||
"cursor-pointer hover:brightness-105 hover:shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400",
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-start">
|
||||
{isComplete ? (
|
||||
<div className="flex size-5 items-center justify-center rounded-full bg-emerald-500 text-white">
|
||||
<CheckIcon className="size-3" strokeWidth={3} />
|
||||
</div>
|
||||
) : (
|
||||
<MiniStatus status={card.status} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{Icon ? (
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
isComplete
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: isRunning
|
||||
? "text-sky-600 dark:text-sky-400"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium text-sm leading-tight",
|
||||
isRunning ? "text-foreground" : "",
|
||||
card.status === "pending" ? "text-muted-foreground" : "",
|
||||
)}
|
||||
>
|
||||
{card.title}
|
||||
</span>
|
||||
</div>
|
||||
<span className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground">
|
||||
{card.subtitle}
|
||||
</span>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniStatus({ status }: { status: CardStatus }) {
|
||||
const map: Record<CardStatus, { label: string; cls: string }> = {
|
||||
complete: {
|
||||
label: "DONE",
|
||||
cls: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
|
||||
},
|
||||
running: {
|
||||
label: "RUNNING",
|
||||
cls: "bg-sky-500/20 text-sky-700 dark:text-sky-200",
|
||||
},
|
||||
waiting: {
|
||||
label: "WAITING",
|
||||
cls: "bg-amber-500/20 text-amber-700 dark:text-amber-200",
|
||||
},
|
||||
pending: {
|
||||
label: "PENDING",
|
||||
cls: "bg-muted text-muted-foreground",
|
||||
},
|
||||
failed: {
|
||||
label: "FAILED",
|
||||
cls: "bg-rose-500/15 text-rose-700 dark:text-rose-300",
|
||||
},
|
||||
cancelled: {
|
||||
label: "CANCELLED",
|
||||
cls: "bg-muted text-muted-foreground",
|
||||
},
|
||||
};
|
||||
const { label, cls } = map[status];
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-semibold tracking-wider",
|
||||
cls,
|
||||
)}
|
||||
>
|
||||
{status === "running" ? (
|
||||
<Loader2Icon className="size-2.5 animate-spin" />
|
||||
) : null}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const BOTTOM_PANEL_DEFAULT_HEIGHT = 288;
|
||||
const BOTTOM_PANEL_MIN_HEIGHT = 120;
|
||||
|
||||
function BottomPanel({
|
||||
logs,
|
||||
history,
|
||||
tab,
|
||||
onTabChange,
|
||||
}: {
|
||||
logs: LogLine[];
|
||||
history: MetricsHistory | null;
|
||||
tab: "log" | "metrics";
|
||||
onTabChange: (next: "log" | "metrics") => void;
|
||||
}) {
|
||||
const [height, setHeight] = useState(BOTTOM_PANEL_DEFAULT_HEIGHT);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const dragRef = useRef<{ startY: number; startH: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFullscreen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setIsFullscreen(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [isFullscreen]);
|
||||
|
||||
const onHandlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (isFullscreen) return;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
dragRef.current = { startY: e.clientY, startH: height };
|
||||
};
|
||||
const onHandlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragRef.current) return;
|
||||
const dy = dragRef.current.startY - e.clientY;
|
||||
const max =
|
||||
typeof window !== "undefined"
|
||||
? Math.max(BOTTOM_PANEL_MIN_HEIGHT, window.innerHeight - 100)
|
||||
: 800;
|
||||
setHeight(
|
||||
Math.max(
|
||||
BOTTOM_PANEL_MIN_HEIGHT,
|
||||
Math.min(max, dragRef.current.startH + dy),
|
||||
),
|
||||
);
|
||||
};
|
||||
const onHandlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragRef.current) return;
|
||||
dragRef.current = null;
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col bg-zinc-950",
|
||||
isFullscreen
|
||||
? "absolute inset-0 z-50 border-t"
|
||||
: "shrink-0 border-t",
|
||||
)}
|
||||
style={isFullscreen ? undefined : { height: `${height}px` }}
|
||||
>
|
||||
{!isFullscreen && (
|
||||
<div
|
||||
onPointerDown={onHandlePointerDown}
|
||||
onPointerMove={onHandlePointerMove}
|
||||
onPointerUp={onHandlePointerUp}
|
||||
onPointerCancel={onHandlePointerUp}
|
||||
className="group h-1.5 shrink-0 cursor-ns-resize bg-zinc-900 hover:bg-sky-500/40 active:bg-sky-500/60"
|
||||
title="拖动调整高度"
|
||||
>
|
||||
<div className="mx-auto mt-[2px] h-[2px] w-10 rounded-full bg-zinc-700 group-hover:bg-sky-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-h-0 flex-1 flex-col px-5 py-3">
|
||||
<div className="mb-2 flex shrink-0 items-center justify-between">
|
||||
<div className="inline-flex rounded-full bg-zinc-900/80 p-0.5">
|
||||
<PillTab active={tab === "log"} onClick={() => onTabChange("log")}>
|
||||
Live Log
|
||||
</PillTab>
|
||||
<PillTab
|
||||
active={tab === "metrics"}
|
||||
onClick={() => onTabChange("metrics")}
|
||||
>
|
||||
Metrics
|
||||
</PillTab>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFullscreen((v) => !v)}
|
||||
className="inline-flex items-center gap-1 text-[11px] text-zinc-400 transition-colors hover:text-zinc-200"
|
||||
title={isFullscreen ? "退出 (Esc)" : "查看全部"}
|
||||
>
|
||||
查看全部
|
||||
{isFullscreen ? (
|
||||
<Minimize2Icon className="size-3" />
|
||||
) : (
|
||||
<Maximize2Icon className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
{tab === "log" ? (
|
||||
<LogStreamBody logs={logs} />
|
||||
) : (
|
||||
<MetricsChartPanel history={history} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PillTab({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 font-semibold text-[11px] uppercase tracking-[0.12em] transition-colors",
|
||||
active
|
||||
? "bg-zinc-700 text-zinc-50"
|
||||
: "text-zinc-400 hover:text-zinc-200",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function LogStreamBody({ logs }: { logs: LogLine[] }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
|
||||
}, [logs]);
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="h-full min-h-0 overflow-y-auto font-mono text-[12px] text-zinc-100 leading-6"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-zinc-500">暂无日志</div>
|
||||
) : (
|
||||
logs.map((line, idx) => (
|
||||
<div key={idx} className="flex gap-3">
|
||||
<span className="text-zinc-500">{line.ts}</span>
|
||||
<span className="text-emerald-400">[iter={line.iter}]</span>
|
||||
<span>{line.text}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusText(state: CardStatus) {
|
||||
const map: Record<CardStatus, string> = {
|
||||
running: "Running",
|
||||
waiting: "等待人工确认",
|
||||
complete: "Complete",
|
||||
failed: "Failed",
|
||||
pending: "Idle",
|
||||
cancelled: "Cancelled",
|
||||
};
|
||||
return map[state];
|
||||
}
|
||||
@@ -48,9 +48,11 @@ function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
hideClose = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
hideClose?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
@@ -72,10 +74,12 @@ function SheetContent({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{hideClose ? null : (
|
||||
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
|
||||
@@ -87,3 +87,19 @@ function normalizeSessionId(value: unknown) {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
// 记录"前端刚生成的 LOCALID"。AssistantWorkspace 的 draft cache effect 用它
|
||||
// 区分:从 newTask 切到一个**新生成**的 LOCALID(草稿应继承)vs 切到一个
|
||||
// **侧栏点击**的老 LOCALID(草稿不应继承)。
|
||||
const freshLocalIds = new Set<string>();
|
||||
|
||||
export function markFreshLocalId(sessionId: string) {
|
||||
freshLocalIds.add(sessionId);
|
||||
}
|
||||
|
||||
export function consumeFreshLocalId(sessionId: string | null): boolean {
|
||||
if (!sessionId) return false;
|
||||
if (!freshLocalIds.has(sessionId)) return false;
|
||||
freshLocalIds.delete(sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -73,6 +73,60 @@ export async function loginAccount(username: string, password: string) {
|
||||
return createSession(account);
|
||||
}
|
||||
|
||||
const XIAOMI_EMAIL_RE = /^([\w.-]+)@xiaomi\.com$/i;
|
||||
|
||||
export function accountIdFromEmail(email: string): string {
|
||||
const match = XIAOMI_EMAIL_RE.exec((email ?? "").trim());
|
||||
if (!match) {
|
||||
throw new Error("请使用小米邮箱(@xiaomi.com)登录");
|
||||
}
|
||||
const prefix = match[1].toLowerCase();
|
||||
if (!/^[a-z0-9._-]{2,40}$/.test(prefix)) {
|
||||
throw new Error(
|
||||
"邮箱前缀只能包含小写字母、数字、点、下划线或中划线,长度 2-40",
|
||||
);
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
|
||||
export async function registerByEmail(email: string, password: string) {
|
||||
const accountId = accountIdFromEmail(email);
|
||||
validatePassword(password);
|
||||
const usersFile = await readUsers();
|
||||
if (usersFile.users.some((user) => user.id === accountId)) {
|
||||
throw new Error("该邮箱已注册,请直接登录");
|
||||
}
|
||||
const account: UserRecord = {
|
||||
id: accountId,
|
||||
username: accountId,
|
||||
passwordHash: hashPassword(password),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
usersFile.users.push(account);
|
||||
await ensureLinuxAccount(account.id, password);
|
||||
await writeUsers(usersFile);
|
||||
await createAccountDirectories(account.id);
|
||||
return createSession(account);
|
||||
}
|
||||
|
||||
export async function loginByEmail(email: string, password: string) {
|
||||
const accountId = accountIdFromEmail(email);
|
||||
const usersFile = await readUsers();
|
||||
const account = usersFile.users.find((user) => user.id === accountId);
|
||||
if (!account) {
|
||||
throw new Error("账号不存在,请先注册");
|
||||
}
|
||||
if (!account.passwordHash) {
|
||||
throw new Error("该账号尚未设置密码,请联系管理员重置");
|
||||
}
|
||||
if (!verifyPassword(password, account.passwordHash)) {
|
||||
throw new Error("邮箱或密码错误");
|
||||
}
|
||||
await ensureLinuxAccount(account.id, password);
|
||||
await createAccountDirectories(account.id);
|
||||
return createSession(account);
|
||||
}
|
||||
|
||||
export async function logoutAccount() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export const SESSION_UPDATED_EVENT = "claw-session-updated";
|
||||
const APPLIED_STORAGE_PREFIX = "claw.training.applied:";
|
||||
|
||||
const MODEL_ITERATION_SKILL_NAMES = new Set(["model-iteration"]);
|
||||
|
||||
const SKILL_POLL_MS = 8000;
|
||||
|
||||
export function useModelIterationEnabled(): {
|
||||
enabled: boolean;
|
||||
loading: boolean;
|
||||
} {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/claw/skills", { cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
if (!cancelled) {
|
||||
setEnabled(false);
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const skills = await res.json();
|
||||
if (!Array.isArray(skills)) {
|
||||
if (!cancelled) {
|
||||
setEnabled(false);
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const isOn = skills.some(
|
||||
(s: { name?: string; enabled?: boolean }) =>
|
||||
MODEL_ITERATION_SKILL_NAMES.has(String(s?.name ?? "")) &&
|
||||
s?.enabled !== false,
|
||||
);
|
||||
if (!cancelled) {
|
||||
setEnabled(isOn);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setEnabled(false);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, SKILL_POLL_MS);
|
||||
const onFocus = () => tick();
|
||||
window.addEventListener("focus", onFocus);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(id);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { enabled, loading };
|
||||
}
|
||||
|
||||
// applied 状态用 localStorage(按 session id)持久化:
|
||||
// - 关 tab / 浏览器重启都不丢
|
||||
// - 刷新页面也保持
|
||||
// 不走后端 PATCH 是为了避开 __LOCALID_* 临时 session id 导致 404
|
||||
export function useAppliedTraining(sessionId: string | null) {
|
||||
const [applied, setApplied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || typeof window === "undefined") {
|
||||
setApplied(false);
|
||||
return;
|
||||
}
|
||||
const stored =
|
||||
window.localStorage.getItem(APPLIED_STORAGE_PREFIX + sessionId) === "1";
|
||||
setApplied(stored);
|
||||
}, [sessionId]);
|
||||
|
||||
const setAppliedPersist = useCallback(
|
||||
(next: boolean) => {
|
||||
setApplied(next);
|
||||
if (typeof window === "undefined") return;
|
||||
if (!sessionId) return;
|
||||
const key = APPLIED_STORAGE_PREFIX + sessionId;
|
||||
if (next) {
|
||||
window.localStorage.setItem(key, "1");
|
||||
} else {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
},
|
||||
[sessionId],
|
||||
);
|
||||
|
||||
return { applied, setApplied: setAppliedPersist };
|
||||
}
|
||||
|
||||
export type ProgramStatus = {
|
||||
available: boolean;
|
||||
path?: string;
|
||||
last_modified_ms?: number | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const PROGRAM_STATUS_POLL_MS = 5000;
|
||||
|
||||
export function useProgramStatus(): ProgramStatus & { loading: boolean } {
|
||||
const [status, setStatus] = useState<ProgramStatus>({ available: false });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/claw/training/program-status", {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (!cancelled) {
|
||||
setStatus({ available: false });
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const payload = (await res.json()) as ProgramStatus;
|
||||
if (!cancelled) {
|
||||
setStatus(payload);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setStatus({ available: false });
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, PROGRAM_STATUS_POLL_MS);
|
||||
const onFocus = () => tick();
|
||||
window.addEventListener("focus", onFocus);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(id);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { ...status, loading };
|
||||
}
|
||||
|
||||
type SessionUpdatedDetail = {
|
||||
sessionId: string;
|
||||
is_training?: boolean;
|
||||
};
|
||||
|
||||
export function dispatchSessionUpdated(detail: SessionUpdatedDetail) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<SessionUpdatedDetail>(SESSION_UPDATED_EVENT, { detail }),
|
||||
);
|
||||
}
|
||||
|
||||
export function useTrainingMode(sessionId: string | null) {
|
||||
const [isTraining, setIsTraining] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoaded(false);
|
||||
if (!sessionId) {
|
||||
setIsTraining(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
fetch(`/api/claw/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
cache: "no-store",
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
})
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
const next = Boolean(payload?.is_training);
|
||||
setIsTraining(next);
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoaded(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
const setTraining = useCallback(
|
||||
async (next: boolean) => {
|
||||
if (!sessionId) return;
|
||||
const previous = isTraining;
|
||||
setIsTraining(next);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/claw/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ is_training: next }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
setIsTraining(previous);
|
||||
return;
|
||||
}
|
||||
dispatchSessionUpdated({ sessionId, is_training: next });
|
||||
} catch {
|
||||
setIsTraining(previous);
|
||||
}
|
||||
},
|
||||
[sessionId, isTraining],
|
||||
);
|
||||
|
||||
return { isTraining, setTraining, loaded };
|
||||
}
|
||||
Generated
+421
@@ -26,6 +26,8 @@
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^2.15.4",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
@@ -35,6 +37,7 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.13",
|
||||
"@tailwindcss/postcss": "^4.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -4676,6 +4679,82 @@
|
||||
"tailwindcss": "4.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/typography": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
||||
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss-selector-parser": "6.0.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
||||
@@ -4930,11 +5009,145 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"cssesc": "bin/cssesc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -4951,6 +5164,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decode-named-character-reference": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
||||
@@ -4997,6 +5216,16 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.21.0",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
|
||||
@@ -5030,6 +5259,12 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eventsource-parser": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz",
|
||||
@@ -5043,6 +5278,15 @@
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
||||
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-nonce": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||
@@ -5109,6 +5353,15 @@
|
||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
||||
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-alphabetical": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
||||
@@ -5169,6 +5422,12 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
|
||||
@@ -5423,6 +5682,12 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/longest-streak": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
|
||||
@@ -5432,6 +5697,18 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz",
|
||||
@@ -6366,6 +6643,15 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-entities": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
|
||||
@@ -6422,6 +6708,20 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss/node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
@@ -6440,6 +6740,23 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types/node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/property-information": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
|
||||
@@ -6645,6 +6962,12 @@
|
||||
"react": "^19.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-markdown": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
||||
@@ -6716,6 +7039,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-smooth": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-equals": "^5.0.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-transition-group": "^4.4.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
@@ -6753,6 +7091,54 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.6.0",
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "2.15.4",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
|
||||
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"eventemitter3": "^4.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"react-is": "^18.3.1",
|
||||
"react-smooth": "^4.0.4",
|
||||
"recharts-scale": "^0.4.4",
|
||||
"tiny-invariant": "^1.3.1",
|
||||
"victory-vendor": "^36.6.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts-scale": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
|
||||
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decimal.js-light": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-gfm": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
|
||||
@@ -7009,6 +7395,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/trim-lines": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||
@@ -7247,6 +7639,13 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vfile": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||
@@ -7273,6 +7672,28 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "36.9.2",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^2.15.4",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
@@ -39,6 +41,7 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.13",
|
||||
"@tailwindcss/postcss": "^4.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
# Skill: model-iteration
|
||||
|
||||
本目录是小爱中控模型迭代 skill,用于评测、错误分析、数据增强、SFT 训练和 CML workflow 追踪。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
model-iteration/
|
||||
├── config.yaml # 评估阈值 + CML 工作流配置
|
||||
├── knowledge/ # 分流知识库
|
||||
│ ├── navigation_routing_rules.md # 地图导航 Agent/ComplexTask 分流
|
||||
│ ├── travel_routing_rules.md # 旅游 Agent/ComplexTask 分流
|
||||
│ └── multi_command_rules.md # 多指令 vs ComplexTask 判定
|
||||
├── scripts/
|
||||
│ ├── prepare_and_train_sft.py # 数据组装 + 本地训练
|
||||
│ ├── submit_sft_via_cml.sh # CML 云端训练提交(R29 起统一用此)
|
||||
│ └── sft_train_job.yaml.tpl # CML 训练 job YAML 模板
|
||||
├── SKILL.md # 完整迭代协议(触发规则、Step 0-6、数据增强/清洗规范)
|
||||
└── README.md # 本文件
|
||||
```
|
||||
|
||||
## 快速上手
|
||||
|
||||
### 1. 评测当前模型
|
||||
|
||||
```bash
|
||||
cml workflow run \
|
||||
--workflow_id f-20260408161444-wu3pz --version v28 \
|
||||
--global_inputs runDic=<N> \
|
||||
--global_inputs model_path_new=<新模型路径> \
|
||||
--global_inputs model_path_old=<基线模型路径>
|
||||
```
|
||||
|
||||
### 2. SFT 训练(CML 云端)
|
||||
|
||||
```bash
|
||||
AUTORESEARCH_ROOT=/mnt/wangsenhao/autoresearch-zk \
|
||||
./scripts/submit_sft_via_cml.sh <RUNDIC> [PREV_RUNDIC]
|
||||
```
|
||||
|
||||
训练完成后自动起评测 workflow。
|
||||
|
||||
### 3. SFT 训练(本地)
|
||||
|
||||
```bash
|
||||
AUTORESEARCH_ROOT=/mnt/wangsenhao/autoresearch-zk \
|
||||
python scripts/prepare_and_train_sft.py prepare --output_dir ./sft_data
|
||||
|
||||
AUTORESEARCH_ROOT=/mnt/wangsenhao/autoresearch-zk \
|
||||
python scripts/prepare_and_train_sft.py train \
|
||||
--data_dir ./sft_data \
|
||||
--model_path /mnt/wangsenhao/verl_zk/Qwen3-4B-Instruct-2507 \
|
||||
--model_type qwen3 \
|
||||
--train_output ./sft_output \
|
||||
--epochs 3 --lr 1e-5
|
||||
```
|
||||
|
||||
## 关键规则
|
||||
|
||||
- **达标标准**:需求集合 ≥ 95%,大盘车载降幅 ≤ 0.3%,specific test 降幅 ≤ 1%
|
||||
- **数据清洗**:case 驱动、禁止批量、必须备份、只改 output 不动 instruction
|
||||
- **训练环境**:FSDP2 + torch 2.6 需要 accelerate==1.7.0,不要改训练代码去适配
|
||||
|
||||
## 完整协议
|
||||
|
||||
详见 [SKILL.md](SKILL.md)
|
||||
+548
-1276
File diff suppressed because it is too large
Load Diff
@@ -9,5 +9,5 @@ thresholds:
|
||||
# CML 评测工作流配置
|
||||
cml_eval:
|
||||
workflow_id: f-20260408161444-wu3pz
|
||||
version: v28
|
||||
version: v32
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# 多轮对话连贯性 —— ComplexTask 继承规则
|
||||
|
||||
## 核心判定
|
||||
|
||||
| 场景 | 输出 | 示例 |
|
||||
|------|------|------|
|
||||
| 上轮是 ComplexTask + 当前 query 和上轮**相关**(延续/细化/替换/挑选/追加) | 当前仍 `ComplexTask(tag="...")` | 上轮 CT 导航多目的地,当前"第二家导航过去" → CT |
|
||||
| 上轮是 Agent + 当前 query 是独立新任务 | 按当前 query 独立分类 | 上轮 Agent 导航,当前"播放周杰伦" → Agent(音乐播放) |
|
||||
| 上轮是 ComplexTask + 当前 query 是**无关**新任务 | 按当前独立分类 | 上轮 CT 旅游,当前"今天天气怎么样" → QA/WeatherQA |
|
||||
|
||||
## 判定要点
|
||||
|
||||
- **"相关"的定义**:当前 query 是对上轮任务的**延续、细化、替换、挑选、追加**中的任一类:
|
||||
- 延续:用户在上轮的同一任务线上继续操作("继续导航"/"换一条不堵的")
|
||||
- 细化:对上轮结果加筛选条件("人少一点的"/"评分最高的"/"最近的那个")
|
||||
- 替换:换掉上轮的某个要素,同领域保持("不要 A 换成 B")
|
||||
- 挑选:从上轮推荐的候选中选择("第一个"/"第二家"/"刚给的那个")
|
||||
- 追加:在上轮任务基础上加步骤("路上再加个加油站"/"顺便找个 ATM")
|
||||
|
||||
- **prev 侧"隐式 CT 信号"(R17777 补充)**:prev 即使只是一轮用户独白,只要含以下任一信号也视作 CT 场景:
|
||||
- **单轮内自我修正/替换**:"导航到 A 哦不对 B"、"去 X 啊不 Y"、"到 A 嗯就是 B" —— 用户在说的过程中换目的地,系统需处理替换逻辑
|
||||
- **候选选择犹豫**:"是 A 还是 B"、"哪个近就哪个"、"算了不去 A 了"
|
||||
- **多 POI 枚举/对比**:"A 和 B 哪个近"、"先说 A 再说 B"
|
||||
|
||||
> 注:单 POI + 多重形容词筛选("最便宜的有车位的充电站")**不算** CT 信号,按 R1 归 Agent。
|
||||
|
||||
- **继承的是 `complex` 维度,不是 tag 内容**:tag 仍可能是 Agent 标签,但 complex=true,因为复合任务语境未结束
|
||||
|
||||
## 反例(不适用继承)
|
||||
|
||||
- 当前 query 明显切换意图:上轮 CT 导航 → 当前"打开空调"(tag=车载控制,无关)→ 独立分类
|
||||
- 当前 query 是独立的闲聊 / 时间 / 天气问答:上轮 CT → 当前"现在几点" → 独立分类
|
||||
- 上轮是 CT 但失败/取消("算了不要了"):后续 query 不再继承
|
||||
|
||||
## 为什么需要这条规则(经验来源)
|
||||
|
||||
R17775 迭代中,我们修改了训练集 47 条"纯路线偏好"样本 complex=true → false,对齐 0511 专项 gold(60% → 76.67%)。但这产生副作用:
|
||||
|
||||
- `复杂导航线上真实_rag` -3.30pp(11 条 gold=true 被过度修正为 false)
|
||||
- 如 "我要重新选一个路线"、"嗯经过凤雏路去江北虹悦城"
|
||||
- `0511 专项` 新引入 6 条错全是**多轮延续 POI/挑选**类
|
||||
- 如 "评分最高的那个导航过去"、"第二家导航过去"、"人少一点的"
|
||||
|
||||
根因:单看 query 这些表达像"纯偏好",但在多轮上下文中是**对上轮 ComplexTask 的挑选/细化**,应继承 CT。SFT 模型在只看当前 query 时判错。
|
||||
|
||||
## 应用场景
|
||||
|
||||
- **数据增强**:生成带 prev_session 的训练样本时,如果 prev 是 `ComplexTask(...)`,当前 query 与之相关 → ground_truth 必须是 ComplexTask(不能矛盾)
|
||||
- **训练集清洗**:不要把"多轮延续同任务"类样本的 complex=true 改为 false(R17774 的 47 条改动里,L10752 / L10943 / L10954 等 multi_turn_deixis 已保留未改,正确)
|
||||
- **Badcase 分析**:错例 query 如果语境是对上轮 CT 的延续,SFT 模型判错不能靠修改训练集同 pattern 样本的 label 来解,需要补"带 prev_session 的延续 CT"仿写增强模型学到上下文信号
|
||||
- **Reward 函数**:对违反此规则的预测(prev=CT + 相关继续 但 pred complex=false)应被惩罚
|
||||
|
||||
## 反模式
|
||||
|
||||
- ❌ 只看当前 query 文本判 complex,忽略 prev_session
|
||||
- ❌ 训练集批改 complex=true→false 时未检查 prev_session,导致多轮延续样本被误改
|
||||
- ❌ 生成仿写时当前 query 与 prev_session 矛盾(prev CT 但 current 标 complex=false)
|
||||
+2
-2
@@ -4,8 +4,8 @@
|
||||
|
||||
| 场景 | 输出 | 示例 |
|
||||
|------|------|------|
|
||||
| 规划路线,无筛选条件 | `Agent(tag="旅游出行")` | 规划一个从北京到上海的路线 |
|
||||
| 规划路线,有筛选条件 | `ComplexTask(tag="旅游出行")` | 规划一个从北京到上海的路线,人少风景好、三天两夜、3000预算 |
|
||||
| 规划路线,无筛选条件 | `Agent(tag="旅游")` | 规划一个从北京到上海的路线 |
|
||||
| 规划路线,有筛选条件 | `ComplexTask(tag="旅游")` | 规划一个从北京到上海的路线,人少风景好、三天两夜、3000预算 |
|
||||
|
||||
## 判定要点
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,18 +2,16 @@
|
||||
"""
|
||||
从 ai-planning 组装 SFT 训练/验证集,并启动 zk_trainer SFT 训练。
|
||||
|
||||
用法:
|
||||
用法(先 cd "$AUTORESEARCH_CHAT_ROOT",所有产物落 chat 工作区):
|
||||
# 仅组装数据
|
||||
AUTORESEARCH_ROOT=/mnt/wangsenhao/autoresearch-zk \
|
||||
python prepare_and_train_sft.py prepare --output_dir /mnt/wangsenhao/autoresearch-zk/sft_data
|
||||
python scripts/prepare_and_train_sft.py prepare --output_dir "$AUTORESEARCH_CHAT_ROOT/sft_data"
|
||||
|
||||
# 启动训练(需先 prepare)
|
||||
AUTORESEARCH_ROOT=/mnt/wangsenhao/autoresearch-zk \
|
||||
python prepare_and_train_sft.py train \
|
||||
--data_dir /mnt/wangsenhao/autoresearch-zk/sft_data \
|
||||
python scripts/prepare_and_train_sft.py train \
|
||||
--data_dir "$AUTORESEARCH_CHAT_ROOT/sft_data" \
|
||||
--model_path /mnt/wangsenhao/verl_zk/Qwen3-4B-Instruct-2507 \
|
||||
--model_type qwen3 \
|
||||
--train_output /mnt/wangsenhao/autoresearch-zk/sft_output
|
||||
--train_output "$AUTORESEARCH_CHAT_ROOT/sft_output"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -28,14 +26,8 @@ from pathlib import Path
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _path_from_env(name: str, fallback: Path) -> Path:
|
||||
return Path(os.environ.get(name, str(fallback))).expanduser().resolve()
|
||||
|
||||
|
||||
AUTORESEARCH_ROOT = _path_from_env("AUTORESEARCH_ROOT", Path("/mnt/wangsenhao/autoresearch-zk"))
|
||||
AI_PLANNING = _path_from_env("AI_PLANNING_DIR", AUTORESEARCH_ROOT / "ai-planning")
|
||||
ZK_TRAINER_DIR = _path_from_env("ZK_TRAINER_DIR", AUTORESEARCH_ROOT / "zk_trainer")
|
||||
CHAT_ROOT = Path(__file__).resolve().parent.parent
|
||||
AI_PLANNING = CHAT_ROOT / "ai-planning"
|
||||
TRAIN_SET_DIR = AI_PLANNING / "data" / "train_set"
|
||||
DATA_TRAIN_DIR = AI_PLANNING / "data_train"
|
||||
GENERATE_TRAIN_SCRIPT = DATA_TRAIN_DIR / "generate_train.py"
|
||||
@@ -157,7 +149,7 @@ def run_sft_training(data_dir: str, model_path: str, model_type: str, train_outp
|
||||
epochs: int = 3, lr: float = 1e-5, max_seq_length: int = 1024,
|
||||
per_device_batch: int = 1, grad_accum: int = 1):
|
||||
"""基于 zk_trainer 的 accelerate launch 方式启动 SFT 训练。"""
|
||||
zk_trainer_dir = str(ZK_TRAINER_DIR)
|
||||
zk_trainer_dir = str(CHAT_ROOT / "zk_trainer")
|
||||
if not os.path.isdir(zk_trainer_dir):
|
||||
log.error(f"zk_trainer 目录不存在: {zk_trainer_dir}")
|
||||
return None
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
# resolve_run_ids.sh —— SFT/EVAL runDic 单一可信来源
|
||||
#
|
||||
# 规则(与 program.md §746 + §5.2 对齐):
|
||||
# SFT_RUNDIC = workflow5/ 下「已落盘」(含 metric_diff/lark_template.json)的最大 runDic
|
||||
# 即 R{n-1}.runDic —— augment 落盘、SFT yaml、modified_samples 归档全用这个值
|
||||
# EVAL_RUNDIC = SFT_RUNDIC + 1
|
||||
# 仅 cml workflow run 提交本轮评测时使用
|
||||
#
|
||||
# 用法:
|
||||
# eval "$(./scripts/resolve_run_ids.sh)"
|
||||
# echo "$SFT_RUNDIC $EVAL_RUNDIC"
|
||||
#
|
||||
# 失败行为:找不到任何已落盘 workflow 时打 ERR 到 stderr 并 exit 1,调用方 set -e 时直接终止。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
WF_ROOT=${WF_ROOT:-/mnt/xiaoai-zk-model-train-tj5/workflow5}
|
||||
|
||||
if [ ! -d "$WF_ROOT" ]; then
|
||||
echo "ERR: WF_ROOT=$WF_ROOT 不存在或不可读" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 只挑「已落盘」的 workflow:必须存在 metric_diff/lark_template.json
|
||||
# 这样可以避免 CML 创建了空目录就被算成最大值
|
||||
MAX=$(
|
||||
for d in "$WF_ROOT"/workflow*; do
|
||||
[ -d "$d" ] || continue
|
||||
[ -f "$d/metric_diff/lark_template.json" ] || continue
|
||||
name=$(basename "$d")
|
||||
echo "${name#workflow}"
|
||||
done | sort -n | tail -1
|
||||
)
|
||||
|
||||
if [ -z "$MAX" ]; then
|
||||
echo "ERR: $WF_ROOT 下没有任何已落盘的 workflow(含 metric_diff/lark_template.json)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "SFT_RUNDIC=$MAX"
|
||||
echo "EVAL_RUNDIC=$((MAX+1))"
|
||||
Regular → Executable
+38
-18
@@ -1,17 +1,35 @@
|
||||
#!/bin/bash
|
||||
# 用 cml custom_train submit 提交 SFT 训练,训练完成后自动起 cml workflow run 评测
|
||||
# 用法: ./submit_sft_via_cml.sh <RUNDIC>
|
||||
# 例: ./submit_sft_via_cml.sh 17756
|
||||
#
|
||||
# 用法:
|
||||
# ./submit_sft_via_cml.sh <SFT_RUNDIC> <EVAL_RUNDIC> [PREV_RUNDIC]
|
||||
#
|
||||
# 推荐调用方式(runDic 由 resolve_run_ids.sh 决定,不要手敲):
|
||||
# eval "$(./scripts/resolve_run_ids.sh)"
|
||||
# ./scripts/submit_sft_via_cml.sh "$SFT_RUNDIC" "$EVAL_RUNDIC"
|
||||
#
|
||||
# 语义(与 program.md §746 + §5.2 对齐):
|
||||
# SFT_RUNDIC = R{n-1}.runDic(augment / yaml / 归档全用这个,不 +1)
|
||||
# EVAL_RUNDIC = R{n-1}.runDic + 1(仅本步起 eval 时使用,仅在这一处 +1)
|
||||
# PREV_RUNDIC = SFT_RUNDIC - 1(默认;只用作上一轮 sft_output 改名)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RUNDIC=${1:?usage: $0 <RUNDIC>}
|
||||
PREV_RUNDIC=${2:-$((RUNDIC-1))}
|
||||
SFT_RUNDIC=${1:?usage: $0 <SFT_RUNDIC> <EVAL_RUNDIC> [PREV_RUNDIC]}
|
||||
EVAL_RUNDIC=${2:?usage: $0 <SFT_RUNDIC> <EVAL_RUNDIC> [PREV_RUNDIC]}
|
||||
PREV_RUNDIC=${3:-$((SFT_RUNDIC-1))}
|
||||
|
||||
# 防御:EVAL_RUNDIC 必须严格大于 SFT_RUNDIC,否则一定是调用方算错
|
||||
if [ "$EVAL_RUNDIC" -le "$SFT_RUNDIC" ]; then
|
||||
echo "[cml-sft] ❌ EVAL_RUNDIC ($EVAL_RUNDIC) 必须 > SFT_RUNDIC ($SFT_RUNDIC)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT=${AUTORESEARCH_ROOT:-/mnt/wangsenhao/autoresearch-zk}
|
||||
MODEL_OLD=${MODEL_OLD:-/mnt/wangsenhao/verl_zk/qwen4b_cispo_wokl_add_bvt_2/global_step_5/actor/huggingface}
|
||||
TPL=${SFT_TRAIN_JOB_TEMPLATE:-$SCRIPT_DIR/sft_train_job.yaml.tpl}
|
||||
YAML=/tmp/sft_train_job_r${RUNDIC}.yaml
|
||||
YAML=/tmp/sft_train_job_r${SFT_RUNDIC}.yaml
|
||||
|
||||
if [ -f ~/.cloudml-cli/.profile ]; then
|
||||
source ~/.cloudml-cli/.profile
|
||||
@@ -20,13 +38,13 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. 渲染 yaml 模板
|
||||
# 1. 渲染 yaml 模板(用 SFT_RUNDIC,不是 EVAL_RUNDIC)
|
||||
sed \
|
||||
-e "s|{RUNDIC}|${RUNDIC}|g" \
|
||||
-e "s|{RUNDIC}|${SFT_RUNDIC}|g" \
|
||||
-e "s|{PREV_RUNDIC}|${PREV_RUNDIC}|g" \
|
||||
-e "s|{AUTORESEARCH_ROOT}|${ROOT}|g" \
|
||||
"$TPL" > "$YAML"
|
||||
echo "[cml-sft] yaml: $YAML"
|
||||
echo "[cml-sft] yaml: $YAML (SFT_RUNDIC=$SFT_RUNDIC EVAL_RUNDIC=$EVAL_RUNDIC PREV_RUNDIC=$PREV_RUNDIC)"
|
||||
|
||||
# 2. 提交训练任务
|
||||
SUBMIT_OUT=$(cml custom_train submit --filename "$YAML" 2>&1)
|
||||
@@ -38,13 +56,14 @@ if [ -z "$JOB_ID" ]; then
|
||||
fi
|
||||
echo "[cml-sft] ✅ JobID: $JOB_ID"
|
||||
|
||||
# 3. 后台 watcher:等训练成功 → 自动起评测
|
||||
WATCHER_LOG=/tmp/r${RUNDIC}_logs/cml_watcher.log
|
||||
# 3. 后台 watcher:等训练成功 → 自动起评测(用 EVAL_RUNDIC)
|
||||
WATCHER_LOG=/tmp/r${SFT_RUNDIC}_logs/cml_watcher.log
|
||||
mkdir -p "$(dirname "$WATCHER_LOG")"
|
||||
|
||||
nohup bash -c '
|
||||
JOB_ID='"$JOB_ID"'
|
||||
RUNDIC='"$RUNDIC"'
|
||||
SFT_RUNDIC='"$SFT_RUNDIC"'
|
||||
EVAL_RUNDIC='"$EVAL_RUNDIC"'
|
||||
LOG='"$WATCHER_LOG"'
|
||||
ROOT='"$ROOT"'
|
||||
MODEL_NEW="$ROOT/sft_output"
|
||||
@@ -74,15 +93,15 @@ done
|
||||
echo "[$(date)] ❌ 产出缺失" >> "$LOG"; exit 1
|
||||
}
|
||||
|
||||
# 起评测
|
||||
echo "[$(date)] 启动 CML 评测 runDic=$RUNDIC" >> "$LOG"
|
||||
# 起评测(这里且仅这里用 EVAL_RUNDIC)
|
||||
echo "[$(date)] 启动 CML 评测 EVAL_RUNDIC=$EVAL_RUNDIC" >> "$LOG"
|
||||
cml workflow run \
|
||||
--workflow_id $EVAL_WORKFLOW_ID --version $EVAL_VERSION \
|
||||
--global_inputs runDic=$RUNDIC \
|
||||
--global_inputs runDic=$EVAL_RUNDIC \
|
||||
--global_inputs model_path_new=$MODEL_NEW \
|
||||
--global_inputs model_path_old=$MODEL_OLD >> "$LOG" 2>&1
|
||||
echo "[$(date)] cml workflow run 提交完成" >> "$LOG"
|
||||
' > /tmp/r${RUNDIC}_logs/watcher_runner.log 2>&1 &
|
||||
echo "[$(date)] cml workflow run 提交完成 (workflow$EVAL_RUNDIC)" >> "$LOG"
|
||||
' > /tmp/r${SFT_RUNDIC}_logs/watcher_runner.log 2>&1 &
|
||||
|
||||
WATCHER_PID=$!
|
||||
echo "[cml-sft] watcher PID: $WATCHER_PID(log: $WATCHER_LOG)"
|
||||
@@ -97,8 +116,9 @@ cat <<EOF
|
||||
watcher log: tail -f $WATCHER_LOG
|
||||
|
||||
训练完成后,evaluation workflow 会自动启动;评测产物在
|
||||
/mnt/xiaoai-zk-model-train-tj5/workflow5/workflow${RUNDIC}/
|
||||
/mnt/xiaoai-zk-model-train-tj5/workflow5/workflow${EVAL_RUNDIC}/
|
||||
|
||||
JobID: $JOB_ID
|
||||
RUNDIC: $RUNDIC
|
||||
SFT_RUNDIC: $SFT_RUNDIC (yaml / sft_output 命名)
|
||||
EVAL_RUNDIC: $EVAL_RUNDIC (workflow 评测目录)
|
||||
EOF
|
||||
|
||||
@@ -162,8 +162,8 @@ def get_doing_tasks_section() -> str:
|
||||
'不要为了单次操作创建 helper 或抽象。优先使用能完整解决问题的最简单实现。',
|
||||
'除非确实需要新文件,否则优先编辑现有文件。',
|
||||
'对于文件解析、表格转换、JSON 或 JSONL 处理、日志分析、批量校验、数据抽样等任务,如果小型 Python 脚本比手工文本处理更可靠,可以编写小型 Python 脚本。',
|
||||
'小脚本应短小、可读,并明确输入和输出。需要执行 Python 代码或 Python 脚本时,使用 python_exec;不要通过 bash 执行 python、python3 或 .venv/bin/python。',
|
||||
'一次性 Python 分析优先直接传给 python_exec.code,不要为了临时分析创建项目文件。',
|
||||
'小脚本应短小、可读,并明确输入和输出。需要执行 Python 时,把代码写到 session/scratchpad 下的 .py 文件,再用 bash 跑 `python3 -u <script> 2>&1 | tee <log>`;`-u` 关 stdout 缓冲、`tee` 让进度行实时落盘,超时被 kill 时仍能 tail 日志看到死在哪。不要把多行 Python 代码以 `-c` 字符串方式塞进 bash command。',
|
||||
'一次性 Python 分析也走脚本文件 + bash,不要为了临时分析在项目根目录创建脚本,scratchpad 是默认临时区。',
|
||||
'如果确实需要临时脚本、缓存或中间产物,必须写入当前 session/scratchpad;交付产物必须优先写入当前 session/output。',
|
||||
'只有用户明确要求长期复用或该脚本属于产品代码时,才把 Python 脚本加入项目源码目录。',
|
||||
'当事情失败时,先诊断原因再改变方向。不要对同一个失败动作反复循环。',
|
||||
@@ -249,6 +249,9 @@ def get_using_your_tools_section(enabled_tool_names: set[str]) -> str:
|
||||
items.append(
|
||||
'需要可靠保存文件时,可以在 python_exec.code 中使用 pathlib.Path(...).write_text(...)、json.dump/json.dumps、csv.writer 或流式逐行写入;这比把大段内容塞进 write_file 参数更稳定。'
|
||||
)
|
||||
items.append(
|
||||
'python_exec 默认 timeout=30s。读 CSV/JSONL 大文件、pandas 处理、批量校验、训练评测脚本等可能 >30s 的任务,调用时显式传 timeout_seconds(常规 120-300、训练/长跑 600+),不要靠默认值。被 timeout(1) kill 后会以 timed_out=true 报错并提示同样的内容,看到就重跑并调大。'
|
||||
)
|
||||
if 'python_package' in enabled_tool_names:
|
||||
items.append(
|
||||
'当 python_exec 因缺少 pandas、pyarrow、openpyxl 等 Python 包失败时,使用 python_package 在当前用户独立 venv 中安装缺失包,然后重试;不要安装到系统 Python 或项目 .venv。'
|
||||
|
||||
@@ -1620,9 +1620,9 @@ class LocalCodingAgent:
|
||||
'请回复“继续”,我会要求模型重新生成合法 JSON 参数;'
|
||||
'如果是 write_file 写短文本/Markdown,应改用 content_lines;'
|
||||
'如果是很小的 JSON/JSONL/CSV,可用 json_content、jsonl_records 或 csv_rows;'
|
||||
'如果是脚本、长文本、大 JSON、JSONL、CSV 或生成数据,应直接改用 python_exec 写文件;'
|
||||
'如果是生成/转换结构化数据,应优先调用对应 skill 脚本或 python_exec,'
|
||||
'不要把大段内容直接塞进工具参数;bash 仅作为最后兜底。'
|
||||
'如果是脚本、长文本、大 JSON、JSONL、CSV 或生成数据,应改用 bash 配合 quoted heredoc 写文件,'
|
||||
'或先 write_file 落地一个小 .py 再用 bash 跑;'
|
||||
'不要把大段内容直接塞进工具参数。'
|
||||
) from exc
|
||||
if not isinstance(arguments, dict):
|
||||
raise OpenAICompatError(
|
||||
|
||||
@@ -10,6 +10,7 @@ from .agent_types import AgentPermissions, AgentRuntimeConfig, ToolExecutionResu
|
||||
if TYPE_CHECKING:
|
||||
from .account_runtime import AccountRuntime
|
||||
from .ask_user_runtime import AskUserRuntime
|
||||
from .bash_bg_store import BgTaskSpec, BgTaskStatus
|
||||
from .config_runtime import ConfigRuntime
|
||||
from .lsp_runtime import LSPRuntime
|
||||
from .mcp_runtime import MCPRuntime
|
||||
@@ -59,6 +60,12 @@ class ToolExecutionContext:
|
||||
team_runtime: 'TeamRuntime | None' = None
|
||||
workflow_runtime: 'WorkflowRuntime | None' = None
|
||||
worktree_runtime: 'WorktreeRuntime | None' = None
|
||||
bg_register: Callable[['BgTaskSpec'], None] | None = None
|
||||
bg_status_query: Callable[[str], 'BgTaskStatus | None'] | None = None
|
||||
bg_kill_request: Callable[[str], bool] | None = None
|
||||
account_id: str | None = None
|
||||
session_id: str | None = None
|
||||
run_id: str | None = None
|
||||
|
||||
|
||||
ToolHandler = Callable[
|
||||
|
||||
@@ -10,51 +10,6 @@ def build_execution_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool
|
||||
"""构建本地命令、Python 执行与短等待工具声明。"""
|
||||
|
||||
return [
|
||||
AgentTool(
|
||||
name='python_exec',
|
||||
description=(
|
||||
'优先用本工具执行小型 Python 代码或项目内 Python 脚本,用于结构化文件分析、'
|
||||
'JSON/JSONL 处理、批量校验、数据抽样和快速计算。默认使用当前用户独立 Python venv;'
|
||||
'不要用 bash 运行 python/python3/.venv/bin/python。不要用本工具安装依赖。'
|
||||
'缺包时应向用户确认后再处理依赖。一次性分析优先传 code;项目或 skill 内已有脚本优先传 script_path,'
|
||||
'不要在 code 里用 subprocess 二次调用 Python 脚本。如需写临时文件,'
|
||||
'必须写入环境变量 PYTHON_EXEC_SCRATCHPAD 指向的会话隔离目录,'
|
||||
'不要在项目根目录创建临时 .py 脚本。'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'code': {
|
||||
'type': 'string',
|
||||
'description': '要通过 python -c 执行的 Python 代码。code 和 script_path 必须二选一;一次性分析优先使用 code。',
|
||||
},
|
||||
'script_path': {
|
||||
'type': 'string',
|
||||
'description': '工作区内已有、需要长期复用的 Python 脚本路径。code 和 script_path 必须二选一;不要为一次性分析在项目根目录新建脚本。',
|
||||
},
|
||||
'args': {
|
||||
'type': 'array',
|
||||
'items': {'type': 'string'},
|
||||
'description': '传给脚本的命令行参数。仅在 script_path 模式下使用。',
|
||||
},
|
||||
'stdin': {
|
||||
'type': 'string',
|
||||
'description': '可选标准输入内容。',
|
||||
},
|
||||
'timeout_seconds': {
|
||||
'type': 'number',
|
||||
'minimum': 1,
|
||||
'description': '可选超时时间,默认使用当前会话命令超时。',
|
||||
},
|
||||
'max_output_chars': {
|
||||
'type': 'integer',
|
||||
'minimum': 100,
|
||||
'description': '可选输出截断长度,默认使用当前会话输出限制。',
|
||||
},
|
||||
},
|
||||
},
|
||||
handler=resolve_handler(handlers, 'python_exec', 'execution'),
|
||||
),
|
||||
AgentTool(
|
||||
name='python_package',
|
||||
description=(
|
||||
@@ -94,20 +49,91 @@ def build_execution_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool
|
||||
AgentTool(
|
||||
name='bash',
|
||||
description=(
|
||||
'运行真实 shell 命令,例如系统命令、进程控制、git 只读检查或用户已确认的依赖安装。'
|
||||
'不要用 bash 执行 python、python3、pip 或 .venv/bin/python;结构化文件分析、'
|
||||
'JSON/JSONL 处理、批量校验、数据抽样和快速计算必须优先使用 python_exec。'
|
||||
'Python 包安装必须优先使用 python_package。'
|
||||
'运行真实 shell 命令,包括系统命令、进程控制、git 只读检查、用户已确认的依赖安装,'
|
||||
'以及所有 Python 脚本执行。'
|
||||
'执行 Python 时,把代码先写到 scratchpad 下的 .py 文件,'
|
||||
'再用本工具跑 `python3 -u <script> 2>&1 | tee <log>`;'
|
||||
'`-u` 关掉 stdout 缓冲,`tee` 让进度行实时落盘,超时被 kill 时仍能 `tail` 日志看到死在哪个 phase。'
|
||||
'不要把多行 Python 代码以 `-c` 字符串方式塞进 command。'
|
||||
'Python 包安装必须优先使用 python_package;不要通过 bash 执行 pip。'
|
||||
'需要等待远端长任务(>30s 的训练、文件落盘、外部 API 回调)时,'
|
||||
'设置 run_in_background=true 让命令在后台 detach 跑、立刻返回 task_id;'
|
||||
'默认 wait_for_completion=true 时进程结束后会自动起新一轮把结果送回。'
|
||||
'后台任务输出落在 <remote_workspace>/.bg_tasks/<task_id>/output。'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'command': {'type': 'string'},
|
||||
'run_in_background': {
|
||||
'type': 'boolean',
|
||||
'description': (
|
||||
'后台 detach 跑。立刻返回 task_id,不等命令结束。'
|
||||
'适合 >30s 的远端任务、需要等异步事件的场景。'
|
||||
),
|
||||
},
|
||||
'wait_for_completion': {
|
||||
'type': 'boolean',
|
||||
'description': (
|
||||
'仅在 run_in_background=true 时生效。'
|
||||
'true(默认):进程结束后由后端自动起新一轮把结果作为 system 消息送回;'
|
||||
'false:不自动续,agent 需自行调 bash_status 取结果。'
|
||||
),
|
||||
},
|
||||
},
|
||||
'required': ['command'],
|
||||
},
|
||||
handler=resolve_handler(handlers, 'bash', 'execution'),
|
||||
),
|
||||
AgentTool(
|
||||
name='bash_status',
|
||||
description=(
|
||||
'查询通过 bash(run_in_background=true) 启动的后台任务状态。'
|
||||
'block=true 会阻塞到任务终止或 timeout_seconds 到期再返回;'
|
||||
'block=false(默认)立刻返回当前快照。'
|
||||
'返回 status (running/completed/failed/cancelled)、exit_code 和最近输出预览。'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'task_id': {
|
||||
'type': 'string',
|
||||
'description': 'bash run_in_background 启动时返回的 task_id。',
|
||||
},
|
||||
'block': {
|
||||
'type': 'boolean',
|
||||
'description': '是否等到任务终止再返回;默认 false。',
|
||||
},
|
||||
'timeout_seconds': {
|
||||
'type': 'number',
|
||||
'minimum': 0,
|
||||
'maximum': 600,
|
||||
'description': 'block=true 时最长等待秒数,默认 30,上限 600。',
|
||||
},
|
||||
},
|
||||
'required': ['task_id'],
|
||||
},
|
||||
handler=resolve_handler(handlers, 'bash_status', 'execution'),
|
||||
),
|
||||
AgentTool(
|
||||
name='bash_kill',
|
||||
description=(
|
||||
'主动终止通过 bash(run_in_background=true) 启动的后台任务。'
|
||||
'注意:用户在前端按 stop 取消当前 run 不会自动杀后台进程(detach 的本意),'
|
||||
'需要明确调用本工具才能终止远端进程。'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'task_id': {
|
||||
'type': 'string',
|
||||
'description': 'bash run_in_background 启动时返回的 task_id。',
|
||||
},
|
||||
},
|
||||
'required': ['task_id'],
|
||||
},
|
||||
handler=resolve_handler(handlers, 'bash_kill', 'execution'),
|
||||
),
|
||||
AgentTool(
|
||||
name='sleep',
|
||||
description='Pause execution briefly for bounded local wait flows.',
|
||||
|
||||
@@ -41,8 +41,8 @@ def build_file_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool]:
|
||||
description=(
|
||||
'Write or append a SMALL UTF-8 file inside the workspace. Creates parent directories when needed. '
|
||||
'Use this for short notes, lightweight markdown, and small config files. '
|
||||
'For Python scripts, JSONL, large JSON/CSV, generated datasets, or quote-heavy/multi-line content, prefer python_exec '
|
||||
'to write the file with pathlib/json/csv; do not pack large content into tool arguments.'
|
||||
'For Python scripts, JSONL, large JSON/CSV, generated datasets, or quote-heavy/multi-line content, prefer bash with a quoted heredoc '
|
||||
"(e.g. `cat > scratchpad/foo.py <<'EOF' ... EOF`); do not pack large content into tool arguments."
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
@@ -51,7 +51,7 @@ def build_file_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool]:
|
||||
'content_lines': {
|
||||
'type': 'array',
|
||||
'items': {'type': 'string'},
|
||||
'description': 'Lines to join with "\\n"; use only for short text/markdown or small scripts. For long scripts use python_exec to create the file.',
|
||||
'description': 'Lines to join with "\\n"; use only for short text/markdown or small scripts. For long scripts use bash with a quoted heredoc to create the file.',
|
||||
},
|
||||
'content': {
|
||||
'type': 'string',
|
||||
@@ -63,12 +63,12 @@ def build_file_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool]:
|
||||
},
|
||||
'json_content': {
|
||||
'type': 'object',
|
||||
'description': 'Small JSON value to serialize with ensure_ascii=false and indent=2. For large JSON use python_exec streaming write.',
|
||||
'description': 'Small JSON value to serialize with ensure_ascii=false and indent=2. For large JSON write a small Python script to scratchpad and run it via bash.',
|
||||
},
|
||||
'jsonl_records': {
|
||||
'type': 'array',
|
||||
'items': {'type': 'object'},
|
||||
'description': 'Small list of objects to serialize as compact JSONL. For more than a few dozen records or generated datasets use python_exec.',
|
||||
'description': 'Small list of objects to serialize as compact JSONL. For more than a few dozen records or generated datasets, write a Python script to scratchpad and run it via bash.',
|
||||
},
|
||||
'csv_headers': {
|
||||
'type': 'array',
|
||||
|
||||
+320
-39
@@ -8,7 +8,9 @@ import json
|
||||
import os
|
||||
import pwd
|
||||
import re
|
||||
import secrets
|
||||
import selectors
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
@@ -33,6 +35,7 @@ from .agent_tool_specs.data_agent import build_data_agent_tools
|
||||
from .agent_tool_specs.execution import build_execution_tools
|
||||
from .agent_tool_specs.files import build_file_tools
|
||||
from .agent_types import DEFAULT_MAX_TURNS, ToolExecutionResult
|
||||
from .bash_bg_store import BgTaskSpec, BgTaskStatus
|
||||
from .data_agent_records import (
|
||||
DataRecordError,
|
||||
confirm_generation_goal,
|
||||
@@ -115,9 +118,10 @@ def default_tool_registry() -> dict[str, AgentTool]:
|
||||
'grep_search': _grep_search,
|
||||
}),
|
||||
*build_execution_tools({
|
||||
'python_exec': _run_python_exec,
|
||||
'python_package': _run_python_package,
|
||||
'bash': _run_bash,
|
||||
'bash_status': _run_bash_status,
|
||||
'bash_kill': _run_bash_kill,
|
||||
'sleep': _sleep,
|
||||
}),
|
||||
AgentTool(
|
||||
@@ -1763,46 +1767,29 @@ def _ensure_shell_allowed(command: str, context: ToolExecutionContext) -> None:
|
||||
'Shell command appears to write platform code. '
|
||||
'Platform code directories are read-only in data-agent sessions.'
|
||||
)
|
||||
if context.permissions.allow_destructive_shell_commands:
|
||||
return
|
||||
destructive_patterns = [
|
||||
r'(^|[;&|])\s*rm\s',
|
||||
r'(^|[;&|])\s*mv\s',
|
||||
r'(^|[;&|])\s*dd\s',
|
||||
r'(^|[;&|])\s*shutdown\s',
|
||||
r'(^|[;&|])\s*reboot\s',
|
||||
r'(^|[;&|])\s*mkfs',
|
||||
r'(^|[;&|])\s*chmod\s+-r\s+777',
|
||||
r'(^|[;&|])\s*chown\s+-r',
|
||||
r'(^|[;&|])\s*git\s+reset\s+--hard',
|
||||
r'(^|[;&|])\s*git\s+clean\s+-fd',
|
||||
r'(^|[;&|])\s*:\s*>\s*',
|
||||
]
|
||||
lowered = command.lower()
|
||||
if any(re.search(pattern, lowered) for pattern in destructive_patterns):
|
||||
raise ToolPermissionError(
|
||||
'Potentially destructive shell command blocked. Re-run with --unsafe to allow it.'
|
||||
)
|
||||
|
||||
|
||||
def _looks_like_platform_code_shell_write(
|
||||
command: str,
|
||||
context: ToolExecutionContext,
|
||||
) -> bool:
|
||||
if context.jupyter_runtime is not None:
|
||||
return False
|
||||
if not _is_platform_app_root(context.root):
|
||||
return False
|
||||
lowered = command.lower()
|
||||
write_marker = re.search(
|
||||
r'>|(^|[\s;&|])(tee|touch|python|python3|sed\s+-i|perl\s+-pi)\b',
|
||||
r'(?<![0-9&])>(?!&)|(^|[\s;&|])(tee|touch|python|python3|sed\s+-i|perl\s+-pi)\b',
|
||||
lowered,
|
||||
)
|
||||
if write_marker is None:
|
||||
return False
|
||||
root_prefix = context.root.as_posix().lower()
|
||||
platform_fragments = [
|
||||
f'{name}/' for name in _PLATFORM_READONLY_DIRS
|
||||
f'{root_prefix}/{name}/' for name in _PLATFORM_READONLY_DIRS
|
||||
] + [
|
||||
f'{context.root.as_posix().lower()}/{name}/'
|
||||
for name in _PLATFORM_READONLY_DIRS
|
||||
f'./{name}/' for name in _PLATFORM_READONLY_DIRS
|
||||
]
|
||||
return any(fragment in lowered for fragment in platform_fragments)
|
||||
|
||||
@@ -2355,6 +2342,26 @@ def _run_python_exec(arguments: dict[str, Any], context: ToolExecutionContext) -
|
||||
]
|
||||
if result.timed_out:
|
||||
payload.insert(0, f'timed_out=true\ntimeout_seconds={timeout_seconds:g}')
|
||||
# 被 timeout(1) 砍掉时 ok=True 会让 agent 误以为脚本跑完了;翻成 ok=False,
|
||||
# 错误消息里直接给出建议(调大 timeout、拆分操作、流式读 CSV 等)。
|
||||
raise ToolExecutionError(
|
||||
_truncate_output(
|
||||
'\n'.join(
|
||||
[
|
||||
*payload,
|
||||
(
|
||||
f'[hint] python_exec 在 {timeout_seconds:g}s 被 timeout(1) kill,没有跑完。\n'
|
||||
'首选做法:把 timeout_seconds 调大(数据分析类任务起步给 300,处理大 CSV/JSONL 给 600+)。\n'
|
||||
'不要把脚本拆成多个更小的 python_exec 调用——每次调用都要重新 import、重读数据、'
|
||||
'重跑一遍带完整对话上下文的远端 round-trip,拆得越碎总耗时越长。'
|
||||
'把多步分析(解析、聚合、统计、写出)合并到一个脚本里才是对的。\n'
|
||||
'只有当数据本身远超内存或单次跑要 1h+ 时,才考虑 pandas chunksize / 流式过滤先把数据降量。'
|
||||
),
|
||||
]
|
||||
).strip(),
|
||||
max_output_chars,
|
||||
)
|
||||
)
|
||||
return (
|
||||
_truncate_output('\n'.join(payload).strip(), max_output_chars),
|
||||
{
|
||||
@@ -2426,21 +2433,17 @@ def _run_python_exec(arguments: dict[str, Any], context: ToolExecutionContext) -
|
||||
stdout.rstrip(),
|
||||
'[stderr]',
|
||||
stderr.rstrip(),
|
||||
(
|
||||
f'[hint] python_exec 在 {timeout_seconds:g}s 被 kill,没有跑完。\n'
|
||||
'首选做法:把 timeout_seconds 调大(数据分析类任务起步给 300,处理大 CSV/JSONL 给 600+)。\n'
|
||||
'不要把脚本拆成多个更小的 python_exec 调用——每次调用都要重新 import、重读数据、'
|
||||
'重跑一遍带完整对话上下文的远端 round-trip,拆得越碎总耗时越长。'
|
||||
'把多步分析(解析、聚合、统计、写出)合并到一个脚本里才是对的。\n'
|
||||
'只有当数据本身远超内存或单次跑要 1h+ 时,才考虑 pandas chunksize / 流式过滤先把数据降量。'
|
||||
),
|
||||
]
|
||||
return (
|
||||
_truncate_output('\n'.join(payload).strip(), max_output_chars),
|
||||
{
|
||||
'action': 'python_exec',
|
||||
'mode': mode,
|
||||
'interpreter': interpreter,
|
||||
'scratchpad_directory': str(context.scratchpad_directory) if context.scratchpad_directory else '',
|
||||
'python_env_dir': str(context.python_env_dir) if context.python_env_dir else '',
|
||||
'timed_out': True,
|
||||
'timeout_seconds': timeout_seconds,
|
||||
'stdout_preview': _snapshot_text(stdout),
|
||||
'stderr_preview': _snapshot_text(stderr),
|
||||
'output_preview': _snapshot_text('\n'.join(payload).strip()),
|
||||
},
|
||||
raise ToolExecutionError(
|
||||
_truncate_output('\n'.join(payload).strip(), max_output_chars)
|
||||
)
|
||||
except ToolExecutionError:
|
||||
raise
|
||||
@@ -2742,7 +2745,15 @@ def _resolve_python_interpreter(context: ToolExecutionContext) -> str:
|
||||
|
||||
|
||||
def _run_bash(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
command = _require_string(arguments, 'command')
|
||||
if arguments.get('run_in_background'):
|
||||
return _run_bash_background(arguments, context)
|
||||
raw_command = arguments.get('command')
|
||||
if not isinstance(raw_command, str) or not raw_command.strip():
|
||||
return (
|
||||
'(no-op: empty bash command — pass a non-empty `command` string '
|
||||
'to actually execute something)'
|
||||
)
|
||||
command = raw_command
|
||||
_ensure_shell_allowed(command, context)
|
||||
if context.jupyter_runtime is not None:
|
||||
result = context.jupyter_runtime.run_command(
|
||||
@@ -2805,6 +2816,276 @@ def _run_bash(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _new_bg_task_id() -> str:
|
||||
return f'bg_{secrets.token_hex(4)}'
|
||||
|
||||
|
||||
def _bg_extract_pid(stdout: str) -> int | None:
|
||||
match = re.search(r'BG_TASK_PID=(\d+)', stdout)
|
||||
if match is None:
|
||||
return None
|
||||
try:
|
||||
return int(match.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _bg_remote_launcher(
|
||||
*,
|
||||
task_dir: str,
|
||||
output_path: str,
|
||||
pid_path: str,
|
||||
exit_code_path: str,
|
||||
user_command: str,
|
||||
) -> str:
|
||||
inner = (
|
||||
f'( {user_command} ) > {shlex.quote(output_path)} 2>&1'
|
||||
f' ; echo $? > {shlex.quote(exit_code_path)}'
|
||||
)
|
||||
return (
|
||||
f'mkdir -p {shlex.quote(task_dir)}\n'
|
||||
f'nohup bash -c {shlex.quote(inner)} </dev/null '
|
||||
'>/dev/null 2>&1 &\n'
|
||||
'PID=$!\n'
|
||||
f'echo $PID > {shlex.quote(pid_path)}\n'
|
||||
'echo "BG_TASK_PID=$PID"\n'
|
||||
)
|
||||
|
||||
|
||||
def _run_bash_background(
|
||||
arguments: dict[str, Any],
|
||||
context: ToolExecutionContext,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
raw_command = arguments.get('command')
|
||||
if not isinstance(raw_command, str) or not raw_command.strip():
|
||||
return (
|
||||
'(no-op: empty bash command — pass a non-empty `command` string '
|
||||
'to actually execute something)',
|
||||
{'action': 'bash_background_noop'},
|
||||
)
|
||||
command = raw_command
|
||||
_ensure_shell_allowed(command, context)
|
||||
wait_for_completion = bool(arguments.get('wait_for_completion', True))
|
||||
task_id = _new_bg_task_id()
|
||||
|
||||
if context.jupyter_runtime is not None:
|
||||
workspace = context.jupyter_runtime.binding.workspace_cwd
|
||||
task_dir = f'{workspace}/.bg_tasks/{task_id}'
|
||||
output_path = f'{task_dir}/output'
|
||||
pid_path = f'{task_dir}/pid'
|
||||
exit_code_path = f'{task_dir}/exit_code'
|
||||
launcher = _bg_remote_launcher(
|
||||
task_dir=task_dir,
|
||||
output_path=output_path,
|
||||
pid_path=pid_path,
|
||||
exit_code_path=exit_code_path,
|
||||
user_command=command,
|
||||
)
|
||||
result = context.jupyter_runtime.run_command(
|
||||
launcher,
|
||||
timeout_seconds=min(context.command_timeout_seconds, 15.0),
|
||||
max_output_chars=4096,
|
||||
cancel_event=context.cancel_event,
|
||||
)
|
||||
if result.exit_code != 0:
|
||||
raise ToolExecutionError(
|
||||
f'后台 bash 启动失败 exit_code={result.exit_code}\n'
|
||||
f'stderr={result.stderr.strip()}'
|
||||
)
|
||||
pid = _bg_extract_pid(result.stdout)
|
||||
if pid is None:
|
||||
raise ToolExecutionError(
|
||||
f'后台 bash 启动后未拿到 pid\nstdout={result.stdout.strip()}'
|
||||
)
|
||||
spec = BgTaskSpec(
|
||||
task_id=task_id,
|
||||
account_key=context.account_id or '',
|
||||
session_id=context.session_id or '',
|
||||
run_id=context.run_id or '',
|
||||
pid=pid,
|
||||
task_dir=task_dir,
|
||||
output_path=output_path,
|
||||
pid_path=pid_path,
|
||||
exit_code_path=exit_code_path,
|
||||
command=command,
|
||||
started_at=time.time(),
|
||||
wait_for_completion=wait_for_completion,
|
||||
)
|
||||
if context.bg_register is not None:
|
||||
context.bg_register(spec)
|
||||
content = (
|
||||
f'已在远端后台启动\n'
|
||||
f'task_id={task_id}\n'
|
||||
f'pid={pid}\n'
|
||||
f'task_dir={task_dir}\n'
|
||||
f'output={output_path}\n'
|
||||
f'wait_for_completion={wait_for_completion}\n'
|
||||
'完成后将自动起新一轮把结果送回;'
|
||||
'中途可用 bash_status(task_id) 查看,bash_kill(task_id) 终止。'
|
||||
)
|
||||
return (
|
||||
content,
|
||||
{
|
||||
'action': 'remote_bash_bg',
|
||||
'task_id': task_id,
|
||||
'pid': pid,
|
||||
'task_dir': task_dir,
|
||||
'output_path': output_path,
|
||||
'command': command,
|
||||
'wait_for_completion': wait_for_completion,
|
||||
},
|
||||
)
|
||||
|
||||
# Local fallback: spawn detached subprocess with file redirects.
|
||||
base_dir = (context.scratchpad_directory or context.root) / '.bg_tasks' / task_id
|
||||
base_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = base_dir / 'output'
|
||||
pid_path = base_dir / 'pid'
|
||||
exit_code_path = base_dir / 'exit_code'
|
||||
inner = (
|
||||
f'( {command} ) > {shlex.quote(str(output_path))} 2>&1'
|
||||
f' ; echo $? > {shlex.quote(str(exit_code_path))}'
|
||||
)
|
||||
process = subprocess.Popen(
|
||||
['bash', '-c', inner],
|
||||
cwd=_execution_cwd(context),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=_build_subprocess_env(context),
|
||||
start_new_session=True,
|
||||
)
|
||||
pid_path.write_text(f'{process.pid}\n', encoding='utf-8')
|
||||
spec = BgTaskSpec(
|
||||
task_id=task_id,
|
||||
account_key=context.account_id or '',
|
||||
session_id=context.session_id or '',
|
||||
run_id=context.run_id or '',
|
||||
pid=process.pid,
|
||||
task_dir=str(base_dir),
|
||||
output_path=str(output_path),
|
||||
pid_path=str(pid_path),
|
||||
exit_code_path=str(exit_code_path),
|
||||
command=command,
|
||||
started_at=time.time(),
|
||||
wait_for_completion=wait_for_completion,
|
||||
)
|
||||
if context.bg_register is not None:
|
||||
context.bg_register(spec)
|
||||
content = (
|
||||
f'已在本地后台启动\n'
|
||||
f'task_id={task_id}\n'
|
||||
f'pid={process.pid}\n'
|
||||
f'output={output_path}\n'
|
||||
)
|
||||
return (
|
||||
content,
|
||||
{
|
||||
'action': 'bash_bg',
|
||||
'task_id': task_id,
|
||||
'pid': process.pid,
|
||||
'task_dir': str(base_dir),
|
||||
'output_path': str(output_path),
|
||||
'command': command,
|
||||
'wait_for_completion': wait_for_completion,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _format_bash_status(status: BgTaskStatus, max_output_chars: int) -> str:
|
||||
finished = (
|
||||
time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(status.finished_at))
|
||||
if status.finished_at is not None
|
||||
else '-'
|
||||
)
|
||||
started = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(status.started_at))
|
||||
body = [
|
||||
f'task_id={status.task_id}',
|
||||
f'status={status.status}',
|
||||
f'pid={status.pid}',
|
||||
f'started_at={started}',
|
||||
f'finished_at={finished}',
|
||||
f'exit_code={status.exit_code if status.exit_code is not None else "-"}',
|
||||
f'auto_resumed={status.auto_resumed}',
|
||||
'[output_preview]',
|
||||
status.output_preview or '(empty)',
|
||||
]
|
||||
return _truncate_output('\n'.join(body), max_output_chars)
|
||||
|
||||
|
||||
def _run_bash_status(
|
||||
arguments: dict[str, Any],
|
||||
context: ToolExecutionContext,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
task_id = _require_string(arguments, 'task_id')
|
||||
if context.bg_status_query is None:
|
||||
raise ToolExecutionError('bg_status_query 未注入;当前运行环境不支持 bash 后台任务')
|
||||
block = bool(arguments.get('block', False))
|
||||
timeout_seconds = float(arguments.get('timeout_seconds', 30))
|
||||
timeout_seconds = max(0.0, min(timeout_seconds, 600.0))
|
||||
deadline = time.monotonic() + timeout_seconds if block else None
|
||||
|
||||
while True:
|
||||
status = context.bg_status_query(task_id)
|
||||
if status is None:
|
||||
raise ToolExecutionError(f'未找到 task_id={task_id}')
|
||||
if not block or status.status != 'running':
|
||||
return (
|
||||
_format_bash_status(status, context.max_output_chars),
|
||||
{
|
||||
'action': 'bash_status',
|
||||
'task_id': task_id,
|
||||
'status': status.status,
|
||||
'exit_code': status.exit_code,
|
||||
'auto_resumed': status.auto_resumed,
|
||||
},
|
||||
)
|
||||
if deadline is None or time.monotonic() >= deadline:
|
||||
return (
|
||||
_format_bash_status(status, context.max_output_chars),
|
||||
{
|
||||
'action': 'bash_status',
|
||||
'task_id': task_id,
|
||||
'status': status.status,
|
||||
'timed_out_block': True,
|
||||
},
|
||||
)
|
||||
if context.cancel_event is not None and context.cancel_event.is_set():
|
||||
return (
|
||||
_format_bash_status(status, context.max_output_chars),
|
||||
{
|
||||
'action': 'bash_status',
|
||||
'task_id': task_id,
|
||||
'status': status.status,
|
||||
'cancelled': True,
|
||||
},
|
||||
)
|
||||
time.sleep(min(2.0, max(0.5, deadline - time.monotonic())))
|
||||
|
||||
|
||||
def _run_bash_kill(
|
||||
arguments: dict[str, Any],
|
||||
context: ToolExecutionContext,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
task_id = _require_string(arguments, 'task_id')
|
||||
if context.bg_kill_request is None:
|
||||
raise ToolExecutionError('bg_kill_request 未注入;当前运行环境不支持 bash 后台任务')
|
||||
killed = context.bg_kill_request(task_id)
|
||||
content = (
|
||||
f'已终止 task_id={task_id}'
|
||||
if killed
|
||||
else f'未能终止 task_id={task_id}(可能已结束或不存在)'
|
||||
)
|
||||
return (
|
||||
content,
|
||||
{
|
||||
'action': 'bash_kill',
|
||||
'task_id': task_id,
|
||||
'killed': killed,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _web_fetch(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
raw_url = _require_string(arguments, 'url')
|
||||
max_chars = _coerce_int(arguments, 'max_chars', context.max_output_chars)
|
||||
|
||||
+2
-2
@@ -157,8 +157,8 @@ DEFAULT_MAX_TURNS = 50
|
||||
class AgentRuntimeConfig:
|
||||
cwd: Path
|
||||
max_turns: int = DEFAULT_MAX_TURNS
|
||||
command_timeout_seconds: float = 30.0
|
||||
max_output_chars: int = 12000
|
||||
command_timeout_seconds: float = 300.0
|
||||
max_output_chars: int = 50000 # ≈ 20k token (Chinese/code mix ≈ 2.5 chars/token)
|
||||
stream_model_responses: bool = False
|
||||
auto_snip_threshold_tokens: int | None = None
|
||||
auto_compact_threshold_tokens: int | None = None
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Persist background bash task records.
|
||||
|
||||
Sibling to run_state_store.py: a SQLite table tracking bg shell tasks the
|
||||
agent spawns via bash(run_in_background=true). The store survives backend
|
||||
restarts so polling can be resumed; the actual remote process is owned by
|
||||
nohup on the Jupyter terminal and is independent of this store.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from threading import RLock
|
||||
from typing import Any
|
||||
|
||||
|
||||
ACTIVE_BG_STATUSES = {'running'}
|
||||
TERMINAL_BG_STATUSES = {'completed', 'failed', 'cancelled'}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BgTaskSpec:
|
||||
"""Payload an agent-side handler hands to the backend on registration."""
|
||||
|
||||
task_id: str
|
||||
account_key: str
|
||||
session_id: str
|
||||
run_id: str
|
||||
pid: int
|
||||
task_dir: str
|
||||
output_path: str
|
||||
pid_path: str
|
||||
exit_code_path: str
|
||||
command: str
|
||||
started_at: float
|
||||
wait_for_completion: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BgTaskStatus:
|
||||
"""Snapshot the agent reads back via bash_status."""
|
||||
|
||||
task_id: str
|
||||
status: str
|
||||
pid: int
|
||||
started_at: float
|
||||
finished_at: float | None
|
||||
exit_code: int | None
|
||||
output_path: str
|
||||
output_preview: str
|
||||
auto_resumed: bool
|
||||
|
||||
|
||||
class BashBgStore:
|
||||
def __init__(self, db_path: Path) -> None:
|
||||
self.db_path = db_path
|
||||
self._lock = RLock()
|
||||
|
||||
def record_start(self, spec: BgTaskSpec) -> None:
|
||||
now = time.time()
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
insert into bash_bg_tasks (
|
||||
task_id, account_key, session_id, run_id, pid,
|
||||
task_dir, output_path, pid_path, exit_code_path,
|
||||
command, status, started_at, updated_at,
|
||||
wait_for_completion, auto_resumed
|
||||
)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, 0)
|
||||
on conflict(task_id) do nothing
|
||||
""",
|
||||
(
|
||||
spec.task_id,
|
||||
spec.account_key,
|
||||
spec.session_id,
|
||||
spec.run_id,
|
||||
spec.pid,
|
||||
spec.task_dir,
|
||||
spec.output_path,
|
||||
spec.pid_path,
|
||||
spec.exit_code_path,
|
||||
spec.command,
|
||||
spec.started_at,
|
||||
now,
|
||||
1 if spec.wait_for_completion else 0,
|
||||
),
|
||||
)
|
||||
|
||||
def mark_completed(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
exit_code: int,
|
||||
finished_at: float | None = None,
|
||||
) -> None:
|
||||
finished = finished_at if finished_at is not None else time.time()
|
||||
status = 'completed' if exit_code == 0 else 'failed'
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
update bash_bg_tasks
|
||||
set status = ?, exit_code = ?, finished_at = ?, updated_at = ?
|
||||
where task_id = ? and status = 'running'
|
||||
""",
|
||||
(status, exit_code, finished, finished, task_id),
|
||||
)
|
||||
|
||||
def mark_killed(self, task_id: str) -> None:
|
||||
now = time.time()
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
update bash_bg_tasks
|
||||
set status = 'cancelled', finished_at = ?, updated_at = ?
|
||||
where task_id = ? and status = 'running'
|
||||
""",
|
||||
(now, now, task_id),
|
||||
)
|
||||
|
||||
def mark_auto_resumed(self, task_id: str) -> bool:
|
||||
"""Set auto_resumed=1 atomically; return True if we won the race."""
|
||||
with self._connect() as conn:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
update bash_bg_tasks
|
||||
set auto_resumed = 1, updated_at = ?
|
||||
where task_id = ? and auto_resumed = 0
|
||||
""",
|
||||
(time.time(), task_id),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def list_active(self) -> list[dict[str, Any]]:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"select * from bash_bg_tasks where status = 'running'"
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def list_for_session(
|
||||
self,
|
||||
account_key: str,
|
||||
session_id: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
select * from bash_bg_tasks
|
||||
where account_key = ? and session_id = ?
|
||||
order by started_at desc
|
||||
""",
|
||||
(account_key, session_id),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def get(self, task_id: str) -> dict[str, Any] | None:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"select * from bash_bg_tasks where task_id = ?",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row is not None else None
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
with self._lock:
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(self.db_path, timeout=10)
|
||||
conn.row_factory = sqlite3.Row
|
||||
self._init_schema(conn)
|
||||
return conn
|
||||
|
||||
def _init_schema(self, conn: sqlite3.Connection) -> None:
|
||||
conn.execute('pragma journal_mode=wal')
|
||||
conn.execute(
|
||||
"""
|
||||
create table if not exists bash_bg_tasks (
|
||||
task_id text primary key,
|
||||
account_key text not null,
|
||||
session_id text not null,
|
||||
run_id text not null default '',
|
||||
pid integer not null,
|
||||
task_dir text not null,
|
||||
output_path text not null,
|
||||
pid_path text not null,
|
||||
exit_code_path text not null,
|
||||
command text not null,
|
||||
status text not null,
|
||||
started_at real not null,
|
||||
finished_at real,
|
||||
exit_code integer,
|
||||
updated_at real not null,
|
||||
wait_for_completion integer not null default 1,
|
||||
auto_resumed integer not null default 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
create index if not exists idx_bash_bg_session
|
||||
on bash_bg_tasks(account_key, session_id, started_at)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
create index if not exists idx_bash_bg_active
|
||||
on bash_bg_tasks(status)
|
||||
"""
|
||||
)
|
||||
@@ -1251,11 +1251,4 @@ def check_shell_security(
|
||||
if result.is_misparsing:
|
||||
return (False, f'Security check: {result.message}')
|
||||
|
||||
# Check destructive patterns if not allowed
|
||||
if not allow_destructive:
|
||||
warning = get_destructive_command_warning(command)
|
||||
if warning:
|
||||
return (False, f'Potentially destructive command blocked: {warning}. '
|
||||
'Re-run with --unsafe to allow it.')
|
||||
|
||||
return (True, '')
|
||||
|
||||
@@ -233,12 +233,41 @@ class JupyterRuntimeSession:
|
||||
'persisted_at': time.time(),
|
||||
}
|
||||
|
||||
@property
|
||||
def chat_workspace_root(self) -> str:
|
||||
"""Per-user-per-chat root on shared NFS so the SFT training pod
|
||||
(which doesn't mount the jupyter pod's private workspace) can read
|
||||
and write the same artifacts. Layout:
|
||||
/mnt/wangsenhao/autoresearch-zk-users/<account_id>/<session_id>/"""
|
||||
account_part = sanitize_remote_path_part(self.binding.account_id)
|
||||
session_part = sanitize_remote_path_part(self.binding.session_id)
|
||||
return (
|
||||
f'/mnt/wangsenhao/autoresearch-zk-users/'
|
||||
f'{account_part}/{session_part}'
|
||||
)
|
||||
|
||||
def bootstrap_workspace(
|
||||
self,
|
||||
*,
|
||||
timeout_seconds: float = 20.0,
|
||||
project_root: Path | None = None,
|
||||
) -> None:
|
||||
chat_root = self.chat_workspace_root
|
||||
nfs_users_root = '/mnt/wangsenhao/autoresearch-zk-users'
|
||||
probe = self.run_command(
|
||||
(
|
||||
f'mkdir -p {shlex.quote(nfs_users_root)} && '
|
||||
f'touch {shlex.quote(nfs_users_root)}/.write_probe && '
|
||||
f'rm -f {shlex.quote(nfs_users_root)}/.write_probe'
|
||||
),
|
||||
timeout_seconds=timeout_seconds,
|
||||
max_output_chars=2000,
|
||||
)
|
||||
if probe.exit_code != 0:
|
||||
raise JupyterRuntimeError(
|
||||
'远端 jupyter pod 未挂载 /mnt/wangsenhao 或不可写:'
|
||||
+ (probe.stdout.strip() or probe.stderr.strip() or 'unknown error')
|
||||
)
|
||||
result = self.run_command(
|
||||
(
|
||||
'mkdir -p '
|
||||
@@ -248,6 +277,9 @@ class JupyterRuntimeSession:
|
||||
f'{shlex.quote(self.binding.workspace_root)}/.runtime/uploads '
|
||||
f'{shlex.quote(self.binding.workspace_root)}/runtime_uploads '
|
||||
f'{shlex.quote(self.account_runtime_root)}/python '
|
||||
f'{shlex.quote(chat_root)}/results '
|
||||
f'{shlex.quote(chat_root)}/sft_output '
|
||||
f'{shlex.quote(chat_root)}/scripts'
|
||||
),
|
||||
timeout_seconds=timeout_seconds,
|
||||
max_output_chars=4000,
|
||||
@@ -259,6 +291,27 @@ class JupyterRuntimeSession:
|
||||
self.ensure_python_environment(timeout_seconds=max(timeout_seconds, 300.0))
|
||||
if project_root is not None:
|
||||
self.sync_runtime_bundle(project_root, timeout_seconds=max(timeout_seconds, 180.0))
|
||||
self._sync_chat_workspace_scripts(timeout_seconds=timeout_seconds)
|
||||
|
||||
def _sync_chat_workspace_scripts(self, *, timeout_seconds: float = 20.0) -> None:
|
||||
if not self.skills_root:
|
||||
return
|
||||
chat_root = self.chat_workspace_root
|
||||
src = f'{self.skills_root}/model-iteration/scripts/prepare_and_train_sft.py'
|
||||
dst = f'{chat_root}/scripts/prepare_and_train_sft.py'
|
||||
result = self.run_command(
|
||||
(
|
||||
f'if [ -f {shlex.quote(src)} ]; then '
|
||||
f'cp {shlex.quote(src)} {shlex.quote(dst)}; '
|
||||
f'fi'
|
||||
),
|
||||
timeout_seconds=timeout_seconds,
|
||||
max_output_chars=2000,
|
||||
)
|
||||
if result.exit_code != 0:
|
||||
raise JupyterRuntimeError(
|
||||
result.stdout.strip() or 'Unable to sync chat workspace SFT script.'
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
payload = self.binding.to_dict()
|
||||
@@ -774,6 +827,20 @@ print(f"{'appended' if MODE == 'a' else 'wrote'} {target} ({len(content)} chars)
|
||||
|
||||
def resolve_workspace_path(self, raw_path: str) -> str:
|
||||
value = raw_path.strip() or '.'
|
||||
|
||||
# 系统提示里给的"当前会话目录"通常是本地后端的绝对/相对路径,例如
|
||||
# `.port_sessions/accounts/<acc>/sessions/<sid>/input` 或
|
||||
# `/home/mi/.../.port_sessions/accounts/<acc>/sessions/<sid>/output/foo.csv`。
|
||||
# 远端 Jupyter 没这套目录结构。识别这种 pattern 后,把 input/output/
|
||||
# scratchpad bucket 映射到远端 workspace 的同名目录。
|
||||
bucket_match = self._match_local_session_bucket(value)
|
||||
if bucket_match is not None:
|
||||
bucket, rest = bucket_match
|
||||
tail = f'/{rest}' if rest else ''
|
||||
return normalize_posix_path(
|
||||
f'{self.binding.workspace_cwd}/{bucket}{tail}'
|
||||
)
|
||||
|
||||
if value.startswith('/'):
|
||||
return normalize_posix_path(value)
|
||||
path = PurePosixPath(value)
|
||||
@@ -787,14 +854,41 @@ print(f"{'appended' if MODE == 'a' else 'wrote'} {target} ({len(content)} chars)
|
||||
if path.parts and path.parts[0] in {'scratchpad', 'scratch'}:
|
||||
tail = PurePosixPath(*path.parts[1:]) if len(path.parts) > 1 else PurePosixPath()
|
||||
return normalize_posix_path(f'{self.binding.workspace_cwd}/scratchpad/{tail}')
|
||||
if path.parts and path.parts[0] in {'input', 'inputs'}:
|
||||
tail = PurePosixPath(*path.parts[1:]) if len(path.parts) > 1 else PurePosixPath()
|
||||
return normalize_posix_path(f'{self.binding.workspace_cwd}/input/{tail}')
|
||||
return normalize_posix_path(f'{self.binding.workspace_cwd}/{value}')
|
||||
|
||||
_LOCAL_SESSION_BUCKET_RE = re.compile(
|
||||
r'(?:^|/)\.port_sessions/accounts/[^/]+/sessions/[^/]+/'
|
||||
r'(?P<bucket>input|inputs|output|outputs|scratchpad|scratch)'
|
||||
r'(?:/(?P<rest>.*))?$'
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _match_local_session_bucket(cls, raw: str) -> tuple[str, str] | None:
|
||||
m = cls._LOCAL_SESSION_BUCKET_RE.search(raw)
|
||||
if not m:
|
||||
return None
|
||||
bucket = m.group('bucket')
|
||||
normalized = {
|
||||
'input': 'input',
|
||||
'inputs': 'input',
|
||||
'output': 'output',
|
||||
'outputs': 'output',
|
||||
'scratchpad': 'scratchpad',
|
||||
'scratch': 'scratchpad',
|
||||
}[bucket]
|
||||
rest = m.group('rest') or ''
|
||||
return normalized, rest
|
||||
|
||||
def _remote_env_prefix(self) -> str:
|
||||
exports = {
|
||||
'ZK_AGENT_WORKSPACE': self.binding.workspace_cwd,
|
||||
'ZK_AGENT_OUTPUT': f'{self.binding.workspace_cwd}/output',
|
||||
'ZK_AGENT_SCRATCHPAD': f'{self.binding.workspace_cwd}/scratchpad',
|
||||
'ZK_AGENT_PYTHON_ENV': self.python_env_path,
|
||||
'AUTORESEARCH_CHAT_ROOT': self.chat_workspace_root,
|
||||
}
|
||||
if self.platform_root:
|
||||
exports['ZK_AGENT_PLATFORM_ROOT'] = self.platform_root
|
||||
|
||||
+104
-14
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Iterator
|
||||
from urllib import error, request
|
||||
|
||||
@@ -31,7 +32,7 @@ ANTHROPIC_MESSAGES_MODEL_PREFIXES = (
|
||||
)
|
||||
|
||||
ANTHROPIC_VERSION = '2023-06-01'
|
||||
ANTHROPIC_MAX_TOKENS = 4096
|
||||
ANTHROPIC_MAX_TOKENS = 16384
|
||||
DEFAULT_MODEL_IDLE_TIMEOUT_SECONDS = 60.0
|
||||
|
||||
|
||||
@@ -218,6 +219,57 @@ def _uses_anthropic_messages_api(model: str, base_url: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
_UNKNOWN_EVENT_LOG_LIMIT = 8
|
||||
_unknown_event_seen: dict[str, int] = {}
|
||||
|
||||
|
||||
def _log_unknown_anthropic_event(kind: str, payload: dict[str, Any]) -> None:
|
||||
"""Emit a one-line stderr warning the first few times we see an unknown event.
|
||||
|
||||
The streaming handler used to silently drop any block/delta type it didn't
|
||||
recognise. When the upstream proxy started emitting frames we didn't decode,
|
||||
the model's output budget was burned with no observable effect. This log
|
||||
surfaces the situation without flooding logs on every turn.
|
||||
"""
|
||||
seen = _unknown_event_seen.get(kind, 0)
|
||||
if seen >= _UNKNOWN_EVENT_LOG_LIMIT:
|
||||
return
|
||||
_unknown_event_seen[kind] = seen + 1
|
||||
try:
|
||||
snippet = json.dumps(payload, ensure_ascii=False)[:400]
|
||||
except Exception: # pragma: no cover - defensive
|
||||
snippet = repr(payload)[:400]
|
||||
print(
|
||||
f'[openai_compat] unknown anthropic stream event {kind!r}: {snippet}',
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def _mark_last_message_cache_breakpoint(messages: list[dict[str, Any]]) -> None:
|
||||
"""Attach cache_control to the last content block of the last message.
|
||||
|
||||
Anthropic prompt cache requires the breakpoint at the *end* of the cacheable
|
||||
prefix. The next turn appends a new message → the previous last becomes the
|
||||
second-to-last with its breakpoint still in the prefix, so everything up to
|
||||
that point is reused on the next call.
|
||||
"""
|
||||
if not messages:
|
||||
return
|
||||
last = messages[-1]
|
||||
content = last.get('content')
|
||||
if not isinstance(content, list) or not content:
|
||||
return
|
||||
last_block = content[-1]
|
||||
if not isinstance(last_block, dict):
|
||||
return
|
||||
new_block = dict(last_block)
|
||||
new_block['cache_control'] = {'type': 'ephemeral'}
|
||||
new_content = list(content)
|
||||
new_content[-1] = new_block
|
||||
last['content'] = new_content
|
||||
|
||||
|
||||
def _anthropic_base_url(base_url: str) -> str:
|
||||
base = base_url.rstrip('/')
|
||||
if base.lower().endswith('/anthropic'):
|
||||
@@ -486,25 +538,46 @@ class OpenAICompatClient:
|
||||
if blocks:
|
||||
self._append_anthropic_message(anthropic_messages, 'user', blocks)
|
||||
|
||||
system_text = '\n\n'.join(system_parts) if system_parts else ''
|
||||
if output_schema is not None:
|
||||
schema_hint = (
|
||||
f'请严格输出符合 JSON Schema `{output_schema.name}` 的 JSON,'
|
||||
'不要输出额外解释。'
|
||||
)
|
||||
system_text = f'{system_text}\n\n{schema_hint}'.strip() if system_text else schema_hint
|
||||
|
||||
cache_enabled = (
|
||||
os.environ.get('CLAW_DISABLE_PROMPT_CACHE', '').strip().lower()
|
||||
not in {'1', 'true', 'yes', 'on'}
|
||||
)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
'model': self.config.model,
|
||||
'messages': anthropic_messages,
|
||||
'max_tokens': ANTHROPIC_MAX_TOKENS,
|
||||
'stream': stream,
|
||||
}
|
||||
if system_parts:
|
||||
payload['system'] = '\n\n'.join(system_parts)
|
||||
if system_text:
|
||||
if cache_enabled:
|
||||
payload['system'] = [
|
||||
{
|
||||
'type': 'text',
|
||||
'text': system_text,
|
||||
'cache_control': {'type': 'ephemeral'},
|
||||
}
|
||||
]
|
||||
else:
|
||||
payload['system'] = system_text
|
||||
converted_tools = self._anthropic_tools(tools)
|
||||
if converted_tools:
|
||||
if cache_enabled:
|
||||
converted_tools = list(converted_tools)
|
||||
last_tool = dict(converted_tools[-1])
|
||||
last_tool['cache_control'] = {'type': 'ephemeral'}
|
||||
converted_tools[-1] = last_tool
|
||||
payload['tools'] = converted_tools
|
||||
if output_schema is not None:
|
||||
schema_hint = (
|
||||
f'请严格输出符合 JSON Schema `{output_schema.name}` 的 JSON,'
|
||||
'不要输出额外解释。'
|
||||
)
|
||||
payload['system'] = (
|
||||
f'{payload.get("system", "")}\n\n{schema_hint}'.strip()
|
||||
)
|
||||
if cache_enabled and anthropic_messages:
|
||||
_mark_last_message_cache_breakpoint(anthropic_messages)
|
||||
return payload
|
||||
|
||||
def _anthropic_headers(self) -> dict[str, str]:
|
||||
@@ -604,7 +677,12 @@ class OpenAICompatClient:
|
||||
content_block = event_payload.get('content_block')
|
||||
if not isinstance(block_index, int) or not isinstance(content_block, dict):
|
||||
continue
|
||||
if content_block.get('type') != 'tool_use':
|
||||
block_type = content_block.get('type')
|
||||
if block_type != 'tool_use':
|
||||
if block_type not in ('text', 'thinking', 'redacted_thinking'):
|
||||
_log_unknown_anthropic_event(
|
||||
'content_block_start', event_payload
|
||||
)
|
||||
continue
|
||||
tool_index = next_tool_index
|
||||
next_tool_index += 1
|
||||
@@ -636,7 +714,8 @@ class OpenAICompatClient:
|
||||
delta = event_payload.get('delta')
|
||||
if not isinstance(delta, dict):
|
||||
continue
|
||||
if delta.get('type') == 'text_delta':
|
||||
delta_type = delta.get('type')
|
||||
if delta_type == 'text_delta':
|
||||
text = delta.get('text')
|
||||
if isinstance(text, str) and text:
|
||||
yield StreamEvent(
|
||||
@@ -645,7 +724,7 @@ class OpenAICompatClient:
|
||||
raw_event=event_payload,
|
||||
)
|
||||
continue
|
||||
if delta.get('type') == 'input_json_delta':
|
||||
if delta_type == 'input_json_delta':
|
||||
block_index = event_payload.get('index')
|
||||
partial_json = delta.get('partial_json')
|
||||
if (
|
||||
@@ -661,6 +740,17 @@ class OpenAICompatClient:
|
||||
raw_event=event_payload,
|
||||
)
|
||||
continue
|
||||
if delta_type in ('thinking_delta', 'signature_delta'):
|
||||
# Extended-thinking blocks: we don't surface them as
|
||||
# content (the runtime has nowhere to put them), but
|
||||
# they previously vanished silently and burned the
|
||||
# whole 4k output budget before tool_use could start.
|
||||
# Logging once is enough to confirm they're flowing.
|
||||
continue
|
||||
_log_unknown_anthropic_event(
|
||||
f'content_block_delta:{delta_type}', event_payload
|
||||
)
|
||||
continue
|
||||
if event_type == 'message_delta':
|
||||
delta = event_payload.get('delta')
|
||||
if isinstance(delta, dict) and isinstance(delta.get('stop_reason'), str):
|
||||
|
||||
@@ -68,6 +68,7 @@ class StoredAgentSession:
|
||||
budget_state: JSONDict
|
||||
plugin_state: JSONDict
|
||||
scratchpad_directory: str | None = None
|
||||
is_training: bool = False
|
||||
|
||||
|
||||
def save_agent_session(session: StoredAgentSession, directory: Path | None = None) -> Path:
|
||||
@@ -118,6 +119,7 @@ def load_agent_session(session_id: str, directory: Path | None = None) -> Stored
|
||||
if isinstance(data.get('scratchpad_directory'), str)
|
||||
else None
|
||||
),
|
||||
is_training=bool(data.get('is_training', False)),
|
||||
)
|
||||
|
||||
|
||||
@@ -222,8 +224,8 @@ def deserialize_runtime_config(payload: JSONDict) -> AgentRuntimeConfig:
|
||||
return AgentRuntimeConfig(
|
||||
cwd=Path(str(payload['cwd'])).resolve(),
|
||||
max_turns=int(payload.get('max_turns', DEFAULT_MAX_TURNS)),
|
||||
command_timeout_seconds=float(payload.get('command_timeout_seconds', 30.0)),
|
||||
max_output_chars=int(payload.get('max_output_chars', 12000)),
|
||||
command_timeout_seconds=float(payload.get('command_timeout_seconds', 300.0)),
|
||||
max_output_chars=int(payload.get('max_output_chars', 50000)),
|
||||
stream_model_responses=bool(payload.get('stream_model_responses', False)),
|
||||
auto_snip_threshold_tokens=_optional_int(payload.get('auto_snip_threshold_tokens')),
|
||||
auto_compact_threshold_tokens=_optional_int(payload.get('auto_compact_threshold_tokens')),
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
"""Tests for the bash run_in_background path (local fallback only).
|
||||
|
||||
Covers:
|
||||
- BashBgStore: record_start / mark_completed / mark_auto_resumed race semantics.
|
||||
- _run_bash_background local fallback: spawns a detached process with file
|
||||
redirects, registers it via the bg_register callback, output/pid/exit_code
|
||||
files materialize.
|
||||
- _run_bash_status / _run_bash_kill: route through bg_status_query / bg_kill_request
|
||||
callbacks and format their result.
|
||||
|
||||
Remote (jupyter_runtime) path is intentionally not exercised here — that
|
||||
requires a live wsh account and is covered by the e2e checklist in the plan.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.agent_tool_core import ToolExecutionContext
|
||||
from src.agent_tools import (
|
||||
_run_bash_background,
|
||||
_run_bash_kill,
|
||||
_run_bash_status,
|
||||
)
|
||||
from src.agent_types import AgentPermissions
|
||||
from src.bash_bg_store import BashBgStore, BgTaskSpec, BgTaskStatus
|
||||
|
||||
|
||||
def _make_context(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
bg_register=None,
|
||||
bg_status_query=None,
|
||||
bg_kill_request=None,
|
||||
) -> ToolExecutionContext:
|
||||
return ToolExecutionContext(
|
||||
root=tmp_path,
|
||||
command_timeout_seconds=10.0,
|
||||
max_output_chars=4096,
|
||||
permissions=AgentPermissions(allow_shell_commands=True),
|
||||
scratchpad_directory=tmp_path,
|
||||
bg_register=bg_register,
|
||||
bg_status_query=bg_status_query,
|
||||
bg_kill_request=bg_kill_request,
|
||||
account_id='acct-test',
|
||||
session_id='sess-test',
|
||||
run_id='run-test',
|
||||
)
|
||||
|
||||
|
||||
# ----- BashBgStore --------------------------------------------------------
|
||||
|
||||
|
||||
def test_store_record_and_complete(tmp_path: Path) -> None:
|
||||
store = BashBgStore(tmp_path / 'bg.db')
|
||||
spec = BgTaskSpec(
|
||||
task_id='bg_abc',
|
||||
account_key='acct',
|
||||
session_id='sess',
|
||||
run_id='run',
|
||||
pid=12345,
|
||||
task_dir=str(tmp_path / 'bg_abc'),
|
||||
output_path=str(tmp_path / 'bg_abc' / 'output'),
|
||||
pid_path=str(tmp_path / 'bg_abc' / 'pid'),
|
||||
exit_code_path=str(tmp_path / 'bg_abc' / 'exit_code'),
|
||||
command='echo hi',
|
||||
started_at=time.time(),
|
||||
wait_for_completion=True,
|
||||
)
|
||||
store.record_start(spec)
|
||||
row = store.get('bg_abc')
|
||||
assert row is not None
|
||||
assert row['status'] == 'running'
|
||||
assert row['exit_code'] is None
|
||||
|
||||
store.mark_completed('bg_abc', exit_code=0)
|
||||
row = store.get('bg_abc')
|
||||
assert row['status'] == 'completed'
|
||||
assert row['exit_code'] == 0
|
||||
assert row['finished_at'] is not None
|
||||
|
||||
|
||||
def test_store_mark_auto_resumed_is_race_safe(tmp_path: Path) -> None:
|
||||
store = BashBgStore(tmp_path / 'bg.db')
|
||||
spec = BgTaskSpec(
|
||||
task_id='bg_race',
|
||||
account_key='acct',
|
||||
session_id='sess',
|
||||
run_id='run',
|
||||
pid=1,
|
||||
task_dir='/tmp/x',
|
||||
output_path='/tmp/x/output',
|
||||
pid_path='/tmp/x/pid',
|
||||
exit_code_path='/tmp/x/exit_code',
|
||||
command='true',
|
||||
started_at=time.time(),
|
||||
wait_for_completion=True,
|
||||
)
|
||||
store.record_start(spec)
|
||||
store.mark_completed('bg_race', exit_code=0)
|
||||
|
||||
# First caller wins; subsequent callers always see False.
|
||||
assert store.mark_auto_resumed('bg_race') is True
|
||||
assert store.mark_auto_resumed('bg_race') is False
|
||||
assert store.mark_auto_resumed('bg_race') is False
|
||||
|
||||
|
||||
def test_store_mark_killed_only_running(tmp_path: Path) -> None:
|
||||
store = BashBgStore(tmp_path / 'bg.db')
|
||||
spec = BgTaskSpec(
|
||||
task_id='bg_kill',
|
||||
account_key='acct',
|
||||
session_id='sess',
|
||||
run_id='run',
|
||||
pid=2,
|
||||
task_dir='/tmp/y',
|
||||
output_path='/tmp/y/output',
|
||||
pid_path='/tmp/y/pid',
|
||||
exit_code_path='/tmp/y/exit_code',
|
||||
command='sleep 100',
|
||||
started_at=time.time(),
|
||||
wait_for_completion=False,
|
||||
)
|
||||
store.record_start(spec)
|
||||
store.mark_killed('bg_kill')
|
||||
row = store.get('bg_kill')
|
||||
assert row['status'] == 'cancelled'
|
||||
|
||||
# Re-killing a non-running task is a no-op (status stays cancelled).
|
||||
store.mark_killed('bg_kill')
|
||||
row = store.get('bg_kill')
|
||||
assert row['status'] == 'cancelled'
|
||||
|
||||
|
||||
# ----- _run_bash_background local fallback --------------------------------
|
||||
|
||||
|
||||
def test_run_bash_background_local_spawns_detached(tmp_path: Path) -> None:
|
||||
captured: list[BgTaskSpec] = []
|
||||
|
||||
ctx = _make_context(tmp_path, bg_register=captured.append)
|
||||
content, metadata = _run_bash_background(
|
||||
{
|
||||
'command': 'echo hello-from-bg',
|
||||
'wait_for_completion': True,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
assert metadata['action'] == 'bash_bg'
|
||||
task_id = metadata['task_id']
|
||||
assert task_id.startswith('bg_')
|
||||
assert metadata['wait_for_completion'] is True
|
||||
assert 'pid=' in content
|
||||
|
||||
# bg_register received exactly one spec, with the agent's session/account.
|
||||
assert len(captured) == 1
|
||||
spec = captured[0]
|
||||
assert spec.task_id == task_id
|
||||
assert spec.account_key == 'acct-test'
|
||||
assert spec.session_id == 'sess-test'
|
||||
assert spec.run_id == 'run-test'
|
||||
assert spec.command == 'echo hello-from-bg'
|
||||
assert spec.wait_for_completion is True
|
||||
|
||||
# Wait for the detached subprocess to finish — bounded by exit_code file.
|
||||
deadline = time.monotonic() + 5.0
|
||||
exit_code_path = Path(spec.exit_code_path)
|
||||
while time.monotonic() < deadline and not exit_code_path.exists():
|
||||
time.sleep(0.05)
|
||||
assert exit_code_path.exists(), 'exit_code file did not appear in 5s'
|
||||
assert exit_code_path.read_text(encoding='utf-8').strip() == '0'
|
||||
|
||||
output_text = Path(spec.output_path).read_text(encoding='utf-8')
|
||||
assert 'hello-from-bg' in output_text
|
||||
|
||||
|
||||
def test_run_bash_background_failed_command_records_nonzero_exit(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
captured: list[BgTaskSpec] = []
|
||||
ctx = _make_context(tmp_path, bg_register=captured.append)
|
||||
|
||||
_content, metadata = _run_bash_background(
|
||||
{'command': 'exit 7'},
|
||||
ctx,
|
||||
)
|
||||
spec = captured[0]
|
||||
assert metadata['action'] == 'bash_bg'
|
||||
|
||||
deadline = time.monotonic() + 5.0
|
||||
exit_code_path = Path(spec.exit_code_path)
|
||||
while time.monotonic() < deadline and not exit_code_path.exists():
|
||||
time.sleep(0.05)
|
||||
assert exit_code_path.exists()
|
||||
assert exit_code_path.read_text(encoding='utf-8').strip() == '7'
|
||||
|
||||
|
||||
def test_run_bash_background_requires_shell_permission(tmp_path: Path) -> None:
|
||||
from src.agent_tool_core import ToolPermissionError
|
||||
|
||||
ctx = ToolExecutionContext(
|
||||
root=tmp_path,
|
||||
command_timeout_seconds=10.0,
|
||||
max_output_chars=4096,
|
||||
permissions=AgentPermissions(allow_shell_commands=False),
|
||||
scratchpad_directory=tmp_path,
|
||||
)
|
||||
with pytest.raises(ToolPermissionError):
|
||||
_run_bash_background({'command': 'echo nope'}, ctx)
|
||||
|
||||
|
||||
# ----- bash_status / bash_kill via callbacks ------------------------------
|
||||
|
||||
|
||||
def test_bash_status_routes_through_callback(tmp_path: Path) -> None:
|
||||
fake_status = BgTaskStatus(
|
||||
task_id='bg_xyz',
|
||||
status='completed',
|
||||
pid=999,
|
||||
started_at=time.time() - 5.0,
|
||||
finished_at=time.time(),
|
||||
exit_code=0,
|
||||
output_path='/tmp/x/output',
|
||||
output_preview='all good\n',
|
||||
auto_resumed=False,
|
||||
)
|
||||
|
||||
queries: list[str] = []
|
||||
|
||||
def query(task_id: str) -> BgTaskStatus | None:
|
||||
queries.append(task_id)
|
||||
return fake_status
|
||||
|
||||
ctx = _make_context(tmp_path, bg_status_query=query)
|
||||
content, metadata = _run_bash_status({'task_id': 'bg_xyz'}, ctx)
|
||||
|
||||
assert queries == ['bg_xyz']
|
||||
assert metadata['action'] == 'bash_status'
|
||||
assert metadata['status'] == 'completed'
|
||||
assert metadata['exit_code'] == 0
|
||||
assert 'all good' in content
|
||||
assert 'task_id=bg_xyz' in content
|
||||
|
||||
|
||||
def test_bash_status_unknown_task_raises(tmp_path: Path) -> None:
|
||||
from src.agent_tool_core import ToolExecutionError
|
||||
|
||||
def query(_task_id: str):
|
||||
return None
|
||||
|
||||
ctx = _make_context(tmp_path, bg_status_query=query)
|
||||
with pytest.raises(ToolExecutionError):
|
||||
_run_bash_status({'task_id': 'bg_missing'}, ctx)
|
||||
|
||||
|
||||
def test_bash_status_without_callback_raises(tmp_path: Path) -> None:
|
||||
from src.agent_tool_core import ToolExecutionError
|
||||
|
||||
ctx = _make_context(tmp_path)
|
||||
with pytest.raises(ToolExecutionError):
|
||||
_run_bash_status({'task_id': 'bg_anything'}, ctx)
|
||||
|
||||
|
||||
def test_bash_kill_routes_through_callback(tmp_path: Path) -> None:
|
||||
killed: list[str] = []
|
||||
|
||||
def kill(task_id: str) -> bool:
|
||||
killed.append(task_id)
|
||||
return True
|
||||
|
||||
ctx = _make_context(tmp_path, bg_kill_request=kill)
|
||||
content, metadata = _run_bash_kill({'task_id': 'bg_kk'}, ctx)
|
||||
assert killed == ['bg_kk']
|
||||
assert metadata['killed'] is True
|
||||
assert 'bg_kk' in content
|
||||
|
||||
|
||||
def test_bash_kill_failure_reports(tmp_path: Path) -> None:
|
||||
ctx = _make_context(tmp_path, bg_kill_request=lambda _t: False)
|
||||
content, metadata = _run_bash_kill({'task_id': 'bg_dead'}, ctx)
|
||||
assert metadata['killed'] is False
|
||||
assert '未能终止' in content
|
||||
|
||||
|
||||
# ----- end-to-end: spawn + register + sync via real BashBgStore -----------
|
||||
|
||||
|
||||
def test_local_bg_lifecycle_with_real_store(tmp_path: Path) -> None:
|
||||
"""Plug a real BashBgStore into bg_register and check the row appears."""
|
||||
store = BashBgStore(tmp_path / 'bg.db')
|
||||
|
||||
def register(spec: BgTaskSpec) -> None:
|
||||
store.record_start(spec)
|
||||
|
||||
ctx = _make_context(tmp_path, bg_register=register)
|
||||
_content, metadata = _run_bash_background(
|
||||
{'command': 'echo lifecycle && exit 0'}, ctx,
|
||||
)
|
||||
task_id = metadata['task_id']
|
||||
row = store.get(task_id)
|
||||
assert row is not None
|
||||
assert row['status'] == 'running'
|
||||
|
||||
# Wait for child to write exit_code (more reliable than kill -0, since
|
||||
# the unreaped child becomes a zombie and kill -0 still reports it alive).
|
||||
deadline = time.monotonic() + 5.0
|
||||
exit_code_path = Path(row['exit_code_path'])
|
||||
while time.monotonic() < deadline and not exit_code_path.exists():
|
||||
time.sleep(0.05)
|
||||
if not exit_code_path.exists():
|
||||
pytest.fail('background process did not exit within 5 seconds')
|
||||
|
||||
# Simulate manager finalize step.
|
||||
exit_code = int(
|
||||
exit_code_path.read_text(encoding='utf-8').strip()
|
||||
)
|
||||
store.mark_completed(task_id, exit_code=exit_code)
|
||||
row = store.get(task_id)
|
||||
assert row['status'] == 'completed'
|
||||
assert row['exit_code'] == 0
|
||||
Reference in New Issue
Block a user