diff --git a/.gitignore b/.gitignore index 7ed6b58..399604e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ archive/ scratchpad/ tasks/ router_session_parquet/ +.logs/ # Environment files .env diff --git a/backend/api/server.py b/backend/api/server.py index 8583065..99fe8fb 100644 --- a/backend/api/server.py +++ b/backend/api/server.py @@ -1696,12 +1696,31 @@ 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) - run_dic = _resolve_run_dic_from_state(state_entries) + # 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 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_/、augment_.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 '' + 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,48 +2018,82 @@ def create_app(state: AgentState) -> FastAPI: async def event_gen(): last_signature: str | None = None last_heartbeat = time.monotonic() - while True: - if await request.is_disconnected(): - break - state_entries, state_mtime = _read_program_state(state_path) - iter_count = await asyncio.to_thread( - _read_iteration_log_count, + refresher = asyncio.create_task( + _iter_count_refresh_loop( session_id, agent_state=state, account_id=account_id, ) - payload = await asyncio.to_thread( - _hardcoded_autoresearch_pipeline, + ) + metrics_refresher = asyncio.create_task( + _metrics_history_refresh_loop( session_id, - sessions_dir=sessions_dir, - state_entries=state_entries, agent_state=state, account_id=account_id, ) - failure_status = await asyncio.to_thread( - _read_session_failure_status, - sessions_dir, - session_id, - ) - run_active = _is_run_active(state, account_id, session_id) - _apply_program_state( - payload, - state_entries, - iteration_log_count=iter_count or 0, - session_failure_status=failure_status, - run_active=run_active, - session_id=session_id, - ) - payload_str = json.dumps(payload, ensure_ascii=False) - signature = f'{state_mtime}|{hash(payload_str)}' - if signature != last_signature: - last_signature = signature - yield f'data: {payload_str}\n\n' - now = time.monotonic() - if now - last_heartbeat > 15: - yield ': heartbeat\n\n' - last_heartbeat = now - await asyncio.sleep(0.5) + ) + try: + while True: + if await request.is_disconnected(): + break + state_entries, state_mtime = _read_program_state(state_path) + iter_count = await asyncio.to_thread( + _read_iteration_log_count, + session_id, + 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, + sessions_dir=sessions_dir, + 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, + sessions_dir, + session_id, + ) + run_active = _is_run_active(state, account_id, session_id) + _apply_program_state( + payload, + state_entries, + iteration_log_count=iter_count or 0, + session_failure_status=failure_status, + run_active=run_active, + session_id=session_id, + ) + payload_str = json.dumps(payload, ensure_ascii=False) + signature = f'{state_mtime}|{hash(payload_str)}' + if signature != last_signature: + last_signature = signature + yield f'data: {payload_str}\n\n' + now = time.monotonic() + if now - last_heartbeat > 15: + 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=running,UI 会从 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=, 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_.json'}, - ('dist-analysis', 'baseline'): {'icon': 'trending-up', 'title': '分层结果分析', 'subtitle': '需求集合 / 车载 / specific'}, - ('report', 'baseline'): {'icon': 'file-text', 'title': '问题分析 & 报告', 'subtitle': 'workflow.md'}, + ('dist-analysis', 'baseline'): {'icon': 'trending-up', 'title': '分层结果分析 & 报告', 'subtitle': '根因归类 + workflow.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_.json'}, - ('dist-analysis', 'analysis'): {'icon': 'trending-up', 'title': '分层结果分析', 'subtitle': '需求集合 / 车载 / specific'}, - ('report', 'analysis'): {'icon': 'file-text', 'title': '问题分析 & 报告', 'subtitle': 'workflow.md'}, + ('dist-analysis', 'analysis'): {'icon': 'trending-up', 'title': '分层结果分析 & 报告', 'subtitle': '根因归类 + workflow.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,17 +7408,33 @@ 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') - 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: - iteration = 'R0-baseline' + # 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: - iteration = f'R{iter_count}' + 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 or iter_count == 0: + iteration = 'R0-baseline' + else: + iteration = f'R{iter_count}' elapsed = _format_elapsed(_compute_step0_start_seconds(state_entries)) target_value = ( f'{target_set} · {target_metric}' if target_metric != '—' else target_set @@ -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, } diff --git a/frontend/app/app/api/claw/training/step-detail/route.ts b/frontend/app/app/api/claw/training/step-detail/route.ts index 2615893..1184b30 100644 --- a/frontend/app/app/api/claw/training/step-detail/route.ts +++ b/frontend/app/app/api/claw/training/step-detail/route.ts @@ -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(); diff --git a/frontend/app/app/assistant.tsx b/frontend/app/app/assistant.tsx index 0f42dc3..e71e5f4 100644 --- a/frontend/app/app/assistant.tsx +++ b/frontend/app/app/assistant.tsx @@ -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(() => readActiveSessionId(), ); @@ -209,6 +232,12 @@ function AssistantWorkspace() { const pipelineContainerRef = useRef(null); const lastPipelineYRef = useRef(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>(new Map()); + const prevSessionKeyRef = useRef(getDraftKey(activeSessionId)); const handlePipelineMouseMove = useCallback( (event: React.MouseEvent) => { @@ -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( + "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 */ } diff --git a/frontend/app/components/assistant-ui/thread-list.tsx b/frontend/app/components/assistant-ui/thread-list.tsx index 1d00f63..ffcb63d 100644 --- a/frontend/app/components/assistant-ui/thread-list.tsx +++ b/frontend/app/components/assistant-ui/thread-list.tsx @@ -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); diff --git a/frontend/app/components/assistant-ui/thread.tsx b/frontend/app/components/assistant-ui/thread.tsx index fd4c2c7..5441ca7 100644 --- a/frontend/app/components/assistant-ui/thread.tsx +++ b/frontend/app/components/assistant-ui/thread.tsx @@ -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(null); - const appliedTerminalRef = useRef(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>(new Map()); + const appliedTerminalRefMap = useRef>(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> = ({ 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> = ({ } }, [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> = ({ }; }, [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; diff --git a/frontend/app/components/training/metrics-chart-panel.tsx b/frontend/app/components/training/metrics-chart-panel.tsx new file mode 100644 index 0000000..ef1d465 --- /dev/null +++ b/frontend/app/components/training/metrics-chart-panel.tsx @@ -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; +}; + +export type MetricsHistory = { + metrics: string[]; + rounds: Round[]; +}; + +const METRIC_LABELS: Record = { + 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> { + 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 ( +
+ 暂无指标数据,等首轮分析完成 +
+ ); + } + + 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 ( +
+
+ {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 ( +
+
+
+ + + {labelOf(k)} + +
+ + {formatValue(curr)} + +
+
+ + Δbase {dBase.text} + + + Δprev {dPrev.text} + +
+
+ + + + + + typeof v === "number" ? v.toFixed(4) : v + } + domain={["auto", "auto"]} + width={60} + /> + [ + typeof value === "number" ? value.toFixed(4) : value, + labelOf(name), + ]} + /> + + + +
+
+ ); + })} +
+
+ ); +} + +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"; +} diff --git a/frontend/app/components/training/step-detail-sheet.tsx b/frontend/app/components/training/step-detail-sheet.tsx index 5f5d516..701cff4 100644 --- a/frontend/app/components/training/step-detail-sheet.tsx +++ b/frontend/app/components/training/step-detail-sheet.tsx @@ -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(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) => { + if (isFullscreen) return; + e.currentTarget.setPointerCapture(e.pointerId); + dragRef.current = { startX: e.clientX, startW: width }; + }; + const onResizePointerMove = (e: React.PointerEvent) => { + 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) => { + 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 ( { + 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 ? ( +
+
+
+ ) : null}
@@ -119,6 +175,21 @@ export function StepDetailSheet({ ) : null}
+ +
+
+ {tab === "log" ? ( + + ) : ( + + )} +
+
); } -function LogStream({ logs }: { logs: LogLine[] }) { +function PillTab({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + +function LogStreamBody({ logs }: { logs: LogLine[] }) { const ref = useRef(null); useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [logs]); return ( -
-
- - Live Log - - -
-
- {logs.length === 0 ? ( -
暂无日志
- ) : ( - logs.map((line, idx) => ( -
- {line.ts} - [iter={line.iter}] - {line.text} -
- )) - )} -
+
+ {logs.length === 0 ? ( +
暂无日志
+ ) : ( + logs.map((line, idx) => ( +
+ {line.ts} + [iter={line.iter}] + {line.text} +
+ )) + )}
); } diff --git a/frontend/app/lib/claw-active-session.ts b/frontend/app/lib/claw-active-session.ts index 98e3220..7345325 100644 --- a/frontend/app/lib/claw-active-session.ts +++ b/frontend/app/lib/claw-active-session.ts @@ -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(); + +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; +} diff --git a/frontend/app/package-lock.json b/frontend/app/package-lock.json index 0def24c..13d57ac 100644 --- a/frontend/app/package-lock.json +++ b/frontend/app/package-lock.json @@ -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", diff --git a/frontend/app/package.json b/frontend/app/package.json index 663b5c9..144b966 100644 --- a/frontend/app/package.json +++ b/frontend/app/package.json @@ -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", diff --git a/src/agent_prompting.py b/src/agent_prompting.py index e946922..dac40aa 100644 --- a/src/agent_prompting.py +++ b/src/agent_prompting.py @@ -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