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:
hupenglong1
2026-05-25 11:43:10 +08:00
parent 57f5b60a3e
commit f068311cac
17 changed files with 1571 additions and 270 deletions
+1
View File
@@ -21,6 +21,7 @@ archive/
scratchpad/
tasks/
router_session_parquet/
.logs/
# Environment files
.env
+478 -33
View File
@@ -1696,11 +1696,30 @@ def create_app(state: AgentState) -> FastAPI:
async def get_training_step_detail(
session_id: str,
step: str,
run_id: str | None = None,
account_id: str | None = None,
) -> Response:
sessions_dir = state.account_paths(account_id)['sessions']
state_path = _session_state_path(sessions_dir, session_id)
state_entries, _ = _read_program_state(state_path)
# Per-round: if frontend passed run_id, use that round's actual runDic
# so historical step views (e.g., R1's augment) read the right
# workflow<N> directory. Fall back to "max in state" only when run_id
# is absent or the round has no resolvable runDic — preserves prior
# behaviour for unbundled callers.
run_dic: int | None = None
if isinstance(run_id, str) and run_id.strip():
rid = run_id.strip()
per_round = _resolve_run_dic_per_round(state_entries)
# augment 处理的是「上一轮评测的错例」,产物按 R{n-1} 的 runDic 命名
# data_clean_<R{n-1}>/、augment_<R{n-1}>.jsonl ...),不是本轮 eval workflow
if step == 'augment':
m = re.match(r'^[Rr](\d+)$', rid)
if m and int(m.group(1)) >= 1:
run_dic = per_round.get(f'R{int(m.group(1)) - 1}')
if run_dic is None:
run_dic = per_round.get(rid)
if run_dic is None:
run_dic = _resolve_run_dic_from_state(state_entries)
runtime = _jupyter_runtime_for_session(state, account_id, session_id)
artifact_paths = _step_artifact_paths(step, run_dic, runtime)
@@ -1850,6 +1869,33 @@ def create_app(state: AgentState) -> FastAPI:
'shown_rows': len(table_rows),
}
)
if step == 'dist-analysis':
# 软警告:dist-analysis 标 complete 时若 H1 候选 CSV 缺失,把 section
# 的兜底 "产物文件不存在" 换成更明确的提示。某些轮次(hparam 类假设、
# 无标签问题)确实可能没有候选,所以不 fail 这一步、不阻塞 human-check。
target_run = (run_id or '').strip()
last_status: str | None = None
for entry in state_entries:
if not isinstance(entry, dict) or entry.get('step') != 'dist-analysis':
continue
eid = (entry.get('run_id') or '').strip()
if target_run and eid and eid != target_run:
continue
if 'status' in entry:
last_status = entry.get('status')
if last_status == 'complete':
rd_label = str(run_dic) if run_dic is not None else '<runDic>'
for sec in table_sections:
if (
str(sec.get('title', '')).startswith('H1 预计修改候选')
and sec.get('kind') == 'missing'
):
sec['error'] = (
f'未检测到 output/relabel_candidates_{rd_label}.csv —— '
f'若本轮无 H1 候选可忽略;如有候选请按 SKILL.md 「阶段一」'
f'落盘 file,line,query,old_label,是否改(1/0),建议新label。'
)
break
payload = {
'step': step,
'run_dic': run_dic,
@@ -1923,6 +1969,12 @@ def create_app(state: AgentState) -> FastAPI:
agent_state=state,
account_id=account_id,
)
metrics_history = await asyncio.to_thread(
_read_iteration_log_metrics_history,
session_id,
agent_state=state,
account_id=account_id,
)
payload = await asyncio.to_thread(
_hardcoded_autoresearch_pipeline,
session_id,
@@ -1931,6 +1983,8 @@ def create_app(state: AgentState) -> FastAPI:
model_config=state.model_config_for(account_id),
agent_state=state,
account_id=account_id,
iteration_log_count=iter_count,
metrics_history=metrics_history,
)
failure_status = await asyncio.to_thread(
_read_session_failure_status,
@@ -1964,6 +2018,21 @@ def create_app(state: AgentState) -> FastAPI:
async def event_gen():
last_signature: str | None = None
last_heartbeat = time.monotonic()
refresher = asyncio.create_task(
_iter_count_refresh_loop(
session_id,
agent_state=state,
account_id=account_id,
)
)
metrics_refresher = asyncio.create_task(
_metrics_history_refresh_loop(
session_id,
agent_state=state,
account_id=account_id,
)
)
try:
while True:
if await request.is_disconnected():
break
@@ -1974,6 +2043,12 @@ def create_app(state: AgentState) -> FastAPI:
agent_state=state,
account_id=account_id,
)
metrics_history = await asyncio.to_thread(
_read_iteration_log_metrics_history,
session_id,
agent_state=state,
account_id=account_id,
)
payload = await asyncio.to_thread(
_hardcoded_autoresearch_pipeline,
session_id,
@@ -1981,6 +2056,8 @@ def create_app(state: AgentState) -> FastAPI:
state_entries=state_entries,
agent_state=state,
account_id=account_id,
iteration_log_count=iter_count,
metrics_history=metrics_history,
)
failure_status = await asyncio.to_thread(
_read_session_failure_status,
@@ -2006,6 +2083,17 @@ def create_app(state: AgentState) -> FastAPI:
yield ': heartbeat\n\n'
last_heartbeat = now
await asyncio.sleep(0.5)
finally:
refresher.cancel()
try:
await refresher
except (asyncio.CancelledError, Exception): # noqa: BLE001
pass
metrics_refresher.cancel()
try:
await metrics_refresher
except (asyncio.CancelledError, Exception): # noqa: BLE001
pass
return StreamingResponse(
event_gen(),
@@ -2886,7 +2974,7 @@ def _normalize_run_event(event: dict[str, object]) -> dict[str, Any] | None:
'exceeds_soft_limit',
}
normalized = {
key: _json_safe_limited(value)
key: _json_safe_limited(value, parent_key=key)
for key, value in event.items()
if key in allowed and value is not None
}
@@ -2894,20 +2982,25 @@ def _normalize_run_event(event: dict[str, object]) -> dict[str, Any] | None:
return normalized
def _json_safe_limited(value: Any, *, depth: int = 0) -> Any:
_VERBOSE_STRING_KEYS = {'code', 'command', 'content', 'delta', 'script', 'stdout', 'output'}
_VERBOSE_STRING_LIMIT = 20000
def _json_safe_limited(value: Any, *, depth: int = 0, parent_key: str | None = None) -> Any:
if depth > 4:
return str(value)[:500]
if isinstance(value, str):
return value[:2000]
limit = _VERBOSE_STRING_LIMIT if parent_key in _VERBOSE_STRING_KEYS else 2000
return value[:limit]
if isinstance(value, (int, float, bool)) or value is None:
return value
if isinstance(value, dict):
return {
str(key)[:200]: _json_safe_limited(item, depth=depth + 1)
str(key)[:200]: _json_safe_limited(item, depth=depth + 1, parent_key=str(key))
for key, item in list(value.items())[:80]
}
if isinstance(value, (list, tuple)):
return [_json_safe_limited(item, depth=depth + 1) for item in value[:80]]
return [_json_safe_limited(item, depth=depth + 1, parent_key=parent_key) for item in value[:80]]
try:
json.dumps(value)
return value
@@ -4885,6 +4978,35 @@ class _WatcherManager:
account_id=account_id,
session_id=session_id,
)
# R{n>=1} 链路:SFT 训练完后 submit_sft_via_cml.sh 内置 nohup
# watcher 自动起 R{n} 评测,agent 不在回路中,没人写
# cml=runningUI 会从 sft=complete 直接跳到 cml=complete
# 中间 ~20min 评测期看起来像"流程卡死"。检测到同 session
# 已注册 cml watch(说明 agent 提交 SFT 时预注册了下游评测
# 的 watch)就顺手补写 cml=running 同 run_id。
if (
step == 'sft'
and session_id is not None
and self.is_watching(session_id, 'cml')
):
await self._write_step(
state_path,
step='cml',
status='running',
run_id=run_id,
agent_state=agent_state,
account_id=account_id,
session_id=session_id,
)
await self._write_log(
state_path,
run_id,
'↪ sft 完成自动衔接 → step cml running '
'(submit_sft_via_cml.sh 内置 watcher 已触发 R 评测)',
agent_state=agent_state,
account_id=account_id,
session_id=session_id,
)
return
if time.monotonic() - start > timeout:
await self._write_step(
@@ -5507,13 +5629,27 @@ def _scan_for_watchers(state: AgentState) -> None:
status = entry.get('status')
if isinstance(step, str) and step and isinstance(status, str):
last_status_per_step[step] = status
# See note in _scan_for_watchers_async: only the latest watch
# entry per step is current.
latest_watch_per_step: dict[str, dict[str, Any]] = {}
latest_running_run_id: dict[str, str] = {}
for entry in entries:
watch = entry.get('watch')
if not isinstance(watch, dict):
continue
step = watch.get('step')
if not isinstance(step, str) or not step:
if isinstance(watch, dict):
w_step = watch.get('step')
if isinstance(w_step, str) and w_step:
latest_watch_per_step[w_step] = watch
continue
e_step = entry.get('step')
e_status = entry.get('status')
e_run = entry.get('run_id')
if (
isinstance(e_step, str) and e_step
and e_status == 'running'
and isinstance(e_run, str) and e_run
):
latest_running_run_id[e_step] = e_run
for step, watch in latest_watch_per_step.items():
if last_status_per_step.get(step) in {
'complete',
'failed',
@@ -5535,6 +5671,9 @@ def _scan_for_watchers(state: AgentState) -> None:
interval = 30.0
timeout = 3600.0
run_id = str(watch.get('run_id') or '?')
running_run_id = latest_running_run_id.get(step)
if running_run_id and run_id != running_run_id:
continue
_watcher_manager.register_file_exists(
session_id=session_id,
state_path=state_path,
@@ -5617,13 +5756,30 @@ async def _scan_for_watchers_async(state: AgentState) -> None:
status = entry.get('status')
if isinstance(step, str) and step and isinstance(status, str):
last_status_per_step[step] = status
# Only the LAST watch entry per step represents the current round's
# intent. Earlier watch entries from previous rounds must NOT be
# re-registered when a new round resets the step to running, or
# the resulting watcher will fire with a stale run_id and the UI
# will never see a complete tagged with the current round.
latest_watch_per_step: dict[str, dict[str, Any]] = {}
latest_running_run_id: dict[str, str] = {}
for entry in entries:
watch = entry.get('watch')
if not isinstance(watch, dict):
continue
step = watch.get('step')
if not isinstance(step, str) or not step:
if isinstance(watch, dict):
w_step = watch.get('step')
if isinstance(w_step, str) and w_step:
latest_watch_per_step[w_step] = watch
continue
e_step = entry.get('step')
e_status = entry.get('status')
e_run = entry.get('run_id')
if (
isinstance(e_step, str) and e_step
and e_status == 'running'
and isinstance(e_run, str) and e_run
):
latest_running_run_id[e_step] = e_run
for step, watch in latest_watch_per_step.items():
if last_status_per_step.get(step) in {
'complete',
'failed',
@@ -5645,6 +5801,13 @@ async def _scan_for_watchers_async(state: AgentState) -> None:
interval_s = 30.0
timeout_s = 3600.0
run_id = str(watch.get('run_id') or '?')
# If a newer round has written `step=<step>, status=running`
# but the matching watch entry hasn't landed yet, skip and
# wait for the next scan rather than spawning a watcher tied
# to the previous round's run_id.
running_run_id = latest_running_run_id.get(step)
if running_run_id and run_id != running_run_id:
continue
_watcher_manager.register_file_exists(
session_id=session_id,
state_path=state_path,
@@ -5752,7 +5915,6 @@ _STEP_KIND: dict[str, str] = {
'cml': 'analysis',
'gold-drift': 'analysis',
'dist-analysis': 'analysis',
'report': 'analysis',
'hypothesis': 'analysis',
'log': 'analysis',
'augment': 'train',
@@ -5780,8 +5942,7 @@ _CARD_META: dict[tuple[str, str], dict[str, Any]] = {
# R0·Baseline
('cml', 'baseline'): {'icon': 'bar-chart', 'title': 'Baseline 评测', 'subtitle': 'CML workflow metric_diff'},
('gold-drift', 'baseline'): {'icon': 'alert-triangle', 'title': 'Gold Drift 检查', 'subtitle': 'drift_<runDic>.json'},
('dist-analysis', 'baseline'): {'icon': 'trending-up', 'title': '分层结果分析', 'subtitle': '需求集合 / 车载 / specific'},
('report', 'baseline'): {'icon': 'file-text', 'title': '问题分析 & 报告', 'subtitle': 'workflow<runDic>.md'},
('dist-analysis', 'baseline'): {'icon': 'trending-up', 'title': '分层结果分析 & 报告', 'subtitle': '根因归类 + workflow<runDic>.md'},
('hypothesis', 'baseline'): {'icon': 'dna', 'title': '形成假设', 'subtitle': 'iteration_log hypothesis 字段'},
('log', 'baseline'): {'icon': 'clipboard-list', 'title': '记录迭代日志', 'subtitle': 'iteration_log.jsonl'},
# R{n}·Train
@@ -5791,8 +5952,7 @@ _CARD_META: dict[tuple[str, str], dict[str, Any]] = {
# R{n}·Analysis
('cml', 'analysis'): {'icon': 'bar-chart', 'title': 'CML 评测', 'subtitle': 'workflow metric_diff'},
('gold-drift', 'analysis'): {'icon': 'alert-triangle', 'title': 'Gold Drift 检查', 'subtitle': 'drift_<runDic>.json'},
('dist-analysis', 'analysis'): {'icon': 'trending-up', 'title': '分层结果分析', 'subtitle': '需求集合 / 车载 / specific'},
('report', 'analysis'): {'icon': 'file-text', 'title': '问题分析 & 报告', 'subtitle': 'workflow<runDic>.md'},
('dist-analysis', 'analysis'): {'icon': 'trending-up', 'title': '分层结果分析 & 报告', 'subtitle': '根因归类 + workflow<runDic>.md'},
('hypothesis', 'analysis'): {'icon': 'dna', 'title': '形成假设', 'subtitle': 'iteration_log hypothesis 字段'},
('log', 'analysis'): {'icon': 'clipboard-list', 'title': '记录迭代日志', 'subtitle': 'iteration_log.jsonl'},
}
@@ -5803,9 +5963,8 @@ _CARD_ORDER: dict[tuple[str, str], int] = {
('cml', 'baseline'): 0,
('gold-drift', 'baseline'): 1,
('dist-analysis', 'baseline'): 2,
('report', 'baseline'): 3,
('hypothesis', 'baseline'): 4,
('log', 'baseline'): 5,
('hypothesis', 'baseline'): 3,
('log', 'baseline'): 4,
# train
('augment', 'train'): 0,
('verify', 'train'): 1,
@@ -5814,9 +5973,8 @@ _CARD_ORDER: dict[tuple[str, str], int] = {
('cml', 'analysis'): 0,
('gold-drift', 'analysis'): 1,
('dist-analysis', 'analysis'): 2,
('report', 'analysis'): 3,
('hypothesis', 'analysis'): 4,
('log', 'analysis'): 5,
('hypothesis', 'analysis'): 3,
('log', 'analysis'): 4,
}
_SECTION_LABELS: dict[str, str] = {
@@ -6514,6 +6672,13 @@ def _scan_run_history_max() -> int | None:
return best if best >= 0 else None
_ITER_COUNT_CACHE: dict[str, int | None] = {}
def _iter_count_cache_key(session_id: str | None, account_id: str | None) -> str:
return f'{account_id or "_"}|{session_id or "_"}'
def _read_iteration_log_count(
session_id: str | None = None,
*,
@@ -6523,10 +6688,53 @@ def _read_iteration_log_count(
"""Count non-empty lines in iteration_log.jsonl.
Chat-root lives under the remote workspace_cwd, so reads MUST go through
the bound jupyter runtime backend host typically can't read this path
locally. Falls back to the legacy global path on the local fs only when
no runtime is bound (session never connected, or transient lookup miss).
the bound jupyter runtime the round-trip is multi-second. To keep the
SSE pipeline loop responsive, this function returns the last cached value
when one exists; only a cold cache pays the synchronous remote cost. The
cache is refreshed in the background by `_iter_count_refresh_loop`, which
the SSE handler spawns alongside its event loop.
"""
cache_key = _iter_count_cache_key(session_id, account_id)
if cache_key in _ITER_COUNT_CACHE:
return _ITER_COUNT_CACHE[cache_key]
value = _read_iteration_log_count_uncached(
session_id, agent_state=agent_state, account_id=account_id,
)
_ITER_COUNT_CACHE[cache_key] = value
return value
async def _iter_count_refresh_loop(
session_id: str | None,
*,
agent_state: 'AgentState | None',
account_id: str | None,
interval_seconds: float = 3.0,
) -> None:
"""Background task that re-reads iteration_log_count every `interval_seconds`
and updates the in-memory cache. Cancelled when the caller (SSE handler)
exits."""
cache_key = _iter_count_cache_key(session_id, account_id)
while True:
try:
value = await asyncio.to_thread(
_read_iteration_log_count_uncached,
session_id,
agent_state=agent_state,
account_id=account_id,
)
_ITER_COUNT_CACHE[cache_key] = value
except Exception: # noqa: BLE001
pass
await asyncio.sleep(interval_seconds)
def _read_iteration_log_count_uncached(
session_id: str | None = None,
*,
agent_state: 'AgentState | None' = None,
account_id: str | None = None,
) -> int | None:
runtime = (
_jupyter_runtime_for_session(agent_state, account_id, session_id)
if session_id and agent_state is not None
@@ -6565,6 +6773,150 @@ def _read_iteration_log_count(
return None
_METRICS_HISTORY_CACHE: dict[str, dict[str, Any] | None] = {}
def _read_iteration_log_metrics_history(
session_id: str | None = None,
*,
agent_state: 'AgentState | None' = None,
account_id: str | None = None,
) -> dict[str, Any] | None:
"""Cached read of full iteration_log.jsonl as metrics time-series."""
cache_key = _iter_count_cache_key(session_id, account_id)
if cache_key in _METRICS_HISTORY_CACHE:
return _METRICS_HISTORY_CACHE[cache_key]
value = _read_iteration_log_metrics_history_uncached(
session_id, agent_state=agent_state, account_id=account_id,
)
_METRICS_HISTORY_CACHE[cache_key] = value
return value
async def _metrics_history_refresh_loop(
session_id: str | None,
*,
agent_state: 'AgentState | None',
account_id: str | None,
interval_seconds: float = 3.0,
) -> None:
cache_key = _iter_count_cache_key(session_id, account_id)
while True:
try:
value = await asyncio.to_thread(
_read_iteration_log_metrics_history_uncached,
session_id,
agent_state=agent_state,
account_id=account_id,
)
_METRICS_HISTORY_CACHE[cache_key] = value
except Exception: # noqa: BLE001
pass
await asyncio.sleep(interval_seconds)
def _read_iteration_log_metrics_history_uncached(
session_id: str | None = None,
*,
agent_state: 'AgentState | None' = None,
account_id: str | None = None,
) -> dict[str, Any] | None:
"""Parse iteration_log.jsonl into {'metrics': [keys], 'rounds': [...]}.
Each entry's `results` dict contributes one round point; metric key union
is taken across all rounds (sorted by first-appearance for stable column
order). Missing values produce nulls so frontend line charts break the
series naturally."""
runtime = (
_jupyter_runtime_for_session(agent_state, account_id, session_id)
if session_id and agent_state is not None
else None
)
text: str | None = None
if runtime is not None:
remote_root = _chat_root_for_runtime(runtime)
if remote_root is not None:
remote_path = f'{remote_root}/results/iteration_log.jsonl'
cmd = (
f'test -f {shlex.quote(remote_path)} '
f'&& cat {shlex.quote(remote_path)} '
f'|| echo __MISSING__'
)
try:
result = runtime.run_command(
cmd, timeout_seconds=10.0, max_output_chars=400_000,
)
except Exception: # noqa: BLE001
return None
stdout = (result.stdout or '').strip() if result is not None else ''
if stdout and stdout != '__MISSING__':
text = stdout
if text is None:
legacy = _autoresearch_root() / 'results' / 'iteration_log.jsonl'
try:
if legacy.is_file():
text = legacy.read_text(encoding='utf-8', errors='replace')
except OSError:
return None
if not text:
return None
metrics_order: list[str] = []
metrics_seen: set[str] = set()
rounds: list[dict[str, Any]] = []
for line in text.splitlines():
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except (ValueError, TypeError):
continue
if not isinstance(obj, dict):
continue
results = obj.get('results')
if not isinstance(results, dict):
continue
values: dict[str, float] = {}
for k, v in results.items():
if not isinstance(k, str):
continue
try:
fv = float(v)
except (TypeError, ValueError):
continue
values[k] = fv
if k not in metrics_seen:
metrics_seen.add(k)
metrics_order.append(k)
if not values:
continue
iteration = obj.get('iteration')
if isinstance(iteration, str) and iteration.lstrip('-').isdigit():
iteration = int(iteration)
if not isinstance(iteration, int):
iteration = len(rounds)
run_dic = obj.get('runDic') or obj.get('run_dic')
if isinstance(run_dic, str) and run_dic.isdigit():
run_dic = int(run_dic)
if not isinstance(run_dic, int):
run_dic = 0
timestamp = obj.get('timestamp')
if not isinstance(timestamp, str):
timestamp = ''
rounds.append({
'iteration': iteration,
'runDic': run_dic,
'label': f'R{iteration}',
'timestamp': timestamp,
'values': values,
})
if not rounds:
return None
return {'metrics': metrics_order, 'rounds': rounds}
def _read_iteration_log_h1_label(
runtime: 'JupyterRuntimeSession | None',
run_dic: int | None,
@@ -7019,6 +7371,8 @@ def _compute_kpis(
*,
agent_state: 'AgentState | None' = None,
account_id: str | None = None,
iteration_log_count: int | None = None,
metrics_history: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""Compute the 7 top KPIs from authoritative sources. Falls back to ''.
@@ -7054,14 +7408,30 @@ def _compute_kpis(
target_metric = _format_metric_value(metrics, 'target_set_pass_rate')
overall_metric = _format_metric_value(metrics, 'overall_car_pass_rate')
specific_metric = _format_metric_value(metrics, 'specific_test_pass_rate')
# Round number = MAX numeric `iteration` field across iteration_log.jsonl
# entries, NOT the line count. iteration_log.jsonl carries multiple rows
# per round (separate hypothesis-only / results / final-summary entries),
# so line count overcounts. Falls back to line-count for backwards
# compatibility when metrics_history isn't available.
max_iteration: int | None = None
if metrics_history and isinstance(metrics_history.get('rounds'), list):
for r in metrics_history['rounds']:
it = r.get('iteration') if isinstance(r, dict) else None
if isinstance(it, int):
if max_iteration is None or it > max_iteration:
max_iteration = it
if max_iteration is not None:
iteration = 'R0-baseline' if max_iteration == 0 else f'R{max_iteration}'
else:
if iteration_log_count is not None:
iter_count = iteration_log_count
else:
iter_count = _read_iteration_log_count(
session_id,
agent_state=agent_state,
account_id=account_id,
)
if iter_count is None:
iteration = 'R0-baseline'
elif iter_count == 0:
if iter_count is None or iter_count == 0:
iteration = 'R0-baseline'
else:
iteration = f'R{iter_count}'
@@ -7134,6 +7504,79 @@ def _resolve_run_dic_from_state(
return best
def _resolve_run_dic_per_round(
state_entries: list[dict[str, Any]],
) -> dict[str, int]:
"""Map each run_id (e.g., 'R2') to the runDic that round actually used.
Same priority as ``_resolve_run_dic_from_state`` but bucketed per run_id
so per-round step-detail views read the right artifact directory instead
of always using the latest workflow id seen anywhere in state."""
workflow_re = re.compile(r'workflow(\d+)')
rundic_re = re.compile(r'runDic\s*[=:取]?\s*(\d+)')
def _scan(obj: Any) -> int | None:
if isinstance(obj, str):
cands = [int(x) for x in workflow_re.findall(obj)]
cands += [int(x) for x in rundic_re.findall(obj)]
return max(cands) if cands else None
if isinstance(obj, dict):
best: int | None = None
for v in obj.values():
hit = _scan(v)
if hit is not None and (best is None or hit > best):
best = hit
return best
if isinstance(obj, list):
best = None
for v in obj:
hit = _scan(v)
if hit is not None and (best is None or hit > best):
best = hit
return best
return None
def _entry_run_id(entry: dict[str, Any]) -> str | None:
# 顶层 run_id 优先;否则从 watch.run_id / log.iter 兜底,让 watcher/log
# 这种 agent 不写顶层 run_id 但嵌套里带轮次信号的条目也能贡献 runDic。
rid = entry.get('run_id')
if isinstance(rid, str) and rid.strip():
return rid.strip()
watch = entry.get('watch')
if isinstance(watch, dict):
wrid = watch.get('run_id')
if isinstance(wrid, str) and wrid.strip():
return wrid.strip()
log = entry.get('log')
if isinstance(log, dict):
lit = log.get('iter')
if isinstance(lit, str) and lit.strip():
return lit.strip()
return None
explicit: dict[str, int] = {}
fallback: dict[str, int] = {}
for entry in state_entries:
run_id = _entry_run_id(entry)
if not run_id:
continue
rd = entry.get('runDic') or entry.get('run_dic') or entry.get('rundic')
if isinstance(rd, str) and rd.isdigit():
rd = int(rd)
if isinstance(rd, int):
explicit[run_id] = rd # latest wins
continue
hit = _scan(entry)
if hit is not None:
cur = fallback.get(run_id)
if cur is None or hit > cur:
fallback[run_id] = hit
out = dict(fallback)
out.update(explicit)
return out
def _step_artifact_paths(
step: str,
run_dic: int | None,
@@ -7176,9 +7619,6 @@ def _step_artifact_paths(
paths.append(
f'{runtime.binding.workspace_cwd}/output/relabel_candidates_{run_dic}.csv'
)
elif step == 'report':
if run_dic is not None:
paths.append(f'{chat_root}/results/workflow{run_dic}.md')
elif step == 'hypothesis':
paths.append(f'{chat_root}/results/iteration_log.jsonl')
elif step == 'augment':
@@ -7342,6 +7782,8 @@ def _hardcoded_autoresearch_pipeline(
*,
agent_state: 'AgentState | None' = None,
account_id: str | None = None,
iteration_log_count: int | None = None,
metrics_history: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Canonical autoresearch model-iteration pipeline structure.
@@ -7359,6 +7801,8 @@ def _hardcoded_autoresearch_pipeline(
model_config=model_config,
agent_state=agent_state,
account_id=account_id,
iteration_log_count=iteration_log_count,
metrics_history=metrics_history,
)
else:
# Defensive default if caller didn't pass sessions_dir.
@@ -7385,6 +7829,7 @@ def _hardcoded_autoresearch_pipeline(
'items': [],
'in_flight': None,
'logs': [],
'metrics_history': metrics_history,
}
@@ -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();
+121 -8
View File
@@ -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 sessionIdnewTask)用 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,13 +371,15 @@ 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" });
if (!res.ok) return;
const res = await fetch(pipelineUrl, { cache: "no-store" });
if (res.ok) {
const payload = await res.json();
if (cancelled || !payload) return;
if (cancelled) return;
if (payload) {
const items = Array.isArray(payload.items) ? payload.items : [];
const hasItemProgress = items.some(
(it: {
@@ -309,7 +402,27 @@ function AssistantWorkspace() {
// items[] 走 phase-complete-only 过滤;R0 只有一张卡在跑时
// items 是空的,进展信号在 in_flight 字段里——也算进展。
const hasInFlight = Boolean(payload.in_flight);
if (hasItemProgress || hasInFlight) setApplied(true);
if (hasItemProgress || hasInFlight) {
setApplied(true);
return;
}
}
}
} catch {
/* ignore, fall through to runs/latest fallback */
}
// Pipeline 还没写出 items/in_flightagent 处于 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 */
}
@@ -24,6 +24,7 @@ import {
import { Button } from "@/components/ui/button";
import {
clearActiveSessionId,
markFreshLocalId,
readActiveSessionId,
writeActiveSessionId,
writePendingWorkspaceSessionId,
@@ -163,6 +164,7 @@ function ensureSidebarWorkspaceSessionId(): {
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 };
}
@@ -438,6 +440,10 @@ const ClawSessionList: FC = () => {
type="button"
className="aui-thread-list-item-trigger flex h-full min-w-0 flex-1 items-center truncate px-3 text-start text-sm"
onClick={async () => {
console.log("[session-list] click", {
sessionId: session.session_id,
preview: session.preview,
});
setOpenMenuSessionId(null);
writeActiveSessionId(session.session_id);
pushSessionUrl(session.session_id);
+38 -19
View File
@@ -84,6 +84,7 @@ import {
ACTIVE_SESSION_CHANGED_EVENT,
clearActiveSessionId,
clearPendingWorkspaceSessionId,
markFreshLocalId,
PENDING_WORKSPACE_SESSION_CHANGED_EVENT,
readActiveSessionId,
readPendingWorkspaceSessionId,
@@ -261,8 +262,14 @@ function useRefreshCurrentRun() {
includePending: true,
includeActive: true,
});
const activeRunRef = useRef<string | null>(null);
const appliedTerminalRef = useRef<string | null>(null);
// Per-session bookkeeping: switching sessions (sidebar history) and coming
// back must NOT re-trigger replaySession on a session that has already been
// applied — replaySession rebuilds the thread tree, which causes a layout
// shift / scroll jitter in the chat UI. Single-value refs would get
// overwritten when visiting another session and forget that this session
// was already settled.
const activeRunRefMap = useRef<Map<string, string>>(new Map());
const appliedTerminalRefMap = useRef<Map<string, string>>(new Map());
useEffect(() => {
if (!sessionId) return;
@@ -274,8 +281,8 @@ function useRefreshCurrentRun() {
if (cancelled) return;
if (runStatus?.status === "queued" || runStatus?.status === "running") {
const runKey = runStatus.run_id ?? "active";
const previousRunKey = activeRunRef.current;
activeRunRef.current = runKey;
const previousRunKey = activeRunRefMap.current.get(sessionId) ?? null;
activeRunRefMap.current.set(sessionId, runKey);
// Watcher auto-resume: backend started a new run via _maybe_auto_resume
// without a local SSE stream driving the UI. We need to replay once so
// the active-run placeholder (pending prompt + 思考中 bubble) appears.
@@ -294,14 +301,15 @@ function useRefreshCurrentRun() {
}
return;
}
if (!runtimeRunning && !activeRunRef.current) return;
const sessionActiveRun = activeRunRefMap.current.get(sessionId) ?? null;
if (!runtimeRunning && !sessionActiveRun) return;
const terminalKey = [
runStatus?.run_id ?? activeRunRef.current ?? "unknown",
runStatus?.run_id ?? sessionActiveRun ?? "unknown",
runStatus?.status ?? "idle",
runStatus?.finished_at ?? "",
runStatus?.elapsed_ms ?? "",
].join(":");
if (appliedTerminalRef.current === terminalKey) return;
if (appliedTerminalRefMap.current.get(sessionId) === terminalKey) return;
const response = await fetch(`/api/claw/sessions/${sessionId}`, {
cache: "no-store",
});
@@ -311,8 +319,8 @@ function useRefreshCurrentRun() {
sessionId,
toReplayRepository(payload, sessionId, runStatus),
);
activeRunRef.current = null;
appliedTerminalRef.current = terminalKey;
activeRunRefMap.current.delete(sessionId);
appliedTerminalRefMap.current.set(sessionId, terminalKey);
scheduleSessionListRefresh();
}
@@ -775,6 +783,7 @@ function ensureWorkspaceSessionId(current: string | null) {
typeof crypto !== "undefined" && "randomUUID" in crypto
? `__LOCALID_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
: `__LOCALID_${Math.random().toString(36).slice(2, 10)}`;
markFreshLocalId(generated);
writeActiveSessionId(generated);
scheduleSessionListRefresh();
return generated;
@@ -2393,6 +2402,11 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
const justSubmittedRef = useRef(false);
const isDisabled = runtimeDisabled || disabled;
// Pull store→local on external store changes (session switch, paste-from-attachment,
// submit-clear, etc). The reverse direction (local→store) is pushed eagerly by
// onChange/handleInsert/handleKeyDown/onCompositionEnd via syncComposerText, so we
// don't need a symmetric effect — and adding one creates a feedback loop that
// re-renders the composer at ~1ms intervals (visible as shake).
useEffect(() => {
if (!isComposingRef.current) {
if (justSubmittedRef.current && storeText) return;
@@ -2401,16 +2415,6 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
}
}, [storeText]);
useEffect(() => {
if (
!isComposingRef.current &&
localText !== storeText &&
aui.composer().getState().isEditing
) {
aui.composer().setText(localText);
}
}, [aui, localText, storeText]);
useEffect(() => {
if (!autoFocus || isDisabled) return;
const textarea = textareaRef.current;
@@ -2433,6 +2437,21 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
};
}, [aui]);
// AssistantWorkspace 切 session 时通过事件通知我们恢复 draft——直接
// 控制 localText,绕过 store→local 路径上的 justSubmittedRef 屏蔽和
// switchToNewThread 引起的异步覆盖。
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ text?: string }>).detail;
const text = detail?.text ?? "";
justSubmittedRef.current = false;
setLocalText(text);
};
window.addEventListener("claw-composer-restore-draft", handler);
return () =>
window.removeEventListener("claw-composer-restore-draft", handler);
}, []);
const syncComposerText = useCallback(
(value: string) => {
if (!aui.composer().getState().isEditing) return;
@@ -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";
}
@@ -3,10 +3,12 @@
import {
ExternalLinkIcon,
FileIcon,
Maximize2Icon,
Minimize2Icon,
RefreshCwIcon,
XIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Button } from "@/components/ui/button";
@@ -52,6 +54,7 @@ type StepDetail = {
type Props = {
sessionId: string | null;
stepKey: string | null;
runId?: string | null;
stepTitle?: string;
stepStatus?: string;
open: boolean;
@@ -61,6 +64,7 @@ type Props = {
export function StepDetailSheet({
sessionId,
stepKey,
runId,
stepTitle,
stepStatus,
open,
@@ -69,13 +73,41 @@ export function StepDetailSheet({
const [detail, setDetail] = useState<StepDetail | null>(null);
const [loading, setLoading] = useState(false);
const [reloadCount, setReloadCount] = useState(0);
const [width, setWidth] = useState(672);
const [isFullscreen, setIsFullscreen] = useState(false);
const dragRef = useRef<{ startX: number; startW: number } | null>(null);
const onResizePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (isFullscreen) return;
e.currentTarget.setPointerCapture(e.pointerId);
dragRef.current = { startX: e.clientX, startW: width };
};
const onResizePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return;
const dx = dragRef.current.startX - e.clientX;
const max =
typeof window !== "undefined"
? Math.max(360, window.innerWidth - 32)
: 1200;
setWidth(Math.max(360, Math.min(max, dragRef.current.startW + dx)));
};
const onResizePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return;
dragRef.current = null;
e.currentTarget.releasePointerCapture(e.pointerId);
};
useEffect(() => {
if (!open || !sessionId || !stepKey) return;
let cancelled = false;
setLoading(true);
setDetail(null);
const url = `/api/claw/training/step-detail?session_id=${encodeURIComponent(sessionId)}&step=${encodeURIComponent(stepKey)}`;
const params = new URLSearchParams({
session_id: sessionId,
step: stepKey,
});
if (runId) params.set("run_id", runId);
const url = `/api/claw/training/step-detail?${params.toString()}`;
fetch(url, { cache: "no-store" })
.then((res) => (res.ok ? res.json() : null))
.then((payload) => {
@@ -89,15 +121,39 @@ export function StepDetailSheet({
return () => {
cancelled = true;
};
}, [open, sessionId, stepKey, reloadCount]);
}, [open, sessionId, stepKey, runId, reloadCount]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
hideClose
className="flex w-[min(50rem,calc(100vw-2rem))] flex-col gap-0 p-0 sm:max-w-2xl"
onEscapeKeyDown={(e) => {
if (isFullscreen) {
e.preventDefault();
setIsFullscreen(false);
}
}}
className="flex max-w-none flex-col gap-0 p-0 sm:max-w-none"
style={
isFullscreen
? { width: "100vw", maxWidth: "100vw" }
: { width: `${width}px`, maxWidth: "none" }
}
>
{!isFullscreen ? (
<div
onPointerDown={onResizePointerDown}
onPointerMove={onResizePointerMove}
onPointerUp={onResizePointerUp}
onPointerCancel={onResizePointerUp}
className="group absolute inset-y-0 left-0 z-50 w-1.5 cursor-ew-resize hover:bg-sky-500/40 active:bg-sky-500/60"
title="拖动调整宽度"
aria-label="拖动调整宽度"
>
<div className="-translate-y-1/2 absolute top-1/2 left-[2px] h-10 w-[2px] rounded-full bg-muted-foreground/30 group-hover:bg-sky-400" />
</div>
) : null}
<header className="flex shrink-0 items-start justify-between gap-3 border-b px-5 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
@@ -119,6 +175,21 @@ export function StepDetailSheet({
) : null}
</div>
<div className="mt-0.5 flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
onClick={() => setIsFullscreen((v) => !v)}
title={isFullscreen ? "退出全屏 (Esc)" : "全屏"}
aria-label={isFullscreen ? "退出全屏" : "全屏"}
>
{isFullscreen ? (
<Minimize2Icon className="size-3.5" />
) : (
<Maximize2Icon className="size-3.5" />
)}
</Button>
<Button
type="button"
variant="ghost"
@@ -8,20 +8,25 @@ import {
ClipboardListIcon,
ClockIcon,
DnaIcon,
ExternalLinkIcon,
FileTextIcon,
FlaskConicalIcon,
GraduationCapIcon,
HourglassIcon,
Loader2Icon,
type LucideIcon,
Maximize2Icon,
MicroscopeIcon,
Minimize2Icon,
RefreshCwIcon,
ShieldCheckIcon,
TrendingUpIcon,
UserCheckIcon,
} from "lucide-react";
import { useEffect, useRef, useState } from "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";
@@ -103,6 +108,7 @@ type PipelinePayload = {
items: PipelineItem[];
in_flight?: InFlight | null;
logs: LogLine[];
metrics_history?: MetricsHistory | null;
};
const ICON_MAP: Record<string, LucideIcon> = {
@@ -145,7 +151,9 @@ export function TrainingPipelinePanel({
key: string;
title: string;
status: string;
runId?: string;
} | null>(null);
const [bottomTab, setBottomTab] = useState<"log" | "metrics">("log");
useEffect(() => {
setLoading(true);
@@ -182,7 +190,7 @@ export function TrainingPipelinePanel({
};
return (
<div className="flex min-w-0 flex-1 flex-col overflow-hidden border-r bg-muted/20">
<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 ?? []} />
@@ -222,6 +230,7 @@ export function TrainingPipelinePanel({
key: stripNamespace(card.key),
title: card.title,
status: card.status,
runId: item.run_id,
})
}
/>
@@ -238,6 +247,7 @@ export function TrainingPipelinePanel({
key: stripNamespace(item.key),
title: item.title,
status: item.status,
runId: item.run_id,
})
}
/>
@@ -250,11 +260,16 @@ export function TrainingPipelinePanel({
})()}
</div>
</div>
<LegendBar />
<LogStream logs={data?.logs ?? []} />
<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}
@@ -774,83 +789,153 @@ function MiniStatus({ status }: { status: CardStatus }) {
);
}
function LegendBar() {
const items = [
{
icon: BarChart3Icon,
label: "Measure",
sub: "Quantify performance",
color:
"bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-500/30",
},
{
icon: DnaIcon,
label: "Improve",
sub: "Data, training, quality",
color:
"bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/30",
},
{
icon: MicroscopeIcon,
label: "Understand",
sub: "Analyze & diagnose",
color:
"bg-violet-500/10 text-violet-700 dark:text-violet-300 border-violet-500/30",
},
{
icon: RefreshCwIcon,
label: "Iterate",
sub: "Repeat & grow",
color:
"bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/30",
},
];
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="flex shrink-0 items-center gap-4 border-t bg-background/80 px-6 py-2.5 overflow-x-auto">
{items.map(({ icon: Icon, label, sub, color }) => (
<div key={label} className="flex items-center gap-2 shrink-0">
<span
<div
className={cn(
"flex size-7 items-center justify-center rounded-full border",
color,
"flex flex-col bg-zinc-950",
isFullscreen
? "absolute inset-0 z-50 border-t"
: "shrink-0 border-t",
)}
style={isFullscreen ? undefined : { height: `${height}px` }}
>
<Icon className="size-3.5" />
</span>
<div className="leading-tight">
<div className="text-[11px] font-semibold text-foreground">
{label}
{!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="text-[10px] text-muted-foreground">{sub}</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 LogStream({ logs }: { logs: LogLine[] }) {
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 className="flex h-72 shrink-0 flex-col border-t bg-zinc-950 px-5 py-3">
<div className="mb-2 flex shrink-0 items-center justify-between">
<span className="font-semibold text-xs uppercase tracking-[0.18em] text-zinc-300">
Live Log
</span>
<button
type="button"
className="inline-flex items-center gap-1 text-[11px] text-zinc-400 transition-colors hover:text-zinc-200"
>
<ExternalLinkIcon className="size-3" />
</button>
</div>
<div
ref={ref}
className="min-h-0 flex-1 overflow-y-auto font-mono text-[12px] leading-6 text-zinc-100"
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>
@@ -864,7 +949,6 @@ function LogStream({ logs }: { logs: LogLine[] }) {
))
)}
</div>
</div>
);
}
+16
View File
@@ -87,3 +87,19 @@ function normalizeSessionId(value: unknown) {
const trimmed = value.trim();
return trimmed || null;
}
// 记录"前端刚生成的 LOCALID"。AssistantWorkspace 的 draft cache effect 用它
// 区分:从 newTask 切到一个**新生成**的 LOCALID(草稿应继承)vs 切到一个
// **侧栏点击**的老 LOCALID(草稿不应继承)。
const freshLocalIds = new Set<string>();
export function markFreshLocalId(sessionId: string) {
freshLocalIds.add(sessionId);
}
export function consumeFreshLocalId(sessionId: string | null): boolean {
if (!sessionId) return false;
if (!freshLocalIds.has(sessionId)) return false;
freshLocalIds.delete(sessionId);
return true;
}
+372
View File
@@ -27,6 +27,7 @@
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-markdown": "^10.1.0",
"recharts": "^2.15.4",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
@@ -4691,6 +4692,69 @@
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/debug": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
@@ -4963,6 +5027,127 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -4979,6 +5164,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/decode-named-character-reference": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
@@ -5025,6 +5216,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/enhanced-resolve": {
"version": "5.21.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
@@ -5058,6 +5259,12 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/eventsource-parser": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz",
@@ -5071,6 +5278,15 @@
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
@@ -5137,6 +5353,15 @@
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-alphabetical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
@@ -5197,6 +5422,12 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
@@ -5451,6 +5682,12 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -5460,6 +5697,18 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lucide-react": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz",
@@ -6394,6 +6643,15 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/parse-entities": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
@@ -6482,6 +6740,23 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
@@ -6687,6 +6962,12 @@
"react": "^19.2.5"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
@@ -6758,6 +7039,21 @@
}
}
},
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"license": "MIT",
"dependencies": {
"fast-equals": "^5.0.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -6795,6 +7091,54 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts-scale": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
"license": "MIT",
"dependencies": {
"decimal.js-light": "^2.4.1"
}
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
@@ -7051,6 +7395,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -7322,6 +7672,28 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+1
View File
@@ -31,6 +31,7 @@
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-markdown": "^10.1.0",
"recharts": "^2.15.4",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
+2 -2
View File
@@ -162,8 +162,8 @@ def get_doing_tasks_section() -> str:
'不要为了单次操作创建 helper 或抽象。优先使用能完整解决问题的最简单实现。',
'除非确实需要新文件,否则优先编辑现有文件。',
'对于文件解析、表格转换、JSON 或 JSONL 处理、日志分析、批量校验、数据抽样等任务,如果小型 Python 脚本比手工文本处理更可靠,可以编写小型 Python 脚本。',
'小脚本应短小、可读,并明确输入和输出。需要执行 Python 代码或 Python 脚本时,使用 python_exec;不要通过 bash 执行 python、python3 或 .venv/bin/python',
'一次性 Python 分析优先直接传给 python_exec.code,不要为了临时分析创建项目文件',
'小脚本应短小、可读,并明确输入和输出。需要执行 Python 时,把代码写到 session/scratchpad 下的 .py 文件,再用 bash 跑 `python3 -u <script> 2>&1 | tee <log>``-u` 关 stdout 缓冲、`tee` 让进度行实时落盘,超时被 kill 时仍能 tail 日志看到死在哪。不要把多行 Python 代码以 `-c` 字符串方式塞进 bash command',
'一次性 Python 分析也走脚本文件 + bash,不要为了临时分析在项目根目录创建脚本,scratchpad 是默认临时区',
'如果确实需要临时脚本、缓存或中间产物,必须写入当前 session/scratchpad;交付产物必须优先写入当前 session/output。',
'只有用户明确要求长期复用或该脚本属于产品代码时,才把 Python 脚本加入项目源码目录。',
'当事情失败时,先诊断原因再改变方向。不要对同一个失败动作反复循环。',
+3 -3
View File
@@ -1620,9 +1620,9 @@ class LocalCodingAgent:
'请回复“继续”,我会要求模型重新生成合法 JSON 参数;'
'如果是 write_file 写短文本/Markdown,应改用 content_lines'
'如果是很小的 JSON/JSONL/CSV,可用 json_content、jsonl_records 或 csv_rows'
'如果是脚本、长文本、大 JSON、JSONL、CSV 或生成数据,应直接改用 python_exec 写文件'
'如果是生成/转换结构化数据,应优先调用对应 skill 脚本或 python_exec'
'不要把大段内容直接塞进工具参数bash 仅作为最后兜底'
'如果是脚本、长文本、大 JSON、JSONL、CSV 或生成数据,应改用 bash 配合 quoted heredoc 写文件'
'或先 write_file 落地一个小 .py 再用 bash 跑;'
'不要把大段内容直接塞进工具参数。'
) from exc
if not isinstance(arguments, dict):
raise OpenAICompatError(
+7 -49
View File
@@ -10,51 +10,6 @@ def build_execution_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool
"""构建本地命令、Python 执行与短等待工具声明。"""
return [
AgentTool(
name='python_exec',
description=(
'优先用本工具执行小型 Python 代码或项目内 Python 脚本,用于结构化文件分析、'
'JSON/JSONL 处理、批量校验、数据抽样和快速计算。默认使用当前用户独立 Python venv'
'不要用 bash 运行 python/python3/.venv/bin/python。不要用本工具安装依赖。'
'缺包时应向用户确认后再处理依赖。一次性分析优先传 code;项目或 skill 内已有脚本优先传 script_path'
'不要在 code 里用 subprocess 二次调用 Python 脚本。如需写临时文件,'
'必须写入环境变量 PYTHON_EXEC_SCRATCHPAD 指向的会话隔离目录,'
'不要在项目根目录创建临时 .py 脚本。'
),
parameters={
'type': 'object',
'properties': {
'code': {
'type': 'string',
'description': '要通过 python -c 执行的 Python 代码。code 和 script_path 必须二选一;一次性分析优先使用 code。',
},
'script_path': {
'type': 'string',
'description': '工作区内已有、需要长期复用的 Python 脚本路径。code 和 script_path 必须二选一;不要为一次性分析在项目根目录新建脚本。',
},
'args': {
'type': 'array',
'items': {'type': 'string'},
'description': '传给脚本的命令行参数。仅在 script_path 模式下使用。',
},
'stdin': {
'type': 'string',
'description': '可选标准输入内容。',
},
'timeout_seconds': {
'type': 'number',
'minimum': 1,
'description': '可选超时时间,默认 30 秒。读大 CSV/JSONL、pandas 分析、批量校验、训练评测脚本等可能 >30s 的任务必须显式传值(常规 120-300、训练/长跑 600+)。',
},
'max_output_chars': {
'type': 'integer',
'minimum': 100,
'description': '可选输出截断长度,默认使用当前会话输出限制。',
},
},
},
handler=resolve_handler(handlers, 'python_exec', 'execution'),
),
AgentTool(
name='python_package',
description=(
@@ -94,10 +49,13 @@ def build_execution_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool
AgentTool(
name='bash',
description=(
'运行真实 shell 命令,例如系统命令、进程控制、git 只读检查用户已确认的依赖安装'
'不要用 bash 执行 python、python3、pip 或 .venv/bin/python;结构化文件分析、'
'JSON/JSONL 处理、批量校验、数据抽样和快速计算必须优先使用 python_exec。'
'Python 包安装必须优先使用 python_package。'
'运行真实 shell 命令,包括系统命令、进程控制、git 只读检查用户已确认的依赖安装'
'以及所有 Python 脚本执行。'
'执行 Python 时,把代码先写到 scratchpad 下的 .py 文件,'
'再用本工具跑 `python3 -u <script> 2>&1 | tee <log>`'
'`-u` 关掉 stdout 缓冲,`tee` 让进度行实时落盘,超时被 kill 时仍能 `tail` 日志看到死在哪个 phase。'
'不要把多行 Python 代码以 `-c` 字符串方式塞进 command。'
'Python 包安装必须优先使用 python_package;不要通过 bash 执行 pip。'
'需要等待远端长任务(>30s 的训练、文件落盘、外部 API 回调)时,'
'设置 run_in_background=true 让命令在后台 detach 跑、立刻返回 task_id'
'默认 wait_for_completion=true 时进程结束后会自动起新一轮把结果送回。'
+5 -5
View File
@@ -41,8 +41,8 @@ def build_file_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool]:
description=(
'Write or append a SMALL UTF-8 file inside the workspace. Creates parent directories when needed. '
'Use this for short notes, lightweight markdown, and small config files. '
'For Python scripts, JSONL, large JSON/CSV, generated datasets, or quote-heavy/multi-line content, prefer python_exec '
'to write the file with pathlib/json/csv; do not pack large content into tool arguments.'
'For Python scripts, JSONL, large JSON/CSV, generated datasets, or quote-heavy/multi-line content, prefer bash with a quoted heredoc '
"(e.g. `cat > scratchpad/foo.py <<'EOF' ... EOF`); do not pack large content into tool arguments."
),
parameters={
'type': 'object',
@@ -51,7 +51,7 @@ def build_file_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool]:
'content_lines': {
'type': 'array',
'items': {'type': 'string'},
'description': 'Lines to join with "\\n"; use only for short text/markdown or small scripts. For long scripts use python_exec to create the file.',
'description': 'Lines to join with "\\n"; use only for short text/markdown or small scripts. For long scripts use bash with a quoted heredoc to create the file.',
},
'content': {
'type': 'string',
@@ -63,12 +63,12 @@ def build_file_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool]:
},
'json_content': {
'type': 'object',
'description': 'Small JSON value to serialize with ensure_ascii=false and indent=2. For large JSON use python_exec streaming write.',
'description': 'Small JSON value to serialize with ensure_ascii=false and indent=2. For large JSON write a small Python script to scratchpad and run it via bash.',
},
'jsonl_records': {
'type': 'array',
'items': {'type': 'object'},
'description': 'Small list of objects to serialize as compact JSONL. For more than a few dozen records or generated datasets use python_exec.',
'description': 'Small list of objects to serialize as compact JSONL. For more than a few dozen records or generated datasets, write a Python script to scratchpad and run it via bash.',
},
'csv_headers': {
'type': 'array',
-1
View File
@@ -117,7 +117,6 @@ def default_tool_registry() -> dict[str, AgentTool]:
'grep_search': _grep_search,
}),
*build_execution_tools({
'python_exec': _run_python_exec,
'python_package': _run_python_package,
'bash': _run_bash,
'bash_status': _run_bash_status,