fix: training panel only opens on explicit skill click
Remove auto-detection from user message patterns and pipeline polling. Panel now opens only when user explicitly enables model-iteration skill via the UI button, and closes via "退出训练模式" or disabling the skill. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,6 @@ import type { ExportedMessageRepository } from "@assistant-ui/core";
|
|||||||
import {
|
import {
|
||||||
AssistantRuntimeProvider,
|
AssistantRuntimeProvider,
|
||||||
useAui,
|
useAui,
|
||||||
useAuiState,
|
|
||||||
} from "@assistant-ui/react";
|
} from "@assistant-ui/react";
|
||||||
import {
|
import {
|
||||||
AssistantChatTransport,
|
AssistantChatTransport,
|
||||||
@@ -195,28 +194,6 @@ function getDraftKey(activeSessionId: string | null): string {
|
|||||||
return activeSessionId;
|
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() {
|
function AssistantWorkspace() {
|
||||||
const aui = useAui();
|
const aui = useAui();
|
||||||
@@ -227,8 +204,6 @@ function AssistantWorkspace() {
|
|||||||
const { applied, setApplied } = useAppliedTraining(activeSessionId);
|
const { applied, setApplied } = useAppliedTraining(activeSessionId);
|
||||||
const { close: closeActivityPanel } = useActivityPanel();
|
const { close: closeActivityPanel } = useActivityPanel();
|
||||||
const showPipeline = skill.enabled && applied;
|
const showPipeline = skill.enabled && applied;
|
||||||
const messages = useAuiState((s) => s.thread.messages);
|
|
||||||
const lastProcessedUserMsgId = useRef<string | null>(null);
|
|
||||||
const pipelineContainerRef = useRef<HTMLDivElement>(null);
|
const pipelineContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const lastPipelineYRef = useRef<number | null>(null);
|
const lastPipelineYRef = useRef<number | null>(null);
|
||||||
const [exitButtonVisible, setExitButtonVisible] = useState(false);
|
const [exitButtonVisible, setExitButtonVisible] = useState(false);
|
||||||
@@ -354,110 +329,16 @@ function AssistantWorkspace() {
|
|||||||
closeActivityPanel();
|
closeActivityPanel();
|
||||||
}, [showPipeline, closeActivityPanel]);
|
}, [showPipeline, closeActivityPanel]);
|
||||||
|
|
||||||
// Skill 关掉时撤销 applied,避免重新启用就立刻弹开。
|
// Skill 开关联动 applied:开 → 立刻展开面板;关 → 收起。
|
||||||
|
// 面板只在用户显式点击 skill 按钮时展开,不再从消息/pipeline 自动推断。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!skill.enabled && applied) {
|
if (skill.enabled && !applied) {
|
||||||
|
setApplied(true);
|
||||||
|
} else if (!skill.enabled && applied) {
|
||||||
setApplied(false);
|
setApplied(false);
|
||||||
}
|
}
|
||||||
}, [skill.enabled, applied, setApplied]);
|
}, [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 /> 在
|
// 注意:用同一棵树 + 条件渲染,保证 <Thread /> 和 <ActivityPanel /> 在
|
||||||
// pipeline 切换前后都保持挂载,否则它们会 remount,
|
// pipeline 切换前后都保持挂载,否则它们会 remount,
|
||||||
// useJupyterWorkspaceStatus 的内部 state 会被清空,UI 上 Jupyter 工作区
|
// useJupyterWorkspaceStatus 的内部 state 会被清空,UI 上 Jupyter 工作区
|
||||||
@@ -503,21 +384,6 @@ function AssistantWorkspace() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
function ActivityDrawerTrigger() {
|
||||||
const { open, openActivity } = useActivityPanel();
|
const { open, openActivity } = useActivityPanel();
|
||||||
|
|||||||
Reference in New Issue
Block a user