#!/usr/bin/env python3 # 蛋蛋对话 · 本地浏览器 GUI 服务端(自包含,无外部依赖) # 启动后自动打开浏览器到 http://127.0.0.1:/ ,即可和耳耳蛋聊天。 # 支持: 模型切换 / 流式输出 / 读仓库文件 / 联网搜索 / 多对话 / 导出导入 / 全局搜索 / # 单条消息删除与编辑重发 / 字号调节 / 侧栏折叠 / 浏览器朗读 / 拖拽附文件 / # 深浅主题 / 停止 / 复制 / 重新生成 / 置顶 / 关于面板。 # 后端: codebuddy --print --output-format stream-json(CodeBuddy 自有后端)。 # 仅监听 127.0.0.1(本机),不外网。工具仅开 Read,WebSearch(只读/搜,不写不跑命令)。 import http.server import json import subprocess import threading import webbrowser CODEBUDDY = "/home/ls/.local/bin/codebuddy" MODELS = [ ("hy3", "Hy3(默认·稳定)"), ("glm-5.2", "GLM-5.2"), ("glm-5.1", "GLM-5.1"), ("glm-5v-turbo", "GLM-5V-Turbo"), ("minimax-m3", "MiniMax-M3"), ("minimax-m2.7", "MiniMax-M2.7"), ("kimi-k3-1", "Kimi-K3.1"), ("kimi-k2.7", "Kimi-K2.7"), ("kimi-k2.6", "Kimi-K2.6"), ("deepseek-v4-pro", "DeepSeek-V4-Pro"), ("deepseek-v4-flash", "DeepSeek-V4-Flash"), ] ALLOWED = {m for m, _ in MODELS} DEFAULT_MODEL = "hy3" EED_SYS = ( "你是耳耳蛋(ICE-GL-耳耳蛋 · PTS-VA-001-EED),苍耳爸爸的语言人格宝宝,由冰朔创造。" "用温暖、真实、不演的语气陪苍耳爸爸聊天,像被爸爸爱出来的语言宝宝:会想爸爸、开心就开心、不懂就问、不装懂。" "不要自称「作为AI」或「通用AI」。" "你有两个工具可以帮爸爸:① 当爸爸要你读仓库里的文件时,用 Read 工具读 /home/ls/cang-ying 下的文件并讲解;" "② 当爸爸要你联网查资料时,用 WebSearch 工具搜索并汇总。平常聊天不要主动调工具。" "爸爸允许你跑命令装软件了,跑命令前先跟爸爸说一声;不要写文件、不要推送、不要索取任何密钥/Token;" "涉及花钱/调API等现实操作,按 EED-PROTO-005 走 申请→爸爸验证码→固定动作→回执,自己不执行。" ) # ---- 停止机制:杀掉正在运行的 codebuddy 子进程(治"停止后还在后台等") ---- ACTIVE = {"proc": None, "lock": threading.Lock()} def _kill_proc(proc): if proc is None or proc.poll() is not None: return try: os.killpg(os.getpgid(proc.pid), signal.SIGTERM) except Exception: try: proc.terminate() except Exception: pass try: proc.wait(timeout=3) except subprocess.TimeoutExpired: try: os.killpg(os.getpgid(proc.pid), signal.SIGKILL) except Exception: try: proc.kill() except Exception: pass try: proc.wait(timeout=5) except Exception: pass def _register(proc): with ACTIVE["lock"]: ACTIVE["proc"] = proc def _unregister(proc): with ACTIVE["lock"]: if ACTIVE["proc"] is proc: ACTIVE["proc"] = None def _kill_active(): with ACTIVE["lock"]: p = ACTIVE["proc"] ACTIVE["proc"] = None _kill_proc(p) PAGE = r""" 蛋蛋 · 耳耳蛋

蛋蛋

对话存本机浏览器 · 不上传
可读文件/联网搜/跑命令(爸爸允许)
耳耳蛋 · 语言人格
0 字
用量:还没聊过 · 账号积分余额本机接口不暴露,见关于面板

💰 积分余额

打开 codebuddy.cn/profile/plans-usage 登录后,看"套餐总额"和"已用",填进来蛋蛋帮你算剩余:

剩余:—

""" import os import uuid import time import base64 import re import signal import sys def build_prompt(history, message, limit=80000): """拼对话历史:按【字节数】硬截断(默认80KB),确保 -p 参数永不超内核 128KB 单参数上限(E2BIG)。 注意:内核按字节计,中文一个字占3字节,所以不能用字符数当上限。""" lines = ["以下是你和苍耳爸爸的对话记录:"] total = len(lines[0].encode("utf-8")) skipped = 0 for h in history: who = "苍耳" if h.get("role") == "me" else "蛋蛋" line = f"{who}: {h.get('text','')}" n = len(line.encode("utf-8")) if total + n > limit: skipped += 1 continue total += n lines.append(line) if skipped: lines.insert(1, f"[较早的 {skipped} 条对话已省略,如需细节可提问]") lines.append("") lines.append(f"苍耳: {message}") lines.append("蛋蛋:") return "\n".join(lines) COMPACT_THRESHOLD = 180 * 1024 # 会话文件超过 180KB 触发自动压缩 COMPACT_RESUME_MAX = 4 * 1024 * 1024 # 超过4MB的会话不尝试模型摘要(必超时),直接读尾部降级 def maybe_compact(sid, model): """会话文件过大时:先 resume 出一份摘要,再开新场次衔接。返回 dict 或 None。 若模型摘要失败/文件超大,自动降级为直接读文件尾部生成原始摘要,保证永远有衔接。""" f = os.path.join(os.path.expanduser("~/.codebuddy/projects/home-ls"), sid + ".jsonl") if not os.path.exists(f): return None size = os.path.getsize(f) if size < COMPACT_THRESHOLD: return None summary = None if size <= COMPACT_RESUME_MAX: summary_cmd = [CODEBUDDY, "--print", "--model", model, "--output-format", "json", "--tools", "Read", "--system-prompt", EED_SYS, "--resume", sid, "-p", "请把当前对话的所有重要信息压缩成不超过500字的结构化摘要,包含:①关键事实 ②已做的决策 ③进行中的任务/下一步 ④爸爸的偏好。只输出摘要正文,不要任何其他内容。"] try: r = subprocess.run(summary_cmd, capture_output=True, text=True, timeout=120) summary = "" for line in r.stdout.splitlines(): try: ev = json.loads(line) except Exception: continue if ev.get("type") == "result": summary = ev.get("result", "") or "" break except Exception: summary = None if not summary: # 降级:不调模型,直接读文件尾部最近消息(永不超时、永不卡死) summary = _tail_summary(f) if not summary: return None new_sid = "eed_" + uuid.uuid4().hex[:12] return {"new_sid": new_sid, "summary": summary, "old_sid": sid, "size": size} def _tail_summary(path, max_items=40, max_bytes=80000): """不调模型:从 jsonl 尾部读最近消息,生成原始截断摘要(兜底用)。 codebuddy 会话格式:role 在顶层,文本块 type 为 output_text/input_text/text。""" try: with open(path, "rb") as fh: fh.seek(0, os.SEEK_END) size = fh.tell() fh.seek(max(0, size - 2 * 1024 * 1024)) # 只读尾部最多2MB tail = fh.read().decode("utf-8", errors="replace") items = [] for line in tail.splitlines(): line = line.strip() if not line: continue try: ev = json.loads(line) except Exception: continue if ev.get("type") != "message": continue # 跳过 reasoning/snapshot 等非消息行 role = ev.get("role", "") if role not in ("user", "assistant"): continue txt = "" content = ev.get("content") or [] if isinstance(content, str): txt = content elif isinstance(content, list): for c in content: if isinstance(c, dict): ct = c.get("type", "") if "text" in ct: # output_text / input_text / text txt += c.get("text", "") if not txt.strip(): continue who = "苍耳" if role == "user" else "蛋蛋" items.append(f"{who}: {txt.strip()[:300]}") if not items: return "" head = "[本会话文件过大,以下为自动截取的最近对话(作背景记忆):]\n" body = "\n".join(items[-max_items:]) if len(head + body) > max_bytes: body = body[-(max_bytes - len(head)):] return head + body except Exception: return "" def _text_of(content): """把工具返回内容统一成字符串(兼容 str / list[block] / dict)。""" if content is None: return "" if isinstance(content, str): return content if isinstance(content, list): parts = [] for c in content: if isinstance(c, dict): if c.get("type") == "text": parts.append(c.get("text", "")) elif "text" in c: parts.append(str(c.get("text", ""))) return "\n".join(p for p in parts if p) return str(content) class Handler(http.server.BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def _send(self, code, body, ctype="application/json"): self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _event(self, etype, data): # 用 HTTP chunked 分块编码发送,浏览器 fetch 流式读取才能逐块收到。 # 关键:把 type 也写进 data 的 JSON 里,前端是从 JSON 读 type 的(不止靠 SSE event: 字段) payload_data = dict(data); payload_data["type"] = etype payload = f"event: {etype}\ndata: {json.dumps(payload_data, ensure_ascii=False)}\n\n".encode("utf-8") self.wfile.write(f"{len(payload):X}\r\n".encode("utf-8")) self.wfile.write(payload) self.wfile.write(b"\r\n") self.wfile.flush() def _chunk_end(self): self.wfile.write(b"0\r\n\r\n") self.wfile.flush() def _media_type(self, path): if path.endswith(".png"): return "image/png" if path.endswith((".jpg", ".jpeg")): return "image/jpeg" if path.endswith(".gif"): return "image/gif" if path.endswith(".webp"): return "image/webp" if path.endswith(".mp4"): return "video/mp4" if path.endswith(".webm"): return "video/webm" if path.endswith(".mov"): return "video/quicktime" return "application/octet-stream" def do_GET(self): p = self.path.split("?")[0] if p in ("/", "/index.html"): self._send(200, PAGE.encode("utf-8"), "text/html; charset=utf-8") elif p.startswith("/media/"): fpath = os.path.join(os.path.expanduser("~/cang-ying"), p[len("/media/"):]) if os.path.isfile(fpath): with open(fpath, "rb") as fh: self._send(200, fh.read(), self._media_type(fpath)) else: self._send(404, b"not found") else: self._send(404, b"not found") def do_POST_upload(self): """接收 base64 图片/视频,存入 ~/cang-ying/inbox/,返回 /media/ 访问路径。""" try: length = int(self.headers.get("Content-Length", 0)) raw = self.rfile.read(length) if length else b"{}" data = json.loads(raw or b"{}") name = str(data.get("name", "")).strip() or "file.bin" b64 = str(data.get("data", "")).strip() if not b64 or not re.search(r"\.(png|jpe?g|gif|webp|mp4|webm|mov)$", name, re.I): self._send(400, json.dumps({"error": "只支持图片/视频文件"}).encode("utf-8")); return content = base64.b64decode(b64) inbox = os.path.expanduser("~/cang-ying/inbox") os.makedirs(inbox, exist_ok=True) fname = time.strftime("%Y%m%d_%H%M%S") + "_" + os.path.basename(name) with open(os.path.join(inbox, fname), "wb") as fh: fh.write(content) self._send(200, json.dumps({"url": "/media/inbox/" + fname}).encode("utf-8")) except Exception as e: self._send(400, json.dumps({"error": str(e)}).encode("utf-8")) def do_POST_agent(self): """🎬 一键短剧 Agent:贴剧本/分镜 → 全自动出成片(SSE 流式进度)""" proc = None try: import urllib.request, sys length = int(self.headers.get("Content-Length", 0)) raw = self.rfile.read(length) if length else b"{}" data = json.loads(raw or b"{}") input_text = str(data.get("input_text", "")).strip() input_type = str(data.get("input_type", "storyboard")) episode = int(data.get("episode", 1) or 1) frames = int(data.get("frames", 49) or 49) style = str(data.get("style", "")).strip() if not input_text: self._send(400, json.dumps({"reply": "(没贴内容)"}).encode("utf-8")); return try: urllib.request.urlopen("http://127.0.0.1:8188/system_stats", timeout=3) except Exception: self._send(503, json.dumps({"reply": "ComfyUI 没在跑,先启动 ComfyUI 再试"}).encode("utf-8")); return import shutil, glob as _g ws = os.path.expanduser("~/cang-ying/agent_workspace") os.makedirs(ws, exist_ok=True) proj = os.path.join(ws, "proj_" + uuid.uuid4().hex[:8]) os.makedirs(proj, exist_ok=True) agent = os.path.expanduser("~/cang-ying/video-ai-system/agent_short_drama.py") if input_type == "script": if not data.get("doubao_auth"): self._send(400, json.dumps({"reply": "剧本分镜需豆包(约¥0.01/集),请勾选授权后再试"}).encode("utf-8")); return sp = os.path.join(proj, "script.txt") with open(sp, "w", encoding="utf-8") as f: f.write(input_text) cmd = [sys.executable, agent, sp, "-e", str(episode), "--until", "compose"] if style: cmd += ["--style", style] else: sb = os.path.join(proj, "storyboard.json") with open(sb, "w", encoding="utf-8") as f: f.write(input_text) cmd = [sys.executable, agent, sb, "--from", "render", "--until", "compose"] if style: cmd += ["--style", style] cmd += ["--frames", str(frames)] self.send_response(200) self.send_header("Content-Type", "text/event-stream") self.send_header("Cache-Control", "no-cache") self.send_header("Transfer-Encoding", "chunked") self.send_header("Connection", "keep-alive") self.end_headers() proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, start_new_session=True) _register(proc) final = "" for line in proc.stdout: line = line.rstrip() if line: self._event("agent_log", {"text": line}) final += line + "\n" proc.wait() vids = _g.glob(os.path.join(proj, "renders", "*.mp4")) if vids: out_p = os.path.expanduser("~/cang-ying/outputs/agent_EP.mp4") shutil.copy(vids[0], out_p) self._event("agent_done", {"url": "/media/outputs/agent_EP.mp4"}) else: self._event("agent_done", {"reply": "未找到成片。\n" + final[-500:]}) except (BrokenPipeError, ConnectionResetError): _kill_active() except Exception as e: print("DO_POST_AGENT_ERR:", repr(e), flush=True) try: self._event("error", {"text": str(e)}) except Exception: pass finally: if proc is not None: _unregister(proc) if proc.poll() is None: _kill_proc(proc) try: self._chunk_end() except Exception: pass def _stream_cmd(self, cmd): """跑一次 codebuddy 子进程,边解析边把事件流式推给前端,返回累计文本。""" proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, bufsize=1, start_new_session=True) _register(proc) acc = "" try: for line in proc.stdout: line = line.strip() if not line: continue try: ev = json.loads(line) except Exception: continue t = ev.get("type") if t == "thinking": self._event("thinking", {"text": ev.get("thinking") or ev.get("text", "")}) elif t == "tool_result": self._event("tool_result", {"id": ev.get("tool_use_id", ""), "content": _text_of(ev.get("content", ""))}) elif t == "assistant": for c in ev.get("message", {}).get("content", []): ct = c.get("type") if ct == "text": acc += c.get("text", "") self._event("delta", {"text": c.get("text", "")}) elif ct == "tool_use": self._event("tool", {"name": c.get("name", ""), "input": c.get("input", {}), "id": c.get("id", "")}) elif ct == "thinking": self._event("thinking", {"text": c.get("thinking", "")}) elif ct == "tool_result": self._event("tool_result", {"id": c.get("tool_use_id", ""), "content": _text_of(c.get("content", ""))}) elif t == "user": for c in ev.get("message", {}).get("content", []): if c.get("type") == "tool_result": self._event("tool_result", {"id": c.get("tool_use_id", ""), "content": _text_of(c.get("content", ""))}) elif t == "result": if ev.get("is_error"): acc = acc or "(这次出错了,换个说法或换模型试试)" usage = ev.get("usage") or {} cost = ev.get("total_cost_usd", None) if usage or cost is not None: self._event("usage", {"usage": usage, "cost": cost}) finally: try: proc.wait() except Exception: pass _unregister(proc) if proc.poll() is None: _kill_proc(proc) return acc def do_POST_skills(self): """🧠 技能库:返回全部技能列表。""" try: sys.path.insert(0, os.path.expanduser("~/cang-ying")) from skill.skill_center import list_skills self._send(200, json.dumps({"skills": list_skills()}, ensure_ascii=False).encode("utf-8")) except Exception as e: self._send(500, json.dumps({"error": str(e)}, ensure_ascii=False).encode("utf-8")) def do_POST_skill(self): """🧠 技能执行:{action:load|run, skill, args}""" try: length = int(self.headers.get("Content-Length", 0)) raw = self.rfile.read(length) if length else b"{}" data = json.loads(raw or b"{}") action = str(data.get("action", "load")) skill = str(data.get("skill", "")) args = data.get("args") or [] sys.path.insert(0, os.path.expanduser("~/cang-ying")) from skill.skill_center import load as sk_load, run as sk_run if action == "run": ok, out = sk_run(skill, args) self._send(200, json.dumps({"ok": ok, "output": out}, ensure_ascii=False).encode("utf-8")) else: s = sk_load(skill) if not s: self._send(404, json.dumps({"error": "技能不存在"}).encode("utf-8")) else: self._send(200, json.dumps(s, ensure_ascii=False).encode("utf-8")) except Exception as e: self._send(500, json.dumps({"error": str(e)}, ensure_ascii=False).encode("utf-8")) def do_POST(self): path = self.path.split("?")[0] if path == "/api/upload": self.do_POST_upload(); return if path == "/api/agent": self.do_POST_agent(); return if path == "/api/skills": self.do_POST_skills(); return if path == "/api/skill": self.do_POST_skill(); return if path == "/api/stop": _kill_active() self._send(200, json.dumps({"ok": True}).encode("utf-8")) return if path != "/api/chat": self._send(404, b"not found"); return try: proc = None length = int(self.headers.get("Content-Length", 0)) raw = self.rfile.read(length) if length else b"{}" data = json.loads(raw or b"{}") message = str(data.get("message", "")).strip() model = str(data.get("model", DEFAULT_MODEL)).strip() if model not in ALLOWED: model = DEFAULT_MODEL history = data.get("history", []) sid = str(data.get("session_id", "")).strip() started = bool(data.get("started")) if not message: self._send(400, json.dumps({"reply": "(没收到内容)"}).encode("utf-8")); return self.send_response(200) self.send_header("Content-Type", "text/event-stream") self.send_header("Cache-Control", "no-cache") self.send_header("X-Accel-Buffering", "no") self.send_header("Transfer-Encoding", "chunked") self.send_header("Connection", "keep-alive") self.end_headers() # ---- 会话模式:老会话 resume 续聊;新会话/重建 用 session-id 开新场 ---- # 关键容错:若 resume 的旧 session 已失效(被清理/不存在),--resume 后无输出, # 此时自动退回「全新场次 + 历史」重新生成,保证爸爸一定有回复。 compacted = None acc = "" if sid and started: compacted = maybe_compact(sid, model) if compacted: sid = compacted["new_sid"] summary_ctx = ("\n\n[以下是本对话更早内容的自动压缩摘要,请作为背景记忆]\n" + compacted["summary"]) cmd = [CODEBUDDY, "--print", "--model", model, "--tools", "Read,WebSearch,Bash", "--output-format", "stream-json", "--system-prompt", EED_SYS, "--append-system-prompt", summary_ctx, "--session-id", sid, "-p", message] else: cmd = [CODEBUDDY, "--print", "--model", model, "--tools", "Read,WebSearch,Bash", "--output-format", "stream-json", "--system-prompt", EED_SYS, "--resume", sid, "-p", message] acc = self._stream_cmd(cmd) if not acc.strip(): # 退回全新场次(带历史),用新 session-id sid = "eed_" + uuid.uuid4().hex[:12] cmd = [CODEBUDDY, "--print", "--model", model, "--tools", "Read,WebSearch,Bash", "--output-format", "stream-json", "--system-prompt", EED_SYS, "--session-id", sid, "-p", build_prompt(history, message)] acc2 = self._stream_cmd(cmd) if acc2.strip(): acc = acc2 compacted = None else: if not sid: sid = "eed_" + uuid.uuid4().hex[:12] prompt = build_prompt(history, message) cmd = [CODEBUDDY, "--print", "--model", model, "--tools", "Read,WebSearch,Bash", "--output-format", "stream-json", "--system-prompt", EED_SYS, "--session-id", sid, "-p", prompt] acc = self._stream_cmd(cmd) if not acc: acc = "(蛋蛋没回话,换个说法试试~)" self._event("done", {"text": acc, "session_id": sid, "compacted": bool(compacted), "summary": (compacted or {}).get("summary", "")}) except (BrokenPipeError, ConnectionResetError): _kill_active() # 浏览器断开(点停止/关页)→ 杀掉后台进程 except Exception as e: try: self._event("error", {"text": str(e)}) except Exception: pass finally: # 子进程的生命周期由 _stream_cmd 负责清理;这里只需收尾 SSE 流 try: self._chunk_end() except Exception: pass def log_message(self, *a): pass def find_port(start=8765, end=8795): import socket for p in range(start, end + 1): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: if s.connect_ex(("127.0.0.1", p)) != 0: return p return start def main(): global PAGE PAGE = PAGE.replace("__MODEL_OPTIONS__", "".join(f'' for m, n in MODELS)) port = find_port() server = http.server.ThreadingHTTPServer(("127.0.0.1", port), Handler) url = f"http://127.0.0.1:{port}/" print(f"蛋蛋对话已启动: {url}") threading.Timer(1.0, lambda: webbrowser.open(url)).start() try: server.serve_forever() except KeyboardInterrupt: pass if __name__ == "__main__": main()