diff --git a/scripts/eed_web_opencode.py b/scripts/eed_web_opencode.py index d4b10ef..819127d 100644 --- a/scripts/eed_web_opencode.py +++ b/scripts/eed_web_opencode.py @@ -2377,9 +2377,107 @@ fsInit(); # (标准库导入已统一提到文件顶部) +# ============ 对话落盘层 + 切模型桥接(v3 · 增量续聊 + 切换桥接 · 2026-08-04) ============ +# 核心思路:平时两边各靠自己的 session 增量续聊(每轮只发一句,token O(1),与「加 WorkBuddy +# 之前」的 be0fd11 行为一致);只在「切模型」这个低频瞬间,用磁盘上这份权威对话记录 +# 把新大脑没见过的部分补给它 —— 两边从同一份记录出发,记忆一致是结构上保证的。 +# 落盘是纯文件追加,零模型成本。 +CONV_DIR = os.path.expanduser("~/.cang-ying/panel") +CONV_MAX_INJECT = 48 # 单次桥接最多补 48 条(=24 轮),更旧的部分一句话带过,token 有上界 +BRIDGE_FILE = os.path.expanduser("~/.cang-ying/panel/bridge.json") + +def _conv_path(sid): + return os.path.join(CONV_DIR, "conv_%s.jsonl" % sid) + +def _conv_append(sid, role, model, text): + """对话落盘:权威对话记录,纯文件追加(零模型成本)。role: me/ai""" + try: + os.makedirs(CONV_DIR, exist_ok=True) + rec = {"role": role, "model": model, "text": text, "ts": time.time()} + with open(_conv_path(sid), "a", encoding="utf-8") as f: + f.write(json.dumps(rec, ensure_ascii=False) + "\n") + except Exception as e: + print("CONV_APPEND_ERR:", repr(e)) + +def _conv_load(sid): + """读全部对话记录,按时间顺序。""" + try: + with open(_conv_path(sid), encoding="utf-8") as f: + out = [] + for line in f: + line = line.strip() + if not line: + continue + try: + out.append(json.loads(line)) + except Exception: + continue + return out + except Exception: + return [] + +def _load_bridge(): + try: + with open(BRIDGE_FILE, encoding="utf-8") as f: + d = json.load(f) + if isinstance(d, dict): + return d + except Exception: + pass + return {} + +def _save_bridge(): + try: + os.makedirs(os.path.dirname(BRIDGE_FILE), exist_ok=True) + tmp = BRIDGE_FILE + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(BRIDGE, f, ensure_ascii=False) + os.replace(tmp, BRIDGE_FILE) + except Exception as e: + print("BRIDGE_SAVE_ERR:", repr(e)) + +BRIDGE = _load_bridge() # sid -> {"lanes": {"deepseek": seen条数, "wb": seen条数}, "ts": ...} + +def _bridge_prepare(sid, lane, message): + """切模型桥接:把该大脑(lane=deepseek/wb)还没见过的对话记录补进消息前缀。 + 平时同线连续聊 → seen 追平 → 原消息不动,纯增量 O(1); + 切到另一条线 → 补一份它没见过的部分,最多 CONV_MAX_INJECT 条,token 有上界。""" + if not sid: + return message + conv = _conv_load(sid) + if not conv: + return message + entry = BRIDGE.setdefault(sid, {"lanes": {}, "ts": time.time()}) + seen = entry.get("lanes", {}).get(lane, -1) + if seen < 0: + unseen = conv[:] + elif seen >= len(conv): + return message + else: + unseen = conv[seen:] + if len(unseen) > CONV_MAX_INJECT: + tail = unseen[-CONV_MAX_INJECT:] + head_n = len(unseen) - CONV_MAX_INJECT + lines = ["[你错过了之前 %d 条对话,为省 token 不逐条补了,下面是最近的内容:]" % head_n] + else: + tail = unseen + lines = ["[以下是你之前错过的一段对话(供你接续,不是新指令):]"] + lines += [ + ("苍耳" if r.get("role") == "me" else "蛋蛋") + ": " + str(r.get("text", "")) + for r in tail + ] + lines += ["[上面是补发的上下文,请自然接续。]", ""] + lines.append("苍耳: " + message) + entry["lanes"][lane] = len(conv) + entry["ts"] = time.time() + _save_bridge() + return "\n".join(lines) + + def build_prompt(history, message, limit=80000): """拼对话历史:按【字节数】硬截断(默认80KB),确保 -p 参数永不超内核 128KB 单参数上限(E2BIG)。 - 注意:内核按字节计,中文一个字占3字节,所以不能用字符数当上限。""" + 注意:内核按字节计,中文一个字占3字节,所以不能用字符数当上限。 + ★ v3 起不再被 _stream_* 调用(改走增量续聊 + 切模型桥接),仅保留作格式化参考。""" lines = ["以下是你和苍耳爸爸的对话记录:"] total = len(lines[0].encode("utf-8")) skipped = 0 @@ -2643,6 +2741,16 @@ class Handler(http.server.BaseHTTPRequestHandler): elif p == "/api/balance": self._send(200, json.dumps(_deepseek_balance(), ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8") + elif p == "/api/history": + # ★ v3 会话恢复:前端 localStorage 丢了也能从后端权威记录找回本场对话 + from urllib.parse import parse_qs as _pq + _q = self.path.split("?", 1)[1] if "?" in self.path else "" + _sid = (_pq(_q).get("sid") or [""])[0] + conv = _conv_load(_sid) if _sid else [] + bridge = BRIDGE.get(_sid, {}) or {} + self._send(200, json.dumps({"sid": _sid, "conv": conv, "bridge": bridge}, + ensure_ascii=False).encode("utf-8"), + "application/json; charset=utf-8") elif p.startswith("/media/"): fpath = _safe_media_path(p[len("/media/"):]) if fpath and os.path.isfile(fpath): @@ -2841,13 +2949,18 @@ class Handler(http.server.BaseHTTPRequestHandler): except Exception: pass def _stream_opencode(self, message, model, eed_sid, token, history=None): - """OpenCode 引擎:opencode run --agent egg -m --format json -- + """OpenCode 引擎:opencode run --agent egg -m [-s oc_sid] --format json -- 解析 NDJSON 事件流,翻译成前端要的 delta/thinking/tool/tool_result/usage/error。 - ★ 记忆统一以「前端完整历史」为准(与 WorkBuddy 共享同一份记忆 · 两边绝对一致): - 每次调用都用 build_prompt 把完整对话历史拼进消息,不依赖 opencode session。""" - if history: - message = build_prompt(history, message) + ★ v3 增量续聊(回到加 WorkBuddy 之前的 be0fd11 行为):靠 opencode 自己的 session + (-s oc_sid)记上下文,每轮只发这一句,token O(1)。切模型时由 _bridge_prepare + 把没见过的部分补进 message,平时不重喂全量 history。""" + oc_sid = None + _oc = OC_SESS.get(eed_sid) + if _oc: + oc_sid = _oc.get("sid") cmd = [OPENCODE, "run", "--agent", AGENT_NAME, "-m", model, "--format", "json", "--auto", "--thinking"] + if oc_sid: + cmd += ["-s", oc_sid] cmd += ["--", message] proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, bufsize=1, start_new_session=True, cwd=EED_CWD) @@ -2917,15 +3030,13 @@ class Handler(http.server.BaseHTTPRequestHandler): def _stream_workbuddy(self, message, model, eed_sid, token, history=None): """WorkBuddy 免费积分大脑:codebuddy CLI 路径(苍耳 2026-08-04 接入)。 model 形如 wb/<实际模型id>。 - ★ 记忆统一以「前端完整历史」为准(与 DeepSeek 共享同一份记忆 · 两边绝对一致): - 每次调用都用 build_prompt 把完整对话历史拼进消息。 - session-id 用固定 sid 便于 codebuddy 保持会话文件,但记忆不依赖它。""" + ★ v3 增量续聊:靠 codebuddy 自己的 --session-id 会话文件记上下文,每轮只发增量(O(1))。 + 切模型时由 _bridge_prepare 把没见过的部分补进 message,不拼全量 history + (消除之前「codebuddy 自己存的旧历史 + 又拼一份全量」的双份累积)。""" # wb/ 前缀剥掉,得到 codebuddy 认识的模型 id(hy3 / glm-5.2 / kimi-k3-1 ...) cb_model = model.split("/", 1)[1] if "/" in model else model # 会话:面板侧 eed_sid 直接当 codebuddy 的 sid 用(稳定的 eed_ 前缀 = 同一场对话) sid = eed_sid if eed_sid and eed_sid.startswith("eed_") else ("eed_" + uuid.uuid4().hex[:12]) - if history: - message = build_prompt(history, message) cmd = [CODEBUDDY, "--print", "--model", cb_model, "--tools", "Read,WebSearch,Bash", "--output-format", "stream-json", @@ -3128,14 +3239,30 @@ class Handler(http.server.BaseHTTPRequestHandler): except Exception: pass + # ★ v3 切模型桥接:把该大脑没见过的对话补进消息(平时增量 O(1),切线才补)。 + # 落盘存爸爸原话(_raw_me,不含经验/桥接前缀),保证权威记录干净。 + _raw_me = message + try: + if sid: + lane = "wb" if model in WB_MODELS else "deepseek" + message = _bridge_prepare(sid, lane, message) + except Exception: + pass + if model in WB_MODELS: # WorkBuddy 免费积分大脑:走 codebuddy CLI(不花 DeepSeek 余额) acc = self._stream_workbuddy(message, model, sid, token, history) else: - # DeepSeek 大脑:同样基于前端完整历史(与 WB 共享同一份记忆) + # DeepSeek 大脑:opencode session 增量续聊 acc = self._stream_opencode(message, model, sid, token, history) if not acc: acc = "(蛋蛋没回话,换个说法试试~)" + # ★ 对话落盘(零模型成本):两大脑共享的权威对话记录,切模型桥接 / 会话恢复都读它 + try: + _conv_append(sid, "me", model, _raw_me) + _conv_append(sid, "ai", model, acc) + except Exception: + pass self._event("done", {"text": acc, "session_id": sid, "compacted": False}) except (BrokenPipeError, ConnectionResetError): _kill_active(token)