400 lines
17 KiB
Python
400 lines
17 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"),
|
||
# ── 2026-10-20 治本:provider + MCP 配置同步(替代 model-manager 压制清单)──
|
||
# 事实定论(用户纠正):
|
||
# ① cc-switch 只往 opencode.json 写(增删模型时写 provider 字段),完全不碰 opencode.json.2
|
||
# ② opencode.json.2 是用户自己从 opencode.json 复制来的副本,给 ai-mcp 对调机制用,非 cc-switch 管
|
||
# ③ MCP server 手动加在 opencode.json,不能用 cc-switch 添加
|
||
# 同步策略:
|
||
# 推 opencode.json -> /root/.config/opencode/opencode.json(provider + MCP 完整同步)
|
||
# cc-switch 删/加模型改 opencode.json.provider -> 收尸同步 -> WSL 一致,无需手动压制
|
||
# 注意:opencode.json 不能进 build_plugin_blob 的「Win 路径转 WSL 路径」逻辑(provider 是相对 key,无绝对路径)
|
||
(os.path.join(HOME, ".config", "opencode", "opencode.json"),
|
||
"/root/.config/opencode/opencode.json"),
|
||
(os.path.join(HOME, ".config", "opencode", "model-manager.cfg.json"),
|
||
"/root/.config/opencode/model-manager.cfg.json"),
|
||
]
|
||
|
||
|
||
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 + opencode.json.2:返回 (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 生效份插件 + opencode.json -> {dst_path: base64}。
|
||
|
||
mcpcfg.txt 特殊处理:把 Win 绝对路径(反斜杠)转 WSL 绝对路径。
|
||
model-manager 压制清单:Win 原样推(Win 空则 WSL 也空)。
|
||
注意:opencode.json 的 provider 是相对 key(如 '1'),无绝对路径,直接 base64 推。
|
||
"""
|
||
blob = {}
|
||
# Win 侧 mcpcfg.txt 里是 C://Users//ypshidifu//.config//opencode// 前缀(单反斜杠)
|
||
win_prefix_bk = "C:" + chr(92) + "Users" + chr(92) + "ypshidifu" + chr(92) + ".config" + chr(92) + "opencode" + chr(92)
|
||
win_prefix_sl = "C:/Users/ypshidifu/.config/opencode/"
|
||
wsl_prefix = "/root/.config/opencode/"
|
||
for src, dst in PLUGINS:
|
||
if not os.path.exists(src):
|
||
continue
|
||
try:
|
||
data = open(src, "rb").read()
|
||
# mcpcfg.txt:Win 路径前缀 -> WSL 路径前缀
|
||
if os.path.basename(src) == "mcpcfg.txt":
|
||
text = data.decode("utf-8")
|
||
text = text.replace(win_prefix_bk, wsl_prefix)
|
||
text = text.replace(win_prefix_sl, wsl_prefix)
|
||
data = text.encode("utf-8")
|
||
blob[dst] = base64.b64encode(data).decode("ascii")
|
||
except Exception:
|
||
pass
|
||
# model-manager 压制清单(Win 原样推,Win 空则 WSL 也空)
|
||
for fname in ["model-manager.removed.json", "model-manager.disabled.json",
|
||
"model-manager.stoplist.bak.json"]:
|
||
fsrc = os.path.join(HOME, ".config", "opencode", fname)
|
||
fdst = "/root/.config/opencode/" + fname
|
||
if os.path.exists(fsrc):
|
||
try:
|
||
blob[fdst] = base64.b64encode(open(fsrc, "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'")
|
||
# ★ 按 WSL kv 表实际列动态插入(补 time_created 等, 避免 NOT NULL 约束失败)
|
||
kv_cols = [r[1] for r in cur.execute("PRAGMA table_info(kv)")]
|
||
now = int(time.time())
|
||
row = {"key": "models-dev:catalog", "value": catalog_raw,
|
||
"time_created": now, "time_updated": now}
|
||
cols = [k for k in row.keys() if k in kv_cols]
|
||
# WSL kv 有但上面没给的列 -> 补 NULL/0
|
||
for k in kv_cols:
|
||
if k not in row:
|
||
row[k] = now if k.startswith("time_") else None
|
||
cols.append(k)
|
||
cur.execute(
|
||
"INSERT INTO kv (%s) VALUES (%s)" % (",".join(cols), ",".join("?" * len(cols))),
|
||
[row[k] for k in cols])
|
||
res["steps"].append("catalog-cleared+overwritten(cols=%s)" % cols)
|
||
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 = dict(c)
|
||
cols = [k for k in row.keys() if k in cred_cols]
|
||
# WSL credential 有但 Win 没给的列 -> 按类型补默认值, 避免 NOT NULL 约束失败
|
||
for k in cred_cols:
|
||
if k not in row:
|
||
if k.startswith("time_"):
|
||
row[k] = int(time.time())
|
||
elif k == "active":
|
||
row[k] = 0
|
||
elif k == "id":
|
||
# 无 id 或 id 缺失时生成稳定 id(避免 PRIMARY KEY 冲突与 NOT NULL)
|
||
row[k] = "win_" + (integ or "unknown").replace("/", "_")
|
||
else:
|
||
row[k] = None
|
||
cols.append(k)
|
||
# label 是 NOT NULL:Win 可能给空串/None,确保非 None
|
||
if row.get("label") is None:
|
||
row["label"] = row.get("id") or row.get("integration_id") or "win"
|
||
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)
|