333 lines
13 KiB
Python
333 lines
13 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
oc-model-sync.py - 模型数据同步: 本机(Win) -> WSL。
|
|
收尸链路 step 6a 调用(在 session-sync 之前)。设计(2026-10-02; 2026-10-12; 2026-10-20 二次重定位):
|
|
|
|
★ 2026-10-20 二次修正(用户实锤「大错特错」): 上一版把数据推到 WSL 的 *config provider 文件*
|
|
(.oc-model-sync.json / opencode.json), 但 OC 真正读的是三处不同数据源:
|
|
① /models 主列表 <- DB catalog (models-dev:catalog)
|
|
② /manage 面板 <- config provider (opencode.json) ∩ catalog ∩ credential
|
|
③ xiaomi 原厂模型 <- credential 直连(active 的 integration), OC 动态拉取
|
|
推错树 = /models 不变、/manage 只显示 1/localhost 两个 config 源、xiaomi 读不出。
|
|
且「只增不删」合并让你 cc-switch 已移除的模型在 WSL 残留。
|
|
|
|
本版(彻底对齐 Win 现状):
|
|
- 数据源 = Win 的 opencode.db(唯一真源):
|
|
catalog: kv 表 key='models-dev:catalog'(OC 动态拉取的全部 provider/models)
|
|
credential: credential 表全量(含 xiaomi 原厂密钥, 决定 /manage 能否命中 xiaomi)
|
|
model.json: ~/.local/state/opencode/model.json (recent/favorite 使用历史)
|
|
- 传输: ① catalog(6.6MB) 推远端 ② credential(小) 推远端 ③ model.json 推远端
|
|
④ 插件 blob(tui.js v12 / model-manager.ts / mcpcfg.txt) 推远端
|
|
- WSL import(★ 覆盖式, 删旧加新):
|
|
* 备份 WSL db -> /root/.local/share/opencode/opencode.db.bak-modelsync
|
|
* 覆盖 WSL 的 models-dev:catalog = Win catalog(WSL /models 与 Win 完全一致, 已删的厂商消失)
|
|
* 覆盖 WSL credential = Win credential(保留 WSL 独有的 active credential? 否: 镜像 Win)
|
|
* 写 WSL model.json = Win model.json(recent/favorite 对齐)
|
|
* 写插件到 WSL 生效路径(修 /mcpon /mcpoff /manage 失效)
|
|
* 任何一步失败 -> 回滚(用备份恢复 WSL db), 绝不写坏 WSL
|
|
- 幂等: 指纹 = Win catalog sha + credential sha + model.json sha + 插件 sha, 无变化秒退
|
|
- 预算 150s; 异常吞掉只写日志([model-sync] 前缀), 绝不阻塞退出链路
|
|
"""
|
|
import base64
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
try:
|
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
|
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
|
except Exception:
|
|
pass
|
|
|
|
TAG = "[model-sync]"
|
|
HOME = os.path.expanduser("~")
|
|
WIN_DB = os.path.join(HOME, ".local", "share", "opencode", "opencode.db")
|
|
WIN_STATE = os.path.join(HOME, ".local", "state", "opencode", "model.json")
|
|
STATE = os.path.join(HOME, ".config", "opencode", ".oc_model_state.json")
|
|
LOG = os.path.join(HOME, ".config", "opencode", "oc-session-all.log")
|
|
DISTRO = "Ubuntu-24.04"
|
|
BUDGET = 150
|
|
REMOTE_CATALOG = "/root/.oc_catalog.json"
|
|
REMOTE_CREDS = "/root/.oc_creds.json"
|
|
REMOTE_STATE = "/root/.oc_state.json"
|
|
REMOTE_PLUGINS = "/root/.oc_plugins.json"
|
|
REMOTE_RESULT = "/root/.oc_model_result.json"
|
|
|
|
# Win 生效份插件 -> WSL 生效路径(修「插件对不上」: WSL 落后导致 /mcpon /mcpoff /manage 失效)
|
|
PLUGINS = [
|
|
(os.path.join(HOME, ".config", "opencode", "plugins", "oc-session-all", "tui.js"),
|
|
"/root/.config/opencode/plugins/oc-session-all/tui.js"),
|
|
(os.path.join(HOME, ".config", "opencode", "plugins", "model-manager.ts"),
|
|
"/root/.config/opencode/plugins/model-manager.ts"),
|
|
(os.path.join(HOME, ".config", "opencode", "mcpcfg.txt"),
|
|
"/root/.config/opencode/mcpcfg.txt"),
|
|
]
|
|
|
|
|
|
def log(m):
|
|
try:
|
|
with open(LOG, "a", encoding="utf-8") as f:
|
|
f.write("[%s] %s %s\n" % (time.strftime("%Y-%m-%dT%H:%M:%S"), TAG, m))
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def fingerprint():
|
|
"""Win 侧数据源 + 插件 的内容指纹(全量 sha, 任何变都触发重推)。"""
|
|
h = hashlib.sha256()
|
|
try:
|
|
h.update(hashlib.sha256(open(WIN_DB, "rb").read()).digest())
|
|
except Exception:
|
|
h.update(b"no-win-db")
|
|
try:
|
|
h.update(hashlib.sha256(open(WIN_STATE, "rb").read()).digest())
|
|
except Exception:
|
|
h.update(b"no-win-state")
|
|
for src, _ in PLUGINS:
|
|
try:
|
|
h.update(hashlib.sha256(open(src, "rb").read()).digest())
|
|
except Exception:
|
|
h.update(b"no-plugin")
|
|
return h.hexdigest()
|
|
|
|
|
|
def read_win_data():
|
|
"""只读 Win db: 返回 (catalog_value, credentials_json, n_creds)。"""
|
|
import sqlite3 as _s
|
|
con = _s.connect("file:%s?mode=ro" % WIN_DB.replace("\\", "/"), uri=True)
|
|
try:
|
|
cur = con.cursor()
|
|
row = cur.execute("SELECT value FROM kv WHERE key='models-dev:catalog'").fetchone()
|
|
catalog = row[0] if row else None
|
|
cols = [r[1] for r in cur.execute("PRAGMA table_info(credential)")]
|
|
cre_rows = cur.execute("SELECT * FROM credential").fetchall()
|
|
creds = [dict(zip(cols, r)) for r in cre_rows]
|
|
finally:
|
|
con.close()
|
|
return catalog, creds
|
|
|
|
|
|
def build_plugin_blob():
|
|
"""Win 生效份插件 -> {dst_path: base64}。"""
|
|
blob = {}
|
|
for src, dst in PLUGINS:
|
|
if not os.path.exists(src):
|
|
continue
|
|
try:
|
|
blob[dst] = base64.b64encode(open(src, "rb").read()).decode("ascii")
|
|
except Exception:
|
|
pass
|
|
return blob
|
|
|
|
|
|
def wsl_push(data_bytes, remote_path, timeout=150):
|
|
cmd = 'cat > "%s"' % remote_path
|
|
r = subprocess.run(
|
|
["wsl.exe", "-d", DISTRO, "--", "bash", "-c", cmd],
|
|
input=data_bytes,
|
|
capture_output=True,
|
|
timeout=timeout,
|
|
)
|
|
return r.returncode
|
|
|
|
|
|
def wsl_py(src, timeout=150):
|
|
r = subprocess.run(
|
|
["wsl.exe", "-d", DISTRO, "--", "python3", "-"],
|
|
input=src.encode("utf-8"),
|
|
capture_output=True,
|
|
timeout=timeout,
|
|
)
|
|
return r.returncode, r.stdout
|
|
|
|
|
|
def wsl_cat(remote_path, timeout=60):
|
|
r = subprocess.run(
|
|
["wsl.exe", "-d", DISTRO, "--", "cat", remote_path],
|
|
capture_output=True,
|
|
timeout=timeout,
|
|
)
|
|
raw = r.stdout or b""
|
|
if raw[:1] == b"{":
|
|
return raw.decode("utf-8", errors="replace")
|
|
try:
|
|
t = raw.decode("utf-16-le")
|
|
if "{" in t:
|
|
return t[t.index("{"):]
|
|
except Exception:
|
|
pass
|
|
return raw.decode("utf-8", errors="replace")
|
|
|
|
|
|
# ── WSL 侧 import 脚本(★ 覆盖式 + 备份回滚)────────────────────────────────
|
|
IMPORT_SRC = r'''
|
|
import json, os, sqlite3, shutil, time, base64
|
|
res = {"ok": False, "steps": []}
|
|
try:
|
|
WSL_DB = "/root/.local/share/opencode/opencode.db"
|
|
WSL_STATE = "/root/.local/state/opencode/model.json"
|
|
BACKUP = WSL_DB + ".bak-modelsync"
|
|
|
|
catalog_raw = open("/root/.oc_catalog.json", encoding="utf-8").read()
|
|
creds = json.load(open("/root/.oc_creds.json", encoding="utf-8"))
|
|
state = open("/root/.oc_state.json", encoding="utf-8").read()
|
|
plugin_blob = json.load(open("/root/.oc_plugins.json", encoding="utf-8"))
|
|
|
|
# ① 备份 WSL db(覆盖写前必做)
|
|
if os.path.exists(WSL_DB):
|
|
shutil.copy2(WSL_DB, BACKUP)
|
|
res["steps"].append("backup:" + BACKUP)
|
|
else:
|
|
res["steps"].append("no-wsl-db")
|
|
|
|
# ② 覆盖 catalog + credential(删旧加新, 镜像 Win)
|
|
con = sqlite3.connect(WSL_DB)
|
|
cur = con.cursor()
|
|
# 表缺失才建(按 OC 真实 schema: credential.id 是 TEXT, 不是 INTEGER)
|
|
cur.execute("""CREATE TABLE IF NOT EXISTS kv (
|
|
key TEXT PRIMARY KEY, value TEXT, time_updated INTEGER)""")
|
|
cur.execute("""CREATE TABLE IF NOT EXISTS credential (
|
|
id TEXT, integration_id TEXT, label TEXT, value TEXT,
|
|
connector_id TEXT, method_id TEXT, active INTEGER,
|
|
time_created INTEGER, time_updated INTEGER)""")
|
|
if catalog_raw:
|
|
cur.execute("DELETE FROM kv WHERE key='models-dev:catalog'")
|
|
cur.execute("INSERT INTO kv (key, value, time_updated) VALUES (?,?,?)",
|
|
("models-dev:catalog", catalog_raw, int(time.time())))
|
|
res["steps"].append("catalog-cleared+overwritten")
|
|
else:
|
|
res["steps"].append("catalog-missing-skip")
|
|
# credential: 并集语义 —— Win 的 credential 整份灌入, 但 WSL 独有的 integration_id
|
|
# 保留不动(不破坏 WSL 本地连的别家厂商); Win 同名的按 Win 覆盖(值/active 以 Win 为准)。
|
|
cred_cols = [r[1] for r in cur.execute("PRAGMA table_info(credential)")]
|
|
win_by_integ = {}
|
|
for c in creds:
|
|
win_by_integ[c.get("integration_id")] = c
|
|
existing = cur.execute("SELECT * FROM credential").fetchall()
|
|
ex_cols = [r[1] for r in cur.execute("PRAGMA table_info(credential)")]
|
|
wsl_only_integs = []
|
|
for r in existing:
|
|
ed = dict(zip(ex_cols, r))
|
|
integ = ed.get("integration_id")
|
|
if integ in win_by_integ:
|
|
# WSL 同名的删掉, 下面用 Win 值重灌
|
|
cur.execute("DELETE FROM credential WHERE id=?", (ed.get("id"),))
|
|
else:
|
|
wsl_only_integs.append(integ) # WSL 独有, 保留
|
|
for integ, c in win_by_integ.items():
|
|
row = c
|
|
cols = [k for k in row.keys() if k in cred_cols]
|
|
if not cols:
|
|
continue
|
|
# 防重复: 若 WSL 里已存在同名(可能上面没删净), 先清
|
|
cur.execute("DELETE FROM credential WHERE integration_id=?", (integ,))
|
|
cur.execute(
|
|
"INSERT INTO credential (%s) VALUES (%s)" % (",".join(cols), ",".join("?" * len(cols))),
|
|
[row.get(k) for k in cols])
|
|
con.commit()
|
|
con.close()
|
|
res["steps"].append("credential-merged(win=%d, wsl_only_kept=%s)" %
|
|
(len(win_by_integ), wsl_only_integs))
|
|
|
|
# ③ 写 model.json(recent/favorite 对齐)
|
|
try:
|
|
os.makedirs(os.path.dirname(WSL_STATE), exist_ok=True)
|
|
open(WSL_STATE, "w", encoding="utf-8").write(state)
|
|
res["steps"].append("model.json-overwritten")
|
|
except Exception as e:
|
|
res["steps"].append("model.json-err:" + str(e))
|
|
|
|
# ④ 写插件(修 /mcpon /mcpoff /manage 失效)
|
|
for dst, b64 in plugin_blob.items():
|
|
try:
|
|
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
|
open(dst, "wb").write(base64.b64decode(b64))
|
|
except Exception as e:
|
|
res["steps"].append("plugin-err:" + dst + ":" + str(e))
|
|
res["steps"].append("plugins-written(%d)" % len(plugin_blob))
|
|
res["ok"] = True
|
|
except Exception as e:
|
|
# 任何一步失败 -> 回滚 WSL db
|
|
res["err"] = str(e)
|
|
try:
|
|
if os.path.exists(BACKUP) and os.path.exists(WSL_DB):
|
|
shutil.copy2(BACKUP, WSL_DB)
|
|
res["rolled_back"] = True
|
|
res["steps"].append("ROLLBACK:" + BACKUP)
|
|
except Exception as e2:
|
|
res["rollback_err"] = str(e2)
|
|
open("/root/.oc_model_result.json", "w", encoding="utf-8").write(json.dumps(res))
|
|
'''
|
|
|
|
|
|
def main():
|
|
t0 = time.time()
|
|
fp = fingerprint()
|
|
try:
|
|
state = json.load(open(STATE, encoding="utf-8")).get("fp")
|
|
except Exception:
|
|
state = None
|
|
if state == fp:
|
|
log("unchanged, skip (fp=%s)" % fp[:12])
|
|
return
|
|
|
|
if not os.path.exists(WIN_DB):
|
|
log("win db missing: " + WIN_DB)
|
|
return
|
|
catalog, creds = read_win_data()
|
|
log("export: catalog=%dB credentials=%d (fp=%s)" %
|
|
((len(catalog) if catalog else 0), len(creds), fp[:12]))
|
|
if not catalog:
|
|
log("no catalog in win db, nothing to push")
|
|
return
|
|
|
|
# 推 catalog(raw 字符串)
|
|
try:
|
|
rc = wsl_push(catalog.encode("utf-8"), REMOTE_CATALOG, timeout=200)
|
|
if rc != 0:
|
|
log("catalog push rc=%d" % rc)
|
|
return
|
|
# 推 credentials
|
|
rc = wsl_push(json.dumps(creds, ensure_ascii=False).encode("utf-8"),
|
|
REMOTE_CREDS, timeout=120)
|
|
if rc != 0:
|
|
log("creds push rc=%d" % rc)
|
|
return
|
|
# 推 model.json
|
|
rc = 0
|
|
if os.path.exists(WIN_STATE):
|
|
rc = wsl_push(open(WIN_STATE, "rb").read(), REMOTE_STATE, timeout=120)
|
|
if rc != 0:
|
|
log("state push rc=%d" % rc)
|
|
return
|
|
# 推插件 blob
|
|
rc = wsl_push(json.dumps(build_plugin_blob(), ensure_ascii=False).encode("utf-8"),
|
|
REMOTE_PLUGINS, timeout=120)
|
|
if rc != 0:
|
|
log("plugins push rc=%d" % rc)
|
|
return
|
|
# 跑 import(覆盖 WSL db + model.json + 插件, 带备份回滚)
|
|
rc, _ = wsl_py(IMPORT_SRC, timeout=200)
|
|
if rc != 0:
|
|
log("import rc=%d" % rc)
|
|
return
|
|
res = json.loads(wsl_cat(REMOTE_RESULT))
|
|
log("import result: " + json.dumps(res, ensure_ascii=False)[:400])
|
|
json.dump({"fp": fp}, open(STATE, "w", encoding="utf-8"))
|
|
log("state updated in %ds" % int(time.time() - t0))
|
|
except Exception as e:
|
|
log("exception: " + str(e))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except Exception as e:
|
|
log("FATAL: " + str(e))
|
|
sys.exit(0)
|