升级 NUPACK 4.1 并支持混合材料设计

This commit is contained in:
Lihatoo 2026-08-20 16:33:16 +08:00
parent 5daa60a464
commit c6189d857d
33 changed files with 5830 additions and 466 deletions

View file

@ -13,6 +13,9 @@ from urllib.parse import urlencode
from urllib.request import Request, urlopen
DEFAULT_TRASH_RETENTION_SECONDS = 2 * 86400
def _json_blob(value):
if value is None:
return None
@ -127,8 +130,26 @@ class AccountStore:
access_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS shares_user_created_idx ON shares(user_id, created_at DESC);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at REAL NOT NULL
);
"""
)
job_columns = {
row["name"] for row in connection.execute("PRAGMA table_info(jobs)").fetchall()
}
if "deleted_at" not in job_columns:
connection.execute("ALTER TABLE jobs ADD COLUMN deleted_at REAL")
if "purge_after" not in job_columns:
connection.execute("ALTER TABLE jobs ADD COLUMN purge_after REAL")
connection.execute(
"CREATE INDEX IF NOT EXISTS jobs_deleted_idx ON jobs(deleted_at, purge_after)"
)
connection.execute(
"DELETE FROM shares WHERE job_id IN (SELECT job_id FROM jobs WHERE deleted_at IS NOT NULL)"
)
self._initialized = True
def upsert_user(self, user):
@ -206,11 +227,15 @@ class AccountStore:
row = connection.execute("SELECT user_id FROM jobs WHERE job_id = ?", (job_id,)).fetchone()
return row["user_id"] if row else None
def get_job(self, user_id, job_id, include_content=True):
def get_job(self, user_id, job_id, include_content=True, include_deleted=False):
self.initialize()
columns = "*" if include_content else "job_id,user_id,status,source,created_at,updated_at,elapsed_seconds,workflow,mode,material,celsius,sodium,magnesium,max_size,strand_count,complex_count,compute_json,trials,stop_condition,max_time_seconds,stored_bytes"
columns = "*" if include_content else "job_id,user_id,status,source,created_at,updated_at,elapsed_seconds,workflow,mode,material,celsius,sodium,magnesium,max_size,strand_count,complex_count,compute_json,trials,stop_condition,max_time_seconds,stored_bytes,deleted_at,purge_after"
deleted_clause = "" if include_deleted else " AND deleted_at IS NULL"
with self._connect() as connection:
row = connection.execute(f"SELECT {columns} FROM jobs WHERE job_id = ? AND user_id = ?", (job_id, user_id)).fetchone()
row = connection.execute(
f"SELECT {columns} FROM jobs WHERE job_id = ? AND user_id = ?{deleted_clause}",
(job_id, user_id),
).fetchone()
return self._job_row(row, include_content=include_content) if row else None
def list_jobs(self, user_id, filters=None):
@ -218,7 +243,8 @@ class AccountStore:
filters = filters or {}
limit = min(100, max(1, int(filters.get("limit", 30))))
offset = max(0, int(filters.get("offset", 0)))
clauses = ["user_id = ?"]
self.purge_expired_jobs()
clauses = ["user_id = ?", "deleted_at IS NULL"]
values = [user_id]
for field in ("status", "workflow", "mode", "material"):
value = str(filters.get(field) or "").strip()
@ -230,7 +256,7 @@ class AccountStore:
clauses.append("(job_id LIKE ? OR workflow LIKE ? OR mode LIKE ? OR material LIKE ?)")
values.extend([f"%{search}%"] * 4)
where = " AND ".join(clauses)
columns = "job_id,user_id,status,source,created_at,updated_at,elapsed_seconds,workflow,mode,material,celsius,sodium,magnesium,max_size,strand_count,complex_count,compute_json,trials,stop_condition,max_time_seconds,stored_bytes"
columns = "job_id,user_id,status,source,created_at,updated_at,elapsed_seconds,workflow,mode,material,celsius,sodium,magnesium,max_size,strand_count,complex_count,compute_json,trials,stop_condition,max_time_seconds,stored_bytes,deleted_at,purge_after"
with self._connect() as connection:
total = connection.execute(f"SELECT count(*) AS count FROM jobs WHERE {where}", values).fetchone()["count"]
rows = connection.execute(
@ -239,6 +265,51 @@ class AccountStore:
).fetchall()
return {"items": [self._job_row(row, include_content=False) for row in rows], "total": total, "limit": limit, "offset": offset}
def list_all_jobs(self, filters=None):
self.initialize()
filters = filters or {}
limit = min(200, max(1, int(filters.get("limit", 50))))
offset = max(0, int(filters.get("offset", 0)))
self.purge_expired_jobs()
deleted = str(filters.get("deleted") or "0").strip() == "1"
clauses = ["j.deleted_at IS NOT NULL" if deleted else "j.deleted_at IS NULL"]
values = []
for field in ("status", "workflow", "mode", "material"):
value = str(filters.get(field) or "").strip()
if value:
clauses.append(f"j.{field} = ?")
values.append(value)
search = str(filters.get("q") or "").strip()
if search:
clauses.append(
"(j.job_id LIKE ? OR j.user_id LIKE ? OR u.username LIKE ? OR "
"coalesce(u.email, '') LIKE ? OR j.workflow LIKE ? OR j.mode LIKE ?)"
)
values.extend([f"%{search}%"] * 6)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
columns = (
"j.job_id,j.user_id,j.status,j.source,j.created_at,j.updated_at,j.elapsed_seconds,"
"j.workflow,j.mode,j.material,j.celsius,j.sodium,j.magnesium,j.max_size,"
"j.strand_count,j.complex_count,j.compute_json,j.trials,j.stop_condition,"
"j.max_time_seconds,j.stored_bytes,j.deleted_at,j.purge_after,u.username,u.email,u.display_name"
)
with self._connect() as connection:
total = connection.execute(
f"SELECT count(*) AS count FROM jobs j JOIN users u ON u.user_id=j.user_id {where}",
values,
).fetchone()["count"]
rows = connection.execute(
f"SELECT {columns} FROM jobs j JOIN users u ON u.user_id=j.user_id "
f"{where} ORDER BY j.created_at DESC LIMIT ? OFFSET ?",
[*values, limit, offset],
).fetchall()
return {
"items": [self._job_row(row, include_content=False) for row in rows],
"total": total,
"limit": limit,
"offset": offset,
}
def usage(self, user_id):
self.initialize()
with self._connect() as connection:
@ -248,7 +319,7 @@ class AccountStore:
coalesce(sum(CASE WHEN status='error' THEN 1 ELSE 0 END),0) AS error_jobs,
coalesce(sum(CASE WHEN status IN ('queued','running','cancel_requested') THEN 1 ELSE 0 END),0) AS active_jobs,
coalesce(sum(elapsed_seconds),0) AS compute_seconds,
coalesce(sum(stored_bytes),0) AS stored_bytes FROM jobs WHERE user_id=?""",
coalesce(sum(stored_bytes),0) AS stored_bytes FROM jobs WHERE user_id=? AND deleted_at IS NULL""",
(user_id,),
).fetchone()
shares = connection.execute("SELECT count(*) AS count FROM shares WHERE user_id=? AND active=1", (user_id,)).fetchone()["count"]
@ -279,17 +350,86 @@ class AccountStore:
imported += 1
return imported
def delete_job(self, user_id, job_id):
def trash_retention_seconds(self):
self.initialize()
with self._connect() as connection:
row = connection.execute("SELECT status FROM jobs WHERE job_id=? AND user_id=?", (job_id, user_id)).fetchone()
row = connection.execute(
"SELECT value FROM app_settings WHERE key='trash_retention_seconds'"
).fetchone()
try:
return max(3600, int(row["value"])) if row else DEFAULT_TRASH_RETENTION_SECONDS
except (TypeError, ValueError):
return DEFAULT_TRASH_RETENTION_SECONDS
def set_trash_retention_days(self, days):
days = float(days)
if not 1 / 24 <= days <= 365:
raise ValueError("Trash retention must be between 1 hour and 365 days.")
seconds = int(days * 86400)
self.initialize()
with self._connect() as connection:
connection.execute(
"""INSERT INTO app_settings(key,value,updated_at) VALUES('trash_retention_seconds',?,?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at""",
(str(seconds), time.time()),
)
return seconds
def purge_expired_jobs(self):
self.initialize()
with self._connect() as connection:
cursor = connection.execute(
"DELETE FROM jobs WHERE deleted_at IS NOT NULL AND purge_after IS NOT NULL AND purge_after <= ?",
(time.time(),),
)
return cursor.rowcount
def delete_job(self, user_id, job_id):
self.initialize()
now = time.time()
retention_seconds = self.trash_retention_seconds()
with self._connect() as connection:
row = connection.execute(
"SELECT status,deleted_at FROM jobs WHERE job_id=? AND user_id=?",
(job_id, user_id),
).fetchone()
if row is None:
return False
if row["status"] in {"queued", "running", "cancel_requested"}:
raise ValueError("Active jobs cannot be deleted.")
connection.execute("DELETE FROM jobs WHERE job_id=? AND user_id=?", (job_id, user_id))
if row["deleted_at"] is not None:
return False
connection.execute(
"UPDATE jobs SET deleted_at=?,purge_after=?,updated_at=? WHERE job_id=? AND user_id=?",
(now, now + retention_seconds, now, job_id, user_id),
)
connection.execute("DELETE FROM shares WHERE job_id=?", (job_id,))
return True
def admin_trash_job(self, job_id):
self.initialize()
with self._connect() as connection:
row = connection.execute("SELECT user_id FROM jobs WHERE job_id=?", (job_id,)).fetchone()
return self.delete_job(row["user_id"], job_id) if row else False
def restore_job(self, job_id):
self.initialize()
with self._connect() as connection:
cursor = connection.execute(
"UPDATE jobs SET deleted_at=NULL,purge_after=NULL,updated_at=? WHERE job_id=? AND deleted_at IS NOT NULL",
(time.time(), job_id),
)
return cursor.rowcount > 0
def permanently_delete_job(self, job_id):
self.initialize()
with self._connect() as connection:
cursor = connection.execute(
"DELETE FROM jobs WHERE job_id=? AND deleted_at IS NOT NULL",
(job_id,),
)
return cursor.rowcount > 0
def create_share(self, user_id, job_id, expires_in=None):
job = self.get_job(user_id, job_id, include_content=False)
if job is None:
@ -298,6 +438,19 @@ class AccountStore:
expires_at = now + int(expires_in) if expires_in else None
share_id = uuid_token(18)
with self._connect() as connection:
existing = connection.execute(
"""SELECT * FROM shares
WHERE user_id=? AND job_id=? AND active=1
AND (expires_at IS NULL OR expires_at>?)
ORDER BY created_at DESC LIMIT 1""",
(user_id, job_id, now),
).fetchone()
if existing is not None:
return dict(existing)
connection.execute(
"DELETE FROM shares WHERE user_id=? AND job_id=?",
(user_id, job_id),
)
connection.execute(
"INSERT INTO shares(share_id,job_id,user_id,active,created_at,expires_at) VALUES(?,?,?,?,?,?)",
(share_id, job_id, user_id, 1, now, expires_at),
@ -329,7 +482,8 @@ class AccountStore:
with self._connect() as connection:
rows = connection.execute(
"""SELECT s.*,j.status,j.workflow,j.mode,j.material,j.created_at AS job_created_at
FROM shares s JOIN jobs j ON j.job_id=s.job_id WHERE s.user_id=? ORDER BY s.created_at DESC""",
FROM shares s JOIN jobs j ON j.job_id=s.job_id
WHERE s.user_id=? AND j.deleted_at IS NULL ORDER BY s.created_at DESC""",
(user_id,),
).fetchall()
return [dict(row) for row in rows]
@ -344,6 +498,14 @@ class AccountStore:
share = self.get_share_for_owner(user_id, share_id)
if share is None:
return None
if active is False:
with self._connect() as connection:
connection.execute(
"DELETE FROM shares WHERE share_id=? AND user_id=?",
(share_id, user_id),
)
share["active"] = 0
return share
fields = []
values = []
if active is not None: