chore: import original project baseline
Import the pre-repair source tree as the history baseline. Runtime data (data/), virtualenvs, bytecode caches and logs are gitignored so local secrets and user state stay out of the repo.
This commit is contained in:
@@ -0,0 +1,944 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""右侧任务面板:本次会话的 bash 任务监控(运行中 / 已完成)
|
||||
|
||||
统一口径(与用户确认):
|
||||
· 两栏 = 「运行中」/「已完成」(按状态分);两栏【同时可见、不互斥】,
|
||||
中间是可拖动分隔线(260px 宽若真做左右并排,每栏仅 ~130px,
|
||||
展开区放不下「参数 + 输出」两块列表,故用上下两栏)。
|
||||
若要严格左右并排:把 _SPLIT_ORIENTATION 改成 Horizontal 即可。
|
||||
· 一次 bash = 一层(BashLayer),自上而下按执行先后排列
|
||||
· 单击层头 = 展开/收起;展开后分「参数」「输出」两块
|
||||
· 运行中:输出为 tool_bash 的实时流(缓冲上限 200KB,超出丢弃最旧并标注)
|
||||
· 已完成:输出为「进入上下文的原文」(DB messages.timeline[].result,不截断)
|
||||
· 范围永远是【当前会话】;切换会话跟随刷新
|
||||
· 面板【不自动收缩、也不自动展开】,开关就在面板自身:
|
||||
收起态 = 栏正中按钮;展开态 = 标题行右上角按钮(永远只有一个可动按钮)
|
||||
· 🆕 左边缘可拖拽调宽(最小 200px);松手立即记录到 config.json["bash_panel_width"],
|
||||
下次按展开按钮自动恢复到记录的宽度
|
||||
· 「已出上下文」= 该层在最近一次压缩切点之前(其输出已不在 API 上下文里)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
from PyQt6 import QtCore, QtGui, QtWidgets
|
||||
|
||||
LIVE_BUF_CAP = 200 * 1024 # 每条实时输出缓冲上限
|
||||
LAYER_LIMIT = 30 # 已完成栏默认只渲染最近 N 层
|
||||
_SPLIT_ORIENTATION = QtCore.Qt.Orientation.Vertical # 改 Horizontal = 左右并排
|
||||
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
_DUR_RE = re.compile(r"\((\d+(?:\.\d+)?)s\)\s*$")
|
||||
|
||||
# ---------------------------------------------------------------- 宽度
|
||||
PANEL_W_DEFAULT = 260 # 首次运行的默认展开宽度
|
||||
PANEL_W_MIN = 200 # 🆕 拖拽最小宽度(再窄「参数/输出」两块就挤坏了)
|
||||
PANEL_W_MAX = 560 # 绝对上限(实际还会受「不超过主窗口 50%」约束)
|
||||
_CFG_KEY = "bash_panel_width"
|
||||
_CFG_PATH = os.path.join(_ROOT, "data", "config.json")
|
||||
|
||||
|
||||
def _cfg_path() -> str:
|
||||
"""配置文件路径(测试可用 HAOCODE_CONFIG_FILE 指向临时文件 → 绝不污染真配置)"""
|
||||
return os.environ.get("HAOCODE_CONFIG_FILE") or _CFG_PATH
|
||||
|
||||
|
||||
def load_panel_width(default: int = PANEL_W_DEFAULT) -> int:
|
||||
"""读回上次拖拽记录的宽度;缺失/异常 → 默认值(并强制夹在 [MIN, MAX])"""
|
||||
try:
|
||||
with open(_cfg_path(), "r", encoding="utf-8") as f:
|
||||
w = int(json.load(f).get(_CFG_KEY, default))
|
||||
except Exception:
|
||||
return default
|
||||
return max(PANEL_W_MIN, min(PANEL_W_MAX, w))
|
||||
|
||||
|
||||
def save_panel_width(w: int) -> bool:
|
||||
"""只改这一个键、其余配置原样保留;临时文件 + replace 原子写"""
|
||||
p = _cfg_path()
|
||||
try:
|
||||
cfg = {}
|
||||
if os.path.exists(p):
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
cfg = json.load(f) or {}
|
||||
cfg[_CFG_KEY] = int(w)
|
||||
tmp = p + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, p)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[UI]: 保存任务面板宽度失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _svg(name: str) -> str:
|
||||
return os.path.join(_ROOT, "svg", name)
|
||||
|
||||
|
||||
def _fmt_dur(sec) -> str:
|
||||
try:
|
||||
sec = float(sec)
|
||||
except Exception:
|
||||
return ""
|
||||
return f"{sec:.1f}s" if sec < 60 else f"{int(sec // 60)}m{int(sec % 60)}s"
|
||||
|
||||
|
||||
def _parse_dur(text):
|
||||
"""从上下文文本结尾的 (Ns) 里取耗时;取不到返回 None"""
|
||||
m = _DUR_RE.search((text or "").strip())
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return float(m.group(1))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _cmd_of(args) -> str:
|
||||
"""args 可能是 JSON 字符串或 dict → 取出 bash 的 command"""
|
||||
if isinstance(args, dict):
|
||||
return args.get("command", "") or ""
|
||||
try:
|
||||
d = json.loads(args or "{}")
|
||||
if isinstance(d, dict):
|
||||
return d.get("command", "") or ""
|
||||
except Exception:
|
||||
pass
|
||||
return str(args or "")
|
||||
|
||||
|
||||
def _iter_bash_entries(timeline):
|
||||
"""timeline 可能是 JSON 字符串(DB)或 list[dict](内存)→ 产出 name=='bash' 的工具条目"""
|
||||
data = timeline
|
||||
if isinstance(timeline, str):
|
||||
if not timeline.strip():
|
||||
return
|
||||
try:
|
||||
data = json.loads(timeline)
|
||||
except Exception:
|
||||
return
|
||||
if not isinstance(data, list):
|
||||
return
|
||||
for e in data:
|
||||
if isinstance(e, dict) and e.get("t") == "tool" and e.get("name") == "bash":
|
||||
yield e
|
||||
|
||||
|
||||
class _ClickFrame(QtWidgets.QFrame):
|
||||
"""整块可单击(单击即触发,不用双击)"""
|
||||
clicked = QtCore.pyqtSignal()
|
||||
|
||||
def mouseReleaseEvent(self, e):
|
||||
if (e.button() == QtCore.Qt.MouseButton.LeftButton
|
||||
and self.rect().contains(e.position().toPoint())):
|
||||
self.clicked.emit()
|
||||
super().mouseReleaseEvent(e)
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 一层 = 一次 bash 执行
|
||||
# ======================================================================
|
||||
class BashLayer(QtWidgets.QFrame):
|
||||
def __init__(self, call_id: str, command: str = "", parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("bl_layer")
|
||||
self.call_id = call_id
|
||||
self.command = command or ""
|
||||
self.ok = None # None=运行中 / True / False=已完成
|
||||
self.in_context = True
|
||||
self.expanded = False
|
||||
self.elapsed = 0
|
||||
self.timeout = 0
|
||||
self._live = ""
|
||||
self._live_truncated = False
|
||||
self._final = None
|
||||
self._build()
|
||||
self._apply_status()
|
||||
|
||||
# ---------------- UI ----------------
|
||||
def _build(self):
|
||||
v = QtWidgets.QVBoxLayout(self)
|
||||
v.setContentsMargins(0, 0, 0, 0)
|
||||
v.setSpacing(0)
|
||||
|
||||
self.head = _ClickFrame()
|
||||
self.head.setObjectName("bl_head")
|
||||
self.head.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||||
self.head.setToolTip(self.command or "")
|
||||
hl = QtWidgets.QHBoxLayout(self.head)
|
||||
hl.setContentsMargins(8, 6, 8, 6)
|
||||
hl.setSpacing(6)
|
||||
|
||||
self.dot = QtWidgets.QLabel("●")
|
||||
self.dot.setObjectName("bl_dot")
|
||||
self.name = QtWidgets.QLabel("bash")
|
||||
self.name.setObjectName("bl_name")
|
||||
self.meta = QtWidgets.QLabel("")
|
||||
self.meta.setObjectName("bl_meta")
|
||||
self.tag = QtWidgets.QLabel("")
|
||||
self.tag.setObjectName("bl_tag")
|
||||
self.cmd = QtWidgets.QLabel(self._preview())
|
||||
self.cmd.setObjectName("bl_cmd")
|
||||
self.cmd.setSizePolicy(QtWidgets.QSizePolicy.Policy.Ignored,
|
||||
QtWidgets.QSizePolicy.Policy.Preferred)
|
||||
self.chev = QtWidgets.QLabel("▸")
|
||||
self.chev.setObjectName("bl_chev")
|
||||
|
||||
hl.addWidget(self.dot, 0)
|
||||
hl.addWidget(self.name, 0)
|
||||
hl.addWidget(self.meta, 0)
|
||||
hl.addWidget(self.tag, 0)
|
||||
hl.addWidget(self.cmd, 1)
|
||||
hl.addWidget(self.chev, 0)
|
||||
v.addWidget(self.head)
|
||||
|
||||
self.body = QtWidgets.QWidget()
|
||||
self.body.setObjectName("bl_body")
|
||||
bl = QtWidgets.QVBoxLayout(self.body)
|
||||
bl.setContentsMargins(8, 0, 8, 8)
|
||||
bl.setSpacing(4)
|
||||
|
||||
self.lbl_arg = QtWidgets.QLabel("参数")
|
||||
self.lbl_arg.setObjectName("bl_sect_label")
|
||||
self.arg_box = self._code_box(80, wrap=True)
|
||||
bl.addWidget(self.lbl_arg)
|
||||
bl.addWidget(self.arg_box)
|
||||
|
||||
self.lbl_out = QtWidgets.QLabel("输出")
|
||||
self.lbl_out.setObjectName("bl_sect_label")
|
||||
self.out_box = self._code_box(230, wrap=False)
|
||||
bl.addWidget(self.lbl_out)
|
||||
bl.addWidget(self.out_box)
|
||||
|
||||
self.body.hide()
|
||||
v.addWidget(self.body)
|
||||
self.head.clicked.connect(self.toggle)
|
||||
|
||||
def _code_box(self, max_h, wrap):
|
||||
box = QtWidgets.QPlainTextEdit()
|
||||
box.setObjectName("bl_code")
|
||||
box.setReadOnly(True)
|
||||
box.setMaximumHeight(max_h)
|
||||
box.setLineWrapMode(
|
||||
QtWidgets.QPlainTextEdit.LineWrapMode.WidgetWidth if wrap
|
||||
else QtWidgets.QPlainTextEdit.LineWrapMode.NoWrap)
|
||||
box.setFrameShape(QtWidgets.QFrame.Shape.NoFrame)
|
||||
box.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
box.setHorizontalScrollBarPolicy(
|
||||
QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded if not wrap
|
||||
else QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
return box
|
||||
|
||||
def _preview(self) -> str:
|
||||
s = " ".join((self.command or "").split())
|
||||
return s[:58] + ("…" if len(s) > 58 else "")
|
||||
|
||||
# ---------------- 状态 ----------------
|
||||
def _apply_status(self):
|
||||
if self.ok is None:
|
||||
self.dot.setProperty("state", "run")
|
||||
self.meta.setText(f"{self.elapsed}/{self.timeout}s" if self.timeout
|
||||
else (f"{self.elapsed}s" if self.elapsed else "…"))
|
||||
else:
|
||||
self.dot.setProperty("state", "ok" if self.ok else "bad")
|
||||
self.meta.setText(_fmt_dur(self.elapsed) if self.elapsed else "")
|
||||
|
||||
if self.ok is not None and not self.in_context:
|
||||
self.tag.setText("已出上下文")
|
||||
self.tag.setProperty("kind", "out")
|
||||
elif self.ok is None:
|
||||
self.tag.setText("运行中")
|
||||
self.tag.setProperty("kind", "run")
|
||||
elif self.ok:
|
||||
self.tag.setText("已完成")
|
||||
self.tag.setProperty("kind", "ok")
|
||||
else:
|
||||
self.tag.setText("失败")
|
||||
self.tag.setProperty("kind", "bad")
|
||||
|
||||
for w in (self.dot, self.tag):
|
||||
w.style().unpolish(w)
|
||||
w.style().polish(w)
|
||||
|
||||
def set_running(self, elapsed, timeout):
|
||||
self.ok = None
|
||||
self.elapsed = int(elapsed or 0)
|
||||
self.timeout = int(timeout or 0)
|
||||
self._apply_status()
|
||||
if self.expanded:
|
||||
self._render_output()
|
||||
|
||||
def append_live(self, text: str):
|
||||
if not text:
|
||||
return
|
||||
self._live += text
|
||||
if len(self._live) > LIVE_BUF_CAP:
|
||||
self._live = self._live[-LIVE_BUF_CAP:]
|
||||
self._live_truncated = True
|
||||
if self.expanded:
|
||||
self._render_output()
|
||||
|
||||
def set_finished(self, ok, text):
|
||||
self.ok = bool(ok)
|
||||
self._final = text or ""
|
||||
d = _parse_dur(self._final)
|
||||
if d is not None:
|
||||
self.elapsed = d
|
||||
self._apply_status()
|
||||
if self.expanded:
|
||||
self._render_output()
|
||||
|
||||
def set_in_context(self, in_ctx: bool):
|
||||
self.in_context = bool(in_ctx)
|
||||
self._apply_status()
|
||||
|
||||
# ---------------- 展开 ----------------
|
||||
def toggle(self):
|
||||
self.expanded = not self.expanded
|
||||
self.body.setVisible(self.expanded)
|
||||
self.chev.setText("▾" if self.expanded else "▸")
|
||||
if self.expanded:
|
||||
self.arg_box.setPlainText(self.command or "(无参数)")
|
||||
self._render_output()
|
||||
|
||||
def _render_output(self):
|
||||
if self.ok is None:
|
||||
body = self._live
|
||||
if self._live_truncated:
|
||||
body = "…(实时缓冲已截断,仅保留最后 200KB)\n" + body
|
||||
self.lbl_out.setText("输出(实时)")
|
||||
self.out_box.setPlainText(body if body else "(等待输出…)")
|
||||
sb = self.out_box.verticalScrollBar()
|
||||
sb.setValue(sb.maximum())
|
||||
else:
|
||||
self.lbl_out.setText("输出(进入上下文)")
|
||||
self.out_box.setPlainText(self._final or "(无输出)")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 一栏:标题(可折叠)+ 层列表
|
||||
# ======================================================================
|
||||
class _Section(QtWidgets.QWidget):
|
||||
def __init__(self, title, empty_hint, on_fold=None, parent=None):
|
||||
super().__init__(parent)
|
||||
self.empty_hint = empty_hint
|
||||
self.folded = False
|
||||
self._on_fold = on_fold # 回调:交面板做高度动画(抽屉式)
|
||||
self._effect = None # 内容区淡入淡出用(按需创建,静止时移除)
|
||||
self._head_hint = 0 # 头部行高缓存(恒定值)
|
||||
v = QtWidgets.QVBoxLayout(self)
|
||||
v.setContentsMargins(0, 0, 0, 0)
|
||||
v.setSpacing(0)
|
||||
|
||||
self.head = _ClickFrame()
|
||||
self.head.setObjectName("bl_sect_head")
|
||||
self.head.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||||
hl = QtWidgets.QHBoxLayout(self.head)
|
||||
hl.setContentsMargins(10, 6, 8, 6)
|
||||
hl.setSpacing(6)
|
||||
self.chev = QtWidgets.QLabel("▾")
|
||||
self.chev.setObjectName("bl_sect_chev")
|
||||
self.title = QtWidgets.QLabel(title)
|
||||
self.title.setObjectName("bl_sect_title")
|
||||
self.count = QtWidgets.QLabel("0")
|
||||
self.count.setObjectName("bl_sect_count")
|
||||
hl.addWidget(self.chev, 0)
|
||||
hl.addWidget(self.title, 0)
|
||||
hl.addStretch(1)
|
||||
hl.addWidget(self.count, 0)
|
||||
v.addWidget(self.head)
|
||||
|
||||
self.scroll = QtWidgets.QScrollArea()
|
||||
self.scroll.setObjectName("bl_scroll")
|
||||
self.scroll.setWidgetResizable(True)
|
||||
self.scroll.setFrameShape(QtWidgets.QFrame.Shape.NoFrame)
|
||||
self.scroll.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
# 🐛 修复:QScrollArea 的 viewport 默认按 palette base(白)自画背景,
|
||||
# 会把父级 #f7f8fa 盖成白色 → 强制透明
|
||||
self.scroll.viewport().setStyleSheet("background: transparent;")
|
||||
self.scroll.viewport().setAutoFillBackground(False)
|
||||
self.host = QtWidgets.QWidget()
|
||||
self.host.setObjectName("bl_host")
|
||||
self.host.setAutoFillBackground(False)
|
||||
self.lay = QtWidgets.QVBoxLayout(self.host)
|
||||
self.lay.setContentsMargins(6, 2, 6, 6)
|
||||
self.lay.setSpacing(5)
|
||||
self.scroll.setWidget(self.host)
|
||||
|
||||
# 🆕 「下面填充的 bash 层」整块放进 body:折叠/展开动画只针对它,
|
||||
# 头部行(self.head)高度永远不变
|
||||
self.body = QtWidgets.QWidget()
|
||||
self.body.setObjectName("bl_body")
|
||||
self.body.setAutoFillBackground(False)
|
||||
bv = QtWidgets.QVBoxLayout(self.body)
|
||||
bv.setContentsMargins(0, 0, 0, 0)
|
||||
bv.setSpacing(0)
|
||||
bv.addWidget(self.scroll, 1)
|
||||
|
||||
self.hint = QtWidgets.QLabel("")
|
||||
self.hint.setObjectName("bl_hint")
|
||||
self.hint.setWordWrap(True)
|
||||
bv.addWidget(self.hint)
|
||||
v.addWidget(self.body, 1)
|
||||
|
||||
self.head.clicked.connect(self._toggle_fold)
|
||||
self._set_hint(self.empty_hint)
|
||||
# 🐛 头部行高【硬固定】:收起时 body 被隐、布局里只剩 head,而 QFrame 默认
|
||||
# 竖直策略可伸长 → head 会被拉满整栏(实测 793px 巨条 = 「按钮变大很高」)
|
||||
self._head_hint = max(16, self.head.sizeHint().height())
|
||||
self.head.setFixedHeight(self._head_hint)
|
||||
|
||||
# ---------------- 头部行高(收起/展开恒定的那一行)----------------
|
||||
def head_height(self) -> int:
|
||||
# 🐛 必须缓存 sizeHint:若用实时 self.head.height(),会形成正反馈——
|
||||
# 头部被压矮 → minimumSizeHint 变小 → splitter 允许更矮 → 头部再被压矮…
|
||||
if self._head_hint <= 0:
|
||||
self._head_hint = max(16, self.head.sizeHint().height())
|
||||
return self._head_hint
|
||||
def minimumSizeHint(self):
|
||||
# 🐛 关键修复:默认最小高 ≈ 113px(头部 + QScrollArea 最小高 + 提示语),
|
||||
# QSplitter 完全没法把本栏收缩 → 收起后留一大块空白(用户报的 bug)。
|
||||
# 改为「只剩头部行」,内容区允许被压到 0(超出部分天然被父级裁切)。
|
||||
return QtCore.QSize(0, self.head_height())
|
||||
|
||||
# ---------------- 内容区透明度(动画帧驱动)----------------
|
||||
def set_body_opacity(self, op: float):
|
||||
op = max(0.0, min(1.0, float(op)))
|
||||
if op >= 0.999:
|
||||
self._drop_effect()
|
||||
return
|
||||
if self._effect is None:
|
||||
self._effect = QtWidgets.QGraphicsOpacityEffect(self.body)
|
||||
self.body.setGraphicsEffect(self._effect)
|
||||
self._effect.setOpacity(op)
|
||||
|
||||
def _drop_effect(self):
|
||||
"""去掉离屏渲染开销(静止时不挂 effect)"""
|
||||
if self.body.graphicsEffect() is not None:
|
||||
self.body.setGraphicsEffect(None)
|
||||
self._effect = None
|
||||
|
||||
def show_body(self):
|
||||
self.body.setVisible(True)
|
||||
self.scroll.setVisible(True)
|
||||
self.hint.setVisible(bool(self.hint.text()))
|
||||
|
||||
def hide_body(self):
|
||||
self.body.setVisible(False)
|
||||
|
||||
def set_folded(self, folded: bool):
|
||||
"""应用折叠状态(高度由面板的 splitter 动画负责)"""
|
||||
self.folded = bool(folded)
|
||||
self.chev.setText("▸" if self.folded else "▾")
|
||||
if self.folded:
|
||||
self._drop_effect()
|
||||
else:
|
||||
self.show_body()
|
||||
self.set_body_opacity(1.0)
|
||||
|
||||
def _toggle_fold(self):
|
||||
if self._on_fold is not None:
|
||||
self._on_fold(self) # 交面板:状态切换 + 高度抽屉动画
|
||||
return
|
||||
self.set_folded(not self.folded)
|
||||
self.scroll.setVisible(not self.folded)
|
||||
self.hint.setVisible(not self.folded and bool(self.hint.text()))
|
||||
|
||||
def _set_hint(self, text):
|
||||
self.hint.setText(text or "")
|
||||
self.hint.setVisible(not self.folded and bool(text))
|
||||
|
||||
def set_layers(self, layers, total=None):
|
||||
"""重排层(复用同一批 widget → 实时缓冲不丢)"""
|
||||
while self.lay.count():
|
||||
it = self.lay.takeAt(0)
|
||||
w = it.widget()
|
||||
if w is not None:
|
||||
w.setParent(None)
|
||||
for w in layers:
|
||||
self.lay.addWidget(w)
|
||||
w.show()
|
||||
self.lay.addStretch(1)
|
||||
self.count.setText(str(len(layers)))
|
||||
total = len(layers) if total is None else total
|
||||
if total > len(layers):
|
||||
self._set_hint(f"仅显示最近 {len(layers)} 层(本会话共 {total} 层)")
|
||||
else:
|
||||
self._set_hint("" if layers else self.empty_hint)
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 右侧任务面板(外壳,镜像左侧边栏)
|
||||
# ======================================================================
|
||||
class _ResizeHandle(QtWidgets.QWidget):
|
||||
"""面板左边缘的拖拽手柄(4px 隐形条,hover 才亮;光标 SizeHor)"""
|
||||
|
||||
W = 4
|
||||
|
||||
def __init__(self, panel):
|
||||
super().__init__(panel)
|
||||
self._panel = panel
|
||||
self.setObjectName("bl_resize_handle")
|
||||
# QWidget 子类必须显式开启才会绘制样式表背景(hover 高亮靠它)
|
||||
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
self.setCursor(QtCore.Qt.CursorShape.SizeHorCursor)
|
||||
self.setStyleSheet("#bl_resize_handle:hover { background: #dfe3ea; }")
|
||||
self.setToolTip("拖拽调整任务面板宽度(最小 %dpx)" % PANEL_W_MIN)
|
||||
self._drag = False
|
||||
|
||||
def mousePressEvent(self, e):
|
||||
if e.button() == QtCore.Qt.MouseButton.LeftButton:
|
||||
self._drag = True
|
||||
self._panel._drag_begin(e.globalPosition().x())
|
||||
e.accept()
|
||||
return
|
||||
super().mousePressEvent(e)
|
||||
|
||||
def mouseMoveEvent(self, e):
|
||||
if self._drag:
|
||||
self._panel._drag_to_global_x(e.globalPosition().x())
|
||||
e.accept()
|
||||
return
|
||||
super().mouseMoveEvent(e)
|
||||
|
||||
def mouseReleaseEvent(self, e):
|
||||
if self._drag:
|
||||
self._drag = False
|
||||
self._panel._commit_drag_width()
|
||||
e.accept()
|
||||
return
|
||||
super().mouseReleaseEvent(e)
|
||||
|
||||
|
||||
class BashPanel(QtWidgets.QWidget):
|
||||
W_EXPAND = PANEL_W_DEFAULT
|
||||
W_COLLAPSE = 52
|
||||
W_MIN = PANEL_W_MIN
|
||||
W_MAX = PANEL_W_MAX
|
||||
FOLD_MS = 200 # 栏折叠/展开时长(与 ModelSelectPopup.DRAWER_DURATION 一致)
|
||||
FOLD_STEP = 16 # 帧间隔(与 DRAWER_STEP 一致,~60fps)
|
||||
MIN_BODY_H = 60 # 展开后「下面填充的层」至少保留的高度
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("right_sidebar")
|
||||
# 🐛 关键修复:QWidget 的自定义子类默认【不绘制样式表背景/边框】
|
||||
# (#sidebar 是原生 QWidget 实例所以正常,BashPanel 是子类所以失效——
|
||||
# 表现为右侧栏底色变白、左侧分隔线丢失、空间感消失)
|
||||
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
# 🆕 展开宽度:优先用上次拖拽记录的值(config.json: bash_panel_width)
|
||||
self.W_EXPAND = load_panel_width()
|
||||
self._end_w = self.W_EXPAND
|
||||
self._drag_right = None
|
||||
self._w = self.W_COLLAPSE
|
||||
self.setFixedWidth(self._w) # 默认收起
|
||||
self.collapsed = True
|
||||
self._target = True
|
||||
self._anim = None
|
||||
self._session_id = None
|
||||
self._db = None
|
||||
self._layers = {} # call_id -> BashLayer
|
||||
self._order = [] # 执行先后
|
||||
self._running = set()
|
||||
self._done = set()
|
||||
self._saved_h = [120, 520] # 每栏收起前的高度(展开时恢复)
|
||||
self._fold = None # 折叠动画状态
|
||||
self._fold_timer = QtCore.QTimer(self)
|
||||
self._fold_timer.setInterval(self.FOLD_STEP)
|
||||
self._fold_timer.timeout.connect(self._fold_tick)
|
||||
self._build()
|
||||
# 🆕 左边缘拖拽手柄(浮在最左 4px,收起/展开两种状态都在)
|
||||
self._handle = _ResizeHandle(self)
|
||||
self._handle.setFixedWidth(_ResizeHandle.W)
|
||||
self._handle.raise_()
|
||||
|
||||
# ---------------- 外壳 ----------------
|
||||
def _build(self):
|
||||
v = QtWidgets.QVBoxLayout(self)
|
||||
v.setContentsMargins(0, 0, 0, 0)
|
||||
v.setSpacing(0)
|
||||
self.stack = QtWidgets.QStackedWidget()
|
||||
v.addWidget(self.stack)
|
||||
|
||||
# ---- 展开页(顶部留白 20px + 36px 标题行,与左侧栏完全对齐)----
|
||||
self.expand_page = QtWidgets.QWidget()
|
||||
ev = QtWidgets.QVBoxLayout(self.expand_page)
|
||||
ev.setContentsMargins(0, 20, 0, 12)
|
||||
ev.setSpacing(0)
|
||||
|
||||
self.header = QtWidgets.QWidget()
|
||||
self.header.setObjectName("bl_panel_head")
|
||||
self.header.setFixedHeight(36)
|
||||
hl = QtWidgets.QHBoxLayout(self.header)
|
||||
hl.setContentsMargins(10, 0, 10, 0)
|
||||
hl.setSpacing(6)
|
||||
self.lbl_title = QtWidgets.QLabel("任务面板")
|
||||
self.lbl_title.setObjectName("bl_panel_title")
|
||||
self.lbl_total = QtWidgets.QLabel("")
|
||||
self.lbl_total.setObjectName("bl_panel_total")
|
||||
# 🐛 修复双按钮:面板内不再自带收起按钮(唯一开关 = 顶部工具栏按钮),
|
||||
# 与左侧栏的区别:左侧栏的开关在栏内,右侧栏的开关在顶部(原「导出」位置)
|
||||
hl.addWidget(self.lbl_title, 0)
|
||||
hl.addWidget(self.lbl_total, 0)
|
||||
hl.addStretch(1)
|
||||
# 🆕 展开态开关:标题行右端(与左侧栏 collapse_btn 完全同规格)
|
||||
self.fold_btn = QtWidgets.QPushButton("")
|
||||
self.fold_btn.setObjectName("collapse_btn")
|
||||
self.fold_btn.setIcon(QtGui.QIcon(_svg("panel_right.svg")))
|
||||
self.fold_btn.setIconSize(QtCore.QSize(16, 16))
|
||||
self.fold_btn.setFixedSize(28, 28)
|
||||
self.fold_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||||
self.fold_btn.setToolTip("收起任务面板")
|
||||
hl.addWidget(self.fold_btn, 0)
|
||||
ev.addWidget(self.header)
|
||||
ev.addSpacing(10)
|
||||
|
||||
self.splitter = QtWidgets.QSplitter(_SPLIT_ORIENTATION)
|
||||
self.splitter.setObjectName("bl_splitter")
|
||||
self.splitter.setChildrenCollapsible(False)
|
||||
self.splitter.setHandleWidth(5)
|
||||
self.sec_running = _Section("运行中", "暂无正在运行的 bash", on_fold=self._toggle_section)
|
||||
self.sec_done = _Section("已完成", "本会话还没有已完成的 bash", on_fold=self._toggle_section)
|
||||
self.splitter.addWidget(self.sec_running)
|
||||
self.splitter.addWidget(self.sec_done)
|
||||
# 🆕 底部留白占位:最后一栏收起时由它吸收余量(否则栏头会被顶到面板底部,
|
||||
# 或被拉伸成巨条)
|
||||
self.spacer = QtWidgets.QWidget()
|
||||
self.spacer.setObjectName("bl_spacer")
|
||||
self.spacer.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
self.spacer.setStyleSheet("#bl_spacer { background: transparent; }")
|
||||
self.splitter.addWidget(self.spacer)
|
||||
self.splitter.setCollapsible(2, True) # 占位层允许被压到 0
|
||||
self.splitter.setSizes(self._saved_h + [0])
|
||||
_h2 = self.splitter.handle(2)
|
||||
if _h2 is not None: # 占位层上方的分隔条:不可拖、不可见
|
||||
_h2.setEnabled(False)
|
||||
_h2.setStyleSheet("background: transparent;")
|
||||
ev.addWidget(self.splitter, 1)
|
||||
self.stack.addWidget(self.expand_page)
|
||||
|
||||
# ---- 收起页:栏正中一个展开按钮(与左侧栏 collapse_expand_btn 完全同规格)----
|
||||
self.collapse_page = QtWidgets.QWidget()
|
||||
cl = QtWidgets.QVBoxLayout(self.collapse_page)
|
||||
cl.setContentsMargins(0, 0, 0, 0)
|
||||
cl.addStretch(1)
|
||||
self.expand_btn = QtWidgets.QPushButton("")
|
||||
self.expand_btn.setObjectName("collapse_btn")
|
||||
self.expand_btn.setIcon(QtGui.QIcon(_svg("panel_right.svg")))
|
||||
self.expand_btn.setIconSize(QtCore.QSize(18, 18))
|
||||
self.expand_btn.setFixedSize(34, 34)
|
||||
self.expand_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||||
self.expand_btn.setToolTip("展开任务面板")
|
||||
cl.addWidget(self.expand_btn, 0, QtCore.Qt.AlignmentFlag.AlignHCenter)
|
||||
cl.addStretch(1)
|
||||
self.stack.addWidget(self.collapse_page)
|
||||
|
||||
# 两个按钮分居 QStackedWidget 两页 → 任何时刻可见开关恒为 1 个
|
||||
self.stack.setCurrentWidget(self.collapse_page)
|
||||
self.fold_btn.clicked.connect(self.toggle)
|
||||
self.expand_btn.clicked.connect(self.toggle)
|
||||
|
||||
# ---------------- 两栏折叠/展开:抽屉式高度动画(头部行高恒定)----------------
|
||||
def _toggle_section(self, sec):
|
||||
"""点栏头 → 只把「下面填充的 bash 层」的高度收到 0,头部行高度不变"""
|
||||
self._finish_fold() # 连点:先把上一段动画落定
|
||||
idx = 0 if sec is self.sec_running else 1
|
||||
sizes = self.splitter.sizes()
|
||||
avail = sum(sizes)
|
||||
head_h = sec.head_height()
|
||||
other = self.sec_done if idx == 0 else self.sec_running
|
||||
if not sec.folded and not other.folded:
|
||||
# 即将收起 + 另一栏展开 → 记住当前高度(用于下次展开恢复);
|
||||
# 另一栏已收起时不记(此时本栏高度是「余量」而非用户意图高度)
|
||||
self._saved_h[idx] = max(sizes[idx], head_h + self.MIN_BODY_H)
|
||||
sec.set_folded(not sec.folded)
|
||||
target = self._layout_targets(actor=sec)
|
||||
if not sec.folded: # 展开 → 内容从透明开始淡入
|
||||
sec.show_body()
|
||||
sec.set_body_opacity(0.0)
|
||||
self._sync_handle()
|
||||
if avail <= 0 or self.splitter.width() <= 0:
|
||||
self.splitter.setSizes(target)
|
||||
self._end_fold_visual(sec)
|
||||
return
|
||||
self._fold = {"sec": sec, "from": list(sizes), "to": target, "t": 0.0}
|
||||
self._fold_timer.start()
|
||||
|
||||
def _layout_targets(self, actor=None):
|
||||
"""统一布局策略(层从顶部堆叠):
|
||||
· 收起栏 = 只剩头部行
|
||||
· 本次被点开的那栏 → 用记录的高度
|
||||
· 余量只由「展开着的已完成」吸收(填满到底部);
|
||||
若「已完成」已收起 → 余量进底部留白(栏头紧跟上方内容)
|
||||
"""
|
||||
s = self.splitter.sizes()
|
||||
avail = sum(s)
|
||||
hr = self.sec_running.head_height()
|
||||
hd = self.sec_done.head_height()
|
||||
h0, h1 = s[0], s[1]
|
||||
run_f, done_f = self.sec_running.folded, self.sec_done.folded
|
||||
if run_f:
|
||||
h0 = hr
|
||||
if done_f:
|
||||
h1 = hd
|
||||
if (not run_f) and actor is self.sec_running:
|
||||
h0 = self._saved_h[0]
|
||||
if (not done_f) and actor is self.sec_done:
|
||||
h1 = self._saved_h[1]
|
||||
if not done_f: # 已完成展开 → 吃掉全部余量
|
||||
floor0 = hr if run_f else hr + self.MIN_BODY_H
|
||||
h1 = max(hd + self.MIN_BODY_H, avail - max(h0, floor0))
|
||||
h0 = max(hr if run_f else hr + self.MIN_BODY_H, h0)
|
||||
h1 = max(hd if done_f else hd + self.MIN_BODY_H, h1)
|
||||
return [h0, h1, max(0, avail - h0 - h1)]
|
||||
|
||||
def _fold_tick(self):
|
||||
"""抽屉式每帧:ease-out 三次方插值高度 + 内容淡入淡出(同模型选择窗口)"""
|
||||
d = self._fold
|
||||
if d is None:
|
||||
self._fold_timer.stop()
|
||||
return
|
||||
d["t"] = min(1.0, d["t"] + self.FOLD_STEP / self.FOLD_MS)
|
||||
e = 1.0 - (1.0 - d["t"]) ** 3
|
||||
a, b = d["from"], d["to"]
|
||||
self.splitter.setSizes([int(round(x + (y - x) * e)) for x, y in zip(a, b)])
|
||||
sec = d["sec"]
|
||||
sec.set_body_opacity((1.0 - e) if sec.folded else e)
|
||||
if d["t"] >= 1.0:
|
||||
self._finish_fold()
|
||||
|
||||
def _finish_fold(self):
|
||||
self._fold_timer.stop()
|
||||
d = self._fold
|
||||
if d is None:
|
||||
return
|
||||
self._fold = None
|
||||
self.splitter.setSizes(d["to"])
|
||||
self._end_fold_visual(d["sec"])
|
||||
|
||||
def _end_fold_visual(self, sec):
|
||||
if sec.folded:
|
||||
sec.set_body_opacity(0.0)
|
||||
sec.hide_body() # 隐藏后连离屏渲染也省了
|
||||
else:
|
||||
sec.show_body()
|
||||
sec.set_body_opacity(1.0) # 置 1 → 自动移除 effect
|
||||
|
||||
def _sync_handle(self):
|
||||
"""两栏都展开 → 中间分隔条可拖;任一栏收起 → 禁用(避免「刚收起又被拖大」)"""
|
||||
h = self.splitter.handle(1)
|
||||
if h is not None:
|
||||
ok = not (self.sec_running.folded or self.sec_done.folded)
|
||||
h.setEnabled(ok)
|
||||
h.setCursor(QtCore.Qt.CursorShape.SplitVCursor if ok
|
||||
else QtCore.Qt.CursorShape.ArrowCursor)
|
||||
|
||||
def _repin_fold(self):
|
||||
"""窗口/面板尺寸变化后,按统一策略重新钉住(收起栏 = 只剩头部行)"""
|
||||
if self._fold is not None or self.collapsed:
|
||||
return
|
||||
if not (self.sec_running.folded or self.sec_done.folded):
|
||||
return
|
||||
want = self._layout_targets()
|
||||
if want != self.splitter.sizes():
|
||||
self.splitter.setSizes(want)
|
||||
|
||||
def get_panelWidth(self):
|
||||
return self._w
|
||||
|
||||
def set_panelWidth(self, w):
|
||||
self._w = int(w)
|
||||
self.setFixedWidth(int(w))
|
||||
|
||||
panelWidth = QtCore.pyqtProperty(int, fget=get_panelWidth, fset=set_panelWidth)
|
||||
|
||||
def toggle(self):
|
||||
self._set_collapsed(not self._target)
|
||||
|
||||
def _set_collapsed(self, collapsed: bool):
|
||||
self._stop_anim()
|
||||
self._target = collapsed
|
||||
self.stack.setCurrentWidget(
|
||||
self.collapse_page if collapsed else self.expand_page)
|
||||
start_w = self.width()
|
||||
# 展开时若记录的宽度超出当前窗口可承受范围 → 夹一下
|
||||
self._end_w = self.W_COLLAPSE if collapsed else self._clamp_w(self.W_EXPAND)
|
||||
end_w = self._end_w
|
||||
self._anim = QtCore.QPropertyAnimation(self, b"panelWidth")
|
||||
self._anim.setDuration(260)
|
||||
self._anim.setStartValue(float(start_w))
|
||||
self._anim.setEndValue(float(end_w))
|
||||
self._anim.setEasingCurve(QtCore.QEasingCurve.Type.InOutCubic)
|
||||
self._anim.finished.connect(self._on_anim_finished)
|
||||
self._anim.start()
|
||||
|
||||
def _on_anim_finished(self):
|
||||
self.setFixedWidth(self.W_COLLAPSE if self._target else self._end_w)
|
||||
self.collapsed = self._target
|
||||
self._stop_anim()
|
||||
|
||||
# ---------------- 宽度拖拽(右边界固定,左边界跟随鼠标)----------------
|
||||
def _stop_anim(self):
|
||||
if self._anim is not None:
|
||||
try:
|
||||
self._anim.stop()
|
||||
self._anim.deleteLater()
|
||||
except Exception:
|
||||
pass
|
||||
self._anim = None
|
||||
|
||||
def _clamp_w(self, w) -> int:
|
||||
"""夹在 [W_MIN, min(W_MAX, 主窗口 50%)](最小宽度就在这里生效)"""
|
||||
hi = self.W_MAX
|
||||
par = self.parentWidget()
|
||||
if par is not None and par.width() > 0:
|
||||
hi = min(hi, max(self.W_MIN, int(par.width() * 0.5)))
|
||||
return max(self.W_MIN, min(hi, int(w)))
|
||||
|
||||
def resizeEvent(self, e):
|
||||
super().resizeEvent(e)
|
||||
h = getattr(self, "_handle", None)
|
||||
if h is not None:
|
||||
h.setGeometry(0, 0, _ResizeHandle.W, self.height())
|
||||
h.raise_()
|
||||
self._repin_fold()
|
||||
|
||||
def _drag_begin(self, gx):
|
||||
self._stop_anim()
|
||||
# 动画中途按下 → 以当前实际宽度为准确定状态
|
||||
self.collapsed = self.width() <= self.W_COLLAPSE + 8
|
||||
self._target = self.collapsed
|
||||
self.stack.setCurrentWidget(
|
||||
self.collapse_page if self.collapsed else self.expand_page)
|
||||
self._drag_right = self.mapToGlobal(QtCore.QPoint(self.width(), 0)).x()
|
||||
|
||||
def _drag_to_global_x(self, gx):
|
||||
if self._drag_right is None:
|
||||
return
|
||||
w = self._clamp_w(self._drag_right - int(gx))
|
||||
if self.collapsed:
|
||||
if w <= self.W_COLLAPSE + 8:
|
||||
return # 还在收起条内拖 → 不响应
|
||||
self.collapsed = False # 从收起态直接拖开 → 立即进展开态
|
||||
self._target = False
|
||||
self.stack.setCurrentWidget(self.expand_page)
|
||||
self._w = w
|
||||
self.setFixedWidth(w)
|
||||
|
||||
def _commit_drag_width(self):
|
||||
self._drag_right = None
|
||||
if self.collapsed:
|
||||
return
|
||||
self.W_EXPAND = self._w
|
||||
if save_panel_width(self._w):
|
||||
print(f"[UI]: 任务面板宽度 -> {self._w}px(已记录,下次展开自动使用)")
|
||||
|
||||
# ---------------- 数据 ----------------
|
||||
def set_session(self, session_id, db, active_stream=None):
|
||||
"""切换会话 → 全量重建。已完成来自 DB 链;未落库的在跑任务来自 active_stream。"""
|
||||
self._session_id = session_id
|
||||
self._db = db
|
||||
self._layers.clear()
|
||||
self._order.clear()
|
||||
self._running.clear()
|
||||
self._done.clear()
|
||||
|
||||
# ① DB:本会话链上的历史 bash(标注是否还在上下文内)
|
||||
try:
|
||||
chain = db.get_message_chain(session_id) if db else []
|
||||
except Exception:
|
||||
chain = []
|
||||
mark_idx = -1
|
||||
for i, m in enumerate(chain):
|
||||
if m.get("role") == "compaction":
|
||||
mark_idx = i
|
||||
for i, m in enumerate(chain):
|
||||
msg_id = m.get("id") or ""
|
||||
for e in _iter_bash_entries(m.get("timeline")):
|
||||
cid = e.get("id") or f"dbmsg_{msg_id}_{i}"
|
||||
if cid in self._layers:
|
||||
continue
|
||||
lay = BashLayer(cid, _cmd_of(e.get("args")))
|
||||
lay.set_finished(bool(e.get("ok")), e.get("result") or "")
|
||||
lay.set_in_context(i > mark_idx)
|
||||
self._layers[cid] = lay
|
||||
self._order.append(cid)
|
||||
self._done.add(cid)
|
||||
|
||||
# ② 内存:正在进行的这一轮(包括已开始但未落库、以及刚结束未落库的)
|
||||
for e in _iter_bash_entries((active_stream or {}).get("timeline")):
|
||||
cid = e.get("id")
|
||||
if not cid or cid in self._layers:
|
||||
continue
|
||||
lay = BashLayer(cid, _cmd_of(e.get("args")))
|
||||
if e.get("ok") is None:
|
||||
lay.set_running(0, 0)
|
||||
if e.get("result"):
|
||||
lay.append_live(str(e.get("result"))) # 尽力回放(上游上限 4000 字符)
|
||||
self._running.add(cid)
|
||||
else:
|
||||
lay.set_finished(bool(e.get("ok")), e.get("result") or "")
|
||||
self._done.add(cid)
|
||||
self._layers[cid] = lay
|
||||
self._order.append(cid)
|
||||
|
||||
self._refresh()
|
||||
|
||||
def _refresh(self):
|
||||
run_ids = [c for c in self._order if c in self._running]
|
||||
done_ids = [c for c in self._order if c in self._done]
|
||||
self.sec_running.set_layers([self._layers[c] for c in run_ids])
|
||||
shown = done_ids[-LAYER_LIMIT:]
|
||||
self.sec_done.set_layers([self._layers[c] for c in shown], total=len(done_ids))
|
||||
self.lbl_total.setText(f"共 {len(self._order)}" if self._order else "")
|
||||
|
||||
# ---------------- 事件 ----------------
|
||||
def on_started(self, call_id, name, args):
|
||||
if name != "bash" or not call_id or call_id in self._layers:
|
||||
return
|
||||
lay = BashLayer(call_id, _cmd_of(args))
|
||||
lay.set_running(0, 0)
|
||||
self._layers[call_id] = lay
|
||||
self._order.append(call_id)
|
||||
self._running.add(call_id)
|
||||
self._refresh()
|
||||
|
||||
def on_output(self, call_id, text):
|
||||
lay = self._layers.get(call_id)
|
||||
if lay is not None and lay.ok is None:
|
||||
lay.append_live(text)
|
||||
|
||||
def on_timed(self, call_id, elapsed, timeout):
|
||||
lay = self._layers.get(call_id)
|
||||
if lay is not None and lay.ok is None:
|
||||
lay.set_running(elapsed, timeout)
|
||||
|
||||
def on_finished(self, call_id, name, ok, text):
|
||||
lay = self._layers.get(call_id)
|
||||
if lay is None:
|
||||
if name != "bash" or not call_id:
|
||||
return
|
||||
lay = BashLayer(call_id, "")
|
||||
self._layers[call_id] = lay
|
||||
self._order.append(call_id)
|
||||
self._running.discard(call_id)
|
||||
self._done.add(call_id)
|
||||
lay.set_finished(bool(ok), text or "")
|
||||
lay.set_in_context(True)
|
||||
self._refresh()
|
||||
|
||||
def clear_all(self):
|
||||
self._layers.clear()
|
||||
self._order.clear()
|
||||
self._running.clear()
|
||||
self._done.clear()
|
||||
self._refresh()
|
||||
|
||||
# ---------------- 测试/调试辅助 ----------------
|
||||
def layer_ids(self, which="all"):
|
||||
if which == "running":
|
||||
return [c for c in self._order if c in self._running]
|
||||
if which == "done":
|
||||
return [c for c in self._order if c in self._done]
|
||||
return list(self._order)
|
||||
@@ -0,0 +1,188 @@
|
||||
# chat_bridge.py
|
||||
import json
|
||||
from PyQt6.QtCore import QObject, pyqtSlot, pyqtSignal
|
||||
|
||||
class ChatBridge(QObject):
|
||||
# 信号定义
|
||||
regenerate_clicked = pyqtSignal(str) # 携带需要重新回答的助手消息ID
|
||||
branch_switch_clicked = pyqtSignal(str, int) # 分支切换信号 (msg_id, direction: -1 为上一条, 1 为下一条)
|
||||
delete_message_requested = pyqtSignal(str) # 删除消息信号 (msg_id)
|
||||
attachment_clicked = pyqtSignal(str)
|
||||
scroll_changed = pyqtSignal(float, float, float) # 页面 scrollY / scrollHeight / innerHeight
|
||||
def __init__(self, page, channel, js_runner=None):
|
||||
"""
|
||||
:param page: QWebEnginePage 实例(QtWebEngine 路径)
|
||||
:param channel: QWebChannel 实例(QtWebEngine 路径)
|
||||
:param js_runner: 可选 JS 执行函数(WebView2 路径:script -> None)
|
||||
"""
|
||||
super().__init__()
|
||||
self.page = page
|
||||
self._js_runner = js_runner
|
||||
if channel is not None:
|
||||
# 将自身注册到 channel 中,前端可通过 bridge 对象调用
|
||||
channel.registerObject("bridge", self)
|
||||
print("ChatBridge被初始化")
|
||||
|
||||
def run_js(self, script: str):
|
||||
if self._js_runner is not None:
|
||||
self._js_runner(script)
|
||||
elif self.page is not None:
|
||||
self.page.runJavaScript(script)
|
||||
else:
|
||||
print("[ChatBridge] run_js: 无可用执行通道")
|
||||
|
||||
# ---------- 消息构造 (Python -> JS) ----------
|
||||
|
||||
def create_message(self, msg_id: str, role: str, text: str = "", sender_name: str = "", branch_info: dict = None):
|
||||
"""
|
||||
在前端创建一条消息容器
|
||||
:param branch_info: 格式如 {"current": 0, "total": 2},若为 None 则不显示分支切换器
|
||||
"""
|
||||
safe_text = json.dumps(text)
|
||||
safe_name = json.dumps(sender_name)
|
||||
safe_branch = json.dumps(branch_info) if branch_info else "null"
|
||||
self.run_js(f"createMessage('{msg_id}', '{role}', {safe_text}, {safe_name}, {safe_branch});")
|
||||
|
||||
def create_long_message(self, msg_id: str, role: str, text: str, sender_name: str = ""):
|
||||
safe_text = json.dumps(text)
|
||||
safe_name = json.dumps(sender_name)
|
||||
size_kb = round(len(text.encode('utf-8')) / 1024, 2)
|
||||
self.run_js(f"createLongMessage('{msg_id}', '{role}', {safe_text}, {safe_name}, {size_kb});")
|
||||
|
||||
def create_user_message_with_attachments(self, msg_id: str, text: str, attachments: list):
|
||||
safe_text = json.dumps(text)
|
||||
safe_attachments = json.dumps(attachments)
|
||||
self.run_js(f"createUserMessageWithAttachments('{msg_id}', {safe_text}, {safe_attachments});")
|
||||
|
||||
# ---------- 流式输出 (Python -> JS) ----------
|
||||
def append_token(self, msg_id: str, token: str):
|
||||
safe_token = json.dumps(token)
|
||||
self.run_js(f"appendToken('{msg_id}', {safe_token});")
|
||||
|
||||
def append_reasoning(self, msg_id: str, token: str):
|
||||
safe_token = json.dumps(token)
|
||||
self.run_js(f"appendReasoning('{msg_id}', {safe_token});")
|
||||
|
||||
def finish_message(self, msg_id: str):
|
||||
self.run_js(f"finishMessage('{msg_id}');")
|
||||
|
||||
# ---------- 工具执行事件 (Python -> JS) —— pi tool_execution_* ----------
|
||||
def tool_execution_started(self, msg_id: str, call_id: str, name: str, args: str):
|
||||
"""工具开始执行:前端在时间线当前位置插入 chip(带 call_id 对号入座)"""
|
||||
safe_call = json.dumps(call_id)
|
||||
safe_name = json.dumps(name)
|
||||
safe_args = json.dumps(args)
|
||||
self.run_js(f"toolExecutionStarted('{msg_id}', {safe_call}, {safe_name}, {safe_args});")
|
||||
|
||||
def tool_execution_updated(self, msg_id: str, call_id: str, text: str):
|
||||
"""工具执行中的增量输出(bash stdout 等)"""
|
||||
safe_call = json.dumps(call_id)
|
||||
safe_text = json.dumps(text)
|
||||
self.run_js(f"toolExecutionUpdated('{msg_id}', {safe_call}, {safe_text});")
|
||||
|
||||
def tool_execution_timed(self, msg_id: str, call_id: str,
|
||||
elapsed: int, timeout: int):
|
||||
"""🆕 bash 运行中每秒读秒:气泡摘要行刷新 N/Ts"""
|
||||
safe_call = json.dumps(call_id)
|
||||
self.run_js(f"toolExecutionTimed('{msg_id}', {safe_call}, "
|
||||
f"{int(elapsed)}, {int(timeout)});")
|
||||
|
||||
def tool_execution_finished(self, msg_id: str, call_id: str, name: str, ok: bool, text: str):
|
||||
"""工具执行结束:chip 按 call_id 标记成功/失败并显示结果摘要"""
|
||||
safe_call = json.dumps(call_id)
|
||||
safe_name = json.dumps(name)
|
||||
safe_text = json.dumps(text)
|
||||
self.run_js(f"toolExecutionFinished('{msg_id}', {safe_call}, {safe_name}, {str(ok).lower()}, {safe_text});")
|
||||
|
||||
# ---------- 时间线 (Python -> JS) ----------
|
||||
def restore_streaming_timeline(self, msg_id: str, timeline_json: str):
|
||||
"""切回进行中的会话:按时间线 JSON 恢复 思考/文本/工具 块(续流)"""
|
||||
safe_json = json.dumps(timeline_json)
|
||||
self.run_js(f"restoreStreamingTimeline('{msg_id}', {safe_json});")
|
||||
|
||||
def render_timeline_history(self, msg_id: str, timeline_json: str):
|
||||
"""历史消息:按时间线 JSON 静态渲染 思考/文本/工具 块"""
|
||||
safe_json = json.dumps(timeline_json)
|
||||
self.run_js(f"renderTimelineHistory('{msg_id}', {safe_json});")
|
||||
|
||||
# ---------- 压缩可视化 (Python -> JS) ----------
|
||||
def compaction_started(self, msg_id: str, path: str):
|
||||
"""压缩开始 → 当前消息时间线内显示「上下文压缩」思考气泡(与深度思考同款)"""
|
||||
safe_id = json.dumps(msg_id or "")
|
||||
safe_path = json.dumps(path or "")
|
||||
self.run_js(f"compactionStarted({safe_id}, {safe_path});")
|
||||
|
||||
def compaction_finished(self, msg_id: str, payload: dict):
|
||||
"""压缩完成 → 同一气泡原地更新(前→后 token + 摘要全文)"""
|
||||
safe_id = json.dumps(msg_id or "")
|
||||
safe_payload = json.dumps(payload or {}, ensure_ascii=False)
|
||||
self.run_js(f"compactionFinished({safe_id}, {safe_payload});")
|
||||
|
||||
# ---------- 系统提示 (Python -> JS) ----------
|
||||
def show_note(self, text: str):
|
||||
"""在聊天流中插入一条居中系统提示(如:已自动压缩上下文)"""
|
||||
safe_text = json.dumps(text)
|
||||
self.run_js(f"showSystemNote({safe_text});")
|
||||
|
||||
# ---------- 错误与清理 (Python -> JS) ----------
|
||||
def show_error(self, msg_id: str, error_text: str):
|
||||
safe_text = json.dumps(error_text)
|
||||
self.run_js(f"showError('{msg_id}', {safe_text});")
|
||||
|
||||
def clear_chat(self):
|
||||
self.run_js("clearChat();")
|
||||
|
||||
def delete_message(self, msg_id: str):
|
||||
"""通知前端从 DOM 中移除特定消息"""
|
||||
self.run_js(f"deleteMessage('{msg_id}');")
|
||||
|
||||
# ---------- 历史记录渲染 ----------
|
||||
|
||||
def render_history_message(self, msg_id: str, role: str, content: str, reasoning: str = "", branch_info: dict = None):
|
||||
self.create_message(msg_id, role, content, branch_info=branch_info)
|
||||
if reasoning:
|
||||
safe_reasoning = json.dumps(reasoning)
|
||||
self.run_js(f"insertThinkBlock('{msg_id}', {safe_reasoning});")
|
||||
self.finish_message(msg_id)
|
||||
|
||||
def show_welcome(self):
|
||||
self.run_js("showWelcome();")
|
||||
|
||||
# ---------- 会话加载界面 (Python -> JS) ----------
|
||||
def show_loading(self):
|
||||
"""显示统一加载界面(图标 + 从左到右扫描条)"""
|
||||
self.run_js("showLoadingOverlay();")
|
||||
|
||||
def hide_loading(self):
|
||||
"""隐藏加载界面(带渐变退场)"""
|
||||
self.run_js("hideLoadingOverlay();")
|
||||
|
||||
# ---------- 交互 Slot (JS -> Python) ----------
|
||||
|
||||
@pyqtSlot(str)
|
||||
def onRegenerateClicked(self, msg_id):
|
||||
"""前端点击“重新生成”按钮时触发"""
|
||||
print(f"Regenerate requested for: {msg_id}")
|
||||
self.regenerate_clicked.emit(msg_id)
|
||||
|
||||
@pyqtSlot(str, int)
|
||||
def onBranchSwitch(self, msg_id, direction):
|
||||
"""前端点击分支切换箭头时触发"""
|
||||
print(f"Branch switch requested for: {msg_id}, direction: {direction}")
|
||||
self.branch_switch_clicked.emit(msg_id, direction)
|
||||
|
||||
@pyqtSlot(str)
|
||||
def onDeleteMessageClicked(self, msg_id):
|
||||
"""接收 JS 发来的确认删除指令(🌟 已去重,原先定义了两次)"""
|
||||
print(f"Delete requested for: {msg_id}")
|
||||
self.delete_message_requested.emit(msg_id)
|
||||
|
||||
@pyqtSlot(str)
|
||||
def onAttachmentClicked(self, meta_json_str):
|
||||
"""接收 JS 发来的附件点击事件"""
|
||||
self.attachment_clicked.emit(meta_json_str)
|
||||
|
||||
@pyqtSlot(float, float, float)
|
||||
def onScrollChanged(self, y, content_h, client_h):
|
||||
"""接收前端上报的页面滚动信息,用于自定义滚动条位置/滑块长度镜像"""
|
||||
self.scroll_changed.emit(y, content_h, client_h)
|
||||
@@ -0,0 +1,66 @@
|
||||
from PyQt6.QtWebEngineCore import QWebEnginePage, QWebEngineScript
|
||||
from PyQt6.QtCore import QUrl
|
||||
from PyQt6.QtGui import QDesktopServices
|
||||
|
||||
class CustomWebPage(QWebEnginePage):
|
||||
"""
|
||||
自定义网页类,集中约束内置 QtWebEngine 的不合规浏览器行为:
|
||||
|
||||
1. 拦截所有外部链接跳转,改用系统默认浏览器打开;
|
||||
2. 禁用 Ctrl/Meta + 滚轮 及 Ctrl + +/-/0 快捷键的页面缩放
|
||||
(浏览器式缩放对桌面聊天工具无意义且易误触)。
|
||||
"""
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._inject_zoom_lock()
|
||||
|
||||
def _inject_zoom_lock(self):
|
||||
"""
|
||||
🌟 缩放锁定:通过网页脚本在文档创建阶段(DocumentCreation)注入监听,
|
||||
使用隔离世界(ApplicationWorld)注入,不污染页面自身逻辑。
|
||||
|
||||
原理:Chromium 的 Ctrl+滚轮缩放会先经过页面 wheel 事件的
|
||||
preventDefault() 判定 —— 页面阻止即取消缩放,这是地图类应用
|
||||
禁用缩放的官方做法;Ctrl+=/-/0 快捷键同理用 keydown 拦截。
|
||||
"""
|
||||
script = QWebEngineScript()
|
||||
script.setName("haocode_zoom_lock")
|
||||
script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation)
|
||||
script.setWorldId(QWebEngineScript.ScriptWorldId.ApplicationWorld)
|
||||
script.setRunsOnSubFrames(True)
|
||||
script.setSourceCode(r"""
|
||||
(function () {
|
||||
if (window.__haocodeZoomLocked) return; // 防重复注入
|
||||
window.__haocodeZoomLocked = true;
|
||||
|
||||
// 1) Ctrl/Meta + 滚轮 → 阻止缩放
|
||||
document.addEventListener('wheel', function (e) {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, { passive: false }); // 必须非被动,preventDefault 才有效
|
||||
|
||||
// 2) Ctrl + = / + / - / 0 → 阻止缩放快捷键
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if ((e.ctrlKey || e.metaKey) && ['+', '-', '=', '0'].indexOf(e.key) >= 0) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, false);
|
||||
})();
|
||||
""")
|
||||
self.scripts().insert(script)
|
||||
|
||||
def acceptNavigationRequest(self, url: QUrl, nav_type, is_main_frame):
|
||||
# 如果是本地文件(我们自己的 index.html),允许加载
|
||||
if url.scheme() == "file":
|
||||
return True
|
||||
|
||||
# 如果是外部链接(http/https),用系统浏览器打开
|
||||
if url.scheme() in ["http", "https"]:
|
||||
print(f"[System]: 在系统浏览器中打开 -> {url.toString()}")
|
||||
QDesktopServices.openUrl(url)
|
||||
return False # 阻止在应用内跳转
|
||||
|
||||
# 其他情况(如 javascript:void(0)),允许
|
||||
return True
|
||||
@@ -0,0 +1,214 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""独立调试器窗口 —— 与主窗口/项目树完全解耦(顶层窗口,parent=None)
|
||||
|
||||
功能:
|
||||
Tab1「调试会话」 实时 tail data/debug_session.log,按来源着色:
|
||||
[USER]蓝 / [AGENT]绿 / [APP]灰 / [SYS]紫
|
||||
Tab2「应用日志」 实时 tail diag.log + compaction_diag.log + stream_diag.log
|
||||
底部输入框 用户输入观察到的情况,回车 → 记 [USER]
|
||||
按钮 暂停显示 / 清空会话日志 / 打开日志文件
|
||||
|
||||
控制: 代理写 data/debug_window.cmd (show/hide),主窗口 2s 轮询后调用本窗口。
|
||||
本文件不 import main_window,可独立离屏测试。
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
from PyQt6.QtGui import QTextCharFormat, QTextCursor, QColor
|
||||
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QPlainTextEdit,
|
||||
QLineEdit, QPushButton, QCheckBox, QLabel,
|
||||
QTabWidget, QMessageBox)
|
||||
from core.debug_log import DEBUG_LOG_PATH, DEBUG_CMD_PATH, debug_log
|
||||
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
_APP_LOGS = [
|
||||
("DIAG", os.path.join(_ROOT, "diag.log")),
|
||||
("COMPACT", os.path.join(_ROOT, "compaction_diag.log")),
|
||||
("STREAM", os.path.join(_ROOT, "stream_diag.log")),
|
||||
]
|
||||
|
||||
_TAG_COLORS = {"USER": "#2563eb", "AGENT": "#16a34a",
|
||||
"APP": "#6b7280", "SYS": "#9333ea"}
|
||||
_LINE_RE = re.compile(r"^\[([^\]]+)\]\s+\[([A-Z]+)\]\s?(.*)$")
|
||||
|
||||
|
||||
class _TailReader:
|
||||
"""单文件增量读取器(文件被截断时自动重置偏移)"""
|
||||
|
||||
def __init__(self, path: str):
|
||||
self.path = path
|
||||
self.offset = 0
|
||||
|
||||
def read_new(self) -> str:
|
||||
try:
|
||||
if not os.path.exists(self.path):
|
||||
return ""
|
||||
size = os.path.getsize(self.path)
|
||||
if size < self.offset: # 被清空/轮转
|
||||
self.offset = 0
|
||||
if size == self.offset:
|
||||
return ""
|
||||
with open(self.path, "r", encoding="utf-8", errors="replace") as f:
|
||||
f.seek(self.offset)
|
||||
data = f.read()
|
||||
self.offset = size
|
||||
return data
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
class DebugWindow(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__(None) # 顶层独立窗口
|
||||
self.setWindowTitle("Haocode 调试器")
|
||||
self.resize(780, 540)
|
||||
self.setWindowFlags(Qt.WindowType.Window)
|
||||
# 默认停靠主屏右上角,避免被主窗口挡住
|
||||
try:
|
||||
from PyQt6.QtGui import QGuiApplication
|
||||
_geo = QGuiApplication.primaryScreen().availableGeometry()
|
||||
self.move(_geo.right() - self.width() - 24, _geo.top() + 24)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._paused = False
|
||||
self._announced = False
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(8, 8, 8, 8)
|
||||
|
||||
# ---- Tab 容器 ----
|
||||
self._tabs = QTabWidget()
|
||||
self._view_session = QPlainTextEdit()
|
||||
self._view_session.setReadOnly(True)
|
||||
self._view_session.setMaximumBlockCount(3000)
|
||||
self._view_session.setLineWrapMode(
|
||||
QPlainTextEdit.LineWrapMode.NoWrap)
|
||||
self._view_app = QPlainTextEdit()
|
||||
self._view_app.setReadOnly(True)
|
||||
self._view_app.setMaximumBlockCount(3000)
|
||||
self._view_app.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
|
||||
self._tabs.addTab(self._view_session, "调试会话")
|
||||
self._tabs.addTab(self._view_app, "应用日志")
|
||||
root.addWidget(self._tabs, 1)
|
||||
|
||||
# ---- 状态行 ----
|
||||
status = QLabel(f"会话日志: {DEBUG_LOG_PATH}\n"
|
||||
f"控制文件: {DEBUG_CMD_PATH} (show/hide)")
|
||||
status.setStyleSheet("color:#888; font-size:11px;")
|
||||
status.setWordWrap(True)
|
||||
root.addWidget(status)
|
||||
|
||||
# ---- 按钮行 ----
|
||||
btn_row = QHBoxLayout()
|
||||
self._chk_pause = QCheckBox("暂停显示(记录继续)")
|
||||
self._chk_pause.toggled.connect(self._on_pause_toggled)
|
||||
self._chk_top = QCheckBox("置顶")
|
||||
self._chk_top.toggled.connect(self._on_top_toggled)
|
||||
btn = QPushButton("清空会话日志")
|
||||
btn.clicked.connect(self._on_clear)
|
||||
btn2 = QPushButton("打开日志文件")
|
||||
btn2.clicked.connect(self._on_open_file)
|
||||
btn_row.addWidget(self._chk_pause)
|
||||
btn_row.addWidget(self._chk_top)
|
||||
btn_row.addStretch(1)
|
||||
btn_row.addWidget(btn)
|
||||
btn_row.addWidget(btn2)
|
||||
root.addLayout(btn_row)
|
||||
|
||||
# ---- 用户输入行 ----
|
||||
in_row = QHBoxLayout()
|
||||
hint = QLabel("观察到的情况(回车记录为 [USER]):")
|
||||
hint.setStyleSheet("color:#555; font-size:12px;")
|
||||
self._input = QLineEdit()
|
||||
self._input.setPlaceholderText("例如: 上下文标签显示 40.5k,刚发送了「接着输出」")
|
||||
self._input.returnPressed.connect(self._on_submit)
|
||||
in_row.addWidget(hint)
|
||||
in_row.addWidget(self._input, 1)
|
||||
root.addLayout(in_row)
|
||||
|
||||
# ---- 文件增量读取器 + 轮询 ----
|
||||
self._reader_session = _TailReader(DEBUG_LOG_PATH)
|
||||
self._readers_app = {tag: _TailReader(p) for tag, p in _APP_LOGS}
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(500)
|
||||
self._timer.timeout.connect(self._tick)
|
||||
self._timer.start()
|
||||
|
||||
# ==================== 轮询 ====================
|
||||
def _tick(self):
|
||||
try:
|
||||
if not self._announced:
|
||||
self._announced = True
|
||||
debug_log("调试窗口开启", "SYS")
|
||||
if not self._paused:
|
||||
data = self._reader_session.read_new()
|
||||
if data:
|
||||
self._append_tagged(self._view_session, data)
|
||||
for tag, _p in _APP_LOGS:
|
||||
d = self._readers_app[tag].read_new()
|
||||
if d:
|
||||
self._view_app.appendPlainText(
|
||||
f"── [{tag}] {os.path.basename(_p)} ──")
|
||||
self._view_app.appendPlainText(d.rstrip("\n"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _append_tagged(self, view: QPlainTextEdit, data: str):
|
||||
cur = view.textCursor()
|
||||
cur.movePosition(QTextCursor.MoveOperation.End)
|
||||
for line in data.splitlines():
|
||||
if not line:
|
||||
continue
|
||||
m = _LINE_RE.match(line)
|
||||
color = _TAG_COLORS.get(m.group(2)) if m else None
|
||||
fmt = QTextCharFormat()
|
||||
if color:
|
||||
fmt.setForeground(QColor(color))
|
||||
cur.insertText(line + "\n", fmt)
|
||||
view.setTextCursor(cur)
|
||||
view.ensureCursorVisible()
|
||||
|
||||
# ==================== 交互 ====================
|
||||
def _on_submit(self):
|
||||
text = self._input.text().strip()
|
||||
if not text:
|
||||
return
|
||||
debug_log(text, "USER")
|
||||
self._input.clear()
|
||||
|
||||
def _on_pause_toggled(self, checked: bool):
|
||||
self._paused = checked
|
||||
|
||||
def _on_top_toggled(self, checked: bool):
|
||||
f = self.windowFlags()
|
||||
if checked:
|
||||
f |= Qt.WindowType.WindowStaysOnTopHint
|
||||
else:
|
||||
f &= ~Qt.WindowType.WindowStaysOnTopHint
|
||||
self.setWindowFlags(f)
|
||||
self.show() # setWindowFlags 会隐藏窗口,需重新 show
|
||||
|
||||
def _on_clear(self):
|
||||
if QMessageBox.question(
|
||||
self, "清空会话日志",
|
||||
"将清空 debug_session.log(USER/AGENT/APP 记录全部丢失),确定?") \
|
||||
!= QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
try:
|
||||
open(DEBUG_LOG_PATH, "w", encoding="utf-8").close()
|
||||
self._reader_session.offset = 0
|
||||
self._view_session.clear()
|
||||
debug_log("会话日志已清空", "SYS")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_open_file(self):
|
||||
try:
|
||||
import subprocess
|
||||
if os.name == "nt":
|
||||
os.startfile(DEBUG_LOG_PATH) # noqa
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", DEBUG_LOG_PATH])
|
||||
except Exception:
|
||||
pass
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
系统级工具集合 (system_tools)
|
||||
- screen_capture: 屏幕截图覆盖层
|
||||
- global_hotkey: 全局热键监听线程
|
||||
- file_reader: 文本/代码文件读取(编码探测 + 二进制探测)
|
||||
"""
|
||||
@@ -0,0 +1,71 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
文本/代码文件读取工具 (file_reader)
|
||||
编码探测 + 二进制探测 + 大小守卫,供附件系统(拖拽/粘贴/上传文本与代码文件)复用。
|
||||
用法:read_text_file(path) -> (content, encoding, size_kb, lines),失败抛 ValueError。
|
||||
说明:纯函数模块,不依赖 Qt,便于单元测试。
|
||||
"""
|
||||
import os
|
||||
|
||||
# 明确拒绝的二进制/文档格式(Word/Excel/PPT/PDF/压缩包/可执行/媒体等)
|
||||
BINARY_EXTS = frozenset({
|
||||
# Office 文档(明确排除 Word/Excel)
|
||||
'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
||||
'.odt', '.ods', '.odp', '.rtf',
|
||||
# 电子书(.pdf 已交由 tools/builtin_tools/pdf_reader.py 专门解析,不在此拦截)
|
||||
'.epub', '.mobi',
|
||||
# 压缩包 / 磁盘镜像
|
||||
'.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz', '.iso', '.dmg',
|
||||
# 可执行 / 编译产物
|
||||
'.exe', '.dll', '.so', '.dylib', '.msi', '.bin', '.apk', '.jar',
|
||||
'.class', '.pyc', '.pyd', '.o', '.a',
|
||||
# 音视频
|
||||
'.mp3', '.wav', '.flac', '.m4a', '.aac', '.ogg',
|
||||
'.mp4', '.avi', '.mov', '.mkv', '.webm', '.wmv',
|
||||
# 字体
|
||||
'.ttf', '.otf', '.woff', '.woff2', '.eot',
|
||||
# 数据库
|
||||
'.db', '.sqlite', '.sqlite3', '.mdb',
|
||||
# 设计稿
|
||||
'.psd', '.ai', '.sketch', '.fig',
|
||||
})
|
||||
|
||||
# 单文件大小上限(字节):超过则拒绝,避免内存与上下文爆炸
|
||||
MAX_ATTACH_FILE_BYTES = 2 * 1024 * 1024 # 2 MB
|
||||
|
||||
# 编码降级链:UTF-8(含BOM) → GB18030(⊇GBK/GB2312) → latin-1(永不失败)
|
||||
_ENCODINGS = ('utf-8-sig', 'gb18030', 'latin-1')
|
||||
|
||||
|
||||
def read_text_file(path, max_bytes=MAX_ATTACH_FILE_BYTES):
|
||||
"""读取一个文本/代码文件。
|
||||
|
||||
返回 (content, encoding, size_kb, lines)。
|
||||
文件过大或判定为二进制时抛出 ValueError(附带可读原因)。
|
||||
"""
|
||||
size = os.path.getsize(path)
|
||||
if size > max_bytes:
|
||||
raise ValueError(
|
||||
f"文件过大({size / 1024 / 1024:.1f} MB > {max_bytes / 1024 / 1024:.0f} MB)"
|
||||
)
|
||||
|
||||
with open(path, 'rb') as f:
|
||||
raw = f.read()
|
||||
|
||||
# 二进制探测:前 8KB 含 NUL 字节即判定为二进制(git 同款启发式)
|
||||
if b'\x00' in raw[:8192]:
|
||||
raise ValueError("二进制文件,无法作为文本读取")
|
||||
|
||||
text, used_enc = None, 'utf-8'
|
||||
for enc in _ENCODINGS:
|
||||
try:
|
||||
text = raw.decode(enc)
|
||||
used_enc = enc
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
if text is None: # 理论上 latin-1 兜底永不失败
|
||||
raise ValueError("无法识别文件编码")
|
||||
|
||||
lines = text.count('\n') + 1
|
||||
return text, used_enc, round(size / 1024, 2), lines
|
||||
@@ -0,0 +1,105 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
全局热键监听器 (Windows)
|
||||
独立线程 + Win32 RegisterHotKey 消息循环,实现主窗口失焦也能触发的系统级全局快捷键。
|
||||
|
||||
用法:
|
||||
hotkey = GlobalHotkeyThread() # 默认 Alt+S
|
||||
hotkey.triggered.connect(on_triggered) # 跨线程信号,槽函数在主线程执行
|
||||
hotkey.start() # 启动线程
|
||||
hotkey.stop() # 注销热键并退出线程
|
||||
"""
|
||||
import ctypes
|
||||
import sys
|
||||
import threading
|
||||
from ctypes import wintypes
|
||||
|
||||
from PyQt6 import QtCore
|
||||
|
||||
# Win32 常量
|
||||
WM_HOTKEY = 0x0312
|
||||
WM_QUIT = 0x0012
|
||||
MOD_ALT = 0x0001
|
||||
MOD_CONTROL = 0x0002
|
||||
MOD_SHIFT = 0x0004
|
||||
MOD_NOREPEAT = 0x4000 # 按住不重复触发 (Windows 7+)
|
||||
VK_S = 0x53
|
||||
|
||||
_is_windows = sys.platform == "win32"
|
||||
|
||||
if _is_windows:
|
||||
_user32 = ctypes.windll.user32
|
||||
_kernel32 = ctypes.windll.kernel32
|
||||
|
||||
# 显式声明函数签名,避免 ctypes 默认 int 截断指针/句柄
|
||||
_user32.RegisterHotKey.argtypes = [wintypes.HWND, ctypes.c_int, wintypes.UINT, wintypes.UINT]
|
||||
_user32.RegisterHotKey.restype = wintypes.BOOL
|
||||
_user32.UnregisterHotKey.argtypes = [wintypes.HWND, ctypes.c_int]
|
||||
_user32.UnregisterHotKey.restype = wintypes.BOOL
|
||||
_user32.GetMessageW.argtypes = [
|
||||
ctypes.POINTER(wintypes.MSG), wintypes.HWND, wintypes.UINT, wintypes.UINT
|
||||
]
|
||||
_user32.GetMessageW.restype = wintypes.BOOL # >0 正常 / 0 WM_QUIT / -1 出错
|
||||
_user32.PostThreadMessageW.argtypes = [
|
||||
wintypes.DWORD, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM
|
||||
]
|
||||
_user32.PostThreadMessageW.restype = wintypes.BOOL
|
||||
_kernel32.GetCurrentThreadId.restype = wintypes.DWORD
|
||||
|
||||
|
||||
class GlobalHotkeyThread(QtCore.QThread):
|
||||
"""在独立线程注册系统级全局热键并运行 Win32 消息循环。
|
||||
|
||||
收到 WM_HOTKEY 后通过 Qt 信号 triggered 通知主线程,
|
||||
信号槽机制保证槽函数在主线程执行,操作 Qt 控件安全。
|
||||
"""
|
||||
|
||||
triggered = QtCore.pyqtSignal()
|
||||
|
||||
def __init__(self, hotkey_id=9001, mod=MOD_ALT, vk=VK_S, parent=None):
|
||||
super().__init__(parent)
|
||||
self._hotkey_id = hotkey_id
|
||||
self._mod = mod
|
||||
self._vk = vk
|
||||
self._thread_id = 0
|
||||
self._ready = threading.Event() # run() 记录线程 ID 后置位
|
||||
self._registered = False
|
||||
|
||||
def run(self):
|
||||
"""线程入口:注册热键 -> 消息循环 -> 退出时注销"""
|
||||
if not _is_windows:
|
||||
self._ready.set()
|
||||
return
|
||||
|
||||
self._thread_id = _kernel32.GetCurrentThreadId()
|
||||
ok = _user32.RegisterHotKey(
|
||||
None, self._hotkey_id, self._mod | MOD_NOREPEAT, self._vk
|
||||
)
|
||||
self._registered = bool(ok)
|
||||
self._ready.set()
|
||||
if not ok:
|
||||
print(
|
||||
f"[GlobalHotkey] 注册热键失败 (id={self._hotkey_id}, mod={self._mod:#x}, "
|
||||
f"vk={self._vk:#x}),可能已被其他程序占用"
|
||||
)
|
||||
return
|
||||
|
||||
# Win32 消息循环:hwnd=None 取本线程所有消息
|
||||
msg = wintypes.MSG()
|
||||
while _user32.GetMessageW(ctypes.byref(msg), None, 0, 0) > 0:
|
||||
if msg.message == WM_HOTKEY and msg.wParam == self._hotkey_id:
|
||||
self.triggered.emit()
|
||||
|
||||
# 收到 WM_QUIT 退出循环 -> 注销热键
|
||||
_user32.UnregisterHotKey(None, self._hotkey_id)
|
||||
self._registered = False
|
||||
|
||||
def stop(self):
|
||||
"""请求线程退出:向线程消息队列投递 WM_QUIT,然后等待结束"""
|
||||
if not _is_windows:
|
||||
return
|
||||
# 等 run() 至少记录好线程 ID(注册成功或失败都行)
|
||||
self._ready.wait(timeout=2.0)
|
||||
if self._thread_id:
|
||||
_user32.PostThreadMessageW(self._thread_id, WM_QUIT, 0, 0)
|
||||
self.wait(2000)
|
||||
@@ -0,0 +1,193 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
屏幕截图覆盖层 (ScreenCaptureOverlay)
|
||||
全屏半透明遮罩 + 鼠标拖拽选区 + 确认/取消工具条 + 截图完成发射 QImage 信号
|
||||
用法:调用 start() 启动截图,监听 screenshot_captured 信号获取结果
|
||||
"""
|
||||
from PyQt6 import QtCore, QtGui, QtWidgets
|
||||
import os
|
||||
|
||||
|
||||
class ScreenCaptureOverlay(QtWidgets.QWidget):
|
||||
"""全屏截图覆盖层:半透明遮罩 + 鼠标拖拽选区 + 确认/取消按钮"""
|
||||
|
||||
screenshot_captured = QtCore.pyqtSignal(QtGui.QImage)
|
||||
|
||||
# 按钮尺寸
|
||||
BTN_W = 36
|
||||
BTN_H = 30
|
||||
BTN_GAP = 4
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowFlags(
|
||||
QtCore.Qt.WindowType.FramelessWindowHint
|
||||
| QtCore.Qt.WindowType.WindowStaysOnTopHint
|
||||
| QtCore.Qt.WindowType.Tool
|
||||
)
|
||||
self.setCursor(QtCore.Qt.CursorShape.CrossCursor)
|
||||
|
||||
self._full_pixmap = None
|
||||
self._start_pos = QtCore.QPoint()
|
||||
self._end_pos = QtCore.QPoint()
|
||||
self._is_drawing = False
|
||||
self._has_selection = False
|
||||
self._current_rect = QtCore.QRect()
|
||||
|
||||
# SVG 图标路径(项目根目录下 svg/ 文件夹)
|
||||
root_dir = os.path.abspath(
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "..")
|
||||
)
|
||||
# 确认 / 取消按钮(使用 SVG 图标,灰白简约风背景)
|
||||
self._btn_confirm = QtWidgets.QPushButton(self)
|
||||
self._btn_cancel = QtWidgets.QPushButton(self)
|
||||
self._btn_confirm.setIcon(QtGui.QIcon(os.path.join(root_dir, "svg", "check.svg")))
|
||||
self._btn_cancel.setIcon(QtGui.QIcon(os.path.join(root_dir, "svg", "cross.svg")))
|
||||
btn_style = (
|
||||
"QPushButton { background-color: #f9f9f9; border: 1px solid #ddd; border-radius: 4px; }"
|
||||
"QPushButton:hover { background-color: #eee; border-color: #ccc; }"
|
||||
)
|
||||
for btn in (self._btn_confirm, self._btn_cancel):
|
||||
btn.setFixedSize(self.BTN_W, self.BTN_H)
|
||||
btn.setIconSize(QtCore.QSize(20, 20))
|
||||
btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||||
btn.setStyleSheet(btn_style)
|
||||
btn.hide()
|
||||
self._btn_confirm.clicked.connect(self._on_confirm)
|
||||
self._btn_cancel.clicked.connect(self._on_cancel)
|
||||
|
||||
def start(self):
|
||||
"""开始截图:抓取屏幕全图并显示覆盖层"""
|
||||
screen = QtWidgets.QApplication.primaryScreen()
|
||||
if not screen:
|
||||
return
|
||||
self._full_pixmap = screen.grabWindow(0)
|
||||
self.setGeometry(screen.geometry())
|
||||
self.show()
|
||||
self.activateWindow()
|
||||
self.raise_()
|
||||
|
||||
def paintEvent(self, event):
|
||||
if not self._full_pixmap:
|
||||
return
|
||||
painter = QtGui.QPainter(self)
|
||||
|
||||
# 1. 绘制屏幕截图作为背景
|
||||
painter.drawPixmap(0, 0, self._full_pixmap)
|
||||
# 2. 半透明遮罩
|
||||
painter.fillRect(self.rect(), QtGui.QColor(0, 0, 0, 100))
|
||||
|
||||
# 绘制中 或 已有选区 时,绘制选区高亮
|
||||
if self._is_drawing or self._has_selection:
|
||||
if self._is_drawing:
|
||||
rect = QtCore.QRect(self._start_pos, self._end_pos).normalized()
|
||||
else:
|
||||
rect = self._current_rect
|
||||
|
||||
if rect.width() > 0 and rect.height() > 0:
|
||||
# 3. 选区内重绘原图(去掉遮罩,形成高亮效果)
|
||||
painter.drawPixmap(rect, self._full_pixmap, rect)
|
||||
# 4. 蓝色边框
|
||||
pen = QtGui.QPen(QtGui.QColor(0, 120, 215), 2)
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(QtCore.Qt.BrushStyle.NoBrush)
|
||||
painter.drawRect(rect)
|
||||
# 5. 尺寸标注
|
||||
size_text = f"{rect.width()} x {rect.height()}"
|
||||
font = painter.font()
|
||||
font.setPointSize(9)
|
||||
painter.setFont(font)
|
||||
fm = painter.fontMetrics()
|
||||
text_w = fm.horizontalAdvance(size_text)
|
||||
text_h = fm.height()
|
||||
text_x = rect.x()
|
||||
text_y = rect.y() - text_h - 2
|
||||
if text_y < 0:
|
||||
text_y = rect.bottom() + 2
|
||||
painter.fillRect(text_x, text_y, text_w + 10, text_h, QtGui.QColor(0, 120, 215))
|
||||
painter.setPen(QtGui.QColor(255, 255, 255))
|
||||
painter.drawText(text_x + 5, text_y + fm.ascent(), size_text)
|
||||
|
||||
painter.end()
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == QtCore.Qt.MouseButton.LeftButton:
|
||||
# 开始新选区,隐藏按钮
|
||||
self._hide_buttons()
|
||||
self._has_selection = False
|
||||
self._start_pos = event.position().toPoint()
|
||||
self._end_pos = self._start_pos
|
||||
self._is_drawing = True
|
||||
self.update()
|
||||
elif event.button() == QtCore.Qt.MouseButton.RightButton:
|
||||
self.close()
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if self._is_drawing:
|
||||
self._end_pos = event.position().toPoint()
|
||||
self.update()
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if event.button() == QtCore.Qt.MouseButton.LeftButton and self._is_drawing:
|
||||
self._is_drawing = False
|
||||
rect = QtCore.QRect(self._start_pos, self._end_pos).normalized()
|
||||
if rect.width() > 5 and rect.height() > 5:
|
||||
# 保留选区,显示确认/取消按钮
|
||||
self._current_rect = rect
|
||||
self._has_selection = True
|
||||
self._position_buttons(rect)
|
||||
self._btn_confirm.show()
|
||||
self._btn_cancel.show()
|
||||
self.update()
|
||||
else:
|
||||
self.close()
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
if event.key() == QtCore.Qt.Key.Key_Escape:
|
||||
self.close()
|
||||
elif event.key() in (QtCore.Qt.Key.Key_Return, QtCore.Qt.Key.Key_Enter):
|
||||
if self._has_selection:
|
||||
self._on_confirm()
|
||||
|
||||
def _position_buttons(self, rect):
|
||||
"""将确认/取消按钮定位到选区右下角"""
|
||||
total_w = self.BTN_W * 2 + self.BTN_GAP
|
||||
# 默认放在选区右下角外侧
|
||||
x = rect.right() - total_w
|
||||
y = rect.bottom() + 4
|
||||
# 边界检测:超出屏幕底部时翻到选区内侧
|
||||
if y + self.BTN_H > self.height():
|
||||
y = rect.bottom() - self.BTN_H - 4
|
||||
if x < 0:
|
||||
x = 0
|
||||
self._btn_confirm.move(x, y)
|
||||
self._btn_cancel.move(x + self.BTN_W + self.BTN_GAP, y)
|
||||
|
||||
def _hide_buttons(self):
|
||||
self._btn_confirm.hide()
|
||||
self._btn_cancel.hide()
|
||||
|
||||
def _on_confirm(self):
|
||||
"""确认截图:裁剪并发射信号"""
|
||||
if self._full_pixmap and self._current_rect.width() > 5 and self._current_rect.height() > 5:
|
||||
dpr = self._full_pixmap.devicePixelRatio()
|
||||
phys_rect = QtCore.QRect(
|
||||
int(self._current_rect.x() * dpr),
|
||||
int(self._current_rect.y() * dpr),
|
||||
int(self._current_rect.width() * dpr),
|
||||
int(self._current_rect.height() * dpr),
|
||||
)
|
||||
captured = self._full_pixmap.toImage().copy(phys_rect)
|
||||
self.screenshot_captured.emit(captured)
|
||||
self.close()
|
||||
|
||||
def _on_cancel(self):
|
||||
"""取消截图:直接关闭"""
|
||||
self.close()
|
||||
|
||||
def closeEvent(self, event):
|
||||
self._is_drawing = False
|
||||
self._has_selection = False
|
||||
self._hide_buttons()
|
||||
self._full_pixmap = None
|
||||
super().closeEvent(event)
|
||||
@@ -0,0 +1,217 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
WebView2View —— 外观兼容 QWebEngineView 的 QWidget(仅实现 main_window 用到的 API 面):
|
||||
setUrl / loadFinished / page().runJavaScript / page().setBackgroundColor / grab
|
||||
内部由 core.webview2.Wv2Session 驱动:
|
||||
- 30ms timer + resizeEvent 双路 SetBoundsAndZoomFactor(slot 矩形 → 父窗客户区物理像素)
|
||||
- WebMessageReceived → ChatBridge 的 JS→Python slot
|
||||
- run_js → ExecuteScriptAsync(与 QWebChannel 路径生成的 JS 文本同构)
|
||||
"""
|
||||
import json
|
||||
import ctypes
|
||||
import ctypes.wintypes as wintypes
|
||||
|
||||
from PyQt6 import QtCore, QtGui, QtWidgets
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QUrl
|
||||
|
||||
# JS → Python 消息名 → ChatBridge slot 名(与 QWebChannel 注册的对象方法一一对应)
|
||||
_BRIDGE_METHODS = (
|
||||
"onRegenerateClicked",
|
||||
"onBranchSwitch",
|
||||
"onDeleteMessageClicked",
|
||||
"onAttachmentClicked",
|
||||
"onScrollChanged",
|
||||
)
|
||||
|
||||
|
||||
class _PageShim:
|
||||
"""QWebEnginePage 的最小替身"""
|
||||
|
||||
def __init__(self, view):
|
||||
self._view = view
|
||||
|
||||
def runJavaScript(self, script, callback=None):
|
||||
if callback is None:
|
||||
self._view.run_js(script)
|
||||
else:
|
||||
self._view.execute_js_async(script, callback)
|
||||
|
||||
def setBackgroundColor(self, color):
|
||||
pass # WebView2 底色由页面 CSS 决定(页面本身就是白底)
|
||||
|
||||
|
||||
class WebView2View(QtWidgets.QWidget):
|
||||
loadFinished = pyqtSignal(bool)
|
||||
|
||||
def __init__(self, session, parent=None):
|
||||
super().__init__(parent)
|
||||
self._session = session
|
||||
self._page = _PageShim(self)
|
||||
self._bridge = None
|
||||
self._session.on_load_finished = self._on_page_loaded
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent, True)
|
||||
# 子窗口发现(异步创建)
|
||||
self._child_timer = QTimer(self)
|
||||
self._child_timer.setInterval(100)
|
||||
self._child_timer.timeout.connect(self._try_find_child)
|
||||
self._child_timer.start()
|
||||
# bounds 同步兜底(resizeEvent 主路 + 30ms 兜底,与 P1 实测一致)
|
||||
self._sync_timer = QTimer(self)
|
||||
self._sync_timer.setInterval(30)
|
||||
self._sync_timer.timeout.connect(self.sync_bounds)
|
||||
self._sync_timer.start()
|
||||
# 几何去重:值没变就不发跨进程 COM(移动窗口时避免主线程被 WebView2 阻塞)
|
||||
self._last_sent_bounds = None
|
||||
|
||||
# ---------- 页面加载完成 ----------
|
||||
def _on_page_loaded(self):
|
||||
# 立即启动 JS 管线(子窗口全程可见,无节流/竞态)。
|
||||
# 首帧前的"透底色"问题由 Qt 侧白色 paintEvent 解决(透明子窗表面露出白底)。
|
||||
self.loadFinished.emit(True)
|
||||
|
||||
def paintEvent(self, ev):
|
||||
# 子窗口隐藏期间(启动首帧前)槽位必须自绘白底,
|
||||
# 否则 WA_OpaquePaintEvent + 无 paintEvent = 未绘制表面 = 纯黑
|
||||
p = QtGui.QPainter(self)
|
||||
p.fillRect(self.rect(), QtGui.QColor("#ffffff"))
|
||||
p.end()
|
||||
|
||||
# ---------- QWebEngineView 兼容 API ----------
|
||||
def page(self):
|
||||
return self._page
|
||||
|
||||
def setUrl(self, url):
|
||||
# 接受 QUrl 或 str
|
||||
s = url.toString() if isinstance(url, QUrl) else str(url)
|
||||
self._session.navigate(s)
|
||||
|
||||
def grab(self):
|
||||
"""子窗口区域截图(调试工具用)"""
|
||||
h = self._session.child_hwnd
|
||||
if not h:
|
||||
return QtGui.QPixmap(self.size())
|
||||
r = wintypes.RECT()
|
||||
if not ctypes.windll.user32.GetWindowRect(wintypes.HWND(h), ctypes.byref(r)):
|
||||
return QtGui.QPixmap(self.size())
|
||||
w = max(1, r.right - r.left)
|
||||
hgt = max(1, r.bottom - r.top)
|
||||
screen_dc = ctypes.windll.user32.GetDC(0)
|
||||
mem_dc = ctypes.windll.gdi32.CreateCompatibleDC(screen_dc)
|
||||
bmp = ctypes.windll.gdi32.CreateCompatibleBitmap(screen_dc, w, hgt)
|
||||
ctypes.windll.gdi32.SelectObject(mem_dc, bmp)
|
||||
ctypes.windll.gdi32.BitBlt(mem_dc, 0, 0, w, hgt, screen_dc, r.left, r.top, 0x00CC0020)
|
||||
# BITMAPINFO
|
||||
class _BMI(ctypes.Structure):
|
||||
_fields_ = [("biSize", wintypes.DWORD), ("biWidth", ctypes.c_long),
|
||||
("biHeight", ctypes.c_long), ("biPlanes", wintypes.WORD),
|
||||
("biBitCount", wintypes.WORD), ("biCompression", wintypes.DWORD),
|
||||
("biSizeImage", wintypes.DWORD), ("biXPelsPerMeter", ctypes.c_long),
|
||||
("biYPelsPerMeter", ctypes.c_long), ("biClrUsed", wintypes.DWORD),
|
||||
("biClrImportant", wintypes.DWORD)]
|
||||
bmi = _BMI()
|
||||
bmi.biSize = ctypes.sizeof(_BMI)
|
||||
bmi.biWidth = w
|
||||
bmi.biHeight = -hgt # 顶向下
|
||||
bmi.biPlanes = 1
|
||||
bmi.biBitCount = 32
|
||||
bmi.biCompression = 0
|
||||
buf = ctypes.create_string_buffer(w * hgt * 4)
|
||||
ctypes.windll.gdi32.GetDIBits(mem_dc, bmp, 0, hgt, buf, ctypes.byref(bmi), 0)
|
||||
ctypes.windll.gdi32.DeleteObject(bmp)
|
||||
ctypes.windll.gdi32.DeleteDC(mem_dc)
|
||||
ctypes.windll.user32.ReleaseDC(0, screen_dc)
|
||||
img = QtGui.QImage(buf, w, hgt, w * 4, QtGui.QImage.Format.Format_RGBA8888).copy()
|
||||
return QtGui.QPixmap.fromImage(img)
|
||||
|
||||
# ---------- 桥接 ----------
|
||||
def attach_bridge(self, bridge):
|
||||
self._bridge = bridge
|
||||
self._session.on_message = self._dispatch_message
|
||||
|
||||
def _dispatch_message(self, data):
|
||||
if not isinstance(data, dict) or not self._bridge:
|
||||
return
|
||||
name = data.get("m")
|
||||
args = data.get("a", [])
|
||||
if name in _BRIDGE_METHODS:
|
||||
fn = getattr(self._bridge, name, None)
|
||||
if fn:
|
||||
try:
|
||||
fn(*args)
|
||||
except Exception as ex:
|
||||
print(f"[WV2] bridge {name} error:", ex)
|
||||
|
||||
# ---------- JS → Python 用的执行通道 ----------
|
||||
def run_js(self, script):
|
||||
self._session.execute_js(script)
|
||||
|
||||
def execute_js_async(self, script, cb):
|
||||
self._session.execute_js_async(script, cb)
|
||||
|
||||
# ---------- 几何同步 ----------
|
||||
def _dpr(self):
|
||||
try:
|
||||
d = self.dpr()
|
||||
return d if d > 0 else 1.0
|
||||
except Exception:
|
||||
return 1.0
|
||||
|
||||
def sync_bounds(self):
|
||||
if not self.isVisible() or not self._session.child_hwnd:
|
||||
return
|
||||
top = self.window()
|
||||
p = self.mapTo(top, QtCore.QPoint(0, 0))
|
||||
d = self._dpr()
|
||||
L, T = p.x() * d, p.y() * d
|
||||
W, H = max(1, self.width() * d), max(1, self.height() * d)
|
||||
# 去重:值没变就不发跨进程 COM(移动窗口时避免主线程被 WebView2 阻塞 → 整窗黑屏)
|
||||
# 注:旧版 resize hold(放大时钳制旧尺寸→露白边)已移除 ——
|
||||
# 拖动黑边的真凶是假异步 JS 泵(已修真异步),高频 SetBounds 实测 Chromium 完全跟得上;
|
||||
# 用户实测:立即发送 = 缩小/放大都实时渲染、无阻塞。
|
||||
if self._last_sent_bounds == (L, T, W, H):
|
||||
return
|
||||
self._session.set_bounds(L, T, W, H)
|
||||
self._last_sent_bounds = (L, T, W, H)
|
||||
|
||||
def resizeEvent(self, ev):
|
||||
super().resizeEvent(ev)
|
||||
self.sync_bounds()
|
||||
|
||||
def showEvent(self, ev):
|
||||
super().showEvent(ev)
|
||||
# 确保渲染层点亮(SDK 初始状态下控制器可能不可见)
|
||||
self._session.set_visible(True)
|
||||
self.sync_bounds()
|
||||
|
||||
def _force_repaint(self):
|
||||
"""WebView2 子窗口创建会打断首帧合成 → 强制整窗重绘(治启动时控件渲染丢失)"""
|
||||
top = self.window()
|
||||
if top is None:
|
||||
return
|
||||
top.update()
|
||||
QtWidgets.QApplication.processEvents()
|
||||
|
||||
def _try_find_child(self):
|
||||
if self._session.child_hwnd:
|
||||
self._child_timer.stop()
|
||||
self._session.set_visible(True)
|
||||
self.sync_bounds()
|
||||
self._force_repaint()
|
||||
QtCore.QTimer.singleShot(300, self._force_repaint)
|
||||
return
|
||||
if self._session.find_child_once():
|
||||
self._child_timer.stop()
|
||||
self._session.set_visible(True)
|
||||
self.sync_bounds()
|
||||
self._force_repaint()
|
||||
QtCore.QTimer.singleShot(300, self._force_repaint)
|
||||
|
||||
# ---------- 生命周期 ----------
|
||||
def closeEvent(self, ev):
|
||||
try:
|
||||
self._child_timer.stop()
|
||||
self._sync_timer.stop()
|
||||
self._session.close()
|
||||
except Exception:
|
||||
pass
|
||||
super().closeEvent(ev)
|
||||
+2111
File diff suppressed because it is too large
Load Diff
Vendored
+3
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#abb2bf;background:#282c34}.hljs-comment,.hljs-quote{color:#5c6370;font-style:italic}.hljs-doctag,.hljs-formula,.hljs-keyword{color:#c678dd}.hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst{color:#e06c75}.hljs-literal{color:#56b6c2}.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#98c379}.hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#d19a66}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#61aeee}.hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#e6c07b}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline}
|
||||
Vendored
+1213
File diff suppressed because one or more lines are too long
@@ -0,0 +1,82 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<!-- Markdown 库 -->
|
||||
<script src="marked.min.js"></script>
|
||||
|
||||
<!-- 代码高亮 -->
|
||||
<link rel="stylesheet" href="highlight/atom-one-dark.min.css">
|
||||
<script src="highlight/highlight.min.js"></script>
|
||||
|
||||
<!-- 🌟 HTML 消毒(防 XSS / 防 HTML 穿透的纵深防御) -->
|
||||
<script src="dompurify.min.js"></script>
|
||||
|
||||
<!-- 🌟 KaTeX 公式渲染(本地离线资产;须在 app.js 之前) -->
|
||||
<link rel="stylesheet" href="katex/katex.min.css">
|
||||
<script src="katex/katex.min.js"></script>
|
||||
|
||||
<!-- 自定义样式 -->
|
||||
<link rel="stylesheet" href="style.css">
|
||||
|
||||
<!-- 🌟 Qt WebChannel 支持(用于前端与 Python 通信) -->
|
||||
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!--🌟 会话加载界面(进入新会话时的统一加载:居中图标 + 从左到右扫描条) -->
|
||||
<div class="session-loading" id="session-loading" style="display:none;">
|
||||
<div class="session-loading-box">
|
||||
<img class="session-loading-icon" src="../../svg/main.svg" alt="haocode">
|
||||
<div class="session-loading-bar">
|
||||
<div class="session-loading-bar-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--🌟 欢迎界面 -->
|
||||
<div class="welcome-screen">
|
||||
<div class="welcome-content">
|
||||
<img class="welcome-icon" src="../../svg/main.svg" alt="haocode">
|
||||
<h1 class="brand-name">haocode</h1>
|
||||
<p class="brand-tagline">探索未至之境</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 聊天容器 -->
|
||||
<div id="chat-container"></div>
|
||||
|
||||
<div id="scroll-anchor" style="height: 1px; margin-bottom: 150px;"></div>
|
||||
|
||||
<!-- 🌟 桥接初始化脚本(必须在 app.js 之前执行)
|
||||
双协议:WebView2(window.chrome.webview.postMessage)/ QtWebEngine(QWebChannel) -->
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
if (window.chrome && window.chrome.webview) {
|
||||
// ---- WebView2 路径:JSON 消息协议,方法名与 QWebChannel slot 一一对应 ----
|
||||
const post = (m, a) => {
|
||||
try { window.chrome.webview.postMessage({ m: m, a: a }); } catch (e) { console.warn("[JS] postMessage 失败:", e); }
|
||||
};
|
||||
window.bridge = {
|
||||
onRegenerateClicked: (x) => post("onRegenerateClicked", [x]),
|
||||
onBranchSwitch: (x, d) => post("onBranchSwitch", [x, d]),
|
||||
onDeleteMessageClicked: (x) => post("onDeleteMessageClicked", [x]),
|
||||
onAttachmentClicked: (x) => post("onAttachmentClicked", [x]),
|
||||
onScrollChanged: (y, h, c) => post("onScrollChanged", [y, h, c])
|
||||
};
|
||||
console.log("[JS] WebView2 桥接就绪,window.bridge 可用");
|
||||
} else {
|
||||
// ---- QtWebEngine 路径:原有 QWebChannel ----
|
||||
new QWebChannel(qt.webChannelTransport, function (channel) {
|
||||
window.bridge = channel.objects.bridge;
|
||||
console.log("[JS] QWebChannel 已连接,window.bridge 就绪");
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- 主逻辑脚本 -->
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+6
File diff suppressed because one or more lines are too long
@@ -0,0 +1,902 @@
|
||||
/* ==================== 1. 全局 ==================== */
|
||||
body, html {
|
||||
margin: 0; padding: 0;
|
||||
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
|
||||
background-color: #ffffff;
|
||||
color: #333;
|
||||
overflow-x: hidden;
|
||||
/* 🌟 不再给 html/body 设 height:100%(会让 body 退化为内部滚动容器,
|
||||
导致 window.scrollY/scrollTo 失效;视口高度由 #chat-container 的 min-height:100vh 承担) */
|
||||
}
|
||||
|
||||
#chat-container {
|
||||
max-width: 95%;
|
||||
margin: 0 auto;
|
||||
padding: 20px 20px 20px 20px;
|
||||
min-height: 100vh; /* 🌟 至少占满整个视口高度 */
|
||||
}
|
||||
|
||||
/* ==================== 隐藏 WebEngine 原生滚动条 ==================== */
|
||||
/* 改用右侧自定义 Qt 滚动条(WebScrollBar),避免内置滚动条高速滑动时的重绘延迟观感 */
|
||||
::-webkit-scrollbar {
|
||||
width: 0px;
|
||||
height: 0px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ==================== 会话加载界面 ==================== */
|
||||
.session-loading {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #ffffff;
|
||||
opacity: 1;
|
||||
transition: opacity 0.45s ease; /* 结束后的渐变退场 */
|
||||
}
|
||||
.session-loading.hide {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.session-loading-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.session-loading-icon {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
.session-loading-bar {
|
||||
width: 190px;
|
||||
height: 4px;
|
||||
margin-top: 26px;
|
||||
background: #eceef2;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.session-loading-bar-fill {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
height: 100%;
|
||||
width: 36%;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, rgba(59,130,246,0) 0%, #3b82f6 50%, rgba(59,130,246,0) 100%);
|
||||
animation: loadingSweep 1.2s ease-in-out infinite; /* 从左到右扫描,非进度条 */
|
||||
}
|
||||
@keyframes loadingSweep {
|
||||
0% { transform: translateX(-110%); }
|
||||
100% { transform: translateX(300%); }
|
||||
}
|
||||
|
||||
/* ==================== 欢迎界面 ==================== */
|
||||
.welcome-screen {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: fixed; /* 🌟 固定定位,脱离文档流 */
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 60px 20px;
|
||||
pointer-events: none; /* 🌟 允许点击穿透到下方的输入框 */
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
pointer-events: auto; /* 🌟 但内容本身可以交互 */
|
||||
/* 旧的渐入动画已移除:会话切换统一由加载界面完成过渡 */
|
||||
}
|
||||
|
||||
/* 欢迎页图标:main.svg */
|
||||
.welcome-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
letter-spacing: 6px;
|
||||
font-family: "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", "Segoe UI", sans-serif;
|
||||
background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.brand-tagline {
|
||||
font-size: 15px;
|
||||
color: #8a94a6;
|
||||
margin: 14px 0 0 0;
|
||||
font-weight: 400;
|
||||
letter-spacing: 5px;
|
||||
}
|
||||
|
||||
|
||||
/* ==================== 2. 消息气泡 ==================== */
|
||||
.message-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.message-wrapper.user { justify-content: flex-end; }
|
||||
|
||||
/* ==================== 3. 头像 ==================== */
|
||||
.avatar {
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 6px;
|
||||
display: flex; justify-content: center; align-items: center;
|
||||
font-weight: bold; font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.assistant .avatar { background-color: #4a90d9; color: white; margin-right: 15px; }
|
||||
.user .avatar { background-color: #e3e3e3; color: #555; margin-left: 15px; order: 2; }
|
||||
|
||||
/* ==================== 4. 消息内容区 ==================== */
|
||||
.message-content {
|
||||
max-width: 85%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.assistant .message-content {
|
||||
align-items: flex-start;
|
||||
/* 🆕 助手气泡恒定宽度:输出中/完成后一律 85%,不随内容伸缩(流式特效不受影响) */
|
||||
width: 85%;
|
||||
}
|
||||
/* 🆕 内层同步撑满:.reply-content 基类是 fit-content(贴内容缩),
|
||||
可见气泡(md-segment 背景/思考卡/工具卡)都在它里面 → 必须 100% 才真正恒定 */
|
||||
.assistant .reply-content { width: 100%; }
|
||||
.user .message-content { align-items: flex-end; }
|
||||
|
||||
.sender-name {
|
||||
font-size: 11px; color: #aaa;
|
||||
margin-bottom: 6px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.reply-content {
|
||||
line-height: 1.6; font-size: 15px;
|
||||
width: fit-content; max-width: 100%;
|
||||
}
|
||||
.user .reply-content {
|
||||
background-color: #f4f4f4;
|
||||
padding: 10px 18px;
|
||||
border-radius: 18px; border-top-right-radius: 4px;
|
||||
color: #333; text-align: left;
|
||||
}
|
||||
|
||||
/* ==================== 5. 思考过程 ==================== */
|
||||
.think-block {
|
||||
margin-bottom: 12px; border-radius: 8px;
|
||||
background-color: #ffffff; border: 1px solid #eaeaea;
|
||||
overflow: hidden; transition: all 0.3s ease; width: 100%;
|
||||
}
|
||||
.think-block summary {
|
||||
padding: 8px 14px; font-size: 13px; color: #888;
|
||||
cursor: pointer; user-select: none;
|
||||
background-color: #fafafa; list-style: none;
|
||||
display: flex; align-items: center; font-weight: 500;
|
||||
}
|
||||
.think-block summary .chev { color: #b8b8b8; margin-right: 8px; }
|
||||
.think-block[open] summary { border-bottom: 1px dashed #eaeaea; }
|
||||
.think-content {
|
||||
padding: 12px 14px; font-size: 13px; color: #777;
|
||||
background-color: #fff; max-height: 400px; overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 🌟 流式输出中:flex 锁底,展示最新思考内容 */
|
||||
/* 🌟 流式输出中:思考框保持可滚动,JS 控制锁底 */
|
||||
.message-wrapper.streaming .think-content {
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
|
||||
.think-inner {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
.think-content.markdown-body p { color: #777; margin-bottom: 8px; }
|
||||
|
||||
/* ============ 思考/压缩气泡内:代码朴素渲染 ============
|
||||
压缩摘要充满代码:浅色语法色(#a626a4 紫)+ code-block-wrapper 边框/语言栏,
|
||||
让定格后的气泡观感"紫色带边框"。此处统一朴素灰、无边框无头部;
|
||||
正文气泡的代码块不受影响。 */
|
||||
.think-content .code-block-wrapper {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
min-width: 0;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.think-content .code-header { display: none; }
|
||||
.think-content .code-block-wrapper pre,
|
||||
.think-content .code-block-wrapper pre code,
|
||||
.think-content .code-block-wrapper .hljs,
|
||||
.think-content .code-block-wrapper .hljs * {
|
||||
color: #777 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
.think-content code,
|
||||
.think-content.markdown-body p code {
|
||||
color: #777;
|
||||
background-color: #f6f7f9;
|
||||
}
|
||||
|
||||
/* ==================== 6. Markdown 基础 ==================== */
|
||||
.markdown-body p { margin-top: 0; margin-bottom: 10px; }
|
||||
.markdown-body code {
|
||||
font-family: 'Consolas', 'Courier New', monospace; font-size: 13px;
|
||||
}
|
||||
.markdown-body p code {
|
||||
background-color: #f0f0f0; color: #e83e8c;
|
||||
padding: 2px 5px; border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ==================== 6b. 🌟 KaTeX 公式渲染 ==================== */
|
||||
.katex-display {
|
||||
margin: 10px 0; padding: 4px 2px;
|
||||
overflow-x: auto; overflow-y: hidden;
|
||||
}
|
||||
.katex-display::-webkit-scrollbar { height: 6px; }
|
||||
.katex-display::-webkit-scrollbar-thumb { background: #d0d0d0; border-radius: 3px; }
|
||||
.markdown-body .katex { font-size: 1.06em; }
|
||||
.markdown-body .katex-display { font-size: 1.08em; }
|
||||
|
||||
/* ==================== 7. 代码块 ==================== */
|
||||
.code-block-wrapper {
|
||||
position: relative;
|
||||
border: 1px solid #e5e5e5; border-radius: 8px;
|
||||
margin: 12px 0; overflow: hidden;
|
||||
background-color: #ffffff;
|
||||
min-width: 500px; /* 短代码也保持基本宽度,避免气泡过窄难看 */
|
||||
}
|
||||
|
||||
.code-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
background-color: #fafafa; padding: 4px 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.code-lang-label {
|
||||
font-size: 11px; color: #aaa; font-weight: 500;
|
||||
text-transform: lowercase; letter-spacing: 0.3px;
|
||||
}
|
||||
.code-header-actions { display: flex; align-items: center; gap: 4px; }
|
||||
|
||||
.code-header button {
|
||||
background: transparent; border: 1px solid transparent;
|
||||
color: #999; cursor: pointer;
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
font-size: 12px; padding: 3px 8px; border-radius: 4px;
|
||||
transition: all 0.15s ease; font-family: inherit; outline: none;
|
||||
}
|
||||
.code-header button:hover {
|
||||
background-color: #f0f0f0; border-color: #e0e0e0; color: #555;
|
||||
}
|
||||
.copy-btn.copied {
|
||||
color: #52c41a !important;
|
||||
border-color: rgba(82, 196, 26, 0.3) !important;
|
||||
background-color: rgba(82, 196, 26, 0.06) !important;
|
||||
}
|
||||
|
||||
/* 代码主体 */
|
||||
.code-body {
|
||||
margin: 0; padding: 14px;
|
||||
background-color: #fafafa !important;
|
||||
overflow-x: auto;
|
||||
max-height: 5000px;
|
||||
}
|
||||
|
||||
/* 修改折叠态:允许滚动但隐藏滚动条 */
|
||||
.code-body.collapsed {
|
||||
max-height: 140px !important;
|
||||
overflow-y: auto !important; /* ← 从 hidden 改为 auto */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
.code-body.collapsed::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari */
|
||||
}
|
||||
|
||||
/* 🌟 流式输出中:用 flex 布局天然展示底部代码,不依赖 JS scrollTop */
|
||||
.message-wrapper.streaming .code-body.collapsed {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
justify-content: flex-end !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
|
||||
/* 默认折叠遮罩:底部渐隐(展示顶部代码) */
|
||||
.code-block-wrapper:has(.code-body.collapsed)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
height: 40px;
|
||||
background: linear-gradient(transparent, #fafafa);
|
||||
pointer-events: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
/* 🌟 流式输出中遮罩:顶部渐隐(展示底部最新代码) */
|
||||
.message-wrapper.streaming .code-block-wrapper:has(.code-body.collapsed)::after {
|
||||
top: 36px; bottom: auto;
|
||||
border-radius: 0;
|
||||
background: linear-gradient(#fafafa, transparent);
|
||||
}
|
||||
|
||||
/* 强制覆盖 highlight.js 深色背景 */
|
||||
.code-block-wrapper pre,
|
||||
.code-block-wrapper pre code,
|
||||
.code-block-wrapper .hljs {
|
||||
background-color: #fafafa !important;
|
||||
color: #383a42 !important;
|
||||
}
|
||||
|
||||
/* 浅色语法高亮 */
|
||||
.code-block-wrapper .hljs-keyword,
|
||||
.code-block-wrapper .hljs-selector-tag,
|
||||
.code-block-wrapper .hljs-title,
|
||||
.code-block-wrapper .hljs-section,
|
||||
.code-block-wrapper .hljs-doctag,
|
||||
.code-block-wrapper .hljs-name,
|
||||
.code-block-wrapper .hljs-strong { color: #a626a4; }
|
||||
.code-block-wrapper .hljs-string,
|
||||
.code-block-wrapper .hljs-attr { color: #50a14f; }
|
||||
.code-block-wrapper .hljs-number,
|
||||
.code-block-wrapper .hljs-literal,
|
||||
.code-block-wrapper .hljs-variable,
|
||||
.code-block-wrapper .hljs-template-variable,
|
||||
.code-block-wrapper .hljs-tag .hljs-attr { color: #986801; }
|
||||
.code-block-wrapper .hljs-comment,
|
||||
.code-block-wrapper .hljs-quote { color: #a0a1a7; font-style: italic; }
|
||||
.code-block-wrapper .hljs-built_in,
|
||||
.code-block-wrapper .hljs-builtin-name { color: #c18401; }
|
||||
.code-block-wrapper .hljs-function .hljs-title,
|
||||
.code-block-wrapper .hljs-class .hljs-title { color: #4078f2; }
|
||||
.code-block-wrapper .hljs-type,
|
||||
.code-block-wrapper .hljs-params { color: #c18401; }
|
||||
.code-block-wrapper .hljs-meta { color: #4078f2; }
|
||||
.code-block-wrapper code {
|
||||
font-family: 'Consolas', 'Courier New', monospace; font-size: 13px;
|
||||
}
|
||||
|
||||
/* ==================== 7.1 SVG 代码块:绘制/源码 切换 ==================== */
|
||||
/* 渲染容器:透明背景、居中,防超大 SVG 撑破布局 */
|
||||
.svg-render-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 14px;
|
||||
background: transparent;
|
||||
overflow: auto;
|
||||
max-height: 5000px;
|
||||
}
|
||||
.svg-render-body svg {
|
||||
min-width: 360px; /* 小图适当放大,避免渲染出来太小看不清 */
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
/* 渲染态:隐藏代码体与折叠渐隐遮罩 */
|
||||
.code-block-wrapper.svg-mode .code-body { display: none !important; }
|
||||
.code-block-wrapper.svg-mode:has(.code-body.collapsed)::after { display: none; }
|
||||
/* 渲染态按钮高亮(此时按钮文案为「源码」) */
|
||||
.code-header .svg-render-btn.active {
|
||||
color: #10a37f;
|
||||
border-color: rgba(16, 163, 127, 0.3);
|
||||
background-color: rgba(16, 163, 127, 0.06);
|
||||
}
|
||||
/* 折叠按钮被锁定时(处于渲染态) */
|
||||
.code-header .fold-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* 流式输出中隐藏「绘制」按钮(与复制按钮同等待遇,避免重渲冲掉切换状态) */
|
||||
.message-wrapper.streaming .svg-render-btn {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ==================== 8. 长文本附件卡片 ==================== */
|
||||
.long-text-card {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 12px 16px; background-color: #f8f8f8;
|
||||
border: 1px solid #e8e8e8; border-radius: 12px;
|
||||
cursor: pointer; transition: all 0.2s ease; max-width: 320px;
|
||||
}
|
||||
.long-text-card:hover {
|
||||
background-color: #f0f0f0; border-color: #d0d0d0;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
}
|
||||
.card-icon {
|
||||
flex-shrink: 0; width: 40px; height: 40px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background-color: #fff; border-radius: 8px; border: 1px solid #eee;
|
||||
}
|
||||
.card-info { overflow: hidden; }
|
||||
.card-title {
|
||||
font-size: 13px; color: #333; font-weight: 500;
|
||||
white-space: nowrap; overflow: hidden;
|
||||
text-overflow: ellipsis; max-width: 220px;
|
||||
}
|
||||
.card-meta { font-size: 11px; color: #aaa; margin-top: 3px; }
|
||||
|
||||
/* ==================== 9. 模态框 ==================== */
|
||||
.content-modal-overlay {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: rgba(0,0,0,0);
|
||||
display: flex; justify-content: center; align-items: center;
|
||||
z-index: 9999; transition: background-color 0.2s ease;
|
||||
}
|
||||
.content-modal-overlay.visible { background-color: rgba(0,0,0,0.4); }
|
||||
.content-modal {
|
||||
background-color: #fff; border-radius: 12px;
|
||||
width: 70%; max-width: 700px; max-height: 75vh;
|
||||
display: flex; flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.15);
|
||||
transform: scale(0.95); opacity: 0;
|
||||
transition: all 0.2s ease; overflow: hidden;
|
||||
}
|
||||
.content-modal-overlay.visible .content-modal {
|
||||
transform: scale(1); opacity: 1;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 14px 20px; border-bottom: 1px solid #eee;
|
||||
font-size: 14px; font-weight: 600; color: #333;
|
||||
}
|
||||
.modal-close-btn {
|
||||
background: transparent; border: none; font-size: 18px;
|
||||
color: #999; cursor: pointer; padding: 4px 8px;
|
||||
border-radius: 4px; transition: all 0.15s ease; line-height: 1;
|
||||
}
|
||||
.modal-close-btn:hover { background-color: #f0f0f0; color: #333; }
|
||||
.modal-body {
|
||||
padding: 16px 20px; overflow-y: auto;
|
||||
font-family: 'Consolas', 'Courier New', monospace;
|
||||
font-size: 13px; line-height: 1.6; color: #444;
|
||||
white-space: pre-wrap; word-break: break-all;
|
||||
margin: 0; background-color: #fafafa;
|
||||
}
|
||||
|
||||
/* ==================== 10. 错误与动画 ==================== */
|
||||
.system-error {
|
||||
background-color: #fef0f0; border: 1px solid #fde2e2;
|
||||
color: #f56c6c; padding: 10px 15px; border-radius: 8px;
|
||||
margin-top: 10px; font-size: 13px; font-weight: bold;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* ==================== 11. 多附件容器 ==================== */
|
||||
.attachments-container {
|
||||
display: flex; flex-direction: column;
|
||||
gap: 8px; margin-bottom: 8px;
|
||||
}
|
||||
.attachments-container .long-text-card { max-width: 300px; }
|
||||
.user .attachments-container + .reply-content { margin-top: 4px; }
|
||||
/* ==================== 12. 流式输出状态(纯 CSS 控制,不闪烁) ==================== */
|
||||
.message-wrapper.streaming .copy-btn {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.message-wrapper.streaming .fold-btn {
|
||||
font-size: 0 !important;
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-wrapper.streaming .fold-btn::after {
|
||||
content: '输出中...';
|
||||
font-size: 12px;
|
||||
color: #f5a623;
|
||||
}
|
||||
/* 过渡态:从 flex-end 切换到 scroll 模式,保持显示底部 */
|
||||
.code-body.collapsed.scroll-locked {
|
||||
display: block !important;
|
||||
overflow-y: auto !important;
|
||||
justify-content: initial !important;
|
||||
flex-direction: initial !important;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.code-body.collapsed.scroll-locked::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
/* ==================== 13. 消息操作栏 ==================== */
|
||||
.message-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
transition: all 0.15s ease;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background-color: #f0f0f0;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.action-btn svg {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 流式输出中隐藏操作栏 */
|
||||
.message-wrapper.streaming .message-actions {
|
||||
display: none !important;
|
||||
}
|
||||
/* ================= 分支选择器 ================= */
|
||||
.branch-selector {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 12px;
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 暗色模式的话可以加这句,如果是纯亮色就不用 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.branch-selector { background-color: #333; color: #aaa; }
|
||||
}
|
||||
|
||||
.branch-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
color: #888;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.branch-btn:hover:not(:disabled) {
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.branch-btn:disabled {
|
||||
color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.branch-text {
|
||||
margin: 0 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
/* ================= 删除确认气泡 ================= */
|
||||
.delete-confirm-popover {
|
||||
position: absolute;
|
||||
bottom: 120%; /* 浮在按钮上方 */
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: #fee2e2; /* 淡红色警告背景 */
|
||||
border: 1px solid #f87171;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
box-shadow: 0 4px 10px rgba(239, 68, 68, 0.2);
|
||||
z-index: 100;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
width: max-content; /* 🌟 核心修复:保证气泡背景宽度根据文字自适应,不被按钮挤压 */
|
||||
}
|
||||
.delete-confirm-popover:hover {
|
||||
background-color: #fecaca;
|
||||
}
|
||||
.delete-confirm-text {
|
||||
color: #ef4444;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.delete-confirm-popover svg {
|
||||
stroke: #ef4444;
|
||||
}
|
||||
|
||||
/* ================= 用户消息专属操作栏 ================= */
|
||||
.user-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end; /* 靠右对齐 */
|
||||
margin-top: 4px;
|
||||
opacity: 1; /* 默认隐藏,悬浮显示 */
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.message-wrapper.user:hover .user-actions {
|
||||
opacity: 1; /* 鼠标悬浮气泡时,显示操作栏 */
|
||||
}
|
||||
|
||||
/* 强制指定按钮的图标颜色、大小和边距,打破继承魔咒 */
|
||||
.user-actions .action-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #999999; /* 强制指定为高级灰,让 SVG 显形 */
|
||||
padding: 6px;
|
||||
margin-left: 4px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.user-actions .action-btn:hover {
|
||||
background-color: #eaeaea;
|
||||
color: #333333; /* 悬浮时图标变深 */
|
||||
}
|
||||
|
||||
.user-actions .user-delete-btn:hover {
|
||||
background-color: #fee2e2;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* 🌟 用户侧靠右对齐 */
|
||||
.user-actions .delete-confirm-popover {
|
||||
left: auto;
|
||||
right: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ⚠️ 注意:删除原本文件最底部的 "确认删除弹窗基础样式" 这一大段重复代码,避免样式冲突 */
|
||||
|
||||
/* ================= 确认删除弹窗基础样式 ================= */
|
||||
.delete-confirm-popover {
|
||||
position: absolute;
|
||||
bottom: 120%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: #fee2e2;
|
||||
border: 1px solid #f87171;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
box-shadow: 0 4px 10px rgba(239, 68, 68, 0.2);
|
||||
z-index: 100;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.delete-confirm-popover:hover {
|
||||
background-color: #fecaca;
|
||||
}
|
||||
|
||||
.delete-confirm-text {
|
||||
color: #ef4444;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.delete-confirm-popover svg {
|
||||
stroke: #ef4444;
|
||||
}
|
||||
/* ==================== 工具执行气泡(pi tool_execution_* 事件,浅色·对齐思考气泡) ==================== */
|
||||
.tool-chip {
|
||||
margin: 10px 0 4px 0; border-radius: 8px;
|
||||
background-color: #ffffff; border: 1px solid #eaeaea;
|
||||
overflow: hidden; transition: all 0.3s ease; width: 100%;
|
||||
}
|
||||
.tool-chip.streaming {
|
||||
border-color: #d6e4ff;
|
||||
background-color: #fbfdff;
|
||||
}
|
||||
.tool-chip summary {
|
||||
padding: 8px 14px; font-size: 12.5px; color: #888;
|
||||
cursor: pointer; user-select: none;
|
||||
background-color: #fafafa; list-style: none;
|
||||
display: flex; align-items: center; gap: 8px; font-weight: 500;
|
||||
}
|
||||
.tool-chip summary::-webkit-details-marker { display: none; }
|
||||
.tool-chip summary .chev { color: #b8b8b8; }
|
||||
.tool-chip[open] summary { border-bottom: 1px dashed #eaeaea; }
|
||||
.tool-chip-icon { font-size: 13px; color: #bbb; }
|
||||
.tool-chip-name {
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-weight: 600; color: #666; font-size: 12px;
|
||||
}
|
||||
.tool-chip-brief {
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
color: #aaa; font-size: 12px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
.tool-chip-brief.done { color: #52c41a; }
|
||||
.tool-chip-status {
|
||||
margin-left: auto; flex-shrink: 0;
|
||||
font-size: 11px; padding: 1px 8px; border-radius: 10px;
|
||||
}
|
||||
.tool-chip-status.running {
|
||||
color: #4a7fd4; background: #eef4ff;
|
||||
animation: toolPulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.tool-chip-status.ok { color: #52c41a; background: #f6ffed; }
|
||||
.tool-chip-status.fail { color: #cf1322; background: #fff1f0; }
|
||||
@keyframes toolPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
.tool-chip-body {
|
||||
padding: 10px 14px 12px 14px;
|
||||
max-height: 260px; overflow: auto; background-color: #fff;
|
||||
}
|
||||
.tool-chip-label {
|
||||
font-size: 11px; color: #bbb; font-weight: 600;
|
||||
margin: 6px 0 4px 0; letter-spacing: 0.5px;
|
||||
}
|
||||
.tool-chip-label:first-child { margin-top: 0; }
|
||||
.tool-chip-pre {
|
||||
margin: 0 0 6px 0; padding: 8px 10px;
|
||||
background-color: #f6f7f9; color: #555;
|
||||
border: 1px solid #eef0f3; border-radius: 6px;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 11.5px; line-height: 1.55;
|
||||
white-space: pre-wrap; word-break: break-all;
|
||||
max-height: 150px; overflow: auto;
|
||||
}
|
||||
|
||||
/* ==================== 系统提示(如:已自动压缩上下文) ==================== */
|
||||
.system-note {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
background: #f3f4f6;
|
||||
border-radius: 10px;
|
||||
padding: 6px 14px;
|
||||
margin: 10px auto;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
/* ==================== 增量流式渲染容器 ==================== */
|
||||
/* 注意:不能用 display:contents —— 本机 Chromium 下父盒(.md-segment)高度坍缩为 0,
|
||||
导致流式正文整段不可见(切会话重渲走无包裹路径所以可见)。用 block 规避。 */
|
||||
.md-stable, .md-tail {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ==================== 浅色 SVG 箭头(替代文字字形) ==================== */
|
||||
.chev {
|
||||
flex: none; display: inline-block;
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
.think-block[open] .chev, .tool-chip[open] .chev { transform: rotate(90deg); }
|
||||
.think-label { color: inherit; }
|
||||
|
||||
/* ==================== 深度思考:默认收起 + 进行中蓝色呼吸动画 ==================== */
|
||||
.think-block.streaming-think > summary .think-label {
|
||||
color: #3b82f6;
|
||||
font-weight: 600;
|
||||
animation: think-breathe 1.6s ease-in-out infinite;
|
||||
}
|
||||
.think-block.streaming-think > summary .think-label::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 1.2em;
|
||||
text-align: left;
|
||||
animation: think-dots 1.4s infinite;
|
||||
}
|
||||
@keyframes think-breathe {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
@keyframes think-dots {
|
||||
0% { content: ''; }
|
||||
25% { content: '.'; }
|
||||
50% { content: '..'; }
|
||||
75% { content: '...'; }
|
||||
}
|
||||
|
||||
/* ==================== 工具 chip:耗时 / 超时徽章 ==================== */
|
||||
.tool-chip-time {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.tool-chip-timeout {
|
||||
font-size: 11px;
|
||||
color: #dc2626;
|
||||
font-weight: 600;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.tool-chip-livetimer {
|
||||
/* 🆕 bash 运行中每秒读秒(N/Ts):琥珀色 + 等宽数字,不跳动 */
|
||||
font-size: 11px;
|
||||
color: #d97706;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* ==================== 工具结果:长输出展开按钮 ==================== */
|
||||
.tool-expand-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 4px 0 0;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
font-family: inherit;
|
||||
}
|
||||
.tool-expand-btn:hover { background: #dbeafe; }
|
||||
.tool-chip-pre.tool-chip-full {
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* ==================== 时间线:正文段 / 流式光标 ==================== */
|
||||
.md-segment:empty { display: none; }
|
||||
.md-segment > :first-child { margin-top: 0; }
|
||||
.md-segment > :last-child { margin-bottom: 0; }
|
||||
/* block 包裹下,额外压平内层首尾段落的边距,保持气泡观感 */
|
||||
.md-segment > :last-child > :last-child { margin-bottom: 0; }
|
||||
/* 光标 "Chasing a shining star":文字顶住不动,只有 ● 缩放闪烁(星光脉冲无限循环)。
|
||||
生命周期=原版:消息第一个时间线事件出现即显示 → finishMessage 移除,
|
||||
思考/工具/压缩/正文全程保持(无休眠态) */
|
||||
.streaming-typing {
|
||||
display: inline-block; margin-left: 4px; color: #3b82f6;
|
||||
font-size: 13px;
|
||||
font-family: "Cascadia Mono", "Cascadia Code", Consolas, "Lucida Console", monospace;
|
||||
}
|
||||
.streaming-typing .typing-dot {
|
||||
display: inline-block;
|
||||
animation: cursor-star 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes cursor-star {
|
||||
0%, 100% {
|
||||
opacity: .25;
|
||||
transform: scale(.85);
|
||||
text-shadow: none;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.15);
|
||||
text-shadow: 0 0 6px rgba(59, 130, 246, .8), 0 0 12px rgba(59, 130, 246, .45);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ========== 助手正文透明气泡(流式中/完成后一致) ==========
|
||||
🐛 修复:原选择器 .message.assistant 是死代码(JS 只挂 message-wrapper 类),背景从未渲染 */
|
||||
/* 🆕 用户决定:助手正文气泡【透明】——不要背景/边框/圆角/内边距(2026-07 像素取证调试后明确:
|
||||
原死选择器 P0 修复带来的浅灰底不是想要的效果),正文直接裸排在 85% 定宽列内 */
|
||||
.message-wrapper.assistant .reply-content .md-segment + .md-segment {
|
||||
margin-top: 6px;
|
||||
}
|
||||
Reference in New Issue
Block a user