Deploy v3.4.0 persistence update
Browse files- DEPLOY.md +1 -1
- README.md +2 -2
- VERSION +1 -1
- app.html +10 -3
- tests/test_release_stability.py +1 -1
- ui_server.py +66 -2
DEPLOY.md
CHANGED
|
@@ -45,7 +45,7 @@ Next server phase:
|
|
| 45 |
- add job/update APIs
|
| 46 |
Then extend `marine_mcp.py` with corresponding tools.
|
| 47 |
|
| 48 |
-
## v3.
|
| 49 |
|
| 50 |
- `VERSION` is the single application version source.
|
| 51 |
- Only root `app.html` is served; do not re-create `templates/app.html`.
|
|
|
|
| 45 |
- add job/update APIs
|
| 46 |
Then extend `marine_mcp.py` with corresponding tools.
|
| 47 |
|
| 48 |
+
## v3.4.0 deployment stability
|
| 49 |
|
| 50 |
- `VERSION` is the single application version source.
|
| 51 |
- Only root `app.html` is served; do not re-create `templates/app.html`.
|
README.md
CHANGED
|
@@ -9,10 +9,10 @@ app_port: 7860
|
|
| 9 |
|
| 10 |
# Global Marine Foundation Data Agent
|
| 11 |
|
| 12 |
-
Current UI release: **v3.
|
| 13 |
|
| 14 |
|
| 15 |
-
## v3.
|
| 16 |
|
| 17 |
- 新增根目录 `VERSION`,后端与前端统一从同一版本源渲染,避免版本号漂移。
|
| 18 |
- 运行时仅服务根目录 `app.html`;发布包不再携带第二份 `templates/app.html`。
|
|
|
|
| 9 |
|
| 10 |
# Global Marine Foundation Data Agent
|
| 11 |
|
| 12 |
+
Current UI release: **v3.4.0**.
|
| 13 |
|
| 14 |
|
| 15 |
+
## v3.4.0 稳定性重构(第一阶段)
|
| 16 |
|
| 17 |
- 新增根目录 `VERSION`,后端与前端统一从同一版本源渲染,避免版本号漂移。
|
| 18 |
- 运行时仅服务根目录 `app.html`;发布包不再携带第二份 `templates/app.html`。
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
3.
|
|
|
|
| 1 |
+
3.4.0
|
app.html
CHANGED
|
@@ -697,6 +697,8 @@ let CURRENT_VIEW="chat";
|
|
| 697 |
let FAVORITES=[];
|
| 698 |
let FAVORITES_SYNC={enabled:false,storage_mode:"local",status:"local"};
|
| 699 |
let FAVORITES_SYNC_TIMER=null;
|
|
|
|
|
|
|
| 700 |
let DATASET_COMPARE=[];
|
| 701 |
let SETTINGS={fontSize:"normal",enterToSend:true,refreshSeconds:15,accent:"blue",defaultView:"chat",compact:false,reduceMotion:false,longitudeRange:"-180_180",timeDisplay:"local",exportFormat:"auto",answerDetail:"standard",historyLimit:30,confirmDownload:true,messageActionTrigger:"left"};
|
| 702 |
let STATUS_TIMER=null;
|
|
@@ -736,7 +738,7 @@ function loadSessions(){
|
|
| 736 |
}
|
| 737 |
let S=[];
|
| 738 |
|
| 739 |
-
function save(){const limit=[10,30,50].includes(Number(SETTINGS.historyLimit))?Number(SETTINGS.historyLimit):30;localStorage.setItem(K,JSON.stringify(S.slice(0,limit)))}
|
| 740 |
function scopedKey(base){return base+":"+USER_ID}
|
| 741 |
function loadFavorites(){
|
| 742 |
try{return JSON.parse(localStorage.getItem(scopedKey(FAV_BASE_K))||"[]")}
|
|
@@ -796,7 +798,11 @@ function loadSettings(){
|
|
| 796 |
}
|
| 797 |
catch{return {...SETTINGS}}
|
| 798 |
}
|
| 799 |
-
function saveSettings(){localStorage.setItem(scopedKey(SETTINGS_BASE_K),JSON.stringify(SETTINGS))}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 800 |
function exportWorkspaceBackup(){
|
| 801 |
const payload={schema:"squid-workspace-backup",schema_version:1,app_version:"__APP_VERSION__",exported_at:new Date().toISOString(),sessions:S.slice(0,50),favorites:FAVORITES.slice(0,100),settings:SETTINGS};
|
| 802 |
const blob=new Blob([JSON.stringify(payload,null,2)],{type:"application/json;charset=utf-8"}),url=URL.createObjectURL(blob),a=document.createElement("a");
|
|
@@ -1975,9 +1981,10 @@ async function boot(){
|
|
| 1975 |
}
|
| 1976 |
|
| 1977 |
S=loadSessions();
|
|
|
|
| 1978 |
FAVORITES=loadFavorites();
|
| 1979 |
await syncFavoritesFromServer();
|
| 1980 |
-
|
| 1981 |
document.body.classList.remove("auth-pending");
|
| 1982 |
hist();
|
| 1983 |
status();
|
|
|
|
| 697 |
let FAVORITES=[];
|
| 698 |
let FAVORITES_SYNC={enabled:false,storage_mode:"local",status:"local"};
|
| 699 |
let FAVORITES_SYNC_TIMER=null;
|
| 700 |
+
let WORKSPACE_SYNC_TIMER=null;
|
| 701 |
+
let WORKSPACE_SYNC={enabled:false,storage_mode:"local",status:"local"};
|
| 702 |
let DATASET_COMPARE=[];
|
| 703 |
let SETTINGS={fontSize:"normal",enterToSend:true,refreshSeconds:15,accent:"blue",defaultView:"chat",compact:false,reduceMotion:false,longitudeRange:"-180_180",timeDisplay:"local",exportFormat:"auto",answerDetail:"standard",historyLimit:30,confirmDownload:true,messageActionTrigger:"left"};
|
| 704 |
let STATUS_TIMER=null;
|
|
|
|
| 738 |
}
|
| 739 |
let S=[];
|
| 740 |
|
| 741 |
+
function save(){const limit=[10,30,50].includes(Number(SETTINGS.historyLimit))?Number(SETTINGS.historyLimit):30;localStorage.setItem(K,JSON.stringify(S.slice(0,limit)));scheduleWorkspaceSync()}
|
| 742 |
function scopedKey(base){return base+":"+USER_ID}
|
| 743 |
function loadFavorites(){
|
| 744 |
try{return JSON.parse(localStorage.getItem(scopedKey(FAV_BASE_K))||"[]")}
|
|
|
|
| 798 |
}
|
| 799 |
catch{return {...SETTINGS}}
|
| 800 |
}
|
| 801 |
+
function saveSettings(){localStorage.setItem(scopedKey(SETTINGS_BASE_K),JSON.stringify(SETTINGS));scheduleWorkspaceSync()}
|
| 802 |
+
function mergeSessions(remote,local){const map=new Map();[...(remote||[]),...(local||[])].forEach(x=>{if(!x||!x.thread_id)return;const prev=map.get(x.thread_id);if(!prev||Number(x.updated||0)>=Number(prev.updated||0))map.set(x.thread_id,x)});return [...map.values()].sort((a,b)=>Number(b.updated||0)-Number(a.updated||0)).slice(0,50)}
|
| 803 |
+
function scheduleWorkspaceSync(){if(!AUTH_ENABLED||!AUTH_TOKEN)return;clearTimeout(WORKSPACE_SYNC_TIMER);WORKSPACE_SYNC_TIMER=setTimeout(()=>pushWorkspaceToServer().catch(()=>{}),500)}
|
| 804 |
+
async function pushWorkspaceToServer(){if(!AUTH_ENABLED||!AUTH_TOKEN)return;WORKSPACE_SYNC.status="syncing";const data=await j("/api/sidebar/workspace",{method:"PUT",body:JSON.stringify({user_id:USER_ID,sessions:S.slice(0,50),settings:SETTINGS})});WORKSPACE_SYNC={enabled:!!data.sync_enabled,storage_mode:data.storage_mode||"server_session",status:"synced"}}
|
| 805 |
+
async function syncWorkspaceFromServer(){if(!AUTH_ENABLED||!AUTH_TOKEN){WORKSPACE_SYNC={enabled:false,storage_mode:"local",status:"local"};return}try{const localSessions=loadSessions(),localSettings=loadSettings();const data=await j("/api/sidebar/workspace?user_id="+encodeURIComponent(USER_ID));S=mergeSessions(data.sessions||[],localSessions);SETTINGS={...SETTINGS,...localSettings,...(data.settings||{})};localStorage.setItem(K,JSON.stringify(S.slice(0,50)));localStorage.setItem(scopedKey(SETTINGS_BASE_K),JSON.stringify(SETTINGS));WORKSPACE_SYNC={enabled:!!data.sync_enabled,storage_mode:data.storage_mode||"server_session",status:"synced"};if(localSessions.length||Object.keys(localSettings||{}).length)await pushWorkspaceToServer()}catch(err){WORKSPACE_SYNC={enabled:false,storage_mode:"local",status:"error"};console.warn("workspace sync failed",err)}}
|
| 806 |
function exportWorkspaceBackup(){
|
| 807 |
const payload={schema:"squid-workspace-backup",schema_version:1,app_version:"__APP_VERSION__",exported_at:new Date().toISOString(),sessions:S.slice(0,50),favorites:FAVORITES.slice(0,100),settings:SETTINGS};
|
| 808 |
const blob=new Blob([JSON.stringify(payload,null,2)],{type:"application/json;charset=utf-8"}),url=URL.createObjectURL(blob),a=document.createElement("a");
|
|
|
|
| 1981 |
}
|
| 1982 |
|
| 1983 |
S=loadSessions();
|
| 1984 |
+
SETTINGS=loadSettings();
|
| 1985 |
FAVORITES=loadFavorites();
|
| 1986 |
await syncFavoritesFromServer();
|
| 1987 |
+
await syncWorkspaceFromServer();
|
| 1988 |
document.body.classList.remove("auth-pending");
|
| 1989 |
hist();
|
| 1990 |
status();
|
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 == "3.
|
| 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 == "3.4.0"
|
| 11 |
assert 'with_name("VERSION")' in ui
|
| 12 |
assert "__APP_VERSION__" in html
|
| 13 |
|
ui_server.py
CHANGED
|
@@ -45,9 +45,9 @@ MEMORY_API_TOKEN = os.environ.get("MEMORY_API_TOKEN", "").strip()
|
|
| 45 |
ADMIN_DASHBOARD_PASSWORD = os.environ.get("ADMIN_DASHBOARD_PASSWORD", "").strip()
|
| 46 |
VERSION_FILE = Path(__file__).with_name("VERSION")
|
| 47 |
try:
|
| 48 |
-
CODE_VERSION = VERSION_FILE.read_text(encoding="utf-8").strip() or "3.
|
| 49 |
except Exception:
|
| 50 |
-
CODE_VERSION = "3.
|
| 51 |
APP_VERSION = CODE_VERSION
|
| 52 |
APP_REVISION = (
|
| 53 |
os.environ.get("SPACE_REVISION", "").strip()
|
|
@@ -366,6 +366,55 @@ class FavoritesSyncRequest(BaseModel):
|
|
| 366 |
user_id: str = ""
|
| 367 |
favorites: list[dict[str, Any]] = Field(default_factory=list)
|
| 368 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
def _favorite_state_file(user_id: str) -> Path:
|
| 370 |
if not valid_user_id(user_id):
|
| 371 |
raise HTTPException(400, "invalid user_id")
|
|
@@ -3994,6 +4043,21 @@ async def put_synced_favorites(body: FavoritesSyncRequest, request: Request):
|
|
| 3994 |
"count": len(items),
|
| 3995 |
}
|
| 3996 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3997 |
@app.get("/api/status")
|
| 3998 |
async def status(request:Request, thread_id: str | None = None):
|
| 3999 |
if AUTH_ENABLED:
|
|
|
|
| 45 |
ADMIN_DASHBOARD_PASSWORD = os.environ.get("ADMIN_DASHBOARD_PASSWORD", "").strip()
|
| 46 |
VERSION_FILE = Path(__file__).with_name("VERSION")
|
| 47 |
try:
|
| 48 |
+
CODE_VERSION = VERSION_FILE.read_text(encoding="utf-8").strip() or "3.4.0"
|
| 49 |
except Exception:
|
| 50 |
+
CODE_VERSION = "3.4.0"
|
| 51 |
APP_VERSION = CODE_VERSION
|
| 52 |
APP_REVISION = (
|
| 53 |
os.environ.get("SPACE_REVISION", "").strip()
|
|
|
|
| 366 |
user_id: str = ""
|
| 367 |
favorites: list[dict[str, Any]] = Field(default_factory=list)
|
| 368 |
|
| 369 |
+
class WorkspaceSyncRequest(BaseModel):
|
| 370 |
+
user_id: str = ""
|
| 371 |
+
sessions: list[dict[str, Any]] = Field(default_factory=list)
|
| 372 |
+
settings: dict[str, Any] = Field(default_factory=dict)
|
| 373 |
+
|
| 374 |
+
def _workspace_state_file(user_id: str) -> Path:
|
| 375 |
+
if not valid_user_id(user_id):
|
| 376 |
+
raise HTTPException(400, "invalid user_id")
|
| 377 |
+
return USER_STATE_ROOT / f"{user_id}.workspace.json"
|
| 378 |
+
|
| 379 |
+
def _sanitize_workspace_sessions(items: Any) -> list[dict[str, Any]]:
|
| 380 |
+
out=[]
|
| 381 |
+
for x in items if isinstance(items,list) else []:
|
| 382 |
+
if not isinstance(x,dict): continue
|
| 383 |
+
thread_id=str(x.get("thread_id") or "")[:200]
|
| 384 |
+
if not thread_id: continue
|
| 385 |
+
messages=[]
|
| 386 |
+
for m in (x.get("messages") if isinstance(x.get("messages"),list) else [])[-80:]:
|
| 387 |
+
if not isinstance(m,dict): continue
|
| 388 |
+
messages.append({
|
| 389 |
+
"role":"assistant" if m.get("role")=="assistant" else "user",
|
| 390 |
+
"text":str(m.get("text") or "")[:50000],
|
| 391 |
+
"meta":str(m.get("meta") or "")[:1000],
|
| 392 |
+
"time":int(m.get("time") or int(time.time()*1000)),
|
| 393 |
+
})
|
| 394 |
+
out.append({"thread_id":thread_id,"title":str(x.get("title") or "新对话")[:200],"updated":int(x.get("updated") or int(time.time()*1000)),"messages":messages})
|
| 395 |
+
if len(out)>=50: break
|
| 396 |
+
return out
|
| 397 |
+
|
| 398 |
+
def _sanitize_workspace_settings(value: Any) -> dict[str, Any]:
|
| 399 |
+
if not isinstance(value,dict): return {}
|
| 400 |
+
allowed={"fontSize","enterToSend","refreshSeconds","accent","defaultView","compact","reduceMotion","longitudeRange","timeDisplay","exportFormat","answerDetail","historyLimit","confirmDownload","messageActionTrigger"}
|
| 401 |
+
return {str(k):v for k,v in value.items() if k in allowed}
|
| 402 |
+
|
| 403 |
+
def _read_server_workspace(user_id: str) -> dict[str, Any]:
|
| 404 |
+
path=_workspace_state_file(user_id)
|
| 405 |
+
if not path.exists(): return {"sessions":[],"settings":{},"updated_at":""}
|
| 406 |
+
try: raw=json.loads(path.read_text(encoding="utf-8"))
|
| 407 |
+
except Exception: return {"sessions":[],"settings":{},"updated_at":""}
|
| 408 |
+
return {"sessions":_sanitize_workspace_sessions(raw.get("sessions",[])),"settings":_sanitize_workspace_settings(raw.get("settings",{})),"updated_at":str(raw.get("updated_at") or "")}
|
| 409 |
+
|
| 410 |
+
def _write_server_workspace(user_id: str, sessions: Any, settings: Any) -> dict[str, Any]:
|
| 411 |
+
clean_sessions=_sanitize_workspace_sessions(sessions)
|
| 412 |
+
clean_settings=_sanitize_workspace_settings(settings)
|
| 413 |
+
payload={"schema":"squid-server-workspace","schema_version":1,"updated_at":datetime.now().astimezone().isoformat(timespec="seconds"),"sessions":clean_sessions,"settings":clean_settings}
|
| 414 |
+
path=_workspace_state_file(user_id); tmp=path.with_suffix(path.suffix+".tmp")
|
| 415 |
+
tmp.write_text(json.dumps(payload,ensure_ascii=False,indent=2),encoding="utf-8"); tmp.replace(path)
|
| 416 |
+
return payload
|
| 417 |
+
|
| 418 |
def _favorite_state_file(user_id: str) -> Path:
|
| 419 |
if not valid_user_id(user_id):
|
| 420 |
raise HTTPException(400, "invalid user_id")
|
|
|
|
| 4043 |
"count": len(items),
|
| 4044 |
}
|
| 4045 |
|
| 4046 |
+
@app.get("/api/sidebar/workspace")
|
| 4047 |
+
async def get_synced_workspace(request: Request):
|
| 4048 |
+
supplied_uid=str(request.query_params.get("user_id") or "").strip()
|
| 4049 |
+
uid,auth_user=await resolve_request_user(request,supplied_uid)
|
| 4050 |
+
if AUTH_ENABLED and not auth_user: raise HTTPException(401,"Authentication required")
|
| 4051 |
+
data=_read_server_workspace(uid)
|
| 4052 |
+
return {**data,"sync_enabled":bool(auth_user),"storage_mode":_favorites_storage_mode(),"user_id":uid}
|
| 4053 |
+
|
| 4054 |
+
@app.put("/api/sidebar/workspace")
|
| 4055 |
+
async def put_synced_workspace(body: WorkspaceSyncRequest, request: Request):
|
| 4056 |
+
uid,auth_user=await resolve_request_user(request,body.user_id)
|
| 4057 |
+
if AUTH_ENABLED and not auth_user: raise HTTPException(401,"Authentication required")
|
| 4058 |
+
data=_write_server_workspace(uid,body.sessions,body.settings)
|
| 4059 |
+
return {**data,"sync_enabled":bool(auth_user),"storage_mode":_favorites_storage_mode(),"user_id":uid}
|
| 4060 |
+
|
| 4061 |
@app.get("/api/status")
|
| 4062 |
async def status(request:Request, thread_id: str | None = None):
|
| 4063 |
if AUTH_ENABLED:
|