
QTextCursor 的编辑接口和 QTextDocument、QTextFrame、QTextBlock 这一组只读遍历接口混着用是 Qt 富文本处理里最容易翻车的点要是再叠上 Codex 的 Base URL 多写了一个 /v1一次排查就变成两条线同时在跑。通道那条线其实很短用 TaoToken 给 Codex 供 Key官网入口是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 真正填进工具的地址是 https://taotoken.net/api 末尾不带 /v1也不挂任何查询参数。Qt 那条线长得多重点就在 showTextFrame 里 currentFrame 和 currentBlock 的判断上——写反一次遍历结果就少一层还特别难看出来。这篇按排障的角度来写先把最容易写错的遍历判断拆开再把 Codex 的 Key 和 Base URL 备好最后用一个最小示例把两边都验证一遍。文中所有 Qt 代码都需要你在本地编译运行Codex 只负责看代码、对分支、给改法它不会去连你的开发机也不会替你跑 qmake。1. showTextFrame 里 currentFrame 和 currentBlock 为什么总写反1.1 两组接口不是同一套东西一支笔两个读表器Qt 的富文本模型里编辑和读取是两套完全不同的入口。QTextCursor是那支笔它持有文档内的一个位置movePosition、insertText、insertBlock、insertTable、insertFrame 都是它的活。它关心的是「我接下来要往哪里写」。QTextFrame::iterator和QTextBlock::iterator是读表器它们只负责按顺序把文档结构报给你本身不改变文档。QTextFrame::iterator 走的是「框架层」一次吐出的是一个 QTextFrame 或一个 QTextBlockQTextBlock::iterator 走的是「片段层」一次吐出的是一个 QTextFragment。把这两套混在一起写最典型的症状就是你用遍历器拿到了一个 QTextBlock然后顺手对它调了 QTextCursor 的方法或者反过来拿着 cursor 想去取 currentFrame。前者编译不过后者拿到的永远是空值。侧类型典型入口能改文档吗编辑QTextCursortextCursor()、insertText()、insertBlock()能只读QTextFrame::iteratorframe-begin()、currentFrame()、currentBlock()不能只读QTextBlock::iteratorblock.begin()、fragment()不能只读QTextDocumentdocument()-rootFrame()、document()-begin()不能除非自己建 cursor还有个小坑值得提前说QTextDocument::begin() 返回的是 QTextBlock而QTextFrame::begin() 返回的是 QTextFrame::iterator。名字一样返回值不一样很多人第一次看文档就在这里卡住然后开始怀疑人生。1.2 最小复现遍历到子框架时currentBlock 返回空块先看一段典型的错误遍历。目标是打印文档里所有框架和段落结果子框架里的内容全丢了void showTextFrame(QTextFrame *frame) { if (!frame) return; QTextFrame::iterator it frame-begin(); for (; !it.atEnd(); it) { // 错在这里只判断 currentBlock框架分支被跳过 QTextBlock block it.currentBlock(); if (block.isValid()) qDebug() block: block.text(); } }这段代码能编过跑起来也不崩但你插进去的 QTextTable 或 QTextFrame 里的文字一个都不会打印。原因很简单迭代器停在一个子框架上时currentBlock() 返回的是一个无效块isValid() 为 false于是整条分支被静默跳过。正确的判断顺序是先问 currentFrame再问 currentBlockvoid showTextFrame(QTextFrame *frame, int depth 0) { if (!frame) return; const QString indent(depth * 2, QLatin1Char( )); QTextFrame::iterator it frame-begin(); for (; !it.atEnd(); it) { QTextFrame *childFrame it.currentFrame(); QTextBlock childBlock it.currentBlock(); if (childFrame) { qDebug().noquote() indent [frame] childFrame-firstPosition() childFrame-lastPosition(); showTextFrame(childFrame, depth 1); } else if (childBlock.isValid()) { qDebug().noquote() indent [block] childBlock.text(); } } }调用的起点是showTextFrame(document-rootFrame())不是document-begin()。这一点也常被写错document-begin() 给的是根框架里的第一个块从它出发你永远走不到子框架。2. 让 Codex 帮你对分支之前先把 Key 和 Base URL 备好2.1 打开官网注册并创建 API Key排查这种「分支少走了一层」的问题最省时间的方式是把两版 showTextFrame 都贴给 Codex让它逐行对比迭代器状态的差异。但在那之前你得先有一把能用的 Key。打开 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 完成注册进控制台创建一把 API Key复制出来先存好。本文所有示例统一用占位符YOUR_API_KEY不要把它写进任何会提交到仓库的文件里。同一个页面还能看到模型广场里面列着当前可用的模型 ID——待会儿配置里要填的就是那里的名字别照着记忆猜。2.2 ~/.codex/config.tomlbase_url 写 https://taotoken.net/api后面别跟 /v1Codex 的配置落在用户目录下的config.toml走的是 model_provider 这套结构。先建好 provider再指定当前用哪个model YOUR_MODEL_ID model_provider taotoken [model_providers.taotoken] name TaoToken base_url https://taotoken.net/api env_key TAOTOKEN_API_KEY wire_api chat这里有三件事必须盯死。第一base_url 是 https://taotoken.net/api末尾不要再补 /v1补了之后客户端会再拼一次路径请求直接落到不存在的地址上。第二model 字段填的是模型广场里真实存在的 ID本文不写死任何具体名字以当时的列表为准。第三Key 通过环境变量注入别直接写进 tomlexport TAOTOKEN_API_KEYYOUR_API_KEY注意这里不要套 ANTHROPIC_* 那组变量那是给 Claude Code 用的。Codex 和 Claude Code 的配置文件、变量名完全不通用混着填只会多一轮无意义的排查。2.3 先发一条最小请求确认通道通了配置改完别急着开新会话先用一条最小请求确认通道本身没问题curl https://taotoken.net/api/chat/completions \ -H Authorization: Bearer YOUR_API_KEY \ -H Content-Type: application/json \ -d {model:YOUR_MODEL_ID,messages:[{role:user,content:ping}]}能正常返回内容说明 Key、base_url、模型 ID 三者对齐了。如果这一步就报错先去 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 核对 Key 状态和模型 ID不要带着通道问题去调 Qt 的遍历逻辑——两条线混在一起你会分不清到底是哪边错了。3. QTextCursor 编辑侧插入顺序决定游标落在哪里3.1 insertBlock / insertText / insertTable / insertFrame 的调用次序编辑侧的问题基本都出在「往哪儿插」上。QTextCursor 在插入一段结构化内容后自己的位置会跟着变但变到哪儿取决于你调的是哪个方法。普通的段落插入最直观QTextEdit *edit new QTextEdit; QTextCursor cursor edit-textCursor(); cursor.movePosition(QTextCursor::End); cursor.insertBlock(); cursor.insertText(QStringLiteral(第一段正文)); cursor.insertBlock(); cursor.insertText(QStringLiteral(第二段正文));表格要绕一层因为文字要插到单元格自己的 cursor 里而不是原来的 cursorQTextCursor cursor edit-textCursor(); QTextTableFormat tableFormat; tableFormat.setBorder(1); QTextTable *table cursor.insertTable(2, 2, tableFormat); table-cellAt(0, 0).firstCursorPosition().insertText(QStringLiteral(A1)); table-cellAt(0, 1).firstCursorPosition().insertText(QStringLiteral(B1));框架同理插完框架后原 cursor 已经不在框架内部了想往框架里写字得用 frame-firstCursorPosition() 重新取一个QTextFrameFormat frameFormat; frameFormat.setBorder(1); frameFormat.setPadding(4); QTextFrame *frame cursor.insertFrame(frameFormat); QTextCursor inner frame-firstCursorPosition(); inner.insertText(QStringLiteral(框架内的文字));这三段的共同点是插入结构化对象之后一定要重新取 cursor。继续拿旧 cursor 往下一行插内容就会跑到你意想不到的位置上去。3.2 编辑完再遍历为什么你的 position 对不上另一个常见困惑是位置对不上。你在编辑时记下的 cursor.position()等文档结构变了之后再拿它去比对数字往往已经错了。原因是 QTextCursor 的位置是文档全局偏移量而插入表格、框架、块都会改变其后所有内容的偏移。编辑阶段记下来的数字只对那一刻的文档成立。真要定位内容用遍历器读出来的 QTextBlock::position() 或 QTextFrame::firstPosition()这两个值是遍历那一刻现算的不存在过期问题。所以顺序应该是先把结构编辑完再开始只读遍历。边插边遍历或者遍历到一半又回去改 cursor是自找麻烦。4. 只读遍历侧QTextDocument → QTextFrame → QTextBlock 三层迭代器怎么写4.1 document-rootFrame() 和 begin() 的关系整个文档是一棵树。QTextDocument 本身不算节点它持有一个隐藏的根框架所有内容都挂在根框架下面。想完整遍历起点必须是document-rootFrame()。QTextDocument *doc edit-document(); showTextFrame(doc-rootFrame(), 0);document-begin() 返回的是根框架里的第一个 QTextBlock它跳过了根框架这一层只适合「我确定文档是平铺段落」的场景。一旦文档里出现表格或嵌套框架从 begin() 出发就会漏内容。4.2 QTextFrame::iteratorcurrentFrame 优先currentBlock 兜底回到第 1 节那个正确的 showTextFrame。它成立的前提就是判断顺序currentFrame() 非空说明当前位置是一个子框架包括表格currentBlock() 有效说明当前位置是一个普通段落块。两者互斥一次只会有一个成立。有一点容易被忽略currentFrame() 返回的是 QTextFrame*即使真实对象是 QTextTable它也是 QTextFrame 的子类。所以判断子框架成立之后还需要再分一层才能区分「普通框架」和「表格」。这一点在 4.4 展开。4.3 QTextBlock::iterator 与 fragment 的只读读取框架层走完到了块内部还要再走一层。一个 QTextBlock 里可以混合多种字符格式这些格式段就是 QTextFragmentvoid showBlockFragments(const QTextBlock block) { if (!block.isValid()) return; for (QTextBlock::iterator fit block.begin(); !fit.atEnd(); fit) { QTextFragment frag fit.fragment(); if (!frag.isValid()) continue; qDebug().noquote() frag: frag.text() pos: frag.position() pointSize: frag.charFormat().fontPointSize(); } }fragment 是只读的想改格式得回到 QTextCursor定位到 frag.position()设置好 charFormat再选中 frag.length() 个字符应用。两套接口在这里必须交接一次交接点就是位置和长度。4.4 QTextTable 也是 QTextFrame遍历时要单独分支表格在遍历里最容易出意外。QTextTable 继承自 QTextFrame所以 it.currentFrame() 会把它返回但表格的「子框架」不是你的内容而是单元格。直接递归进去你会打印出一堆空框架。用 qobject_cast 把表格挑出来单独处理QTextFrame *child it.currentFrame(); if (child) { if (QTextTable *tbl qobject_castQTextTable *(child)) { for (int r 0; r tbl-rows(); r) { for (int c 0; c tbl-columns(); c) { QTextCursor cellCursor tbl-cellAt(r, c).firstCursorPosition(); qDebug().noquote() cell( r , c ): cellCursor.block().text(); } } } else { showTextFrame(child, depth 1); } }注意表格单元格里的内容也要用 cursor 去取因为单元格不暴露自己的文本只暴露 cursor。这和遍历普通框架的思路刚好相反是另一个容易写反的地方。5. 把报错丢给 Codex 的姿势它只出思路跑的人是你5.1 一次对话里该贴哪三样东西与其问「我的遍历为什么不对」不如把三样东西一次性贴清楚你的 Qt 版本、最小示例代码、实际打印输出。这三样凑齐Codex 基本能直接指出是 currentFrame / currentBlock 的判断顺序错了还是递归起点错了。要强调一点Codex 只负责读代码、解释结构、给出改法。编译、运行、把输出贴回来全部由你在本地完成。它不会连上你的开发机也不会替你执行 qmake 或运行可执行文件。遍历结果这种东西只有你本地跑出来的才算数。5.2 让它给最小可编译改动本地跑完再贴回来提问时可以直接约定「只改 showTextFrame 里的分支判断别动数据结构给一段能直接替换的最小改动」。拿到改动后自己编译、自己跑把新的输出贴回去再问下一轮。这种一轮一改的方式看着慢其实比一次性让模型重写整个模块靠谱得多。遍历代码的正确性依赖具体的文档结构模型看不到你的运行时文档树只能靠你贴的输出逐步逼近。6. 三类错误分开排通道错、模型 ID 错、遍历判断错6.1 base_url、wire_api 与重复的 /v1Codex 这边最常见的错就是路径拼接。base_url 写成 https://taotoken.net/api 之后客户端会自己补上具体的接口路径如果你顺手加了 /v1最终地址就多了一层服务端找不到路由返回的报错看起来像 Key 有问题其实和 Key 无关。判断方法很直接报错如果是「找不到路径」这一类先检查 base_url 末尾有没有多余的路径段报错如果是鉴权失败再去检查 Key 和环境变量名是否对得上。两种错分开看别一起改。另外 wire_api 要和你的请求格式匹配。配成 chat 时走的是对话补全那一套路径配成别的协议时路径不一样混用同样会 404。6.2 遍历输出里出现空 block 的四种原因Qt 这边遍历输出异常基本逃不出四种情况。第一种判断顺序反了先查 currentBlock 再查 currentFrame框架分支被跳过。第二种递归起点用了 document-begin() 而不是 rootFrame()根框架里的子结构没进去。第三种递归时忘了传新的 childFrame而是把当前的 frame 又传了一遍要么死循环要么原地打转。第四种表格没单独分支直接递归进单元格框架打出来全是空行。对着这四条逐个过一遍比漫无目的地加 qDebug 快得多。真要加日志就在每次进入循环体时把it.atEnd()、currentFrame()指针、currentBlock().isValid()三个值一起打出来一眼就能看出停在了哪一类节点上。6.3 跑通后回控制台对一下这次调用通道和遍历都跑通之后建议回 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 看一眼这次的调用有没有正常记上。有时候本地显示成功但用量对不上多半是配置里有两份 provider 定义实际生效的是另一份。排查这种问题不用改代码把 config.toml 从头到尾读一遍确认model_provider指向的正是你改的那个 provider 名字就够了。7. 下一步把遍历结果和调用记录对齐把 showTextFrame 的递归跑通之后可以顺手做两件小事。一是给输出加上 firstPosition 和 lastPosition对着文档的实际结构看一遍确认每个框架的起止位置是递增的二是把两版遍历代码都留在注释里下次再写反判断时翻出来看一眼就知道差在哪。需要长期用 Codex 读代码的话可以去 Coding Plan 看套餐是否够用Key 统一在 控制台 API Keys 管理想单独试一次模型对话可以走 模型对话 页面。模型的名字和当前可用的列表都以模型广场上当时显示的为准——Qt 的遍历逻辑靠自己跑通道的地址和 Key 靠这里取两边分开管排查起来才不会互相干扰。