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/
|
scratchpad/
|
||||||
tasks/
|
tasks/
|
||||||
router_session_parquet/
|
router_session_parquet/
|
||||||
|
.logs/
|
||||||
|
|
||||||
# Environment files
|
# Environment files
|
||||||
.env
|
.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";
|
"use client";
|
||||||
|
|
||||||
import type { ExportedMessageRepository } from "@assistant-ui/core";
|
import type { ExportedMessageRepository } from "@assistant-ui/core";
|
||||||
import { AssistantRuntimeProvider } from "@assistant-ui/react";
|
import {
|
||||||
|
AssistantRuntimeProvider,
|
||||||
|
useAui,
|
||||||
|
useAuiState,
|
||||||
|
} from "@assistant-ui/react";
|
||||||
import {
|
import {
|
||||||
AssistantChatTransport,
|
AssistantChatTransport,
|
||||||
useChatRuntime,
|
useChatRuntime,
|
||||||
} from "@assistant-ui/react-ai-sdk";
|
} from "@assistant-ui/react-ai-sdk";
|
||||||
import type { UIMessage } from "ai";
|
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 {
|
import {
|
||||||
ActivityPanel,
|
ActivityPanel,
|
||||||
ActivityProvider,
|
ActivityProvider,
|
||||||
|
useActivityPanel,
|
||||||
} from "@/components/assistant-ui/activity-panel";
|
} from "@/components/assistant-ui/activity-panel";
|
||||||
import { Thread } from "@/components/assistant-ui/thread";
|
import { Thread } from "@/components/assistant-ui/thread";
|
||||||
import {
|
import {
|
||||||
@@ -18,18 +24,27 @@ import {
|
|||||||
toReplayRepository,
|
toReplayRepository,
|
||||||
} from "@/components/assistant-ui/thread-list";
|
} from "@/components/assistant-ui/thread-list";
|
||||||
import { ThreadListSidebar } from "@/components/assistant-ui/threadlist-sidebar";
|
import { ThreadListSidebar } from "@/components/assistant-ui/threadlist-sidebar";
|
||||||
|
import { TrainingPipelinePanel } from "@/components/training/training-pipeline-panel";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
SidebarInset,
|
SidebarInset,
|
||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
SidebarTrigger,
|
SidebarTrigger,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import {
|
import {
|
||||||
|
ACTIVE_SESSION_CHANGED_EVENT,
|
||||||
|
consumeFreshLocalId,
|
||||||
readActiveSessionId,
|
readActiveSessionId,
|
||||||
readPendingWorkspaceSessionId,
|
readPendingWorkspaceSessionId,
|
||||||
writeActiveSessionId,
|
writeActiveSessionId,
|
||||||
} from "@/lib/claw-active-session";
|
} from "@/lib/claw-active-session";
|
||||||
import { ClawSessionReplayProvider } from "@/lib/claw-session-replay";
|
import { ClawSessionReplayProvider } from "@/lib/claw-session-replay";
|
||||||
import { pushSessionUrl } from "@/lib/claw-session-url";
|
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";
|
import { ClawAccountGate, useClawAccount } from "./claw-account-gate";
|
||||||
|
|
||||||
type AssistantProps = {
|
type AssistantProps = {
|
||||||
@@ -81,6 +96,7 @@ export const Assistant = ({ initialSessionId }: AssistantProps = {}) => {
|
|||||||
});
|
});
|
||||||
const replaySession = useCallback(
|
const replaySession = useCallback(
|
||||||
(sessionId: string, repository: ExportedMessageRepository) => {
|
(sessionId: string, repository: ExportedMessageRepository) => {
|
||||||
|
console.log("[replay-session] called", { sessionId });
|
||||||
writeActiveSessionId(sessionId);
|
writeActiveSessionId(sessionId);
|
||||||
pushSessionUrl(sessionId);
|
pushSessionUrl(sessionId);
|
||||||
// 后端已经落盘/结束后,用回放结果接管当前会话。
|
// 后端已经落盘/结束后,用回放结果接管当前会话。
|
||||||
@@ -153,12 +169,7 @@ export const Assistant = ({ initialSessionId }: AssistantProps = {}) => {
|
|||||||
onLogout={() => setAccount(null)}
|
onLogout={() => setAccount(null)}
|
||||||
/>
|
/>
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
<AssistantWorkspace />
|
||||||
<div className="min-w-0 flex-1 overflow-hidden">
|
|
||||||
<Thread />
|
|
||||||
</div>
|
|
||||||
<ActivityPanel />
|
|
||||||
</div>
|
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</div>
|
</div>
|
||||||
</SidebarProvider>
|
</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[]) {
|
function getLastSessionId(messages: UIMessage[]) {
|
||||||
for (const message of [...messages].reverse()) {
|
for (const message of [...messages].reverse()) {
|
||||||
if (message.role !== "assistant") continue;
|
if (message.role !== "assistant") continue;
|
||||||
|
|||||||
@@ -36,36 +36,59 @@ export function useClawAccount() {
|
|||||||
return { account, isLoading, refresh, setAccount };
|
return { account, isLoading, refresh, setAccount };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const XIAOMI_EMAIL_RE = /^[\w.-]+@xiaomi\.com$/i;
|
||||||
|
|
||||||
export function ClawAccountGate({
|
export function ClawAccountGate({
|
||||||
onAccount,
|
onAccount,
|
||||||
}: {
|
}: {
|
||||||
onAccount: (account: ClawAccount) => void;
|
onAccount: (account: ClawAccount) => void;
|
||||||
}) {
|
}) {
|
||||||
const [mode, setMode] = useState<"login" | "register">("login");
|
const [mode, setMode] = useState<"login" | "register">("login");
|
||||||
const [username, setUsername] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
setError("");
|
setError("");
|
||||||
|
const trimmed = email.trim();
|
||||||
|
if (!XIAOMI_EMAIL_RE.test(trimmed)) {
|
||||||
|
setError("请使用小米邮箱(@xiaomi.com)登录");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 6) {
|
||||||
|
setError("密码至少 6 位");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
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",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ email: trimmed, password }),
|
||||||
});
|
});
|
||||||
const payload = (await response.json()) as {
|
const payload = (await response.json()) as {
|
||||||
account?: ClawAccount;
|
account?: ClawAccount;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
if (!response.ok || !payload.account) {
|
if (!response.ok || !payload.account) {
|
||||||
throw new Error(payload.error ?? "账号操作失败");
|
throw new Error(
|
||||||
|
payload.error ?? (mode === "register" ? "注册失败" : "登录失败"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
onAccount(payload.account);
|
onAccount(payload.account);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "账号操作失败");
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: mode === "register"
|
||||||
|
? "注册失败"
|
||||||
|
: "登录失败",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -77,22 +100,25 @@ export function ClawAccountGate({
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="font-semibold text-xl">ZK Data Agent</h1>
|
<h1 className="font-semibold text-xl">ZK Data Agent</h1>
|
||||||
<p className="mt-1 text-muted-foreground text-sm">
|
<p className="mt-1 text-muted-foreground text-sm">
|
||||||
中控数据开发平台会按账号隔离会话、上传文件和生成产物。
|
{mode === "register"
|
||||||
|
? "创建账号:使用小米邮箱注册,前缀作为 autoresearch 工作根目录。"
|
||||||
|
: "使用小米邮箱登录。会话/上传/产物按邮箱前缀和 chat 二级隔离。"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="账号"
|
type="email"
|
||||||
value={username}
|
placeholder="your_name@xiaomi.com"
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Enter") submit();
|
if (event.key === "Enter") submit();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="密码"
|
placeholder={mode === "register" ? "设置密码(≥6 位)" : "密码"}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
@@ -101,7 +127,7 @@ export function ClawAccountGate({
|
|||||||
/>
|
/>
|
||||||
{error ? <p className="text-destructive text-sm">{error}</p> : null}
|
{error ? <p className="text-destructive text-sm">{error}</p> : null}
|
||||||
<Button onClick={submit} disabled={isSubmitting}>
|
<Button onClick={submit} disabled={isSubmitting}>
|
||||||
{isSubmitting ? "处理中..." : mode === "login" ? "登录" : "注册"}
|
{isSubmitting ? "处理中..." : mode === "register" ? "注册" : "登录"}
|
||||||
</Button>
|
</Button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@@ -51,6 +52,86 @@
|
|||||||
background-position: -100% 0;
|
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 {
|
.project-doc-page {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||||
import {
|
import {
|
||||||
ACTIVE_SESSION_CHANGED_EVENT,
|
ACTIVE_SESSION_CHANGED_EVENT,
|
||||||
readActiveSessionId,
|
readActiveSessionId,
|
||||||
@@ -140,7 +141,7 @@ type ActivityItem = {
|
|||||||
|
|
||||||
type LiveRunEvent = NonNullable<ClawRunStatus["events"]>[number];
|
type LiveRunEvent = NonNullable<ClawRunStatus["events"]>[number];
|
||||||
|
|
||||||
export function ActivityPanel() {
|
export function ActivityPanel({ asDrawer = false }: { asDrawer?: boolean } = {}) {
|
||||||
const {
|
const {
|
||||||
open,
|
open,
|
||||||
mode,
|
mode,
|
||||||
@@ -211,6 +212,7 @@ export function ActivityPanel() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const latestId = items.at(-1)?.id ?? null;
|
const latestId = items.at(-1)?.id ?? null;
|
||||||
if (
|
if (
|
||||||
|
!asDrawer &&
|
||||||
mode === "activity" &&
|
mode === "activity" &&
|
||||||
!selectedId &&
|
!selectedId &&
|
||||||
latestId &&
|
latestId &&
|
||||||
@@ -219,22 +221,14 @@ export function ActivityPanel() {
|
|||||||
openActivity();
|
openActivity();
|
||||||
}
|
}
|
||||||
prevLatestIdRef.current = latestId;
|
prevLatestIdRef.current = latestId;
|
||||||
}, [items, mode, openActivity, selectedId]);
|
}, [asDrawer, items, mode, openActivity, selectedId]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!asDrawer && !open) return null;
|
||||||
|
|
||||||
if (mode === "files") {
|
const isFilesMode = mode === "files";
|
||||||
return (
|
|
||||||
<SessionFilesPanel
|
|
||||||
key={`${sessionId ?? "active"}:${filesRefreshToken}`}
|
|
||||||
sessionId={sessionId}
|
|
||||||
onClose={close}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
const activityBody = (
|
||||||
<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">
|
<>
|
||||||
<header className="flex h-16 shrink-0 items-center justify-between border-b px-4">
|
<header className="flex h-16 shrink-0 items-center justify-between border-b px-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="font-semibold text-base">活动</h2>
|
<h2 className="font-semibold text-base">活动</h2>
|
||||||
@@ -244,6 +238,7 @@ export function ActivityPanel() {
|
|||||||
: "暂无链路"}
|
: "暂无链路"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{!asDrawer ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -254,6 +249,7 @@ export function ActivityPanel() {
|
|||||||
>
|
>
|
||||||
<PanelRightCloseIcon className="size-4" />
|
<PanelRightCloseIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||||
{visibleItems.length === 0 ? (
|
{visibleItems.length === 0 ? (
|
||||||
@@ -274,12 +270,59 @@ export function ActivityPanel() {
|
|||||||
open={selectedId === item.id}
|
open={selectedId === item.id}
|
||||||
onOpenChange={(nextOpen) => {
|
onOpenChange={(nextOpen) => {
|
||||||
if (nextOpen) openItem(item.id);
|
if (nextOpen) openItem(item.id);
|
||||||
|
else if (selectedId === item.id) openActivity();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -308,9 +351,11 @@ type SessionFilesPayload = {
|
|||||||
function SessionFilesPanel({
|
function SessionFilesPanel({
|
||||||
sessionId,
|
sessionId,
|
||||||
onClose,
|
onClose,
|
||||||
|
embedded = false,
|
||||||
}: {
|
}: {
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
embedded?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [payload, setPayload] = useState<SessionFilesPayload | null>(null);
|
const [payload, setPayload] = useState<SessionFilesPayload | null>(null);
|
||||||
const [status, setStatus] = useState("正在读取聊天中的文件...");
|
const [status, setStatus] = useState("正在读取聊天中的文件...");
|
||||||
@@ -377,8 +422,8 @@ function SessionFilesPanel({
|
|||||||
const outputFiles = payload?.output ?? [];
|
const outputFiles = payload?.output ?? [];
|
||||||
const total = inputFiles.length + outputFiles.length;
|
const total = inputFiles.length + outputFiles.length;
|
||||||
|
|
||||||
return (
|
const body = (
|
||||||
<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">
|
<>
|
||||||
<header className="flex h-16 shrink-0 items-center justify-between border-b px-4">
|
<header className="flex h-16 shrink-0 items-center justify-between border-b px-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="font-semibold text-base">聊天中的文件</h2>
|
<h2 className="font-semibold text-base">聊天中的文件</h2>
|
||||||
@@ -386,6 +431,7 @@ function SessionFilesPanel({
|
|||||||
{status || `${total} 个文件`}
|
{status || `${total} 个文件`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{!embedded ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -396,6 +442,7 @@ function SessionFilesPanel({
|
|||||||
>
|
>
|
||||||
<PanelRightCloseIcon className="size-4" />
|
<PanelRightCloseIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||||
{!status && total === 0 ? (
|
{!status && total === 0 ? (
|
||||||
@@ -406,6 +453,16 @@ function SessionFilesPanel({
|
|||||||
<FileSection title="输入" files={inputFiles} />
|
<FileSection title="输入" files={inputFiles} />
|
||||||
<FileSection title="输出" files={outputFiles} />
|
<FileSection title="输出" files={outputFiles} />
|
||||||
</div>
|
</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>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import {
|
|||||||
import { ThreadListPrimitive } from "@assistant-ui/react";
|
import { ThreadListPrimitive } from "@assistant-ui/react";
|
||||||
import type { UIMessage } from "ai";
|
import type { UIMessage } from "ai";
|
||||||
import {
|
import {
|
||||||
|
Loader2Icon,
|
||||||
MoreHorizontalIcon,
|
MoreHorizontalIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
|
ServerIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
type FC,
|
type FC,
|
||||||
@@ -21,8 +24,10 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
clearActiveSessionId,
|
clearActiveSessionId,
|
||||||
|
markFreshLocalId,
|
||||||
readActiveSessionId,
|
readActiveSessionId,
|
||||||
writeActiveSessionId,
|
writeActiveSessionId,
|
||||||
|
writePendingWorkspaceSessionId,
|
||||||
} from "@/lib/claw-active-session";
|
} from "@/lib/claw-active-session";
|
||||||
import { useClawSessionReplay } from "@/lib/claw-session-replay";
|
import { useClawSessionReplay } from "@/lib/claw-session-replay";
|
||||||
import { pushHomeUrl, pushSessionUrl } from "@/lib/claw-session-url";
|
import { pushHomeUrl, pushSessionUrl } from "@/lib/claw-session-url";
|
||||||
@@ -112,6 +117,7 @@ export const ThreadList: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<ThreadListPrimitive.Root className="aui-root aui-thread-list-root flex min-h-0 flex-1 flex-col gap-1">
|
<ThreadListPrimitive.Root className="aui-root aui-thread-list-root flex min-h-0 flex-1 flex-col gap-1">
|
||||||
<ThreadListNew />
|
<ThreadListNew />
|
||||||
|
<JupyterWorkspaceList />
|
||||||
<ClawSessionList />
|
<ClawSessionList />
|
||||||
</ThreadListPrimitive.Root>
|
</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 ClawSessionList: FC = () => {
|
||||||
const { replaySession } = useClawSessionReplay();
|
const { replaySession } = useClawSessionReplay();
|
||||||
const [sessions, setSessions] = useState<ClawSession[]>([]);
|
const [sessions, setSessions] = useState<ClawSession[]>([]);
|
||||||
@@ -262,6 +440,10 @@ const ClawSessionList: FC = () => {
|
|||||||
type="button"
|
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"
|
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 () => {
|
onClick={async () => {
|
||||||
|
console.log("[session-list] click", {
|
||||||
|
sessionId: session.session_id,
|
||||||
|
preview: session.preview,
|
||||||
|
});
|
||||||
setOpenMenuSessionId(null);
|
setOpenMenuSessionId(null);
|
||||||
writeActiveSessionId(session.session_id);
|
writeActiveSessionId(session.session_id);
|
||||||
pushSessionUrl(session.session_id);
|
pushSessionUrl(session.session_id);
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ import {
|
|||||||
ACTIVE_SESSION_CHANGED_EVENT,
|
ACTIVE_SESSION_CHANGED_EVENT,
|
||||||
clearActiveSessionId,
|
clearActiveSessionId,
|
||||||
clearPendingWorkspaceSessionId,
|
clearPendingWorkspaceSessionId,
|
||||||
|
markFreshLocalId,
|
||||||
PENDING_WORKSPACE_SESSION_CHANGED_EVENT,
|
PENDING_WORKSPACE_SESSION_CHANGED_EVENT,
|
||||||
readActiveSessionId,
|
readActiveSessionId,
|
||||||
readPendingWorkspaceSessionId,
|
readPendingWorkspaceSessionId,
|
||||||
@@ -261,8 +262,14 @@ function useRefreshCurrentRun() {
|
|||||||
includePending: true,
|
includePending: true,
|
||||||
includeActive: true,
|
includeActive: true,
|
||||||
});
|
});
|
||||||
const activeRunRef = useRef<string | null>(null);
|
// Per-session bookkeeping: switching sessions (sidebar history) and coming
|
||||||
const appliedTerminalRef = useRef<string | null>(null);
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
@@ -273,17 +280,15 @@ function useRefreshCurrentRun() {
|
|||||||
const runStatus = await fetchLatestRunStatus(sessionId);
|
const runStatus = await fetchLatestRunStatus(sessionId);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (runStatus?.status === "queued" || runStatus?.status === "running") {
|
if (runStatus?.status === "queued" || runStatus?.status === "running") {
|
||||||
activeRunRef.current = runStatus.run_id ?? "active";
|
const runKey = runStatus.run_id ?? "active";
|
||||||
return;
|
const previousRunKey = activeRunRefMap.current.get(sessionId) ?? null;
|
||||||
}
|
activeRunRefMap.current.set(sessionId, runKey);
|
||||||
if (!runtimeRunning && !activeRunRef.current) return;
|
// Watcher auto-resume: backend started a new run via _maybe_auto_resume
|
||||||
const terminalKey = [
|
// without a local SSE stream driving the UI. We need to replay once so
|
||||||
runStatus?.run_id ?? activeRunRef.current ?? "unknown",
|
// the active-run placeholder (pending prompt + 思考中 bubble) appears.
|
||||||
runStatus?.status ?? "idle",
|
// useRefreshReplayedRun will take over polling once replayedRun.active
|
||||||
runStatus?.finished_at ?? "",
|
// becomes true.
|
||||||
runStatus?.elapsed_ms ?? "",
|
if (!runtimeRunning && previousRunKey !== runKey) {
|
||||||
].join(":");
|
|
||||||
if (appliedTerminalRef.current === terminalKey) return;
|
|
||||||
const response = await fetch(`/api/claw/sessions/${sessionId}`, {
|
const response = await fetch(`/api/claw/sessions/${sessionId}`, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
@@ -293,8 +298,29 @@ function useRefreshCurrentRun() {
|
|||||||
sessionId,
|
sessionId,
|
||||||
toReplayRepository(payload, sessionId, runStatus),
|
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();
|
scheduleSessionListRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,17 +451,16 @@ async function cancelLatestRun(sessionId: string, runId?: string | null) {
|
|||||||
const ChatTopActions: FC = () => {
|
const ChatTopActions: FC = () => {
|
||||||
const { openFiles } = useActivityPanel();
|
const { openFiles } = useActivityPanel();
|
||||||
const [workspaceOpen, setWorkspaceOpen] = useState(false);
|
const [workspaceOpen, setWorkspaceOpen] = useState(false);
|
||||||
const actionSessionId = useCurrentThreadSessionId({
|
// "切换工作区" dialog 与 indicator 必须用同一个 sessionId,否则会出现
|
||||||
includePending: true,
|
// "绑给 A、查的是 B" 的错位(agent 的 bash 路由也是用 active session id,
|
||||||
// 工作区切换必须绑定当前可见会话;空白新会话不能回退到上一个
|
// 错位时表面 indicator 显示已连接,实际 agent 跑在本地)。
|
||||||
// activeSessionId,否则会把 Jupyter 工作区切到旧会话上。
|
// 新建空会话场景由 thread-list.tsx 的 clearActiveSessionId() 兜底,
|
||||||
includeActive: false,
|
// 此处不再需要刻意排除 activeSessionId。
|
||||||
});
|
const sessionId = useCurrentThreadSessionId({
|
||||||
const displaySessionId = useCurrentThreadSessionId({
|
|
||||||
includePending: true,
|
includePending: true,
|
||||||
includeActive: true,
|
includeActive: true,
|
||||||
});
|
});
|
||||||
const workspaceStatus = useJupyterWorkspaceStatus(displaySessionId);
|
const workspaceStatus = useJupyterWorkspaceStatus(sessionId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none sticky top-3 z-20 mr-4 flex justify-end">
|
<div className="pointer-events-none sticky top-3 z-20 mr-4 flex justify-end">
|
||||||
@@ -462,7 +487,7 @@ const ChatTopActions: FC = () => {
|
|||||||
type="button"
|
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"
|
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-muted"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
openFiles(displaySessionId);
|
openFiles(sessionId);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FileTextIcon className="size-4 text-muted-foreground" />
|
<FileTextIcon className="size-4 text-muted-foreground" />
|
||||||
@@ -482,7 +507,7 @@ const ChatTopActions: FC = () => {
|
|||||||
<WorkspaceSwitchDialog
|
<WorkspaceSwitchDialog
|
||||||
open={workspaceOpen}
|
open={workspaceOpen}
|
||||||
onOpenChange={setWorkspaceOpen}
|
onOpenChange={setWorkspaceOpen}
|
||||||
sessionId={actionSessionId}
|
sessionId={sessionId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -633,6 +658,7 @@ function WorkspaceSwitchDialog({
|
|||||||
if (needsFirstMessageSession) {
|
if (needsFirstMessageSession) {
|
||||||
writePendingWorkspaceSessionId(sessionIdForRequest);
|
writePendingWorkspaceSessionId(sessionIdForRequest);
|
||||||
}
|
}
|
||||||
|
window.dispatchEvent(new Event("claw-workspaces-changed"));
|
||||||
setStatus(payload as JupyterWorkspaceStatus);
|
setStatus(payload as JupyterWorkspaceStatus);
|
||||||
setProgress({ percent: 100, label: "切换完成" });
|
setProgress({ percent: 100, label: "切换完成" });
|
||||||
setMessage("工作区切换成功,后续工具会在远端 Jupyter 工作区执行。");
|
setMessage("工作区切换成功,后续工具会在远端 Jupyter 工作区执行。");
|
||||||
@@ -757,6 +783,7 @@ function ensureWorkspaceSessionId(current: string | null) {
|
|||||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||||
? `__LOCALID_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
|
? `__LOCALID_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
|
||||||
: `__LOCALID_${Math.random().toString(36).slice(2, 10)}`;
|
: `__LOCALID_${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
markFreshLocalId(generated);
|
||||||
writeActiveSessionId(generated);
|
writeActiveSessionId(generated);
|
||||||
scheduleSessionListRefresh();
|
scheduleSessionListRefresh();
|
||||||
return generated;
|
return generated;
|
||||||
@@ -1187,6 +1214,7 @@ function ModelPickerButton({
|
|||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
const filteredModels = useMemo(() => {
|
const filteredModels = useMemo(() => {
|
||||||
if (!normalizedQuery) return status.models;
|
if (!normalizedQuery) return status.models;
|
||||||
@@ -1200,7 +1228,7 @@ function ModelPickerButton({
|
|||||||
}, [normalizedQuery, status.models]);
|
}, [normalizedQuery, status.models]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PopoverPrimitive.Root>
|
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
|
||||||
<PopoverPrimitive.Trigger asChild>
|
<PopoverPrimitive.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1241,7 +1269,10 @@ function ModelPickerButton({
|
|||||||
key={model.id}
|
key={model.id}
|
||||||
type="button"
|
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"
|
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="min-w-0 flex-1 truncate">{model.id}</span>
|
||||||
<span className="shrink-0 text-muted-foreground text-xs">
|
<span className="shrink-0 text-muted-foreground text-xs">
|
||||||
@@ -2371,6 +2402,11 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
|
|||||||
const justSubmittedRef = useRef(false);
|
const justSubmittedRef = useRef(false);
|
||||||
const isDisabled = runtimeDisabled || disabled;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isComposingRef.current) {
|
if (!isComposingRef.current) {
|
||||||
if (justSubmittedRef.current && storeText) return;
|
if (justSubmittedRef.current && storeText) return;
|
||||||
@@ -2379,16 +2415,6 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
|
|||||||
}
|
}
|
||||||
}, [storeText]);
|
}, [storeText]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!isComposingRef.current &&
|
|
||||||
localText !== storeText &&
|
|
||||||
aui.composer().getState().isEditing
|
|
||||||
) {
|
|
||||||
aui.composer().setText(localText);
|
|
||||||
}
|
|
||||||
}, [aui, localText, storeText]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoFocus || isDisabled) return;
|
if (!autoFocus || isDisabled) return;
|
||||||
const textarea = textareaRef.current;
|
const textarea = textareaRef.current;
|
||||||
@@ -2411,6 +2437,21 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
|
|||||||
};
|
};
|
||||||
}, [aui]);
|
}, [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(
|
const syncComposerText = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
if (!aui.composer().getState().isEditing) return;
|
if (!aui.composer().getState().isEditing) return;
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ type ClawSessionSummary = {
|
|||||||
preview?: string;
|
preview?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
usage?: UsageSummary;
|
usage?: UsageSummary;
|
||||||
|
is_training?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UsageSummary = {
|
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"
|
className="flex w-full flex-col rounded-lg px-2 py-2 text-left transition-colors hover:bg-muted"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
<span className="truncate font-medium text-sm">
|
<span className="truncate font-medium text-sm">
|
||||||
{loading ? "回放中..." : session.preview || session.session_id}
|
{loading ? "回放中..." : session.preview || session.session_id}
|
||||||
</span>
|
</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">
|
<span className="mt-0.5 text-muted-foreground text-xs">
|
||||||
{session.turns ?? 0} 轮 · {session.tool_calls ?? 0} 次工具
|
{session.turns ?? 0} 轮 · {session.tool_calls ?? 0} 次工具
|
||||||
</span>
|
</span>
|
||||||
@@ -382,6 +390,7 @@ function useSidebarSessions(open: boolean) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const reload = () => {
|
||||||
fetch("/api/claw/sessions", { cache: "no-store" })
|
fetch("/api/claw/sessions", { cache: "no-store" })
|
||||||
.then((res) => (res.ok ? res.json() : []))
|
.then((res) => (res.ok ? res.json() : []))
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
@@ -391,8 +400,13 @@ function useSidebarSessions(open: boolean) {
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setSessions([]);
|
if (!cancelled) setSessions([]);
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
reload();
|
||||||
|
const handleUpdate = () => reload();
|
||||||
|
window.addEventListener("claw-session-updated", handleUpdate);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
window.removeEventListener("claw-session-updated", handleUpdate);
|
||||||
};
|
};
|
||||||
}, [open]);
|
}, [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,
|
className,
|
||||||
children,
|
children,
|
||||||
side = "right",
|
side = "right",
|
||||||
|
hideClose = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
side?: "top" | "right" | "bottom" | "left";
|
side?: "top" | "right" | "bottom" | "left";
|
||||||
|
hideClose?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SheetPortal>
|
<SheetPortal>
|
||||||
@@ -72,10 +74,12 @@ function SheetContent({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{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">
|
<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" />
|
<XIcon className="size-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</SheetPrimitive.Close>
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
</SheetPrimitive.Content>
|
</SheetPrimitive.Content>
|
||||||
</SheetPortal>
|
</SheetPortal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -87,3 +87,19 @@ function normalizeSessionId(value: unknown) {
|
|||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
return trimmed || null;
|
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);
|
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() {
|
export async function logoutAccount() {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
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",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"recharts": "^2.15.4",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
@@ -35,6 +37,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.13",
|
"@biomejs/biome": "^2.4.13",
|
||||||
"@tailwindcss/postcss": "^4.2.4",
|
"@tailwindcss/postcss": "^4.2.4",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -4676,6 +4679,82 @@
|
|||||||
"tailwindcss": "4.2.4"
|
"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": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.13",
|
"version": "4.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
||||||
@@ -4930,11 +5009,145 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"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": {
|
"node_modules/decode-named-character-reference": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
"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"
|
"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": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.21.0",
|
"version": "5.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
|
||||||
@@ -5030,6 +5259,12 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/eventsource-parser": {
|
||||||
"version": "3.0.8",
|
"version": "3.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
|
"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": {
|
"node_modules/get-nonce": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
||||||
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="
|
"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": {
|
"node_modules/is-alphabetical": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
||||||
@@ -5169,6 +5422,12 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"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": {
|
"node_modules/json-schema": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
|
||||||
@@ -5423,6 +5682,12 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"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": {
|
"node_modules/longest-streak": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
|
||||||
@@ -5432,6 +5697,18 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/lucide-react": {
|
||||||
"version": "1.14.0",
|
"version": "1.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz",
|
||||||
@@ -6366,6 +6643,15 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/parse-entities": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
|
||||||
@@ -6422,6 +6708,20 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/postcss/node_modules/nanoid": {
|
||||||
"version": "3.3.12",
|
"version": "3.3.12",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
"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": "^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": {
|
"node_modules/property-information": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
|
||||||
@@ -6645,6 +6962,12 @@
|
|||||||
"react": "^19.2.5"
|
"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": {
|
"node_modules/react-markdown": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
"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": {
|
"node_modules/react-style-singleton": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
"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"
|
"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": {
|
"node_modules/remark-gfm": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
|
||||||
@@ -7009,6 +7395,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/trim-lines": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
"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"
|
"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": {
|
"node_modules/vfile": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||||
@@ -7273,6 +7672,28 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/zod": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||||
|
|||||||
@@ -30,6 +30,8 @@
|
|||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"recharts": "^2.15.4",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
@@ -39,6 +41,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.13",
|
"@biomejs/biome": "^2.4.13",
|
||||||
"@tailwindcss/postcss": "^4.2.4",
|
"@tailwindcss/postcss": "^4.2.4",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@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 评测工作流配置
|
||||||
cml_eval:
|
cml_eval:
|
||||||
workflow_id: f-20260408161444-wu3pz
|
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="旅游出行")` | 规划一个从北京到上海的路线 |
|
| 规划路线,无筛选条件 | `Agent(tag="旅游")` | 规划一个从北京到上海的路线 |
|
||||||
| 规划路线,有筛选条件 | `ComplexTask(tag="旅游出行")` | 规划一个从北京到上海的路线,人少风景好、三天两夜、3000预算 |
|
| 规划路线,有筛选条件 | `ComplexTask(tag="旅游")` | 规划一个从北京到上海的路线,人少风景好、三天两夜、3000预算 |
|
||||||
|
|
||||||
## 判定要点
|
## 判定要点
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,18 +2,16 @@
|
|||||||
"""
|
"""
|
||||||
从 ai-planning 组装 SFT 训练/验证集,并启动 zk_trainer SFT 训练。
|
从 ai-planning 组装 SFT 训练/验证集,并启动 zk_trainer SFT 训练。
|
||||||
|
|
||||||
用法:
|
用法(先 cd "$AUTORESEARCH_CHAT_ROOT",所有产物落 chat 工作区):
|
||||||
# 仅组装数据
|
# 仅组装数据
|
||||||
AUTORESEARCH_ROOT=/mnt/wangsenhao/autoresearch-zk \
|
python scripts/prepare_and_train_sft.py prepare --output_dir "$AUTORESEARCH_CHAT_ROOT/sft_data"
|
||||||
python prepare_and_train_sft.py prepare --output_dir /mnt/wangsenhao/autoresearch-zk/sft_data
|
|
||||||
|
|
||||||
# 启动训练(需先 prepare)
|
# 启动训练(需先 prepare)
|
||||||
AUTORESEARCH_ROOT=/mnt/wangsenhao/autoresearch-zk \
|
python scripts/prepare_and_train_sft.py train \
|
||||||
python prepare_and_train_sft.py train \
|
--data_dir "$AUTORESEARCH_CHAT_ROOT/sft_data" \
|
||||||
--data_dir /mnt/wangsenhao/autoresearch-zk/sft_data \
|
|
||||||
--model_path /mnt/wangsenhao/verl_zk/Qwen3-4B-Instruct-2507 \
|
--model_path /mnt/wangsenhao/verl_zk/Qwen3-4B-Instruct-2507 \
|
||||||
--model_type qwen3 \
|
--model_type qwen3 \
|
||||||
--train_output /mnt/wangsenhao/autoresearch-zk/sft_output
|
--train_output "$AUTORESEARCH_CHAT_ROOT/sft_output"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -28,14 +26,8 @@ from pathlib import Path
|
|||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CHAT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
def _path_from_env(name: str, fallback: Path) -> Path:
|
AI_PLANNING = CHAT_ROOT / "ai-planning"
|
||||||
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")
|
|
||||||
TRAIN_SET_DIR = AI_PLANNING / "data" / "train_set"
|
TRAIN_SET_DIR = AI_PLANNING / "data" / "train_set"
|
||||||
DATA_TRAIN_DIR = AI_PLANNING / "data_train"
|
DATA_TRAIN_DIR = AI_PLANNING / "data_train"
|
||||||
GENERATE_TRAIN_SCRIPT = DATA_TRAIN_DIR / "generate_train.py"
|
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,
|
epochs: int = 3, lr: float = 1e-5, max_seq_length: int = 1024,
|
||||||
per_device_batch: int = 1, grad_accum: int = 1):
|
per_device_batch: int = 1, grad_accum: int = 1):
|
||||||
"""基于 zk_trainer 的 accelerate launch 方式启动 SFT 训练。"""
|
"""基于 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):
|
if not os.path.isdir(zk_trainer_dir):
|
||||||
log.error(f"zk_trainer 目录不存在: {zk_trainer_dir}")
|
log.error(f"zk_trainer 目录不存在: {zk_trainer_dir}")
|
||||||
return None
|
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
|
#!/bin/bash
|
||||||
# 用 cml custom_train submit 提交 SFT 训练,训练完成后自动起 cml workflow run 评测
|
# 用 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
|
set -euo pipefail
|
||||||
|
|
||||||
RUNDIC=${1:?usage: $0 <RUNDIC>}
|
SFT_RUNDIC=${1:?usage: $0 <SFT_RUNDIC> <EVAL_RUNDIC> [PREV_RUNDIC]}
|
||||||
PREV_RUNDIC=${2:-$((RUNDIC-1))}
|
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)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
ROOT=${AUTORESEARCH_ROOT:-/mnt/wangsenhao/autoresearch-zk}
|
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}
|
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}
|
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
|
if [ -f ~/.cloudml-cli/.profile ]; then
|
||||||
source ~/.cloudml-cli/.profile
|
source ~/.cloudml-cli/.profile
|
||||||
@@ -20,13 +38,13 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 1. 渲染 yaml 模板
|
# 1. 渲染 yaml 模板(用 SFT_RUNDIC,不是 EVAL_RUNDIC)
|
||||||
sed \
|
sed \
|
||||||
-e "s|{RUNDIC}|${RUNDIC}|g" \
|
-e "s|{RUNDIC}|${SFT_RUNDIC}|g" \
|
||||||
-e "s|{PREV_RUNDIC}|${PREV_RUNDIC}|g" \
|
-e "s|{PREV_RUNDIC}|${PREV_RUNDIC}|g" \
|
||||||
-e "s|{AUTORESEARCH_ROOT}|${ROOT}|g" \
|
-e "s|{AUTORESEARCH_ROOT}|${ROOT}|g" \
|
||||||
"$TPL" > "$YAML"
|
"$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. 提交训练任务
|
# 2. 提交训练任务
|
||||||
SUBMIT_OUT=$(cml custom_train submit --filename "$YAML" 2>&1)
|
SUBMIT_OUT=$(cml custom_train submit --filename "$YAML" 2>&1)
|
||||||
@@ -38,13 +56,14 @@ if [ -z "$JOB_ID" ]; then
|
|||||||
fi
|
fi
|
||||||
echo "[cml-sft] ✅ JobID: $JOB_ID"
|
echo "[cml-sft] ✅ JobID: $JOB_ID"
|
||||||
|
|
||||||
# 3. 后台 watcher:等训练成功 → 自动起评测
|
# 3. 后台 watcher:等训练成功 → 自动起评测(用 EVAL_RUNDIC)
|
||||||
WATCHER_LOG=/tmp/r${RUNDIC}_logs/cml_watcher.log
|
WATCHER_LOG=/tmp/r${SFT_RUNDIC}_logs/cml_watcher.log
|
||||||
mkdir -p "$(dirname "$WATCHER_LOG")"
|
mkdir -p "$(dirname "$WATCHER_LOG")"
|
||||||
|
|
||||||
nohup bash -c '
|
nohup bash -c '
|
||||||
JOB_ID='"$JOB_ID"'
|
JOB_ID='"$JOB_ID"'
|
||||||
RUNDIC='"$RUNDIC"'
|
SFT_RUNDIC='"$SFT_RUNDIC"'
|
||||||
|
EVAL_RUNDIC='"$EVAL_RUNDIC"'
|
||||||
LOG='"$WATCHER_LOG"'
|
LOG='"$WATCHER_LOG"'
|
||||||
ROOT='"$ROOT"'
|
ROOT='"$ROOT"'
|
||||||
MODEL_NEW="$ROOT/sft_output"
|
MODEL_NEW="$ROOT/sft_output"
|
||||||
@@ -74,15 +93,15 @@ done
|
|||||||
echo "[$(date)] ❌ 产出缺失" >> "$LOG"; exit 1
|
echo "[$(date)] ❌ 产出缺失" >> "$LOG"; exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# 起评测
|
# 起评测(这里且仅这里用 EVAL_RUNDIC)
|
||||||
echo "[$(date)] 启动 CML 评测 runDic=$RUNDIC" >> "$LOG"
|
echo "[$(date)] 启动 CML 评测 EVAL_RUNDIC=$EVAL_RUNDIC" >> "$LOG"
|
||||||
cml workflow run \
|
cml workflow run \
|
||||||
--workflow_id $EVAL_WORKFLOW_ID --version $EVAL_VERSION \
|
--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_new=$MODEL_NEW \
|
||||||
--global_inputs model_path_old=$MODEL_OLD >> "$LOG" 2>&1
|
--global_inputs model_path_old=$MODEL_OLD >> "$LOG" 2>&1
|
||||||
echo "[$(date)] cml workflow run 提交完成" >> "$LOG"
|
echo "[$(date)] cml workflow run 提交完成 (workflow$EVAL_RUNDIC)" >> "$LOG"
|
||||||
' > /tmp/r${RUNDIC}_logs/watcher_runner.log 2>&1 &
|
' > /tmp/r${SFT_RUNDIC}_logs/watcher_runner.log 2>&1 &
|
||||||
|
|
||||||
WATCHER_PID=$!
|
WATCHER_PID=$!
|
||||||
echo "[cml-sft] watcher PID: $WATCHER_PID(log: $WATCHER_LOG)"
|
echo "[cml-sft] watcher PID: $WATCHER_PID(log: $WATCHER_LOG)"
|
||||||
@@ -97,8 +116,9 @@ cat <<EOF
|
|||||||
watcher log: tail -f $WATCHER_LOG
|
watcher log: tail -f $WATCHER_LOG
|
||||||
|
|
||||||
训练完成后,evaluation workflow 会自动启动;评测产物在
|
训练完成后,evaluation workflow 会自动启动;评测产物在
|
||||||
/mnt/xiaoai-zk-model-train-tj5/workflow5/workflow${RUNDIC}/
|
/mnt/xiaoai-zk-model-train-tj5/workflow5/workflow${EVAL_RUNDIC}/
|
||||||
|
|
||||||
JobID: $JOB_ID
|
JobID: $JOB_ID
|
||||||
RUNDIC: $RUNDIC
|
SFT_RUNDIC: $SFT_RUNDIC (yaml / sft_output 命名)
|
||||||
|
EVAL_RUNDIC: $EVAL_RUNDIC (workflow 评测目录)
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -162,8 +162,8 @@ def get_doing_tasks_section() -> str:
|
|||||||
'不要为了单次操作创建 helper 或抽象。优先使用能完整解决问题的最简单实现。',
|
'不要为了单次操作创建 helper 或抽象。优先使用能完整解决问题的最简单实现。',
|
||||||
'除非确实需要新文件,否则优先编辑现有文件。',
|
'除非确实需要新文件,否则优先编辑现有文件。',
|
||||||
'对于文件解析、表格转换、JSON 或 JSONL 处理、日志分析、批量校验、数据抽样等任务,如果小型 Python 脚本比手工文本处理更可靠,可以编写小型 Python 脚本。',
|
'对于文件解析、表格转换、JSON 或 JSONL 处理、日志分析、批量校验、数据抽样等任务,如果小型 Python 脚本比手工文本处理更可靠,可以编写小型 Python 脚本。',
|
||||||
'小脚本应短小、可读,并明确输入和输出。需要执行 Python 代码或 Python 脚本时,使用 python_exec;不要通过 bash 执行 python、python3 或 .venv/bin/python。',
|
'小脚本应短小、可读,并明确输入和输出。需要执行 Python 时,把代码写到 session/scratchpad 下的 .py 文件,再用 bash 跑 `python3 -u <script> 2>&1 | tee <log>`;`-u` 关 stdout 缓冲、`tee` 让进度行实时落盘,超时被 kill 时仍能 tail 日志看到死在哪。不要把多行 Python 代码以 `-c` 字符串方式塞进 bash command。',
|
||||||
'一次性 Python 分析优先直接传给 python_exec.code,不要为了临时分析创建项目文件。',
|
'一次性 Python 分析也走脚本文件 + bash,不要为了临时分析在项目根目录创建脚本,scratchpad 是默认临时区。',
|
||||||
'如果确实需要临时脚本、缓存或中间产物,必须写入当前 session/scratchpad;交付产物必须优先写入当前 session/output。',
|
'如果确实需要临时脚本、缓存或中间产物,必须写入当前 session/scratchpad;交付产物必须优先写入当前 session/output。',
|
||||||
'只有用户明确要求长期复用或该脚本属于产品代码时,才把 Python 脚本加入项目源码目录。',
|
'只有用户明确要求长期复用或该脚本属于产品代码时,才把 Python 脚本加入项目源码目录。',
|
||||||
'当事情失败时,先诊断原因再改变方向。不要对同一个失败动作反复循环。',
|
'当事情失败时,先诊断原因再改变方向。不要对同一个失败动作反复循环。',
|
||||||
@@ -249,6 +249,9 @@ def get_using_your_tools_section(enabled_tool_names: set[str]) -> str:
|
|||||||
items.append(
|
items.append(
|
||||||
'需要可靠保存文件时,可以在 python_exec.code 中使用 pathlib.Path(...).write_text(...)、json.dump/json.dumps、csv.writer 或流式逐行写入;这比把大段内容塞进 write_file 参数更稳定。'
|
'需要可靠保存文件时,可以在 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:
|
if 'python_package' in enabled_tool_names:
|
||||||
items.append(
|
items.append(
|
||||||
'当 python_exec 因缺少 pandas、pyarrow、openpyxl 等 Python 包失败时,使用 python_package 在当前用户独立 venv 中安装缺失包,然后重试;不要安装到系统 Python 或项目 .venv。'
|
'当 python_exec 因缺少 pandas、pyarrow、openpyxl 等 Python 包失败时,使用 python_package 在当前用户独立 venv 中安装缺失包,然后重试;不要安装到系统 Python 或项目 .venv。'
|
||||||
|
|||||||
@@ -1620,9 +1620,9 @@ class LocalCodingAgent:
|
|||||||
'请回复“继续”,我会要求模型重新生成合法 JSON 参数;'
|
'请回复“继续”,我会要求模型重新生成合法 JSON 参数;'
|
||||||
'如果是 write_file 写短文本/Markdown,应改用 content_lines;'
|
'如果是 write_file 写短文本/Markdown,应改用 content_lines;'
|
||||||
'如果是很小的 JSON/JSONL/CSV,可用 json_content、jsonl_records 或 csv_rows;'
|
'如果是很小的 JSON/JSONL/CSV,可用 json_content、jsonl_records 或 csv_rows;'
|
||||||
'如果是脚本、长文本、大 JSON、JSONL、CSV 或生成数据,应直接改用 python_exec 写文件;'
|
'如果是脚本、长文本、大 JSON、JSONL、CSV 或生成数据,应改用 bash 配合 quoted heredoc 写文件,'
|
||||||
'如果是生成/转换结构化数据,应优先调用对应 skill 脚本或 python_exec,'
|
'或先 write_file 落地一个小 .py 再用 bash 跑;'
|
||||||
'不要把大段内容直接塞进工具参数;bash 仅作为最后兜底。'
|
'不要把大段内容直接塞进工具参数。'
|
||||||
) from exc
|
) from exc
|
||||||
if not isinstance(arguments, dict):
|
if not isinstance(arguments, dict):
|
||||||
raise OpenAICompatError(
|
raise OpenAICompatError(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from .agent_types import AgentPermissions, AgentRuntimeConfig, ToolExecutionResu
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .account_runtime import AccountRuntime
|
from .account_runtime import AccountRuntime
|
||||||
from .ask_user_runtime import AskUserRuntime
|
from .ask_user_runtime import AskUserRuntime
|
||||||
|
from .bash_bg_store import BgTaskSpec, BgTaskStatus
|
||||||
from .config_runtime import ConfigRuntime
|
from .config_runtime import ConfigRuntime
|
||||||
from .lsp_runtime import LSPRuntime
|
from .lsp_runtime import LSPRuntime
|
||||||
from .mcp_runtime import MCPRuntime
|
from .mcp_runtime import MCPRuntime
|
||||||
@@ -59,6 +60,12 @@ class ToolExecutionContext:
|
|||||||
team_runtime: 'TeamRuntime | None' = None
|
team_runtime: 'TeamRuntime | None' = None
|
||||||
workflow_runtime: 'WorkflowRuntime | None' = None
|
workflow_runtime: 'WorkflowRuntime | None' = None
|
||||||
worktree_runtime: 'WorktreeRuntime | 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[
|
ToolHandler = Callable[
|
||||||
|
|||||||
@@ -10,51 +10,6 @@ def build_execution_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool
|
|||||||
"""构建本地命令、Python 执行与短等待工具声明。"""
|
"""构建本地命令、Python 执行与短等待工具声明。"""
|
||||||
|
|
||||||
return [
|
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(
|
AgentTool(
|
||||||
name='python_package',
|
name='python_package',
|
||||||
description=(
|
description=(
|
||||||
@@ -94,20 +49,91 @@ def build_execution_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool
|
|||||||
AgentTool(
|
AgentTool(
|
||||||
name='bash',
|
name='bash',
|
||||||
description=(
|
description=(
|
||||||
'运行真实 shell 命令,例如系统命令、进程控制、git 只读检查或用户已确认的依赖安装。'
|
'运行真实 shell 命令,包括系统命令、进程控制、git 只读检查、用户已确认的依赖安装,'
|
||||||
'不要用 bash 执行 python、python3、pip 或 .venv/bin/python;结构化文件分析、'
|
'以及所有 Python 脚本执行。'
|
||||||
'JSON/JSONL 处理、批量校验、数据抽样和快速计算必须优先使用 python_exec。'
|
'执行 Python 时,把代码先写到 scratchpad 下的 .py 文件,'
|
||||||
'Python 包安装必须优先使用 python_package。'
|
'再用本工具跑 `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={
|
parameters={
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
'properties': {
|
'properties': {
|
||||||
'command': {'type': 'string'},
|
'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'],
|
'required': ['command'],
|
||||||
},
|
},
|
||||||
handler=resolve_handler(handlers, 'bash', 'execution'),
|
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(
|
AgentTool(
|
||||||
name='sleep',
|
name='sleep',
|
||||||
description='Pause execution briefly for bounded local wait flows.',
|
description='Pause execution briefly for bounded local wait flows.',
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ def build_file_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool]:
|
|||||||
description=(
|
description=(
|
||||||
'Write or append a SMALL UTF-8 file inside the workspace. Creates parent directories when needed. '
|
'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. '
|
'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 '
|
'For Python scripts, JSONL, large JSON/CSV, generated datasets, or quote-heavy/multi-line content, prefer bash with a quoted heredoc '
|
||||||
'to write the file with pathlib/json/csv; do not pack large content into tool arguments.'
|
"(e.g. `cat > scratchpad/foo.py <<'EOF' ... EOF`); do not pack large content into tool arguments."
|
||||||
),
|
),
|
||||||
parameters={
|
parameters={
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
@@ -51,7 +51,7 @@ def build_file_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool]:
|
|||||||
'content_lines': {
|
'content_lines': {
|
||||||
'type': 'array',
|
'type': 'array',
|
||||||
'items': {'type': 'string'},
|
'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': {
|
'content': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
@@ -63,12 +63,12 @@ def build_file_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool]:
|
|||||||
},
|
},
|
||||||
'json_content': {
|
'json_content': {
|
||||||
'type': 'object',
|
'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': {
|
'jsonl_records': {
|
||||||
'type': 'array',
|
'type': 'array',
|
||||||
'items': {'type': 'object'},
|
'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': {
|
'csv_headers': {
|
||||||
'type': 'array',
|
'type': 'array',
|
||||||
|
|||||||
+320
-39
@@ -8,7 +8,9 @@ import json
|
|||||||
import os
|
import os
|
||||||
import pwd
|
import pwd
|
||||||
import re
|
import re
|
||||||
|
import secrets
|
||||||
import selectors
|
import selectors
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
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.execution import build_execution_tools
|
||||||
from .agent_tool_specs.files import build_file_tools
|
from .agent_tool_specs.files import build_file_tools
|
||||||
from .agent_types import DEFAULT_MAX_TURNS, ToolExecutionResult
|
from .agent_types import DEFAULT_MAX_TURNS, ToolExecutionResult
|
||||||
|
from .bash_bg_store import BgTaskSpec, BgTaskStatus
|
||||||
from .data_agent_records import (
|
from .data_agent_records import (
|
||||||
DataRecordError,
|
DataRecordError,
|
||||||
confirm_generation_goal,
|
confirm_generation_goal,
|
||||||
@@ -115,9 +118,10 @@ def default_tool_registry() -> dict[str, AgentTool]:
|
|||||||
'grep_search': _grep_search,
|
'grep_search': _grep_search,
|
||||||
}),
|
}),
|
||||||
*build_execution_tools({
|
*build_execution_tools({
|
||||||
'python_exec': _run_python_exec,
|
|
||||||
'python_package': _run_python_package,
|
'python_package': _run_python_package,
|
||||||
'bash': _run_bash,
|
'bash': _run_bash,
|
||||||
|
'bash_status': _run_bash_status,
|
||||||
|
'bash_kill': _run_bash_kill,
|
||||||
'sleep': _sleep,
|
'sleep': _sleep,
|
||||||
}),
|
}),
|
||||||
AgentTool(
|
AgentTool(
|
||||||
@@ -1763,46 +1767,29 @@ def _ensure_shell_allowed(command: str, context: ToolExecutionContext) -> None:
|
|||||||
'Shell command appears to write platform code. '
|
'Shell command appears to write platform code. '
|
||||||
'Platform code directories are read-only in data-agent sessions.'
|
'Platform code directories are read-only in data-agent sessions.'
|
||||||
)
|
)
|
||||||
if context.permissions.allow_destructive_shell_commands:
|
|
||||||
return
|
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(
|
def _looks_like_platform_code_shell_write(
|
||||||
command: str,
|
command: str,
|
||||||
context: ToolExecutionContext,
|
context: ToolExecutionContext,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
if context.jupyter_runtime is not None:
|
||||||
|
return False
|
||||||
if not _is_platform_app_root(context.root):
|
if not _is_platform_app_root(context.root):
|
||||||
return False
|
return False
|
||||||
lowered = command.lower()
|
lowered = command.lower()
|
||||||
write_marker = re.search(
|
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,
|
lowered,
|
||||||
)
|
)
|
||||||
if write_marker is None:
|
if write_marker is None:
|
||||||
return False
|
return False
|
||||||
|
root_prefix = context.root.as_posix().lower()
|
||||||
platform_fragments = [
|
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}/'
|
f'./{name}/' for name in _PLATFORM_READONLY_DIRS
|
||||||
for name in _PLATFORM_READONLY_DIRS
|
|
||||||
]
|
]
|
||||||
return any(fragment in lowered for fragment in platform_fragments)
|
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:
|
if result.timed_out:
|
||||||
payload.insert(0, f'timed_out=true\ntimeout_seconds={timeout_seconds:g}')
|
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 (
|
return (
|
||||||
_truncate_output('\n'.join(payload).strip(), max_output_chars),
|
_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(),
|
stdout.rstrip(),
|
||||||
'[stderr]',
|
'[stderr]',
|
||||||
stderr.rstrip(),
|
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 (
|
raise ToolExecutionError(
|
||||||
_truncate_output('\n'.join(payload).strip(), max_output_chars),
|
_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()),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
except ToolExecutionError:
|
except ToolExecutionError:
|
||||||
raise
|
raise
|
||||||
@@ -2742,7 +2745,15 @@ def _resolve_python_interpreter(context: ToolExecutionContext) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _run_bash(arguments: dict[str, Any], 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)
|
_ensure_shell_allowed(command, context)
|
||||||
if context.jupyter_runtime is not None:
|
if context.jupyter_runtime is not None:
|
||||||
result = context.jupyter_runtime.run_command(
|
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:
|
def _web_fetch(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||||
raw_url = _require_string(arguments, 'url')
|
raw_url = _require_string(arguments, 'url')
|
||||||
max_chars = _coerce_int(arguments, 'max_chars', context.max_output_chars)
|
max_chars = _coerce_int(arguments, 'max_chars', context.max_output_chars)
|
||||||
|
|||||||
+2
-2
@@ -157,8 +157,8 @@ DEFAULT_MAX_TURNS = 50
|
|||||||
class AgentRuntimeConfig:
|
class AgentRuntimeConfig:
|
||||||
cwd: Path
|
cwd: Path
|
||||||
max_turns: int = DEFAULT_MAX_TURNS
|
max_turns: int = DEFAULT_MAX_TURNS
|
||||||
command_timeout_seconds: float = 30.0
|
command_timeout_seconds: float = 300.0
|
||||||
max_output_chars: int = 12000
|
max_output_chars: int = 50000 # ≈ 20k token (Chinese/code mix ≈ 2.5 chars/token)
|
||||||
stream_model_responses: bool = False
|
stream_model_responses: bool = False
|
||||||
auto_snip_threshold_tokens: int | None = None
|
auto_snip_threshold_tokens: int | None = None
|
||||||
auto_compact_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:
|
if result.is_misparsing:
|
||||||
return (False, f'Security check: {result.message}')
|
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, '')
|
return (True, '')
|
||||||
|
|||||||
@@ -233,12 +233,41 @@ class JupyterRuntimeSession:
|
|||||||
'persisted_at': time.time(),
|
'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(
|
def bootstrap_workspace(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
timeout_seconds: float = 20.0,
|
timeout_seconds: float = 20.0,
|
||||||
project_root: Path | None = None,
|
project_root: Path | None = 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(
|
result = self.run_command(
|
||||||
(
|
(
|
||||||
'mkdir -p '
|
'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.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(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,
|
timeout_seconds=timeout_seconds,
|
||||||
max_output_chars=4000,
|
max_output_chars=4000,
|
||||||
@@ -259,6 +291,27 @@ class JupyterRuntimeSession:
|
|||||||
self.ensure_python_environment(timeout_seconds=max(timeout_seconds, 300.0))
|
self.ensure_python_environment(timeout_seconds=max(timeout_seconds, 300.0))
|
||||||
if project_root is not None:
|
if project_root is not None:
|
||||||
self.sync_runtime_bundle(project_root, timeout_seconds=max(timeout_seconds, 180.0))
|
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]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
payload = self.binding.to_dict()
|
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:
|
def resolve_workspace_path(self, raw_path: str) -> str:
|
||||||
value = raw_path.strip() or '.'
|
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('/'):
|
if value.startswith('/'):
|
||||||
return normalize_posix_path(value)
|
return normalize_posix_path(value)
|
||||||
path = PurePosixPath(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'}:
|
if path.parts and path.parts[0] in {'scratchpad', 'scratch'}:
|
||||||
tail = PurePosixPath(*path.parts[1:]) if len(path.parts) > 1 else PurePosixPath()
|
tail = PurePosixPath(*path.parts[1:]) if len(path.parts) > 1 else PurePosixPath()
|
||||||
return normalize_posix_path(f'{self.binding.workspace_cwd}/scratchpad/{tail}')
|
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}')
|
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:
|
def _remote_env_prefix(self) -> str:
|
||||||
exports = {
|
exports = {
|
||||||
'ZK_AGENT_WORKSPACE': self.binding.workspace_cwd,
|
'ZK_AGENT_WORKSPACE': self.binding.workspace_cwd,
|
||||||
'ZK_AGENT_OUTPUT': f'{self.binding.workspace_cwd}/output',
|
'ZK_AGENT_OUTPUT': f'{self.binding.workspace_cwd}/output',
|
||||||
'ZK_AGENT_SCRATCHPAD': f'{self.binding.workspace_cwd}/scratchpad',
|
'ZK_AGENT_SCRATCHPAD': f'{self.binding.workspace_cwd}/scratchpad',
|
||||||
'ZK_AGENT_PYTHON_ENV': self.python_env_path,
|
'ZK_AGENT_PYTHON_ENV': self.python_env_path,
|
||||||
|
'AUTORESEARCH_CHAT_ROOT': self.chat_workspace_root,
|
||||||
}
|
}
|
||||||
if self.platform_root:
|
if self.platform_root:
|
||||||
exports['ZK_AGENT_PLATFORM_ROOT'] = self.platform_root
|
exports['ZK_AGENT_PLATFORM_ROOT'] = self.platform_root
|
||||||
|
|||||||
+104
-14
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from typing import Any, Iterator
|
from typing import Any, Iterator
|
||||||
from urllib import error, request
|
from urllib import error, request
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ ANTHROPIC_MESSAGES_MODEL_PREFIXES = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
ANTHROPIC_VERSION = '2023-06-01'
|
ANTHROPIC_VERSION = '2023-06-01'
|
||||||
ANTHROPIC_MAX_TOKENS = 4096
|
ANTHROPIC_MAX_TOKENS = 16384
|
||||||
DEFAULT_MODEL_IDLE_TIMEOUT_SECONDS = 60.0
|
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:
|
def _anthropic_base_url(base_url: str) -> str:
|
||||||
base = base_url.rstrip('/')
|
base = base_url.rstrip('/')
|
||||||
if base.lower().endswith('/anthropic'):
|
if base.lower().endswith('/anthropic'):
|
||||||
@@ -486,25 +538,46 @@ class OpenAICompatClient:
|
|||||||
if blocks:
|
if blocks:
|
||||||
self._append_anthropic_message(anthropic_messages, 'user', 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] = {
|
payload: dict[str, Any] = {
|
||||||
'model': self.config.model,
|
'model': self.config.model,
|
||||||
'messages': anthropic_messages,
|
'messages': anthropic_messages,
|
||||||
'max_tokens': ANTHROPIC_MAX_TOKENS,
|
'max_tokens': ANTHROPIC_MAX_TOKENS,
|
||||||
'stream': stream,
|
'stream': stream,
|
||||||
}
|
}
|
||||||
if system_parts:
|
if system_text:
|
||||||
payload['system'] = '\n\n'.join(system_parts)
|
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)
|
converted_tools = self._anthropic_tools(tools)
|
||||||
if converted_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
|
payload['tools'] = converted_tools
|
||||||
if output_schema is not None:
|
if cache_enabled and anthropic_messages:
|
||||||
schema_hint = (
|
_mark_last_message_cache_breakpoint(anthropic_messages)
|
||||||
f'请严格输出符合 JSON Schema `{output_schema.name}` 的 JSON,'
|
|
||||||
'不要输出额外解释。'
|
|
||||||
)
|
|
||||||
payload['system'] = (
|
|
||||||
f'{payload.get("system", "")}\n\n{schema_hint}'.strip()
|
|
||||||
)
|
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
def _anthropic_headers(self) -> dict[str, str]:
|
def _anthropic_headers(self) -> dict[str, str]:
|
||||||
@@ -604,7 +677,12 @@ class OpenAICompatClient:
|
|||||||
content_block = event_payload.get('content_block')
|
content_block = event_payload.get('content_block')
|
||||||
if not isinstance(block_index, int) or not isinstance(content_block, dict):
|
if not isinstance(block_index, int) or not isinstance(content_block, dict):
|
||||||
continue
|
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
|
continue
|
||||||
tool_index = next_tool_index
|
tool_index = next_tool_index
|
||||||
next_tool_index += 1
|
next_tool_index += 1
|
||||||
@@ -636,7 +714,8 @@ class OpenAICompatClient:
|
|||||||
delta = event_payload.get('delta')
|
delta = event_payload.get('delta')
|
||||||
if not isinstance(delta, dict):
|
if not isinstance(delta, dict):
|
||||||
continue
|
continue
|
||||||
if delta.get('type') == 'text_delta':
|
delta_type = delta.get('type')
|
||||||
|
if delta_type == 'text_delta':
|
||||||
text = delta.get('text')
|
text = delta.get('text')
|
||||||
if isinstance(text, str) and text:
|
if isinstance(text, str) and text:
|
||||||
yield StreamEvent(
|
yield StreamEvent(
|
||||||
@@ -645,7 +724,7 @@ class OpenAICompatClient:
|
|||||||
raw_event=event_payload,
|
raw_event=event_payload,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if delta.get('type') == 'input_json_delta':
|
if delta_type == 'input_json_delta':
|
||||||
block_index = event_payload.get('index')
|
block_index = event_payload.get('index')
|
||||||
partial_json = delta.get('partial_json')
|
partial_json = delta.get('partial_json')
|
||||||
if (
|
if (
|
||||||
@@ -661,6 +740,17 @@ class OpenAICompatClient:
|
|||||||
raw_event=event_payload,
|
raw_event=event_payload,
|
||||||
)
|
)
|
||||||
continue
|
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':
|
if event_type == 'message_delta':
|
||||||
delta = event_payload.get('delta')
|
delta = event_payload.get('delta')
|
||||||
if isinstance(delta, dict) and isinstance(delta.get('stop_reason'), str):
|
if isinstance(delta, dict) and isinstance(delta.get('stop_reason'), str):
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ class StoredAgentSession:
|
|||||||
budget_state: JSONDict
|
budget_state: JSONDict
|
||||||
plugin_state: JSONDict
|
plugin_state: JSONDict
|
||||||
scratchpad_directory: str | None = None
|
scratchpad_directory: str | None = None
|
||||||
|
is_training: bool = False
|
||||||
|
|
||||||
|
|
||||||
def save_agent_session(session: StoredAgentSession, directory: Path | None = None) -> Path:
|
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)
|
if isinstance(data.get('scratchpad_directory'), str)
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
is_training=bool(data.get('is_training', False)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -222,8 +224,8 @@ def deserialize_runtime_config(payload: JSONDict) -> AgentRuntimeConfig:
|
|||||||
return AgentRuntimeConfig(
|
return AgentRuntimeConfig(
|
||||||
cwd=Path(str(payload['cwd'])).resolve(),
|
cwd=Path(str(payload['cwd'])).resolve(),
|
||||||
max_turns=int(payload.get('max_turns', DEFAULT_MAX_TURNS)),
|
max_turns=int(payload.get('max_turns', DEFAULT_MAX_TURNS)),
|
||||||
command_timeout_seconds=float(payload.get('command_timeout_seconds', 30.0)),
|
command_timeout_seconds=float(payload.get('command_timeout_seconds', 300.0)),
|
||||||
max_output_chars=int(payload.get('max_output_chars', 12000)),
|
max_output_chars=int(payload.get('max_output_chars', 50000)),
|
||||||
stream_model_responses=bool(payload.get('stream_model_responses', False)),
|
stream_model_responses=bool(payload.get('stream_model_responses', False)),
|
||||||
auto_snip_threshold_tokens=_optional_int(payload.get('auto_snip_threshold_tokens')),
|
auto_snip_threshold_tokens=_optional_int(payload.get('auto_snip_threshold_tokens')),
|
||||||
auto_compact_threshold_tokens=_optional_int(payload.get('auto_compact_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