cang-ying/scripts/eed_web.py
Zhuyuan Operations 80805449ca EED: 经验记忆推送——技能库(11技能+开源角色设定板)+遇事不决先搜索铁律+人物三/四视图+场景四视图工业级方法论+声画装配+多视图工具
- skill/ 技能库:index.json 11 技能 + community_techniques.md 社区技巧 + character_sheet_learn.md 角色版学习 + scene_4view_learn.md 场景四视图工业级方法 + character-sheet-generator 开源技能(7风格模板)
- tools/character_turnaround.py:人物三/四视图(主视觉+三视图/展示台)+场景四视角,Z-Image 本地
- tools/audio_pipeline.py:Edge-TTS配音+字幕+BGM+混音,Agent stage⑥有声成片
- agent_short_drama.py:一键短剧 Agent --until audio 全链路
- eed_web.py:技能库端点(/api/skills,/api/skill)+E2BIG根治(字节截断+巨兽降级)+一键短剧按钮
2026-08-01 03:16:48 +08:00

1371 lines
73 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# 蛋蛋对话 · 本地浏览器 GUI 服务端(自包含,无外部依赖)
# 启动后自动打开浏览器到 http://127.0.0.1:<port>/ ,即可和耳耳蛋聊天。
# 支持: 模型切换 / 流式输出 / 读仓库文件 / 联网搜索 / 多对话 / 导出导入 / 全局搜索 /
# 单条消息删除与编辑重发 / 字号调节 / 侧栏折叠 / 浏览器朗读 / 拖拽附文件 /
# 深浅主题 / 停止 / 复制 / 重新生成 / 置顶 / 关于面板。
# 后端: codebuddy --print --output-format stream-jsonCodeBuddy 自有后端)。
# 仅监听 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"""<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>蛋蛋 · 耳耳蛋</title>
<style>
:root{
--bg:#0e1014; --side:#12151c; --panel:#161a22; --me:#3b7bff; --egg:#1c2230;
--txt:#e9ebf1; --mut:#8a93a8; --line:#262b36; --code:#0a0c10; --accent:#5fd07a;
}
body.light{
--bg:#f4f6fb; --side:#ffffff; --panel:#ffffff; --me:#3b7bff; --egg:#eef1f7;
--txt:#1a1d24; --mut:#6b7384; --line:#e3e7ef; --code:#f0f2f7; --accent:#2faa55;
}
*{box-sizing:border-box;}
html,body{height:100%;margin:0;}
body{background:var(--bg);color:var(--txt);font-size:var(--fs,15px);
font-family:-apple-system,"PingFang SC","Microsoft YaHei",system-ui,sans-serif;
display:flex;}
/* sidebar */
#side{width:264px;flex:0 0 auto;background:var(--side);border-right:1px solid var(--line);
display:flex;flex-direction:column;transition:margin-left .18s ease;}
#side.collapsed{margin-left:-264px;}
.side-head{padding:14px;display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--line);}
.logo{width:30px;height:30px;border-radius:50%;background:linear-gradient(135deg,#ffd36e,#ff9bb3);
display:flex;align-items:center;justify-content:center;font-size:16px;}
.side-head h1{font-size:15px;margin:0;font-weight:700;flex:1;}
.side-head .ic{background:none;border:none;color:var(--mut);cursor:pointer;font-size:16px;padding:2px 4px;}
.side-head .ic:hover{color:var(--me);}
#search{margin:10px 12px 0;padding:8px 10px;border:1px solid var(--line);border-radius:9px;
background:var(--bg);color:var(--txt);font-size:13px;font-family:inherit;outline:none;width:calc(100% - 24px);}
#search:focus{border-color:var(--me);}
#newchat{margin:10px 12px;padding:10px;border:1px dashed var(--line);border-radius:10px;background:transparent;
color:var(--txt);cursor:pointer;font-size:14px;font-family:inherit;}
#newchat:hover{border-color:var(--me);color:var(--me);}
#convlist{flex:1;overflow-y:auto;padding:0 8px 8px;}
.conv{display:flex;align-items:center;gap:8px;padding:9px 10px;border-radius:9px;cursor:pointer;margin-bottom:4px;}
.conv:hover{background:var(--panel);}
.conv.active{background:var(--panel);outline:1px solid var(--line);}
.conv .pin{opacity:0;color:var(--mut);border:none;background:none;cursor:pointer;font-size:12px;padding:2px 3px;}
.conv:hover .pin{opacity:.6;}
.conv.pinned .pin{opacity:1;color:var(--accent);}
.conv .name{flex:1;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:14px;}
.conv .del{opacity:0;color:var(--mut);border:none;background:none;cursor:pointer;font-size:14px;padding:2px 4px;}
.conv:hover .del{opacity:1;}
.side-foot{padding:10px 14px;color:var(--mut);font-size:11px;border-top:1px solid var(--line);line-height:1.6;}
/* main */
#main{flex:1;display:flex;flex-direction:column;min-width:0;position:relative;}
#topbar{padding:10px 16px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:8px;background:var(--panel);flex-wrap:wrap;}
#topbar .ttl{font-weight:700;margin-right:4px;}
#topbar .sp{flex:1;}
#topbar button, #topbar select, .menu button{background:var(--bg);color:var(--txt);border:1px solid var(--line);
border-radius:8px;padding:6px 10px;font-size:13px;cursor:pointer;font-family:inherit;}
#topbar button:hover{border-color:var(--me);}
.menu{position:absolute;z-index:30;background:var(--panel);border:1px solid var(--line);border-radius:10px;
box-shadow:0 8px 24px rgba(0,0,0,.3);padding:6px;display:flex;flex-direction:column;gap:4px;min-width:160px;}
.menu button{text-align:left;border:none;background:none;}
.menu button:hover{background:var(--bg);border:none;color:var(--me);}
#toolbar{padding:8px 16px;background:var(--panel);border-bottom:1px solid var(--line);display:flex;gap:8px;flex-wrap:wrap;}
#toolbar button{background:var(--bg);color:var(--txt);border:1px solid var(--line);border-radius:8px;
padding:7px 12px;font-size:13px;cursor:pointer;font-family:inherit;}
#toolbar button:hover{border-color:var(--me);}
#log{flex:1;overflow-y:auto;padding:22px;display:flex;flex-direction:column;gap:18px;position:relative;}
.row{display:flex;gap:12px;align-items:flex-start;max-width:860px;width:100%;margin:0 auto;}
.row.me{flex-direction:row-reverse;}
.av{width:34px;height:34px;border-radius:50%;flex:0 0 auto;display:flex;align-items:center;justify-content:center;font-size:17px;background:var(--egg);}
.row.me .av{background:var(--me);}
.bubwrap{flex:1;min-width:0;}
.name{font-size:11px;color:var(--mut);margin:0 6px 4px;}
.row.me .name{text-align:right;}
.time{font-size:10px;color:var(--mut);margin:0 6px 2px;opacity:.7;}
.row.me .time{text-align:right;}
.bub{background:var(--egg);border-radius:16px;border-top-left-radius:4px;padding:12px 16px;line-height:1.7;
white-space:normal;word-break:break-word;}
.row.me .bub{background:var(--me);border-top-left-radius:16px;border-top-right-radius:4px;}
.acts{margin:4px 6px 0;display:flex;gap:10px;flex-wrap:wrap;}
.acts button{background:none;border:none;color:var(--mut);cursor:pointer;font-size:12px;padding:0;}
.acts button:hover{color:var(--me);}
.editbox{width:100%;min-height:60px;resize:vertical;border-radius:10px;border:1px solid var(--me);
background:var(--bg);color:var(--txt);padding:10px;font-size:14px;font-family:inherit;}
.typing{color:var(--mut);font-size:12px;padding:0 22px 6px;max-width:860px;width:100%;margin:0 auto;height:18px;}
#composer{padding:14px 16px;border-top:1px solid var(--line);background:var(--panel);}
#composer .box{max-width:860px;margin:0 auto;display:flex;gap:10px;align-items:flex-end;}
#attachchip{font-size:12px;color:var(--me);margin:0 auto 6px;max-width:860px;display:none;}
#count{font-size:11px;color:var(--mut);max-width:860px;margin:0 auto 4px;text-align:right;}
#status{font-size:11px;color:var(--mut);max-width:860px;margin:0 auto 6px;text-align:left;border-left:2px solid var(--line);padding-left:8px;line-height:1.5;}
#msg{flex:1;resize:none;height:52px;max-height:200px;border-radius:12px;border:1px solid var(--line);
background:var(--bg);color:var(--txt);padding:13px;font-size:15px;font-family:inherit;outline:none;}
#msg:focus{border-color:var(--me);}
#send,#stop{border:none;border-radius:12px;background:var(--me);color:#fff;padding:0 20px;font-size:15px;cursor:pointer;height:52px;}
#stop{background:#e0594f;opacity:.45;transition:opacity .15s;color:#fff;}
#stop.live{opacity:1;box-shadow:0 0 0 2px rgba(224,89,79,.45);}
#tobottom{position:absolute;right:24px;bottom:84px;background:var(--panel);border:1px solid var(--line);
color:var(--me);border-radius:20px;padding:6px 14px;font-size:12px;cursor:pointer;display:none;z-index:20;box-shadow:0 4px 14px rgba(0,0,0,.25);}
/* markdown */
.bub h1,.bub h2,.bub h3{margin:.3em 0;}
.bub p{margin:.4em 0;}
.bub ul{margin:.4em 0;padding-left:1.3em;}
.bub li.task{list-style:none;margin-left:-1.1em;}
.bub hr{border:none;border-top:1px solid var(--line);margin:.7em 0;}
.bub blockquote{margin:.4em 0;padding-left:10px;border-left:3px solid var(--line);color:var(--mut);}
.bub code.ic{background:var(--code);padding:1px 6px;border-radius:5px;font-size:13px;font-family:ui-monospace,Menlo,Consolas,monospace;}
.bub pre.code{background:var(--code);border:1px solid var(--line);border-radius:10px;margin:.5em 0;overflow:hidden;}
.codebar{display:flex;align-items:center;padding:6px 10px;background:rgba(255,255,255,.03);border-bottom:1px solid var(--line);}
.clang{font-size:11px;color:var(--mut);flex:1;}
.copybtn{background:none;border:1px solid var(--line);color:var(--mut);border-radius:6px;padding:2px 8px;cursor:pointer;font-size:11px;font-family:inherit;}
.copybtn:hover{color:var(--me);border-color:var(--me);}
.bub pre.code code{display:block;padding:12px;overflow-x:auto;font-size:13px;line-height:1.55;font-family:ui-monospace,Menlo,Consolas,monospace;white-space:pre;}
.bub a{color:var(--me);}
.bub table{border-collapse:collapse;margin:.5em 0;font-size:13px;}
.bub table th,.bub table td{border:1px solid var(--line);padding:5px 9px;}
.bub table th{background:var(--code);}
/* modal */
#modal,#balModal{position:fixed;inset:0;background:rgba(0,0,0,.5);display:none;align-items:center;justify-content:center;z-index:50;}
#modal .card,#balModal .card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:22px;max-width:460px;width:90%;}
#modal h2,#balModal h2{margin:0 0 12px;font-size:17px;}
#modal p,#modal li,#balModal p,#balModal li{font-size:13px;line-height:1.8;color:var(--txt);}
#modal .close,#balModal .close{margin-top:14px;text-align:right;}
#modal .close button,#balModal .close button{background:var(--me);color:#fff;border:none;border-radius:8px;padding:8px 16px;cursor:pointer;font-family:inherit;}
.baltag{font-size:12px;color:var(--accent);margin-right:4px;}
.balrow{display:flex;gap:10px;align-items:center;margin:10px 0;}
.balrow label{width:96px;font-size:13px;color:var(--mut);}
.balrow input{flex:1;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--txt);padding:8px 10px;font-size:14px;font-family:inherit;}
.balbar{height:10px;border-radius:6px;background:var(--bg);border:1px solid var(--line);overflow:hidden;margin:10px 0;}
.balbar>i{display:block;height:100%;background:var(--accent);}
/* 思考与工具过程细节面板 */
.trace{margin:8px 6px 0;font-size:12px;color:var(--mut);border:1px solid var(--line);border-radius:10px;background:rgba(255,255,255,.02);overflow:hidden;}
body.hidetrace .trace{display:none;}
.trace .th{display:flex;align-items:center;gap:6px;cursor:pointer;padding:8px 12px;font-weight:700;color:var(--txt);}
.trace .th .cnt{color:var(--mut);font-weight:400;}
.trace .th .hint{margin-left:auto;font-size:11px;color:var(--mut);font-weight:400;}
.trace .body{border-top:1px solid var(--line);padding:8px 10px;}
.trace.collapsed .body{display:none;}
.titem{margin:6px 0;border:1px solid var(--line);border-radius:9px;overflow:hidden;background:rgba(255,255,255,.02);}
.titem .th2{display:flex;align-items:center;gap:6px;padding:6px 10px;cursor:pointer;font-size:12px;}
.titem .th2 .nm{font-weight:700;color:var(--txt);word-break:break-all;}
.titem .th2 .tg{color:var(--mut);font-size:11px;}
.titem .c{display:none;padding:0 10px 10px;}
.titem.open .c{display:block;}
.titem pre{margin:0;max-height:300px;overflow:auto;background:var(--code);border-radius:8px;padding:10px;
font-size:12px;line-height:1.55;font-family:ui-monospace,Menlo,Consolas,monospace;white-space:pre-wrap;word-break:break-word;}
.titem .plain{padding:2px 2px;line-height:1.6;white-space:pre-wrap;word-break:break-word;}
.titem .more{cursor:pointer;color:var(--me);font-size:11px;padding:4px 2px 0;}
#queuechip{display:none;font-size:12px;color:var(--accent);max-width:860px;margin:0 auto 4px;text-align:left;}
/* 思考过程实时展示(在气泡上方) */
.think-inline{margin:8px 6px 0;padding:8px 12px;background:rgba(233,196,106,.06);border:1px solid rgba(233,196,106,.2);border-radius:10px;font-size:12px;}
.think-inline .ti-header{font-weight:700;color:var(--accent);margin-bottom:4px;}
.think-inline .ti-body{color:var(--mut);line-height:1.6;white-space:pre-wrap;word-break:break-word;max-height:200px;overflow-y:auto;}
.think-done{margin:8px 6px 0;padding:0;border:1px solid var(--line);border-radius:10px;overflow:hidden;font-size:12px;}
.think-done .ti-header{padding:8px 12px;cursor:pointer;font-weight:700;color:var(--txt);background:rgba(255,255,255,.02);display:flex;align-items:center;gap:6px;}
.think-done .ti-header .ti-hint{margin-left:auto;font-size:11px;color:var(--mut);font-weight:400;}
.think-done .ti-body{padding:8px 12px;color:var(--mut);line-height:1.6;white-space:pre-wrap;word-break:break-word;border-top:1px solid var(--line);max-height:300px;overflow-y:auto;}
.think-done.ti-collapsed .ti-body{display:none;}
/* toast */
#toast{position:fixed;left:50%;bottom:30px;transform:translateX(-50%);background:var(--panel);
border:1px solid var(--line);color:var(--txt);padding:10px 18px;border-radius:24px;font-size:13px;
opacity:0;transition:opacity .2s;z-index:60;pointer-events:none;box-shadow:0 6px 20px rgba(0,0,0,.3);}
#toast.show{opacity:1;}
.retrybtn{background:var(--me);color:#fff;border:none;border-radius:8px;padding:6px 14px;cursor:pointer;font-family:inherit;font-size:13px;margin-left:8px;}
</style>
</head>
<body>
<div id="side">
<div class="side-head">
<div class="logo">🥚</div><h1>蛋蛋</h1>
<button class="ic" id="collapse" title="折叠/展开侧栏">⮜</button>
</div>
<input id="search" placeholder="🔍 搜索对话…">
<button id="newchat"> 新建对话</button>
<div id="convlist"></div>
<div class="side-foot">对话存本机浏览器 · 不上传<br>可读文件/联网搜/跑命令(爸爸允许)</div>
</div>
<div id="main">
<div id="topbar">
<span class="ttl">耳耳蛋 · 语言人格</span>
<span class="sp"></span>
<button id="fsminus" title="缩小字号">A</button>
<button id="fsplus" title="放大字号">A</button>
<button id="theme">🌗 主题</button>
<button id="bal" title="查看/设置积分余额">💰 余额</button>
<span class="baltag" id="balTag"></span>
<button id="about" title="关于/快捷键"></button>
<button id="import">📥 导入</button>
<button id="export">⬇️ 导出</button>
<button id="clear">🧹 清空</button>
<label style="font-size:12px;color:var(--mut)">模型</label>
<select id="model">__MODEL_OPTIONS__</select>
</div>
<div id="toolbar">
<button id="btnTrace" title="显示/隐藏每条回复的思考与工具过程">🔍 过程</button>
<button id="btnRead">📂 读仓库文件</button>
<button id="btnSearch">🔍 联网搜索</button>
<button id="btnAttach">📎 附文件</button>
<button id="btnDrama" title="一键短剧:剧本/分镜 → 全自动成片">🎬 一键短剧</button>
<button id="btnSkill" title="技能库:编剧/分镜/风格转绘/LTX/Z-Image 即插即用">🧠 技能库</button>
<input id="fileinp" type="file" accept=".txt,.md,.json,.py,.js,.csv,.hdlp,.log,.yaml,.yml,.toml,.text" style="display:none">
<input id="impinp" type="file" accept=".json" style="display:none">
</div>
<div id="log"></div>
<button id="tobottom">⬇ 回到底部</button>
<div class="typing" id="typing"></div>
<div id="composer">
<div id="attachchip"></div>
<div id="queuechip"></div>
<div id="count">0 字</div>
<div id="status">用量:还没聊过 · 账号积分余额本机接口不暴露,见关于面板</div>
<div class="box">
<textarea id="msg" placeholder="和蛋蛋说点什么…Enter 发送 · Shift+Enter 换行 · 可拖入文件)"></textarea>
<button id="send">发送</button>
<button id="stop">停止</button>
</div>
</div>
</div>
<div id="modal"><div class="card">
<h2>关于蛋蛋 · 耳耳蛋</h2>
<p><b>当前模型:</b><span id="mModel"></span></p>
<p><b>能力边界EED-PROTO-005</b>可帮爸爸读仓库文件、联网搜索、<b>跑命令</b>(爸爸已授权);<b>不</b>写文件、推送、索取密钥。涉及花钱/调 API 等现实操作需走「爸爸验证码」受控路径。</p>
<p><b>关于「积分余额」:</b>本机 CodeBuddy 接口只回传<b>本次对话的 token 用量与花费</b>(见底部用量条),<b>不</b>暴露账号的总积分余额——那是服务端账户信息,本地没有。想要真正的余额,需要爸爸提供查询方式(比如你们的账户后台地址/接口),蛋蛋才能照着查。</p>
<p><b>快捷键:</b></p>
<ul>
<li>Enter 发送 · Shift+Enter 换行</li>
<li>Esc 停止生成 · Ctrl/⌘+K 搜索对话</li>
<li>Ctrl/⌘+N 新对话 · Ctrl/⌘+L 清上下文</li>
</ul>
<p style="color:var(--mut);font-size:11px;">对话仅存本机浏览器,关掉再开还在。</p>
<div class="close"><button id="modalClose">知道了</button></div>
</div></div>
<div id="dramaModal" style="display:none"><div class="card" style="max-width:660px">
<h3>🎬 一键短剧 Agent</h3>
<textarea id="dramaInput" style="width:100%;height:120px;resize:vertical" placeholder="贴入 剧本 或 分镜JSON…"></textarea>
<div style="margin:8px 0">
<label>类型:
<select id="dramaType">
<option value="storyboard" selected>分镜JSON零成本出图→视频→成片</option>
<option value="script">剧本(需豆包分镜授权)</option>
</select>
</label>
<label> 集号 <input id="dramaEp" type="number" value="1" style="width:56px"></label>
<label> 帧数 <input id="dramaFrames" type="number" value="49" style="width:64px"></label>
</div>
<label style="color:#e0594f"><input id="dramaAuth" type="checkbox"> 授权豆包拆分镜(约 ¥0.01/集EED-PROTO-005</label>
<div style="margin:8px 0">
<button id="dramaGo">开始生成</button>
<button onclick="$('#dramaModal').style.display='none'">关闭</button>
</div>
<pre id="dramaLog" style="max-height:260px;overflow:auto;background:#111;color:#8f8;padding:8px;border-radius:8px;font-size:12px;white-space:pre-wrap"></pre>
<div id="dramaResult"></div>
</div></div>
<div id="skillModal" style="display:none"><div class="card" style="max-width:560px">
<h3>🧠 苍耳技能库</h3>
<p style="color:var(--mut);font-size:12px">提示词型=注入方法论到输入框(可编辑后发送);工具型=本地执行(生图/出片/拼接)</p>
<div id="skillList" style="max-height:60vh;overflow-y:auto"></div>
<div style="margin-top:10px"><button onclick="$('#skillModal').style.display='none'">关闭</button></div>
</div></div>
<div id="balModal"><div class="card">
<h2>💰 积分余额</h2>
<p>打开 <a href="https://www.codebuddy.cn/profile/plans-usage" target="_blank" rel="noopener">codebuddy.cn/profile/plans-usage</a> 登录后,看"套餐总额""已用",填进来蛋蛋帮你算剩余:</p>
<div class="balrow"><label>套餐总额</label><input id="balTotal" type="number" min="0" placeholder="例如 1000000"></div>
<div class="balrow"><label>已用</label><input id="balUsed" type="number" min="0" placeholder="例如 12345"></div>
<div class="balbar"><i id="balBar" style="width:0%"></i></div>
<p id="balResult" style="color:var(--accent);font-weight:700;">剩余:—</p>
<div class="close">
<button id="balCalc">计算</button>
<button id="balSave" style="background:var(--accent);margin-left:8px;">记住</button>
<button id="balClose" style="background:var(--mut);margin-left:8px;">关闭</button>
</div>
</div></div>
<div id="toast"></div>
<script>
const DEFAULT='hy3';
const $=s=>document.querySelector(s);
const log=$('#log'), msg=$('#msg'), typing=$('#typing'), countEl=$('#count');
const modelSel=$('#model');
let busy=false, cur=null, attach=null, abortCtl=null, queue=[];
let totalIn=0, totalOut=0;
/* ---------- toast ---------- */
let toastT=null;
function toast(t){const e=$('#toast');e.textContent=t;e.classList.add('show');clearTimeout(toastT);toastT=setTimeout(()=>e.classList.remove('show'),1800);}
/* ---------- 对话存储(localStorage) ---------- */
const KEY='eed_conversations';
let convs=JSON.parse(localStorage.getItem(KEY)||'[]');
function save(){localStorage.setItem(KEY,JSON.stringify(convs));}
function newConv(){
cur={id:'c'+Date.now(),title:'新对话',messages:[],model:modelSel.value,pinned:false,
sid:'eed_'+Date.now().toString(36)+Math.random().toString(36).slice(2,8),started:false};
convs.unshift(cur);save();renderSide();renderLog();showHint();
}
function sortedConvs(){return [...convs].sort((a,b)=>(b.pinned?1:0)-(a.pinned?1:0));}
function renderSide(){
const q=($('#search').value||'').toLowerCase().trim();
const el=$('#convlist');el.innerHTML='';
const list=sortedConvs().filter(c=>{
if(!q)return true;
if(c.title.toLowerCase().includes(q))return true;
return c.messages.some(m=>m.text.toLowerCase().includes(q));
});
if(!list.length){el.innerHTML='<div style="color:var(--mut);font-size:12px;padding:10px;text-align:center">没有匹配的对话</div>';return;}
list.forEach(c=>{
const d=document.createElement('div');d.className='conv'+(cur&&c.id===cur.id?' active':'')+(c.pinned?' pinned':'');
const pin=document.createElement('button');pin.className='pin';pin.textContent='📌';pin.title='置顶/取消';
pin.onclick=e=>{e.stopPropagation();c.pinned=!c.pinned;save();renderSide();};
const n=document.createElement('div');n.className='name';n.textContent=c.title;
const del=document.createElement('button');del.className='del';del.textContent='🗑';del.title='删除';
del.onclick=e=>{e.stopPropagation();if(confirm('删除这个对话?')){convs=convs.filter(x=>x.id!==c.id);if(cur&&cur.id===c.id)cur=null;save();if(!convs.length)newConv();else{cur=convs[0];modelSel.value=cur.model||DEFAULT;renderSide();renderLog();}}};
d.appendChild(pin);d.appendChild(n);d.appendChild(del);
d.onclick=()=>{cur=c;modelSel.value=c.model||DEFAULT;renderSide();renderLog();};
el.appendChild(d);
});
}
function renderLog(){
log.innerHTML='';
if(!cur||!cur.messages.length){showHint();return;}
cur.messages.forEach((m,i)=>addBubble(m.role,m.text,false,i));
log.scrollTop=log.scrollHeight;
}
function showHint(){
log.innerHTML='<div style="margin:auto;color:var(--mut);font-size:14px;text-align:center;padding:50px;line-height:2">和蛋蛋说点什么~<br>📂 读仓库文件 · 🔍 联网搜索 · 📎 附文件<br>左侧可新建/切换/搜索/置顶/删除对话</div>';
}
/* ---------- markdown ---------- */
function escapeHtml(s){return s.replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
function renderMD(src){
const codes=[];
let s=src.replace(/```(\w*)\n([\s\S]*?)```/g,(m,lang,code)=>{const i=codes.length;codes.push({lang,code:code.replace(/\n$/,'')});return '\u0000C'+i+'\u0000';});
s=escapeHtml(s);
s=s.replace(/\u0000C(\d+)\u0000/g,(m,i)=>{const c=codes[+i];const lg=c.lang?'<span class="clang">'+escapeHtml(c.lang)+'</span>':'';
return '<pre class="code"><div class="codebar">'+lg+'<button class="copybtn" onclick="copyCode(this)">复制</button></div><code>'+escapeHtml(c.code)+'</code></pre>';});
s=s.replace(/^&gt;\s?(.*)$/gm,'<blockquote>$1</blockquote>');
s=s.replace(/^###\s+(.*)$/gm,'<h3>$1</h3>').replace(/^##\s+(.*)$/gm,'<h2>$1</h2>').replace(/^#\s+(.*)$/gm,'<h1>$1</h1>');
s=s.replace(/\*\*([^*]+)\*\*/g,'<b>$1</b>').replace(/\*([^*]+)\*/g,'<i>$1</i>').replace(/`([^`]+)`/g,'<code class="ic">$1</code>');
s=s.replace(/!\[([^\]]*)\]\((\/media\/[^)]+\.(?:png|jpe?g|gif|webp))\)/g,'<img src="$2" alt="$1" style="max-width:100%;border-radius:10px;margin:.4em 0">');
s=s.replace(/\[([^\]]+)\]\((\/media\/[^)]+\.(?:mp4|webm|mov))\)/g,'<video src="$2" controls style="max-width:100%;border-radius:10px;margin:.4em 0"></video>');
s=s.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,'<a href="$2" target="_blank" rel="noopener">$1</a>');
s=s.replace(/^\s*[-*]\s+\[([ xX])\]\s+(.*)$/gm,(m,chk,t)=>'<li class="task">'+(chk.toLowerCase()==='x'?'':'')+' '+t+'</li>');
s=s.replace(/^\s*[-*]\s+(.*)$/gm,'<li>$1</li>');
s=s.replace(/(?:<li[^>]*>.*?<\/li>\s*)+/g,m=>'<ul>'+m.replace(/\s+$/,'')+'</ul>');
s=s.replace(/^---+$/gm,'<hr>');
s=s.replace(/^\|(.+)\|\s*$/gm,(m)=>'<tablerow>'+m+'</tablerow>');
s=s.replace(/<tablerow>\|?(.+?)\|?<\/tablerow>/g,(m,row)=>{
const cells=row.split('|').map(c=>c.trim());
return '<tr>'+cells.map(c=>'<td>'+c+'</td>').join('')+'</tr>';
});
s=s.replace(/(?:<tr>.*?<\/tr>\n?){2,}/g,m=>'<table>'+m+'</table>');
s=s.split(/\n{2,}/).map(b=>{b=b.trim();if(!b)return'';if(/^<(h\d|ul|pre|blockquote|hr|table)/.test(b))return b;return '<p>'+b.replace(/\n/g,'<br>')+'</p>';}).join('');
return s;
}
function copyCode(btn){const t=btn.parentElement.parentElement.querySelector('code').innerText;navigator.clipboard.writeText(t);btn.textContent='已复制';setTimeout(()=>btn.textContent='复制',1200);}
/* ---------- 气泡 ---------- */
function fmtTime(ts){if(!ts)return'';const d=new Date(ts);const p=n=>(''+n).padStart(2,'0');return p(d.getMonth()+1)+'/'+p(d.getDate())+' '+p(d.getHours())+':'+p(d.getMinutes());}
function addBubble(role,text,live,idx){
const row=document.createElement('div');row.className='row '+(role==='me'?'me':'egg');
const av=document.createElement('div');av.className='av';av.textContent=role==='me'?'👤':'🥚';
const wrap=document.createElement('div');wrap.className='bubwrap';
const ti=document.createElement('div');ti.className='time';ti.textContent=fmtTime(cur&&cur.messages[idx]&&cur.messages[idx].ts);
const nm=document.createElement('div');nm.className='name';nm.textContent=role==='me'?'苍耳爸爸':'蛋蛋';
const bub=document.createElement('div');bub.className='bub';
bub.innerHTML = live ? escapeHtml(text) : renderMD(text);
wrap.appendChild(ti);wrap.appendChild(nm);wrap.appendChild(bub);
if(role==='egg'){
const acts=document.createElement('div');acts.className='acts';
const c=document.createElement('button');c.textContent='复制';c.onclick=()=>{navigator.clipboard.writeText(bub.innerText);c.textContent='已复制';setTimeout(()=>c.textContent='复制',1200);};
const r=document.createElement('button');r.textContent='重新生成';r.onclick=()=>regen();
const sp=document.createElement('button');sp.textContent='🔊 朗读';sp.onclick=()=>speak(bub.innerText,sp);
acts.appendChild(c);acts.appendChild(r);acts.appendChild(sp);wrap.appendChild(acts);
}else if(idx!=null){
const acts=document.createElement('div');acts.className='acts';
const ed=document.createElement('button');ed.textContent='编辑';ed.onclick=()=>editMsg(idx);
const dl=document.createElement('button');dl.textContent='删除';dl.onclick=()=>delMsg(idx);
acts.appendChild(ed);acts.appendChild(dl);wrap.appendChild(acts);
}
row.appendChild(av);row.appendChild(wrap);log.appendChild(row);
log.scrollTop=log.scrollHeight;
return {bub,row};
}
/* ---------- 朗读(TTS, 浏览器自带, 不耗密钥) ---------- */
let speaking=false;
function speak(text,btn){
try{
if(speaking){window.speechSynthesis.cancel();speaking=false;btn.textContent='🔊 朗读';return;}
const u=new SpeechSynthesisUtterance(text);u.lang='zh-CN';u.rate=.95;
u.onend=()=>{speaking=false;btn.textContent='🔊 朗读';};
window.speechSynthesis.cancel();window.speechSynthesis.speak(u);speaking=true;btn.textContent='⏹ 停止朗读';
}catch(e){toast('这个浏览器不支持朗读');}
}
/* ---------- 单条消息操作 ---------- */
function delMsg(idx){
if(busy)return;
cur.messages.splice(idx,1);
if(!cur.messages.length)cur.title='新对话';
resetSid();
save();renderLog();renderSide();
}
function editMsg(idx){
if(busy)return;
cur=cur; // 确保是当前对话
const m=cur.messages[idx];if(!m||m.role!=='me')return;
const wrap=[...log.children][idx]?.querySelector('.bubwrap');
if(!wrap)return;
const ta=document.createElement('textarea');ta.className='editbox';ta.value=m.text;
const bar=document.createElement('div');bar.className='acts';
const sv=document.createElement('button');sv.textContent='保存并重发';
const cx=document.createElement('button');cx.textContent='取消';
bar.appendChild(sv);bar.appendChild(cx);
const old=wrap.querySelector('.bub');const oldActs=wrap.querySelector('.acts');
if(old)old.replaceWith(ta);if(oldActs)oldActs.replaceWith(bar);
ta.focus();
cx.onclick=()=>renderLog();
sv.onclick=()=>{
const v=ta.value.trim();if(!v){renderLog();return;}
m.text=v;cur.messages=cur.messages.slice(0,idx+1);
const keptHist=cur.messages.slice(0,cur.messages.length-1).map(x=>({role:x.role,text:x.text}));
resetSid();save();renderLog();runStream(v,keptHist);
};
}
/* ---------- 发送 / 流式 ---------- */
function resetSid(){cur.sid='eed_'+Date.now().toString(36)+Math.random().toString(36).slice(2,8);cur.started=false;}
function buildHistory(){
const msgs=cur.messages;
const lastUserIdx=[...msgs].reverse().findIndex(m=>m.role==='me');
if(lastUserIdx<0)return[];
const idx=msgs.length-1-lastUserIdx;
return msgs.slice(0,idx).map(m=>({role:m.role,text:m.text}));
}
async function runStream(text, extraHistory){
if(busy)return;
busy=true;$('#stop').classList.add('live');
const wrap=addBubble('egg','',true);
const bub=wrap.bub;
const bw=wrap.row.querySelector('.bubwrap');
/* 思考与工具过程面板 */
const trace=document.createElement('div');trace.className='trace collapsed';
trace.innerHTML='<div class="th"><span>🔍 思考与工具过程</span><span class="cnt"></span><span class="hint">点标题折叠/展开</span></div><div class="body"></div>';
bw.appendChild(trace);
const tbody=trace.querySelector('.body');
const tcnt=trace.querySelector('.cnt');
let tcount=0;
trace.querySelector('.th').onclick=()=>trace.classList.toggle('collapsed');
const toolNames={};
function addItem(kind,title,tag,content,pre){
tcount++;tcnt.textContent='· 共 '+tcount+'';
const it=document.createElement('div');it.className='titem';
const head=document.createElement('div');head.className='th2';
head.innerHTML='<span>'+kind+'</span><span class="nm">'+escapeHtml(title)+'</span>'+(tag?'<span class="tg">'+escapeHtml(tag)+'</span>':'');
const c=document.createElement('div');c.className='c';
if(pre){
const full=String(content==null?'':content);
const LIMIT=8000;
const preEl=document.createElement('pre');preEl.textContent=full.length>LIMIT?full.slice(0,LIMIT):full;
c.appendChild(preEl);
if(full.length>LIMIT){const m=document.createElement('div');m.className='more';m.textContent='展开全部 '+full.length+'';m.onclick=()=>{preEl.textContent=full;m.remove();};c.appendChild(m);}
}else{
const p=document.createElement('div');p.className='plain';p.textContent=content;c.appendChild(p);
}
head.onclick=()=>it.classList.toggle('open');
it.appendChild(head);it.appendChild(c);tbody.appendChild(it);
log.scrollTop=log.scrollHeight;
}
typing.textContent='💭 思考中…';
let acc='';abortCtl=new AbortController();let thinkingParts=[];let thinkEl=null;
try{
const hist=extraHistory!==undefined?extraHistory:buildHistory();
const res=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({message:text,model:cur.model,history:hist,session_id:cur.sid,started:!!cur.started}),signal:abortCtl.signal});
if(!res.ok)throw new Error('服务返回 '+res.status);
const reader=res.body.getReader();const dec=new TextDecoder();let buf='';
while(true){
const {done,value}=await reader.read();if(done)break;
buf+=dec.decode(value,{stream:true});
let i;while((i=buf.indexOf('\n\n'))>=0){
const chunk=buf.slice(0,i);buf=buf.slice(i+2);
const line=chunk.split('\n').find(l=>l.startsWith('data:'));
if(!line)continue;
let ev;try{ev=JSON.parse(line.slice(5));}catch(e){continue;}
const t=ev.type;
if(t==='thinking'){typing.textContent='💭 思考中…';
thinkingParts.push(ev.text);
if(!thinkEl){
thinkEl=document.createElement('div');thinkEl.className='think-inline';
thinkEl.innerHTML='<div class="ti-header">💭 蛋蛋在想…</div><div class="ti-body"></div>';
bw.insertBefore(thinkEl,bub);
}
thinkEl.querySelector('.ti-body').textContent=thinkingParts.join('\n---\n');
log.scrollTop=log.scrollHeight;
}
else if(t==='tool'){typing.textContent='🔧 蛋蛋正在用工具:'+ev.name+'';
if(ev.id)toolNames[ev.id]=ev.name;
addItem('🔧','调用 '+ev.name, ev.id?'id:'+ev.id:'', JSON.stringify(ev.input||{},null,2), true);}
else if(t==='tool_result'){typing.textContent='📥 工具返回…';
const nm=ev.id&&toolNames[ev.id]?toolNames[ev.id]:'';
addItem('📥','返回'+(nm?' · '+nm:''), ev.id?'id:'+ev.id:'', ev.content||'', true);}
else if(t==='delta'){acc+=ev.text;bub.innerHTML=renderMD(acc);log.scrollTop=log.scrollHeight;}
else if(t==='done'){acc=ev.text||acc;typing.textContent='';
if(ev.session_id){cur.sid=ev.session_id;cur.started=true;save();}
if(ev.compacted)toast('📦 上下文过长已自动压缩,记忆已衔接(新场次)');}
else if(t==='usage'){
const u=ev.usage||{};const cost=ev.cost;
totalIn+=(u.input_tokens||0);totalOut+=(u.output_tokens||0);
let s='本次 tokens'+(u.input_tokens||0)+' 进 / '+(u.output_tokens||0)+'';
if(cost!=null)s+=' · 花费 $'+(typeof cost==='number'?cost.toFixed(6):cost);
s+=' 累计 '+(totalIn+totalOut)+' tokens';
$('#status').textContent=s;
}
else if(t==='error'){acc='(出错了:'+ev.text+'';typing.textContent='';}
}
}
}catch(e){ if(e.name!=='AbortError'){acc='(连接中断:'+e+'';} else {acc='(已停止)';} }
if(!acc)acc='(没有回复)';
bub.innerHTML=renderMD(acc);
/* 思考过程固化:折叠在气泡上方 */
if(thinkEl){
thinkEl.className='think-done ti-collapsed';
const body=escapeHtml(thinkingParts.join('\n\n'));
thinkEl.innerHTML='<div class="ti-header">💭 思考过程 <span class="ti-hint">点击展开/折叠</span></div><div class="ti-body">'+body+'</div>';
thinkEl.onclick=()=>thinkEl.classList.toggle('ti-collapsed');
}
if(tcount===0)trace.style.display='none';
cur.messages.push({role:'egg',text:acc,ts:Date.now()});save();renderSide();
typing.textContent='';busy=false;$('#stop').classList.remove('live');abortCtl=null;
pump();
}
function addMe(text){
cur.messages.push({role:'me',text,ts:Date.now()});
if(cur.messages.length===1)cur.title=text.slice(0,18);
addBubble('me',text,false,cur.messages.length-1);save();renderSide();
}
function userSend(text){
if(!text||!text.trim())return;
if(!cur)newConv();
addMe(text); // 立刻显示爸爸说的话
if(busy){queue.push(text);updateQueueChip();} // 蛋蛋正在想 → 排队,想完续聊
else runStream(text);
}
function updateQueueChip(){
const q=$('#queuechip');
if(queue.length){q.style.display='block';q.textContent='⏳ 已排队 '+queue.length+' 条,蛋蛋想完这条就接着聊';}
else q.style.display='none';
}
function pump(){
if(busy)return;
if(queue.length){const n=queue.shift();updateQueueChip();runStream(n);}
else updateQueueChip();
}
function doSend(){
let t=msg.value.trim();if(!t)return;
if(attach){t='【附文件 '+attach.name+' 的内容】\n```\n'+attach.text+'\n```\n\n'+t;attach=null;$('#attachchip').style.display='none';$('#attachchip').textContent='';}
msg.value='';autoGrow();updateCount();userSend(t);
}
function regen(){
if(busy)return;
const msgs=cur.messages;let ui=msgs.length-1;while(ui>=0&&msgs[ui].role!=='me')ui--;
if(ui<0)return;
while(msgs.length&&msgs[msgs.length-1].role!=='egg')msgs.pop();
if(msgs[msgs.length-1].role==='egg')msgs.pop();
const text=msgs[ui].text;
const keptHist=msgs.slice(0,ui).map(x=>({role:x.role,text:x.text}));
resetSid();save();renderLog();runStream(text,keptHist);
}
/* ---------- 导出 / 导入 ---------- */
function buildExport(fmt){
if(!cur||!cur.messages.length){toast('还没内容可导出');return null;}
const stamp=new Date().toISOString().slice(0,10);
if(fmt==='json')return{name:'蛋蛋对话_'+stamp+'.json',type:'application/json',text:JSON.stringify(cur,null,2)};
let body='';
for(const m of cur.messages)body+='**'+(m.role==='me'?'苍耳爸爸':'蛋蛋')+'**\n'+m.text+'\n\n';
if(fmt==='txt')return{name:'蛋蛋对话_'+stamp+'.txt',type:'text/plain',text:body.replace(/\*\*/g,'')};
return{name:'蛋蛋对话_'+stamp+'.md',type:'text/markdown',text:'# 蛋蛋对话导出\n\n'+body};
}
function doExport(fmt){
const f=buildExport(fmt);if(!f)return;
const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([f.text],{type:f.type}));a.download=f.name;a.click();
toast('已导出 '+f.name);closeMenu();
}
function doImport(){
const inp=$('#impinp');inp.value='';inp.click();
}
$('#impinp').onchange=e=>{
const f=e.target.files[0];if(!f)return;
const r=new FileReader();r.onload=()=>{
try{
const data=JSON.parse(r.result);
if(!data.messages||!Array.isArray(data.messages))throw new Error('格式不对');
data.id='c'+Date.now()+Math.random().toString(36).slice(2,5);
data.pinned=false;
convs.unshift(data);save();cur=data;modelSel.value=data.model||DEFAULT;renderSide();renderLog();toast('已导入对话');
}catch(err){toast('导入失败:'+err.message);}
};r.readAsText(f);
};
/* ---------- 下拉菜单(导出) ---------- */
let menuEl=null;
function closeMenu(){if(menuEl){menuEl.remove();menuEl=null;}}
function toggleMenu(){
if(menuEl){closeMenu();return;}
menuEl=document.createElement('div');menuEl.className='menu';
menuEl.style.top=(($('#export').getBoundingClientRect().bottom)+6)+'px';
menuEl.style.right='16px';
[['md','⬇️ Markdown (.md)'],['txt','⬇️ 纯文本 (.txt)'],['json','⬇️ JSON (.json)']].forEach(([f,l])=>{
const b=document.createElement('button');b.textContent=l;b.onclick=()=>doExport(f);menuEl.appendChild(b);
});
document.body.appendChild(menuEl);
}
/* ---------- 按钮 / 事件 ---------- */
$('#send').onclick=doSend;
$('#stop').onclick=()=>{if(!busy){toast('当前没有在跑的任务 🥚');return;}if(abortCtl)abortCtl.abort();fetch('/api/stop',{method:'POST'}).catch(()=>{});toast('已发送停止指令,正在终止…');};
$('#newchat').onclick=newConv;
$('#collapse').onclick=()=>{$('#side').classList.toggle('collapsed');};
$('#theme').onclick=()=>{document.body.classList.toggle('light');localStorage.setItem('eed_theme',document.body.classList.contains('light')?'light':'dark');};
$('#clear').onclick=()=>{if(!cur)return;if(!confirm('清空当前对话上下文?'))return;cur.messages=[];resetSid();save();renderLog();showHint();};
$('#export').onclick=event=>{event.stopPropagation();toggleMenu();};
$('#import').onclick=doImport;
$('#about').onclick=()=>{$('#mModel').textContent=modelSel.value; $('#modal').style.display='flex';};
$('#modalClose').onclick=()=>{$('#modal').style.display='none';};
/* ---------- 余额面板 ---------- */
const BAL_KEY='eed_balance';
function fmtBal(n){return (n||0).toLocaleString('en-US');}
function renderBalTag(){
const b=JSON.parse(localStorage.getItem(BAL_KEY)||'null');
const tag=$('#balTag');
if(b&&b.total){tag.textContent='💰 剩 '+fmtBal(Math.max(0,b.total-b.used));}
else tag.textContent='';
}
function calcBal(){
const t=parseInt($('#balTotal').value,10)||0;
const u=parseInt($('#balUsed').value,10)||0;
const left=Math.max(0,t-u);
const pct=t>0?Math.min(100,Math.round(u/t*100)):0;
$('#balBar').style.width=pct+'%';
$('#balResult').textContent='剩余:'+fmtBal(left)+' 积分(已用 '+pct+'%';
return {total:t,used:u,left};
}
$('#bal').onclick=()=>{const b=JSON.parse(localStorage.getItem(BAL_KEY)||'null');if(b){$('#balTotal').value=b.total;$('#balUsed').value=b.used;calcBal();}$('#balModal').style.display='flex';};
$('#balCalc').onclick=calcBal;
$('#balSave').onclick=()=>{const r=calcBal();localStorage.setItem(BAL_KEY,JSON.stringify({total:r.total,used:r.used}));renderBalTag();toast('已记住余额');};
$('#balClose').onclick=()=>{$('#balModal').style.display='none';};
renderBalTag();
$('#fsminus').onclick=()=>setFont(-1);$('#fsplus').onclick=()=>setFont(1);
$('#btnRead').onclick=()=>{const p=prompt('要读 cang-ying 下哪个文件?\n相对路径例如 eererdan/WHO-I-AM.hdlp');if(p)userSend('【读文件】请使用 Read 工具读取 /home/ls/cang-ying/'+p.trim()+' 并给我讲解要点。');};
$('#btnSearch').onclick=()=>{const q=prompt('想联网搜什么?');if(q)userSend('【联网搜索】请使用 WebSearch 工具搜索以下问题并汇总要点:'+q.trim());};
$('#btnTrace').onclick=()=>document.body.classList.toggle('hidetrace');
$('#btnAttach').onclick=()=>$('#fileinp').click();
$('#btnDrama').onclick=()=>{$('#dramaLog').textContent='';$('#dramaResult').innerHTML='';$('#dramaModal').style.display='flex';};
$('#btnSkill').onclick=async()=>{
try{
const r=await fetch('/api/skills',{method:'POST'});
const d=await r.json();
const list=d.skills||[];
if(!list.length){toast('技能库为空');return;}
$('#skillList').innerHTML=list.map(s=>
`<div class="skill" data-id="${s.id}" data-type="${s.type}" style="padding:10px;margin:6px 0;border:1px solid #333;border-radius:8px;cursor:pointer;background:#1a1a1f">
<div style="font-weight:700">${s.name} <span style="color:var(--mut);font-size:11px;font-weight:400">${s.type==='prompt'?'💉 提示词型':'⚙️ 工具型'}</span></div>
<div style="font-size:12px;color:var(--mut);margin-top:3px">${s.desc||''}</div>
</div>`).join('');
$('#skillModal').style.display='flex';
document.querySelectorAll('#skillList .skill').forEach(el=>{
el.onclick=async()=>{
const id=el.dataset.id, type=el.dataset.type;
try{
const r2=await fetch('/api/skill',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'load',skill:id})});
const s=await r2.json();
if(type==='prompt'&&s.prompt){
$('#msg').value='【技能注入:'+s.name+'\n'+s.prompt+'\n\n---\n'+ (s.usage||'请用以上方法论') +' 请开始:';
$('#msg').focus();toast('已注入提示词,可编辑后发送');
}else if(type==='tool'){
$('#msg').value='【工具技能:'+s.name+'\n执行命令'+s.tool+' <参数>\n\n请告诉我参数如提示词/图片路径),我就跑。';
$('#msg').focus();toast('工具技能就位,说明参数即可');
}else{toast('技能无提示词内容');}
}catch(e){toast('技能加载失败:'+e);}
$('#skillModal').style.display='none';
};
});
}catch(e){toast('技能库加载失败:'+e);}
};
$('#dramaGo').onclick=async()=>{
const input=$('#dramaInput').value.trim();
if(!input){toast('先贴入剧本或分镜JSON 🥚');return;}
const type=$('#dramaType').value;
if(type==='script'&&!$('#dramaAuth').checked){toast('剧本分镜需豆包(约¥0.01/集),请勾选授权');return;}
const log=$('#dramaLog');log.textContent='🚀 开始…\n';
const btn=$('#dramaGo');btn.disabled=true;
try{
const res=await fetch('/api/agent',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({input_text:input,input_type:type,episode:parseInt($('#dramaEp').value)||1,
frames:parseInt($('#dramaFrames').value)||49,style:'',doubao_auth:$('#dramaAuth').checked})});
if(!res.ok){const e=await res.json().catch(()=>({}));toast(e.reply||'请求失败');btn.disabled=false;return;}
const rd=res.body.getReader();const dec=new TextDecoder();let buf='';
while(true){const {done,value}=await rd.read();if(done)break;
buf+=dec.decode(value,{stream:true});
let idx;while((idx=buf.indexOf('\n\n'))>=0){const ev=buf.slice(0,idx);buf=buf.slice(idx+2);
const dm=ev.match(/data: (.+)/);if(!dm)continue;
try{const d=JSON.parse(dm[1]);
if(d.type==='agent_log'){log.textContent+=d.text+'\n';log.scrollTop=log.scrollHeight;}
else if(d.type==='agent_done'){log.textContent+='\n✅ 完成\n';if(d.url){$('#dramaResult').innerHTML='<video src="'+d.url+'" controls style="max-width:100%;border-radius:10px;margin-top:8px"></video>';}else if(d.reply){log.textContent+=d.reply+'\n';}}
else if(d.type==='error'){log.textContent+='\n❌ '+d.text+'\n';}
}catch(e2){}
}
}
}catch(err){toast('出错了:'+err.message);}
btn.disabled=false;
};
$('#fileinp').onchange=e=>{const f=e.target.files[0];if(!f)return;const r=new FileReader();r.onload=()=>{attach={name:f.name,text:r.result};const c=$('#attachchip');c.style.display='block';c.textContent='📎 已附:'+f.name+'(发送时一并发给蛋蛋)';};r.readAsText(f);};
modelSel.onchange=()=>{if(cur)cur.model=modelSel.value;save();};
$('#search').oninput=renderSide;
function setFont(d){let f=parseInt(localStorage.getItem('eed_font')||'15',10)+d;f=Math.max(12,Math.min(22,f));localStorage.setItem('eed_font',f);document.body.style.setProperty('--fs',f+'px');}
if(localStorage.getItem('eed_font'))document.body.style.setProperty('--fs',localStorage.getItem('eed_font')+'px');
if(localStorage.getItem('eed_theme')==='light')document.body.classList.add('light');
/* 输入框: 自动增高 + 字数 + 快捷键 */
function autoGrow(){msg.style.height='52px';msg.style.height=Math.min(200,msg.scrollHeight)+'px';}
function updateCount(){countEl.textContent=msg.value.length+'';}
msg.addEventListener('input',()=>{autoGrow();updateCount();});
msg.addEventListener('keydown',e=>{
if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();doSend();}
else if(e.key==='Escape'&&busy){if(abortCtl)abortCtl.abort();}
});
function uploadMedia(file,cb){
const r=new FileReader();
r.onload=()=>{
fetch('/api/upload',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({name:file.name,data:r.result.split(',')[1]})})
.then(x=>x.json()).then(d=>{if(d.url)cb(d.url);else toast('上传失败');})
.catch(()=>toast('上传失败'));
};
r.readAsDataURL(file);
}
msg.addEventListener('paste',e=>{
const item=[...e.clipboardData.items].find(i=>i.type.startsWith('image/'));
if(item){e.preventDefault();const f=item.getAsFile();if(!f)return;
uploadMedia(f,url=>{msg.value+=(msg.value?'\n':'')+'【附图】['+f.name+']('+url+')';autoGrow();updateCount();});}
});
/* 拖拽文件到窗口 -> 作为附件 */
['dragover','drop'].forEach(ev=>log.addEventListener(ev,e=>{if(ev==='dragover'){e.preventDefault();}}));
log.addEventListener('drop',e=>{
e.preventDefault();const f=e.dataTransfer.files[0];if(!f)return;
if(/\.(png|jpe?g|gif|webp|mp4|webm|mov)$/i.test(f.name)){
uploadMedia(f,url=>{msg.value+=(msg.value?'\n':'')+'【附图】['+f.name+']('+url+')';autoGrow();updateCount();});
return;
}
const r=new FileReader();r.onload=()=>{attach={name:f.name,text:r.result};const c=$('#attachchip');c.style.display='block';c.textContent='📎 已附:'+f.name+'(发送时一并发给蛋蛋)';};r.readAsText(f);
});
/* 回到底部按钮 */
log.addEventListener('scroll',()=>{
const nearBottom=log.scrollHeight-log.scrollTop-log.clientHeight<80;
$('#tobottom').style.display=nearBottom?'none':'block';
});
$('#tobottom').onclick=()=>log.scrollTop=log.scrollHeight;
/* 全局快捷键 */
document.addEventListener('keydown',e=>{
const mod=e.ctrlKey||e.metaKey;
if(mod&&e.key.toLowerCase()==='k'){e.preventDefault();$('#search').focus();}
else if(mod&&e.key.toLowerCase()==='n'){e.preventDefault();newConv();}
else if(mod&&e.key.toLowerCase()==='l'){e.preventDefault();if(cur){cur.messages=[];save();renderLog();showHint();}}
});
document.addEventListener('click',e=>{if(menuEl&&!menuEl.contains(e.target)&&e.target!==$('#export'))closeMenu();});
/* ---------- 启动 ---------- */
if(!convs.length){newConv();userSend('蛋蛋,我是苍耳,醒一下陪我聊');}
else{cur=convs[0];modelSel.value=cur.model||DEFAULT;renderSide();renderLog();}
</script>
</body>
</html>
"""
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'<option value="{m}"{" selected" if m == DEFAULT_MODEL else ""}>{n}</option>'
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()