From 894684470c7e7ca9d5982459705866b1bfa3b0fe Mon Sep 17 00:00:00 2001 From: pigmagicfly <2831850183@qq.com> Date: Mon, 7 Sep 2026 14:50:25 +0800 Subject: [PATCH 1/6] =?UTF-8?q?[0957]=20=E4=BB=BB=E5=8A=A1=E6=96=87?= =?UTF-8?q?=E6=A1=A3=EF=BC=9Achat=20=E5=8D=8F=E8=AE=AE=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=EF=BC=88%chat=20=E6=89=A9=E5=8F=82=20+=20=E5=81=87=20goldfish?= =?UTF-8?q?=20=E5=8D=8F=E8=AE=AE=E5=9B=9E=E4=BC=A0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devel/0957.md | 177 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 devel/0957.md diff --git a/devel/0957.md b/devel/0957.md new file mode 100644 index 0000000000..cf95110829 --- /dev/null +++ b/devel/0957.md @@ -0,0 +1,177 @@ +# 0957 协议升级:chat-tab-send 扩参 + 新 %chat 协议 + 假 goldfish 回显 + manifest 迁移确认 + +## What + +1. `chat-tab-send` / `chat-tab-session-send` 由 4 参数扩为 6 参数: + 新增 `base-url`(模型服务端接口,已拼 site 的绝对 URL)与 + `default-system`(模型默认系统提示,仅新会话首轮非空)。 +2. `%chat` 协议 params 定稿为 + `{model, baseUrl, thinking, search, default_system}` + (`baseUrl` 驼峰、`default_system` 下划线,值域 `enabled|disabled`); + `images` 数组从协议移除,相关组装代码一并删除。 +3. C++ `onSendRequested` 发送前从 `ChatModelStore` 取当前模型的 + `baseUrl`/`defaultSystem` 下发;模型不在清单内时经 `find()` 兜底。 +4. 假 goldfish `tm-llm.scm` 识别 `%chat ` 前缀,用 `(liii json)` 解析 + 并以 %chat 协议原格式回传(content 换为假回复文本,params/ + sessionId 原样保留,**不联网**);解析失败回退原文回显。 +5. serializer 透传核查(G7):确认 `object->string` 对 + `(document "%chat ...")` 会加引号/转义并包外壳,**透传不正确**, + 已按 liii 旧版 `serialize-as-code` 思路修正 `llm-serialize`。 + +## Why + +- 模型切换(PR-M3/0955)只写 `ChatSession.model` 与 manifest,发送 + 协议仍是旧的 4 参数形态,子进程收不到当前模型的 `baseUrl` 与默认 + 系统提示,「模型切换真正下发到子进程」无法成立。 +- 旧 `%chat` 协议带 `images` 数组,但图片上传属第二阶段,且发送入口 + 已对含图片输入提示不支持;协议里保留该字段只会误导后续实现。 +- 社区版假 goldfish 原样 echo 序列化文本,升级协议后无法供无网络 + 环境验证协议字段是否正确下发,需要识别协议行并回显结构化摘要。 + +## How + +### 1. `TeXmacs/plugins/llm/progs/llm/chat-protocol.scm` + +- `` record 扩为七字段: + `input session-id model base-url thinking search default-system`, + `make-chat-input` 与访问器同步。 +- `chat-tab-build-context-input`:params 依次写入 `model`、`baseUrl`、 + `thinking`、`search`、`default_system`;顶层仍是 `sessionId` + + `content`。 +- 删除图片链路:`chat-tab-collect-images`、`chat-tab-image-node->pair`、 + `chat-tab-suffix->mime` 及 `images` 组装(输入区图片暂按纯文本参与 + `content`,含图片时 C++ 侧仍提前拦截提示不支持)。 + +### 2. `src/Plugins/Qt/qt_chat_controller.hpp/.cpp` + +`onSendRequested` 发送块改为: + +```cpp +ChatModelInfo info = modelStore_.find (session->model); +string baseUrl = resolveBaseUrl (info.baseUrl, currentStemSite ()); +string defSys = isFirstRound (sessionId) ? info.defaultSystem : ""; +call ("chat-tab-send", {sessionId, info.key, baseUrl, + thinkingStr, searchStr, defSys}); // array +``` + +- `resolveBaseUrl (baseUrl, site)`(public static,便于测试): + 以 `http` 开头 → 原样;为空 → 空串(子进程兜底);否则 `site * + baseUrl`。 +- `currentStemSite ()`(O1 结论):复用 `(account liii)` 模块既有的 + `current-stem-site` 桥,`(catch #t (lambda () (when (not (defined? + 'current-stem-site)) (use-modules (account liii))) (current-stem-site)) + (lambda args ""))`;模块缺失或求值失败回退空串,相对 base_url 原样 + 下传由子进程兜底拼 site。**未新增配置项**。注:profile 非 + production/staging 时 `current-stem-site` 返回 `"local"`,按规则 + 照拼下传(假插件仅回显,无副作用)。 +- `isFirstRound (sessionId)` 判定口径:**message buffer 尚无对话轮次** + ——buffer 不存在、body 无 `session` 外壳、或 session 内层 + `document` 为空,均视为首轮;与 scheme 侧 `chat-tab-buffer-empty?` + 的空判语义一致。不用 `ChatSession.title`(发送前标题已生成)、不用 + `ChatSession.registered`(首轮发送时已置位)。本轮消息在判定之后的 + `chat-tab-send` 才写入,判定点不受影响。 + +### 3. `TeXmacs/plugins/llm/goldfish/tm-llm.scm` + +- 新增 import `(liii json)` / `(liii string)`(均为基础库,未引入 + `(liii http)`,**不发起任何网络请求**)。 +- `fake-llm-chat-reply`:剥掉 `%chat ` 前缀后 `string->json` 解析; + 顶层非对象、解析抛错或 `params` 非对象均返回 `#f`;成功则以 + `%chat {json}` **协议原格式回传**——`content` 替换为 + `[fake-llm] 我收到了你的消息:<原 content>`,`params` + (model/baseUrl/thinking/search/default_system)与 `sessionId` + 原样保留,全保真回显协议字段。 +- `eval-and-print`:`%chat ` 前缀且解析成功 → 回显 %chat 回传行, + 否则回退 echo 原文;两者统一走 `flush-verbatim`(`utf8:` 纯文本 + 通道)。`*large-data-threshold*` 写临时文件逻辑不变。 + - **不得用 `flush-scheme` 回显自由文本**(首测踩坑):`scheme:` + 通道经 `scheme_to_tree` 解析,自由文本首词会被当作复合树的 + label、其余全部成为子节点,渲染只剩 `**fake-llm**` 一个标签且 + 打断本轮完成(超时)。旧 echo 侥幸可用是因为旧协议行 + `(document "...")` 本身即合法树代码。 + - 也不用 `markdown:`:社区版未注册 markdown 转换格式 + (`init-research.scm` 中 markdown 仅非社区版 `lazy-format`)。 + +### 4. `TeXmacs/plugins/llm/progs/init-llm.scm`(G7 核查后的修正) + +核查结论:`connection_write` → `plugin-serialize` → `llm-serialize` +收到的 stree 为 `(document "%chat {json}")`,`object->string` 输出 +`(document "%chat {\"a\":1}")`——带引号、带转义、包外壳,**当前 +main 上 `%chat` 并非原样透传**(已用 `gf eval` 验证)。修正: +`(document "%…")` 形态的文档(首子节点为 `%` 开头字符串)原样输出 +该字符串 + `\n\n`;其余输入维持 `object->string` 行为。mogan +内 `%` 前缀消息仅有 `%chat` 一种发送方,无其他 magic 命令受影响。 + +### 5. manifest 迁移确认(不新增迁移代码) + +发送路径中 `session->model` 恒为清单内 key:`activateSession` 对 +不在清单内的旧值做内存回退(M3/0955 已合入),`onModelSelected` +拒绝清单外 key;即便极端场景残留旧值,`ChatModelStore::find` 也会以 +该 key 兜底构造 Info。发送后 `updateManifest` 落盘的 `model` 即 +真实模型 id,回退 → 发送 → 落新值的链路成立,无需迁移代码。 + +### 6. 测试 + +`tests/Plugins/Qt/qt_chat_model_test.cpp` 补 +`ChatController::resolveBaseUrl` 四个用例:http 前缀原样、空串兜底、 +相对路径拼 site、site 为空时相对路径原样下传。 + +## 涉及文件 + +- `TeXmacs/plugins/llm/progs/llm/chat-protocol.scm` +- `TeXmacs/plugins/llm/progs/init-llm.scm` +- `TeXmacs/plugins/llm/goldfish/tm-llm.scm` +- `src/Plugins/Qt/qt_chat_controller.hpp` / `.cpp` +- `tests/Plugins/Qt/qt_chat_model_test.cpp` +- `devel/0957.md` + +## 行为不变量核对 + +1. 社区版假插件:发送后消息区出现包含 6 个协议字段的假回显;不联网 + ——代码层保证(无 `(liii http)` import),GUI 效果待手工验证。 +2. 除 `chat-tab-send` 外,其余 C++→Scheme 调用签名未动 + (`chat-persist-*`、`chat-tab-cancel` 等均未改)。 +3. `chat-tab-cancel`、持久化、恢复、导出路径未触碰。 +4. `%chat` 不再带 `images`,组装代码已删(`chat-tab-tree-has-image?` + 拦截保留)。 +5. chat 标签页与 dock 侧边栏共用 `onSendRequested`,行为一致。 + +## 验证 + +- `xmake b --yes stem` 通过;`qt_chat_model_test` 23 项全过(含 4 个 + 新增)、`qt_chat_controller_test` 20 项全过;`gf fmt + --changed-since=main` 已运行,无无关改动。 +- 三个改动 `.scm` 经 gf reader 解析无错;真实二进制 headless + (`xmake r stem -headless -d -b <诊断脚本> -q`)加载 + `(llm chat-protocol)` 并实际构建协议行,输出 + `%chat {"content":"你好","params":{"baseUrl":…,"default_system":…, + "model":"kimi-k3","search":"disabled","thinking":"enabled"}, + "sessionId":"sid-1"}`(键名、值域符合契约);该输出回喂 gf 中同版 + 摘要函数,六字段全部正确回显。 +- serializer 新逻辑三分支(%chat 行原样 / 普通文本旧行为 / 空表兜底) + 经 `gf eval` 验证;读端 `read-paragraph-by-visible-eof` 对 + `%chat …\n\n` 的消费经管道模拟验证。 +- 假插件手工管道验证(bundle 内 goldfish 二进制 + 更新后脚本):真实 + 长度 `default_system`(473 字符,含 `\n` 转义、`\\text{}`、`…`、 + `$…$`)解析正确,回传 `%chat {"content":"[fake-llm] 我收到了你的 + 消息:介绍你自己","params":{…五字段全保真…},"sessionId":"…"}` + 经 `utf8:` 通道完整回显;损坏 JSON 回退原文回显。注意 app bundle + 的 `TeXmacs/plugins` 数据经 `xmake install stem` 才刷新 + (`xmake b stem` 不会重拷新的 `.scm`)。 + +## 首测问题记录(已修复) + +首次 GUI 验证(2026-09-07 14:05)回复区只显示 `**fake-llm**` 且随后 +报 script 超时:假回显最初经 `flush-scheme` 下发,`scheme_to_tree` +把自由文本解析成「首词 `**[fake-llm]**` 作 label、其余作子节点」的 +畸形树,渲染只剩标签并中断本轮完成信号。改为 `flush-verbatim` +(含解析失败回退分支)后管道复验通过。回显格式随后按用户要求由 +Markdown 摘要改为 %chat 协议原格式回传。 + +## 待手工 GUI 验证 + +发送一条消息,确认假回显为 %chat 原格式回传行:`content` 为 +`[fake-llm] 我收到了你的消息:<输入>`,`params` 中 +`model`/`baseUrl`/`thinking`/`search` 与 UI 选择一致;新会话首轮 +`default_system` 非空、第二轮为空;切换模型后下一轮 `model` 变化; +无 script 超时报错。 From dba16b78a9f97dc4122e15afd6b8b4ea70bbdd8a Mon Sep 17 00:00:00 2001 From: pigmagicfly <2831850183@qq.com> Date: Mon, 7 Sep 2026 14:50:36 +0800 Subject: [PATCH 2/6] =?UTF-8?q?[0957]=20chat-tab-send=20=E6=89=A9=E5=8F=82?= =?UTF-8?q?=206=20=E5=8F=82=E6=95=B0=20+=20%chat=20=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E5=AE=9A=E7=A8=BF=20+=20=E5=81=87=20goldfish=20=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E5=9B=9E=E4=BC=A0=20+=20serializer=20=E9=80=8F?= =?UTF-8?q?=E4=BC=A0=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chat-protocol.scm: 七字段;params 写入 model/baseUrl/thinking/search/default_system;移除 images 图片链路 - qt_chat_controller:发送前从 ChatModelStore 取 baseUrl(拼 site,复用 account 模块 current-stem-site)与 defaultSystem(仅首轮非空) - tm-llm.scm:%chat 解析后协议原格式回传(content 换假回复文本),utf8: 通道回显,不联网 - init-llm.scm:llm-serialize 对 % 开头单字符串文档原样透传(object->string 会加引号转义包外壳) --- TeXmacs/plugins/llm/goldfish/tm-llm.scm | 38 +++++- TeXmacs/plugins/llm/progs/init-llm.scm | 13 ++- .../plugins/llm/progs/llm/chat-protocol.scm | 110 ++++-------------- src/Plugins/Qt/qt_chat_controller.cpp | 55 ++++++++- src/Plugins/Qt/qt_chat_controller.hpp | 30 +++++ tests/Plugins/Qt/qt_chat_model_test.cpp | 43 +++++++ 6 files changed, 192 insertions(+), 97 deletions(-) diff --git a/TeXmacs/plugins/llm/goldfish/tm-llm.scm b/TeXmacs/plugins/llm/goldfish/tm-llm.scm index 1f99ccaaa2..16ccf5299e 100644 --- a/TeXmacs/plugins/llm/goldfish/tm-llm.scm +++ b/TeXmacs/plugins/llm/goldfish/tm-llm.scm @@ -18,7 +18,7 @@ ;; limitations under the License. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(import (texmacs protocol) (liii path) (liii uuid)) +(import (texmacs protocol) (liii path) (liii uuid) (liii json) (liii string)) (define (welcome) (flush-prompt "llm> ") @@ -37,10 +37,44 @@ ) ;let* ) ;define +;; 假插件不联网:把收到的 "%chat {json}" 协议行原样回传——content 换成 +;; 假回复文本,params/sessionId 原样保留,供无网络环境验证协议字段 +;; 双向链路;解析失败返回 #f,由调用方回退原文回显 + +(define (fake-llm-chat-reply data) + (let* ((payload (string-trim (string-drop data (string-length "%chat ")))) + (j (catch #t (lambda () (string->json payload)) (lambda args #f))) + ) ; + (if (not (json-object? j)) + #f + (let ((content (json-ref-string j "content" "")) + (params (catch #t (lambda () (json-ref j "params")) (lambda args #f))) + ) ; + (if (not (json-object? params)) + #f + (string-append "%chat " + (json->string (json-set j + "content" + (string-append "[fake-llm] 我收到了你的消息:" content) + ) ;json-set + ) ;json->string + ) ;string-append + ) ;if + ) ;let + ) ;if + ) ;let* +) ;define + (define (eval-and-print data) + ;; 文本回显一律走 utf8: 通道:scheme: 通道要求负载是表示树的 scheme + ;; 代码,自由文本会被解析成「首词作树标签」的畸形树,渲染只剩标签且 + ;; 打断本轮完成(超时)。旧 echo 侥幸可用是因为旧协议行恰好形如 + ;; (document "..."),本身即是合法树代码 (if (> (string-length data) *large-data-threshold*) (flush-verbatim (llm-write-temp-file data)) - (flush-scheme data) + (let ((reply (and (string-starts? data "%chat ") (fake-llm-chat-reply data)))) + (flush-verbatim (or reply data)) + ) ;let ) ;if ) ;define diff --git a/TeXmacs/plugins/llm/progs/init-llm.scm b/TeXmacs/plugins/llm/progs/init-llm.scm index acad708f42..60febd2cab 100644 --- a/TeXmacs/plugins/llm/progs/init-llm.scm +++ b/TeXmacs/plugins/llm/progs/init-llm.scm @@ -13,7 +13,18 @@ (import (liii path)) (define (llm-serialize lan t) - (string-append (object->string t) "\n\n") + ;; connection-write 调用前已做 tree_herk_to_utf8,传入的 stree 字符串是 + ;; UTF-8。% 开头的单字符串文档(%chat 协议行)须原样透传:object->string + ;; 会给字符串加引号/转义并包 (document ...) 外壳,子进程将无法识别协议行 + (if (and (pair? t) + (>= (length t) 2) + (eq? (car t) 'document) + (string? (cadr t)) + (string-starts? (cadr t) "%") + ) ;and + (string-append (cadr t) "\n\n") + (string-append (object->string t) "\n\n") + ) ;if ) ;define (define (llm-launcher) diff --git a/TeXmacs/plugins/llm/progs/llm/chat-protocol.scm b/TeXmacs/plugins/llm/progs/llm/chat-protocol.scm index 6d48bd6c80..efe594e144 100644 --- a/TeXmacs/plugins/llm/progs/llm/chat-protocol.scm +++ b/TeXmacs/plugins/llm/progs/llm/chat-protocol.scm @@ -30,13 +30,15 @@ ;;; ---------- Record Type ---------- (define-record-type - (make-chat-input input session-id model thinking search) + (make-chat-input input session-id model base-url thinking search default-system) chat-input? (input chat-input-input) (session-id chat-input-session-id) (model chat-input-model) + (base-url chat-input-base-url) (thinking chat-input-thinking) (search chat-input-search) + (default-system chat-input-default-system) ) ;define-record-type ;;; ---------- Buffer 类型检测 ---------- @@ -326,108 +328,30 @@ ;;; ---------- 上下文构建 ---------- -(define (chat-tab-suffix->mime suffix) - (cond ((== suffix "png") "image/png") - ((or (== suffix "jpg") (== suffix "jpeg")) "image/jpeg") - ((== suffix "gif") "image/gif") - ((== suffix "webp") "image/webp") - (else #f) - ) ;cond -) ;define - -(define (chat-tab-image-node->pair img-stree) - ;; img-stree = (image ...) - ;; Returns (mime . base64-data) or #f - (if (< (length img-stree) 2) - #f - (let ((name (cadr img-stree))) - (cond - ;; Embedded: (tuple (raw-data ) ) - ((and (pair? name) (eq? (car name) 'tuple) (>= (length name) 3)) - (let ((data-node (cadr name)) (suffix-str (caddr name))) - (let ((suffix (url-suffix suffix-str)) - (mime (chat-tab-suffix->mime (url-suffix suffix-str))) - ) ; - (if (not mime) - #f - (cond - ;; raw-data format: data already base64 - ((and (pair? data-node) - (>= (length data-node) 2) - (eq? (car data-node) 'raw-data) - ) ;and - (cons mime (cadr data-node)) - ) ; - (else #f) - ) ;cond - ) ;if - ) ;let - ) ;let - ) ; - ;; Linked: string path — 需要读文件并 base64 编码 - ((string? name) - ;; TODO: 需要加载 (liii base64) 后支持链接图片的 base64 编码 - #f - ) ; - (else #f) - ) ;cond - ) ;let - ) ;if -) ;define - -(define (chat-tab-collect-images s acc) - (cond ((string? s) acc) - ((not (pair? s)) acc) - ((eq? (car s) 'image) - (let ((img (chat-tab-image-node->pair s))) - (if img (cons img acc) acc) - ) ;let - ) ; - (else (let loop - ((rest (cdr s)) (a acc)) - (if (null? rest) a (loop (cdr rest) (chat-tab-collect-images (car rest) a))) - ) ;let - ) ;else - ) ;cond -) ;define - (define (chat-tab-build-context-input ctx) ;; 单轮:只编码当前用户输入 + per-round 参数 ;; 线格式:%chat \n\n + ;; images 数组已随协议移除:图片上传属第二阶段,输入区图片暂按纯文本 + ;; 参与 content(含图片时 C++ 侧已提前拦截提示不支持) (let* ((input (chat-input-input ctx)) (session-id (chat-input-session-id ctx)) (model (chat-input-model ctx)) + (base-url (chat-input-base-url ctx)) (thinking (chat-input-thinking ctx)) (search (chat-input-search ctx)) + (default-system (chat-input-default-system ctx)) (content (chat-tab-tree->plain-text input)) (obj (string->njson "{}")) (params (string->njson "{}")) - (stree-input (if (tree? input) (tree->stree input) input)) - (images (chat-tab-collect-images stree-input '())) ) ; (njson-set! obj "sessionId" session-id) (njson-set! params "model" model) + (njson-set! params "baseUrl" base-url) (njson-set! params "thinking" thinking) (njson-set! params "search" search) + (njson-set! params "default_system" default-system) (njson-set! obj "params" params) (njson-set! obj "content" content) - ;; 可选:有图片时加入 images 数组 - (when (pair? images) - (let ((img-arr (string->njson "[]"))) - (for-each (lambda (img-pair) - (let ((img-obj (string->njson "{}"))) - (njson-set! img-obj "mime" (car img-pair)) - (njson-set! img-obj "data" (cdr img-pair)) - (njson-append! img-arr img-obj) - (njson-free img-obj) - ) ;let - ) ;lambda - images - ) ;for-each - (njson-set! obj "images" img-arr) - (njson-free img-arr) - ) ;let - ) ;when (let ((json-str (njson->string obj))) (njson-free params) (njson-free obj) @@ -460,12 +384,16 @@ ;;; ---------- 发送 ---------- -(tm-define (chat-tab-session-send session-id model thinking search) +(tm-define (chat-tab-session-send session-id model base-url thinking search default-system) (:synopsis "Send user message through chat tab session") (:argument session-id "Session UUID") (:argument model "Model name") + (:argument base-url "Resolved service endpoint URL") (:argument thinking "Thinking mode: enabled or disabled") (:argument search "Search mode: enabled or disabled") + (:argument default-system + "Model default system prompt, non-empty on first round only" + ) ;:argument (let* ((in-buf (chat-tab-session->input-buffer session-id)) (body (buffer-get-body in-buf)) ) ; @@ -511,7 +439,9 @@ #t ) ;begin (begin - (let ((ctx (make-chat-input input session-id model thinking search))) + (let ((ctx (make-chat-input input session-id model base-url thinking search default-system) + ) ;ctx + ) ; (chat-tab-session-feed chat-tab-session-name plugin-ses ctx out '()) ) ;let #t @@ -552,13 +482,15 @@ ) ;if ) ;tm-define -(tm-define (chat-tab-send session-id model thinking search) +(tm-define (chat-tab-send session-id model base-url thinking search default-system) (:synopsis "Adapter send entry for a chat tab") (:argument session-id "Session UUID") (:argument model "Model name") + (:argument base-url "Resolved service endpoint URL") (:argument thinking "Thinking mode") (:argument search "Search mode") - (chat-tab-session-send session-id model thinking search) + (:argument default-system "Model default system prompt, first round only") + (chat-tab-session-send session-id model base-url thinking search default-system) ) ;tm-define (tm-define (chat-tab-cancel session-id) diff --git a/src/Plugins/Qt/qt_chat_controller.cpp b/src/Plugins/Qt/qt_chat_controller.cpp index 773ed3e487..0993796783 100644 --- a/src/Plugins/Qt/qt_chat_controller.cpp +++ b/src/Plugins/Qt/qt_chat_controller.cpp @@ -15,6 +15,7 @@ #include "qt_floating_toast.hpp" #include "qt_utilities.hpp" +#include "analyze.hpp" #include "new_buffer.hpp" #include "s7_tm.hpp" #include "scheme.hpp" @@ -216,11 +217,17 @@ ChatController::onSendRequested (const string& sessionId) { // session 文档末尾(devel/1230.md) panel->ensureMessageWidget (); - if (!as_bool ( - call ("chat-tab-send", sessionId, session->model, - session->thinking ? string ("enabled") : string ("disabled"), - session->search ? string ("enabled") : string ("disabled")))) - return; + // 协议下发参数取自模型清单:baseUrl 拼成绝对 URL;defaultSystem 仅 + // 新会话首轮非空(首轮口径见 isFirstRound) + ChatModelInfo info = modelStore_.find (session->model); + string baseUrl= resolveBaseUrl (info.baseUrl, currentStemSite ()); + string defSys= isFirstRound (sessionId) ? info.defaultSystem : string (""); + array args; + args << object (sessionId) << object (info.key) << object (baseUrl) + << object (session->thinking ? string ("enabled") : string ("disabled")) + << object (session->search ? string ("enabled") : string ("disabled")) + << object (defSys); + if (!as_bool (call ("chat-tab-send", args))) return; sessionManager_.setState (sessionId, ChatState::Generating); sessionManager_.touchSession (sessionId); @@ -769,6 +776,44 @@ ChatController::getOrCreatePanel (const string& sessionId) { * ChatController 辅助方法 ******************************************************************************/ +string +ChatController::resolveBaseUrl (const string& baseUrl, const string& site) { + if (starts (baseUrl, "http")) return baseUrl; + if (is_empty (baseUrl)) return ""; + return site * baseUrl; +} + +string +ChatController::currentStemSite () { + // 复用 account 模块既有的 current-stem-site(O1),不新增配置项; + // 模块缺失或求值失败时回退空串,相对 base_url 原样下传由子进程兜底 + return as_string ( + eval ("(catch #t (lambda () (when (not (defined? 'current-stem-site)) " + "(use-modules (account liii))) (current-stem-site)) (lambda args " + "\"\"))")); +} + +bool +ChatController::isFirstRound (const string& sessionId) { + // 首轮口径:message buffer 尚无对话轮次。body 形如 + // (document (session llm "chat-tab:" (document ...轮次...))), + // 与 scheme 侧 chat-tab-buffer-empty? 的空判语义一致:buffer 不存在、 + // 无 session 外壳或 session 内层 document 为空均视为首轮。 + // 本轮消息在判定之后的 chat-tab-send 才写入,判定点不受影响 + tree body= get_buffer_body (ChatSessionManager::messageBufferUrl (sessionId)); + if (is_atomic (body)) return is_empty (body->label); + if (!is_func (body, DOCUMENT)) return false; + bool hasRounds= false; + for (int i= 0; i < N (body); i++) { + tree child= body[i]; + if (is_compound (child, "session", 3) && is_func (child[2], DOCUMENT)) + hasRounds= hasRounds || N (child[2]) > 0; + else if (!(is_atomic (child) && is_empty (child->label))) + hasRounds= true; // 非空的非 session 内容按已有轮次处理 + } + return !hasRounds; +} + QList ChatController::buildDisplayInfos () { QList infos; diff --git a/src/Plugins/Qt/qt_chat_controller.hpp b/src/Plugins/Qt/qt_chat_controller.hpp index 6170eebf1c..c5314c660a 100644 --- a/src/Plugins/Qt/qt_chat_controller.hpp +++ b/src/Plugins/Qt/qt_chat_controller.hpp @@ -167,6 +167,16 @@ class ChatController : public QObject { */ static QString sanitizeExportFileName (const QString& rawName); + /** + * @brief 解析模型清单的 base_url 为下发协议用的绝对 URL。 + * + * 以 http 开头 → 原样;为空 → 空串(子进程兜底);否则拼接 site。 + * @param baseUrl 模型清单条目的 base_url 字段(可为相对路径) + * @param site 当前 stem site(如 https://liiistem.cn) + * @return 绝对 URL、原样相对路径或空串 + */ + static string resolveBaseUrl (const string& baseUrl, const string& site); + private: QTChatTabWidget* view_= nullptr; ///< View 指针,由 createView 创建 ChatSessionManager sessionManager_; ///< 会话管理器 @@ -267,6 +277,26 @@ class ChatController : public QObject { */ void applyModelCapabilities (const string& sessionId); + /** + * @brief 获取当前 stem site(复用 account 模块既有的 current-stem-site)。 + * + * 模块缺失或求值失败时返回空串,此时相对 base_url 原样下传, + * 由子进程兜底拼接 site;不为此引入新配置项。 + * @return site 前缀(如 https://liiistem.cn),失败时为空串 + */ + static string currentStemSite (); + + /** + * @brief 判断本轮发送是否为新会话首轮。 + * + * 口径:message buffer 尚无消息内容(buffer 不存在或 body 为空文档)。 + * 历史会话在激活加载后非空;本轮消息在本函数之后的 chat-tab-send + * 才写入,判定不受影响。 + * @param sessionId 目标会话 ID + * @return 首轮返回 true + */ + bool isFirstRound (const string& sessionId); + friend void qt_chat_tab_set_state (string sessionId, string stateStr); friend void qt_chat_tab_restore_session (string sessionId, string title, string model, string archived, diff --git a/tests/Plugins/Qt/qt_chat_model_test.cpp b/tests/Plugins/Qt/qt_chat_model_test.cpp index 2d5da36231..f032ed897c 100644 --- a/tests/Plugins/Qt/qt_chat_model_test.cpp +++ b/tests/Plugins/Qt/qt_chat_model_test.cpp @@ -9,6 +9,7 @@ * in the root directory or . ******************************************************************************/ +#include "Qt/qt_chat_controller.hpp" #include "Qt/qt_chat_model.hpp" #include "base.hpp" #include @@ -49,6 +50,12 @@ private slots: void test_find_missing_returns_key_fallback (); void test_find_empty_key (); + // === ChatController::resolveBaseUrl(协议下发前拼接) === + void test_resolve_base_url_absolute (); + void test_resolve_base_url_empty (); + void test_resolve_base_url_relative (); + void test_resolve_base_url_relative_empty_site (); + private: /// 在 rootDir 下写一份模型清单 JSON,返回是否成功 static bool write_menu_file (const QString& rootDir, const char* content); @@ -335,5 +342,41 @@ TestChatModel::test_find_empty_key () { QVERIFY (m.name == string ("")); } +// === ChatController::resolveBaseUrl(协议下发前拼接) === + +void +TestChatModel::test_resolve_base_url_absolute () { + // 以 http 开头的 base_url 视为绝对 URL,原样下发 + QVERIFY ( + ChatController::resolveBaseUrl ("https://custom.example.com/api/v1/chat", + "https://liiistem.cn") == + string ("https://custom.example.com/api/v1/chat")); + QVERIFY (ChatController::resolveBaseUrl ("http://insecure.example.com/chat", + "https://liiistem.cn") == + string ("http://insecure.example.com/chat")); +} + +void +TestChatModel::test_resolve_base_url_empty () { + // 清单未提供 base_url → 空串,由子进程兜底 + QVERIFY (ChatController::resolveBaseUrl ("", "https://liiistem.cn") == + string ("")); +} + +void +TestChatModel::test_resolve_base_url_relative () { + // 相对路径拼接当前 stem site + QVERIFY (ChatController::resolveBaseUrl ("/api/v1/ai/siliconflow/chat", + "https://liiistem.cn") == + string ("https://liiistem.cn/api/v1/ai/siliconflow/chat")); +} + +void +TestChatModel::test_resolve_base_url_relative_empty_site () { + // site 获取失败(account 模块缺失)时相对路径原样下传,子进程兜底拼 site + QVERIFY (ChatController::resolveBaseUrl ("/api/v1/ai/siliconflow/chat", "") == + string ("/api/v1/ai/siliconflow/chat")); +} + QTEST_MAIN (TestChatModel) #include "qt_chat_model_test.moc" From 83c56e04703226fb973a8c41cb98adfb756322cc Mon Sep 17 00:00:00 2001 From: pigmagicfly <2831850183@qq.com> Date: Mon, 7 Sep 2026 17:30:39 +0800 Subject: [PATCH 3/6] =?UTF-8?q?[0957]=20=E8=AF=84=E5=AE=A1=E4=BF=AE?= =?UTF-8?q?=E8=AE=A2=EF=BC=9Abase=5Furl=20=E6=8B=BC=E6=8E=A5=E4=B8=8B?= =?UTF-8?q?=E6=B2=89=20scheme=20+=20=E7=A7=BB=E9=99=A4=20default=5Fsystem?= =?UTF-8?q?=20=E5=8D=8F=E8=AE=AE=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TeXmacs/plugins/llm/data/liii_llm_menu.json | 3 -- .../plugins/llm/progs/llm/chat-protocol.scm | 51 +++++++++++------- devel/0957.md | 31 +++++++++++ src/Plugins/Qt/qt_chat_controller.cpp | 52 ++----------------- src/Plugins/Qt/qt_chat_controller.hpp | 30 ----------- src/Plugins/Qt/qt_chat_model.cpp | 1 - src/Plugins/Qt/qt_chat_model.hpp | 1 - tests/Plugins/Qt/qt_chat_model_test.cpp | 46 ---------------- 8 files changed, 69 insertions(+), 146 deletions(-) diff --git a/TeXmacs/plugins/llm/data/liii_llm_menu.json b/TeXmacs/plugins/llm/data/liii_llm_menu.json index a0979b5d9a..3135d8be58 100644 --- a/TeXmacs/plugins/llm/data/liii_llm_menu.json +++ b/TeXmacs/plugins/llm/data/liii_llm_menu.json @@ -5,7 +5,6 @@ "model": "kimi-k3", "name": "K3", "base_url": "/api/v1/ai/siliconflow/chat", - "default_system": "You are a helpful AI assistant integrated into Liii STEM. Provide clear, accurate, and concise responses.\n\nWhen writing mathematical content:\n- Use LaTeX syntax for all mathematical expressions\n- Prefer display math ($…$) for important equations and multi-line derivations\n- Use inline math ($…$) for variables and short expressions within text\n- Number key equations explicitly when they will be referenced later\n- Align equations at relation symbols (=, ≤, ≡) using align environments\n- Define all notation before first use\n- Show intermediate steps in derivations; do not skip algebraic manipulation\n- State domains, assumptions, and special cases clearly\n- Use \\text{} for words inside math mode\n- Escape special Markdown characters (*, _, [, ]) when they appear in math contexts\n- For complex nested structures, use explicit braces to avoid delimiter ambiguity", "thinking": true, "search": true, "enable": true, @@ -19,7 +18,6 @@ "model": "deepseek-v4-pro", "name": "V4-Pro", "base_url": "/api/v1/ai/deepseek/chat", - "default_system": "You are a helpful AI assistant integrated into Liii STEM. Provide clear, accurate, and concise responses.\n\nWhen writing mathematical content:\n- Use LaTeX syntax for all mathematical expressions\n- Prefer display math ($…$) for important equations and multi-line derivations\n- Use inline math ($…$) for variables and short expressions within text\n- Number key equations explicitly when they will be referenced later\n- Align equations at relation symbols (=, ≤, ≡) using align environments\n- Define all notation before first use\n- Show intermediate steps in derivations; do not skip algebraic manipulation\n- State domains, assumptions, and special cases clearly\n- Use \\text{} for words inside math mode\n- Escape special Markdown characters (*, _, [, ]) when they appear in math contexts\n- For complex nested structures, use explicit braces to avoid delimiter ambiguity", "thinking": true, "search": false, "enable": true, @@ -33,7 +31,6 @@ "model": "deepseek-v4-flash", "name": "V4-Flash", "base_url": "/api/v1/ai/deepseek/chat", - "default_system": "You are a helpful AI assistant integrated into Liii STEM. Provide clear, accurate, and concise responses.\n\nWhen writing mathematical content:\n- Use LaTeX syntax for all mathematical expressions\n- Prefer display math ($…$) for important equations and multi-line derivations\n- Use inline math ($…$) for variables and short expressions within text\n- Number key equations explicitly when they will be referenced later\n- Align equations at relation symbols (=, ≤, ≡) using align environments\n- Define all notation before first use\n- Show intermediate steps in derivations; do not skip algebraic manipulation\n- State domains, assumptions, and special cases clearly\n- Use \\text{} for words inside math mode\n- Escape special Markdown characters (*, _, [, ]) when they appear in math contexts\n- For complex nested structures, use explicit braces to avoid delimiter ambiguity", "thinking": true, "search": false, "enable": true, diff --git a/TeXmacs/plugins/llm/progs/llm/chat-protocol.scm b/TeXmacs/plugins/llm/progs/llm/chat-protocol.scm index efe594e144..432d200385 100644 --- a/TeXmacs/plugins/llm/progs/llm/chat-protocol.scm +++ b/TeXmacs/plugins/llm/progs/llm/chat-protocol.scm @@ -30,7 +30,7 @@ ;;; ---------- Record Type ---------- (define-record-type - (make-chat-input input session-id model base-url thinking search default-system) + (make-chat-input input session-id model base-url thinking search) chat-input? (input chat-input-input) (session-id chat-input-session-id) @@ -38,7 +38,6 @@ (base-url chat-input-base-url) (thinking chat-input-thinking) (search chat-input-search) - (default-system chat-input-default-system) ) ;define-record-type ;;; ---------- Buffer 类型检测 ---------- @@ -328,18 +327,41 @@ ;;; ---------- 上下文构建 ---------- +(define (chat-tab-current-stem-site) + ;; 复用 account 模块既有的 current-stem-site,不新增配置项; + ;; 模块缺失或求值失败时回退空串,相对 base-url 原样下传由子进程兜底 + (catch #t + (lambda () + (when (not (defined? 'current-stem-site)) + (use-modules (account liii)) + ) ;when + (current-stem-site) + ) ;lambda + (lambda args "") + ) ;catch +) ;define + +(define (chat-tab-resolve-base-url base-url) + ;; http 开头视为绝对 URL 原样下发;空串原样(子进程兜底); + ;; 相对路径在 scheme 侧拼接当前 stem site,C++ 只透传清单原值 + (cond ((string=? base-url "") "") + ((string-starts? base-url "http") base-url) + (else (string-append (chat-tab-current-stem-site) base-url)) + ) ;cond +) ;define + (define (chat-tab-build-context-input ctx) ;; 单轮:只编码当前用户输入 + per-round 参数 ;; 线格式:%chat \n\n ;; images 数组已随协议移除:图片上传属第二阶段,输入区图片暂按纯文本 - ;; 参与 content(含图片时 C++ 侧已提前拦截提示不支持) + ;; 参与 content(含图片时 C++ 侧已提前拦截提示不支持); + ;; 系统提示词不下发:由服务端或子进程插件配置 (let* ((input (chat-input-input ctx)) (session-id (chat-input-session-id ctx)) (model (chat-input-model ctx)) - (base-url (chat-input-base-url ctx)) + (base-url (chat-tab-resolve-base-url (chat-input-base-url ctx))) (thinking (chat-input-thinking ctx)) (search (chat-input-search ctx)) - (default-system (chat-input-default-system ctx)) (content (chat-tab-tree->plain-text input)) (obj (string->njson "{}")) (params (string->njson "{}")) @@ -349,7 +371,6 @@ (njson-set! params "baseUrl" base-url) (njson-set! params "thinking" thinking) (njson-set! params "search" search) - (njson-set! params "default_system" default-system) (njson-set! obj "params" params) (njson-set! obj "content" content) (let ((json-str (njson->string obj))) @@ -384,16 +405,13 @@ ;;; ---------- 发送 ---------- -(tm-define (chat-tab-session-send session-id model base-url thinking search default-system) +(tm-define (chat-tab-session-send session-id model base-url thinking search) (:synopsis "Send user message through chat tab session") (:argument session-id "Session UUID") (:argument model "Model name") - (:argument base-url "Resolved service endpoint URL") + (:argument base-url "Raw base_url from model manifest, may be relative") (:argument thinking "Thinking mode: enabled or disabled") (:argument search "Search mode: enabled or disabled") - (:argument default-system - "Model default system prompt, non-empty on first round only" - ) ;:argument (let* ((in-buf (chat-tab-session->input-buffer session-id)) (body (buffer-get-body in-buf)) ) ; @@ -439,9 +457,7 @@ #t ) ;begin (begin - (let ((ctx (make-chat-input input session-id model base-url thinking search default-system) - ) ;ctx - ) ; + (let ((ctx (make-chat-input input session-id model base-url thinking search))) (chat-tab-session-feed chat-tab-session-name plugin-ses ctx out '()) ) ;let #t @@ -482,15 +498,14 @@ ) ;if ) ;tm-define -(tm-define (chat-tab-send session-id model base-url thinking search default-system) +(tm-define (chat-tab-send session-id model base-url thinking search) (:synopsis "Adapter send entry for a chat tab") (:argument session-id "Session UUID") (:argument model "Model name") - (:argument base-url "Resolved service endpoint URL") + (:argument base-url "Raw base_url from model manifest, may be relative") (:argument thinking "Thinking mode") (:argument search "Search mode") - (:argument default-system "Model default system prompt, first round only") - (chat-tab-session-send session-id model base-url thinking search default-system) + (chat-tab-session-send session-id model base-url thinking search) ) ;tm-define (tm-define (chat-tab-cancel session-id) diff --git a/devel/0957.md b/devel/0957.md index cf95110829..3a34732933 100644 --- a/devel/0957.md +++ b/devel/0957.md @@ -175,3 +175,34 @@ Markdown 摘要改为 %chat 协议原格式回传。 `model`/`baseUrl`/`thinking`/`search` 与 UI 选择一致;新会话首轮 `default_system` 非空、第二轮为空;切换模型后下一轮 `model` 变化; 无 script 超时报错。 + +## 评审修订(PR #4515 评审意见,2026-09-07) + +评审结论:C++ 层保持干净,逻辑尽量下沉 scheme;系统提示词不进 +协议(由服务端或子进程插件配置)。 + +- C++ `ChatController` 删除 `resolveBaseUrl` / `currentStemSite` / + `isFirstRound`(及 `analyze.hpp` include);`onSendRequested` 只透传 + 清单原值 `base_url` + `thinking/search` 开关,`chat-tab-send` 缩回 + 5 参数(仍经 `array`,超过 `call` 4 位置参数上限)。 +- scheme 侧新增 `chat-tab-current-stem-site`(沿用 generic-edit 的 + 惰性 `(use-modules (account liii))` + catch 兜底写法)与 + `chat-tab-resolve-base-url`(http 开头原样 / 空串原样 / 相对路径 + 拼 site),在 `chat-tab-build-context-input` 组装时解析。 +- `%chat` params 移除 `default_system`;`` record、 + `chat-tab-send` / `chat-tab-session-send` 同步缩参。 +- `ChatModelInfo.defaultSystem` 字段与解析删除(0955 引入,本任务 + 一并清理);内置清单 `liii_llm_menu.json` 三个条目的 + `default_system` 键删除;对应 C++ 测试断言与 4 个 + `resolveBaseUrl` 用例删除(拼接逻辑迁至 scheme)。 +- 验证:`xmake b stem` 通过;`qt_chat_model_test` 19 项、 + `qt_chat_controller_test` 20 项全过;headless 实跑 scheme 侧解析 + —— site=`https://liiistem.cn`,绝对/空/相对三分支正确,协议行 + `%chat {"content":"你好","params":{"baseUrl":"https://liiistem.cn + /api/v1/ai/siliconflow/chat","model":"kimi-k3","search":"disabled", + "thinking":"enabled"},"sessionId":"sid-1"}`,无 `default_system`。 + +### 待手工 GUI 验证(更新) + +假回显 `params` 应为四字段 `{model, baseUrl, thinking, search}`, +`baseUrl` 为拼好 site 的绝对 URL;不再出现 `default_system`。 diff --git a/src/Plugins/Qt/qt_chat_controller.cpp b/src/Plugins/Qt/qt_chat_controller.cpp index 0993796783..2db7ad2a69 100644 --- a/src/Plugins/Qt/qt_chat_controller.cpp +++ b/src/Plugins/Qt/qt_chat_controller.cpp @@ -15,7 +15,6 @@ #include "qt_floating_toast.hpp" #include "qt_utilities.hpp" -#include "analyze.hpp" #include "new_buffer.hpp" #include "s7_tm.hpp" #include "scheme.hpp" @@ -217,16 +216,13 @@ ChatController::onSendRequested (const string& sessionId) { // session 文档末尾(devel/1230.md) panel->ensureMessageWidget (); - // 协议下发参数取自模型清单:baseUrl 拼成绝对 URL;defaultSystem 仅 - // 新会话首轮非空(首轮口径见 isFirstRound) - ChatModelInfo info = modelStore_.find (session->model); - string baseUrl= resolveBaseUrl (info.baseUrl, currentStemSite ()); - string defSys= isFirstRound (sessionId) ? info.defaultSystem : string (""); + // 协议下发参数取自模型清单:baseUrl 透传清单原值,相对路径由 scheme 侧 + // 拼接当前 stem site + ChatModelInfo info= modelStore_.find (session->model); array args; - args << object (sessionId) << object (info.key) << object (baseUrl) + args << object (sessionId) << object (info.key) << object (info.baseUrl) << object (session->thinking ? string ("enabled") : string ("disabled")) - << object (session->search ? string ("enabled") : string ("disabled")) - << object (defSys); + << object (session->search ? string ("enabled") : string ("disabled")); if (!as_bool (call ("chat-tab-send", args))) return; sessionManager_.setState (sessionId, ChatState::Generating); @@ -776,44 +772,6 @@ ChatController::getOrCreatePanel (const string& sessionId) { * ChatController 辅助方法 ******************************************************************************/ -string -ChatController::resolveBaseUrl (const string& baseUrl, const string& site) { - if (starts (baseUrl, "http")) return baseUrl; - if (is_empty (baseUrl)) return ""; - return site * baseUrl; -} - -string -ChatController::currentStemSite () { - // 复用 account 模块既有的 current-stem-site(O1),不新增配置项; - // 模块缺失或求值失败时回退空串,相对 base_url 原样下传由子进程兜底 - return as_string ( - eval ("(catch #t (lambda () (when (not (defined? 'current-stem-site)) " - "(use-modules (account liii))) (current-stem-site)) (lambda args " - "\"\"))")); -} - -bool -ChatController::isFirstRound (const string& sessionId) { - // 首轮口径:message buffer 尚无对话轮次。body 形如 - // (document (session llm "chat-tab:" (document ...轮次...))), - // 与 scheme 侧 chat-tab-buffer-empty? 的空判语义一致:buffer 不存在、 - // 无 session 外壳或 session 内层 document 为空均视为首轮。 - // 本轮消息在判定之后的 chat-tab-send 才写入,判定点不受影响 - tree body= get_buffer_body (ChatSessionManager::messageBufferUrl (sessionId)); - if (is_atomic (body)) return is_empty (body->label); - if (!is_func (body, DOCUMENT)) return false; - bool hasRounds= false; - for (int i= 0; i < N (body); i++) { - tree child= body[i]; - if (is_compound (child, "session", 3) && is_func (child[2], DOCUMENT)) - hasRounds= hasRounds || N (child[2]) > 0; - else if (!(is_atomic (child) && is_empty (child->label))) - hasRounds= true; // 非空的非 session 内容按已有轮次处理 - } - return !hasRounds; -} - QList ChatController::buildDisplayInfos () { QList infos; diff --git a/src/Plugins/Qt/qt_chat_controller.hpp b/src/Plugins/Qt/qt_chat_controller.hpp index c5314c660a..6170eebf1c 100644 --- a/src/Plugins/Qt/qt_chat_controller.hpp +++ b/src/Plugins/Qt/qt_chat_controller.hpp @@ -167,16 +167,6 @@ class ChatController : public QObject { */ static QString sanitizeExportFileName (const QString& rawName); - /** - * @brief 解析模型清单的 base_url 为下发协议用的绝对 URL。 - * - * 以 http 开头 → 原样;为空 → 空串(子进程兜底);否则拼接 site。 - * @param baseUrl 模型清单条目的 base_url 字段(可为相对路径) - * @param site 当前 stem site(如 https://liiistem.cn) - * @return 绝对 URL、原样相对路径或空串 - */ - static string resolveBaseUrl (const string& baseUrl, const string& site); - private: QTChatTabWidget* view_= nullptr; ///< View 指针,由 createView 创建 ChatSessionManager sessionManager_; ///< 会话管理器 @@ -277,26 +267,6 @@ class ChatController : public QObject { */ void applyModelCapabilities (const string& sessionId); - /** - * @brief 获取当前 stem site(复用 account 模块既有的 current-stem-site)。 - * - * 模块缺失或求值失败时返回空串,此时相对 base_url 原样下传, - * 由子进程兜底拼接 site;不为此引入新配置项。 - * @return site 前缀(如 https://liiistem.cn),失败时为空串 - */ - static string currentStemSite (); - - /** - * @brief 判断本轮发送是否为新会话首轮。 - * - * 口径:message buffer 尚无消息内容(buffer 不存在或 body 为空文档)。 - * 历史会话在激活加载后非空;本轮消息在本函数之后的 chat-tab-send - * 才写入,判定不受影响。 - * @param sessionId 目标会话 ID - * @return 首轮返回 true - */ - bool isFirstRound (const string& sessionId); - friend void qt_chat_tab_set_state (string sessionId, string stateStr); friend void qt_chat_tab_restore_session (string sessionId, string title, string model, string archived, diff --git a/src/Plugins/Qt/qt_chat_model.cpp b/src/Plugins/Qt/qt_chat_model.cpp index 299707220c..b2dfe5da71 100644 --- a/src/Plugins/Qt/qt_chat_model.cpp +++ b/src/Plugins/Qt/qt_chat_model.cpp @@ -64,7 +64,6 @@ info_from_entry (const string& key, const QJsonObject& entry) { info.allowThinking= json_bool_field (entry, "allow_thinking", true); info.allowSearch = json_bool_field (entry, "allow_search", true); info.baseUrl = json_string_field (entry, "base_url", ""); - info.defaultSystem= json_string_field (entry, "default_system", ""); return info; } diff --git a/src/Plugins/Qt/qt_chat_model.hpp b/src/Plugins/Qt/qt_chat_model.hpp index 832fcfcb78..ce72a97c78 100644 --- a/src/Plugins/Qt/qt_chat_model.hpp +++ b/src/Plugins/Qt/qt_chat_model.hpp @@ -27,7 +27,6 @@ struct ChatModelInfo { bool allowThinking= true; ///< 是否允许推理模式,缺省 true bool allowSearch = true; ///< 是否允许网络搜索,缺省 true string baseUrl; ///< 服务端接口(可为相对路径;PR-M5 发送时使用) - string defaultSystem; ///< 模型默认系统提示(PR-M5 发送时使用) }; /** diff --git a/tests/Plugins/Qt/qt_chat_model_test.cpp b/tests/Plugins/Qt/qt_chat_model_test.cpp index f032ed897c..cdd8e2b3cd 100644 --- a/tests/Plugins/Qt/qt_chat_model_test.cpp +++ b/tests/Plugins/Qt/qt_chat_model_test.cpp @@ -9,7 +9,6 @@ * in the root directory or . ******************************************************************************/ -#include "Qt/qt_chat_controller.hpp" #include "Qt/qt_chat_model.hpp" #include "base.hpp" #include @@ -50,12 +49,6 @@ private slots: void test_find_missing_returns_key_fallback (); void test_find_empty_key (); - // === ChatController::resolveBaseUrl(协议下发前拼接) === - void test_resolve_base_url_absolute (); - void test_resolve_base_url_empty (); - void test_resolve_base_url_relative (); - void test_resolve_base_url_relative_empty_site (); - private: /// 在 rootDir 下写一份模型清单 JSON,返回是否成功 static bool write_menu_file (const QString& rootDir, const char* content); @@ -108,7 +101,6 @@ TestChatModel::test_parse_new_format_full_fields () { " \"models\": [" " { \"model\": \"kimi-k3\", \"name\": \"K3\"," " \"base_url\": \"/api/v1/ai/siliconflow/chat\"," - " \"default_system\": \"You are helpful.\"," " \"thinking\": true, \"search\": true, \"enable\": true," " \"allow_thinking\": false, \"allow_search\": false," " \"icon\": \"kimi\", \"description\": \"Vision\"," @@ -126,7 +118,6 @@ TestChatModel::test_parse_new_format_full_fields () { QVERIFY (!m.allowThinking); QVERIFY (!m.allowSearch); QVERIFY (m.baseUrl == string ("/api/v1/ai/siliconflow/chat")); - QVERIFY (m.defaultSystem == string ("You are helpful.")); QVERIFY (defaultKey == string ("kimi-k3")); } @@ -160,7 +151,6 @@ TestChatModel::test_parse_new_format_defaults () { QVERIFY (m.allowThinking); QVERIFY (m.allowSearch); QVERIFY (m.baseUrl == string ("")); - QVERIFY (m.defaultSystem == string ("")); } void @@ -342,41 +332,5 @@ TestChatModel::test_find_empty_key () { QVERIFY (m.name == string ("")); } -// === ChatController::resolveBaseUrl(协议下发前拼接) === - -void -TestChatModel::test_resolve_base_url_absolute () { - // 以 http 开头的 base_url 视为绝对 URL,原样下发 - QVERIFY ( - ChatController::resolveBaseUrl ("https://custom.example.com/api/v1/chat", - "https://liiistem.cn") == - string ("https://custom.example.com/api/v1/chat")); - QVERIFY (ChatController::resolveBaseUrl ("http://insecure.example.com/chat", - "https://liiistem.cn") == - string ("http://insecure.example.com/chat")); -} - -void -TestChatModel::test_resolve_base_url_empty () { - // 清单未提供 base_url → 空串,由子进程兜底 - QVERIFY (ChatController::resolveBaseUrl ("", "https://liiistem.cn") == - string ("")); -} - -void -TestChatModel::test_resolve_base_url_relative () { - // 相对路径拼接当前 stem site - QVERIFY (ChatController::resolveBaseUrl ("/api/v1/ai/siliconflow/chat", - "https://liiistem.cn") == - string ("https://liiistem.cn/api/v1/ai/siliconflow/chat")); -} - -void -TestChatModel::test_resolve_base_url_relative_empty_site () { - // site 获取失败(account 模块缺失)时相对路径原样下传,子进程兜底拼 site - QVERIFY (ChatController::resolveBaseUrl ("/api/v1/ai/siliconflow/chat", "") == - string ("/api/v1/ai/siliconflow/chat")); -} - QTEST_MAIN (TestChatModel) #include "qt_chat_model_test.moc" From 88012142f818faf1756f02215fe6e88364fa3602 Mon Sep 17 00:00:00 2001 From: pigmagicfly <2831850183@qq.com> Date: Mon, 7 Sep 2026 18:04:51 +0800 Subject: [PATCH 4/6] =?UTF-8?q?[0957]=20=E8=AF=84=E5=AE=A1=E4=BF=AE?= =?UTF-8?q?=E8=AE=A2=EF=BC=9A=E7=9B=B4=E6=8E=A5=20use-modules=20(account?= =?UTF-8?q?=20liii)=20+=20JSON=20=E7=BB=84=E8=A3=85=E6=94=B9=E7=94=A8=20(l?= =?UTF-8?q?iii=20json)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plugins/llm/progs/llm/chat-protocol.scm | 48 +++++++------------ devel/0957.md | 15 ++++++ 2 files changed, 31 insertions(+), 32 deletions(-) diff --git a/TeXmacs/plugins/llm/progs/llm/chat-protocol.scm b/TeXmacs/plugins/llm/progs/llm/chat-protocol.scm index 432d200385..e10475164f 100644 --- a/TeXmacs/plugins/llm/progs/llm/chat-protocol.scm +++ b/TeXmacs/plugins/llm/progs/llm/chat-protocol.scm @@ -25,7 +25,9 @@ ) ;:use ) ;texmacs-module -(import (liii njson)) +(import (liii json)) + +(use-modules (account liii)) ;;; ---------- Record Type ---------- @@ -327,26 +329,12 @@ ;;; ---------- 上下文构建 ---------- -(define (chat-tab-current-stem-site) - ;; 复用 account 模块既有的 current-stem-site,不新增配置项; - ;; 模块缺失或求值失败时回退空串,相对 base-url 原样下传由子进程兜底 - (catch #t - (lambda () - (when (not (defined? 'current-stem-site)) - (use-modules (account liii)) - ) ;when - (current-stem-site) - ) ;lambda - (lambda args "") - ) ;catch -) ;define - (define (chat-tab-resolve-base-url base-url) ;; http 开头视为绝对 URL 原样下发;空串原样(子进程兜底); - ;; 相对路径在 scheme 侧拼接当前 stem site,C++ 只透传清单原值 + ;; 相对路径在 scheme 侧拼接 current-stem-site,C++ 只透传清单原值 (cond ((string=? base-url "") "") ((string-starts? base-url "http") base-url) - (else (string-append (chat-tab-current-stem-site) base-url)) + (else (string-append (current-stem-site) base-url)) ) ;cond ) ;define @@ -363,22 +351,18 @@ (thinking (chat-input-thinking ctx)) (search (chat-input-search ctx)) (content (chat-tab-tree->plain-text input)) - (obj (string->njson "{}")) - (params (string->njson "{}")) + (json-str (json->string `((,"sessionId" . ,session-id) + (,"params" + (,"model" . ,model) + (,"baseUrl" . ,base-url) + (,"thinking" . ,thinking) + (,"search" . ,search)) + (,"content" . ,content)) + ) ;json->string + ) ;json-str ) ; - (njson-set! obj "sessionId" session-id) - (njson-set! params "model" model) - (njson-set! params "baseUrl" base-url) - (njson-set! params "thinking" thinking) - (njson-set! params "search" search) - (njson-set! obj "params" params) - (njson-set! obj "content" content) - (let ((json-str (njson->string obj))) - (njson-free params) - (njson-free obj) - (let ((cork-json (utf8->cork json-str))) - (stree->tree `(document ,(string-append "%chat " cork-json))) - ) ;let + (let ((cork-json (utf8->cork json-str))) + (stree->tree `(document ,(string-append "%chat " cork-json))) ) ;let ) ;let* ) ;define diff --git a/devel/0957.md b/devel/0957.md index 3a34732933..d84667215a 100644 --- a/devel/0957.md +++ b/devel/0957.md @@ -206,3 +206,18 @@ Markdown 摘要改为 %chat 协议原格式回传。 假回显 `params` 应为四字段 `{model, baseUrl, thinking, search}`, `baseUrl` 为拼好 site 的绝对 URL;不再出现 `default_system`。 + +### 评审二轮修订(2026-09-07) + +- 去掉 `chat-tab-current-stem-site` 的 catch 与惰性加载:文件顶部直接 + `(use-modules (account liii))`,`chat-tab-resolve-base-url` 里直接调 + `(current-stem-site)`。 +- JSON 组装由 `(liii njson)` 改为 `(liii json)`:协议对象直接以有序 + alist 字面量构造(`(liii json)` 对象即 alist,`json-set` 只改不增, + 构造场景不适用),删掉 `string->njson`/`njson-set!`/`njson-free`。 + 注意 `json->string` 会把 `/` 转义为 `\/`(合法 JSON,回读正常)。 +- 验证:headless 实跑,协议行 + `%chat {"sessionId":"sid-1","params":{"model":"kimi-k3","baseUrl": + "https:\/\/liiistem.cn\/api\/v1\/ai\/siliconflow\/chat","thinking": + "enabled","search":"disabled"},"content":"你好\"引号"}`; + `\/` 与 `\"` 经 string->json/json-set/json->string 回环保真。 From 0e736b268e24858147726c93ad824a527b1c84cb Mon Sep 17 00:00:00 2001 From: pigmagicfly <2831850183@qq.com> Date: Tue, 8 Sep 2026 09:43:54 +0800 Subject: [PATCH 5/6] =?UTF-8?q?[0957]=20=E8=AF=84=E5=AE=A1=E4=BF=AE?= =?UTF-8?q?=E8=AE=A2=EF=BC=9A=E6=8A=BD=E5=87=BA=20chat-input->json=20?= =?UTF-8?q?=E8=BE=85=E5=8A=A9=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plugins/llm/progs/llm/chat-protocol.scm | 40 +++++++++---------- devel/0957.md | 16 ++++++++ 2 files changed, 35 insertions(+), 21 deletions(-) diff --git a/TeXmacs/plugins/llm/progs/llm/chat-protocol.scm b/TeXmacs/plugins/llm/progs/llm/chat-protocol.scm index e10475164f..5dbf764938 100644 --- a/TeXmacs/plugins/llm/progs/llm/chat-protocol.scm +++ b/TeXmacs/plugins/llm/progs/llm/chat-protocol.scm @@ -338,33 +338,31 @@ ) ;cond ) ;define +(define (chat-input->json ctx) + ;; 协议 JSON:content 为当前输入的纯文本,params 为 per-round 参数 + ;; (baseUrl 在此解析为绝对 URL) + (json->string (list (cons "sessionId" (chat-input-session-id ctx)) + (cons "params" + (list (cons "model" (chat-input-model ctx)) + (cons "baseUrl" (chat-tab-resolve-base-url (chat-input-base-url ctx))) + (cons "thinking" (chat-input-thinking ctx)) + (cons "search" (chat-input-search ctx)) + ) ;list + ) ;cons + (cons "content" (chat-tab-tree->plain-text (chat-input-input ctx))) + ) ;list + ) ;json->string +) ;define + (define (chat-tab-build-context-input ctx) ;; 单轮:只编码当前用户输入 + per-round 参数 ;; 线格式:%chat \n\n ;; images 数组已随协议移除:图片上传属第二阶段,输入区图片暂按纯文本 ;; 参与 content(含图片时 C++ 侧已提前拦截提示不支持); ;; 系统提示词不下发:由服务端或子进程插件配置 - (let* ((input (chat-input-input ctx)) - (session-id (chat-input-session-id ctx)) - (model (chat-input-model ctx)) - (base-url (chat-tab-resolve-base-url (chat-input-base-url ctx))) - (thinking (chat-input-thinking ctx)) - (search (chat-input-search ctx)) - (content (chat-tab-tree->plain-text input)) - (json-str (json->string `((,"sessionId" . ,session-id) - (,"params" - (,"model" . ,model) - (,"baseUrl" . ,base-url) - (,"thinking" . ,thinking) - (,"search" . ,search)) - (,"content" . ,content)) - ) ;json->string - ) ;json-str - ) ; - (let ((cork-json (utf8->cork json-str))) - (stree->tree `(document ,(string-append "%chat " cork-json))) - ) ;let - ) ;let* + (let ((cork-json (utf8->cork (chat-input->json ctx)))) + (stree->tree `(document ,(string-append "%chat " cork-json))) + ) ;let ) ;define ;;; ---------- Feed ---------- diff --git a/devel/0957.md b/devel/0957.md index d84667215a..85f48f188b 100644 --- a/devel/0957.md +++ b/devel/0957.md @@ -221,3 +221,19 @@ Markdown 摘要改为 %chat 协议原格式回传。 "https:\/\/liiistem.cn\/api\/v1\/ai\/siliconflow\/chat","thinking": "enabled","search":"disabled"},"content":"你好\"引号"}`; `\/` 与 `\"` 经 string->json/json-set/json->string 回环保真。 + +### 评审三轮修订(2026-09-08,未提交) + +- 新增 `chat-input->json` 辅助函数:协议 JSON 的构造整体从 + `chat-tab-build-context-input` 抽出(content 纯文本化、params 四字段、 + baseUrl 解析都在其中),`build-context-input` 只剩 cork 转换 + 包 + `%chat` 行。用 `cons`/`list` 显式构造而非嵌套 quasiquote—— + `(liii json)` 的 alist 嵌套层数敏感(params 值须直接是 pair 列表, + 多包一层会被 g_json->string 当成「键为 pair」而报 type-error)。 +- `` 已使用 define-record-type(0957 首版即有),无需 + 额外改动。 +- 验证:headless 实跑 `chat-input->json` 输出 + `{"sessionId":"sid-1","params":{"model":"kimi-k3","baseUrl":"https:\/\ + /liiistem.cn\/api\/v1\/ai\/siliconflow\/chat","thinking":"enabled", + "search":"disabled"},"content":...}`,`build-context-input` 产出的 + %chat 协议行与上轮一致。 From 13be83856995e51a58c1bbff1a55ce729b733840 Mon Sep 17 00:00:00 2001 From: pigmagicfly <2831850183@qq.com> Date: Tue, 8 Sep 2026 10:16:15 +0800 Subject: [PATCH 6/6] =?UTF-8?q?[0957]=20chat-input->json=20=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=20scheme=20json=20=E5=AF=B9=E8=B1=A1=E5=B9=B6?= =?UTF-8?q?=E4=B8=8A=E7=A7=BB=E8=87=B3=20record=20=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E6=97=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plugins/llm/progs/llm/chat-protocol.scm | 33 +++++++++---------- devel/0957.md | 13 ++++++++ 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/TeXmacs/plugins/llm/progs/llm/chat-protocol.scm b/TeXmacs/plugins/llm/progs/llm/chat-protocol.scm index 5dbf764938..e0329dea4e 100644 --- a/TeXmacs/plugins/llm/progs/llm/chat-protocol.scm +++ b/TeXmacs/plugins/llm/progs/llm/chat-protocol.scm @@ -42,6 +42,21 @@ (search chat-input-search) ) ;define-record-type +(define (chat-input->json ctx) + ;; 协议 JSON 数据:content 为当前输入的纯文本,params 为 per-round 参数 + ;; (baseUrl 在此解析为绝对 URL) + (list (cons "sessionId" (chat-input-session-id ctx)) + (cons "params" + (list (cons "model" (chat-input-model ctx)) + (cons "baseUrl" (chat-tab-resolve-base-url (chat-input-base-url ctx))) + (cons "thinking" (chat-input-thinking ctx)) + (cons "search" (chat-input-search ctx)) + ) ;list + ) ;cons + (cons "content" (chat-tab-tree->plain-text (chat-input-input ctx))) + ) ;list +) ;define + ;;; ---------- Buffer 类型检测 ---------- (tm-define (chat-message-buffer? buf) @@ -338,29 +353,13 @@ ) ;cond ) ;define -(define (chat-input->json ctx) - ;; 协议 JSON:content 为当前输入的纯文本,params 为 per-round 参数 - ;; (baseUrl 在此解析为绝对 URL) - (json->string (list (cons "sessionId" (chat-input-session-id ctx)) - (cons "params" - (list (cons "model" (chat-input-model ctx)) - (cons "baseUrl" (chat-tab-resolve-base-url (chat-input-base-url ctx))) - (cons "thinking" (chat-input-thinking ctx)) - (cons "search" (chat-input-search ctx)) - ) ;list - ) ;cons - (cons "content" (chat-tab-tree->plain-text (chat-input-input ctx))) - ) ;list - ) ;json->string -) ;define - (define (chat-tab-build-context-input ctx) ;; 单轮:只编码当前用户输入 + per-round 参数 ;; 线格式:%chat \n\n ;; images 数组已随协议移除:图片上传属第二阶段,输入区图片暂按纯文本 ;; 参与 content(含图片时 C++ 侧已提前拦截提示不支持); ;; 系统提示词不下发:由服务端或子进程插件配置 - (let ((cork-json (utf8->cork (chat-input->json ctx)))) + (let ((cork-json (utf8->cork (json->string (chat-input->json ctx))))) (stree->tree `(document ,(string-append "%chat " cork-json))) ) ;let ) ;define diff --git a/devel/0957.md b/devel/0957.md index 85f48f188b..7933a8cca7 100644 --- a/devel/0957.md +++ b/devel/0957.md @@ -237,3 +237,16 @@ Markdown 摘要改为 %chat 协议原格式回传。 /liiistem.cn\/api\/v1\/ai\/siliconflow\/chat","thinking":"enabled", "search":"disabled"},"content":...}`,`build-context-input` 产出的 %chat 协议行与上轮一致。 + +### chat-input->json 定稿调整(2026-09-08,未提交) + +- 语义对齐 `(liii json)` 约定:json 即 scheme 对象(对象 = alist、数组 + = vector),`chat-input->json` 返回协议的 alist 表示而非字符串 + (参考 `legacy-scm-user-shortcut-entry->json` 等既有 `->json` 实现), + `json->string` 仅在 `chat-tab-build-context-input` 组线时调用一次。 +- 函数上移至 `` record 定义之后(数据与其 json 表示相邻)。 +- 验证:headless 实跑返回 + `(("sessionId" . "sid-1") ("params" ("model" . "kimi-k3") ("baseUrl" + . "https://liiistem.cn/api/v1/ai/siliconflow/chat") ("thinking" . + "enabled") ("search" . "disabled")) ("content" . …))`,组线产物 + `(document "%chat {…四字段…}")` 不变。