init repo
This commit is contained in:
@@ -0,0 +1,956 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""右侧任务面板:本次会话的 bash 任务监控(运行中 / 已完成)
|
||||
|
||||
统一口径(与用户确认):
|
||||
· 两栏 = 「运行中」/「已完成」(按状态分);两栏【同时可见、不互斥】,
|
||||
中间是可拖动分隔线(260px 宽若真做左右并排,每栏仅 ~130px,
|
||||
展开区放不下「参数 + 输出」两块列表,故用上下两栏)。
|
||||
若要严格左右并排:把 _SPLIT_ORIENTATION 改成 Horizontal 即可。
|
||||
· 一次 bash = 一层(BashLayer),自上而下按【启动先后倒序】排列(P2-01:
|
||||
最新启动的任务在第一项;排序键恒为启动顺序,运行中→完成归位时
|
||||
不按完成时间重排;已落库时间线无显式时间时,启动序号 = 消息链
|
||||
顺序 + 时间线内顺序,稳定可复现)
|
||||
· 单击层头 = 展开/收起;展开后分「参数」「输出」两块
|
||||
· 运行中:输出为 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"
|
||||
|
||||
# P0-01:配置路径统一走 core.config_paths(环境变量 HAOCODE_CONFIG_FILE 优先)
|
||||
from core.config_paths import config_path as _unified_config_path # noqa: E402
|
||||
|
||||
|
||||
def _cfg_path() -> str:
|
||||
"""配置文件路径(P0-01 统一入口:测试可用 HAOCODE_CONFIG_FILE 指向临时文件 → 绝不污染真配置)"""
|
||||
return _unified_config_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 = [] # 稳定启动序号(0=最早启动;追加顺序 = 启动先后)
|
||||
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):
|
||||
# P2-01:两栏都按【启动顺序倒序】显示(最新启动在第一项)。
|
||||
# 排序键恒为 self._order 中的位置(稳定启动序号)——
|
||||
# · 运行中→已完成 的任务仍占原启动位置(绝不按完成时间重排);
|
||||
# · 已完成栏的限量窗口 = 最近启动的 LAYER_LIMIT 个(窗口成员不变,仅显示顺序反转)。
|
||||
# 重排走 set_layers(复用同一批 BashLayer 实例):展开/折叠状态、实时输出、
|
||||
# 代码框水平/垂直滚动值、两栏 section 滚动位置全部保持(同对象,不重建)。
|
||||
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 reversed(run_ids)])
|
||||
shown = done_ids[-LAYER_LIMIT:]
|
||||
self.sec_done.set_layers([self._layers[c] for c in reversed(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"):
|
||||
# running/done 返回真实显示顺序(启动倒序);"all" = 原始启动序号(正序)
|
||||
if which == "running":
|
||||
return [c for c in self._order if c in self._running][::-1]
|
||||
if which == "done":
|
||||
return [c for c in self._order if c in self._done][::-1]
|
||||
return list(self._order)
|
||||
@@ -0,0 +1,230 @@
|
||||
# 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
|
||||
# 🆕 P1-01 渲染窗口:前端请求换页(fire-and-forget;响应经 run_js 推送)
|
||||
window_page_requested = pyqtSignal(str, str, str, int) # (session_id, direction, boundary_id, generation)
|
||||
|
||||
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
|
||||
self.window = None # 🆕 P1-01:MainWindow 引用(分页请求需要访问 DB/代次)
|
||||
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();")
|
||||
|
||||
# ---------- 🆕 P1-01 渲染窗口 (Python -> JS) ----------
|
||||
|
||||
def rw_config(self, mode: str, size: int):
|
||||
"""注入渲染窗口配置(JS 就绪后、首次加载前调用一次)"""
|
||||
self.run_js("rwApplyConfig(" + json.dumps(
|
||||
{"render_window_mode": mode, "render_window_size": size}) + ");")
|
||||
|
||||
def rw_begin(self, session_id: str, generation: int, total: int):
|
||||
"""开始一轮窗口化加载:清屏 + 重同步 (session, generation)"""
|
||||
self.run_js(f"rwBegin({json.dumps(session_id)}, {int(generation)}, {int(total)});")
|
||||
|
||||
def rw_init_window(self, session_id: str, generation: int, chain_len: int, items):
|
||||
"""初始窗口就绪:items = [(msg_id, chain_index), ...](旧→新)"""
|
||||
payload = json.dumps({
|
||||
"sessionId": session_id,
|
||||
"generation": int(generation),
|
||||
"chainLen": int(chain_len),
|
||||
"items": [{"id": mid, "chainIndex": ix} for mid, ix in items],
|
||||
}, ensure_ascii=False)
|
||||
self.run_js(f"rwInitWindow({payload});")
|
||||
|
||||
def rw_note_live(self, session_id: str, generation: int, msg_id: str,
|
||||
chain_index: int, chain_len: int = -1):
|
||||
"""新消息追加进窗口(发送用户消息 / 助手占位 / 切回续流)。
|
||||
未持久化/未知 → chain_index=-1;未知链长 → chain_len=-1(JS 保持旧值)"""
|
||||
self.run_js(f"rwNoteLive({json.dumps(session_id)}, {int(generation)}, "
|
||||
f"{json.dumps(msg_id)}, {int(chain_index)}, {int(chain_len)});")
|
||||
|
||||
def rw_page_response(self, payload: dict):
|
||||
"""分页响应推送:payload 由 MainWindow._on_window_page_request 构造"""
|
||||
self.run_js("rwPageResponse(" + json.dumps(payload, ensure_ascii=False) + ");")
|
||||
|
||||
# ---------- 交互 Slot (JS -> Python) ----------
|
||||
|
||||
@pyqtSlot(str, str, str, int)
|
||||
def onRequestWindowPage(self, session_id, direction, boundary_msg_id, generation):
|
||||
"""🆕 P1-01:前端请求换页(direction: older/newer)。
|
||||
fire-and-forget:结果由 Python 经 rwPageResponse 推送(双引擎同构)。"""
|
||||
self.window_page_requested.emit(session_id, direction, boundary_msg_id, int(generation))
|
||||
|
||||
@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,77 @@
|
||||
from PyQt6.QtWebEngineCore import QWebEnginePage, QWebEngineScript, QWebEngineProfile
|
||||
from PyQt6.QtCore import QUrl
|
||||
from PyQt6.QtGui import QDesktopServices
|
||||
|
||||
class CustomWebPage(QWebEnginePage):
|
||||
"""
|
||||
自定义网页类,集中约束内置 QtWebEngine 的不合规浏览器行为:
|
||||
|
||||
1. 拦截所有外部链接跳转,改用系统默认浏览器打开;
|
||||
2. 禁用 Ctrl/Meta + 滚轮 及 Ctrl + +/-/0 快捷键的页面缩放
|
||||
(浏览器式缩放对桌面聊天工具无意义且易误触)。
|
||||
"""
|
||||
def __init__(self, profile_or_parent=None, parent=None):
|
||||
# P1-03:兼容两种调用 —— 新式 CustomWebPage(profile, parent) 与旧式 CustomWebPage(parent)。
|
||||
# profile 缺省 None → QtWebEngine 默认 profile;main_window 传入本实例独立 profile。
|
||||
if isinstance(profile_or_parent, QWebEngineProfile):
|
||||
profile, par = profile_or_parent, parent
|
||||
else:
|
||||
profile, par = None, profile_or_parent
|
||||
if parent is not None:
|
||||
raise TypeError("CustomWebPage(profile, parent) 或 CustomWebPage(parent)")
|
||||
if profile is not None:
|
||||
super().__init__(profile, par)
|
||||
else:
|
||||
super().__init__(par)
|
||||
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,76 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""P1-04:桌面会话探测 + 截图/全局热键能力路由(纯 stdlib,可在任何平台导入)。
|
||||
|
||||
契约(REPAIR_BACKLOG P1-04):
|
||||
- Windows 保持现有行为(Win32 RegisterHotKey + Qt grabWindow 覆盖层)。
|
||||
- Linux X11:原生全局快捷键(XGrabKey,见 x11_hotkey.py)+ 原生屏幕捕获(Qt grabWindow,X11 可用)。
|
||||
- Linux Wayland:截图走 xdg-desktop-portal(用户授权,不绕过 compositor,见 portal_capture.py);
|
||||
全局热键依赖 compositor 桌面协议(ext-global-shortcut 等),本版本无免依赖实现 →
|
||||
明确告知不可用,仅保留应用内 Alt+S 快捷键,主程序其余功能不受影响。
|
||||
- offscreen/无显示(unknown):能力不可用要有明确日志,主程序仍可聊天。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def session_kind() -> str:
|
||||
"""返回 "win32" / "x11" / "wayland" / "unknown"。
|
||||
|
||||
判定顺序(Linux,综合 Qt 平台名与 XDG_SESSION_TYPE,见 PLATFORM_PLAN):
|
||||
1. QT_QPA_PLATFORM 以 offscreen 开头 → unknown(自动化离屏,无桌面能力)
|
||||
2. QT_QPA_PLATFORM 以 wayland 开头,或 WAYLAND_DISPLAY 已设置,
|
||||
或 XDG_SESSION_TYPE=wayland → wayland
|
||||
3. DISPLAY 已设置,或 XDG_SESSION_TYPE=x11 → x11
|
||||
4. 其余(headless/无显示)→ unknown
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
return "win32"
|
||||
if sys.platform != "linux":
|
||||
# 其他 POSIX(macOS 等)不在本任务支持矩阵内
|
||||
return "unknown"
|
||||
qt_plat = os.environ.get("QT_QPA_PLATFORM", "").strip()
|
||||
if qt_plat.startswith("offscreen"):
|
||||
return "unknown"
|
||||
xdg_type = os.environ.get("XDG_SESSION_TYPE", "").strip().lower()
|
||||
if (qt_plat.startswith("wayland")
|
||||
or os.environ.get("WAYLAND_DISPLAY", "").strip()
|
||||
or xdg_type == "wayland"):
|
||||
return "wayland"
|
||||
if os.environ.get("DISPLAY", "").strip() or xdg_type == "x11":
|
||||
return "x11"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def hotkey_plan(kind: str):
|
||||
"""全局热键能力路由 → (thread_factory | None, message)。
|
||||
|
||||
thread_factory() 返回 QThread(带 triggered 信号);None 表示无全局热键能力
|
||||
(调用方应保留应用内 QShortcut 兜底)。message 需要打印以明确当前能力。
|
||||
"""
|
||||
if kind == "win32":
|
||||
from ui.views.system_tools.global_hotkey import GlobalHotkeyThread
|
||||
return GlobalHotkeyThread, "[GlobalHotkey] Windows:系统级全局热键 Alt+S(RegisterHotKey)"
|
||||
if kind == "x11":
|
||||
from ui.views.system_tools.x11_hotkey import X11HotkeyThread
|
||||
return X11HotkeyThread, "[GlobalHotkey] X11:原生全局热键 Alt+S(XGrabKey)"
|
||||
if kind == "wayland":
|
||||
return None, ("[GlobalHotkey] Wayland:全局热键需要 compositor 桌面协议"
|
||||
"(ext-global-shortcut 等),本版本未启用 → 仅提供应用内 Alt+S 快捷键"
|
||||
"(窗口获焦时生效)与截图按钮;其余功能不受影响")
|
||||
return None, ("[GlobalHotkey] 当前环境无显示服务(offscreen/无 DISPLAY)→ 全局热键不可用;"
|
||||
"应用内 Alt+S 快捷键同样不可用,可用截图按钮以外的全部功能")
|
||||
|
||||
|
||||
def capture_plan(kind: str):
|
||||
"""截图能力路由 → (mode, message)。
|
||||
|
||||
mode: "overlay"(Qt grabWindow 覆盖层,win32/x11)
|
||||
"portal"(xdg-desktop-portal 交互截图,wayland)
|
||||
"unavailable"(unknown:明确告知,主程序其余功能不受影响)
|
||||
"""
|
||||
if kind in ("win32", "x11"):
|
||||
return "overlay", None
|
||||
if kind == "wayland":
|
||||
return "portal", None
|
||||
return "unavailable", ("[Screenshot] 当前环境无法获取屏幕画面(offscreen/无显示)→ "
|
||||
"截图功能不可用;聊天与其他功能不受影响")
|
||||
@@ -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,163 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
xdg-desktop-portal 截图(P1-04,Linux Wayland 专用窄适配器)
|
||||
|
||||
Wayland compositor 不允许应用直接抓取屏幕画面(安全模型),截图必须走
|
||||
xdg-desktop-portal 的 org.freedesktop.portal.Screenshot:
|
||||
1. gdbus 调 Screenshot(handle, parent_window=/, {}) → 返回 request 对象路径;
|
||||
2. gdbus monitor 监听该 request 对象:
|
||||
FilePicked(file_uri, ...) → 用户已授权并保存成功 → 返回文件路径;
|
||||
Finished(无 FilePicked) → 用户取消/授权被拒 → 返回 denied;
|
||||
3. 全程有显式时间预算;超时 → timeout。
|
||||
|
||||
实现只用系统自带 CLI(gdbus,GLib 的一部分)+ 标准库,不新增 Python 依赖、
|
||||
不绕过 compositor、不伪造成功。portal/桌面环境不支持时返回明确的不可用原因。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
from PyQt6 import QtCore
|
||||
|
||||
PORTAL_DEST = "org.freedesktop.portal.Desktop"
|
||||
PORTAL_PATH = "/org/freedesktop/portal/desktop"
|
||||
SCREENSHOT_IFACE = "org.freedesktop.portal.Screenshot"
|
||||
REQUEST_IFACE = "org.freedesktop.portal.Request"
|
||||
|
||||
|
||||
def detect_portal():
|
||||
"""探测 portal 截图能力 → (ok: bool, reason: str | None)。"""
|
||||
if sys.platform != "linux":
|
||||
return False, "portal 截图仅用于 Linux"
|
||||
if not (os.environ.get("XDG_RUNTIME_DIR", "").strip()
|
||||
or os.environ.get("DBUS_SESSION_BUS_ADDRESS", "").strip()):
|
||||
return False, ("未检测到 D-Bus 会话(XDG_RUNTIME_DIR/DBUS_SESSION_BUS_ADDRESS 均缺失)"
|
||||
" → portal 截图不可用")
|
||||
if not shutil.which("gdbus"):
|
||||
return False, ("未找到 gdbus CLI(GLib 组件)→ portal 截图不可用;"
|
||||
"聊天与其他功能不受影响")
|
||||
return True, None
|
||||
|
||||
|
||||
def _gdbus_screenshot_request(handle: str, timeout_s: float):
|
||||
"""调 Screenshot 方法,返回 (request_path, stderr);失败抛 RuntimeError。"""
|
||||
cmd = ["gdbus", "call", "--session", "--dest", PORTAL_DEST,
|
||||
"--object-path", PORTAL_PATH,
|
||||
f"--method={SCREENSHOT_IFACE}.Screenshot",
|
||||
handle, "/", "{}"]
|
||||
try:
|
||||
p = subprocess.run(cmd, capture_output=True, text=True,
|
||||
encoding="utf-8", errors="replace",
|
||||
timeout=timeout_s, check=False)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError(f"portal Screenshot 调用超时({timeout_s:.0f}s 预算)")
|
||||
if p.returncode != 0:
|
||||
err = (p.stderr or p.stdout or "").strip().splitlines()
|
||||
raise RuntimeError("portal 不可用: " + (err[-1] if err else "未知错误"))
|
||||
out = (p.stdout or "").strip()
|
||||
# gdbus 输出形如:('/org/freedesktop/portal/desktop/request/1000/haocode/42', <>)
|
||||
start = out.find("('")
|
||||
end = out.find("',") if start != -1 else -1
|
||||
if start == -1 or end == -1:
|
||||
raise RuntimeError(f"无法解析 Screenshot 返回: {out[:120]!r}")
|
||||
return out[start + 2:end], (p.stderr or "").strip()
|
||||
|
||||
|
||||
def _gdbus_monitor_request(request_path: str, budget_s: float):
|
||||
"""监听 request 对象直到 FilePicked / Finished / 预算耗尽。
|
||||
|
||||
返回 (status, path_or_reason):status ∈ {"ok", "denied", "timeout"}。
|
||||
"""
|
||||
cmd = ["gdbus", "monitor", "--session", "--object-path", request_path]
|
||||
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, encoding="utf-8", errors="replace")
|
||||
deadline = time.monotonic() + budget_s
|
||||
file_path = None
|
||||
finished = False
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
line = p.stdout.readline()
|
||||
if not line:
|
||||
if p.poll() is not None:
|
||||
break
|
||||
continue
|
||||
line = line.strip()
|
||||
if not line.startswith("{"):
|
||||
continue
|
||||
try:
|
||||
ev = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
member = ev.get("member", "")
|
||||
body = ev.get("body", [])
|
||||
if member == "FilePicked" and body:
|
||||
uri = str(body[0])
|
||||
# 剥前缀 + unquote(比 urlparse 稳健:兼容 file:///tmp/x 与 file://D%3A%5Cx)
|
||||
file_path = urllib.parse.unquote(uri[len("file://"):]) \
|
||||
if uri.startswith("file://") else uri
|
||||
elif member == "Finished" and ev.get("interface", "") == REQUEST_IFACE:
|
||||
finished = True
|
||||
break
|
||||
if file_path:
|
||||
return "ok", file_path
|
||||
if finished and time.monotonic() <= deadline:
|
||||
return "denied", "用户在 portal 授权窗口取消/拒绝"
|
||||
return "timeout", f"等待 portal 授权超时({budget_s:.0f}s 预算)"
|
||||
finally:
|
||||
try:
|
||||
p.kill()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
p.wait(timeout=3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def portal_screenshot_sync(handle: str = "haocode-shot",
|
||||
request_timeout_s: float = 10.0,
|
||||
wait_budget_s: float = 120.0):
|
||||
"""同步执行 portal 截图(调用方须保证不在 GUI 线程阻塞,或用 PortalScreenshotWorker)。
|
||||
|
||||
返回 (ok: bool, path_or_reason: str)。
|
||||
"""
|
||||
ok, reason = detect_portal()
|
||||
if not ok:
|
||||
return False, reason
|
||||
try:
|
||||
request_path, _err = _gdbus_screenshot_request(handle, request_timeout_s)
|
||||
except RuntimeError as e:
|
||||
return False, str(e)
|
||||
status, result = _gdbus_monitor_request(request_path, wait_budget_s)
|
||||
if status == "ok":
|
||||
return True, result
|
||||
if status == "denied":
|
||||
return False, result
|
||||
return False, result
|
||||
|
||||
|
||||
class PortalScreenshotWorker(QtCore.QThread):
|
||||
"""GUI 线程安全的 portal 截图:done(ok, path_or_reason) 信号回主线程。"""
|
||||
|
||||
done = QtCore.pyqtSignal(bool, str)
|
||||
|
||||
def __init__(self, handle: str = "haocode-shot",
|
||||
request_timeout_s: float = 10.0,
|
||||
wait_budget_s: float = 120.0, parent=None):
|
||||
super().__init__(parent)
|
||||
self._handle = handle
|
||||
self._request_timeout_s = request_timeout_s
|
||||
self._wait_budget_s = wait_budget_s
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
ok, result = portal_screenshot_sync(self._handle,
|
||||
self._request_timeout_s,
|
||||
self._wait_budget_s)
|
||||
except Exception as e:
|
||||
ok, result = False, f"portal 截图异常({type(e).__name__}: {e})"
|
||||
self.done.emit(ok, result)
|
||||
@@ -0,0 +1,200 @@
|
||||
# -*- 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:
|
||||
print("[Screenshot] 无主屏幕 → 截图不可用", flush=True)
|
||||
return
|
||||
self._full_pixmap = screen.grabWindow(0)
|
||||
# P1-04:X11 某些 compositor/环境下 grabWindow 可能拿到空图(Wayland 已改走 portal)
|
||||
if self._full_pixmap is None or self._full_pixmap.isNull() or self._full_pixmap.width() == 0:
|
||||
print("[Screenshot] 屏幕抓取返回空画面(当前 compositor/环境限制)→ 本次截图取消;"
|
||||
"聊天与其他功能不受影响", flush=True)
|
||||
self._full_pixmap = None
|
||||
return
|
||||
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,196 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
X11 原生全局热键(P1-04,Linux X11 专用窄适配器)
|
||||
|
||||
用 ctypes 调 libX11:XOpenDisplay → XGrabKey(root, keycode, Mod1Mask) →
|
||||
select(display_fd) + XNextEvent 消息循环,命中后发射 triggered 信号。
|
||||
与 GlobalHotkeyThread(Windows)同一公开面:triggered / start() / stop()。
|
||||
|
||||
约束:
|
||||
- 只支持现有截图快捷键(Alt+S),不增加任意键监听/记录/重映射;
|
||||
- 注册失败/无显示/无 libX11 → 打印明确"不可用"日志后安静退出,绝不影响主程序;
|
||||
- 不引入任何新的 Python 依赖(libX11 是 X11 桌面必然存在的系统库)。
|
||||
"""
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import select
|
||||
import threading
|
||||
|
||||
from PyQt6 import QtCore
|
||||
|
||||
# ---- X11 常量 ----
|
||||
KeyPress = 2
|
||||
Mod1Mask = 0x0001 # Alt
|
||||
KeyPressMask = 0x00002 # XSelectInput event mask
|
||||
AnyModifier = 0xFFFFFF
|
||||
DEFAULT_MOD = 1 # 与 global_hotkey.MOD_ALT 同值
|
||||
DEFAULT_VK = 0x53 # 与 global_hotkey.VK_S 同值('S')
|
||||
|
||||
#: Win32 VK → X11 keysym 的窄映射(只覆盖现有快捷键;扩展需同步本表)
|
||||
_VK_TO_KEYSYM = {
|
||||
0x53: 0x73, # S → 's'
|
||||
}
|
||||
|
||||
_XEVENT_FIELDS = [
|
||||
("type", ctypes.c_int),
|
||||
("serial", ctypes.c_ulong),
|
||||
("send_event", ctypes.c_int),
|
||||
("display", ctypes.c_void_p),
|
||||
("window", ctypes.c_uint),
|
||||
("root", ctypes.c_uint),
|
||||
("subwindow", ctypes.c_uint),
|
||||
("time", ctypes.c_ulong),
|
||||
("x", ctypes.c_int),
|
||||
("y", ctypes.c_int),
|
||||
("root_x", ctypes.c_int),
|
||||
("root_y", ctypes.c_int),
|
||||
("state", ctypes.c_uint),
|
||||
("keycode", ctypes.c_uint),
|
||||
("same_screen", ctypes.c_int),
|
||||
]
|
||||
|
||||
|
||||
class XEvent(ctypes.Structure):
|
||||
"""XEvent union 的按键事件视图(字段布局与 xproto.h XKeyEvent 一致,64 位下 keycode 位于偏移 76)"""
|
||||
_fields_ = _XEVENT_FIELDS
|
||||
|
||||
|
||||
def _load_x11():
|
||||
"""加载 libX11 并声明最小 API 原型。失败抛 OSError(调用方转成"不可用"日志)。"""
|
||||
name = ctypes.util.find_library("X11") or "libX11.so.6"
|
||||
x11 = ctypes.CDLL(name)
|
||||
x11.XOpenDisplay.argtypes = [ctypes.c_char_p]
|
||||
x11.XOpenDisplay.restype = ctypes.c_void_p
|
||||
x11.XCloseDisplay.argtypes = [ctypes.c_void_p]
|
||||
x11.XConnectionNumber.argtypes = [ctypes.c_void_p]
|
||||
x11.XConnectionNumber.restype = ctypes.c_int
|
||||
x11.XDefaultRootWindow.argtypes = [ctypes.c_void_p]
|
||||
x11.XDefaultRootWindow.restype = ctypes.c_uint
|
||||
x11.XKeysymToKeycode.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
|
||||
x11.XKeysymToKeycode.restype = ctypes.c_int
|
||||
x11.XSelectInput.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_long]
|
||||
x11.XGrabKey.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_uint, ctypes.c_uint, ctypes.c_int]
|
||||
x11.XGrabKey.restype = ctypes.c_int
|
||||
x11.XUngrabKey.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_uint, ctypes.c_uint]
|
||||
x11.XPending.argtypes = [ctypes.c_void_p]
|
||||
x11.XPending.restype = ctypes.c_int
|
||||
x11.XNextEvent.argtypes = [ctypes.c_void_p, ctypes.POINTER(XEvent)]
|
||||
return x11
|
||||
|
||||
|
||||
def _open_x11():
|
||||
"""可注入入口(测试用替身替换)。返回 (x11, display);display 为 0/None 表示无显示。"""
|
||||
x11 = _load_x11()
|
||||
disp = x11.XOpenDisplay(None)
|
||||
return x11, disp
|
||||
|
||||
|
||||
class X11HotkeyThread(QtCore.QThread):
|
||||
"""X11 全局热键线程(公开面与 GlobalHotkeyThread 一致:triggered/start/stop)。"""
|
||||
|
||||
triggered = QtCore.pyqtSignal()
|
||||
|
||||
def __init__(self, hotkey_id=9001, mod=DEFAULT_MOD, vk=DEFAULT_VK, parent=None):
|
||||
super().__init__(parent)
|
||||
self._hotkey_id = hotkey_id
|
||||
self._mod = mod
|
||||
self._vk = vk
|
||||
self._ready = threading.Event()
|
||||
self._stop_flag = False
|
||||
self._ready_flag = False
|
||||
self._registered = False
|
||||
self._keycode = 0
|
||||
self._x11 = None
|
||||
self._display = None
|
||||
self._fd = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _setup(self):
|
||||
"""打开显示、注册热键。成功返回 True;失败打印明确日志并返回 False。"""
|
||||
try:
|
||||
keysym = _VK_TO_KEYSYM.get(self._vk)
|
||||
if keysym is None or self._mod != DEFAULT_MOD:
|
||||
print(f"[X11Hotkey] 暂不支持的快捷键组合 (mod={self._mod:#x}, vk={self._vk:#x})"
|
||||
f" → 全局热键不可用(本适配器只覆盖现有 Alt+S)", flush=True)
|
||||
return False
|
||||
x11, disp = _open_x11()
|
||||
if not disp:
|
||||
print("[X11Hotkey] XOpenDisplay 失败(无 X11 显示服务)→ 全局热键不可用;"
|
||||
"应用内快捷键/截图按钮以外的功能不受影响", flush=True)
|
||||
return False
|
||||
self._x11 = x11
|
||||
self._display = disp
|
||||
self._fd = x11.XConnectionNumber(disp)
|
||||
root = x11.XDefaultRootWindow(disp)
|
||||
keycode = x11.XKeysymToKeycode(disp, keysym)
|
||||
if not keycode:
|
||||
print("[X11Hotkey] 键码解析失败 → 全局热键不可用", flush=True)
|
||||
self._cleanup()
|
||||
return False
|
||||
self._keycode = keycode
|
||||
x11.XSelectInput(disp, root, ctypes.c_long(KeyPressMask))
|
||||
if x11.XGrabKey(disp, keycode, Mod1Mask, root, 1) == 0:
|
||||
# BadAccess:键已被其他程序抢占
|
||||
print(f"[X11Hotkey] XGrabKey 注册失败(Alt+S 可能已被其他程序占用)"
|
||||
f" → 全局热键不可用;应用内快捷键/截图按钮以外的功能不受影响", flush=True)
|
||||
self._cleanup()
|
||||
return False
|
||||
self._registered = True
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[X11Hotkey] 初始化失败({type(e).__name__}: {e})→ 全局热键不可用", flush=True)
|
||||
self._cleanup()
|
||||
return False
|
||||
|
||||
def _cleanup(self):
|
||||
if self._display and self._x11:
|
||||
try:
|
||||
self._x11.XUngrabKey(self._display, self._keycode, AnyModifier,
|
||||
self._x11.XDefaultRootWindow(self._display))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._x11.XCloseDisplay(self._display)
|
||||
except Exception:
|
||||
pass
|
||||
self._display = None
|
||||
self._x11 = None
|
||||
self._fd = None
|
||||
self._registered = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def run(self):
|
||||
if not self._setup():
|
||||
self._ready.set()
|
||||
return
|
||||
self._ready.set()
|
||||
try:
|
||||
while not self._stop_flag:
|
||||
if self._fd is None:
|
||||
break
|
||||
try:
|
||||
r, _, _ = select.select([self._fd], [], [], 0.2)
|
||||
except (OSError, ValueError):
|
||||
break
|
||||
if not r:
|
||||
continue
|
||||
if not self._x11.XPending(self._display):
|
||||
continue
|
||||
ev = XEvent()
|
||||
self._x11.XNextEvent(self._display, ctypes.byref(ev))
|
||||
if ev.type == KeyPress and ev.keycode == self._keycode:
|
||||
self.triggered.emit()
|
||||
except BaseException as e:
|
||||
# 绝不允许异常逃逸出 QThread.run()(PyQt6 会 abort 整个应用)
|
||||
print(f"[X11Hotkey] 事件循环异常({type(e).__name__}: {e})→ 全局热键不可用;其余功能不受影响",
|
||||
flush=True)
|
||||
finally:
|
||||
self._cleanup()
|
||||
|
||||
def wait_ready(self, timeout_s: float = 2.0) -> bool:
|
||||
"""等待注册流程结束(成功或失败都算就绪;测试/诊断用)"""
|
||||
return self._ready.wait(timeout_s)
|
||||
|
||||
def stop(self):
|
||||
"""请求退出(select 0.2s 超时轮询 stop 标志 → 线程安全退出,无需向 X 连接发消息)"""
|
||||
self._stop_flag = True
|
||||
@@ -0,0 +1,218 @@
|
||||
# -*- 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",
|
||||
"onRequestWindowPage", # 🆕 P1-01 渲染窗口换页请求
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
+2309
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,85 @@
|
||||
<!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>
|
||||
|
||||
<!-- 聊天容器(🆕 P1-01:顶部/底部加载入口由 app.js 按需插入 chat-container 首/尾) -->
|
||||
<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]),
|
||||
onRequestWindowPage: (s, d, b, g) => post("onRequestWindowPage", [s, d, b, g])
|
||||
};
|
||||
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>
|
||||
|
||||
<!-- 🌟 主逻辑脚本 -->
|
||||
<!-- 🆕 P1-01 渲染窗口状态机(DOM 无关;须在 app.js 之前) -->
|
||||
<script src="render_window.js"></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,304 @@
|
||||
/* ui/web/render_window.js — P1-01 双向消息渲染窗口:DOM 无关状态机
|
||||
*
|
||||
* 同时可被 Node(tests/test_render_window.js)与浏览器(index.html)加载。
|
||||
* 不触碰任何 DOM:几何量以参数传入,输出「动作计划」(加入/移除/滚动增量),
|
||||
* 由 app.js 的 DOM 层执行。消息描述符渲染由 Python 经既有桥接调用完成,
|
||||
* 本模块只维护窗口游标(已加载消息 id 序列 + 链内下标)。
|
||||
*
|
||||
* 配置规则(与 core/config_paths.render_window_settings 一致):
|
||||
* size:只接受非布尔整数 10..200;缺失/布尔/字符串/小数/零/负数/越界 → 静默回落 40;
|
||||
* mode:只接受 "auto"/"manual",否则回落 "auto"。
|
||||
*
|
||||
* 窗口模型:
|
||||
* order —— 已加载(= 当前窗口)的消息 id 序列,长度恒 ≤ size;
|
||||
* indexById —— id → 链内下标(Python 页载荷给出;未持久化的活动消息为 -1);
|
||||
* hiddenOlder/hiddenNewer —— 窗口之外、链中仍存在的消息数(按链内下标推导);
|
||||
* 一次换页 = 「加入一端、裁掉另一端」;活动流式消息计入上限、永不裁剪;
|
||||
* 请求/响应携带 (sessionId, generation);clear() 本地递增代次,
|
||||
* initFullChain() 用 Python 代次重同步;不匹配的旧载荷一律 stale。
|
||||
*/
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory();
|
||||
else root.RenderWindowState = factory();
|
||||
}(typeof self !== 'undefined' ? self : this, function () {
|
||||
'use strict';
|
||||
|
||||
var MIN_SIZE = 10, MAX_SIZE = 200, DEFAULT_SIZE = 40;
|
||||
|
||||
function isInt(v) {
|
||||
return typeof v === 'number' && isFinite(v) && Math.floor(v) === v;
|
||||
}
|
||||
|
||||
function validSize(v) {
|
||||
return isInt(v) && v >= MIN_SIZE && v <= MAX_SIZE;
|
||||
}
|
||||
|
||||
function normalizeConfig(cfg) {
|
||||
var c = (cfg && typeof cfg === 'object') ? cfg : {};
|
||||
var size = validSize(c.render_window_size) ? c.render_window_size : DEFAULT_SIZE;
|
||||
var mode = (c.render_window_mode === 'auto' || c.render_window_mode === 'manual')
|
||||
? c.render_window_mode : 'auto';
|
||||
return { mode: mode, size: size };
|
||||
}
|
||||
|
||||
function create(cfg) {
|
||||
var c = normalizeConfig(cfg);
|
||||
return {
|
||||
mode: c.mode,
|
||||
size: c.size,
|
||||
sessionId: null,
|
||||
generation: 0,
|
||||
order: [], // 窗口内 id(旧 → 新),长度 ≤ size
|
||||
indexById: {}, // id → 链内下标(-1 = 未持久化)
|
||||
chainLen: 0, // 最近一次载荷给出的可见链长度
|
||||
hiddenOlder: 0, // 窗口之上链中消息数
|
||||
hiddenNewer: 0, // 窗口之下链中消息数
|
||||
hasMoreOlder: false,
|
||||
hasMoreNewer: false,
|
||||
pending: null, // {direction, boundaryId, generation} | null
|
||||
activeStreamId: null, // 活动流式消息 id(计入上限、永不裁剪)
|
||||
followBottom: false
|
||||
};
|
||||
}
|
||||
|
||||
function windowIds(st) { return st.order.slice(); }
|
||||
|
||||
function isFull(st) { return st.order.length >= st.size; }
|
||||
|
||||
function canRequest(st, direction) {
|
||||
if (!st || st.pending || st.order.length === 0) return false;
|
||||
return direction === 'older' ? st.hasMoreOlder : st.hasMoreNewer;
|
||||
}
|
||||
|
||||
/* 登记一次换页请求,返回 {direction, boundaryId};boundary 为窗口对应端的消息 id。 */
|
||||
function beginRequest(st, direction) {
|
||||
if (!canRequest(st, direction)) return null;
|
||||
var boundaryId = direction === 'older' ? st.order[0] : st.order[st.order.length - 1];
|
||||
st.pending = { direction: direction, boundaryId: boundaryId, generation: st.generation };
|
||||
return { direction: direction, boundaryId: boundaryId };
|
||||
}
|
||||
|
||||
function num(v) { return (isInt(v) && v >= 0) ? v : 0; }
|
||||
|
||||
/* 按链内下标推导两端隐藏计数。
|
||||
* 未持久化消息(index=-1)只可能出现在较新一端:
|
||||
* hiddenOlder = 窗口最旧 id 的下标(最旧端必为已持久化消息)
|
||||
* hiddenNewer = chainLen - 1 - 已知最大下标 - 其后的未持久化条数 */
|
||||
function recompute(st) {
|
||||
var minI = null, maxI = null, maxPos = -1, unknownAfterMax = 0;
|
||||
for (var i = 0; i < st.order.length; i++) {
|
||||
var ix = st.indexById[st.order[i]];
|
||||
if (ix === undefined || ix < 0) continue;
|
||||
if (minI === null || ix < minI) minI = ix;
|
||||
if (maxI === null || ix > maxI) { maxI = ix; maxPos = i; }
|
||||
}
|
||||
for (var j = maxPos + 1; j < st.order.length; j++) {
|
||||
var jx = st.indexById[st.order[j]];
|
||||
if (jx === undefined || jx < 0) unknownAfterMax++;
|
||||
}
|
||||
if (minI === null) {
|
||||
st.hiddenOlder = 0;
|
||||
st.hiddenNewer = 0;
|
||||
st.hasMoreOlder = false;
|
||||
st.hasMoreNewer = false;
|
||||
return;
|
||||
}
|
||||
st.hiddenOlder = minI;
|
||||
st.hiddenNewer = Math.max(0, st.chainLen - 1 - maxI - unknownAfterMax);
|
||||
st.hasMoreOlder = st.hiddenOlder > 0;
|
||||
st.hasMoreNewer = st.hiddenNewer > 0;
|
||||
}
|
||||
|
||||
/* 从较新一端裁剪,直到长度 ≤ size;活动流永不裁剪。 */
|
||||
function trimTail(st, removed) {
|
||||
while (st.order.length > st.size) {
|
||||
var i = st.order.length - 1;
|
||||
if (st.order[i] === st.activeStreamId) { i--; } // 跳过尾部活动流,裁次新一条
|
||||
if (i < 0) break; // 病态兜底:整窗都是活动流(不可能,仅一条流)
|
||||
removed.push(st.order.splice(i, 1)[0]);
|
||||
delete st.indexById[removed[removed.length - 1]];
|
||||
}
|
||||
}
|
||||
|
||||
/* 从较旧一端裁剪,直到长度 ≤ size;活动流永不裁剪。 */
|
||||
function trimHead(st, removed) {
|
||||
while (st.order.length > st.size) {
|
||||
var i = 0;
|
||||
if (st.order[i] === st.activeStreamId) { i = 1; }
|
||||
if (i >= st.order.length) break;
|
||||
removed.push(st.order.splice(i, 1)[0]);
|
||||
delete st.indexById[removed[removed.length - 1]];
|
||||
}
|
||||
}
|
||||
|
||||
function absorb(st, items) {
|
||||
var added = [];
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var d = items[i];
|
||||
if (!d || !d.id || st.order.indexOf(d.id) >= 0) continue;
|
||||
st.order.push(d.id);
|
||||
st.indexById[d.id] = (d.chainIndex === undefined || d.chainIndex < 0) ? -1 : d.chainIndex;
|
||||
added.push(d.id);
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
/* 初始窗口:最新 size 条(Python 负责截取)。
|
||||
* payload = {sessionId, generation, chainLen, items:[{id, chainIndex}]}
|
||||
* 用 Python 代次重同步(覆盖 clear() 的本地自增)。 */
|
||||
function initFullChain(st, payload) {
|
||||
st.sessionId = payload.sessionId;
|
||||
st.generation = num(payload.generation);
|
||||
st.order = [];
|
||||
st.indexById = {};
|
||||
st.pending = null;
|
||||
st.activeStreamId = null;
|
||||
st.followBottom = false;
|
||||
st.chainLen = num(payload.chainLen);
|
||||
absorb(st, payload.items || []);
|
||||
trimTail(st, []); // 防御:载荷超过 size 时保留最新端
|
||||
recompute(st);
|
||||
return { window: windowIds(st), hasMoreOlder: st.hasMoreOlder };
|
||||
}
|
||||
|
||||
/* 收到 Python 页载荷。
|
||||
* req = beginRequest 的返回值(或 {direction, boundaryId})
|
||||
* payload = {sessionId, generation, boundaryId, direction, chainLen,
|
||||
* items:[{id, chainIndex}]}
|
||||
* 返回 {stale:true} 或
|
||||
* {side, addedIds, removedIds, hiddenOlder, hiddenNewer,
|
||||
* hasMoreOlder, hasMoreNewer}
|
||||
* (DOM 层:addedIds 已渲染、加到 side 端;removedIds 从 DOM 移除;
|
||||
* 加载入口可见性按 hasMore* 更新) */
|
||||
function applyPage(st, req, payload) {
|
||||
if (!st || !req || !payload) return { stale: true };
|
||||
if (payload.sessionId !== st.sessionId ||
|
||||
payload.generation !== st.generation) {
|
||||
st.pending = null;
|
||||
return { stale: true };
|
||||
}
|
||||
if (!st.pending ||
|
||||
st.pending.direction !== req.direction ||
|
||||
st.pending.boundaryId !== req.boundaryId) {
|
||||
// 无匹配的未决请求(代次已推进/窗口已变/重复投递)→ 丢弃
|
||||
st.pending = null;
|
||||
return { stale: true };
|
||||
}
|
||||
st.pending = null;
|
||||
|
||||
var items = payload.items || [];
|
||||
var removedIds = [];
|
||||
var addedIds = absorb(st, items);
|
||||
if (req.direction === 'older') {
|
||||
// absorb 追加在尾部,这里把新页挪到头部(保持 旧→新 顺序)
|
||||
var head = st.order.splice(st.order.length - addedIds.length, addedIds.length);
|
||||
st.order = head.concat(st.order);
|
||||
trimTail(st, removedIds);
|
||||
} else {
|
||||
trimHead(st, removedIds);
|
||||
}
|
||||
st.chainLen = num(payload.chainLen);
|
||||
recompute(st);
|
||||
return {
|
||||
side: req.direction,
|
||||
addedIds: addedIds,
|
||||
removedIds: removedIds,
|
||||
hiddenOlder: st.hiddenOlder,
|
||||
hiddenNewer: st.hiddenNewer,
|
||||
hasMoreOlder: st.hasMoreOlder,
|
||||
hasMoreNewer: st.hasMoreNewer
|
||||
};
|
||||
}
|
||||
|
||||
/* 切会话 / 清屏 / 切分支:游标、缓存、未决请求、代次全部清空(本地自增使
|
||||
* 旧载荷失效);已注入的配置模式与大小保持不变。下一次 initFullChain
|
||||
* 用 Python 代次重同步。 */
|
||||
function clear(st) {
|
||||
st.generation += 1;
|
||||
st.sessionId = null;
|
||||
st.order = [];
|
||||
st.indexById = {};
|
||||
st.chainLen = 0;
|
||||
st.hiddenOlder = 0;
|
||||
st.hiddenNewer = 0;
|
||||
st.hasMoreOlder = false;
|
||||
st.hasMoreNewer = false;
|
||||
st.pending = null;
|
||||
st.activeStreamId = null;
|
||||
st.followBottom = false;
|
||||
return st;
|
||||
}
|
||||
|
||||
/* 活动流开始:该消息计入上限、永不被裁剪。 */
|
||||
function noteStream(st, msgId) {
|
||||
st.activeStreamId = msgId;
|
||||
st.followBottom = true;
|
||||
}
|
||||
|
||||
function streamFinished(st, msgId) {
|
||||
if (st.activeStreamId === msgId) {
|
||||
st.activeStreamId = null;
|
||||
st.followBottom = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* 新消息追加到窗口较新一端(发送用户消息 / 助手占位 / 切回续流)。
|
||||
* 超出 size 时从较旧一端裁剪(活动流除外),流式消息计入上限。 */
|
||||
function noteLive(st, msgId, chainIndex) {
|
||||
if (!st || st.sessionId === null || !msgId) return { added: false };
|
||||
if (st.order.indexOf(msgId) >= 0) {
|
||||
if (chainIndex !== undefined && chainIndex >= 0) st.indexById[msgId] = chainIndex;
|
||||
recompute(st);
|
||||
return { added: false };
|
||||
}
|
||||
st.order.push(msgId);
|
||||
st.indexById[msgId] = (chainIndex === undefined || chainIndex < 0) ? -1 : chainIndex;
|
||||
var removed = [];
|
||||
trimHead(st, removed);
|
||||
recompute(st);
|
||||
return { added: true, removedIds: removed };
|
||||
}
|
||||
|
||||
/* ---------- 锚点几何(纯数学;DOM 层传入测量值) ---------- */
|
||||
|
||||
/* 首个可见消息 + 像素偏移。
|
||||
* entries: [{id, top, height}](文档坐标,DOM 顺序 旧→新)
|
||||
* viewportTop/viewportBottom: 视口在文档坐标中的范围
|
||||
* 返回 {msgId, offset};无可见消息 → null */
|
||||
function computeAnchor(entries, viewportTop, viewportBottom) {
|
||||
for (var i = 0; i < entries.length; i++) {
|
||||
var e = entries[i];
|
||||
if (e.top + e.height > viewportTop && e.top < viewportBottom) {
|
||||
return { msgId: e.id, offset: Math.max(0, viewportTop - e.top) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* 换页后的滚动增量:把换页前记录的 anchor 文档 top(rectTopBefore)
|
||||
* 对齐到换页后实测的 top(rectTopAfter)。DOM 层用两次真实测量,
|
||||
* 误差只来自亚像素取整,≤ 2 px。 */
|
||||
function scrollDeltaFromRects(rectTopBefore, rectTopAfter) {
|
||||
return rectTopAfter - rectTopBefore;
|
||||
}
|
||||
|
||||
return {
|
||||
MIN_SIZE: MIN_SIZE,
|
||||
MAX_SIZE: MAX_SIZE,
|
||||
DEFAULT_SIZE: DEFAULT_SIZE,
|
||||
normalizeConfig: normalizeConfig,
|
||||
create: create,
|
||||
windowIds: windowIds,
|
||||
isFull: isFull,
|
||||
canRequest: canRequest,
|
||||
beginRequest: beginRequest,
|
||||
applyPage: applyPage,
|
||||
initFullChain: initFullChain,
|
||||
clear: clear,
|
||||
noteStream: noteStream,
|
||||
streamFinished: streamFinished,
|
||||
noteLive: noteLive,
|
||||
computeAnchor: computeAnchor,
|
||||
scrollDeltaFromRects: scrollDeltaFromRects
|
||||
};
|
||||
}));
|
||||
@@ -0,0 +1,932 @@
|
||||
/* ==================== 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;
|
||||
}
|
||||
|
||||
/* ==================== 🆕 P1-01 渲染窗口:加载入口按钮 ==================== */
|
||||
/* 上/下两端的「加载更多」入口:无边框浅灰胶囊,居中;无更多消息时由 JS 隐藏 */
|
||||
.load-window-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 220px;
|
||||
margin: 14px auto;
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
background: #f4f4f6;
|
||||
border: 1px solid #e3e3e8;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.load-window-btn:hover {
|
||||
background: #eaeaf0;
|
||||
color: #333;
|
||||
}
|
||||
.load-window-btn[hidden] {
|
||||
display: none;
|
||||
}
|
||||
/* 底部入口与 scroll-anchor 的间距(scroll-anchor 自带 150px margin) */
|
||||
#load-newer {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
Reference in New Issue
Block a user