Add render_window.js: Python-driven incremental pagination with id+chainIndex state, anchor restore, batch guard and streaming auto-detect; wired through chat_bridge and the page shell.
231 lines
11 KiB
Python
231 lines
11 KiB
Python
# 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)
|