diff --git a/TeXmacs/plugins/llm/progs/llm/chat-protocol.scm b/TeXmacs/plugins/llm/progs/llm/chat-protocol.scm index 2e78f53021..8f8cbdd1ed 100644 --- a/TeXmacs/plugins/llm/progs/llm/chat-protocol.scm +++ b/TeXmacs/plugins/llm/progs/llm/chat-protocol.scm @@ -70,6 +70,24 @@ ) ;with ) ;tm-define +(tm-define (chat-tab-prefillable-source? buf) + (:synopsis "Whether BUF is a valid source for chat input prefill") + ;; chat 相关 buffer(含 tmfs://chat-tab 与 tmfs://chat//*)不作为来源, + ;; 避免把聊天输入区自身的内容填回自身 + (and buf (not (string-starts? (url->system buf) "tmfs://chat"))) +) ;tm-define + +(tm-define (chat-tab-prefill-from-selection! session-id) + (:synopsis "Prefill the chat input with the current document selection") + (:argument session-id "Session UUID") + ;; 须在焦点离开文档编辑器之前调用,selection-tree 取自 current editor + (when (and (chat-tab-prefillable-source? (current-buffer)) (selection-active-any?)) + (chat-tab-prefill-input! (chat-tab-session->input-buffer session-id) + (selection-tree) + ) ;chat-tab-prefill-input! + ) ;when +) ;tm-define + ;;; ---------- 会话初始化 ---------- (tm-define (chat-tab-init-session! session-id model) diff --git a/TeXmacs/plugins/llm/progs/llm/chat-tree-ops.scm b/TeXmacs/plugins/llm/progs/llm/chat-tree-ops.scm index ee6eeb0496..6cbf83763c 100644 --- a/TeXmacs/plugins/llm/progs/llm/chat-tree-ops.scm +++ b/TeXmacs/plugins/llm/progs/llm/chat-tree-ops.scm @@ -369,6 +369,22 @@ ) ;with-buffer ) ;tm-define +(tm-define (chat-tab-should-prefill? input-buffer sel) + (:synopsis "Whether SEL should be prefilled into INPUT-BUFFER") + ;; 仅在输入区为空且选区内容非空白时预填,避免覆盖用户已输入内容 + (and sel + (not (chat-tab-empty-body? sel)) + (chat-tab-buffer-empty? (buffer-get-body input-buffer)) + ) ;and +) ;tm-define + +(tm-define (chat-tab-prefill-input! input-buffer sel) + (:synopsis "Prefill the chat input buffer with the selected content") + (when (chat-tab-should-prefill? input-buffer sel) + (chat-tab-set-input-body! input-buffer sel) + ) ;when +) ;tm-define + ;;; ---------- 追加对话轮次 ---------- (tm-define (chat-tab-append-round! message-buffer body model) diff --git a/TeXmacs/tests/1243.scm b/TeXmacs/tests/1243.scm new file mode 100644 index 0000000000..a1808f6774 --- /dev/null +++ b/TeXmacs/tests/1243.scm @@ -0,0 +1,154 @@ +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; +;; MODULE : 1243.scm +;; DESCRIPTION : 打开 AI 侧边栏时自动填入文档选区内容 +;; COPYRIGHT : (C) 2026 Mogan STEM +;; +;; This software falls under the GNU general public license version 3 or later. +;; It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE +;; in the root directory or . +;; +;; PURPOSE +;; chat-tab-should-prefill? / chat-tab-prefillable-source? 是纯判定, +;; headless 下直接断言。 +;; 端到端用例以间谍替换写入函数 chat-tab-set-input-body!(其内部 +;; with-buffer + buffer-focus 仅对已有嵌入式视图的生产输入 buffer +;; 有效),验证选区捕获与参数传递;真实写入路径由 +;; chat-tab-clear-input! 的生产调用(每次发送后清空输入区)覆盖。 +;; +;; USAGE +;; xmake b stem && xmake r 1243 +;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(texmacs-module (texmacs tests 1243) + (:use (utils library cursor)) +) ;texmacs-module + +(import (liii check)) + +(check-set-mode! 'report) + +;;; ---------- 辅助 ---------- + +(define (in-buf sid) + (chat-tab-session->input-buffer sid) +) ;define + +(define (reset-input! sid) + (buffer-set-body (in-buf sid) '(document "")) +) ;define + +;;; ---------- chat-tab-should-prefill?(纯判定,headless) ---------- + +;; 输入区为空 + 选区非空 => 预填 +(define (test-should-prefill-empty-input) + (let ((sid "test1243a")) + (reset-input! sid) + (check (chat-tab-should-prefill? (in-buf sid) + (stree->tree '(document "hello"))) + => #t) + ) ;let +) ;define + +;; 输入区已有内容 => 不覆盖 +(define (test-should-prefill-nonempty-input) + (let ((sid "test1243b")) + (buffer-set-body (in-buf sid) '(document "typed")) + (check (chat-tab-should-prefill? (in-buf sid) + (stree->tree '(document "hello"))) + => #f) + ) ;let +) ;define + +;; 选区为 #f(无选区)=> 不预填 +(define (test-should-prefill-false-selection) + (let ((sid "test1243c")) + (reset-input! sid) + (check (chat-tab-should-prefill? (in-buf sid) #f) => #f) + ) ;let +) ;define + +;; 选区为纯空白 => 不预填 +(define (test-should-prefill-blank-selection) + (let ((sid "test1243d")) + (reset-input! sid) + (check (chat-tab-should-prefill? (in-buf sid) + (stree->tree '(document " "))) + => #f) + ) ;let +) ;define + +;; 选区为原子串(selection-tree 对单行选区的实际返回形态)=> 预填 +(define (test-should-prefill-atomic-selection) + (let ((sid "test1243e")) + (reset-input! sid) + (check (chat-tab-should-prefill? (in-buf sid) (string->tree "plain text")) + => #t) + ) ;let +) ;define + +;;; ---------- chat-tab-prefillable-source?(来源 buffer 守卫) ---------- + +(define (test-prefillable-source) + (check (chat-tab-prefillable-source? (string->url "tmfs://chat/abc/input")) => #f) + (check (chat-tab-prefillable-source? (string->url "tmfs://chat/abc/message")) => #f) + (check (chat-tab-prefillable-source? (string->url "tmfs://chat-tab")) => #f) + (check (chat-tab-prefillable-source? (string->url "tmfs://startup-tab")) => #t) + (check (chat-tab-prefillable-source? (string->url "/tmp/test1243.tm")) => #t) +) ;define + +;;; ---------- 端到端(选区 → 写入调用链) ---------- + +;; 写入函数 chat-tab-set-input-body! 内部走 with-buffer + buffer-focus, +;; 仅对已有嵌入式视图的生产输入 buffer 有效;测试中以间谍替换, +;; 验证包装函数正确捕获选区并以正确参数触发写入 +(define prefill-spy '()) + +(define (test-prefill-from-selection-e2e) + (let ((sid "test1243e2e")) + (reset-input! sid) + (set! prefill-spy '()) + (tm-define (chat-tab-set-input-body! input-buffer body) + (set! prefill-spy (list input-buffer body)) + ) ;tm-define + ;; headless 验证过当前 buffer 上 insert/select-all 可用 + (insert "pick me") + (select-all) + (check (selection-active-any?) => #t) + (chat-tab-prefill-from-selection! sid) + (check (url->string (car prefill-spy)) => "tmfs://chat/test1243e2e/input") + ;; selection-tree 对单行选区返回原子串 + (check (tree->stree (cadr prefill-spy)) => "pick me") + ) ;let +) ;define + +;; 无选区时包装函数不触发写入 +(define (test-prefill-from-selection-no-selection) + (let ((sid "test1243e2f")) + (reset-input! sid) + (set! prefill-spy '()) + (selection-cancel) + (check (selection-active-any?) => #f) + (chat-tab-prefill-from-selection! sid) + (check prefill-spy => '()) + ) ;let +) ;define + +;;; ---------- 入口 ---------- + +(tm-define (test_1243) + ;; llm 插件按 idle 延迟初始化,headless 下测试先于插件加载执行 + (use-modules (llm chat-loader)) + (test-should-prefill-empty-input) + (test-should-prefill-nonempty-input) + (test-should-prefill-false-selection) + (test-should-prefill-blank-selection) + (test-should-prefill-atomic-selection) + (test-prefillable-source) + ;; e2e 会修改当前 buffer 内容并重定义写入函数,须最后执行 + (test-prefill-from-selection-e2e) + (test-prefill-from-selection-no-selection) + (check-report) + (when (getenv "MOGAN_TEST_GUI") (quit-TeXmacs)) +) ;tm-define diff --git a/devel/1243.md b/devel/1243.md new file mode 100644 index 0000000000..bd97465475 --- /dev/null +++ b/devel/1243.md @@ -0,0 +1,106 @@ +# [1243] 打开 AI 侧边栏时自动填入文档选区内容 + +## 1 相关文档 + +- [dddd.md](dddd.md) - 任务文档模板 +- [1230.md](1230.md) - chat 首轮回显问题(texmacs_input_widget 覆盖 buffer 的教训) + +## 2 任务相关的代码文件 + +- `src/Plugins/Qt/qt_tm_widget.cpp` — `sync_chat_sidebar_mode()`,侧边栏显隐同步,预填钩子挂在这里 +- `TeXmacs/plugins/llm/progs/llm/chat-tree-ops.scm` — 纯判定 `chat-tab-should-prefill?` 与写入 `chat-tab-prefill-input!` +- `TeXmacs/plugins/llm/progs/llm/chat-protocol.scm` — 来源守卫 `chat-tab-prefillable-source?` 与包装 `chat-tab-prefill-from-selection!` +- `TeXmacs/tests/1243.scm` — 集成测试 + +## 3 如何测试 + +### 3.1 确定性测试(集成测试,headless 可跑全部断言) + +```bash +xmake b stem && xmake r 1243 +``` + +当前结果:15 correct, 0 failed(headless 与 GUI 模式均通过)。 + +覆盖: +- `chat-tab-should-prefill?` 全组合:输入区空/非空 × 选区有效/#f/纯空白/原子串 +- `chat-tab-prefillable-source?`:chat 相关 buffer(`tmfs://chat/`、 + `tmfs://chat-tab`)不作为预填来源 +- 端到端:在当前 buffer 构造真实选区,间谍替换写入函数 + `chat-tab-set-input-body!`(其内部 `with-buffer` + `buffer-focus` 仅对 + 已有嵌入式视图的生产输入 buffer 有效,测试环境无视图会挂起/短路), + 验证选区捕获与参数传递;真实写入路径由 `chat-tab-clear-input!` + 的生产调用(每次发送后清空输入区)覆盖 + +### 3.2 非确定性测试(GUI 验证,需非 community 构建) + +community 构建没有 AI 侧边栏(`chatSideDock` 为空, +`sync_chat_sidebar_mode` 直接返回),C++ 钩子需非 community 配置构建后 +手工验证: + +```bash +xmake f --is_community=n -c --yes +xmake b stem +``` + +手工步骤: + +1. 打开一个文档,选中一段文字 +2. 点击文档区右上角的 AI 对话按钮(或快捷键 `std j`) +3. 侧边栏打开,输入区自动出现选中内容,光标在输入区 +4. 不发送,关闭侧边栏后修改输入内容再重开:已有内容不应被覆盖 +5. 未选中任何内容时打开侧边栏:输入区保持空白 + +## 4 如何提交 + +```bash +gf fmt --changed-since=main +git push +``` + +## 5 What + +文档中存在选区时,打开 AI 对话侧边栏(点击右上角按钮或 `std j`), +若当前会话输入区为空,则自动把选中内容填入输入区。 + +1. scheme 纯判定 `chat-tab-should-prefill?`:输入区为空且选区非空白 +2. scheme 写入 `chat-tab-prefill-input!`:复用生产已有写入路径 + `chat-tab-set-input-body!` +3. scheme 包装 `chat-tab-prefill-from-selection!`:捕获当前编辑器选区, + 守卫 chat 相关 buffer 不作为来源 +4. C++ 钩子:`sync_chat_sidebar_mode()` 在侧边栏「隐藏→显示」转换时、 + 焦点切到聊天输入框之前调用上述包装函数 + +## 6 Why + +用户选中一段内容后想让 AI 处理(解释、翻译、润色等), +目前需要手动复制粘贴到对话框。自动填入减少一步操作。 + +只在输入区为空时填入,避免覆盖用户已输入的内容; +只在侧边栏从隐藏变为显示时触发,避免 tab 切换等场景反复打扰。 + +## 7 How + +- 预填判定全部放在 scheme 侧,复用已有的 `chat-tab-buffer-empty?` / + `chat-tab-empty-body?` / `chat-tab-set-input-body!`,C++ 只负责在正确的 + 时机调一行 `call`。 +- 时机选择 `sync_chat_sidebar_mode()` 中 `chatSideDock->show()` 之后、 + `focusInput()` 之前:此时 current editor 仍是文档编辑器(按钮是 + `Qt::NoFocus`,点击不夺焦点),`selection-tree` 能取到文档选区。 + 该函数同时覆盖按钮点击和 scheme `show-chat-sidebar`(快捷键)两条路径。 +- 用 `chatSideDock->isVisible()` 做隐藏→显示的转换检测,避免窗口布局 + 刷新、tab 切换恢复等重复调用 sync 时重复预填。 +- 焦点已在 chat 相关 buffer(`tmfs://chat` 前缀,含 chat-tab 与 + 会话 input/message)时不预填,避免把聊天输入区自身的内容填回自身。 + +### 测试过程中的坑(记录) + +- `with-buffer` 内部经 `buffer-focus` 切换视图,对无视图的 buffer 在 + headless 下会挂起(`buffer-set-body` 直接建的 buffer 即无视图); + 生产环境输入 buffer 由 `texmacs_input_widget` 创建嵌入式视图,无此问题。 + 因此测试用间谍替换写入函数,只验证判定与参数传递。 +- headless 下当前 buffer(`tmfs://startup-tab`)上可直接 + `insert` / `select-all` / `selection-tree` 构造真实选区。 +- `check` 的输出经 stdout 缓冲,进程 `(exit 1)` 异常退出时缓冲丢失, + 日志末尾只能看到最后几个用例的结果——误判过「前面的用例没跑」。 +- 0205 在本分支基线上已有失败(`chat-tab-get-state` unbound),与本改动无关。 diff --git a/src/Plugins/Qt/qt_tm_widget.cpp b/src/Plugins/Qt/qt_tm_widget.cpp index 52a134d31c..1302bb1009 100644 --- a/src/Plugins/Qt/qt_tm_widget.cpp +++ b/src/Plugins/Qt/qt_tm_widget.cpp @@ -1400,6 +1400,7 @@ qt_tm_widget_rep::sync_chat_sidebar_mode () { if (!layout) return; if (chatSidebarMode) { + bool wasVisible= chatSideDock->isVisible (); // 确保不与全屏聊天模式同时存在 if (chatTabMode) { chatTabMode= false; @@ -1449,6 +1450,13 @@ qt_tm_widget_rep::sync_chat_sidebar_mode () { chatSideDock->show (); chatContentWidget->show (); + // 侧边栏由隐藏转为显示时,若输入区为空则预填文档选区内容。 + // 必须在 focusInput 之前调用:此时 current editor 仍是文档编辑器, + // scheme 侧的 selection-tree 才能取到选区 + if (!wasVisible && chatWidget && chatWidget->activeConversation ()) { + call ("chat-tab-prefill-from-selection!", + chatWidget->activeConversation ()->sessionId ()); + } // 焦点切到聊天输入框 if (chatWidget && chatWidget->activeConversation ()) { chatWidget->activeConversation ()->focusInput ();