backend/frontend/runtime: 训练面板 / session 隔离 / agent runtime 调整
- backend/api/server.py:训练 step-detail / pipeline 卡片排序逻辑 - frontend:thread-list / thread / training-pipeline-panel / step-detail-sheet 配套调整,新增 metrics-chart-panel - src/agent_*:tool spec / runtime / prompting 配套 - .gitignore 加 .logs/ Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ export async function GET(request: Request) {
|
||||
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" },
|
||||
@@ -20,6 +21,7 @@ export async function GET(request: Request) {
|
||||
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();
|
||||
|
||||
+141
-28
@@ -1,7 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { ExportedMessageRepository } from "@assistant-ui/core";
|
||||
import { AssistantRuntimeProvider, useAuiState } from "@assistant-ui/react";
|
||||
import {
|
||||
AssistantRuntimeProvider,
|
||||
useAui,
|
||||
useAuiState,
|
||||
} from "@assistant-ui/react";
|
||||
import {
|
||||
AssistantChatTransport,
|
||||
useChatRuntime,
|
||||
@@ -29,6 +33,7 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import {
|
||||
ACTIVE_SESSION_CHANGED_EVENT,
|
||||
consumeFreshLocalId,
|
||||
readActiveSessionId,
|
||||
readPendingWorkspaceSessionId,
|
||||
writeActiveSessionId,
|
||||
@@ -91,6 +96,7 @@ export const Assistant = ({ initialSessionId }: AssistantProps = {}) => {
|
||||
});
|
||||
const replaySession = useCallback(
|
||||
(sessionId: string, repository: ExportedMessageRepository) => {
|
||||
console.log("[replay-session] called", { sessionId });
|
||||
writeActiveSessionId(sessionId);
|
||||
pushSessionUrl(sessionId);
|
||||
// 后端已经落盘/结束后,用回放结果接管当前会话。
|
||||
@@ -173,6 +179,22 @@ 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[] = [
|
||||
@@ -197,6 +219,7 @@ function detectApplyIntent(text: string): boolean {
|
||||
}
|
||||
|
||||
function AssistantWorkspace() {
|
||||
const aui = useAui();
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(() =>
|
||||
readActiveSessionId(),
|
||||
);
|
||||
@@ -209,6 +232,12 @@ function AssistantWorkspace() {
|
||||
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>) => {
|
||||
@@ -244,11 +273,20 @@ function AssistantWorkspace() {
|
||||
}, [showPipeline]);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setActiveSessionId(readActiveSessionId());
|
||||
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;
|
||||
setActiveSessionId(detail?.sessionId ?? readActiveSessionId());
|
||||
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);
|
||||
@@ -259,6 +297,59 @@ function AssistantWorkspace() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 切 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]);
|
||||
@@ -280,36 +371,58 @@ function AssistantWorkspace() {
|
||||
if (applied) return;
|
||||
if (!activeSessionId) return;
|
||||
let cancelled = false;
|
||||
const url = `/api/claw/training/pipeline?session_id=${encodeURIComponent(activeSessionId)}`;
|
||||
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(url, { cache: "no-store" });
|
||||
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;
|
||||
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);
|
||||
if (payload.status === "running" || payload.status === "queued") {
|
||||
setApplied(true);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user