v1.1.0: 设置面板+双触发自动备份+恢复 + 标签多选 + 摘要历史 + 文本编辑/一键复制 + 粘贴图片上传 + Logo回首页

This commit is contained in:
2026-08-27 15:59:12 +08:00
parent d682952f41
commit 82c0d51671
3 changed files with 581 additions and 40 deletions
+348 -25
View File
@@ -12,6 +12,8 @@ import re
import io
import json
import uuid
import time
import zipfile
import shutil
import threading
import datetime
@@ -28,8 +30,10 @@ DATA_DIR = BASE_DIR / "data"
UPLOAD_DIR = DATA_DIR / "uploads"
DB_PATH = DATA_DIR / "material.db"
STATIC_DIR = BASE_DIR / "static"
BACKUP_DIR = DATA_DIR / "backups"
os.makedirs(UPLOAD_DIR, exist_ok=True)
os.makedirs(STATIC_DIR, exist_ok=True)
os.makedirs(BACKUP_DIR, exist_ok=True)
MAX_FILE_MB = 500 # 单文件上限
MAX_TEXT_LEN = 30000 # 抽取文本送入 LLM 的最大长度
@@ -128,9 +132,23 @@ def init_db():
);
CREATE INDEX IF NOT EXISTS idx_mat_project ON materials(project_id);
CREATE INDEX IF NOT EXISTS idx_ana_project ON analyses(project_id);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE VIRTUAL TABLE IF NOT EXISTS materials_fts USING fts5(term);
""")
db.commit()
# 写入默认设置(不覆盖已有)
defaults = {
"llm_base_url": LLM_BASE_URL, "llm_api_key": LLM_API_KEY, "llm_model": LLM_MODEL,
"vision_base_url": VISION_BASE_URL, "vision_api_key": VISION_API_KEY, "vision_model": VISION_MODEL,
"backup_interval_hours": "24", "backup_change_threshold": "50", "backup_max_keep": "10",
"last_backup_time": "", "change_counter": "0",
}
for k, v in defaults.items():
db.execute("INSERT OR IGNORE INTO settings(key,value) VALUES(?,?)", (k, v))
db.commit()
db.close()
@@ -241,20 +259,158 @@ def _safe_filename(name):
return name[:120] or "unnamed"
# ---------------- 设置 & 备份机制 ----------------
def get_settings(db=None):
if db is None:
db = sqlite3.connect(str(DB_PATH))
db.row_factory = sqlite3.Row
close = True
else:
close = False
rows = db.execute("SELECT key,value FROM settings").fetchall()
s = {r["key"]: r["value"] for r in rows}
if close:
db.close()
return s
def get_setting(key, default=""):
s = get_settings()
return s.get(key) if s.get(key) not in (None, "") else default
def set_setting(db, key, value):
db.execute("INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, str(value)))
def bump_changes(db):
"""变更计数+1(用于按变更量触发备份)"""
cur = db.execute("SELECT value FROM settings WHERE key='change_counter'").fetchone()
cnt = int(cur["value"]) if cur and cur["value"] else 0
set_setting(db, "change_counter", str(cnt + 1))
def llm_config():
return {
"base_url": get_setting("llm_base_url", LLM_BASE_URL),
"api_key": get_setting("llm_api_key", LLM_API_KEY),
"model": get_setting("llm_model", LLM_MODEL),
}
def vision_config():
return {
"base_url": get_setting("vision_base_url", VISION_BASE_URL),
"api_key": get_setting("vision_api_key", VISION_API_KEY),
"model": get_setting("vision_model", VISION_MODEL),
}
def make_backup(manual=False):
"""生成备份 zip(数据库 + 上传目录)"""
with _lock:
c = sqlite3.connect(str(DB_PATH))
try:
c.execute("PRAGMA wal_checkpoint(TRUNCATE)")
finally:
c.close()
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
name = f"backup_{ts}{'_manual' if manual else ''}.zip"
zpath = BACKUP_DIR / name
with zipfile.ZipFile(zpath, "w", zipfile.ZIP_DEFLATED) as z:
z.write(DB_PATH, "data/material.db")
if UPLOAD_DIR.exists():
for p in sorted(UPLOAD_DIR.rglob("*")):
if p.is_file():
z.write(p, f"data/uploads/{p.relative_to(UPLOAD_DIR)}")
db = sqlite3.connect(str(DB_PATH))
set_setting(db, "last_backup_time", _ts())
set_setting(db, "change_counter", "0")
db.commit()
db.close()
_prune_backups()
return name
def _prune_backups():
try:
max_keep = int(get_setting("backup_max_keep", "10") or 10)
except Exception:
max_keep = 10
files = sorted(BACKUP_DIR.glob("backup_*.zip"), key=lambda p: p.stat().st_mtime, reverse=True)
for f in files[max_keep:]:
try:
f.unlink()
except Exception:
pass
def list_backups():
out = []
for p in sorted(BACKUP_DIR.glob("backup_*.zip"), key=lambda x: x.stat().st_mtime, reverse=True):
out.append({"name": p.name, "size": p.stat().st_size,
"time": datetime.datetime.fromtimestamp(p.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")})
return out
def _backup_scheduler():
"""后台调度:按时间间隔 / 变更量 自动备份"""
while True:
try:
time.sleep(30)
s = get_settings()
try:
interval = float(s.get("backup_interval_hours", "24") or 0)
except Exception:
interval = 24
try:
threshold = int(s.get("backup_change_threshold", "50") or 0)
except Exception:
threshold = 50
try:
cnt = int(s.get("change_counter", "0") or 0)
except Exception:
cnt = 0
last = s.get("last_backup_time", "")
due_time = False
if interval > 0:
if not last:
due_time = True
else:
try:
lt = datetime.datetime.strptime(last, "%Y-%m-%d %H:%M:%S")
due_time = (datetime.datetime.now() - lt).total_seconds() >= interval * 3600
except Exception:
due_time = True
if (due_time or (threshold > 0 and cnt >= threshold)):
make_backup()
except Exception:
pass
# ---------------- 大模型调用 ----------------
def _llm_chat(messages, model=None, temperature=0.3, max_tokens=1500):
url = f"{LLM_BASE_URL.rstrip('/')}/chat/completions"
payload = {
"model": model or LLM_MODEL,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"response_format": {"type": "json_object"},
}
r = requests.post(url, headers={"Authorization": f"Bearer {LLM_API_KEY}"},
json=payload, timeout=120)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"]
cfg = llm_config()
url = f"{cfg['base_url'].rstrip('/')}/chat/completions"
headers = {"Authorization": f"Bearer {cfg['api_key']}"}
payload_base = {"model": model or cfg["model"], "messages": messages,
"temperature": temperature, "max_tokens": max_tokens}
# 部分模型不支持 response_format=json_object,失败时自动降级重试
for use_json in (True, False):
payload = dict(payload_base)
if use_json:
payload["response_format"] = {"type": "json_object"}
try:
r = requests.post(url, headers=headers, json=payload, timeout=120)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"]
except requests.HTTPError:
if not use_json:
raise
continue
except Exception:
raise
raise RuntimeError("LLM 调用失败")
ANALYZE_PROMPT = """你是专业素材库分析助手。请对下面这个素材做深度分析,提取关键信息。
@@ -283,13 +439,14 @@ def _parse_json(content):
def _vision_describe(path):
""" doubao 视觉模型描述图片"""
"""用视觉模型描述图片"""
try:
import base64
cfg = vision_config()
b64 = base64.b64encode(Path(path).read_bytes()).decode()
url = f"{VISION_BASE_URL.rstrip('/')}/chat/completions"
url = f"{cfg['base_url'].rstrip('/')}/chat/completions"
payload = {
"model": VISION_MODEL,
"model": cfg["model"],
"messages": [{
"role": "user",
"content": [
@@ -300,7 +457,7 @@ def _vision_describe(path):
"temperature": 0.3,
"max_tokens": 300,
}
r = requests.post(url, headers={"Authorization": f"Bearer {VISION_API_KEY}"},
r = requests.post(url, headers={"Authorization": f"Bearer {cfg['api_key']}"},
json=payload, timeout=120)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"].strip()
@@ -332,16 +489,16 @@ def analyze_material(mat):
prompt = ANALYZE_PROMPT.format(name=mat["name"], mtype=mtype, extra=extra, content=text)
out = _llm_chat([{"role": "user", "content": prompt}])
data = _parse_json(out)
model = llm_config()["model"]
detail["raw"] = out[:3000]
detail["model"] = LLM_MODEL
return data, LLM_MODEL, detail
detail["model"] = model
return data, model, detail
def _store_analysis(db, material_id, project_id, scope, data, model, detail):
if scope == "material":
db.execute("DELETE FROM analyses WHERE material_id=? AND scope='material'", (material_id,))
else:
db.execute("DELETE FROM analyses WHERE project_id=? AND scope='project'", (project_id,))
# project 摘要:保留全部历史记录,不做删除
cur = db.execute(
"INSERT INTO analyses (material_id, project_id, scope, summary, key_points, keywords, tags, category, detail, model, created_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
@@ -441,7 +598,8 @@ def _run_project_summary(project_id):
"""
out = _llm_chat([{"role": "user", "content": prompt}])
data = _parse_json(out)
_store_analysis(db, None, project_id, "project", data, LLM_MODEL, {"model": LLM_MODEL, "raw": out[:3000]})
model = llm_config()["model"]
_store_analysis(db, None, project_id, "project", data, model, {"model": model, "raw": out[:3000]})
db.execute("UPDATE projects SET updated_at=? WHERE id=?", (_ts(), project_id))
db.commit()
_tasks[key]["done"] = 1
@@ -517,7 +675,7 @@ def list_projects():
db = get_db()
q = (request.args.get("q") or "").strip()
category = (request.args.get("category") or "").strip()
tag = (request.args.get("tag") or "").strip()
tags_param = (request.args.get("tags") or request.args.get("tag") or "").strip()
sql = "SELECT * FROM projects WHERE 1=1"
args = []
if q:
@@ -527,9 +685,11 @@ def list_projects():
if category:
sql += " AND category=?"
args.append(category)
if tag:
sql += " AND tags LIKE ?"
args.append(f"%{tag}%")
if tags_param:
# 多选标签:项目需同时包含所有选中标签(AND)
for t in [x.strip() for x in tags_param.split(",") if x.strip()]:
sql += " AND tags LIKE ?"
args.append(f"%{t}%")
sql += " ORDER BY updated_at DESC, id DESC"
rows = db.execute(sql, args).fetchall()
return jsonify([project_json(r, db) for r in rows])
@@ -547,6 +707,8 @@ def create_project():
(d.get("category") or "未分类").strip(),
(d.get("tags") or "").strip(), _ts()))
db.commit()
bump_changes(db)
db.commit()
row = db.execute("SELECT * FROM projects WHERE id=?", (cur.lastrowid,)).fetchone()
return jsonify(project_json(row, db))
@@ -575,6 +737,7 @@ def update_project(pid):
(name, (d.get("description", row["description"]) or "").strip(),
(d.get("category", row["category"]) or "未分类").strip(),
(d.get("tags", row["tags"]) or "").strip(), _ts(), pid))
bump_changes(db)
db.commit()
return jsonify(project_json(db.execute("SELECT * FROM projects WHERE id=?", (pid,)).fetchone(), db))
@@ -595,6 +758,7 @@ def delete_project(pid):
db.execute("DELETE FROM analyses WHERE project_id=?", (pid,))
db.execute("DELETE FROM materials WHERE project_id=?", (pid,))
db.execute("DELETE FROM projects WHERE id=?", (pid,))
bump_changes(db)
db.commit()
return jsonify({"ok": True})
@@ -658,6 +822,7 @@ def upload_materials(pid):
mid = cur.lastrowid
_index_material(db, mid, safe, text)
db.execute("UPDATE projects SET updated_at=? WHERE id=?", (_ts(), pid))
bump_changes(db)
db.commit()
row = db.execute("SELECT * FROM materials WHERE id=?", (mid,)).fetchone()
results.append(material_json(row, db))
@@ -680,6 +845,7 @@ def add_text_material(pid):
mid = cur.lastrowid
_index_material(db, mid, name, content)
db.execute("UPDATE projects SET updated_at=? WHERE id=?", (_ts(), pid))
bump_changes(db)
db.commit()
row = db.execute("SELECT * FROM materials WHERE id=?", (mid,)).fetchone()
return jsonify(material_json(row, db))
@@ -703,12 +869,21 @@ def update_material(mid):
return jsonify({"error": "素材不存在"}), 404
name = (d.get("name") or row["name"]).strip()
mtype = (d.get("mtype") or row["mtype"]).strip()
# 支持编辑文本内容:内容变了则旧分析作废,需重新分析
if "content" in d:
new_content = d.get("content") or ""
if new_content != (row["extracted_text"] or ""):
db.execute("UPDATE materials SET extracted_text=?, text_status=?, status='uploaded' WHERE id=?",
(new_content, "extracted" if new_content.strip() else "empty", mid))
db.execute("DELETE FROM analyses WHERE material_id=? AND scope='material'", (mid,))
db.execute("UPDATE materials SET name=?, mtype=? WHERE id=?", (name, mtype, mid))
# 更新索引
row = db.execute("SELECT * FROM materials WHERE id=?", (mid,)).fetchone()
ana = db.execute("SELECT * FROM analyses WHERE material_id=? AND scope='material' ORDER BY id DESC LIMIT 1", (mid,)).fetchone()
kw = ",".join(json.loads(ana["keywords"] or "[]")) if ana else ""
_index_material(db, mid, name, row["extracted_text"] or "", (ana["summary"] if ana else "") or "", kw)
db.execute("UPDATE projects SET updated_at=? WHERE id=?", (_ts(), row["project_id"]))
bump_changes(db)
db.commit()
row2 = db.execute("SELECT * FROM materials WHERE id=?", (mid,)).fetchone()
return jsonify(material_json(row2, db))
@@ -731,6 +906,7 @@ def delete_material(mid):
db.execute("DELETE FROM analyses WHERE material_id=?", (mid,))
db.execute("DELETE FROM materials WHERE id=?", (mid,))
db.execute("UPDATE projects SET updated_at=? WHERE id=?", (_ts(), row["project_id"]))
bump_changes(db)
db.commit()
return jsonify({"ok": True})
@@ -812,11 +988,158 @@ def search():
})
# ---------------- 路由:设置 & 备份 ----------------
@app.get("/api/settings")
def api_get_settings():
return jsonify(get_settings())
@app.put("/api/settings")
def api_put_settings():
d = request.get_json(force=True, silent=True) or {}
db = get_db()
keys = ["llm_base_url", "llm_api_key", "llm_model",
"vision_base_url", "vision_api_key", "vision_model",
"backup_interval_hours", "backup_change_threshold", "backup_max_keep"]
for k in keys:
if k in d:
set_setting(db, k, d[k])
db.commit()
return jsonify(get_settings())
@app.post("/api/settings/test")
def api_test_settings():
d = request.get_json(force=True, silent=True) or {}
kind = d.get("kind", "llm")
base_url = (d.get("base_url") or "").strip()
api_key = (d.get("api_key") or "").strip()
model = (d.get("model") or "").strip()
if not base_url or not api_key or not model:
return jsonify({"ok": False, "error": "请填写完整的接口地址 / Key / 模型名"}), 400
try:
payload = {"model": model, "messages": [{"role": "user", "content": "请用 json 格式回复:{\"status\":\"ok\"}"}], "max_tokens": 30}
if kind == "llm":
payload["response_format"] = {"type": "json_object"}
r = requests.post(f"{base_url.rstrip('/')}/chat/completions",
headers={"Authorization": f"Bearer {api_key}"},
json=payload, timeout=60)
r.raise_for_status()
msg = r.json()["choices"][0]["message"]
reply = msg.get("content") or ""
if not reply.strip() and msg.get("reasoning_content"):
reply = "(推理过程) " + msg["reasoning_content"]
return jsonify({"ok": True, "reply": str(reply)[:200]})
except Exception as e:
return jsonify({"ok": False, "error": str(e)[:300]})
@app.get("/api/backups")
def api_list_backups():
return jsonify({"backups": list_backups(),
"last_backup_time": get_setting("last_backup_time", ""),
"change_counter": get_setting("change_counter", "0")})
@app.post("/api/backup")
def api_make_backup():
try:
name = make_backup(manual=True)
return jsonify({"ok": True, "name": name})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.get("/api/backup/download/<path:name>")
def api_download_backup(name):
safe = Path(name).name
if not re.fullmatch(r"backup_\d{8}_\d{6}(_manual)?\.zip", safe):
abort(400)
p = BACKUP_DIR / safe
if not p.exists():
abort(404)
return send_file(str(p), as_attachment=True, download_name=safe)
@app.delete("/api/backup/<path:name>")
def api_delete_backup(name):
safe = Path(name).name
if not re.fullmatch(r"backup_\d{8}_\d{6}(_manual)?\.zip", safe):
abort(400)
p = BACKUP_DIR / safe
if p.exists():
p.unlink()
return jsonify({"ok": True})
@app.post("/api/backup/restore")
def api_restore_backup():
for k, t in _tasks.items():
if t.get("running"):
return jsonify({"ok": False, "error": "当前有后台任务运行中,请稍后再恢复"}), 409
f = request.files.get("file")
if not f or not f.filename:
return jsonify({"ok": False, "error": "请选择备份文件"}), 400
tmp = DATA_DIR / "_restore_tmp"
if tmp.exists():
shutil.rmtree(str(tmp))
tmp.mkdir()
try:
with zipfile.ZipFile(f.stream) as z:
if "data/material.db" not in z.namelist():
shutil.rmtree(str(tmp))
return jsonify({"ok": False, "error": "备份文件不包含数据库"}), 400
z.extractall(str(tmp))
except Exception as e:
shutil.rmtree(str(tmp))
return jsonify({"ok": False, "error": f"备份文件解析失败: {e}"}), 400
try:
c = sqlite3.connect(str(DB_PATH))
c.execute("PRAGMA wal_checkpoint(TRUNCATE)")
c.close()
db_tmp = tmp / "data" / "material.db"
shutil.copy2(str(db_tmp), str(DB_PATH))
for suffix in ("-wal", "-shm"):
p = Path(str(DB_PATH) + suffix)
if p.exists():
p.unlink()
if UPLOAD_DIR.exists():
shutil.rmtree(str(UPLOAD_DIR))
up_tmp = tmp / "data" / "uploads"
if up_tmp.exists():
shutil.move(str(up_tmp), str(UPLOAD_DIR))
else:
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
init_db()
shutil.rmtree(str(tmp))
return jsonify({"ok": True, "msg": "恢复成功"})
except Exception as e:
shutil.rmtree(str(tmp))
return jsonify({"ok": False, "error": f"恢复失败: {e}"}), 500
# ---------------- 路由:摘要历史 ----------------
@app.get("/api/projects/<int:pid>/summaries")
def project_summaries(pid):
db = get_db()
rows = db.execute("SELECT * FROM analyses WHERE project_id=? AND scope='project' ORDER BY id DESC", (pid,)).fetchall()
out = []
for r in rows:
d = dict(r)
d["key_points"] = json.loads(d["key_points"] or "[]")
d["keywords"] = json.loads(d["keywords"] or "[]")
d["tags"] = json.loads(d["tags"] or "[]")
d["detail"] = json.loads(d["detail"] or "{}")
out.append(d)
return jsonify(out)
app.teardown_appcontext(close_db)
if __name__ == "__main__":
init_db()
threading.Thread(target=_backup_scheduler, daemon=True).start()
port = int(os.environ.get("PORT", "16091"))
print(f"素材库系统启动: http://0.0.0.0:{port}")
app.run(host="0.0.0.0", port=port, debug=False, threaded=True)