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:
@@ -21,6 +21,7 @@ archive/
|
|||||||
scratchpad/
|
scratchpad/
|
||||||
tasks/
|
tasks/
|
||||||
router_session_parquet/
|
router_session_parquet/
|
||||||
|
.logs/
|
||||||
|
|
||||||
# Environment files
|
# Environment files
|
||||||
.env
|
.env
|
||||||
|
|||||||
+520
-75
@@ -1696,12 +1696,31 @@ def create_app(state: AgentState) -> FastAPI:
|
|||||||
async def get_training_step_detail(
|
async def get_training_step_detail(
|
||||||
session_id: str,
|
session_id: str,
|
||||||
step: str,
|
step: str,
|
||||||
|
run_id: str | None = None,
|
||||||
account_id: str | None = None,
|
account_id: str | None = None,
|
||||||
) -> Response:
|
) -> Response:
|
||||||
sessions_dir = state.account_paths(account_id)['sessions']
|
sessions_dir = state.account_paths(account_id)['sessions']
|
||||||
state_path = _session_state_path(sessions_dir, session_id)
|
state_path = _session_state_path(sessions_dir, session_id)
|
||||||
state_entries, _ = _read_program_state(state_path)
|
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<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)
|
runtime = _jupyter_runtime_for_session(state, account_id, session_id)
|
||||||
artifact_paths = _step_artifact_paths(step, run_dic, runtime)
|
artifact_paths = _step_artifact_paths(step, run_dic, runtime)
|
||||||
|
|
||||||
@@ -1850,6 +1869,33 @@ def create_app(state: AgentState) -> FastAPI:
|
|||||||
'shown_rows': len(table_rows),
|
'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 = {
|
payload = {
|
||||||
'step': step,
|
'step': step,
|
||||||
'run_dic': run_dic,
|
'run_dic': run_dic,
|
||||||
@@ -1923,6 +1969,12 @@ def create_app(state: AgentState) -> FastAPI:
|
|||||||
agent_state=state,
|
agent_state=state,
|
||||||
account_id=account_id,
|
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(
|
payload = await asyncio.to_thread(
|
||||||
_hardcoded_autoresearch_pipeline,
|
_hardcoded_autoresearch_pipeline,
|
||||||
session_id,
|
session_id,
|
||||||
@@ -1931,6 +1983,8 @@ def create_app(state: AgentState) -> FastAPI:
|
|||||||
model_config=state.model_config_for(account_id),
|
model_config=state.model_config_for(account_id),
|
||||||
agent_state=state,
|
agent_state=state,
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
|
iteration_log_count=iter_count,
|
||||||
|
metrics_history=metrics_history,
|
||||||
)
|
)
|
||||||
failure_status = await asyncio.to_thread(
|
failure_status = await asyncio.to_thread(
|
||||||
_read_session_failure_status,
|
_read_session_failure_status,
|
||||||
@@ -1964,48 +2018,82 @@ def create_app(state: AgentState) -> FastAPI:
|
|||||||
async def event_gen():
|
async def event_gen():
|
||||||
last_signature: str | None = None
|
last_signature: str | None = None
|
||||||
last_heartbeat = time.monotonic()
|
last_heartbeat = time.monotonic()
|
||||||
while True:
|
refresher = asyncio.create_task(
|
||||||
if await request.is_disconnected():
|
_iter_count_refresh_loop(
|
||||||
break
|
|
||||||
state_entries, state_mtime = _read_program_state(state_path)
|
|
||||||
iter_count = await asyncio.to_thread(
|
|
||||||
_read_iteration_log_count,
|
|
||||||
session_id,
|
session_id,
|
||||||
agent_state=state,
|
agent_state=state,
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
)
|
)
|
||||||
payload = await asyncio.to_thread(
|
)
|
||||||
_hardcoded_autoresearch_pipeline,
|
metrics_refresher = asyncio.create_task(
|
||||||
|
_metrics_history_refresh_loop(
|
||||||
session_id,
|
session_id,
|
||||||
sessions_dir=sessions_dir,
|
|
||||||
state_entries=state_entries,
|
|
||||||
agent_state=state,
|
agent_state=state,
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
)
|
)
|
||||||
failure_status = await asyncio.to_thread(
|
)
|
||||||
_read_session_failure_status,
|
try:
|
||||||
sessions_dir,
|
while True:
|
||||||
session_id,
|
if await request.is_disconnected():
|
||||||
)
|
break
|
||||||
run_active = _is_run_active(state, account_id, session_id)
|
state_entries, state_mtime = _read_program_state(state_path)
|
||||||
_apply_program_state(
|
iter_count = await asyncio.to_thread(
|
||||||
payload,
|
_read_iteration_log_count,
|
||||||
state_entries,
|
session_id,
|
||||||
iteration_log_count=iter_count or 0,
|
agent_state=state,
|
||||||
session_failure_status=failure_status,
|
account_id=account_id,
|
||||||
run_active=run_active,
|
)
|
||||||
session_id=session_id,
|
metrics_history = await asyncio.to_thread(
|
||||||
)
|
_read_iteration_log_metrics_history,
|
||||||
payload_str = json.dumps(payload, ensure_ascii=False)
|
session_id,
|
||||||
signature = f'{state_mtime}|{hash(payload_str)}'
|
agent_state=state,
|
||||||
if signature != last_signature:
|
account_id=account_id,
|
||||||
last_signature = signature
|
)
|
||||||
yield f'data: {payload_str}\n\n'
|
payload = await asyncio.to_thread(
|
||||||
now = time.monotonic()
|
_hardcoded_autoresearch_pipeline,
|
||||||
if now - last_heartbeat > 15:
|
session_id,
|
||||||
yield ': heartbeat\n\n'
|
sessions_dir=sessions_dir,
|
||||||
last_heartbeat = now
|
state_entries=state_entries,
|
||||||
await asyncio.sleep(0.5)
|
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(
|
return StreamingResponse(
|
||||||
event_gen(),
|
event_gen(),
|
||||||
@@ -2886,7 +2974,7 @@ def _normalize_run_event(event: dict[str, object]) -> dict[str, Any] | None:
|
|||||||
'exceeds_soft_limit',
|
'exceeds_soft_limit',
|
||||||
}
|
}
|
||||||
normalized = {
|
normalized = {
|
||||||
key: _json_safe_limited(value)
|
key: _json_safe_limited(value, parent_key=key)
|
||||||
for key, value in event.items()
|
for key, value in event.items()
|
||||||
if key in allowed and value is not None
|
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
|
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:
|
if depth > 4:
|
||||||
return str(value)[:500]
|
return str(value)[:500]
|
||||||
if isinstance(value, str):
|
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:
|
if isinstance(value, (int, float, bool)) or value is None:
|
||||||
return value
|
return value
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
return {
|
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]
|
for key, item in list(value.items())[:80]
|
||||||
}
|
}
|
||||||
if isinstance(value, (list, tuple)):
|
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:
|
try:
|
||||||
json.dumps(value)
|
json.dumps(value)
|
||||||
return value
|
return value
|
||||||
@@ -4885,6 +4978,35 @@ class _WatcherManager:
|
|||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
session_id=session_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
|
return
|
||||||
if time.monotonic() - start > timeout:
|
if time.monotonic() - start > timeout:
|
||||||
await self._write_step(
|
await self._write_step(
|
||||||
@@ -5507,13 +5629,27 @@ def _scan_for_watchers(state: AgentState) -> None:
|
|||||||
status = entry.get('status')
|
status = entry.get('status')
|
||||||
if isinstance(step, str) and step and isinstance(status, str):
|
if isinstance(step, str) and step and isinstance(status, str):
|
||||||
last_status_per_step[step] = status
|
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:
|
for entry in entries:
|
||||||
watch = entry.get('watch')
|
watch = entry.get('watch')
|
||||||
if not isinstance(watch, dict):
|
if isinstance(watch, dict):
|
||||||
continue
|
w_step = watch.get('step')
|
||||||
step = watch.get('step')
|
if isinstance(w_step, str) and w_step:
|
||||||
if not isinstance(step, str) or not step:
|
latest_watch_per_step[w_step] = watch
|
||||||
continue
|
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 {
|
if last_status_per_step.get(step) in {
|
||||||
'complete',
|
'complete',
|
||||||
'failed',
|
'failed',
|
||||||
@@ -5535,6 +5671,9 @@ def _scan_for_watchers(state: AgentState) -> None:
|
|||||||
interval = 30.0
|
interval = 30.0
|
||||||
timeout = 3600.0
|
timeout = 3600.0
|
||||||
run_id = str(watch.get('run_id') or '?')
|
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(
|
_watcher_manager.register_file_exists(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
state_path=state_path,
|
state_path=state_path,
|
||||||
@@ -5617,13 +5756,30 @@ async def _scan_for_watchers_async(state: AgentState) -> None:
|
|||||||
status = entry.get('status')
|
status = entry.get('status')
|
||||||
if isinstance(step, str) and step and isinstance(status, str):
|
if isinstance(step, str) and step and isinstance(status, str):
|
||||||
last_status_per_step[step] = status
|
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:
|
for entry in entries:
|
||||||
watch = entry.get('watch')
|
watch = entry.get('watch')
|
||||||
if not isinstance(watch, dict):
|
if isinstance(watch, dict):
|
||||||
continue
|
w_step = watch.get('step')
|
||||||
step = watch.get('step')
|
if isinstance(w_step, str) and w_step:
|
||||||
if not isinstance(step, str) or not step:
|
latest_watch_per_step[w_step] = watch
|
||||||
continue
|
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 {
|
if last_status_per_step.get(step) in {
|
||||||
'complete',
|
'complete',
|
||||||
'failed',
|
'failed',
|
||||||
@@ -5645,6 +5801,13 @@ async def _scan_for_watchers_async(state: AgentState) -> None:
|
|||||||
interval_s = 30.0
|
interval_s = 30.0
|
||||||
timeout_s = 3600.0
|
timeout_s = 3600.0
|
||||||
run_id = str(watch.get('run_id') or '?')
|
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(
|
_watcher_manager.register_file_exists(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
state_path=state_path,
|
state_path=state_path,
|
||||||
@@ -5752,7 +5915,6 @@ _STEP_KIND: dict[str, str] = {
|
|||||||
'cml': 'analysis',
|
'cml': 'analysis',
|
||||||
'gold-drift': 'analysis',
|
'gold-drift': 'analysis',
|
||||||
'dist-analysis': 'analysis',
|
'dist-analysis': 'analysis',
|
||||||
'report': 'analysis',
|
|
||||||
'hypothesis': 'analysis',
|
'hypothesis': 'analysis',
|
||||||
'log': 'analysis',
|
'log': 'analysis',
|
||||||
'augment': 'train',
|
'augment': 'train',
|
||||||
@@ -5780,8 +5942,7 @@ _CARD_META: dict[tuple[str, str], dict[str, Any]] = {
|
|||||||
# R0·Baseline
|
# R0·Baseline
|
||||||
('cml', 'baseline'): {'icon': 'bar-chart', 'title': 'Baseline 评测', 'subtitle': 'CML workflow metric_diff'},
|
('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'},
|
('gold-drift', 'baseline'): {'icon': 'alert-triangle', 'title': 'Gold Drift 检查', 'subtitle': 'drift_<runDic>.json'},
|
||||||
('dist-analysis', 'baseline'): {'icon': 'trending-up', 'title': '分层结果分析', 'subtitle': '需求集合 / 车载 / specific'},
|
('dist-analysis', 'baseline'): {'icon': 'trending-up', 'title': '分层结果分析 & 报告', 'subtitle': '根因归类 + workflow<runDic>.md'},
|
||||||
('report', 'baseline'): {'icon': 'file-text', 'title': '问题分析 & 报告', 'subtitle': 'workflow<runDic>.md'},
|
|
||||||
('hypothesis', 'baseline'): {'icon': 'dna', 'title': '形成假设', 'subtitle': 'iteration_log hypothesis 字段'},
|
('hypothesis', 'baseline'): {'icon': 'dna', 'title': '形成假设', 'subtitle': 'iteration_log hypothesis 字段'},
|
||||||
('log', 'baseline'): {'icon': 'clipboard-list', 'title': '记录迭代日志', 'subtitle': 'iteration_log.jsonl'},
|
('log', 'baseline'): {'icon': 'clipboard-list', 'title': '记录迭代日志', 'subtitle': 'iteration_log.jsonl'},
|
||||||
# R{n}·Train
|
# R{n}·Train
|
||||||
@@ -5791,8 +5952,7 @@ _CARD_META: dict[tuple[str, str], dict[str, Any]] = {
|
|||||||
# R{n}·Analysis
|
# R{n}·Analysis
|
||||||
('cml', 'analysis'): {'icon': 'bar-chart', 'title': 'CML 评测', 'subtitle': 'workflow metric_diff'},
|
('cml', 'analysis'): {'icon': 'bar-chart', 'title': 'CML 评测', 'subtitle': 'workflow metric_diff'},
|
||||||
('gold-drift', 'analysis'): {'icon': 'alert-triangle', 'title': 'Gold Drift 检查', 'subtitle': 'drift_<runDic>.json'},
|
('gold-drift', 'analysis'): {'icon': 'alert-triangle', 'title': 'Gold Drift 检查', 'subtitle': 'drift_<runDic>.json'},
|
||||||
('dist-analysis', 'analysis'): {'icon': 'trending-up', 'title': '分层结果分析', 'subtitle': '需求集合 / 车载 / specific'},
|
('dist-analysis', 'analysis'): {'icon': 'trending-up', 'title': '分层结果分析 & 报告', 'subtitle': '根因归类 + workflow<runDic>.md'},
|
||||||
('report', 'analysis'): {'icon': 'file-text', 'title': '问题分析 & 报告', 'subtitle': 'workflow<runDic>.md'},
|
|
||||||
('hypothesis', 'analysis'): {'icon': 'dna', 'title': '形成假设', 'subtitle': 'iteration_log hypothesis 字段'},
|
('hypothesis', 'analysis'): {'icon': 'dna', 'title': '形成假设', 'subtitle': 'iteration_log hypothesis 字段'},
|
||||||
('log', 'analysis'): {'icon': 'clipboard-list', 'title': '记录迭代日志', 'subtitle': 'iteration_log.jsonl'},
|
('log', 'analysis'): {'icon': 'clipboard-list', 'title': '记录迭代日志', 'subtitle': 'iteration_log.jsonl'},
|
||||||
}
|
}
|
||||||
@@ -5803,9 +5963,8 @@ _CARD_ORDER: dict[tuple[str, str], int] = {
|
|||||||
('cml', 'baseline'): 0,
|
('cml', 'baseline'): 0,
|
||||||
('gold-drift', 'baseline'): 1,
|
('gold-drift', 'baseline'): 1,
|
||||||
('dist-analysis', 'baseline'): 2,
|
('dist-analysis', 'baseline'): 2,
|
||||||
('report', 'baseline'): 3,
|
('hypothesis', 'baseline'): 3,
|
||||||
('hypothesis', 'baseline'): 4,
|
('log', 'baseline'): 4,
|
||||||
('log', 'baseline'): 5,
|
|
||||||
# train
|
# train
|
||||||
('augment', 'train'): 0,
|
('augment', 'train'): 0,
|
||||||
('verify', 'train'): 1,
|
('verify', 'train'): 1,
|
||||||
@@ -5814,9 +5973,8 @@ _CARD_ORDER: dict[tuple[str, str], int] = {
|
|||||||
('cml', 'analysis'): 0,
|
('cml', 'analysis'): 0,
|
||||||
('gold-drift', 'analysis'): 1,
|
('gold-drift', 'analysis'): 1,
|
||||||
('dist-analysis', 'analysis'): 2,
|
('dist-analysis', 'analysis'): 2,
|
||||||
('report', 'analysis'): 3,
|
('hypothesis', 'analysis'): 3,
|
||||||
('hypothesis', 'analysis'): 4,
|
('log', 'analysis'): 4,
|
||||||
('log', 'analysis'): 5,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_SECTION_LABELS: dict[str, str] = {
|
_SECTION_LABELS: dict[str, str] = {
|
||||||
@@ -6514,6 +6672,13 @@ def _scan_run_history_max() -> int | None:
|
|||||||
return best if best >= 0 else 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(
|
def _read_iteration_log_count(
|
||||||
session_id: str | None = None,
|
session_id: str | None = None,
|
||||||
*,
|
*,
|
||||||
@@ -6523,10 +6688,53 @@ def _read_iteration_log_count(
|
|||||||
"""Count non-empty lines in iteration_log.jsonl.
|
"""Count non-empty lines in iteration_log.jsonl.
|
||||||
|
|
||||||
Chat-root lives under the remote workspace_cwd, so reads MUST go through
|
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
|
the bound jupyter runtime — the round-trip is multi-second. To keep the
|
||||||
locally. Falls back to the legacy global path on the local fs only when
|
SSE pipeline loop responsive, this function returns the last cached value
|
||||||
no runtime is bound (session never connected, or transient lookup miss).
|
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 = (
|
runtime = (
|
||||||
_jupyter_runtime_for_session(agent_state, account_id, session_id)
|
_jupyter_runtime_for_session(agent_state, account_id, session_id)
|
||||||
if session_id and agent_state is not None
|
if session_id and agent_state is not None
|
||||||
@@ -6565,6 +6773,150 @@ def _read_iteration_log_count(
|
|||||||
return None
|
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(
|
def _read_iteration_log_h1_label(
|
||||||
runtime: 'JupyterRuntimeSession | None',
|
runtime: 'JupyterRuntimeSession | None',
|
||||||
run_dic: int | None,
|
run_dic: int | None,
|
||||||
@@ -7019,6 +7371,8 @@ def _compute_kpis(
|
|||||||
*,
|
*,
|
||||||
agent_state: 'AgentState | None' = None,
|
agent_state: 'AgentState | None' = None,
|
||||||
account_id: str | None = None,
|
account_id: str | None = None,
|
||||||
|
iteration_log_count: int | None = None,
|
||||||
|
metrics_history: dict[str, Any] | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Compute the 7 top KPIs from authoritative sources. Falls back to '—'.
|
"""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')
|
target_metric = _format_metric_value(metrics, 'target_set_pass_rate')
|
||||||
overall_metric = _format_metric_value(metrics, 'overall_car_pass_rate')
|
overall_metric = _format_metric_value(metrics, 'overall_car_pass_rate')
|
||||||
specific_metric = _format_metric_value(metrics, 'specific_test_pass_rate')
|
specific_metric = _format_metric_value(metrics, 'specific_test_pass_rate')
|
||||||
iter_count = _read_iteration_log_count(
|
# Round number = MAX numeric `iteration` field across iteration_log.jsonl
|
||||||
session_id,
|
# entries, NOT the line count. iteration_log.jsonl carries multiple rows
|
||||||
agent_state=agent_state,
|
# per round (separate hypothesis-only / results / final-summary entries),
|
||||||
account_id=account_id,
|
# so line count overcounts. Falls back to line-count for backwards
|
||||||
)
|
# compatibility when metrics_history isn't available.
|
||||||
if iter_count is None:
|
max_iteration: int | None = None
|
||||||
iteration = 'R0-baseline'
|
if metrics_history and isinstance(metrics_history.get('rounds'), list):
|
||||||
elif iter_count == 0:
|
for r in metrics_history['rounds']:
|
||||||
iteration = 'R0-baseline'
|
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:
|
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))
|
elapsed = _format_elapsed(_compute_step0_start_seconds(state_entries))
|
||||||
target_value = (
|
target_value = (
|
||||||
f'{target_set} · {target_metric}' if target_metric != '—' else target_set
|
f'{target_set} · {target_metric}' if target_metric != '—' else target_set
|
||||||
@@ -7134,6 +7504,79 @@ def _resolve_run_dic_from_state(
|
|||||||
return best
|
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(
|
def _step_artifact_paths(
|
||||||
step: str,
|
step: str,
|
||||||
run_dic: int | None,
|
run_dic: int | None,
|
||||||
@@ -7176,9 +7619,6 @@ def _step_artifact_paths(
|
|||||||
paths.append(
|
paths.append(
|
||||||
f'{runtime.binding.workspace_cwd}/output/relabel_candidates_{run_dic}.csv'
|
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':
|
elif step == 'hypothesis':
|
||||||
paths.append(f'{chat_root}/results/iteration_log.jsonl')
|
paths.append(f'{chat_root}/results/iteration_log.jsonl')
|
||||||
elif step == 'augment':
|
elif step == 'augment':
|
||||||
@@ -7342,6 +7782,8 @@ def _hardcoded_autoresearch_pipeline(
|
|||||||
*,
|
*,
|
||||||
agent_state: 'AgentState | None' = None,
|
agent_state: 'AgentState | None' = None,
|
||||||
account_id: str | None = None,
|
account_id: str | None = None,
|
||||||
|
iteration_log_count: int | None = None,
|
||||||
|
metrics_history: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Canonical autoresearch model-iteration pipeline structure.
|
"""Canonical autoresearch model-iteration pipeline structure.
|
||||||
|
|
||||||
@@ -7359,6 +7801,8 @@ def _hardcoded_autoresearch_pipeline(
|
|||||||
model_config=model_config,
|
model_config=model_config,
|
||||||
agent_state=agent_state,
|
agent_state=agent_state,
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
|
iteration_log_count=iteration_log_count,
|
||||||
|
metrics_history=metrics_history,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Defensive default if caller didn't pass sessions_dir.
|
# Defensive default if caller didn't pass sessions_dir.
|
||||||
@@ -7385,6 +7829,7 @@ def _hardcoded_autoresearch_pipeline(
|
|||||||
'items': [],
|
'items': [],
|
||||||
'in_flight': None,
|
'in_flight': None,
|
||||||
'logs': [],
|
'logs': [],
|
||||||
|
'metrics_history': metrics_history,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export async function GET(request: Request) {
|
|||||||
const incoming = new URL(request.url);
|
const incoming = new URL(request.url);
|
||||||
const sessionId = incoming.searchParams.get("session_id") ?? "";
|
const sessionId = incoming.searchParams.get("session_id") ?? "";
|
||||||
const step = incoming.searchParams.get("step") ?? "";
|
const step = incoming.searchParams.get("step") ?? "";
|
||||||
|
const runId = incoming.searchParams.get("run_id");
|
||||||
if (!sessionId || !step) {
|
if (!sessionId || !step) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: "session_id and step are required" },
|
{ 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("account_id", account.id);
|
||||||
url.searchParams.set("session_id", sessionId);
|
url.searchParams.set("session_id", sessionId);
|
||||||
url.searchParams.set("step", step);
|
url.searchParams.set("step", step);
|
||||||
|
if (runId) url.searchParams.set("run_id", runId);
|
||||||
|
|
||||||
const response = await fetch(url, { cache: "no-store" });
|
const response = await fetch(url, { cache: "no-store" });
|
||||||
const payload = await response.text();
|
const payload = await response.text();
|
||||||
|
|||||||
+141
-28
@@ -1,7 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ExportedMessageRepository } from "@assistant-ui/core";
|
import type { ExportedMessageRepository } from "@assistant-ui/core";
|
||||||
import { AssistantRuntimeProvider, useAuiState } from "@assistant-ui/react";
|
import {
|
||||||
|
AssistantRuntimeProvider,
|
||||||
|
useAui,
|
||||||
|
useAuiState,
|
||||||
|
} from "@assistant-ui/react";
|
||||||
import {
|
import {
|
||||||
AssistantChatTransport,
|
AssistantChatTransport,
|
||||||
useChatRuntime,
|
useChatRuntime,
|
||||||
@@ -29,6 +33,7 @@ import {
|
|||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import {
|
import {
|
||||||
ACTIVE_SESSION_CHANGED_EVENT,
|
ACTIVE_SESSION_CHANGED_EVENT,
|
||||||
|
consumeFreshLocalId,
|
||||||
readActiveSessionId,
|
readActiveSessionId,
|
||||||
readPendingWorkspaceSessionId,
|
readPendingWorkspaceSessionId,
|
||||||
writeActiveSessionId,
|
writeActiveSessionId,
|
||||||
@@ -91,6 +96,7 @@ export const Assistant = ({ initialSessionId }: AssistantProps = {}) => {
|
|||||||
});
|
});
|
||||||
const replaySession = useCallback(
|
const replaySession = useCallback(
|
||||||
(sessionId: string, repository: ExportedMessageRepository) => {
|
(sessionId: string, repository: ExportedMessageRepository) => {
|
||||||
|
console.log("[replay-session] called", { sessionId });
|
||||||
writeActiveSessionId(sessionId);
|
writeActiveSessionId(sessionId);
|
||||||
pushSessionUrl(sessionId);
|
pushSessionUrl(sessionId);
|
||||||
// 后端已经落盘/结束后,用回放结果接管当前会话。
|
// 后端已经落盘/结束后,用回放结果接管当前会话。
|
||||||
@@ -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[] = [
|
const APPLY_INTENT_PATTERNS: RegExp[] = [
|
||||||
@@ -197,6 +219,7 @@ function detectApplyIntent(text: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AssistantWorkspace() {
|
function AssistantWorkspace() {
|
||||||
|
const aui = useAui();
|
||||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(() =>
|
const [activeSessionId, setActiveSessionId] = useState<string | null>(() =>
|
||||||
readActiveSessionId(),
|
readActiveSessionId(),
|
||||||
);
|
);
|
||||||
@@ -209,6 +232,12 @@ function AssistantWorkspace() {
|
|||||||
const pipelineContainerRef = useRef<HTMLDivElement>(null);
|
const pipelineContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const lastPipelineYRef = useRef<number | null>(null);
|
const lastPipelineYRef = useRef<number | null>(null);
|
||||||
const [exitButtonVisible, setExitButtonVisible] = useState(false);
|
const [exitButtonVisible, setExitButtonVisible] = useState(false);
|
||||||
|
// Composer 是 per-thread 的,但本应用所有 backend session 都共用同一个
|
||||||
|
// assistant-ui thread(从不调 switchToThread),因此 composer.text 跨 session
|
||||||
|
// 共享。在 activeSessionId 切换时手动 save+restore,给每个 session 一份
|
||||||
|
// 独立的 draft。null sessionId(newTask)用 NEWTASK_KEY 落盘。
|
||||||
|
const composerCacheRef = useRef<Map<string, string>>(new Map());
|
||||||
|
const prevSessionKeyRef = useRef<string>(getDraftKey(activeSessionId));
|
||||||
|
|
||||||
const handlePipelineMouseMove = useCallback(
|
const handlePipelineMouseMove = useCallback(
|
||||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
@@ -244,11 +273,20 @@ function AssistantWorkspace() {
|
|||||||
}, [showPipeline]);
|
}, [showPipeline]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const update = () => setActiveSessionId(readActiveSessionId());
|
const update = () => {
|
||||||
|
const next = readActiveSessionId();
|
||||||
|
console.log("[active-session] focus-update", { next });
|
||||||
|
setActiveSessionId(next);
|
||||||
|
};
|
||||||
const handleChanged = (event: Event) => {
|
const handleChanged = (event: Event) => {
|
||||||
const detail = (event as CustomEvent<{ sessionId?: string | null }>)
|
const detail = (event as CustomEvent<{ sessionId?: string | null }>)
|
||||||
.detail;
|
.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(ACTIVE_SESSION_CHANGED_EVENT, handleChanged);
|
||||||
window.addEventListener("focus", update);
|
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(() => {
|
useEffect(() => {
|
||||||
closeActivityPanel();
|
closeActivityPanel();
|
||||||
}, [showPipeline, closeActivityPanel]);
|
}, [showPipeline, closeActivityPanel]);
|
||||||
@@ -280,36 +371,58 @@ function AssistantWorkspace() {
|
|||||||
if (applied) return;
|
if (applied) return;
|
||||||
if (!activeSessionId) return;
|
if (!activeSessionId) return;
|
||||||
let cancelled = false;
|
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 () => {
|
const tick = async () => {
|
||||||
try {
|
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;
|
if (!res.ok) return;
|
||||||
const payload = await res.json();
|
const payload = await res.json();
|
||||||
if (cancelled || !payload) return;
|
if (cancelled || !payload) return;
|
||||||
const items = Array.isArray(payload.items) ? payload.items : [];
|
if (payload.status === "running" || payload.status === "queued") {
|
||||||
const hasItemProgress = items.some(
|
setApplied(true);
|
||||||
(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);
|
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
clearActiveSessionId,
|
clearActiveSessionId,
|
||||||
|
markFreshLocalId,
|
||||||
readActiveSessionId,
|
readActiveSessionId,
|
||||||
writeActiveSessionId,
|
writeActiveSessionId,
|
||||||
writePendingWorkspaceSessionId,
|
writePendingWorkspaceSessionId,
|
||||||
@@ -163,6 +164,7 @@ function ensureSidebarWorkspaceSessionId(): {
|
|||||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||||
? `__LOCALID_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
|
? `__LOCALID_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
|
||||||
: `__LOCALID_${Math.random().toString(36).slice(2, 10)}`;
|
: `__LOCALID_${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
markFreshLocalId(generated);
|
||||||
writeActiveSessionId(generated);
|
writeActiveSessionId(generated);
|
||||||
return { sessionId: generated, created: true };
|
return { sessionId: generated, created: true };
|
||||||
}
|
}
|
||||||
@@ -438,6 +440,10 @@ const ClawSessionList: FC = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="aui-thread-list-item-trigger flex h-full min-w-0 flex-1 items-center truncate px-3 text-start text-sm"
|
className="aui-thread-list-item-trigger flex h-full min-w-0 flex-1 items-center truncate px-3 text-start text-sm"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
console.log("[session-list] click", {
|
||||||
|
sessionId: session.session_id,
|
||||||
|
preview: session.preview,
|
||||||
|
});
|
||||||
setOpenMenuSessionId(null);
|
setOpenMenuSessionId(null);
|
||||||
writeActiveSessionId(session.session_id);
|
writeActiveSessionId(session.session_id);
|
||||||
pushSessionUrl(session.session_id);
|
pushSessionUrl(session.session_id);
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ import {
|
|||||||
ACTIVE_SESSION_CHANGED_EVENT,
|
ACTIVE_SESSION_CHANGED_EVENT,
|
||||||
clearActiveSessionId,
|
clearActiveSessionId,
|
||||||
clearPendingWorkspaceSessionId,
|
clearPendingWorkspaceSessionId,
|
||||||
|
markFreshLocalId,
|
||||||
PENDING_WORKSPACE_SESSION_CHANGED_EVENT,
|
PENDING_WORKSPACE_SESSION_CHANGED_EVENT,
|
||||||
readActiveSessionId,
|
readActiveSessionId,
|
||||||
readPendingWorkspaceSessionId,
|
readPendingWorkspaceSessionId,
|
||||||
@@ -261,8 +262,14 @@ function useRefreshCurrentRun() {
|
|||||||
includePending: true,
|
includePending: true,
|
||||||
includeActive: true,
|
includeActive: true,
|
||||||
});
|
});
|
||||||
const activeRunRef = useRef<string | null>(null);
|
// Per-session bookkeeping: switching sessions (sidebar history) and coming
|
||||||
const appliedTerminalRef = useRef<string | null>(null);
|
// back must NOT re-trigger replaySession on a session that has already been
|
||||||
|
// applied — replaySession rebuilds the thread tree, which causes a layout
|
||||||
|
// shift / scroll jitter in the chat UI. Single-value refs would get
|
||||||
|
// overwritten when visiting another session and forget that this session
|
||||||
|
// was already settled.
|
||||||
|
const activeRunRefMap = useRef<Map<string, string>>(new Map());
|
||||||
|
const appliedTerminalRefMap = useRef<Map<string, string>>(new Map());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
@@ -274,8 +281,8 @@ function useRefreshCurrentRun() {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (runStatus?.status === "queued" || runStatus?.status === "running") {
|
if (runStatus?.status === "queued" || runStatus?.status === "running") {
|
||||||
const runKey = runStatus.run_id ?? "active";
|
const runKey = runStatus.run_id ?? "active";
|
||||||
const previousRunKey = activeRunRef.current;
|
const previousRunKey = activeRunRefMap.current.get(sessionId) ?? null;
|
||||||
activeRunRef.current = runKey;
|
activeRunRefMap.current.set(sessionId, runKey);
|
||||||
// Watcher auto-resume: backend started a new run via _maybe_auto_resume
|
// 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
|
// without a local SSE stream driving the UI. We need to replay once so
|
||||||
// the active-run placeholder (pending prompt + 思考中 bubble) appears.
|
// the active-run placeholder (pending prompt + 思考中 bubble) appears.
|
||||||
@@ -294,14 +301,15 @@ function useRefreshCurrentRun() {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!runtimeRunning && !activeRunRef.current) return;
|
const sessionActiveRun = activeRunRefMap.current.get(sessionId) ?? null;
|
||||||
|
if (!runtimeRunning && !sessionActiveRun) return;
|
||||||
const terminalKey = [
|
const terminalKey = [
|
||||||
runStatus?.run_id ?? activeRunRef.current ?? "unknown",
|
runStatus?.run_id ?? sessionActiveRun ?? "unknown",
|
||||||
runStatus?.status ?? "idle",
|
runStatus?.status ?? "idle",
|
||||||
runStatus?.finished_at ?? "",
|
runStatus?.finished_at ?? "",
|
||||||
runStatus?.elapsed_ms ?? "",
|
runStatus?.elapsed_ms ?? "",
|
||||||
].join(":");
|
].join(":");
|
||||||
if (appliedTerminalRef.current === terminalKey) return;
|
if (appliedTerminalRefMap.current.get(sessionId) === terminalKey) return;
|
||||||
const response = await fetch(`/api/claw/sessions/${sessionId}`, {
|
const response = await fetch(`/api/claw/sessions/${sessionId}`, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
@@ -311,8 +319,8 @@ function useRefreshCurrentRun() {
|
|||||||
sessionId,
|
sessionId,
|
||||||
toReplayRepository(payload, sessionId, runStatus),
|
toReplayRepository(payload, sessionId, runStatus),
|
||||||
);
|
);
|
||||||
activeRunRef.current = null;
|
activeRunRefMap.current.delete(sessionId);
|
||||||
appliedTerminalRef.current = terminalKey;
|
appliedTerminalRefMap.current.set(sessionId, terminalKey);
|
||||||
scheduleSessionListRefresh();
|
scheduleSessionListRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -775,6 +783,7 @@ function ensureWorkspaceSessionId(current: string | null) {
|
|||||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||||
? `__LOCALID_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
|
? `__LOCALID_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
|
||||||
: `__LOCALID_${Math.random().toString(36).slice(2, 10)}`;
|
: `__LOCALID_${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
markFreshLocalId(generated);
|
||||||
writeActiveSessionId(generated);
|
writeActiveSessionId(generated);
|
||||||
scheduleSessionListRefresh();
|
scheduleSessionListRefresh();
|
||||||
return generated;
|
return generated;
|
||||||
@@ -2393,6 +2402,11 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
|
|||||||
const justSubmittedRef = useRef(false);
|
const justSubmittedRef = useRef(false);
|
||||||
const isDisabled = runtimeDisabled || disabled;
|
const isDisabled = runtimeDisabled || disabled;
|
||||||
|
|
||||||
|
// Pull store→local on external store changes (session switch, paste-from-attachment,
|
||||||
|
// submit-clear, etc). The reverse direction (local→store) is pushed eagerly by
|
||||||
|
// onChange/handleInsert/handleKeyDown/onCompositionEnd via syncComposerText, so we
|
||||||
|
// don't need a symmetric effect — and adding one creates a feedback loop that
|
||||||
|
// re-renders the composer at ~1ms intervals (visible as shake).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isComposingRef.current) {
|
if (!isComposingRef.current) {
|
||||||
if (justSubmittedRef.current && storeText) return;
|
if (justSubmittedRef.current && storeText) return;
|
||||||
@@ -2401,16 +2415,6 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
|
|||||||
}
|
}
|
||||||
}, [storeText]);
|
}, [storeText]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!isComposingRef.current &&
|
|
||||||
localText !== storeText &&
|
|
||||||
aui.composer().getState().isEditing
|
|
||||||
) {
|
|
||||||
aui.composer().setText(localText);
|
|
||||||
}
|
|
||||||
}, [aui, localText, storeText]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoFocus || isDisabled) return;
|
if (!autoFocus || isDisabled) return;
|
||||||
const textarea = textareaRef.current;
|
const textarea = textareaRef.current;
|
||||||
@@ -2433,6 +2437,21 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
|
|||||||
};
|
};
|
||||||
}, [aui]);
|
}, [aui]);
|
||||||
|
|
||||||
|
// AssistantWorkspace 切 session 时通过事件通知我们恢复 draft——直接
|
||||||
|
// 控制 localText,绕过 store→local 路径上的 justSubmittedRef 屏蔽和
|
||||||
|
// switchToNewThread 引起的异步覆盖。
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<{ text?: string }>).detail;
|
||||||
|
const text = detail?.text ?? "";
|
||||||
|
justSubmittedRef.current = false;
|
||||||
|
setLocalText(text);
|
||||||
|
};
|
||||||
|
window.addEventListener("claw-composer-restore-draft", handler);
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener("claw-composer-restore-draft", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const syncComposerText = useCallback(
|
const syncComposerText = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
if (!aui.composer().getState().isEditing) return;
|
if (!aui.composer().getState().isEditing) return;
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
FileIcon,
|
FileIcon,
|
||||||
|
Maximize2Icon,
|
||||||
|
Minimize2Icon,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -52,6 +54,7 @@ type StepDetail = {
|
|||||||
type Props = {
|
type Props = {
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
stepKey: string | null;
|
stepKey: string | null;
|
||||||
|
runId?: string | null;
|
||||||
stepTitle?: string;
|
stepTitle?: string;
|
||||||
stepStatus?: string;
|
stepStatus?: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -61,6 +64,7 @@ type Props = {
|
|||||||
export function StepDetailSheet({
|
export function StepDetailSheet({
|
||||||
sessionId,
|
sessionId,
|
||||||
stepKey,
|
stepKey,
|
||||||
|
runId,
|
||||||
stepTitle,
|
stepTitle,
|
||||||
stepStatus,
|
stepStatus,
|
||||||
open,
|
open,
|
||||||
@@ -69,13 +73,41 @@ export function StepDetailSheet({
|
|||||||
const [detail, setDetail] = useState<StepDetail | null>(null);
|
const [detail, setDetail] = useState<StepDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [reloadCount, setReloadCount] = useState(0);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open || !sessionId || !stepKey) return;
|
if (!open || !sessionId || !stepKey) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setDetail(null);
|
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" })
|
fetch(url, { cache: "no-store" })
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
@@ -89,15 +121,39 @@ export function StepDetailSheet({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [open, sessionId, stepKey, reloadCount]);
|
}, [open, sessionId, stepKey, runId, reloadCount]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
side="right"
|
side="right"
|
||||||
hideClose
|
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">
|
<header className="flex shrink-0 items-start justify-between gap-3 border-b px-5 py-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -119,6 +175,21 @@ export function StepDetailSheet({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 flex shrink-0 items-center gap-1">
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -8,20 +8,25 @@ import {
|
|||||||
ClipboardListIcon,
|
ClipboardListIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
DnaIcon,
|
DnaIcon,
|
||||||
ExternalLinkIcon,
|
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
FlaskConicalIcon,
|
FlaskConicalIcon,
|
||||||
GraduationCapIcon,
|
GraduationCapIcon,
|
||||||
HourglassIcon,
|
HourglassIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
|
Maximize2Icon,
|
||||||
MicroscopeIcon,
|
MicroscopeIcon,
|
||||||
|
Minimize2Icon,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
TrendingUpIcon,
|
TrendingUpIcon,
|
||||||
UserCheckIcon,
|
UserCheckIcon,
|
||||||
} from "lucide-react";
|
} 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 { StepDetailSheet } from "@/components/training/step-detail-sheet";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -103,6 +108,7 @@ type PipelinePayload = {
|
|||||||
items: PipelineItem[];
|
items: PipelineItem[];
|
||||||
in_flight?: InFlight | null;
|
in_flight?: InFlight | null;
|
||||||
logs: LogLine[];
|
logs: LogLine[];
|
||||||
|
metrics_history?: MetricsHistory | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ICON_MAP: Record<string, LucideIcon> = {
|
const ICON_MAP: Record<string, LucideIcon> = {
|
||||||
@@ -145,7 +151,9 @@ export function TrainingPipelinePanel({
|
|||||||
key: string;
|
key: string;
|
||||||
title: string;
|
title: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
runId?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [bottomTab, setBottomTab] = useState<"log" | "metrics">("log");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -182,7 +190,7 @@ export function TrainingPipelinePanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||||
<HeaderRow status={data?.status ?? null} loading={loading} />
|
<HeaderRow status={data?.status ?? null} loading={loading} />
|
||||||
<KpiRow kpis={data?.kpis ?? []} />
|
<KpiRow kpis={data?.kpis ?? []} />
|
||||||
@@ -222,6 +230,7 @@ export function TrainingPipelinePanel({
|
|||||||
key: stripNamespace(card.key),
|
key: stripNamespace(card.key),
|
||||||
title: card.title,
|
title: card.title,
|
||||||
status: card.status,
|
status: card.status,
|
||||||
|
runId: item.run_id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -238,6 +247,7 @@ export function TrainingPipelinePanel({
|
|||||||
key: stripNamespace(item.key),
|
key: stripNamespace(item.key),
|
||||||
title: item.title,
|
title: item.title,
|
||||||
status: item.status,
|
status: item.status,
|
||||||
|
runId: item.run_id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -250,11 +260,16 @@ export function TrainingPipelinePanel({
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<LegendBar />
|
<BottomPanel
|
||||||
<LogStream logs={data?.logs ?? []} />
|
logs={data?.logs ?? []}
|
||||||
|
history={data?.metrics_history ?? null}
|
||||||
|
tab={bottomTab}
|
||||||
|
onTabChange={setBottomTab}
|
||||||
|
/>
|
||||||
<StepDetailSheet
|
<StepDetailSheet
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
stepKey={openCard?.key ?? null}
|
stepKey={openCard?.key ?? null}
|
||||||
|
runId={openCard?.runId ?? null}
|
||||||
stepTitle={openCard?.title}
|
stepTitle={openCard?.title}
|
||||||
stepStatus={openCard?.status}
|
stepStatus={openCard?.status}
|
||||||
open={openCard !== null}
|
open={openCard !== null}
|
||||||
@@ -774,96 +789,165 @@ function MiniStatus({ status }: { status: CardStatus }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegendBar() {
|
const BOTTOM_PANEL_DEFAULT_HEIGHT = 288;
|
||||||
const items = [
|
const BOTTOM_PANEL_MIN_HEIGHT = 120;
|
||||||
{
|
|
||||||
icon: BarChart3Icon,
|
function BottomPanel({
|
||||||
label: "Measure",
|
logs,
|
||||||
sub: "Quantify performance",
|
history,
|
||||||
color:
|
tab,
|
||||||
"bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-500/30",
|
onTabChange,
|
||||||
},
|
}: {
|
||||||
{
|
logs: LogLine[];
|
||||||
icon: DnaIcon,
|
history: MetricsHistory | null;
|
||||||
label: "Improve",
|
tab: "log" | "metrics";
|
||||||
sub: "Data, training, quality",
|
onTabChange: (next: "log" | "metrics") => void;
|
||||||
color:
|
}) {
|
||||||
"bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/30",
|
const [height, setHeight] = useState(BOTTOM_PANEL_DEFAULT_HEIGHT);
|
||||||
},
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
{
|
const dragRef = useRef<{ startY: number; startH: number } | null>(null);
|
||||||
icon: MicroscopeIcon,
|
|
||||||
label: "Understand",
|
useEffect(() => {
|
||||||
sub: "Analyze & diagnose",
|
if (!isFullscreen) return;
|
||||||
color:
|
const onKey = (e: KeyboardEvent) => {
|
||||||
"bg-violet-500/10 text-violet-700 dark:text-violet-300 border-violet-500/30",
|
if (e.key === "Escape") setIsFullscreen(false);
|
||||||
},
|
};
|
||||||
{
|
window.addEventListener("keydown", onKey);
|
||||||
icon: RefreshCwIcon,
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
label: "Iterate",
|
}, [isFullscreen]);
|
||||||
sub: "Repeat & grow",
|
|
||||||
color:
|
const onHandlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
"bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/30",
|
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 (
|
return (
|
||||||
<div className="flex shrink-0 items-center gap-4 border-t bg-background/80 px-6 py-2.5 overflow-x-auto">
|
<div
|
||||||
{items.map(({ icon: Icon, label, sub, color }) => (
|
className={cn(
|
||||||
<div key={label} className="flex items-center gap-2 shrink-0">
|
"flex flex-col bg-zinc-950",
|
||||||
<span
|
isFullscreen
|
||||||
className={cn(
|
? "absolute inset-0 z-50 border-t"
|
||||||
"flex size-7 items-center justify-center rounded-full border",
|
: "shrink-0 border-t",
|
||||||
color,
|
)}
|
||||||
)}
|
style={isFullscreen ? undefined : { height: `${height}px` }}
|
||||||
>
|
>
|
||||||
<Icon className="size-3.5" />
|
{!isFullscreen && (
|
||||||
</span>
|
<div
|
||||||
<div className="leading-tight">
|
onPointerDown={onHandlePointerDown}
|
||||||
<div className="text-[11px] font-semibold text-foreground">
|
onPointerMove={onHandlePointerMove}
|
||||||
{label}
|
onPointerUp={onHandlePointerUp}
|
||||||
</div>
|
onPointerCancel={onHandlePointerUp}
|
||||||
<div className="text-[10px] text-muted-foreground">{sub}</div>
|
className="group h-1.5 shrink-0 cursor-ns-resize bg-zinc-900 hover:bg-sky-500/40 active:bg-sky-500/60"
|
||||||
</div>
|
title="拖动调整高度"
|
||||||
|
>
|
||||||
|
<div className="mx-auto mt-[2px] h-[2px] w-10 rounded-full bg-zinc-700 group-hover:bg-sky-400" />
|
||||||
</div>
|
</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>
|
</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);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
|
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
|
||||||
}, [logs]);
|
}, [logs]);
|
||||||
return (
|
return (
|
||||||
<div className="flex h-72 shrink-0 flex-col border-t bg-zinc-950 px-5 py-3">
|
<div
|
||||||
<div className="mb-2 flex shrink-0 items-center justify-between">
|
ref={ref}
|
||||||
<span className="font-semibold text-xs uppercase tracking-[0.18em] text-zinc-300">
|
className="h-full min-h-0 overflow-y-auto font-mono text-[12px] text-zinc-100 leading-6"
|
||||||
Live Log
|
>
|
||||||
</span>
|
{logs.length === 0 ? (
|
||||||
<button
|
<div className="text-zinc-500">暂无日志</div>
|
||||||
type="button"
|
) : (
|
||||||
className="inline-flex items-center gap-1 text-[11px] text-zinc-400 transition-colors hover:text-zinc-200"
|
logs.map((line, idx) => (
|
||||||
>
|
<div key={idx} className="flex gap-3">
|
||||||
查看全部
|
<span className="text-zinc-500">{line.ts}</span>
|
||||||
<ExternalLinkIcon className="size-3" />
|
<span className="text-emerald-400">[iter={line.iter}]</span>
|
||||||
</button>
|
<span>{line.text}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
))
|
||||||
ref={ref}
|
)}
|
||||||
className="min-h-0 flex-1 overflow-y-auto font-mono text-[12px] leading-6 text-zinc-100"
|
|
||||||
>
|
|
||||||
{logs.length === 0 ? (
|
|
||||||
<div className="text-zinc-500">暂无日志</div>
|
|
||||||
) : (
|
|
||||||
logs.map((line, idx) => (
|
|
||||||
<div key={idx} className="flex gap-3">
|
|
||||||
<span className="text-zinc-500">{line.ts}</span>
|
|
||||||
<span className="text-emerald-400">[iter={line.iter}]</span>
|
|
||||||
<span>{line.text}</span>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,3 +87,19 @@ function normalizeSessionId(value: unknown) {
|
|||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
return trimmed || null;
|
return trimmed || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 记录"前端刚生成的 LOCALID"。AssistantWorkspace 的 draft cache effect 用它
|
||||||
|
// 区分:从 newTask 切到一个**新生成**的 LOCALID(草稿应继承)vs 切到一个
|
||||||
|
// **侧栏点击**的老 LOCALID(草稿不应继承)。
|
||||||
|
const freshLocalIds = new Set<string>();
|
||||||
|
|
||||||
|
export function markFreshLocalId(sessionId: string) {
|
||||||
|
freshLocalIds.add(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumeFreshLocalId(sessionId: string | null): boolean {
|
||||||
|
if (!sessionId) return false;
|
||||||
|
if (!freshLocalIds.has(sessionId)) return false;
|
||||||
|
freshLocalIds.delete(sessionId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|||||||
Generated
+372
@@ -27,6 +27,7 @@
|
|||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"recharts": "^2.15.4",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
@@ -4691,6 +4692,69 @@
|
|||||||
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
|
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/debug": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.13",
|
"version": "4.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
||||||
@@ -4963,6 +5027,127 @@
|
|||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -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": {
|
"node_modules/decode-named-character-reference": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
||||||
@@ -5025,6 +5216,16 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-helpers": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.8.7",
|
||||||
|
"csstype": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.21.0",
|
"version": "5.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
|
||||||
@@ -5058,6 +5259,12 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "4.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||||
|
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/eventsource-parser": {
|
"node_modules/eventsource-parser": {
|
||||||
"version": "3.0.8",
|
"version": "3.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz",
|
||||||
@@ -5071,6 +5278,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
|
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-equals": {
|
||||||
|
"version": "5.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
||||||
|
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-nonce": {
|
"node_modules/get-nonce": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||||
@@ -5137,6 +5353,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
||||||
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="
|
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-alphabetical": {
|
"node_modules/is-alphabetical": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
||||||
@@ -5197,6 +5422,12 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/js-tokens": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/json-schema": {
|
"node_modules/json-schema": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
|
||||||
@@ -5451,6 +5682,12 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/longest-streak": {
|
"node_modules/longest-streak": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
|
||||||
@@ -5460,6 +5697,18 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/loose-envify": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"loose-envify": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lucide-react": {
|
"node_modules/lucide-react": {
|
||||||
"version": "1.14.0",
|
"version": "1.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz",
|
||||||
@@ -6394,6 +6643,15 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-assign": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parse-entities": {
|
"node_modules/parse-entities": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
|
||||||
@@ -6482,6 +6740,23 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prop-types": {
|
||||||
|
"version": "15.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"loose-envify": "^1.4.0",
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"react-is": "^16.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prop-types/node_modules/react-is": {
|
||||||
|
"version": "16.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/property-information": {
|
"node_modules/property-information": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
|
||||||
@@ -6687,6 +6962,12 @@
|
|||||||
"react": "^19.2.5"
|
"react": "^19.2.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-is": {
|
||||||
|
"version": "18.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||||
|
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/react-markdown": {
|
"node_modules/react-markdown": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
||||||
@@ -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": {
|
"node_modules/react-style-singleton": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||||
@@ -6795,6 +7091,54 @@
|
|||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-transition-group": {
|
||||||
|
"version": "4.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||||
|
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.5.5",
|
||||||
|
"dom-helpers": "^5.0.1",
|
||||||
|
"loose-envify": "^1.4.0",
|
||||||
|
"prop-types": "^15.6.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.6.0",
|
||||||
|
"react-dom": ">=16.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "2.15.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
|
||||||
|
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"eventemitter3": "^4.0.1",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"react-is": "^18.3.1",
|
||||||
|
"react-smooth": "^4.0.4",
|
||||||
|
"recharts-scale": "^0.4.4",
|
||||||
|
"tiny-invariant": "^1.3.1",
|
||||||
|
"victory-vendor": "^36.6.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/recharts-scale": {
|
||||||
|
"version": "0.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
|
||||||
|
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"decimal.js-light": "^2.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/remark-gfm": {
|
"node_modules/remark-gfm": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
|
||||||
@@ -7051,6 +7395,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/trim-lines": {
|
"node_modules/trim-lines": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||||
@@ -7322,6 +7672,28 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "36.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||||
|
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"recharts": "^2.15.4",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
|||||||
@@ -162,8 +162,8 @@ def get_doing_tasks_section() -> str:
|
|||||||
'不要为了单次操作创建 helper 或抽象。优先使用能完整解决问题的最简单实现。',
|
'不要为了单次操作创建 helper 或抽象。优先使用能完整解决问题的最简单实现。',
|
||||||
'除非确实需要新文件,否则优先编辑现有文件。',
|
'除非确实需要新文件,否则优先编辑现有文件。',
|
||||||
'对于文件解析、表格转换、JSON 或 JSONL 处理、日志分析、批量校验、数据抽样等任务,如果小型 Python 脚本比手工文本处理更可靠,可以编写小型 Python 脚本。',
|
'对于文件解析、表格转换、JSON 或 JSONL 处理、日志分析、批量校验、数据抽样等任务,如果小型 Python 脚本比手工文本处理更可靠,可以编写小型 Python 脚本。',
|
||||||
'小脚本应短小、可读,并明确输入和输出。需要执行 Python 代码或 Python 脚本时,使用 python_exec;不要通过 bash 执行 python、python3 或 .venv/bin/python。',
|
'小脚本应短小、可读,并明确输入和输出。需要执行 Python 时,把代码写到 session/scratchpad 下的 .py 文件,再用 bash 跑 `python3 -u <script> 2>&1 | tee <log>`;`-u` 关 stdout 缓冲、`tee` 让进度行实时落盘,超时被 kill 时仍能 tail 日志看到死在哪。不要把多行 Python 代码以 `-c` 字符串方式塞进 bash command。',
|
||||||
'一次性 Python 分析优先直接传给 python_exec.code,不要为了临时分析创建项目文件。',
|
'一次性 Python 分析也走脚本文件 + bash,不要为了临时分析在项目根目录创建脚本,scratchpad 是默认临时区。',
|
||||||
'如果确实需要临时脚本、缓存或中间产物,必须写入当前 session/scratchpad;交付产物必须优先写入当前 session/output。',
|
'如果确实需要临时脚本、缓存或中间产物,必须写入当前 session/scratchpad;交付产物必须优先写入当前 session/output。',
|
||||||
'只有用户明确要求长期复用或该脚本属于产品代码时,才把 Python 脚本加入项目源码目录。',
|
'只有用户明确要求长期复用或该脚本属于产品代码时,才把 Python 脚本加入项目源码目录。',
|
||||||
'当事情失败时,先诊断原因再改变方向。不要对同一个失败动作反复循环。',
|
'当事情失败时,先诊断原因再改变方向。不要对同一个失败动作反复循环。',
|
||||||
|
|||||||
@@ -1620,9 +1620,9 @@ class LocalCodingAgent:
|
|||||||
'请回复“继续”,我会要求模型重新生成合法 JSON 参数;'
|
'请回复“继续”,我会要求模型重新生成合法 JSON 参数;'
|
||||||
'如果是 write_file 写短文本/Markdown,应改用 content_lines;'
|
'如果是 write_file 写短文本/Markdown,应改用 content_lines;'
|
||||||
'如果是很小的 JSON/JSONL/CSV,可用 json_content、jsonl_records 或 csv_rows;'
|
'如果是很小的 JSON/JSONL/CSV,可用 json_content、jsonl_records 或 csv_rows;'
|
||||||
'如果是脚本、长文本、大 JSON、JSONL、CSV 或生成数据,应直接改用 python_exec 写文件;'
|
'如果是脚本、长文本、大 JSON、JSONL、CSV 或生成数据,应改用 bash 配合 quoted heredoc 写文件,'
|
||||||
'如果是生成/转换结构化数据,应优先调用对应 skill 脚本或 python_exec,'
|
'或先 write_file 落地一个小 .py 再用 bash 跑;'
|
||||||
'不要把大段内容直接塞进工具参数;bash 仅作为最后兜底。'
|
'不要把大段内容直接塞进工具参数。'
|
||||||
) from exc
|
) from exc
|
||||||
if not isinstance(arguments, dict):
|
if not isinstance(arguments, dict):
|
||||||
raise OpenAICompatError(
|
raise OpenAICompatError(
|
||||||
|
|||||||
@@ -10,51 +10,6 @@ def build_execution_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool
|
|||||||
"""构建本地命令、Python 执行与短等待工具声明。"""
|
"""构建本地命令、Python 执行与短等待工具声明。"""
|
||||||
|
|
||||||
return [
|
return [
|
||||||
AgentTool(
|
|
||||||
name='python_exec',
|
|
||||||
description=(
|
|
||||||
'优先用本工具执行小型 Python 代码或项目内 Python 脚本,用于结构化文件分析、'
|
|
||||||
'JSON/JSONL 处理、批量校验、数据抽样和快速计算。默认使用当前用户独立 Python venv;'
|
|
||||||
'不要用 bash 运行 python/python3/.venv/bin/python。不要用本工具安装依赖。'
|
|
||||||
'缺包时应向用户确认后再处理依赖。一次性分析优先传 code;项目或 skill 内已有脚本优先传 script_path,'
|
|
||||||
'不要在 code 里用 subprocess 二次调用 Python 脚本。如需写临时文件,'
|
|
||||||
'必须写入环境变量 PYTHON_EXEC_SCRATCHPAD 指向的会话隔离目录,'
|
|
||||||
'不要在项目根目录创建临时 .py 脚本。'
|
|
||||||
),
|
|
||||||
parameters={
|
|
||||||
'type': 'object',
|
|
||||||
'properties': {
|
|
||||||
'code': {
|
|
||||||
'type': 'string',
|
|
||||||
'description': '要通过 python -c 执行的 Python 代码。code 和 script_path 必须二选一;一次性分析优先使用 code。',
|
|
||||||
},
|
|
||||||
'script_path': {
|
|
||||||
'type': 'string',
|
|
||||||
'description': '工作区内已有、需要长期复用的 Python 脚本路径。code 和 script_path 必须二选一;不要为一次性分析在项目根目录新建脚本。',
|
|
||||||
},
|
|
||||||
'args': {
|
|
||||||
'type': 'array',
|
|
||||||
'items': {'type': 'string'},
|
|
||||||
'description': '传给脚本的命令行参数。仅在 script_path 模式下使用。',
|
|
||||||
},
|
|
||||||
'stdin': {
|
|
||||||
'type': 'string',
|
|
||||||
'description': '可选标准输入内容。',
|
|
||||||
},
|
|
||||||
'timeout_seconds': {
|
|
||||||
'type': 'number',
|
|
||||||
'minimum': 1,
|
|
||||||
'description': '可选超时时间,默认 30 秒。读大 CSV/JSONL、pandas 分析、批量校验、训练评测脚本等可能 >30s 的任务必须显式传值(常规 120-300、训练/长跑 600+)。',
|
|
||||||
},
|
|
||||||
'max_output_chars': {
|
|
||||||
'type': 'integer',
|
|
||||||
'minimum': 100,
|
|
||||||
'description': '可选输出截断长度,默认使用当前会话输出限制。',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
handler=resolve_handler(handlers, 'python_exec', 'execution'),
|
|
||||||
),
|
|
||||||
AgentTool(
|
AgentTool(
|
||||||
name='python_package',
|
name='python_package',
|
||||||
description=(
|
description=(
|
||||||
@@ -94,10 +49,13 @@ def build_execution_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool
|
|||||||
AgentTool(
|
AgentTool(
|
||||||
name='bash',
|
name='bash',
|
||||||
description=(
|
description=(
|
||||||
'运行真实 shell 命令,例如系统命令、进程控制、git 只读检查或用户已确认的依赖安装。'
|
'运行真实 shell 命令,包括系统命令、进程控制、git 只读检查、用户已确认的依赖安装,'
|
||||||
'不要用 bash 执行 python、python3、pip 或 .venv/bin/python;结构化文件分析、'
|
'以及所有 Python 脚本执行。'
|
||||||
'JSON/JSONL 处理、批量校验、数据抽样和快速计算必须优先使用 python_exec。'
|
'执行 Python 时,把代码先写到 scratchpad 下的 .py 文件,'
|
||||||
'Python 包安装必须优先使用 python_package。'
|
'再用本工具跑 `python3 -u <script> 2>&1 | tee <log>`;'
|
||||||
|
'`-u` 关掉 stdout 缓冲,`tee` 让进度行实时落盘,超时被 kill 时仍能 `tail` 日志看到死在哪个 phase。'
|
||||||
|
'不要把多行 Python 代码以 `-c` 字符串方式塞进 command。'
|
||||||
|
'Python 包安装必须优先使用 python_package;不要通过 bash 执行 pip。'
|
||||||
'需要等待远端长任务(>30s 的训练、文件落盘、外部 API 回调)时,'
|
'需要等待远端长任务(>30s 的训练、文件落盘、外部 API 回调)时,'
|
||||||
'设置 run_in_background=true 让命令在后台 detach 跑、立刻返回 task_id;'
|
'设置 run_in_background=true 让命令在后台 detach 跑、立刻返回 task_id;'
|
||||||
'默认 wait_for_completion=true 时进程结束后会自动起新一轮把结果送回。'
|
'默认 wait_for_completion=true 时进程结束后会自动起新一轮把结果送回。'
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ def build_file_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool]:
|
|||||||
description=(
|
description=(
|
||||||
'Write or append a SMALL UTF-8 file inside the workspace. Creates parent directories when needed. '
|
'Write or append a SMALL UTF-8 file inside the workspace. Creates parent directories when needed. '
|
||||||
'Use this for short notes, lightweight markdown, and small config files. '
|
'Use this for short notes, lightweight markdown, and small config files. '
|
||||||
'For Python scripts, JSONL, large JSON/CSV, generated datasets, or quote-heavy/multi-line content, prefer python_exec '
|
'For Python scripts, JSONL, large JSON/CSV, generated datasets, or quote-heavy/multi-line content, prefer bash with a quoted heredoc '
|
||||||
'to write the file with pathlib/json/csv; do not pack large content into tool arguments.'
|
"(e.g. `cat > scratchpad/foo.py <<'EOF' ... EOF`); do not pack large content into tool arguments."
|
||||||
),
|
),
|
||||||
parameters={
|
parameters={
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
@@ -51,7 +51,7 @@ def build_file_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool]:
|
|||||||
'content_lines': {
|
'content_lines': {
|
||||||
'type': 'array',
|
'type': 'array',
|
||||||
'items': {'type': 'string'},
|
'items': {'type': 'string'},
|
||||||
'description': 'Lines to join with "\\n"; use only for short text/markdown or small scripts. For long scripts use python_exec to create the file.',
|
'description': 'Lines to join with "\\n"; use only for short text/markdown or small scripts. For long scripts use bash with a quoted heredoc to create the file.',
|
||||||
},
|
},
|
||||||
'content': {
|
'content': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
@@ -63,12 +63,12 @@ def build_file_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool]:
|
|||||||
},
|
},
|
||||||
'json_content': {
|
'json_content': {
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
'description': 'Small JSON value to serialize with ensure_ascii=false and indent=2. For large JSON use python_exec streaming write.',
|
'description': 'Small JSON value to serialize with ensure_ascii=false and indent=2. For large JSON write a small Python script to scratchpad and run it via bash.',
|
||||||
},
|
},
|
||||||
'jsonl_records': {
|
'jsonl_records': {
|
||||||
'type': 'array',
|
'type': 'array',
|
||||||
'items': {'type': 'object'},
|
'items': {'type': 'object'},
|
||||||
'description': 'Small list of objects to serialize as compact JSONL. For more than a few dozen records or generated datasets use python_exec.',
|
'description': 'Small list of objects to serialize as compact JSONL. For more than a few dozen records or generated datasets, write a Python script to scratchpad and run it via bash.',
|
||||||
},
|
},
|
||||||
'csv_headers': {
|
'csv_headers': {
|
||||||
'type': 'array',
|
'type': 'array',
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ def default_tool_registry() -> dict[str, AgentTool]:
|
|||||||
'grep_search': _grep_search,
|
'grep_search': _grep_search,
|
||||||
}),
|
}),
|
||||||
*build_execution_tools({
|
*build_execution_tools({
|
||||||
'python_exec': _run_python_exec,
|
|
||||||
'python_package': _run_python_package,
|
'python_package': _run_python_package,
|
||||||
'bash': _run_bash,
|
'bash': _run_bash,
|
||||||
'bash_status': _run_bash_status,
|
'bash_status': _run_bash_status,
|
||||||
|
|||||||
Reference in New Issue
Block a user