Zin299 commited on
Commit
4a1cf15
·
1 Parent(s): 84f3771

Deploy v4.6.9 manual task center

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +1 -1
  2. VERSION +1 -1
  3. routes/manual_tasks.py +66 -0
  4. services/manual_tasks.py +109 -0
  5. static/js/app.js +53 -6
  6. tests/test_api_entry_preflight_v456.py +1 -1
  7. tests/test_chat_lifecycle_progress_v393.py +1 -1
  8. tests/test_clarification_ui_v454.py +1 -1
  9. tests/test_classification_cleanup_v466.py +1 -1
  10. tests/test_context_and_multitask_finalize_v451.py +1 -1
  11. tests/test_download_host_authorization_v460.py +1 -1
  12. tests/test_download_opfs_fallback_v459.py +1 -1
  13. tests/test_download_progress_v391.py +1 -1
  14. tests/test_download_resilience_v422.py +1 -1
  15. tests/test_frontend_preflight_v457.py +1 -1
  16. tests/test_history_sidebar_v373.py +1 -1
  17. tests/test_large_download_jobs_v400.py +1 -1
  18. tests/test_manual_tasks_v469.py +30 -0
  19. tests/test_merged_health_v433.py +1 -1
  20. tests/test_multitask_observability_v442.py +1 -1
  21. tests/test_multitask_sse_finalize_v452.py +1 -1
  22. tests/test_noaa_ramldb_roles_v465.py +1 -1
  23. tests/test_ocean_batch_downloads_v420.py +1 -1
  24. tests/test_ocean_batch_routing_v421.py +1 -1
  25. tests/test_ocean_batch_v410.py +1 -1
  26. tests/test_ocean_date_default_v453.py +1 -1
  27. tests/test_ocean_download_url_base_v462.py +1 -1
  28. tests/test_p0_finish_v380.py +1 -1
  29. tests/test_p0_phase2_v371.py +1 -1
  30. tests/test_p0_phase3_v372.py +1 -1
  31. tests/test_platform_reliability_v450.py +1 -1
  32. tests/test_quality_center_v362.py +1 -1
  33. tests/test_quality_trust_v361.py +1 -1
  34. tests/test_reference_cleanup_v468.py +1 -1
  35. tests/test_release_stability.py +1 -1
  36. tests/test_resumable_batch_download_v458.py +1 -1
  37. tests/test_route_region_clarification_v455.py +1 -1
  38. tests/test_runtime_hotfix_v376.py +1 -1
  39. tests/test_sidebar_collapse_v374.py +1 -1
  40. tests/test_sidebar_partial_collapse_v375.py +1 -1
  41. tests/test_smart_progress_avatar_sync_v441.py +1 -1
  42. tests/test_source_mapping_roles_v464.py +1 -1
  43. tests/test_squid_finalize_v467.py +1 -1
  44. tests/test_sse_heartbeat_v401.py +1 -1
  45. tests/test_ui_avatar_multitask_v440.py +1 -1
  46. tests/test_unclassified_inventory_v463.py +1 -1
  47. tests/test_unified_download_manager_v392.py +1 -1
  48. tests/test_unified_query_v390.py +1 -1
  49. tests/test_upstream_refresh_retry_v461.py +1 -1
  50. tests/test_v381_navigation_ocean_intent.py +1 -1
README.md CHANGED
@@ -10,7 +10,7 @@ pinned: false
10
 
11
  # Global Marine Foundation Data Agent
12
 
13
- Current UI release: **v4.6.8**.
14
 
15
 
16
  ## v3.4.0 稳定性重构(第一阶段)
 
10
 
11
  # Global Marine Foundation Data Agent
12
 
13
+ Current UI release: **v4.6.9**.
14
 
15
 
16
  ## v3.4.0 稳定性重构(第一阶段)
VERSION CHANGED
@@ -1 +1 @@
1
- 4.6.8
 
1
+ 4.6.9
routes/manual_tasks.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from fastapi import HTTPException, Request
3
+
4
+
5
+ def _inject(context):
6
+ globals().update(context)
7
+
8
+
9
+ async def create_manual_task(request:Request):
10
+ try:
11
+ body=await request.json()
12
+ except Exception:
13
+ raise HTTPException(400,"invalid json")
14
+ uid,_=await resolve_request_user(request,str(body.get("user_id") or ""))
15
+ try:
16
+ task=MANUAL_TASK_STORE.create(
17
+ uid,
18
+ body.get("title"),
19
+ body.get("description") or "",
20
+ body.get("status") or "pending",
21
+ )
22
+ except ValueError as exc:
23
+ raise HTTPException(400,str(exc))
24
+ try:
25
+ await safe_memory_event(uid,"manual_task_created",{"task_id":task["task_id"],"title":task["title"],"status":task["status"]})
26
+ except Exception:
27
+ pass
28
+ return {"status":"ok","task":task,"storage":MANUAL_TASK_STORE.storage_mode}
29
+
30
+
31
+ async def update_manual_task(task_id:str,request:Request):
32
+ try:
33
+ body=await request.json()
34
+ except Exception:
35
+ raise HTTPException(400,"invalid json")
36
+ uid,_=await resolve_request_user(request,str(body.get("user_id") or ""))
37
+ try:
38
+ task=MANUAL_TASK_STORE.update(uid,task_id,body)
39
+ except ValueError as exc:
40
+ raise HTTPException(400,str(exc))
41
+ if not task:
42
+ raise HTTPException(404,"manual task not found")
43
+ try:
44
+ await safe_memory_event(uid,"manual_task_updated",{"task_id":task_id,"status":task.get("status")})
45
+ except Exception:
46
+ pass
47
+ return {"status":"ok","task":task,"storage":MANUAL_TASK_STORE.storage_mode}
48
+
49
+
50
+ async def delete_manual_task(task_id:str,request:Request):
51
+ supplied=str(request.query_params.get("user_id") or "").strip()
52
+ uid,_=await resolve_request_user(request,supplied)
53
+ if not MANUAL_TASK_STORE.delete(uid,task_id):
54
+ raise HTTPException(404,"manual task not found")
55
+ try:
56
+ await safe_memory_event(uid,"manual_task_deleted",{"task_id":task_id})
57
+ except Exception:
58
+ pass
59
+ return {"status":"ok","task_id":task_id}
60
+
61
+
62
+ def register_manual_task_routes(app,context):
63
+ _inject(context)
64
+ app.add_api_route("/api/sidebar/tasks/manual",create_manual_task,methods=["POST"])
65
+ app.add_api_route("/api/sidebar/tasks/manual/{task_id}",update_manual_task,methods=["PATCH"])
66
+ app.add_api_route("/api/sidebar/tasks/manual/{task_id}",delete_manual_task,methods=["DELETE"])
services/manual_tasks.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import secrets
5
+ import time
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from threading import RLock
9
+ from typing import Any
10
+
11
+
12
+ class ManualTaskStore:
13
+ VALID_STATUSES={"pending","running","completed","failed"}
14
+
15
+ def __init__(self, root:Path, valid_user_id, max_tasks:int=300):
16
+ self.root=Path(root)/"manual_tasks"
17
+ self.root.mkdir(parents=True,exist_ok=True)
18
+ self.valid_user_id=valid_user_id
19
+ self.max_tasks=max(20,int(max_tasks))
20
+ self.lock=RLock()
21
+
22
+ @property
23
+ def storage_mode(self)->str:
24
+ return "persistent" if str(self.root).startswith("/data/") else "server-session"
25
+
26
+ def _file(self,user_id:str)->Path:
27
+ if not self.valid_user_id(user_id):
28
+ raise ValueError("invalid user_id")
29
+ return self.root/f"{user_id}.json"
30
+
31
+ def _read(self,user_id:str)->list[dict[str,Any]]:
32
+ path=self._file(user_id)
33
+ try:
34
+ raw=json.loads(path.read_text(encoding="utf-8"))
35
+ return raw if isinstance(raw,list) else []
36
+ except Exception:
37
+ return []
38
+
39
+ def _write(self,user_id:str,tasks:list[dict[str,Any]])->None:
40
+ path=self._file(user_id)
41
+ tmp=path.with_suffix(".tmp")
42
+ tmp.write_text(json.dumps(tasks[:self.max_tasks],ensure_ascii=False,indent=2),encoding="utf-8")
43
+ tmp.replace(path)
44
+
45
+ def _clean_text(self,value:Any,limit:int)->str:
46
+ return " ".join(str(value or "").replace("\x00","").split())[:limit]
47
+
48
+ def _normalize_status(self,value:Any)->str:
49
+ status=str(value or "pending").strip().lower()
50
+ return status if status in self.VALID_STATUSES else "pending"
51
+
52
+ def list_user(self,user_id:str,limit:int=200)->list[dict[str,Any]]:
53
+ with self.lock:
54
+ tasks=self._read(user_id)
55
+ tasks.sort(key=lambda x:str(x.get("updated_at") or x.get("created_at") or ""),reverse=True)
56
+ return tasks[:max(1,min(int(limit or 200),self.max_tasks))]
57
+
58
+ def create(self,user_id:str,title:str,description:str="",status:str="pending")->dict[str,Any]:
59
+ now=datetime.now().astimezone().isoformat(timespec="seconds")
60
+ task={
61
+ "kind":"manual",
62
+ "task_id":"manual_"+secrets.token_hex(8),
63
+ "user_id":user_id,
64
+ "title":self._clean_text(title,160),
65
+ "description":self._clean_text(description,4000),
66
+ "status":self._normalize_status(status),
67
+ "source":"manual",
68
+ "created_at":now,
69
+ "updated_at":now,
70
+ }
71
+ if not task["title"]:
72
+ raise ValueError("title is required")
73
+ with self.lock:
74
+ tasks=self._read(user_id)
75
+ tasks=[task]+[x for x in tasks if x.get("task_id")!=task["task_id"]]
76
+ self._write(user_id,tasks)
77
+ return task
78
+
79
+ def update(self,user_id:str,task_id:str,patch:dict[str,Any])->dict[str,Any]|None:
80
+ with self.lock:
81
+ tasks=self._read(user_id)
82
+ target=None
83
+ for task in tasks:
84
+ if str(task.get("task_id") or "")==task_id:
85
+ target=task
86
+ break
87
+ if target is None:
88
+ return None
89
+ if "title" in patch:
90
+ title=self._clean_text(patch.get("title"),160)
91
+ if not title:
92
+ raise ValueError("title is required")
93
+ target["title"]=title
94
+ if "description" in patch:
95
+ target["description"]=self._clean_text(patch.get("description"),4000)
96
+ if "status" in patch:
97
+ target["status"]=self._normalize_status(patch.get("status"))
98
+ target["updated_at"]=datetime.now().astimezone().isoformat(timespec="seconds")
99
+ self._write(user_id,tasks)
100
+ return dict(target)
101
+
102
+ def delete(self,user_id:str,task_id:str)->bool:
103
+ with self.lock:
104
+ tasks=self._read(user_id)
105
+ kept=[x for x in tasks if str(x.get("task_id") or "")!=task_id]
106
+ if len(kept)==len(tasks):
107
+ return False
108
+ self._write(user_id,kept)
109
+ return True
static/js/app.js CHANGED
@@ -1087,8 +1087,8 @@ async function renderServices(){
1087
 
1088
  const TASK_OPERATION_ZH={quality_check:"数据质量检查",marine_export:"海洋数据导出",marine_query:"海洋数据查询",marine_subset:"海洋区域裁剪",fisheries_query:"渔业数据查询",task:"数据处理任务"};
1089
  function taskOperationLabel(v){return TASK_OPERATION_ZH[String(v||"")]||String(v||"数据处理任务")}
1090
- function taskStatusGroup(v){const s=String(v||"");if(/completed|done|success|成功|完成/i.test(s))return "completed";if(/failed|error|失败|异常/i.test(s))return "failed";return "running"}
1091
- function taskStatusLabel(v){const g=taskStatusGroup(v);return g==="completed"?"已完成":g==="failed"?"失败":"处理中"}
1092
  function taskProgressHtml(task){
1093
  const g=taskStatusGroup(task.status),explicit=Number(task.progress);
1094
  if(task.progress!==null&&task.progress!==undefined&&Number.isFinite(explicit))return '<div class="task-progress" title="进度 '+esc(explicit)+'%"><span style="width:'+Math.max(0,Math.min(100,explicit))+'%"></span></div><div class="row-meta">处理进度:'+esc(explicit)+'%</div>';
@@ -1119,21 +1119,68 @@ function assetContinuePrompt(asset){return '请分析这个已生成的数据资
1119
  function openAssetDetail(asset){
1120
  document.querySelector(".drawer-backdrop")?.remove();const wrap=document.createElement("div");wrap.className="drawer-backdrop";wrap.innerHTML='<aside class="detail-drawer" role="dialog" aria-modal="true" aria-label="数据资产详情"><div class="drawer-head"><div><h2>'+esc(asset.name||"未命名资产")+'</h2><p>'+esc(taskOperationLabel(asset.operation))+' · '+esc(taskStatusLabel(asset.status))+'</p></div><button class="drawer-close" aria-label="关闭">×</button></div><div class="task-summary"><div><span>资产编号</span><b>'+esc(asset.asset_id||"-")+'</b></div><div><span>文件大小</span><b>'+esc(formatBytes(asset.size_bytes))+'</b></div><div><span>文件类型</span><b>'+esc(asset.mime_type||"-")+'</b></div><div><span>创建时间</span><b>'+esc(formatTime(asset.created_at))+'</b></div></div><div class="detail-section"><h3>资产来源</h3><div class="row-detail">来源:'+esc(asset.source||"未记录")+(asset.path?'<br>路径:'+esc(asset.path):'')+(asset.thread_id?'<br>关联会话:'+esc(asset.thread_id):'')+'</div></div><div class="card-actions">'+(asset.download_url?'<a class="download" href="'+esc(asset.download_url)+'" target="_blank" rel="noopener">↓ 下载资产</a>':'')+'<button class="view-btn primary asset-chat">在对话中分析</button></div></aside>';document.body.appendChild(wrap);const close=()=>wrap.remove();wrap.querySelector(".drawer-close").onclick=close;wrap.onclick=e=>{if(e.target===wrap)close()};wrap.querySelector(".asset-chat").onclick=()=>{close();goChat(assetContinuePrompt(asset))}
1121
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1122
  async function renderTasks(){
1123
- const root=$("workspaceInner");root.innerHTML='<div class="view-head"><div><h1>我的任务</h1><p>查看当前登录用户的数据处理任务和生成的数据资产。</p></div><button id="refreshTasks" class="view-btn">↻ 刷新</button></div><div class="loading">正在读取任务记录…</div>';$("refreshTasks").onclick=renderTasks;
1124
  try{
1125
- const data=await j("/api/sidebar/tasks?user_id="+encodeURIComponent(USER_ID)),tasks=data.tasks||[],assets=data.assets||[],localTasks=data.local_tasks||[],allStatus=[...tasks,...localTasks],running=allStatus.filter(x=>["running","queued","transferring","paused"].includes(String(x.status||"").toLowerCase())).length,failed=allStatus.filter(x=>["failed","partial"].includes(String(x.status||"").toLowerCase())).length,completed=allStatus.filter(x=>String(x.status||"").toLowerCase()==="completed").length;
1126
- let html='<div class="view-head"><div><h1>我的任务</h1><p>'+esc(data.enabled?"任务、结果和数据资产已按当前用户关联":"任务记录服务尚未启用")+'</p></div><button id="refreshTasks" class="view-btn">↻ 刷新</button></div><div class="summary-grid"><div class="summary-card"><strong>'+(tasks.length+localTasks.length)+'</strong><span>全部任务</span></div><div class="summary-card"><strong>'+running+'</strong><span>处理中</span></div><div class="summary-card"><strong>'+completed+'</strong><span>已完成</span></div><div class="summary-card"><strong>'+failed+'</strong><span>失败</span></div><div class="summary-card"><strong>'+assets.length+'</strong><span>数据资产</span></div></div>';
1127
  if(data.error)html+='<div class="service-row"><div class="row-detail">'+esc(data.error)+'</div></div>';
1128
  html+='<h3 style="font-size:14px;margin:20px 0 10px">持久化任务</h3><div id="persistentTaskList"></div><div class="filter-bar"><input id="taskSearch" type="search" placeholder="搜索任务类型、编号、来源或关联文件"><select id="taskStatus"><option value="all">全部状态</option><option value="running">处理中</option><option value="completed">已完成</option><option value="failed">失败</option></select><span id="taskCount" class="filter-count"></span></div><h3 style="font-size:14px;margin:20px 0 10px">处理任务</h3><div id="taskList"></div><div class="filter-bar" style="margin-top:20px"><input id="assetSearch" type="search" placeholder="搜索资产名称、类型或来源"><span id="assetCount" class="filter-count"></span></div><h3 style="font-size:14px;margin:12px 0 10px">数据资产</h3><div id="assetList"></div>';
1129
- root.innerHTML=html;$("refreshTasks").onclick=renderTasks;
1130
  const drawPersistent=()=>{
1131
  const box=$("persistentTaskList");if(!box)return;
1132
  if(!localTasks.length){box.innerHTML='<div class="empty-view"><b>暂无持久化任务</b>Ocean 批量导出和大文件下载任务会保存在这里。</div>';return}
1133
  box.innerHTML=localTasks.map((x,i)=>{
 
1134
  if(x.kind==="ocean_batch"){const c=x.counts||{},pct=Number(x.progress_percent||0);return '<div class="task-row" data-local-index="'+i+'"><div class="task-head"><b>Ocean 批量导出</b><span class="badge '+(x.status==="completed"?"ok":x.status==="partial"?"bad":"warn")+'">'+esc(x.status||"-")+'</span></div><div class="row-meta">'+esc(x.job_id||"")+' · '+esc(formatTime(x.updated_at||x.created_at))+(x.thread_id?' · 关联会话 '+esc(x.thread_id):'')+'</div><div class="row-detail">'+esc(x.prompt||"")+'</div><div class="ocean-batch-track"><span style="width:'+Math.min(100,pct)+'%"></span></div><div class="row-meta">'+pct.toFixed(0)+'% · 完成 '+esc(c.completed||0)+' / '+esc(x.total||0)+' · 失败 '+esc(c.failed||0)+'</div><div class="card-actions">'+(x.status==="paused"?'<button class="view-btn local-resume">继续任务</button>':'')+((c.failed||0)>0?'<button class="view-btn local-retry">仅重试失败项</button>':'')+'<button class="view-btn local-detail">查看子任务</button>'+(x.thread_id?'<button class="view-btn primary local-chat">回到关联对话</button>':'')+'</div><div class="local-subtasks" hidden></div></div>'}
1135
  return '<div class="task-row"><div class="task-head"><b>大文件下载</b><span class="badge '+(x.status==="completed"?"ok":x.status==="failed"?"bad":"warn")+'">'+esc(x.status||"-")+'</span></div><div class="row-meta">'+esc(x.job_id||"")+' · '+esc(x.filename||"download")+' · '+esc(formatTime(x.updated_at))+'</div><div class="row-detail">已传输 '+esc(formatBytes(x.transferred_bytes||0))+(x.total_bytes?' / '+esc(formatBytes(x.total_bytes)):'')+(x.resume_supported?' · 支持断点续传':'')+'</div></div>'
1136
  }).join('');
 
 
 
 
 
 
 
1137
  box.querySelectorAll('[data-local-index]').forEach(card=>{const x=localTasks[Number(card.dataset.localIndex)];
1138
  card.querySelector('.local-resume')?.addEventListener('click',async()=>{await j('/api/ocean/batch/'+encodeURIComponent(x.job_id)+'/resume',{method:'POST'});renderTasks()});
1139
  card.querySelector('.local-retry')?.addEventListener('click',async()=>{await j('/api/ocean/batch/'+encodeURIComponent(x.job_id)+'/retry',{method:'POST'});renderTasks()});
 
1087
 
1088
  const TASK_OPERATION_ZH={quality_check:"数据质量检查",marine_export:"海洋数据导出",marine_query:"海洋数据查询",marine_subset:"海洋区域裁剪",fisheries_query:"渔业数据查询",task:"数据处理任务"};
1089
  function taskOperationLabel(v){return TASK_OPERATION_ZH[String(v||"")]||String(v||"数据处理任务")}
1090
+ function taskStatusGroup(v){const s=String(v||"");if(/completed|done|success|成功|完成/i.test(s))return "completed";if(/failed|error|失败|异常/i.test(s))return "failed";if(/pending|todo|queued|待处理|等待/i.test(s))return "pending";return "running"}
1091
+ function taskStatusLabel(v){const g=taskStatusGroup(v);return g==="completed"?"已完成":g==="failed"?"失败":g==="pending"?"待处理":"处理中"}
1092
  function taskProgressHtml(task){
1093
  const g=taskStatusGroup(task.status),explicit=Number(task.progress);
1094
  if(task.progress!==null&&task.progress!==undefined&&Number.isFinite(explicit))return '<div class="task-progress" title="进度 '+esc(explicit)+'%"><span style="width:'+Math.max(0,Math.min(100,explicit))+'%"></span></div><div class="row-meta">处理进度:'+esc(explicit)+'%</div>';
 
1119
  function openAssetDetail(asset){
1120
  document.querySelector(".drawer-backdrop")?.remove();const wrap=document.createElement("div");wrap.className="drawer-backdrop";wrap.innerHTML='<aside class="detail-drawer" role="dialog" aria-modal="true" aria-label="数据资产详情"><div class="drawer-head"><div><h2>'+esc(asset.name||"未命名资产")+'</h2><p>'+esc(taskOperationLabel(asset.operation))+' · '+esc(taskStatusLabel(asset.status))+'</p></div><button class="drawer-close" aria-label="关闭">×</button></div><div class="task-summary"><div><span>资产编号</span><b>'+esc(asset.asset_id||"-")+'</b></div><div><span>文件大小</span><b>'+esc(formatBytes(asset.size_bytes))+'</b></div><div><span>文件类型</span><b>'+esc(asset.mime_type||"-")+'</b></div><div><span>创建时间</span><b>'+esc(formatTime(asset.created_at))+'</b></div></div><div class="detail-section"><h3>资产来源</h3><div class="row-detail">来源:'+esc(asset.source||"未记录")+(asset.path?'<br>路径:'+esc(asset.path):'')+(asset.thread_id?'<br>关联会话:'+esc(asset.thread_id):'')+'</div></div><div class="card-actions">'+(asset.download_url?'<a class="download" href="'+esc(asset.download_url)+'" target="_blank" rel="noopener">↓ 下载资产</a>':'')+'<button class="view-btn primary asset-chat">在对话中分析</button></div></aside>';document.body.appendChild(wrap);const close=()=>wrap.remove();wrap.querySelector(".drawer-close").onclick=close;wrap.onclick=e=>{if(e.target===wrap)close()};wrap.querySelector(".asset-chat").onclick=()=>{close();goChat(assetContinuePrompt(asset))}
1121
  }
1122
+ function openManualTaskEditor(task=null){
1123
+ document.querySelector(".drawer-backdrop")?.remove();
1124
+ const editing=!!task;
1125
+ const wrap=document.createElement("div");wrap.className="drawer-backdrop";
1126
+ wrap.innerHTML='<aside class="detail-drawer" role="dialog" aria-modal="true" aria-label="'+(editing?'编辑任务':'新建任务')+'"><div class="drawer-head"><div><h2>'+(editing?'编辑任务':'新建任务')+'</h2><p>手动任务会绑定当前账号并保存在任务中心。</p></div><button class="drawer-close" aria-label="关闭">×</button></div><div class="detail-tool"><label class="row-detail">任务名称</label><input id="manualTaskTitle" type="text" maxlength="160" placeholder="例如:跨设备同步测试" value="'+esc(task?.title||"")+'"><label class="row-detail" style="display:block;margin-top:12px">任务内容</label><textarea id="manualTaskDescription" rows="7" maxlength="4000" placeholder="写清楚要完成的事情、检查条件或后续动作。">'+esc(task?.description||"")+'</textarea><label class="row-detail" style="display:block;margin-top:12px">状态</label><select id="manualTaskStatus"><option value="pending">待处理</option><option value="running">处理中</option><option value="completed">已完成</option><option value="failed">失败</option></select></div><div class="card-actions"><button id="manualTaskSave" class="view-btn primary">'+(editing?'保存修改':'创建任务')+'</button><button class="view-btn manual-task-cancel">取消</button></div></aside>';
1127
+ document.body.appendChild(wrap);
1128
+ const close=()=>wrap.remove();
1129
+ wrap.querySelector(".drawer-close").onclick=close;
1130
+ wrap.querySelector(".manual-task-cancel").onclick=close;
1131
+ wrap.onclick=e=>{if(e.target===wrap)close()};
1132
+ const status=wrap.querySelector("#manualTaskStatus");status.value=task?.status||"pending";
1133
+ wrap.querySelector("#manualTaskSave").onclick=async()=>{
1134
+ const title=wrap.querySelector("#manualTaskTitle").value.trim();
1135
+ const description=wrap.querySelector("#manualTaskDescription").value.trim();
1136
+ if(!title){toast("请填写任务名称");wrap.querySelector("#manualTaskTitle").focus();return}
1137
+ const btn=wrap.querySelector("#manualTaskSave");btn.disabled=true;
1138
+ try{
1139
+ if(editing){
1140
+ await j("/api/sidebar/tasks/manual/"+encodeURIComponent(task.task_id),{method:"PATCH",body:JSON.stringify({user_id:USER_ID,title,description,status:status.value})});
1141
+ toast("任务已更新");
1142
+ }else{
1143
+ await j("/api/sidebar/tasks/manual",{method:"POST",body:JSON.stringify({user_id:USER_ID,title,description,status:status.value})});
1144
+ toast("任务已创建");
1145
+ }
1146
+ close();await renderTasks();
1147
+ }catch(err){toast("保存失败:"+(err.message||String(err)))}
1148
+ finally{btn.disabled=false}
1149
+ };
1150
+ setTimeout(()=>wrap.querySelector("#manualTaskTitle")?.focus(),0);
1151
+ }
1152
+
1153
+ async function deleteManualTask(task){
1154
+ if(!confirm('确认删除任务“'+(task.title||"未命名任务")+'”?'))return;
1155
+ try{
1156
+ await j("/api/sidebar/tasks/manual/"+encodeURIComponent(task.task_id)+"?user_id="+encodeURIComponent(USER_ID),{method:"DELETE"});
1157
+ toast("任务已删除");await renderTasks();
1158
+ }catch(err){toast("删除失败:"+(err.message||String(err)))}
1159
+ }
1160
+
1161
  async function renderTasks(){
1162
+ const root=$("workspaceInner");root.innerHTML='<div class="view-head"><div><h1>我的任务</h1><p>查看当前登录用户的数据处理任务和生成的数据资产。</p></div><div class="card-actions"><button id="newManualTask" class="view-btn primary">+ 新建任务</button><button id="refreshTasks" class="view-btn">↻ 刷新</button></div></div><div class="loading">正在读取任务记录…</div>';$("refreshTasks").onclick=renderTasks;$("newManualTask").onclick=()=>openManualTaskEditor();
1163
  try{
1164
+ const data=await j("/api/sidebar/tasks?user_id="+encodeURIComponent(USER_ID)),tasks=data.tasks||[],assets=data.assets||[],localTasks=data.local_tasks||[],allStatus=[...tasks,...localTasks],pending=allStatus.filter(x=>taskStatusGroup(x.status)==="pending").length,running=allStatus.filter(x=>taskStatusGroup(x.status)==="running").length,failed=allStatus.filter(x=>taskStatusGroup(x.status)==="failed").length,completed=allStatus.filter(x=>taskStatusGroup(x.status)==="completed").length;
1165
+ let html='<div class="view-head"><div><h1>我的任务</h1><p>'+esc(data.enabled?"任务、结果和数据资产已按当前用户关联":"任务记录服务尚未启用")+'</p></div><div class="card-actions"><button id="newManualTask" class="view-btn primary">+ 新建任务</button><button id="refreshTasks" class="view-btn">↻ 刷新</button></div></div><div class="summary-grid"><div class="summary-card"><strong>'+(tasks.length+localTasks.length)+'</strong><span>全部任务</span></div><div class="summary-card"><strong>'+pending+'</strong><span>待处理</span></div><div class="summary-card"><strong>'+running+'</strong><span>处理中</span></div><div class="summary-card"><strong>'+completed+'</strong><span>已完成</span></div><div class="summary-card"><strong>'+failed+'</strong><span>失败</span></div><div class="summary-card"><strong>'+assets.length+'</strong><span>数据资产</span></div></div><div class="detail-note">手动任务存储:'+esc(data.manual_task_storage==="persistent"?"账号服务器持久化(可跨设备)":"服务器会话存储(未挂��持久卷)")+'。</div>';
1166
  if(data.error)html+='<div class="service-row"><div class="row-detail">'+esc(data.error)+'</div></div>';
1167
  html+='<h3 style="font-size:14px;margin:20px 0 10px">持久化任务</h3><div id="persistentTaskList"></div><div class="filter-bar"><input id="taskSearch" type="search" placeholder="搜索任务类型、编号、来源或关联文件"><select id="taskStatus"><option value="all">全部状态</option><option value="running">处理中</option><option value="completed">已完成</option><option value="failed">失败</option></select><span id="taskCount" class="filter-count"></span></div><h3 style="font-size:14px;margin:20px 0 10px">处理任务</h3><div id="taskList"></div><div class="filter-bar" style="margin-top:20px"><input id="assetSearch" type="search" placeholder="搜索资产名称、类型或来源"><span id="assetCount" class="filter-count"></span></div><h3 style="font-size:14px;margin:12px 0 10px">数据资产</h3><div id="assetList"></div>';
1168
+ root.innerHTML=html;$("refreshTasks").onclick=renderTasks;$("newManualTask").onclick=()=>openManualTaskEditor();
1169
  const drawPersistent=()=>{
1170
  const box=$("persistentTaskList");if(!box)return;
1171
  if(!localTasks.length){box.innerHTML='<div class="empty-view"><b>暂无持久化任务</b>Ocean 批量导出和大文件下载任务会保存在这里。</div>';return}
1172
  box.innerHTML=localTasks.map((x,i)=>{
1173
+ if(x.kind==="manual"){const g=taskStatusGroup(x.status);return '<div class="task-row" data-manual-index="'+i+'"><div class="task-head"><b>'+esc(x.title||"手动任务")+'</b><span class="badge '+(g==="completed"?"ok":g==="failed"?"bad":"warn")+'">'+esc(taskStatusLabel(x.status))+'</span></div><div class="row-meta">'+esc(x.task_id||"")+' · 手动创建 · '+esc(formatTime(x.updated_at||x.created_at))+'</div><div class="row-detail">'+esc(x.description||"暂无任务说明")+'</div><div class="card-actions"><button class="view-btn primary manual-chat">在对话中继续</button><button class="view-btn manual-edit">编辑</button>'+(x.status!=="completed"?'<button class="view-btn manual-complete">标记完成</button>':'<button class="view-btn manual-reopen">重新打开</button>')+'<button class="view-btn danger manual-delete">删除</button></div></div>'}
1174
  if(x.kind==="ocean_batch"){const c=x.counts||{},pct=Number(x.progress_percent||0);return '<div class="task-row" data-local-index="'+i+'"><div class="task-head"><b>Ocean 批量导出</b><span class="badge '+(x.status==="completed"?"ok":x.status==="partial"?"bad":"warn")+'">'+esc(x.status||"-")+'</span></div><div class="row-meta">'+esc(x.job_id||"")+' · '+esc(formatTime(x.updated_at||x.created_at))+(x.thread_id?' · 关联会话 '+esc(x.thread_id):'')+'</div><div class="row-detail">'+esc(x.prompt||"")+'</div><div class="ocean-batch-track"><span style="width:'+Math.min(100,pct)+'%"></span></div><div class="row-meta">'+pct.toFixed(0)+'% · 完成 '+esc(c.completed||0)+' / '+esc(x.total||0)+' · 失败 '+esc(c.failed||0)+'</div><div class="card-actions">'+(x.status==="paused"?'<button class="view-btn local-resume">继续任务</button>':'')+((c.failed||0)>0?'<button class="view-btn local-retry">仅重试失败项</button>':'')+'<button class="view-btn local-detail">查看子任务</button>'+(x.thread_id?'<button class="view-btn primary local-chat">回到关联对话</button>':'')+'</div><div class="local-subtasks" hidden></div></div>'}
1175
  return '<div class="task-row"><div class="task-head"><b>大文件下载</b><span class="badge '+(x.status==="completed"?"ok":x.status==="failed"?"bad":"warn")+'">'+esc(x.status||"-")+'</span></div><div class="row-meta">'+esc(x.job_id||"")+' · '+esc(x.filename||"download")+' · '+esc(formatTime(x.updated_at))+'</div><div class="row-detail">已传输 '+esc(formatBytes(x.transferred_bytes||0))+(x.total_bytes?' / '+esc(formatBytes(x.total_bytes)):'')+(x.resume_supported?' · 支持断点续传':'')+'</div></div>'
1176
  }).join('');
1177
+ box.querySelectorAll('[data-manual-index]').forEach(card=>{const x=localTasks[Number(card.dataset.manualIndex)];
1178
+ card.querySelector('.manual-edit')?.addEventListener('click',()=>openManualTaskEditor(x));
1179
+ card.querySelector('.manual-delete')?.addEventListener('click',()=>deleteManualTask(x));
1180
+ card.querySelector('.manual-chat')?.addEventListener('click',()=>goChat('请帮我继续处理这个手动任务。\n任务:'+(x.title||'')+'\n说明:'+(x.description||'')+'\n当前状态:'+taskStatusLabel(x.status)));
1181
+ card.querySelector('.manual-complete')?.addEventListener('click',async()=>{await j('/api/sidebar/tasks/manual/'+encodeURIComponent(x.task_id),{method:'PATCH',body:JSON.stringify({user_id:USER_ID,status:'completed'})});toast('已标记完成');renderTasks()});
1182
+ card.querySelector('.manual-reopen')?.addEventListener('click',async()=>{await j('/api/sidebar/tasks/manual/'+encodeURIComponent(x.task_id),{method:'PATCH',body:JSON.stringify({user_id:USER_ID,status:'pending'})});toast('任务已重新打开');renderTasks()});
1183
+ });
1184
  box.querySelectorAll('[data-local-index]').forEach(card=>{const x=localTasks[Number(card.dataset.localIndex)];
1185
  card.querySelector('.local-resume')?.addEventListener('click',async()=>{await j('/api/ocean/batch/'+encodeURIComponent(x.job_id)+'/resume',{method:'POST'});renderTasks()});
1186
  card.querySelector('.local-retry')?.addEventListener('click',async()=>{await j('/api/ocean/batch/'+encodeURIComponent(x.job_id)+'/retry',{method:'POST'});renderTasks()});
tests/test_api_entry_preflight_v456.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
6
 
7
  def test_guard_is_before_auth_and_dispatch():
8
  chat=(ROOT/"routes"/"chat.py").read_text(encoding="utf-8")
 
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
6
 
7
  def test_guard_is_before_auth_and_dispatch():
8
  chat=(ROOT/"routes"/"chat.py").read_text(encoding="utf-8")
tests/test_chat_lifecycle_progress_v393.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_393():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
7
 
8
  def test_frontend_lifecycle_progress():
9
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_393():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
7
 
8
  def test_frontend_lifecycle_progress():
9
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
tests/test_clarification_ui_v454.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
6
 
7
  def test_missing_date_reuses_canonical_region_bbox():
8
  rt=(ROOT/"services"/"chat_runtime.py").read_text(encoding="utf-8")
 
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
6
 
7
  def test_missing_date_reuses_canonical_region_bbox():
8
  rt=(ROOT/"services"/"chat_runtime.py").read_text(encoding="utf-8")
tests/test_classification_cleanup_v466.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
6
 
7
  def test_seaaroundus_alias():
8
  py=(ROOT/"sidebar_catalog.py").read_text(encoding="utf-8")
 
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
6
 
7
  def test_seaaroundus_alias():
8
  py=(ROOT/"sidebar_catalog.py").read_text(encoding="utf-8")
tests/test_context_and_multitask_finalize_v451.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
6
 
7
  def test_new_query_slot_reset():
8
  rt=(ROOT/"services"/"chat_runtime.py").read_text(encoding="utf-8")
 
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
6
 
7
  def test_new_query_slot_reset():
8
  rt=(ROOT/"services"/"chat_runtime.py").read_text(encoding="utf-8")
tests/test_download_host_authorization_v460.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
6
 
7
  def test_batch_url_authorization_helper_exists():
8
  py=(ROOT/"routes"/"downloads.py").read_text(encoding="utf-8")
 
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
6
 
7
  def test_batch_url_authorization_helper_exists():
8
  py=(ROOT/"routes"/"downloads.py").read_text(encoding="utf-8")
tests/test_download_opfs_fallback_v459.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
6
 
7
  def test_opfs_is_probed_not_assumed():
8
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
 
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
6
 
7
  def test_opfs_is_probed_not_assumed():
8
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
tests/test_download_progress_v391.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_391():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
7
 
8
  def test_p1_label_removed():
9
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_391():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
7
 
8
  def test_p1_label_removed():
9
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
tests/test_download_resilience_v422.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
7
 
8
  def test_refresh_route_registered():
9
  t=(ROOT/"routes"/"ocean_batch.py").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
7
 
8
  def test_refresh_route_registered():
9
  t=(ROOT/"routes"/"ocean_batch.py").read_text(encoding="utf-8")
tests/test_frontend_preflight_v457.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
6
 
7
  def test_frontend_preflight_exists_before_progress():
8
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
 
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
6
 
7
  def test_frontend_preflight_exists_before_progress():
8
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
tests/test_history_sidebar_v373.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_373():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
7
 
8
  def test_project_history_backend():
9
  route=(ROOT/"routes"/"project_package.py").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_373():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
7
 
8
  def test_project_history_backend():
9
  route=(ROOT/"routes"/"project_package.py").read_text(encoding="utf-8")
tests/test_large_download_jobs_v400.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_400():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
7
 
8
  def test_download_job_backend():
9
  route=(ROOT/"routes"/"downloads.py").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_400():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
7
 
8
  def test_download_job_backend():
9
  route=(ROOT/"routes"/"downloads.py").read_text(encoding="utf-8")
tests/test_manual_tasks_v469.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ ROOT=Path(__file__).resolve().parents[1]
3
+
4
+ def test_version():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
6
+
7
+ def test_manual_task_backend_exists():
8
+ service=(ROOT/"services/manual_tasks.py").read_text(encoding="utf-8")
9
+ routes=(ROOT/"routes/manual_tasks.py").read_text(encoding="utf-8")
10
+ assert "class ManualTaskStore" in service
11
+ assert "VALID_STATUSES" in service
12
+ assert "/api/sidebar/tasks/manual" in routes
13
+ assert 'methods=["POST"]' in routes
14
+ assert 'methods=["PATCH"]' in routes
15
+ assert 'methods=["DELETE"]' in routes
16
+
17
+ def test_ui_server_lists_manual_tasks():
18
+ py=(ROOT/"ui_server.py").read_text(encoding="utf-8")
19
+ assert "MANUAL_TASK_STORE.list_user" in py
20
+ assert '"kind":"manual"' in py
21
+ assert "register_manual_task_routes" in py
22
+
23
+ def test_frontend_new_task_controls():
24
+ js=(ROOT/"static/js/app.js").read_text(encoding="utf-8")
25
+ assert "+ 新建任务" in js
26
+ assert "openManualTaskEditor" in js
27
+ assert "标记完成" in js
28
+ assert "重新打开" in js
29
+ assert "manual-delete" in js
30
+ assert "账号服务器持久化(可跨设备)" in js
tests/test_merged_health_v433.py CHANGED
@@ -3,7 +3,7 @@ from services.ocean_batch import parse_ocean_batch_request
3
  ROOT=Path(__file__).resolve().parents[1]
4
 
5
  def test_merged_version():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
7
 
8
  def test_ocean_month_batch_survives_merge():
9
  p=parse_ocean_batch_request("查询 2002 年 8 月西北太平洋的 SST 数据,并导出为 CSV 文件供我下载")
 
3
  ROOT=Path(__file__).resolve().parents[1]
4
 
5
  def test_merged_version():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
7
 
8
  def test_ocean_month_batch_survives_merge():
9
  p=parse_ocean_batch_request("查询 2002 年 8 月西北太平洋的 SST 数据,并导出为 CSV 文件供我下载")
tests/test_multitask_observability_v442.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
6
 
7
  def test_frontend_sends_multitask_trace_headers():
8
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
 
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
6
 
7
  def test_frontend_sends_multitask_trace_headers():
8
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
tests/test_multitask_sse_finalize_v452.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
6
 
7
  def test_parallel_sse_uses_real_blank_line_separator():
8
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
 
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
6
 
7
  def test_parallel_sse_uses_real_blank_line_separator():
8
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
tests/test_noaa_ramldb_roles_v465.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
6
 
7
  def test_noaa_and_ramldb_aliases():
8
  py=(ROOT/"sidebar_catalog.py").read_text(encoding="utf-8")
 
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
6
 
7
  def test_noaa_and_ramldb_aliases():
8
  py=(ROOT/"sidebar_catalog.py").read_text(encoding="utf-8")
tests/test_ocean_batch_downloads_v420.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_420():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
7
 
8
  def test_manifest_has_filename_and_sizes():
9
  svc=(ROOT/"services"/"ocean_batch.py").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_420():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
7
 
8
  def test_manifest_has_filename_and_sizes():
9
  svc=(ROOT/"services"/"ocean_batch.py").read_text(encoding="utf-8")
tests/test_ocean_batch_routing_v421.py CHANGED
@@ -4,7 +4,7 @@ from services.ocean_batch import parse_ocean_batch_request
4
  ROOT=pathlib.Path(__file__).resolve().parents[1]
5
 
6
  def test_version_421():
7
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
8
 
9
  def test_exact_user_month_query_routes_to_batch():
10
  p=parse_ocean_batch_request("查询 2002 年 8 月西北太平洋的 SST 数据,并导出为 CSV 文件供我下载")
 
4
  ROOT=pathlib.Path(__file__).resolve().parents[1]
5
 
6
  def test_version_421():
7
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
8
 
9
  def test_exact_user_month_query_routes_to_batch():
10
  p=parse_ocean_batch_request("查询 2002 年 8 月西北太平洋的 SST 数据,并导出为 CSV 文件供我下载")
tests/test_ocean_batch_v410.py CHANGED
@@ -4,7 +4,7 @@ from services.ocean_batch import parse_ocean_batch_request
4
  ROOT=pathlib.Path(__file__).resolve().parents[1]
5
 
6
  def test_version_410():
7
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
8
 
9
  def test_month_three_variable_batch_plan():
10
  p=parse_ocean_batch_request("查询并导出 2002 年 8 月西北太平洋 120°E–140°E、20°N–40°N 的 SST、SSH、CHL 数据,并打包下载")
 
4
  ROOT=pathlib.Path(__file__).resolve().parents[1]
5
 
6
  def test_version_410():
7
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
8
 
9
  def test_month_three_variable_batch_plan():
10
  p=parse_ocean_batch_request("查询并导出 2002 年 8 月西北太平洋 120°E–140°E、20°N–40°N 的 SST、SSH、CHL 数据,并打包下载")
tests/test_ocean_date_default_v453.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
6
 
7
  def test_missing_date_never_defaults_to_today():
8
  rt=(ROOT/"services"/"chat_runtime.py").read_text(encoding="utf-8")
 
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
6
 
7
  def test_missing_date_never_defaults_to_today():
8
  rt=(ROOT/"services"/"chat_runtime.py").read_text(encoding="utf-8")
tests/test_ocean_download_url_base_v462.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
6
 
7
  def test_generic_public_base_not_used():
8
  py=(ROOT/"services"/"ocean_batch.py").read_text(encoding="utf-8")
 
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
6
 
7
  def test_generic_public_base_not_used():
8
  py=(ROOT/"services"/"ocean_batch.py").read_text(encoding="utf-8")
tests/test_p0_finish_v380.py CHANGED
@@ -3,7 +3,7 @@ import pathlib, re, subprocess, shutil
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_380():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
7
 
8
  def test_chat_runtime_extracted():
9
  ui=(ROOT/"ui_server.py").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_380():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
7
 
8
  def test_chat_runtime_extracted():
9
  ui=(ROOT/"ui_server.py").read_text(encoding="utf-8")
tests/test_p0_phase2_v371.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
7
 
8
  def test_frontend_feature_modules_exist():
9
  html=(ROOT/"app.html").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
7
 
8
  def test_frontend_feature_modules_exist():
9
  html=(ROOT/"app.html").read_text(encoding="utf-8")
tests/test_p0_phase3_v372.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_372():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
7
 
8
  def test_route_modules_exist():
9
  for name in ("datasets.py","user_state.py","project_package.py","data_health.py"):
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_372():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
7
 
8
  def test_route_modules_exist():
9
  for name in ("datasets.py","user_state.py","project_package.py","data_health.py"):
tests/test_platform_reliability_v450.py CHANGED
@@ -6,7 +6,7 @@ from services.ocean_batch import parse_ocean_batch_request
6
  ROOT=Path(__file__).resolve().parents[1]
7
 
8
  def test_version():
9
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
10
 
11
  def test_squid_center_uses_repository_membership():
12
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
 
6
  ROOT=Path(__file__).resolve().parents[1]
7
 
8
  def test_version():
9
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
10
 
11
  def test_squid_center_uses_repository_membership():
12
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
tests/test_quality_center_v362.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT = pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_371():
6
- assert (ROOT / "VERSION").read_text(encoding="utf-8").strip() == "4.6.8"
7
 
8
  def test_native_health_backend():
9
  text = (ROOT / "routes" / "data_health.py").read_text(encoding="utf-8")
 
3
  ROOT = pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_371():
6
+ assert (ROOT / "VERSION").read_text(encoding="utf-8").strip() == "4.6.9"
7
 
8
  def test_native_health_backend():
9
  text = (ROOT / "routes" / "data_health.py").read_text(encoding="utf-8")
tests/test_quality_trust_v361.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version_current():
5
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
6
 
7
  def test_quality_confidence_is_now_native_api_metric():
8
  text=(ROOT/"routes"/"data_health.py").read_text(encoding="utf-8")
 
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version_current():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
6
 
7
  def test_quality_confidence_is_now_native_api_metric():
8
  text=(ROOT/"routes"/"data_health.py").read_text(encoding="utf-8")
tests/test_reference_cleanup_v468.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
6
 
7
  def test_reference_and_reproducibility_are_auxiliary():
8
  py=(ROOT/"routes"/"datasets.py").read_text(encoding="utf-8")
 
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
6
 
7
  def test_reference_and_reproducibility_are_auxiliary():
8
  py=(ROOT/"routes"/"datasets.py").read_text(encoding="utf-8")
tests/test_release_stability.py CHANGED
@@ -7,7 +7,7 @@ def test_version_is_single_source():
7
  version = (ROOT / "VERSION").read_text(encoding="utf-8").strip()
8
  ui = (ROOT / "ui_server.py").read_text(encoding="utf-8")
9
  html = (ROOT / "app.html").read_text(encoding="utf-8")
10
- assert version == "4.6.8"
11
  assert 'with_name("VERSION")' in ui
12
  assert "__APP_VERSION__" in html
13
 
 
7
  version = (ROOT / "VERSION").read_text(encoding="utf-8").strip()
8
  ui = (ROOT / "ui_server.py").read_text(encoding="utf-8")
9
  html = (ROOT / "app.html").read_text(encoding="utf-8")
10
+ assert version == "4.6.9"
11
  assert 'with_name("VERSION")' in ui
12
  assert "__APP_VERSION__" in html
13
 
tests/test_resumable_batch_download_v458.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
6
 
7
  def test_network_loss_pauses_not_fails_all():
8
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
 
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
6
 
7
  def test_network_loss_pauses_not_fails_all():
8
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
tests/test_route_region_clarification_v455.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
6
 
7
  def test_route_guard_exists_before_agent_dispatch():
8
  chat=(ROOT/"routes"/"chat.py").read_text(encoding="utf-8")
 
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
6
 
7
  def test_route_guard_exists_before_agent_dispatch():
8
  chat=(ROOT/"routes"/"chat.py").read_text(encoding="utf-8")
tests/test_runtime_hotfix_v376.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_376():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
7
 
8
  def test_no_stray_async_before_sidebar_toggle():
9
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_376():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
7
 
8
  def test_no_stray_async_before_sidebar_toggle():
9
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
tests/test_sidebar_collapse_v374.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_374():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
7
 
8
  def test_sidebar_toggle_markup():
9
  html=(ROOT/"app.html").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_374():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
7
 
8
  def test_sidebar_toggle_markup():
9
  html=(ROOT/"app.html").read_text(encoding="utf-8")
tests/test_sidebar_partial_collapse_v375.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_375():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
7
 
8
  def test_sidebar_order_and_groups():
9
  html=(ROOT/"app.html").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_375():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
7
 
8
  def test_sidebar_order_and_groups():
9
  html=(ROOT/"app.html").read_text(encoding="utf-8")
tests/test_smart_progress_avatar_sync_v441.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
6
 
7
  def test_simple_chat_hides_progress_until_needed():
8
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
 
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
6
 
7
  def test_simple_chat_hides_progress_until_needed():
8
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
tests/test_source_mapping_roles_v464.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
6
 
7
  def test_new_source_mappings():
8
  py=(ROOT/"sidebar_catalog.py").read_text(encoding="utf-8")
 
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
6
 
7
  def test_new_source_mappings():
8
  py=(ROOT/"sidebar_catalog.py").read_text(encoding="utf-8")
tests/test_squid_finalize_v467.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
6
 
7
  def test_ccamlr_and_ommastrephidae_mappings():
8
  py=(ROOT/"sidebar_catalog.py").read_text(encoding="utf-8")
 
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
6
 
7
  def test_ccamlr_and_ommastrephidae_mappings():
8
  py=(ROOT/"sidebar_catalog.py").read_text(encoding="utf-8")
tests/test_sse_heartbeat_v401.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_401():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
7
 
8
  def test_chat_has_independent_heartbeat_wrapper():
9
  route=(ROOT/"routes"/"chat.py").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_401():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
7
 
8
  def test_chat_has_independent_heartbeat_wrapper():
9
  route=(ROOT/"routes"/"chat.py").read_text(encoding="utf-8")
tests/test_ui_avatar_multitask_v440.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
6
 
7
  def test_assistant_avatar_removed_user_avatar_customizable():
8
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
 
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
6
 
7
  def test_assistant_avatar_removed_user_avatar_customizable():
8
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
tests/test_unclassified_inventory_v463.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
6
 
7
  def test_backend_unique_classification_and_route():
8
  py=(ROOT/"routes"/"datasets.py").read_text(encoding="utf-8")
 
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
6
 
7
  def test_backend_unique_classification_and_route():
8
  py=(ROOT/"routes"/"datasets.py").read_text(encoding="utf-8")
tests/test_unified_download_manager_v392.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_392():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
7
 
8
  def test_download_proxy_route_registered():
9
  route=(ROOT/"routes"/"downloads.py").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_392():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
7
 
8
  def test_download_proxy_route_registered():
9
  route=(ROOT/"routes"/"downloads.py").read_text(encoding="utf-8")
tests/test_unified_query_v390.py CHANGED
@@ -4,7 +4,7 @@ from services.unified_query import plan_unified_query
4
  ROOT=pathlib.Path(__file__).resolve().parents[1]
5
 
6
  def test_version_390():
7
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
8
 
9
  def test_joint_squid_environment_plan():
10
  p=plan_unified_query("查询 2002 年 8 月西北太平洋柔鱼记录,并匹配 SST、SSH、CHL")
 
4
  ROOT=pathlib.Path(__file__).resolve().parents[1]
5
 
6
  def test_version_390():
7
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
8
 
9
  def test_joint_squid_environment_plan():
10
  p=plan_unified_query("查询 2002 年 8 月西北太平洋柔鱼记录,并匹配 SST、SSH、CHL")
tests/test_upstream_refresh_retry_v461.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
6
 
7
  def test_probe_returns_status_and_rejects_dead_url():
8
  py=(ROOT/"routes"/"downloads.py").read_text(encoding="utf-8")
 
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
6
 
7
  def test_probe_returns_status_and_rejects_dead_url():
8
  py=(ROOT/"routes"/"downloads.py").read_text(encoding="utf-8")
tests/test_v381_navigation_ocean_intent.py CHANGED
@@ -3,7 +3,7 @@ import pathlib, ast
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_381():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.8"
7
 
8
  def test_recent_conversation_switches_to_chat():
9
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_381():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.6.9"
7
 
8
  def test_recent_conversation_switches_to_chat():
9
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")