From b4bc46e7f13c402a2416262e88f1a0795eb282f9 Mon Sep 17 00:00:00 2001 From: hongwei Date: Mon, 18 May 2026 20:03:26 +0800 Subject: [PATCH 1/4] =?UTF-8?q?[0204]=20=E5=A2=9E=E5=8A=A0session=20manage?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TeXmacs/progs/dynamic/chat-adapter.scm | 64 ++- TeXmacs/progs/dynamic/chat-tab-session.scm | 470 ++++++++------------- src/Plugins/Qt/qt_chat_tab_widget.cpp | 391 ++++++++++++++--- src/Plugins/Qt/qt_chat_tab_widget.hpp | 111 ++++- src/Scheme/L5/glue_widget.lua | 9 + 5 files changed, 673 insertions(+), 372 deletions(-) diff --git a/TeXmacs/progs/dynamic/chat-adapter.scm b/TeXmacs/progs/dynamic/chat-adapter.scm index 3757c10fcd..7f28017814 100644 --- a/TeXmacs/progs/dynamic/chat-adapter.scm +++ b/TeXmacs/progs/dynamic/chat-adapter.scm @@ -35,23 +35,7 @@ ;; ---- ;; # ;; 无显式返回值。 -;; -;; 逻辑 -;; ---- -;; 1. 检查是否提供了 model-opt -;; - 若提供,调用 chat-tab-session-select-model 设置模型 -;; 2. 检查聊天标签页缓冲区是否已存在 -;; - 若存在,直接切换到该缓冲区 -;; - 若不存在,创建新缓冲区并初始化: -;; a. 设置缓冲区内容为空文档 -;; b. 设置缓冲区标题为 "Chat" -;; c. 切换到该缓冲区 -;; d. 标记缓冲区为已保存状态 -;; -;; 注意 -;; ---- -;; 此函数是 LLM 聊天功能的入口,负责聊天标签页的创建和切换。 -;; 聊天标签页使用固定的 tmfs://chat-tab URL。 + (tm-define (open-llm-chat-tab . model-opt) (:synopsis "Open or switch to the LLM Chat tab") (when (nnull? model-opt) @@ -73,18 +57,12 @@ ;; ;; 语法 ;; ---- -;; (chat-tab-send message-buffer input-buffer body) +;; (chat-tab-send session-id) ;; ;; 参数 ;; ---- -;; message-buffer : url -;; 消息缓冲区名称(URL),用于存储对话历史。 -;; -;; input-buffer : url -;; 输入缓冲区名称(URL),用户在此输入消息。 -;; -;; body : tree -;; 输入消息树,即用户要发送的内容。 +;; session-id : string +;; 会话 UUID。 ;; ;; 返回值 ;; ---- @@ -92,19 +70,31 @@ ;; 委托 chat-tab-session-send 的返回值: ;; - #t : 消息发送成功 ;; - #f : 消息为空,未发送 + +(tm-define (chat-tab-send session-id) + (:synopsis "Adapter send entry for a chat tab") + (:argument session-id "Session UUID") + (chat-tab-session-send session-id) +) ;tm-define + +;; chat-tab-cancel +;; 聊天标签的适配器取消入口。 ;; -;; 逻辑 +;; 语法 ;; ---- -;; 1. 直接调用 chat-tab-session-send,将参数原样传递 +;; (chat-tab-cancel session-id) ;; -;; 注意 +;; 参数 ;; ---- -;; 此函数是 chat-tab-session.scm 的薄包装层,用于解耦适配层与会话引擎。 -;; 所有实际发送逻辑由 chat-tab-session-send 处理。 -(tm-define (chat-tab-send message-buffer input-buffer body) - (:synopsis "Adapter send entry for a chat tab") - (:argument message-buffer "Message buffer name") - (:argument input-buffer "Input buffer name") - (:argument body "Input message tree") - (chat-tab-session-send message-buffer input-buffer body) +;; session-id : string +;; 会话 UUID。 + +(tm-define (chat-tab-cancel session-id) + (:synopsis "Adapter cancel entry for a chat tab") + (:argument session-id "Session UUID") + (let* ((st (chat-tab-get-state session-id)) + (model (chat-tab-state-model st)) + (plugin-ses (string-append model ":chat-tab:" session-id))) + (plugin-cancel chat-tab-session-name plugin-ses #f) + ) ;let* ) ;tm-define diff --git a/TeXmacs/progs/dynamic/chat-tab-session.scm b/TeXmacs/progs/dynamic/chat-tab-session.scm index d88568c837..bb57f61cef 100644 --- a/TeXmacs/progs/dynamic/chat-tab-session.scm +++ b/TeXmacs/progs/dynamic/chat-tab-session.scm @@ -24,34 +24,42 @@ (define chat-tab-current-model "default") -(define chat-tab-session-serial 0) - (define chat-tab-session-states (make-ahash-table)) -(define (chat-tab-state input-buffer model session-id) - (list input-buffer model session-id) +;;; ---------- Buffer URL 推导函数 ---------- + +(define (chat-tab-session->message-buffer session-id) + (string->url (string-append "tmfs://chat-message-" session-id)) ) ;define -(define (chat-tab-state-input-buffer st) - (list-ref st 0) +(define (chat-tab-session->input-buffer session-id) + (string->url (string-append "tmfs://chat-input-" session-id)) +) ;define + +;;; ---------- State 构造器和访问器 ---------- + +(define (chat-tab-state model) + (list model) ) ;define (define (chat-tab-state-model st) - (list-ref st 1) + (list-ref st 0) ) ;define -(define (chat-tab-state-session-id st) - (list-ref st 2) +(define (chat-tab-state->plugin-session-id st session-id) + (string-append (chat-tab-state-model st) ":chat-tab:" session-id) ) ;define -(define (chat-tab-set-state! message-buffer st) - (ahash-set! chat-tab-session-states message-buffer st) +(define (chat-tab-set-state! session-id st) + (ahash-set! chat-tab-session-states session-id st) ) ;define -(define (chat-tab-get-state message-buffer) - (ahash-ref chat-tab-session-states message-buffer) +(define (chat-tab-get-state session-id) + (ahash-ref chat-tab-session-states session-id) ) ;define +;;; ---------- 文档处理工具 ---------- + (define (chat-tab-normalize-document body) (cond ((tree? body) (if (tree-is? body 'document) @@ -177,60 +185,40 @@ ) ;with-buffer ) ;define -(define (chat-tab-next-session-id model) - (set! chat-tab-session-serial (+ chat-tab-session-serial 1)) - (string-append model - ":chat-tab:" - (number->string (texmacs-time)) - "-" - (number->string chat-tab-session-serial) - ) ;string-append -) ;define +;;; ---------- 会话管理 ---------- -(define (chat-tab-ensure-session! message-buffer input-buffer) - (let ((st (chat-tab-get-state message-buffer))) +(define (chat-tab-ensure-session! session-id) + (let ((st (chat-tab-get-state session-id))) (if st - (if (== (chat-tab-state-input-buffer st) input-buffer) - st - (let ((updated (chat-tab-state input-buffer - (chat-tab-state-model st) - (chat-tab-state-session-id st) - ) ;chat-tab-state - ) ;updated - ) ; - (chat-tab-set-state! message-buffer updated) - updated - ) ;let - ) ;if + st (let* ((model (or chat-tab-current-model "default")) - (ses (chat-tab-next-session-id model)) - (new (chat-tab-state input-buffer model ses)) + (plugin-ses (string-append model ":chat-tab:" session-id)) + (new (chat-tab-state model)) ) ; - (session-enable-text-input chat-tab-session-name ses) - (chat-tab-set-state! message-buffer new) + (session-enable-text-input chat-tab-session-name plugin-ses) + (chat-tab-set-state! session-id new) new ) ;let* ) ;if ) ;let ) ;define +;;; ---------- 编码/解码 ---------- + ;; chat-tab-session-encode ;; 将聊天标签会话的上下文编码为一个列表,用于传递给 plugin-feed。 ;; ;; 语法 ;; ---- -;; (chat-tab-session-encode input message-buffer input-buffer out opts) +;; (chat-tab-session-encode input session-id out opts) ;; ;; 参数 ;; ---- ;; input : tree ;; 规范化后的输入消息树。 ;; -;; message-buffer : url -;; 消息缓冲区名称。 -;; -;; input-buffer : url -;; 输入缓冲区名称。 +;; session-id : string +;; 会话 UUID,从中可推导出 message-buffer 和 input-buffer。 ;; ;; out : tree ;; 输出节点树。 @@ -242,24 +230,18 @@ ;; ---- ;; list ;; 编码后的列表,结构为: -;; ((do notify next cancel) input message-buffer input-buffer tree-pointer opts) +;; ((do notify next cancel) input session-id tree-pointer opts) ;; 其中第一个元素是四个回调函数的列表,out 被转换为 tree-pointer 以避免 ;; 在异步执行期间被垃圾回收。 -;; -;; 注意 -;; ---- -;; 此函数与 chat-tab-session-decode 成对使用,负责在任务进入 pending -;; 队列前将上下文打包。 -(define (chat-tab-session-encode input message-buffer input-buffer out opts) +(define (chat-tab-session-encode input session-id out opts) (list (list chat-tab-session-do chat-tab-session-notify chat-tab-session-next chat-tab-session-cancel ) ;list input - message-buffer - input-buffer + session-id (tree->tree-pointer out) opts ) ;list @@ -281,22 +263,19 @@ ;; ---- ;; list ;; 解码后的上下文列表: -;; (input message-buffer input-buffer out opts) +;; (input session-id out opts) ;; 其中 out 由 tree-pointer 还原为 tree。 -;; -;; 注意 -;; ---- -;; 返回值通常通过 with 绑定解构为多个变量使用。 -;; 此函数与 chat-tab-session-encode 成对使用。 (define (chat-tab-session-decode l) - (list (second l) (third l) (fourth l) (tree-pointer->tree (fifth l)) (sixth l)) + (list (second l) (third l) (tree-pointer->tree (fourth l)) (fifth l)) ) ;define (define (chat-tab-session-detach l) - (tree-pointer-detach (fifth l)) + (tree-pointer-detach (fourth l)) ) ;define +;;; ---------- 回调函数 ---------- + ;; chat-tab-session-do ;; 聊天标签会话的任务开始回调。 ;; 解码 pending 队列首元素,并将输入消息写入插件会话。 @@ -312,30 +291,12 @@ ;; ;; ses : string ;; 会话标识字符串。 -;; -;; 返回值 -;; ---- -;; # -;; 无显式返回值。 -;; -;; 逻辑 -;; ---- -;; 1. 获取当前 (lan ses) 的待处理队列 l。 -;; 2. 若队列非空: -;; a. 解码首元素,获取 input、message-buffer 等上下文。 -;; b. 若 input 为空树,调用 plugin-next 跳过当前任务。 -;; c. 否则,调用 plugin-write 将 input 以 :session 模式写入插件。 -;; -;; 注意 -;; ---- -;; 此函数作为 do 回调传递给 plugin-feed,由 plugin-do 在任务开始执行时调用。 -;; 当输入为空时直接跳过,避免向插件发送无意义请求。 (define (chat-tab-session-do lan ses) (with l (pending-ref lan ses) (when (nnull? l) - (with (input message-buffer input-buffer out opts) + (with (input session-id out opts) (chat-tab-session-decode (car l)) (if (tree-empty? input) (plugin-next lan ses) @@ -348,7 +309,8 @@ ;; chat-tab-session-next ;; 聊天标签会话的任务完成回调。 -;; 清理输出区域的 script-busy 标记,并分离当前任务的 tree-pointer。 +;; 清理输出区域的 script-busy 标记,分离当前任务的 tree-pointer, +;; 并通知 C++ 生成结束。 ;; ;; 语法 ;; ---- @@ -361,43 +323,27 @@ ;; ;; ses : string ;; 会话标识字符串。 -;; -;; 返回值 -;; ---- -;; # -;; 无显式返回值。 -;; -;; 逻辑 -;; ---- -;; 1. 获取当前 (lan ses) 的待处理队列 l。 -;; 2. 若队列非空: -;; a. 解码首元素,获取 message-buffer、out 等上下文。 -;; b. 在 message-buffer 中检查 out 的最后一个节点是否为 script-busy, -;; 若是则移除该节点。 -;; c. 标记 message-buffer 为已保存。 -;; d. 分离当前任务的 tree-pointer。 -;; -;; 注意 -;; ---- -;; 此函数作为 next 回调传递给 plugin-feed,由 plugin-next 在任务完成后调用。 -;; 主要职责是结束输出区域的忙等状态并释放 tree-pointer 资源。 (define (chat-tab-session-next lan ses) (with l (pending-ref lan ses) (when (nnull? l) - (with (input message-buffer input-buffer out opts) + (with (input session-id out opts) (chat-tab-session-decode (car l)) - (with-buffer message-buffer - (when (and (tm-func? out 'document) - (> (tree-arity out) 0) - (tm-func? (tree-ref out :last) 'script-busy) - ) ;and - (tree-remove! out (- (tree-arity out) 1) 1) - ) ;when - (buffer-pretend-saved message-buffer) - ) ;with-buffer + (let ((msg-buf (chat-tab-session->message-buffer session-id))) + (with-buffer msg-buf + (when (and (tm-func? out 'document) + (> (tree-arity out) 0) + (tm-func? (tree-ref out :last) 'script-busy) + ) ;and + (tree-remove! out (- (tree-arity out) 1) 1) + ) ;when + (buffer-pretend-saved msg-buf) + ) ;with-buffer + ) ;let (chat-tab-session-detach (car l)) + ;; 通知 C++ 生成结束 + (exec-delayed (lambda () (qt-chat-tab-set-state session-id "idle"))) ) ;with ) ;when ) ;with @@ -428,51 +374,33 @@ ;; ;; t : tree ;; 通知携带的数据树。 -;; -;; 返回值 -;; ---- -;; # -;; 无显式返回值。 -;; -;; 逻辑 -;; ---- -;; 1. 获取当前 (lan ses) 的待处理队列 l。 -;; 2. 若队列非空,解码首元素获取上下文。 -;; 3. 根据通道 ch 分派处理: -;; - "output" : 调用 chat-tab-output 将 t 追加到输出区域。 -;; - "error" : 调用 chat-tab-errput 将 t 追加到错误输出区域。 -;; - "prompt" : 无操作。 -;; - "input" : 若队列中仅剩当前任务,将 t 设置到输入缓冲区。 -;; 4. 每次写入后标记 message-buffer 为已保存。 -;; -;; 注意 -;; ---- -;; 此函数作为 notify 回调传递给 plugin-feed,由插件连接层异步调用。 -;; "input" 通道仅在队列为单任务时更新输入缓冲区,避免多任务竞争。 (define (chat-tab-session-notify lan ses ch t) (with l (pending-ref lan ses) (when (nnull? l) - (with (input message-buffer input-buffer out opts) + (with (input session-id out opts) (chat-tab-session-decode (car l)) - (cond ((== ch "output") - (with-buffer message-buffer - (chat-tab-output out t) - (buffer-pretend-saved message-buffer) - ) ;with-buffer - ) ; - ((== ch "error") - (with-buffer message-buffer - (chat-tab-errput out t) - (buffer-pretend-saved message-buffer) - ) ;with-buffer - ) ; - ((== ch "prompt") (noop)) - ((and (== ch "input") (null? (cdr l))) - (chat-tab-set-input-body! input-buffer t) - ) ; - ) ;cond + (let ((msg-buf (chat-tab-session->message-buffer session-id)) + (in-buf (chat-tab-session->input-buffer session-id))) + (cond ((== ch "output") + (with-buffer msg-buf + (chat-tab-output out t) + (buffer-pretend-saved msg-buf) + ) ;with-buffer + ) ; + ((== ch "error") + (with-buffer msg-buf + (chat-tab-errput out t) + (buffer-pretend-saved msg-buf) + ) ;with-buffer + ) ; + ((== ch "prompt") (noop)) + ((and (== ch "input") (null? (cdr l))) + (chat-tab-set-input-body! in-buf t) + ) ; + ) ;cond + ) ;let ) ;with ) ;when ) ;with @@ -481,7 +409,7 @@ ;; chat-tab-session-cancel ;; 聊天标签会话的任务取消回调。 ;; 将输出区域的 script-busy 标记替换为 script-dead 或 script-interrupted, -;; 并分离当前任务的 tree-pointer。 +;; 分离当前任务的 tree-pointer,并通知 C++ 生成结束。 ;; ;; 语法 ;; ---- @@ -499,56 +427,42 @@ ;; 取消原因标志: ;; - #t : 插件进程已死亡,替换为 script-dead。 ;; - #f : 任务被用户中断,替换为 script-interrupted。 -;; -;; 返回值 -;; ---- -;; # -;; 无显式返回值。 -;; -;; 逻辑 -;; ---- -;; 1. 获取当前 (lan ses) 的待处理队列 l。 -;; 2. 若队列非空: -;; a. 解码首元素,获取 message-buffer、out 等上下文。 -;; b. 在 message-buffer 中检查 out 的最后一个节点是否为 script-busy, -;; 若是则根据 dead? 替换为 script-dead 或 script-interrupted。 -;; c. 标记 message-buffer 为已保存。 -;; d. 分离当前任务的 tree-pointer。 -;; -;; 注意 -;; ---- -;; 此函数作为 cancel 回调传递给 plugin-feed,由 plugin-cancel 在任务被取消 -;; 或插件进程异常终止时调用。 (define (chat-tab-session-cancel lan ses dead?) (with l (pending-ref lan ses) (when (nnull? l) - (with (input message-buffer input-buffer out opts) + (with (input session-id out opts) (chat-tab-session-decode (car l)) - (with-buffer message-buffer - (when (and (tm-func? out 'document) - (> (tree-arity out) 0) - (tm-func? (tree-ref out :last) 'script-busy) - ) ;and - (tree-assign (tree-ref out :last) - (if dead? '(script-dead) '(script-interrupted)) - ) ;tree-assign - ) ;when - (buffer-pretend-saved message-buffer) - ) ;with-buffer + (let ((msg-buf (chat-tab-session->message-buffer session-id))) + (with-buffer msg-buf + (when (and (tm-func? out 'document) + (> (tree-arity out) 0) + (tm-func? (tree-ref out :last) 'script-busy) + ) ;and + (tree-assign (tree-ref out :last) + (if dead? '(script-dead) '(script-interrupted)) + ) ;tree-assign + ) ;when + (buffer-pretend-saved msg-buf) + ) ;with-buffer + ) ;let (chat-tab-session-detach (car l)) + ;; 通知 C++ 生成结束 + (exec-delayed (lambda () (qt-chat-tab-set-state session-id "idle"))) ) ;with ) ;when ) ;with ) ;define +;;; ---------- Feed ---------- + ;; chat-tab-session-feed ;; 将用户输入投递到插件会话的待处理队列,并标记输出区域为忙等状态。 ;; ;; 语法 ;; ---- -;; (chat-tab-session-feed lan ses input message-buffer input-buffer out opts) +;; (chat-tab-session-feed lan ses input session-id out opts) ;; ;; 参数 ;; ---- @@ -561,47 +475,28 @@ ;; input : tree ;; 规范化后的输入消息树。 ;; -;; message-buffer : url -;; 消息缓冲区名称。 -;; -;; input-buffer : url -;; 输入缓冲区名称。 +;; session-id : string +;; 会话 UUID。 ;; ;; out : tree ;; 输出节点树,将被重置为 (document (script-busy))。 ;; ;; opts : list ;; 附加选项列表。 -;; -;; 返回值 -;; ---- -;; # -;; 无显式返回值。 -;; -;; 逻辑 -;; ---- -;; 1. 调用 plugin-preprocess 对 input 进行预处理。 -;; 2. 在 message-buffer 中将 out 重置为 (document (script-busy)),表示开始忙等。 -;; 3. 调用 chat-tab-session-encode 编码上下文为 x。 -;; - (car x) 为回调列表 (do notify next cancel)。 -;; - (cdr x) 为附加参数列表 (input message-buffer input-buffer tree-pointer opts)。 -;; 4. 通过 apply 调用 plugin-feed,将任务加入 pending 队列。 -;; 若队列为空则立即开始执行。 -;; -;; 注意 -;; ---- -;; 此函数是聊天标签会话向底层插件引擎提交任务的唯一入口。 -;; out 被重置为 script-busy 后,用户界面会显示忙等指示,直到任务完成。 -(define (chat-tab-session-feed lan ses input message-buffer input-buffer out opts) +(define (chat-tab-session-feed lan ses input session-id out opts) (set! input (plugin-preprocess lan ses input opts)) - (with-buffer message-buffer (tree-assign! out '(document (script-busy)))) + (with-buffer (chat-tab-session->message-buffer session-id) + (tree-assign! out '(document (script-busy))) + ) ;with-buffer (with x - (chat-tab-session-encode input message-buffer input-buffer out opts) + (chat-tab-session-encode input session-id out opts) (apply plugin-feed `(,lan ,ses ,@(car x) ,(cdr x))) ) ;with ) ;define +;;; ---------- 模型选择 ---------- + ;; chat-tab-session-select-model ;; 选择用于新聊天标签会话的模型。 ;; @@ -617,17 +512,8 @@ ;; 返回值 ;; ---- ;; string -;; 当前选中的模型名称(全局变量 chat-tab-current-model)。 -;; -;; 逻辑 -;; ---- -;; 1. 检查 model 非空 -;; - 若非空,将 chat-tab-current-model 设置为该模型 -;; 2. 返回 chat-tab-current-model -;; -;; 注意 -;; ---- -;; 此函数仅影响后续新建的会话,不会更改已有会话的模型。 +;; 当前选中的模型名称。 + (tm-define (chat-tab-session-select-model model) (:synopsis "Select the model used for new chat tab sessions") (:argument model "Model") @@ -637,23 +523,19 @@ chat-tab-current-model ) ;tm-define +;;; ---------- 发送 ---------- + ;; chat-tab-session-send ;; 通过聊天标签会话发送用户消息。 ;; ;; 语法 ;; ---- -;; (chat-tab-session-send message-buffer input-buffer body) +;; (chat-tab-session-send session-id) ;; ;; 参数 ;; ---- -;; message-buffer : url -;; 消息缓冲区名称(URL),用于存储对话历史。 -;; -;; input-buffer : url -;; 输入缓冲区名称(URL),用户在此输入消息。 -;; -;; body : tree -;; 输入消息树,即用户要发送的内容。 +;; session-id : string +;; 会话 UUID,C++ 侧传入。 ;; ;; 返回值 ;; ---- @@ -663,59 +545,77 @@ ;; ;; 逻辑 ;; ---- -;; 1. 检查 body 是否为空 -;; - 若为空,返回 #f -;; 2. 规范化输入文档 +;; 1. 从 input buffer 读取 body +;; 2. 检查 body 是否为空 ;; 3. 确保会话存在(chat-tab-ensure-session!) -;; 4. 在消息缓冲区追加一轮对话(chat-tab-append-round!) -;; - 若追加失败,返回 #f +;; 4. 在消息缓冲区追加一轮对话 ;; 5. 清空输入缓冲区 -;; 6. 检查是否已定义 chat-tab-session-name 连接 -;; - 若未定义,直接将输入回显到输出区域 -;; - 若已定义,通过 plugin 机制发送消息(chat-tab-session-feed) -;; 7. 返回 #t +;; 6. 通过 plugin 机制发送消息 + +(tm-define (chat-tab-session-send session-id) + (:synopsis "Send user message through chat tab session") + (:argument session-id "Session UUID") + (let* ((in-buf (chat-tab-session->input-buffer session-id)) + (body (buffer-get-body in-buf))) + (if (chat-tab-empty-body? body) + #f + (let* ((input (chat-tab-normalize-document body)) + (msg-buf (chat-tab-session->message-buffer session-id)) + (st (chat-tab-ensure-session! session-id)) + (plugin-ses (chat-tab-state->plugin-session-id st session-id)) + (out (chat-tab-append-round! msg-buf input)) + ) ; + (if (not out) + #f + (begin + (chat-tab-clear-input! in-buf) + (if (not (connection-defined? chat-tab-session-name)) + (begin + (with-buffer msg-buf + (chat-tab-output out input) + (buffer-pretend-saved msg-buf) + ) ;with-buffer + #t + ) ;begin + (begin + (chat-tab-session-feed chat-tab-session-name + plugin-ses + input + session-id + out + '() + ) ;chat-tab-session-feed + #t + ) ;begin + ) ;if + ) ;begin + ) ;if + ) ;let* + ) ;if + ) ;let* +) ;tm-define + +;;; ---------- 通知 C++ ---------- + +;; chat-tab-notify-state +;; 通知 C++ 侧会话生成状态变更。 ;; -;; 注意 +;; 语法 ;; ---- -;; 此函数是聊天标签会话的核心发送入口,负责消息格式化、会话管理和插件交互。 -(tm-define (chat-tab-session-send message-buffer input-buffer body) - (:synopsis "Send user message through chat tab session") - (:argument message-buffer "Message buffer name") - (:argument input-buffer "Input buffer name") - (:argument body "Input message tree") - (if (chat-tab-empty-body? body) - #f - (let* ((input (chat-tab-normalize-document body)) - (st (chat-tab-ensure-session! message-buffer input-buffer)) - (ses (chat-tab-state-session-id st)) - (out (chat-tab-append-round! message-buffer input)) - ) ; - (if (not out) - #f - (begin - (chat-tab-clear-input! input-buffer) - (if (not (connection-defined? chat-tab-session-name)) - (begin - (with-buffer message-buffer - (chat-tab-output out input) - (buffer-pretend-saved message-buffer) - ) ;with-buffer - #t - ) ;begin - (begin - (chat-tab-session-feed chat-tab-session-name - ses - input - message-buffer - input-buffer - out - '() - ) ;chat-tab-session-feed - #t - ) ;begin - ) ;if - ) ;begin - ) ;if - ) ;let* - ) ;if +;; (chat-tab-notify-state session-id state) +;; +;; 参数 +;; ---- +;; session-id : string +;; 会话 UUID。 +;; +;; state : string +;; 新状态:"idle" 或 "generating"。 + +(tm-define (chat-tab-notify-state session-id state) + (:synopsis "Notify C++ that session generation state changed") + (:argument session-id "Session UUID") + (:argument state "New state: idle or generating") + (exec-delayed + (lambda () (qt-chat-tab-set-state session-id state))) ) ;tm-define diff --git a/src/Plugins/Qt/qt_chat_tab_widget.cpp b/src/Plugins/Qt/qt_chat_tab_widget.cpp index 2453f52b4e..05805d5c1b 100644 --- a/src/Plugins/Qt/qt_chat_tab_widget.cpp +++ b/src/Plugins/Qt/qt_chat_tab_widget.cpp @@ -21,14 +21,19 @@ #include "s7_tm.hpp" #include "tm_window.hpp" +#include #include #include +#include #include #include +#include #include #include +#include #include +#include #include #include #include @@ -44,32 +49,23 @@ using namespace moebius; namespace { /** - * @brief 生成下一个聊天 buffer 的唯一 ID。 - * @return 递增后的 ID。 - */ -int -next_chat_input_buffer_id () { - static int s_nextId= 0; - return ++s_nextId; -} - -/** - * @brief 创建唯一的输入 buffer URL。 - * @return 格式为 tmfs://chat-input- 的 URL。 - */ -url -make_chat_input_buffer_name () { - return url ("tmfs://chat-input-" * as_string (next_chat_input_buffer_id ())); -} - -/** - * @brief 创建唯一的消息 buffer URL。 - * @return 格式为 tmfs://chat-message- 的 URL。 + * @brief 从文档树中提取纯文本标题。 + * @param body TeXmacs 文档树。 + * @param maxLen 标题最大字符数。 + * @return 提取的标题字符串。 */ -url -make_chat_message_buffer_name () { - return url ("tmfs://chat-message-" * - as_string (next_chat_input_buffer_id ())); +string +extract_title (tree body, int maxLen) { + string result; + if (is_func (body, DOCUMENT)) { + for (int i= 0; i < N (body) && N (result) < maxLen; ++i) { + if (is_atomic (body[i])) result << body[i]->label; + } + } + if (N (result) > maxLen) { + result= result (0, maxLen) * "..."; + } + return result; } /** @@ -183,15 +179,13 @@ struct QTChatTabWidget::ChatConversationPanel { QLabel* welcomeTitle = nullptr; ///< 欢迎标题标签。 QWidget* messageFrame = nullptr; ///< 承载消息控件的边框。 QWidget* inputEditorWidget= nullptr; ///< 输入编辑器的 Qt 控件。 - QPushButton* sendButton = nullptr; ///< 发送按钮。 + QPushButton* sendButton = nullptr; ///< 发送/停止按钮。 QPushButton* sidebarButton = nullptr; ///< 侧边栏入口按钮。 QSpacerItem* topSpacer = nullptr; ///< 顶部占位,用于垂直偏移。 widget messageWidget; ///< 消息显示的 TeXmacs 控件。 widget inputWidget; ///< 用户输入的 TeXmacs 控件。 - url messageBufferName; ///< 消息历史 buffer 的 URL。 - url inputBufferName; ///< 输入编辑器 buffer 的 URL。 + string sessionId; ///< 会话 UUID(跨层交互标识)。 bool conversationMode= false; ///< 面板是否已离开欢迎态。 - QString title; ///< 会话的显示标题。 }; /** @@ -202,13 +196,14 @@ struct QTChatTabWidget::ChatConversationPanel { QTChatTabWidget::QTChatTabWidget (QWidget* parent) : QWidget (parent), sidebarWidget_ (nullptr), contentWidget_ (nullptr), conversationCountLabel_ (nullptr), conversationListWidget_ (nullptr), - conversationListLayout_ (nullptr), newChatButton_ (nullptr), + conversationListLayout_ (nullptr), archiveHeaderButton_ (nullptr), + archiveListWidget_ (nullptr), archiveListLayout_ (nullptr), + archiveCollapsed_ (true), newChatButton_ (nullptr), collapseButton_ (nullptr), sidebarNormalContent_ (nullptr), sidebarCollapsedBar_ (nullptr), conversationStack_ (nullptr), - activeConversation_ (nullptr), nextConversationTitleId_ (1), - sidebarCollapsed_ (false), sidebarExpandedWidth_ (0), - chatMenuToolBar_ (nullptr), chatModeToolBar_ (nullptr), - chatFocusToolBar_ (nullptr) { + activeConversation_ (nullptr), sidebarCollapsed_ (false), + sidebarExpandedWidth_ (0), chatMenuToolBar_ (nullptr), + chatModeToolBar_ (nullptr), chatFocusToolBar_ (nullptr) { setFocusPolicy (Qt::StrongFocus); QHBoxLayout* mainLayout= new QHBoxLayout (this); @@ -296,6 +291,36 @@ QTChatTabWidget::setup_left_sidebar (QVBoxLayout* sidebarLayout) { conversationListLayout_->setSpacing (DpiUtils::scaled (kSidebarSpacing)); normalLayout->addWidget (conversationListWidget_); + // 归档区标题按钮:点击展开/折叠归档列表 + archiveHeaderButton_= new QPushButton ("Archived (0)", normalContent); + archiveHeaderButton_->setObjectName ("chat-tab-archive-header"); + archiveHeaderButton_->setFocusPolicy (Qt::NoFocus); + archiveHeaderButton_->setCursor (Qt::PointingHandCursor); + DpiUtils::applyScaledFont (archiveHeaderButton_, kNavTitleFontPx); + archiveHeaderButton_->setStyleSheet ( + QString ("QPushButton { text-align: left; border: none; " + "padding: %1px %2px; color: #666666; background: transparent; " + "font-weight: bold; } " + "QPushButton:hover { color: #333333; }") + .arg (DpiUtils::scaled (kNavTitlePadding)) + .arg (DpiUtils::scaled (kNavButtonPadX))); + connect (archiveHeaderButton_, &QPushButton::clicked, this, + [this] () { + archiveCollapsed_= !archiveCollapsed_; + if (archiveListWidget_) + archiveListWidget_->setVisible (!archiveCollapsed_); + }); + normalLayout->addWidget (archiveHeaderButton_); + + // 归档会话列表 + archiveListWidget_= new QWidget (normalContent); + archiveListWidget_->setObjectName ("chat-tab-archive-list"); + archiveListLayout_= new QVBoxLayout (archiveListWidget_); + archiveListLayout_->setContentsMargins (0, 0, 0, 0); + archiveListLayout_->setSpacing (DpiUtils::scaled (kSidebarSpacing)); + archiveListWidget_->hide (); // 默认折叠 + normalLayout->addWidget (archiveListWidget_); + normalLayout->addStretch (); // 底部收缩按钮 @@ -396,10 +421,13 @@ QTChatTabWidget::ChatConversationPanel* QTChatTabWidget::create_conversation (const QString& title) { if (!conversationStack_ || !conversationListLayout_) return nullptr; + string sessionId= sessionManager_.createSession (); ChatConversationPanel* panel= new ChatConversationPanel (); - panel->title = title; - panel->messageBufferName = make_chat_message_buffer_name (); - panel->inputBufferName = make_chat_input_buffer_name (); + panel->sessionId = sessionId; + sessionManager_.setPanel (sessionId, panel); + + url msgBufUrl= ChatSessionManager::messageBufferUrl (sessionId); + url inBufUrl = ChatSessionManager::inputBufferUrl (sessionId); QWidget* page= new QWidget (conversationStack_); page->setObjectName ("chat-tab-conversation-page"); @@ -428,7 +456,7 @@ QTChatTabWidget::create_conversation (const QString& title) { panel->messageWidget= texmacs_input_widget (tree (DOCUMENT, ""), make_chat_embedded_style (), - panel->messageBufferName); + msgBufUrl); QWidget* messageQWidget= concrete (panel->messageWidget)->as_qwidget (); panel->messageFrame = new QWidget (topPanel); panel->messageFrame->setObjectName ("chat-tab-message-frame"); @@ -449,7 +477,7 @@ QTChatTabWidget::create_conversation (const QString& title) { topLayout->addWidget (panel->messageFrame, 1); panel->inputWidget= texmacs_input_widget ( - tree (DOCUMENT, ""), make_chat_embedded_style (), panel->inputBufferName); + tree (DOCUMENT, ""), make_chat_embedded_style (), inBufUrl); QWidget* inputQWidget = concrete (panel->inputWidget)->as_qwidget (); panel->inputEditorWidget= inputQWidget; disable_scrollbars_recursively (inputQWidget); @@ -494,7 +522,7 @@ QTChatTabWidget::create_conversation (const QString& title) { contentLayout->addWidget (topPanel, 1, Qt::AlignHCenter | Qt::AlignTop); conversationStack_->addWidget (page); - panel->sidebarButton= new QPushButton (title, conversationListWidget_); + panel->sidebarButton= new QPushButton ("新会话", conversationListWidget_); panel->sidebarButton->setObjectName ("chat-tab-conversation-btn"); panel->sidebarButton->setCheckable (true); panel->sidebarButton->setFocusPolicy (Qt::NoFocus); @@ -521,8 +549,7 @@ QTChatTabWidget::create_conversation (const QString& title) { */ void QTChatTabWidget::create_new_conversation () { - QString title= QString ("Chat %1").arg (nextConversationTitleId_++); - ChatConversationPanel* panel= create_conversation (title); + ChatConversationPanel* panel= create_conversation (""); if (!panel) return; conversations_.append (panel); activate_conversation (panel); @@ -546,14 +573,135 @@ QTChatTabWidget::activate_conversation (ChatConversationPanel* panel) { */ void QTChatTabWidget::refresh_sidebar () { + // 统计活跃和归档会话数 + int activeCount= 0; + int archivedCount= 0; + for (ChatConversationPanel* panel : conversations_) { + if (!panel) continue; + ChatSession* session= sessionManager_.getSession (panel->sessionId); + if (session && session->archived) ++archivedCount; + else ++activeCount; + } if (conversationCountLabel_) { conversationCountLabel_->setText ( - QString ("Conversations (%1)").arg (conversations_.size ())); + QString ("Conversations (%1)").arg (activeCount)); + } + + // 更新归档区标题 + if (archiveHeaderButton_) { + archiveHeaderButton_->setText ( + QString ("Archived (%1)").arg (archivedCount)); + archiveHeaderButton_->setVisible (true); + } + + // 标题去重:统计每个 title 出现次数 + QMap titleCounts; + for (ChatConversationPanel* panel : conversations_) { + if (!panel) continue; + ChatSession* session= sessionManager_.getSession (panel->sessionId); + if (!session || is_empty (session->title)) continue; + QString t= to_qstring (session->title); + titleCounts[t]++; + } + + // 先将归档按钮从旧父控件移除,以便重新分配 + for (ChatConversationPanel* panel : conversations_) { + if (!panel || !panel->sidebarButton) continue; + panel->sidebarButton->setParent (nullptr); + } + + // 清空两个列表布局 + while (conversationListLayout_->count () > 0) { + QLayoutItem* item= conversationListLayout_->takeAt (0); + delete item; } + while (archiveListLayout_->count () > 0) { + QLayoutItem* item= archiveListLayout_->takeAt (0); + delete item; + } + + // 更新侧边栏按钮 + QMap titleSeq; // 去重序号 for (ChatConversationPanel* panel : conversations_) { if (!panel || !panel->sidebarButton) continue; - panel->sidebarButton->setText (panel->title); - panel->sidebarButton->setChecked (panel == activeConversation_); + ChatSession* session= sessionManager_.getSession (panel->sessionId); + bool archived= session && session->archived; + + QString displayText; + if (session && !is_empty (session->title)) { + displayText= to_qstring (session->title); + // 标题去重:同标题追加序号 + if (titleCounts[displayText] > 1) { + int seq= ++titleSeq[displayText]; + displayText+= QString (" (%1)").arg (seq); + } + } + else { + displayText= QString::fromUtf8 ("新会话"); + } + + panel->sidebarButton->setText (displayText); + panel->sidebarButton->setChecked (panel == activeConversation_ && !archived); + panel->sidebarButton->setVisible (true); + + if (archived) { + // 归档会话:放入归档列表,禁止点击导航 + panel->sidebarButton->setParent (archiveListWidget_); + archiveListLayout_->addWidget (panel->sidebarButton); + disconnect (panel->sidebarButton, &QPushButton::clicked, this, nullptr); + // 点击归档项不做任何导航 + connect (panel->sidebarButton, &QPushButton::clicked, this, + [] () {}); + } + else { + // 活跃会话:放入会话列表,点击切换 + panel->sidebarButton->setParent (conversationListWidget_); + conversationListLayout_->addWidget (panel->sidebarButton); + disconnect (panel->sidebarButton, &QPushButton::clicked, this, nullptr); + connect (panel->sidebarButton, &QPushButton::clicked, this, + [this, panel] () { activate_conversation (panel); }); + } + + // 右键菜单:重命名、归档/恢复 + panel->sidebarButton->setContextMenuPolicy (Qt::CustomContextMenu); + disconnect (panel->sidebarButton, &QPushButton::customContextMenuRequested, + this, nullptr); + connect (panel->sidebarButton, &QPushButton::customContextMenuRequested, + this, [this, panel] (const QPoint& pos) { + QMenu menu (panel->sidebarButton); + ChatSession* s= sessionManager_.getSession (panel->sessionId); + QAction* renameAction= menu.addAction ("重命名"); + QAction* archiveAction= + menu.addAction (s && s->archived ? "恢复" : "归档"); + QAction* chosen= menu.exec ( + panel->sidebarButton->mapToGlobal (pos)); + if (chosen == renameAction) { + // 重命名:通过输入对话框 + bool ok; + QString newTitle= QInputDialog::getText ( + panel->sidebarButton, "重命名会话", "新标题:", + QLineEdit::Normal, + s ? to_qstring (s->title) : "", &ok); + if (ok && s) { + sessionManager_.setTitle (panel->sessionId, + from_qstring (newTitle)); + refresh_sidebar (); + } + } + else if (chosen == archiveAction) { + if (s && s->archived) + sessionManager_.restoreSession (panel->sessionId); + else + sessionManager_.archiveSession (panel->sessionId); + refresh_sidebar (); + } + }); + } + + // 归档列表可见性:有归档项且非折叠时显示 + if (archiveListWidget_) { + archiveListWidget_->setVisible ( + archivedCount > 0 && !archiveCollapsed_); } } @@ -658,11 +806,18 @@ QTChatTabWidget::handle_send (ChatConversationPanel* panel) { tree inputBody= read_input_message (panel); if (is_empty_document_body (inputBody)) return; - if (!as_bool (call ("chat-tab-send", as_string (panel->messageBufferName), - as_string (panel->inputBufferName), object (inputBody)))) - return; + // 首次发送:自动提取标题 + ChatSession* session= sessionManager_.getSession (panel->sessionId); + if (session && is_empty (session->title)) { + string extracted= extract_title (inputBody, 20); + sessionManager_.setTitle (panel->sessionId, extracted); + } + + if (!as_bool (call ("chat-tab-send", panel->sessionId))) return; + sessionManager_.setState (panel->sessionId, ChatState::Generating); enter_conversation_mode (panel); + refresh_sidebar (); focus_input_editor (panel); } @@ -674,7 +829,52 @@ QTChatTabWidget::handle_send (ChatConversationPanel* panel) { tree QTChatTabWidget::read_input_message (const ChatConversationPanel* panel) const { if (!panel) return tree (DOCUMENT, ""); - return get_buffer_body (panel->inputBufferName); + return get_buffer_body (ChatSessionManager::inputBufferUrl (panel->sessionId)); +} + +/** + * @brief 取消当前会话的 LLM 生成。 + * @param panel 待取消的会话面板。 + */ +void +QTChatTabWidget::handle_cancel (ChatConversationPanel* panel) { + if (!panel) return; + call ("chat-tab-cancel", panel->sessionId); + sessionManager_.setState (panel->sessionId, ChatState::Idle); +} + +/** + * @brief 被 Scheme 侧通知生成状态变更。 + * @param sessionId 会话 ID。 + * @param stateStr 状态字符串 ("idle" 或 "generating")。 + */ +void +QTChatTabWidget::notifyStateChanged (const string& sessionId, + const string& stateStr) { + ChatSession* session= sessionManager_.getSession (sessionId); + if (!session) return; + + ChatState newState= + (stateStr == "generating") ? ChatState::Generating : ChatState::Idle; + sessionManager_.setState (sessionId, newState); + + // 更新按钮状态 + ChatConversationPanel* panel= + static_cast (session->panel); + if (!panel || !panel->sendButton) return; + + if (newState == ChatState::Generating) { + panel->sendButton->setText ("Stop"); + disconnect (panel->sendButton, &QPushButton::clicked, this, nullptr); + connect (panel->sendButton, &QPushButton::clicked, this, + [this, panel] () { handle_cancel (panel); }); + } + else { + panel->sendButton->setText ("Send"); + disconnect (panel->sendButton, &QPushButton::clicked, this, nullptr); + connect (panel->sendButton, &QPushButton::clicked, this, + [this, panel] () { handle_send (panel); }); + } } /** @@ -834,3 +1034,96 @@ QTChatTabWidget::set_chat_focus_icons (widget focusWidget) { chatFocusToolBar_->setUpdatesEnabled (true); chatFocusToolBar_->setVisible (true); } + +/****************************************************************************** + * ChatSessionManager 实现 + ******************************************************************************/ + +string +ChatSessionManager::createSession () { + string sessionId= lolly::hash::uuid_make (); + ChatSession session; + session.sessionId= sessionId; + session.state = ChatState::Idle; + session.archived = false; + session.panel = nullptr; + sessions_.insert (std::make_pair (sessionId, session)); + return sessionId; +} + +void +ChatSessionManager::removeSession (const string& sessionId) { + sessions_.erase (sessionId); +} + +void +ChatSessionManager::archiveSession (const string& sessionId) { + ChatSession* s= getSession (sessionId); + if (s) s->archived= true; +} + +void +ChatSessionManager::restoreSession (const string& sessionId) { + ChatSession* s= getSession (sessionId); + if (s) s->archived= false; +} + +void +ChatSessionManager::setTitle (const string& sessionId, const string& title) { + ChatSession* s= getSession (sessionId); + if (s) s->title= title; +} + +void +ChatSessionManager::setState (const string& sessionId, ChatState state) { + ChatSession* s= getSession (sessionId); + if (s) s->state= state; +} + +ChatSession* +ChatSessionManager::getSession (const string& sessionId) { + auto it= sessions_.find (sessionId); + if (it != sessions_.end ()) return &(it->second); + return nullptr; +} + +ChatSession* +ChatSessionManager::findSessionByPanel (void* panel) { + for (auto& kv : sessions_) { + if (kv.second.panel == panel) return &kv.second; + } + return nullptr; +} + +void +ChatSessionManager::setPanel (const string& sessionId, void* panel) { + ChatSession* s= getSession (sessionId); + if (s) s->panel= panel; +} + +url +ChatSessionManager::messageBufferUrl (const string& sessionId) { + return url ("tmfs://chat-message-" * sessionId); +} + +url +ChatSessionManager::inputBufferUrl (const string& sessionId) { + return url ("tmfs://chat-input-" * sessionId); +} + +/****************************************************************************** + * qt_chat_tab_set_state 自由函数(Scheme→C++ 回调) + ******************************************************************************/ + +void +qt_chat_tab_set_state (string sessionId, string stateStr) { + // 从顶层窗口查找 QTChatTabWidget + QWidgetList topWidgets= QApplication::topLevelWidgets (); + for (QWidget* top : topWidgets) { + QTChatTabWidget* chat= top->findChild (); + if (chat) { + chat->notifyStateChanged (sessionId, stateStr); + return; + } + } +} diff --git a/src/Plugins/Qt/qt_chat_tab_widget.hpp b/src/Plugins/Qt/qt_chat_tab_widget.hpp index 43e5efbaa9..65d8dccf78 100644 --- a/src/Plugins/Qt/qt_chat_tab_widget.hpp +++ b/src/Plugins/Qt/qt_chat_tab_widget.hpp @@ -18,6 +18,8 @@ #include "widget.hpp" +#include + class QHBoxLayout; class QLabel; class QPushButton; @@ -28,6 +30,89 @@ class QToolBar; class QVBoxLayout; class QEvent; +/** + * @brief 聊天会话的生成状态。 + */ +enum class ChatState { + Idle, ///< 空闲,可发送 + Generating, ///< LLM 正在生成,可取消 +}; + +/** + * @brief 单个聊天会话的数据。 + */ +struct ChatSession { + string sessionId; ///< UUID,创建时生成 + string title; ///< 会话标题,初始为空字符串 + ChatState state; ///< 当前生成状态 + bool archived; ///< 是否归档 + void* panel; ///< 关联的 ChatConversationPanel 指针 +}; + +/** + * @brief 聊天会话管理器,负责会话的创建、销毁和元数据管理。 + */ +class ChatSessionManager { +public: + /** + * @brief 创建新会话,分配 UUID,返回 sessionId。 + */ + string createSession (); + + /** + * @brief 销毁指定会话。 + */ + void removeSession (const string& sessionId); + + /** + * @brief 将会话移入归档区。 + */ + void archiveSession (const string& sessionId); + + /** + * @brief 将会话从归档区恢复。 + */ + void restoreSession (const string& sessionId); + + /** + * @brief 设置会话标题。 + */ + void setTitle (const string& sessionId, const string& title); + + /** + * @brief 设置会话生成状态。 + */ + void setState (const string& sessionId, ChatState state); + + /** + * @brief 获取会话数据,不存在则返回 nullptr。 + */ + ChatSession* getSession (const string& sessionId); + + /** + * @brief 通过面板指针反查会话。 + */ + ChatSession* findSessionByPanel (void* panel); + + /** + * @brief 设置会话关联的面板指针。 + */ + void setPanel (const string& sessionId, void* panel); + + /** + * @brief 根据 sessionId 推导消息 buffer URL。 + */ + static url messageBufferUrl (const string& sessionId); + + /** + * @brief 根据 sessionId 推导输入 buffer URL。 + */ + static url inputBufferUrl (const string& sessionId); + +private: + std::map sessions_; ///< sessionId → ChatSession 映射。 +}; + /** * @brief Mogan STEM 的 LLM 聊天标签页控件。 * @@ -121,6 +206,12 @@ class QTChatTabWidget : public QWidget { */ void handle_send (ChatConversationPanel* panel); + /** + * @brief 取消当前会话的 LLM 生成。 + * @param panel 待取消的会话面板。 + */ + void handle_cancel (ChatConversationPanel* panel); + /** * @brief 从输入 buffer 中获取文档树。 * @param panel 待读取输入的会话面板。 @@ -158,12 +249,23 @@ class QTChatTabWidget : public QWidget { */ void set_chat_focus_icons (widget focusWidget); + /** + * @brief 被通知 Scheme 侧生成状态变更。 + * @param sessionId 会话 ID。 + * @param stateStr 状态字符串 ("idle" 或 "generating")。 + */ + void notifyStateChanged (const string& sessionId, const string& stateStr); + private: QWidget* sidebarWidget_; ///< 左侧边栏容器。 QWidget* contentWidget_; ///< 右侧内容区容器。 QLabel* conversationCountLabel_; ///< 显示会话数量的标签。 QWidget* conversationListWidget_; ///< 承载会话列表的控件。 QVBoxLayout* conversationListLayout_; ///< 会话按钮的布局。 + QPushButton* archiveHeaderButton_; ///< 归档区标题按钮(点击展开/折叠)。 + QWidget* archiveListWidget_; ///< 承载归档会话列表的控件。 + QVBoxLayout* archiveListLayout_; ///< 归档会话按钮的布局。 + bool archiveCollapsed_; ///< 归档区当前是否折叠。 QPushButton* collapseButton_; ///< 侧边栏内的收缩按钮。 QPushButton* newChatButton_; ///< 新建会话按钮。 QWidget* sidebarNormalContent_; ///< 侧边栏展开时的内容容器。 @@ -171,7 +273,7 @@ class QTChatTabWidget : public QWidget { QStackedWidget* conversationStack_; ///< 会话页面的堆叠控件。 QList conversations_; ///< 所有会话面板的列表。 ChatConversationPanel* activeConversation_; ///< 当前激活的会话。 - int nextConversationTitleId_; ///< 自动生成会话标题的 ID 计数器。 + ChatSessionManager sessionManager_; ///< 会话管理器。 bool sidebarCollapsed_; ///< 侧边栏当前是否处于收起状态。 int sidebarExpandedWidth_; ///< 侧边栏展开时的宽度(像素)。 QToolBar* chatMenuToolBar_; ///< Chat Tab 的菜单工具栏。 @@ -179,4 +281,11 @@ class QTChatTabWidget : public QWidget { QToolBar* chatFocusToolBar_; ///< Chat Tab 的焦点工具栏。 }; +/** + * @brief Scheme→C++ 回调:通知 Chat Tab 的会话状态变更。 + * @param sessionId 会话 UUID。 + * @param stateStr 状态字符串 ("idle" 或 "generating")。 + */ +void qt_chat_tab_set_state (string sessionId, string stateStr); + #endif // QT_CHAT_TAB_WIDGET_HPP diff --git a/src/Scheme/L5/glue_widget.lua b/src/Scheme/L5/glue_widget.lua index 83775afec8..7a26eca0fb 100644 --- a/src/Scheme/L5/glue_widget.lua +++ b/src/Scheme/L5/glue_widget.lua @@ -502,6 +502,15 @@ function main() scm_name = "open-pricing-url", cpp_name = "open_pricing_url", ret_type = "void" + }, + { + scm_name = "qt-chat-tab-set-state", + cpp_name = "qt_chat_tab_set_state", + ret_type = "void", + arg_list = { + "string", + "string" + } } } } From 651174f191411b3a3893c4815889b08222b67db8 Mon Sep 17 00:00:00 2001 From: hongwei Date: Mon, 18 May 2026 20:43:00 +0800 Subject: [PATCH 2/4] add devel --- TeXmacs/progs/dynamic/chat-adapter.scm | 8 +- devel/0204.md | 160 +++++++++++++++++++++++++ src/Plugins/Qt/qt_chat_tab_widget.cpp | 136 +++++++++++++++++++-- src/Plugins/Qt/qt_chat_tab_widget.hpp | 31 +++++ src/Scheme/L5/glue_widget.lua | 8 ++ 5 files changed, 335 insertions(+), 8 deletions(-) create mode 100644 devel/0204.md diff --git a/TeXmacs/progs/dynamic/chat-adapter.scm b/TeXmacs/progs/dynamic/chat-adapter.scm index 7f28017814..1fbf945026 100644 --- a/TeXmacs/progs/dynamic/chat-adapter.scm +++ b/TeXmacs/progs/dynamic/chat-adapter.scm @@ -42,7 +42,13 @@ (chat-tab-session-select-model (car model-opt)) ) ;when (if (buffer-exists? chat-tab-url) - (switch-to-buffer chat-tab-url) + (begin + (switch-to-buffer chat-tab-url) + ;; 如果指定了模型,在已有的 Chat Tab 中新建会话 + (when (nnull? model-opt) + (qt-chat-tab-new-session (car model-opt)) + ) ;when + ) ;begin (begin (buffer-set chat-tab-url '(document "")) (buffer-set-title chat-tab-url "Chat") diff --git a/devel/0204.md b/devel/0204.md new file mode 100644 index 0000000000..1ab7e63041 --- /dev/null +++ b/devel/0204.md @@ -0,0 +1,160 @@ +# [0204] Chat Tab 归档 UI 与模型绑定 + +## 相关文档 +- [docs/ai-sidebar/architecture-double-buffer-session.md](../docs/ai-sidebar/architecture-double-buffer-session.md) - 双 Buffer 会话架构 +- [docs/ai-sidebar/chat-session-manager.md](../docs/ai-sidebar/chat-session-manager.md) - 会话管理器 + +## 任务相关的代码文件 +- `src/Plugins/Qt/qt_chat_tab_widget.hpp` +- `src/Plugins/Qt/qt_chat_tab_widget.cpp` +- `src/Scheme/L5/glue_widget.lua` +- `TeXmacs/progs/dynamic/chat-adapter.scm` +- `TeXmacs/plugins/llm/goldfish/liii/llm.scm` +- `TeXmacs/plugins/llm/progs/init-llm.scm` + +## 如何测试 + +### 确定性测试(单元测试) +暂无单元测试。 + +### 非确定性测试(文档验证) + +#### 归档区 UI +1. 启动 Mogan STEM,切换到 Chat Tab +2. 确认左侧边栏显示 "Archived (0)" 标题,且列表为空 +3. 右键某个会话按钮,选择"归档",确认: + - 该会话从 Conversations 列表中消失 + - Archived 计数增加 + - 点击 "Archived (N)" 标题可展开/折叠归档列表 +4. 展开归档列表后,确认归档的会话显示在列表中 +5. 点击归档列表中的会话,确认: + - 页面切换到一个新的空白会话(不会导航到归档会话) + - 重复点击同一归档会话,不会重复创建新会话 +6. 右键归档会话,选择"恢复",确认会话回到 Conversations 列表 + +#### 模型绑定 +1. 确认侧边栏 "New chat" 按钮旁有模型选择下拉框 +2. 从下拉框选择一个模型,确认: + - 自动创建新会话 + - 新会话面板上方显示模型名称标签 +3. 点击 AI 菜单中的模型名称,确认: + - Chat Tab 获得焦点 + - 自动创建一个使用该模型的新会话 +4. 发送消息后,确认会话侧边栏按钮标题正确 + +## 如何提交 + +提交前执行以下最少步骤: + +```bash +xmake b stem +``` + +确认编译通过后提交并推送: + +```bash +git add -A +git commit -m "[0204] Chat Tab 归档 UI 与模型绑定" +git push origin hongwei/0204/add_session_manager +``` + +## What + +为 Chat Tab 的侧边栏添加归档区 UI,并为会话绑定模型。 + +修改内容: +1. `qt_chat_tab_widget.hpp`: + - `ChatSession` 新增 `model` 字段 + - `ChatSessionManager` 新增 `setModel`/`getModel` 方法 + - `ChatConversationPanel` 新增 `modelLabel` 成员 + - `QTChatTabWidget` 新增 `archiveHeaderButton_`、`archiveListWidget_`、`archiveListLayout_`、`archiveCollapsed_`、`modelSelector_` 成员 + - 新增 `create_new_conversation_with_model()`、`newSessionWithModel()` 方法 + - 新增 `qt_chat_tab_new_session()` 自由函数声明 + +2. `qt_chat_tab_widget.cpp`: + - 侧边栏新增 "Archived (N)" 可折叠标题和归档列表容器 + - `refresh_sidebar()` 重构:活跃/归档会话分别放入不同列表,归档会话点击切换到新会话 + - "New chat" 按钮旁增加模型选择 `QComboBox` + - 会话面板顶部增加模型名称标签 + - `create_new_conversation()` 从 Scheme 获取当前模型 + - `create_new_conversation_with_model()` 创建指定模型的会话 + +3. `glue_widget.lua`: + - 新增 `qt-chat-tab-new-session` Scheme→C++ 绑定 + +4. `chat-adapter.scm`: + - `open-llm-chat-tab` 在指定模型且 Chat Tab 已打开时调用 `qt-chat-tab-new-session` 创建新会话 + +5. `liii/llm.scm`: + - 新增并导出 `llm-list-models` 函数(基于 `load-llm-menu` 获取可用模型列表) + +6. `init-llm.scm`: + - 新增 `chat-tab-list-models` tm-define 桥接函数,供 C++ 调用 + +## Why + +### 归档区 UI +归档会话之前只是从侧边栏消失(`setVisible(false)`),用户无法查看或操作归档内容。需要一个显式的归档区域让用户: +- 看到有多少会话被归档 +- 通过展开/折叠控制归档列表的可见性 +- 通过右键菜单恢复归档会话 +- 点击归档会话时切换到新会话而非导航到归档内容 + +### 模型绑定 +之前会话不绑定模型,模型选择是全局状态(`chat-tab-current-model`),切换模型影响所有后续发送。需要让每个会话绑定自己的模型,并在 UI 上显示: +- 用户明确知道当前会话使用的模型 +- 从 AI 菜单点击不同模型时创建新会话而非切换全局模型 +- "New chat" 时可选择模型 + +## How + +### 归档区结构 + +侧边栏布局: + +``` +sidebarNormalContent_ + ├── "Chat" 标题 + ├── New chat + 模型选择器 + ├── "Conversations (N)" 标签 + ├── conversationListWidget_ ← 活跃会话列表 + ├── "Archived (N)" 标题按钮 ← 点击展开/折叠 + ├── archiveListWidget_ ← 归档会话列表(默认隐藏) + ├── stretch + └── "收缩" 按钮 +``` + +归档区默认折叠(`archiveCollapsed_ = true`),点击标题按钮切换。`refresh_sidebar()` 将会话按钮根据归档状态分配到不同列表中。 + +### 归档会话点击行为 + +点击归档会话时不导航到归档内容,而是切换到空白会话。如果当前已有一个空的活跃会话,直接切换过去,避免重复创建。 + +### 模型绑定流程 + +``` +用户点击模型 → Scheme open-llm-chat-tab(model) + → qt-chat-tab-new-session(model) + → C++ newSessionWithModel(model) + → create_new_conversation_with_model(model) + → call("chat-tab-session-select-model", model) // 通知 Scheme + → sessionManager_.setModel(id, model) // C++ 侧记录 + → modelLabel->setText(model) // UI 显示 +``` + +### 模型选择器 + +"New chat" 按钮旁的 `QComboBox` 在初始化时通过 `call("chat-tab-list-models")` 从 Scheme 获取可用模型列表。选择模型后调用 `create_new_conversation_with_model()` 创建新会话。 + +## 影响范围确认 + +本次修改涉及: +- `QTChatTabWidget` 类的内部实现和公开接口 +- Scheme 适配层 `chat-adapter.scm` +- LLM 插件库 `liii/llm.scm` 和初始化 `init-llm.scm` +- Scheme→C++ 绑定 `glue_widget.lua` + +不影响: +- 会话发送、取消等核心消息逻辑 +- 插件通信协议 +- 其他 Qt 插件或窗口组件 diff --git a/src/Plugins/Qt/qt_chat_tab_widget.cpp b/src/Plugins/Qt/qt_chat_tab_widget.cpp index 05805d5c1b..e62fe9e6e0 100644 --- a/src/Plugins/Qt/qt_chat_tab_widget.cpp +++ b/src/Plugins/Qt/qt_chat_tab_widget.cpp @@ -26,6 +26,7 @@ #include #include +#include #include #include #include @@ -177,6 +178,7 @@ constexpr int kTransitionDurationMs= 220; struct QTChatTabWidget::ChatConversationPanel { QWidget* pageWidget = nullptr; ///< 本会话的堆叠页面。 QLabel* welcomeTitle = nullptr; ///< 欢迎标题标签。 + QLabel* modelLabel = nullptr; ///< 模型名称标签。 QWidget* messageFrame = nullptr; ///< 承载消息控件的边框。 QWidget* inputEditorWidget= nullptr; ///< 输入编辑器的 Qt 控件。 QPushButton* sendButton = nullptr; ///< 发送/停止按钮。 @@ -199,7 +201,8 @@ QTChatTabWidget::QTChatTabWidget (QWidget* parent) conversationListLayout_ (nullptr), archiveHeaderButton_ (nullptr), archiveListWidget_ (nullptr), archiveListLayout_ (nullptr), archiveCollapsed_ (true), newChatButton_ (nullptr), - collapseButton_ (nullptr), sidebarNormalContent_ (nullptr), + modelSelector_ (nullptr), collapseButton_ (nullptr), + sidebarNormalContent_ (nullptr), sidebarCollapsedBar_ (nullptr), conversationStack_ (nullptr), activeConversation_ (nullptr), sidebarCollapsed_ (false), sidebarExpandedWidth_ (0), chatMenuToolBar_ (nullptr), @@ -265,8 +268,14 @@ QTChatTabWidget::setup_left_sidebar (QVBoxLayout* sidebarLayout) { DpiUtils::applyScaledFont (navTitle, kNavTitleFontPx); normalLayout->addWidget (navTitle); - // New chat 按钮 - newChatButton_= new QPushButton ("New chat", normalContent); + // New chat 按钮和模型选择器 + QWidget* newChatRow= new QWidget (normalContent); + newChatRow->setObjectName ("chat-tab-new-chat-row"); + QHBoxLayout* newChatRowLayout= new QHBoxLayout (newChatRow); + newChatRowLayout->setContentsMargins (0, 0, 0, 0); + newChatRowLayout->setSpacing (DpiUtils::scaled (4)); + + newChatButton_= new QPushButton ("New chat", newChatRow); newChatButton_->setObjectName ("chat-tab-new-btn"); newChatButton_->setFocusPolicy (Qt::NoFocus); newChatButton_->setCursor (Qt::PointingHandCursor); @@ -276,7 +285,30 @@ QTChatTabWidget::setup_left_sidebar (QVBoxLayout* sidebarLayout) { .arg (DpiUtils::scaled (kNavButtonPadX))); connect (newChatButton_, &QPushButton::clicked, this, [this] () { create_new_conversation (); }); - normalLayout->addWidget (newChatButton_); + newChatRowLayout->addWidget (newChatButton_); + + modelSelector_= new QComboBox (newChatRow); + modelSelector_->setObjectName ("chat-tab-model-selector"); + modelSelector_->setFocusPolicy (Qt::NoFocus); + modelSelector_->setCursor (Qt::PointingHandCursor); + modelSelector_->setSizeAdjustPolicy (QComboBox::AdjustToContents); + // 从 Scheme 获取可用模型列表 + tree models_tree= as_tree (call ("chat-tab-list-models")); + if (is_tuple (models_tree)) { + for (int i= 0; i < N (models_tree); ++i) { + if (is_atomic (models_tree[i])) { + modelSelector_->addItem (to_qstring (models_tree[i]->label)); + } + } + } + connect (modelSelector_, QOverload::of (&QComboBox::activated), this, + [this] (int index) { + string model= from_qstring (modelSelector_->itemText (index)); + create_new_conversation_with_model (model); + }); + newChatRowLayout->addWidget (modelSelector_, 1); + + normalLayout->addWidget (newChatRow); conversationCountLabel_= new QLabel ("Conversations (0)", normalContent); conversationCountLabel_->setObjectName ("chat-tab-conversation-count"); @@ -454,6 +486,17 @@ QTChatTabWidget::create_conversation (const QString& title) { DpiUtils::applyScaledFont (panel->welcomeTitle, kWelcomeFontPx); topLayout->addWidget (panel->welcomeTitle); + // 模型名称标签 + panel->modelLabel= new QLabel ("", topPanel); + panel->modelLabel->setObjectName ("chat-tab-model-label"); + panel->modelLabel->setAlignment (Qt::AlignCenter); + DpiUtils::applyScaledFont (panel->modelLabel, kNavTitleFontPx); + panel->modelLabel->setStyleSheet ( + "color: #888888; padding: 2px 8px; background-color: #f0f0f0; " + "border-radius: 4px;"); + panel->modelLabel->setMinimumHeight (DpiUtils::scaled (20)); + topLayout->addWidget (panel->modelLabel, 0, Qt::AlignHCenter); + panel->messageWidget= texmacs_input_widget (tree (DOCUMENT, ""), make_chat_embedded_style (), msgBufUrl); @@ -549,9 +592,36 @@ QTChatTabWidget::create_conversation (const QString& title) { */ void QTChatTabWidget::create_new_conversation () { + // 从 Scheme 获取当前模型 + string model= as_string (call ("chat-tab-session-select-model", string (""))); + create_new_conversation_with_model (model); +} + +/** + * @brief 使用指定模型创建并激活一个新会话。 + * @param model 模型名称。 + */ +void +QTChatTabWidget::create_new_conversation_with_model (const string& model) { + // 通知 Scheme 层切换模型 + call ("chat-tab-session-select-model", model); + ChatConversationPanel* panel= create_conversation (""); if (!panel) return; conversations_.append (panel); + + // 绑定模型到会话并显示 + sessionManager_.setModel (panel->sessionId, model); + if (panel->modelLabel) { + panel->modelLabel->setText (to_qstring (model)); + } + + // 同步模型选择器 + if (modelSelector_) { + int idx= modelSelector_->findText (to_qstring (model)); + if (idx >= 0) modelSelector_->setCurrentIndex (idx); + } + activate_conversation (panel); } @@ -644,14 +714,29 @@ QTChatTabWidget::refresh_sidebar () { panel->sidebarButton->setChecked (panel == activeConversation_ && !archived); panel->sidebarButton->setVisible (true); + // 同步模型标签 + if (panel->modelLabel && session) { + panel->modelLabel->setText (to_qstring (session->model)); + } + if (archived) { - // 归档会话:放入归档列表,禁止点击导航 + // 归档会话:放入归档列表,点击切换到空白会话 panel->sidebarButton->setParent (archiveListWidget_); archiveListLayout_->addWidget (panel->sidebarButton); disconnect (panel->sidebarButton, &QPushButton::clicked, this, nullptr); - // 点击归档项不做任何导航 connect (panel->sidebarButton, &QPushButton::clicked, this, - [] () {}); + [this, panel] () { + // 如果当前激活的是空白的非归档会话,直接切换过去 + if (activeConversation_ + && activeConversation_ != panel + && !activeConversation_->conversationMode) { + activate_conversation (activeConversation_); + } + else { + // 否则创建新会话 + create_new_conversation (); + } + }); } else { // 活跃会话:放入会话列表,点击切换 @@ -877,6 +962,15 @@ QTChatTabWidget::notifyStateChanged (const string& sessionId, } } +/** + * @brief 使用指定模型创建新会话(供 Scheme 回调调用)。 + * @param model 模型名称。 + */ +void +QTChatTabWidget::newSessionWithModel (const string& model) { + create_new_conversation_with_model (model); +} + /** * @brief 将键盘焦点设置到指定面板的输入编辑器。 * @param panel 目标会话面板。 @@ -1080,6 +1174,19 @@ ChatSessionManager::setState (const string& sessionId, ChatState state) { if (s) s->state= state; } +void +ChatSessionManager::setModel (const string& sessionId, const string& model) { + ChatSession* s= getSession (sessionId); + if (s) s->model= model; +} + +string +ChatSessionManager::getModel (const string& sessionId) { + ChatSession* s= getSession (sessionId); + if (s) return s->model; + return ""; +} + ChatSession* ChatSessionManager::getSession (const string& sessionId) { auto it= sessions_.find (sessionId); @@ -1127,3 +1234,18 @@ qt_chat_tab_set_state (string sessionId, string stateStr) { } } } + +/** + * @brief Scheme→C++ 回调:使用指定模型创建新会话。 + */ +void +qt_chat_tab_new_session (string model) { + QWidgetList topWidgets= QApplication::topLevelWidgets (); + for (QWidget* top : topWidgets) { + QTChatTabWidget* chat= top->findChild (); + if (chat) { + chat->newSessionWithModel (model); + return; + } + } +} diff --git a/src/Plugins/Qt/qt_chat_tab_widget.hpp b/src/Plugins/Qt/qt_chat_tab_widget.hpp index 65d8dccf78..dfcd380db4 100644 --- a/src/Plugins/Qt/qt_chat_tab_widget.hpp +++ b/src/Plugins/Qt/qt_chat_tab_widget.hpp @@ -29,6 +29,7 @@ class QString; class QToolBar; class QVBoxLayout; class QEvent; +class QComboBox; /** * @brief 聊天会话的生成状态。 @@ -44,6 +45,7 @@ enum class ChatState { struct ChatSession { string sessionId; ///< UUID,创建时生成 string title; ///< 会话标题,初始为空字符串 + string model; ///< 绑定的模型名称 ChatState state; ///< 当前生成状态 bool archived; ///< 是否归档 void* panel; ///< 关联的 ChatConversationPanel 指针 @@ -84,6 +86,16 @@ class ChatSessionManager { */ void setState (const string& sessionId, ChatState state); + /** + * @brief 设置会话绑定的模型。 + */ + void setModel (const string& sessionId, const string& model); + + /** + * @brief 获取会话绑定的模型。 + */ + string getModel (const string& sessionId); + /** * @brief 获取会话数据,不存在则返回 nullptr。 */ @@ -181,6 +193,12 @@ class QTChatTabWidget : public QWidget { */ void create_new_conversation (); + /** + * @brief 使用指定模型创建并激活一个新会话。 + * @param model 模型名称。 + */ + void create_new_conversation_with_model (const string& model); + /** * @brief 将可见页面切换到指定会话。 * @param panel 待激活的会话面板。 @@ -256,6 +274,12 @@ class QTChatTabWidget : public QWidget { */ void notifyStateChanged (const string& sessionId, const string& stateStr); + /** + * @brief 使用指定模型创建新会话(供 Scheme 回调调用)。 + * @param model 模型名称。 + */ + void newSessionWithModel (const string& model); + private: QWidget* sidebarWidget_; ///< 左侧边栏容器。 QWidget* contentWidget_; ///< 右侧内容区容器。 @@ -268,6 +292,7 @@ class QTChatTabWidget : public QWidget { bool archiveCollapsed_; ///< 归档区当前是否折叠。 QPushButton* collapseButton_; ///< 侧边栏内的收缩按钮。 QPushButton* newChatButton_; ///< 新建会话按钮。 + QComboBox* modelSelector_; ///< 模型选择下拉框。 QWidget* sidebarNormalContent_; ///< 侧边栏展开时的内容容器。 QWidget* sidebarCollapsedBar_; ///< 侧边栏收起时的窄条容器。 QStackedWidget* conversationStack_; ///< 会话页面的堆叠控件。 @@ -288,4 +313,10 @@ class QTChatTabWidget : public QWidget { */ void qt_chat_tab_set_state (string sessionId, string stateStr); +/** + * @brief Scheme→C++ 回调:使用指定模型创建新会话。 + * @param model 模型名称。 + */ +void qt_chat_tab_new_session (string model); + #endif // QT_CHAT_TAB_WIDGET_HPP diff --git a/src/Scheme/L5/glue_widget.lua b/src/Scheme/L5/glue_widget.lua index 7a26eca0fb..45c7d6ed7e 100644 --- a/src/Scheme/L5/glue_widget.lua +++ b/src/Scheme/L5/glue_widget.lua @@ -511,6 +511,14 @@ function main() "string", "string" } + }, + { + scm_name = "qt-chat-tab-new-session", + cpp_name = "qt_chat_tab_new_session", + ret_type = "void", + arg_list = { + "string" + } } } } From 8c6f90996e2851c03577a805214c000920ef700c Mon Sep 17 00:00:00 2001 From: hongwei Date: Mon, 18 May 2026 20:44:58 +0800 Subject: [PATCH 3/4] format --- src/Plugins/Qt/qt_chat_tab_widget.cpp | 110 +++++++++++++------------- src/Plugins/Qt/qt_chat_tab_widget.hpp | 54 ++++++------- 2 files changed, 80 insertions(+), 84 deletions(-) diff --git a/src/Plugins/Qt/qt_chat_tab_widget.cpp b/src/Plugins/Qt/qt_chat_tab_widget.cpp index e62fe9e6e0..96388b414c 100644 --- a/src/Plugins/Qt/qt_chat_tab_widget.cpp +++ b/src/Plugins/Qt/qt_chat_tab_widget.cpp @@ -33,8 +33,8 @@ #include #include #include -#include #include +#include #include #include #include @@ -202,11 +202,11 @@ QTChatTabWidget::QTChatTabWidget (QWidget* parent) archiveListWidget_ (nullptr), archiveListLayout_ (nullptr), archiveCollapsed_ (true), newChatButton_ (nullptr), modelSelector_ (nullptr), collapseButton_ (nullptr), - sidebarNormalContent_ (nullptr), - sidebarCollapsedBar_ (nullptr), conversationStack_ (nullptr), - activeConversation_ (nullptr), sidebarCollapsed_ (false), - sidebarExpandedWidth_ (0), chatMenuToolBar_ (nullptr), - chatModeToolBar_ (nullptr), chatFocusToolBar_ (nullptr) { + sidebarNormalContent_ (nullptr), sidebarCollapsedBar_ (nullptr), + conversationStack_ (nullptr), activeConversation_ (nullptr), + sidebarCollapsed_ (false), sidebarExpandedWidth_ (0), + chatMenuToolBar_ (nullptr), chatModeToolBar_ (nullptr), + chatFocusToolBar_ (nullptr) { setFocusPolicy (Qt::StrongFocus); QHBoxLayout* mainLayout= new QHBoxLayout (this); @@ -336,12 +336,10 @@ QTChatTabWidget::setup_left_sidebar (QVBoxLayout* sidebarLayout) { "QPushButton:hover { color: #333333; }") .arg (DpiUtils::scaled (kNavTitlePadding)) .arg (DpiUtils::scaled (kNavButtonPadX))); - connect (archiveHeaderButton_, &QPushButton::clicked, this, - [this] () { - archiveCollapsed_= !archiveCollapsed_; - if (archiveListWidget_) - archiveListWidget_->setVisible (!archiveCollapsed_); - }); + connect (archiveHeaderButton_, &QPushButton::clicked, this, [this] () { + archiveCollapsed_= !archiveCollapsed_; + if (archiveListWidget_) archiveListWidget_->setVisible (!archiveCollapsed_); + }); normalLayout->addWidget (archiveHeaderButton_); // 归档会话列表 @@ -453,9 +451,9 @@ QTChatTabWidget::ChatConversationPanel* QTChatTabWidget::create_conversation (const QString& title) { if (!conversationStack_ || !conversationListLayout_) return nullptr; - string sessionId= sessionManager_.createSession (); - ChatConversationPanel* panel= new ChatConversationPanel (); - panel->sessionId = sessionId; + string sessionId= sessionManager_.createSession (); + ChatConversationPanel* panel = new ChatConversationPanel (); + panel->sessionId = sessionId; sessionManager_.setPanel (sessionId, panel); url msgBufUrl= ChatSessionManager::messageBufferUrl (sessionId); @@ -497,9 +495,8 @@ QTChatTabWidget::create_conversation (const QString& title) { panel->modelLabel->setMinimumHeight (DpiUtils::scaled (20)); topLayout->addWidget (panel->modelLabel, 0, Qt::AlignHCenter); - panel->messageWidget= - texmacs_input_widget (tree (DOCUMENT, ""), make_chat_embedded_style (), - msgBufUrl); + panel->messageWidget= texmacs_input_widget ( + tree (DOCUMENT, ""), make_chat_embedded_style (), msgBufUrl); QWidget* messageQWidget= concrete (panel->messageWidget)->as_qwidget (); panel->messageFrame = new QWidget (topPanel); panel->messageFrame->setObjectName ("chat-tab-message-frame"); @@ -644,7 +641,7 @@ QTChatTabWidget::activate_conversation (ChatConversationPanel* panel) { void QTChatTabWidget::refresh_sidebar () { // 统计活跃和归档会话数 - int activeCount= 0; + int activeCount = 0; int archivedCount= 0; for (ChatConversationPanel* panel : conversations_) { if (!panel) continue; @@ -694,8 +691,8 @@ QTChatTabWidget::refresh_sidebar () { QMap titleSeq; // 去重序号 for (ChatConversationPanel* panel : conversations_) { if (!panel || !panel->sidebarButton) continue; - ChatSession* session= sessionManager_.getSession (panel->sessionId); - bool archived= session && session->archived; + ChatSession* session = sessionManager_.getSession (panel->sessionId); + bool archived= session && session->archived; QString displayText; if (session && !is_empty (session->title)) { @@ -711,7 +708,8 @@ QTChatTabWidget::refresh_sidebar () { } panel->sidebarButton->setText (displayText); - panel->sidebarButton->setChecked (panel == activeConversation_ && !archived); + panel->sidebarButton->setChecked (panel == activeConversation_ && + !archived); panel->sidebarButton->setVisible (true); // 同步模型标签 @@ -727,9 +725,8 @@ QTChatTabWidget::refresh_sidebar () { connect (panel->sidebarButton, &QPushButton::clicked, this, [this, panel] () { // 如果当前激活的是空白的非归档会话,直接切换过去 - if (activeConversation_ - && activeConversation_ != panel - && !activeConversation_->conversationMode) { + if (activeConversation_ && activeConversation_ != panel && + !activeConversation_->conversationMode) { activate_conversation (activeConversation_); } else { @@ -753,40 +750,38 @@ QTChatTabWidget::refresh_sidebar () { this, nullptr); connect (panel->sidebarButton, &QPushButton::customContextMenuRequested, this, [this, panel] (const QPoint& pos) { - QMenu menu (panel->sidebarButton); - ChatSession* s= sessionManager_.getSession (panel->sessionId); - QAction* renameAction= menu.addAction ("重命名"); - QAction* archiveAction= - menu.addAction (s && s->archived ? "恢复" : "归档"); - QAction* chosen= menu.exec ( - panel->sidebarButton->mapToGlobal (pos)); - if (chosen == renameAction) { - // 重命名:通过输入对话框 - bool ok; - QString newTitle= QInputDialog::getText ( - panel->sidebarButton, "重命名会话", "新标题:", - QLineEdit::Normal, - s ? to_qstring (s->title) : "", &ok); - if (ok && s) { - sessionManager_.setTitle (panel->sessionId, - from_qstring (newTitle)); - refresh_sidebar (); - } - } - else if (chosen == archiveAction) { - if (s && s->archived) - sessionManager_.restoreSession (panel->sessionId); - else - sessionManager_.archiveSession (panel->sessionId); - refresh_sidebar (); - } - }); + QMenu menu (panel->sidebarButton); + ChatSession* s= sessionManager_.getSession (panel->sessionId); + QAction* renameAction= menu.addAction ("重命名"); + QAction* archiveAction= + menu.addAction (s && s->archived ? "恢复" : "归档"); + QAction* chosen= + menu.exec (panel->sidebarButton->mapToGlobal (pos)); + if (chosen == renameAction) { + // 重命名:通过输入对话框 + bool ok; + QString newTitle= QInputDialog::getText ( + panel->sidebarButton, "重命名会话", + "新标题:", QLineEdit::Normal, + s ? to_qstring (s->title) : "", &ok); + if (ok && s) { + sessionManager_.setTitle (panel->sessionId, + from_qstring (newTitle)); + refresh_sidebar (); + } + } + else if (chosen == archiveAction) { + if (s && s->archived) + sessionManager_.restoreSession (panel->sessionId); + else sessionManager_.archiveSession (panel->sessionId); + refresh_sidebar (); + } + }); } // 归档列表可见性:有归档项且非折叠时显示 if (archiveListWidget_) { - archiveListWidget_->setVisible ( - archivedCount > 0 && !archiveCollapsed_); + archiveListWidget_->setVisible (archivedCount > 0 && !archiveCollapsed_); } } @@ -914,7 +909,8 @@ QTChatTabWidget::handle_send (ChatConversationPanel* panel) { tree QTChatTabWidget::read_input_message (const ChatConversationPanel* panel) const { if (!panel) return tree (DOCUMENT, ""); - return get_buffer_body (ChatSessionManager::inputBufferUrl (panel->sessionId)); + return get_buffer_body ( + ChatSessionManager::inputBufferUrl (panel->sessionId)); } /** @@ -1135,7 +1131,7 @@ QTChatTabWidget::set_chat_focus_icons (widget focusWidget) { string ChatSessionManager::createSession () { - string sessionId= lolly::hash::uuid_make (); + string sessionId= lolly::hash::uuid_make (); ChatSession session; session.sessionId= sessionId; session.state = ChatState::Idle; diff --git a/src/Plugins/Qt/qt_chat_tab_widget.hpp b/src/Plugins/Qt/qt_chat_tab_widget.hpp index dfcd380db4..46daec5b5a 100644 --- a/src/Plugins/Qt/qt_chat_tab_widget.hpp +++ b/src/Plugins/Qt/qt_chat_tab_widget.hpp @@ -43,12 +43,12 @@ enum class ChatState { * @brief 单个聊天会话的数据。 */ struct ChatSession { - string sessionId; ///< UUID,创建时生成 - string title; ///< 会话标题,初始为空字符串 - string model; ///< 绑定的模型名称 - ChatState state; ///< 当前生成状态 - bool archived; ///< 是否归档 - void* panel; ///< 关联的 ChatConversationPanel 指针 + string sessionId; ///< UUID,创建时生成 + string title; ///< 会话标题,初始为空字符串 + string model; ///< 绑定的模型名称 + ChatState state; ///< 当前生成状态 + bool archived; ///< 是否归档 + void* panel; ///< 关联的 ChatConversationPanel 指针 }; /** @@ -281,29 +281,29 @@ class QTChatTabWidget : public QWidget { void newSessionWithModel (const string& model); private: - QWidget* sidebarWidget_; ///< 左侧边栏容器。 - QWidget* contentWidget_; ///< 右侧内容区容器。 - QLabel* conversationCountLabel_; ///< 显示会话数量的标签。 - QWidget* conversationListWidget_; ///< 承载会话列表的控件。 - QVBoxLayout* conversationListLayout_; ///< 会话按钮的布局。 - QPushButton* archiveHeaderButton_; ///< 归档区标题按钮(点击展开/折叠)。 - QWidget* archiveListWidget_; ///< 承载归档会话列表的控件。 - QVBoxLayout* archiveListLayout_; ///< 归档会话按钮的布局。 - bool archiveCollapsed_; ///< 归档区当前是否折叠。 - QPushButton* collapseButton_; ///< 侧边栏内的收缩按钮。 - QPushButton* newChatButton_; ///< 新建会话按钮。 - QComboBox* modelSelector_; ///< 模型选择下拉框。 - QWidget* sidebarNormalContent_; ///< 侧边栏展开时的内容容器。 - QWidget* sidebarCollapsedBar_; ///< 侧边栏收起时的窄条容器。 - QStackedWidget* conversationStack_; ///< 会话页面的堆叠控件。 - QList conversations_; ///< 所有会话面板的列表。 + QWidget* sidebarWidget_; ///< 左侧边栏容器。 + QWidget* contentWidget_; ///< 右侧内容区容器。 + QLabel* conversationCountLabel_; ///< 显示会话数量的标签。 + QWidget* conversationListWidget_; ///< 承载会话列表的控件。 + QVBoxLayout* conversationListLayout_; ///< 会话按钮的布局。 + QPushButton* archiveHeaderButton_; ///< 归档区标题按钮(点击展开/折叠)。 + QWidget* archiveListWidget_; ///< 承载归档会话列表的控件。 + QVBoxLayout* archiveListLayout_; ///< 归档会话按钮的布局。 + bool archiveCollapsed_; ///< 归档区当前是否折叠。 + QPushButton* collapseButton_; ///< 侧边栏内的收缩按钮。 + QPushButton* newChatButton_; ///< 新建会话按钮。 + QComboBox* modelSelector_; ///< 模型选择下拉框。 + QWidget* sidebarNormalContent_; ///< 侧边栏展开时的内容容器。 + QWidget* sidebarCollapsedBar_; ///< 侧边栏收起时的窄条容器。 + QStackedWidget* conversationStack_; ///< 会话页面的堆叠控件。 + QList conversations_; ///< 所有会话面板的列表。 ChatConversationPanel* activeConversation_; ///< 当前激活的会话。 ChatSessionManager sessionManager_; ///< 会话管理器。 - bool sidebarCollapsed_; ///< 侧边栏当前是否处于收起状态。 - int sidebarExpandedWidth_; ///< 侧边栏展开时的宽度(像素)。 - QToolBar* chatMenuToolBar_; ///< Chat Tab 的菜单工具栏。 - QToolBar* chatModeToolBar_; ///< Chat Tab 的模式工具栏。 - QToolBar* chatFocusToolBar_; ///< Chat Tab 的焦点工具栏。 + bool sidebarCollapsed_; ///< 侧边栏当前是否处于收起状态。 + int sidebarExpandedWidth_; ///< 侧边栏展开时的宽度(像素)。 + QToolBar* chatMenuToolBar_; ///< Chat Tab 的菜单工具栏。 + QToolBar* chatModeToolBar_; ///< Chat Tab 的模式工具栏。 + QToolBar* chatFocusToolBar_; ///< Chat Tab 的焦点工具栏。 }; /** From c8ec150dcc5f93f4e3391c578e53ca55facece35 Mon Sep 17 00:00:00 2001 From: hongwei Date: Mon, 18 May 2026 20:53:36 +0800 Subject: [PATCH 4/4] format --- devel/0204.md | 50 +++++++++---------------- src/Plugins/Qt/qt_chat_tab_widget.cpp | 53 ++++----------------------- src/Plugins/Qt/qt_chat_tab_widget.hpp | 2 - 3 files changed, 26 insertions(+), 79 deletions(-) diff --git a/devel/0204.md b/devel/0204.md index 1ab7e63041..4fc3054bdc 100644 --- a/devel/0204.md +++ b/devel/0204.md @@ -1,16 +1,10 @@ # [0204] Chat Tab 归档 UI 与模型绑定 -## 相关文档 -- [docs/ai-sidebar/architecture-double-buffer-session.md](../docs/ai-sidebar/architecture-double-buffer-session.md) - 双 Buffer 会话架构 -- [docs/ai-sidebar/chat-session-manager.md](../docs/ai-sidebar/chat-session-manager.md) - 会话管理器 - ## 任务相关的代码文件 - `src/Plugins/Qt/qt_chat_tab_widget.hpp` - `src/Plugins/Qt/qt_chat_tab_widget.cpp` - `src/Scheme/L5/glue_widget.lua` - `TeXmacs/progs/dynamic/chat-adapter.scm` -- `TeXmacs/plugins/llm/goldfish/liii/llm.scm` -- `TeXmacs/plugins/llm/progs/init-llm.scm` ## 如何测试 @@ -33,10 +27,8 @@ 6. 右键归档会话,选择"恢复",确认会话回到 Conversations 列表 #### 模型绑定 -1. 确认侧边栏 "New chat" 按钮旁有模型选择下拉框 -2. 从下拉框选择一个模型,确认: - - 自动创建新会话 - - 新会话面板上方显示模型名称标签 +1. 确认会话面板上方显示当前模型名称标签 +2. 点击 "New chat" 按钮,确认创建新会话并使用当前 default model 3. 点击 AI 菜单中的模型名称,确认: - Chat Tab 获得焦点 - 自动创建一个使用该模型的新会话 @@ -67,16 +59,15 @@ git push origin hongwei/0204/add_session_manager - `ChatSession` 新增 `model` 字段 - `ChatSessionManager` 新增 `setModel`/`getModel` 方法 - `ChatConversationPanel` 新增 `modelLabel` 成员 - - `QTChatTabWidget` 新增 `archiveHeaderButton_`、`archiveListWidget_`、`archiveListLayout_`、`archiveCollapsed_`、`modelSelector_` 成员 + - `QTChatTabWidget` 新增 `archiveHeaderButton_`、`archiveListWidget_`、`archiveListLayout_`、`archiveCollapsed_` 成员 - 新增 `create_new_conversation_with_model()`、`newSessionWithModel()` 方法 - 新增 `qt_chat_tab_new_session()` 自由函数声明 2. `qt_chat_tab_widget.cpp`: - 侧边栏新增 "Archived (N)" 可折叠标题和归档列表容器 - `refresh_sidebar()` 重构:活跃/归档会话分别放入不同列表,归档会话点击切换到新会话 - - "New chat" 按钮旁增加模型选择 `QComboBox` - 会话面板顶部增加模型名称标签 - - `create_new_conversation()` 从 Scheme 获取当前模型 + - `create_new_conversation()` 从 Scheme 获取当前 default model - `create_new_conversation_with_model()` 创建指定模型的会话 3. `glue_widget.lua`: @@ -85,12 +76,6 @@ git push origin hongwei/0204/add_session_manager 4. `chat-adapter.scm`: - `open-llm-chat-tab` 在指定模型且 Chat Tab 已打开时调用 `qt-chat-tab-new-session` 创建新会话 -5. `liii/llm.scm`: - - 新增并导出 `llm-list-models` 函数(基于 `load-llm-menu` 获取可用模型列表) - -6. `init-llm.scm`: - - 新增 `chat-tab-list-models` tm-define 桥接函数,供 C++ 调用 - ## Why ### 归档区 UI @@ -101,10 +86,9 @@ git push origin hongwei/0204/add_session_manager - 点击归档会话时切换到新会话而非导航到归档内容 ### 模型绑定 -之前会话不绑定模型,模型选择是全局状态(`chat-tab-current-model`),切换模型影响所有后续发送。需要让每个会话绑定自己的模型,并在 UI 上显示: -- 用户明确知道当前会话使用的模型 -- 从 AI 菜单点击不同模型时创建新会话而非切换全局模型 -- "New chat" 时可选择模型 +之前会话不绑定模型,模型选择是全局状态(`chat-tab-current-model`),切换模型影响所有后续发送。需要让每个会话绑定自己的模型,并在 UI 上显示。 + +设计决策:不引入模型选择下拉框,避免 C++ 侧与 LLM 插件的模型列表耦合。用户通过 "New chat" 按钮创建新会话时,使用 Scheme 层当前的 default model;通过 AI 菜单点击某个模型时,Scheme 层切换 default model 并通知 C++ 创建新会话。 ## How @@ -115,7 +99,7 @@ git push origin hongwei/0204/add_session_manager ``` sidebarNormalContent_ ├── "Chat" 标题 - ├── New chat + 模型选择器 + ├── "New chat" 按钮 ├── "Conversations (N)" 标签 ├── conversationListWidget_ ← 活跃会话列表 ├── "Archived (N)" 标题按钮 ← 点击展开/折叠 @@ -133,28 +117,30 @@ sidebarNormalContent_ ### 模型绑定流程 ``` -用户点击模型 → Scheme open-llm-chat-tab(model) +"New chat" 按钮 → create_new_conversation() + → call("chat-tab-session-select-model", "") // 获取当前 default model + → sessionManager_.setModel(id, model) + → modelLabel->setText(model) + +AI 菜单点击模型 → Scheme open-llm-chat-tab(model) + → chat-tab-session-select-model(model) // 切换 default model → qt-chat-tab-new-session(model) → C++ newSessionWithModel(model) → create_new_conversation_with_model(model) → call("chat-tab-session-select-model", model) // 通知 Scheme - → sessionManager_.setModel(id, model) // C++ 侧记录 - → modelLabel->setText(model) // UI 显示 + → sessionManager_.setModel(id, model) + → modelLabel->setText(model) ``` -### 模型选择器 - -"New chat" 按钮旁的 `QComboBox` 在初始化时通过 `call("chat-tab-list-models")` 从 Scheme 获取可用模型列表。选择模型后调用 `create_new_conversation_with_model()` 创建新会话。 - ## 影响范围确认 本次修改涉及: - `QTChatTabWidget` 类的内部实现和公开接口 - Scheme 适配层 `chat-adapter.scm` -- LLM 插件库 `liii/llm.scm` 和初始化 `init-llm.scm` - Scheme→C++ 绑定 `glue_widget.lua` 不影响: +- LLM 插件内部实现(`liii/llm.scm`、`init-llm.scm` 无改动) - 会话发送、取消等核心消息逻辑 - 插件通信协议 - 其他 Qt 插件或窗口组件 diff --git a/src/Plugins/Qt/qt_chat_tab_widget.cpp b/src/Plugins/Qt/qt_chat_tab_widget.cpp index 96388b414c..868d8e09ef 100644 --- a/src/Plugins/Qt/qt_chat_tab_widget.cpp +++ b/src/Plugins/Qt/qt_chat_tab_widget.cpp @@ -26,7 +26,6 @@ #include #include -#include #include #include #include @@ -201,12 +200,11 @@ QTChatTabWidget::QTChatTabWidget (QWidget* parent) conversationListLayout_ (nullptr), archiveHeaderButton_ (nullptr), archiveListWidget_ (nullptr), archiveListLayout_ (nullptr), archiveCollapsed_ (true), newChatButton_ (nullptr), - modelSelector_ (nullptr), collapseButton_ (nullptr), - sidebarNormalContent_ (nullptr), sidebarCollapsedBar_ (nullptr), - conversationStack_ (nullptr), activeConversation_ (nullptr), - sidebarCollapsed_ (false), sidebarExpandedWidth_ (0), - chatMenuToolBar_ (nullptr), chatModeToolBar_ (nullptr), - chatFocusToolBar_ (nullptr) { + collapseButton_ (nullptr), sidebarNormalContent_ (nullptr), + sidebarCollapsedBar_ (nullptr), conversationStack_ (nullptr), + activeConversation_ (nullptr), sidebarCollapsed_ (false), + sidebarExpandedWidth_ (0), chatMenuToolBar_ (nullptr), + chatModeToolBar_ (nullptr), chatFocusToolBar_ (nullptr) { setFocusPolicy (Qt::StrongFocus); QHBoxLayout* mainLayout= new QHBoxLayout (this); @@ -268,14 +266,8 @@ QTChatTabWidget::setup_left_sidebar (QVBoxLayout* sidebarLayout) { DpiUtils::applyScaledFont (navTitle, kNavTitleFontPx); normalLayout->addWidget (navTitle); - // New chat 按钮和模型选择器 - QWidget* newChatRow= new QWidget (normalContent); - newChatRow->setObjectName ("chat-tab-new-chat-row"); - QHBoxLayout* newChatRowLayout= new QHBoxLayout (newChatRow); - newChatRowLayout->setContentsMargins (0, 0, 0, 0); - newChatRowLayout->setSpacing (DpiUtils::scaled (4)); - - newChatButton_= new QPushButton ("New chat", newChatRow); + // New chat 按钮 + newChatButton_= new QPushButton ("New chat", normalContent); newChatButton_->setObjectName ("chat-tab-new-btn"); newChatButton_->setFocusPolicy (Qt::NoFocus); newChatButton_->setCursor (Qt::PointingHandCursor); @@ -285,30 +277,7 @@ QTChatTabWidget::setup_left_sidebar (QVBoxLayout* sidebarLayout) { .arg (DpiUtils::scaled (kNavButtonPadX))); connect (newChatButton_, &QPushButton::clicked, this, [this] () { create_new_conversation (); }); - newChatRowLayout->addWidget (newChatButton_); - - modelSelector_= new QComboBox (newChatRow); - modelSelector_->setObjectName ("chat-tab-model-selector"); - modelSelector_->setFocusPolicy (Qt::NoFocus); - modelSelector_->setCursor (Qt::PointingHandCursor); - modelSelector_->setSizeAdjustPolicy (QComboBox::AdjustToContents); - // 从 Scheme 获取可用模型列表 - tree models_tree= as_tree (call ("chat-tab-list-models")); - if (is_tuple (models_tree)) { - for (int i= 0; i < N (models_tree); ++i) { - if (is_atomic (models_tree[i])) { - modelSelector_->addItem (to_qstring (models_tree[i]->label)); - } - } - } - connect (modelSelector_, QOverload::of (&QComboBox::activated), this, - [this] (int index) { - string model= from_qstring (modelSelector_->itemText (index)); - create_new_conversation_with_model (model); - }); - newChatRowLayout->addWidget (modelSelector_, 1); - - normalLayout->addWidget (newChatRow); + normalLayout->addWidget (newChatButton_); conversationCountLabel_= new QLabel ("Conversations (0)", normalContent); conversationCountLabel_->setObjectName ("chat-tab-conversation-count"); @@ -613,12 +582,6 @@ QTChatTabWidget::create_new_conversation_with_model (const string& model) { panel->modelLabel->setText (to_qstring (model)); } - // 同步模型选择器 - if (modelSelector_) { - int idx= modelSelector_->findText (to_qstring (model)); - if (idx >= 0) modelSelector_->setCurrentIndex (idx); - } - activate_conversation (panel); } diff --git a/src/Plugins/Qt/qt_chat_tab_widget.hpp b/src/Plugins/Qt/qt_chat_tab_widget.hpp index 46daec5b5a..f12f570745 100644 --- a/src/Plugins/Qt/qt_chat_tab_widget.hpp +++ b/src/Plugins/Qt/qt_chat_tab_widget.hpp @@ -29,7 +29,6 @@ class QString; class QToolBar; class QVBoxLayout; class QEvent; -class QComboBox; /** * @brief 聊天会话的生成状态。 @@ -292,7 +291,6 @@ class QTChatTabWidget : public QWidget { bool archiveCollapsed_; ///< 归档区当前是否折叠。 QPushButton* collapseButton_; ///< 侧边栏内的收缩按钮。 QPushButton* newChatButton_; ///< 新建会话按钮。 - QComboBox* modelSelector_; ///< 模型选择下拉框。 QWidget* sidebarNormalContent_; ///< 侧边栏展开时的内容容器。 QWidget* sidebarCollapsedBar_; ///< 侧边栏收起时的窄条容器。 QStackedWidget* conversationStack_; ///< 会话页面的堆叠控件。