From 3dd2231c708c58bc69df172b3eb1a1438b38879e Mon Sep 17 00:00:00 2001 From: Wang Zhiyu Date: Sat, 3 Oct 2026 14:56:56 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E8=A7=84=E8=8C=83=E6=A0=87=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 8 +- Dockerfile | 28 +- Makefile | 118 +++---- README.zh.md | 658 ++++++++++++++++++------------------ README2.md | 658 ++++++++++++++++++------------------ ligent.example.json | 2 +- src/agent.c | 4 +- src/agent.h | 16 +- src/archive.c | 2 +- src/archive.h | 14 +- src/attach.c | 22 +- src/attach.h | 12 +- src/capability.c | 26 +- src/capability.h | 14 +- src/config.c | 24 +- src/config.h | 10 +- src/confirm.c | 14 +- src/confirm.h | 6 +- src/doctor.c | 18 +- src/doctor.h | 2 +- src/http.c | 28 +- src/http.h | 12 +- src/i18n.c | 198 +++++------ src/i18n.h | 16 +- src/image.c | 14 +- src/image.h | 30 +- src/json.c | 20 +- src/json.h | 6 +- src/lineedit.c | 24 +- src/lineedit.h | 6 +- src/llm.c | 12 +- src/main.c | 70 ++-- src/markdown.c | 68 ++-- src/markdown.h | 8 +- src/meta.c | 56 +-- src/meta.h | 12 +- src/models.c | 4 +- src/models.h | 6 +- src/priv.c | 6 +- src/priv.h | 14 +- src/shell.c | 18 +- src/shell.h | 2 +- src/spinner.c | 8 +- src/spinner.h | 6 +- src/tools.c | 192 +++++------ src/tools.h | 6 +- src/util.c | 10 +- src/util.h | 18 +- src/version.c | 18 +- src/version.h | 20 +- tests/confirm_check.py | 28 +- tests/fixtures/error.log | 2 +- tests/fixtures/session.log | 6 +- tests/fuzz.c | 26 +- tests/mock_server.py | 80 ++--- tests/reduce.c | 6 +- tests/repl_drive.py | 6 +- tests/repl_edit_check.py | 46 +-- tests/replay.py | 24 +- tests/run_e2e.sh | 198 +++++------ tests/shell_screen_check.py | 14 +- tests/test_agent.c | 30 +- tests/test_archive.c | 40 +-- tests/test_attach.c | 8 +- tests/test_capability.c | 10 +- tests/test_config.c | 20 +- tests/test_http.c | 10 +- tests/test_i18n.c | 26 +- tests/test_image.c | 30 +- tests/test_json.c | 14 +- tests/test_lineedit.c | 8 +- tests/test_markdown.c | 30 +- tests/test_meta.c | 32 +- tests/test_models.c | 6 +- tests/test_priv.c | 16 +- tests/test_spinner.c | 18 +- tests/test_tools.c | 60 ++-- tools/build-static-deps.sh | 70 ++-- tools/check-colors.sh | 12 +- tools/get-version.sh | 24 +- tools/md_demo.c | 38 +-- 81 files changed, 1751 insertions(+), 1751 deletions(-) diff --git a/.dockerignore b/.dockerignore index 87320c6..8299e18 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,26 +3,26 @@ # 构建上下文越小, podman/docker 传得越快; 更重要的是, **有些东西绝不能被 # COPY 进去**. 这里每一条都写清楚为什么. -# 构建产物:容器里会重新编, 带进去只会让缓存失效、还可能串味 +# 构建产物: 容器里会重新编, 带进去只会让缓存失效、还可能串味 # (比如本机 build/static-prefix 是给本机路径编的, 进容器就废了) build/ ligent ligent-static *.o -# 版本控制:容器里用 --build-arg VERSION 传版本号, 不需要 .git. +# 版本控制: 容器里用 --build-arg VERSION 传版本号, 不需要 .git. # 而且 .git 可能有几百 MB, 是构建上下文里最大的一块. .git/ .gitignore -# 本地运行期状态:配置和密钥绝不能进镜像. +# 本地运行期状态: 配置和密钥绝不能进镜像. # ligent.json 里可能写着 API key —— 这是最要紧的一条. ligent.json ligent.archive/ ligent.history ligent.history.tmp -# 测试:不需要它们来构建. 带上会让"改一行测试"也触发镜像重建. +# 测试: 不需要它们来构建. 带上会让"改一行测试"也触发镜像重建. tests/ # 文档在容器里用不上 (scratch 镜像里没有 cat, 放进去也读不了) diff --git a/Dockerfile b/Dockerfile index 5836e33..7041d92 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,12 @@ # Dockerfile - 构建完全静态的 ligent # -# 用法 (podman 和 docker 都行, 命令一样): +# 用法 (podman 和 docker 都行, 命令一样): # # podman build -t ligent-static . # podman run --rm ligent-static --version # podman create --name tmp ligent-static && podman cp tmp:/ligent-static . && podman rm tmp # -# 或者直接跑 make 目标: +# 或者直接跑 make 目标: # make docker-build # 构建镜像 # make docker-extract # 把产物拷到当前目录 (./ligent-static) # @@ -21,7 +21,7 @@ # musl 目标, 省掉了"装 musl 工具链 + 配 spec 文件"这一整段麻烦, # 而且 musl-gcc 这个包装脚本在 Alpine 上根本不存在也不需要. # -# 具体地:Alpine 的 gcc 默认 `--target=x86_64-alpine-linux-musl`, +# 具体地: Alpine 的 gcc 默认 `--target=x86_64-alpine-linux-musl`, # 所以 tools/build-static-deps.sh 里的 CC 检测会落到 x86_64-linux-musl-gcc # 分支 (我们显式给它 CC=cc), 产出的 .a 天然就是 musl 的. # @@ -36,7 +36,7 @@ # 关于构建缓存 # ------------ # 依赖的编译 (OpenSSL 那步最慢, 几分钟)单独占一层, 且只依赖脚本本身. -# 源码 COPY 放在它后面:改一行 C 代码不会让"重编 OpenSSL"这件事再发生一遍. +# 源码 COPY 放在它后面: 改一行 C 代码不会让"重编 OpenSSL"这件事再发生一遍. # 这是这个 Dockerfile 里唯一一处为了速度而做的结构调整, 值得. # ------------------------------------------------------------------ 构建阶段 @@ -64,7 +64,7 @@ WORKDIR /src # .dockerignore 把 .git 排除了, 容器里本来也看不到它. COPY tools/build-static-deps.sh /src/tools/build-static-deps.sh -# CC=cc:Alpine 的 cc 就是 musl 目标的 gcc, 不需要 musl-gcc 包装脚本. +# CC=cc: Alpine 的 cc 就是 musl 目标的 gcc, 不需要 musl-gcc 包装脚本. # 这一步会联网下载并编译 zlib + OpenSSL + curl, 约几分钟. RUN CC=cc sh /src/tools/build-static-deps.sh @@ -74,7 +74,7 @@ COPY Makefile /src/Makefile COPY tools/get-version.sh /src/tools/get-version.sh # 版本号由外面注入. 容器里没有 .git, get-version.sh 会回退到源码文件的 mtime -# —— 那是"这份源码的日期", 不是"tag 的日期", 所以 CI 应当显式传进来: +# —— 那是"这份源码的日期", 不是"tag 的日期", 所以 CI 应当显式传进来: # podman build --build-arg VERSION=$(git describe --tags --abbrev=0) . # podman build --build-arg VERSION=$(date -u +%Y.%m.%d) . ARG VERSION= @@ -91,31 +91,31 @@ RUN set -eux; \ if [ -n "$GIT_BRANCH" ]; then export LIGENT_BRANCH_OVERRIDE="$GIT_BRANCH"; fi; \ if [ -n "$BUILD_EPOCH" ]; then export SOURCE_DATE_EPOCH="$BUILD_EPOCH"; fi; \ \ - # 静态目标. STATIC_CC=cc:同上, Alpine 的 cc 已经是 musl 目标. \ + # 静态目标. STATIC_CC=cc: 同上, Alpine 的 cc 已经是 musl 目标. \ make static STATIC_CC=cc; \ \ - # 自检:产物必须真的存在、真的是静态的、真的能跑. \ + # 自检: 产物必须真的存在、真的是静态的、真的能跑. \ # 不检查的话, "构建成功"可能只是"链接器没报错", 而产物是个空壳 \ # (比如依赖没链全, 或者 -s 把符号表剥没了导致入口找不到). \ test -x /src/ligent-static; \ if ldd /src/ligent-static 2>&1 | grep -qv "not a dynamic executable\|statically linked\|Not a valid dynamic program"; then \ - echo "产物不是静态链接的:" >&2; ldd /src/ligent-static >&2; exit 1; \ + echo "产物不是静态链接的: " >&2; ldd /src/ligent-static >&2; exit 1; \ fi; \ V=$(/src/ligent-static --version); \ echo "容器内产物版本: $V"; \ # 版本号还是回退默认值 => -D 没生效, 构建是"看起来成功". - # 这里只在**没传 VERSION** 时警告:那种情况下回退是预期行为 (比如有人 + # 这里只在**没传 VERSION** 时警告: 那种情况下回退是预期行为 (比如有人 # 直接 podman build 不带参数), 传了却没用上才是真问题. if [ -n "$VERSION" ] && echo "$V" | grep -q '0.0.0-unknown'; then \ - echo "错误:传了 VERSION=$VERSION 但产物仍报回退版本号" >&2; exit 1; \ + echo "错误: 传了 VERSION=$VERSION 但产物仍报回退版本号" >&2; exit 1; \ fi # ------------------------------------------------------------------ 运行阶段 -# scratch 就够了:ligent-static 是完全静态的, 连 libc 都不需要. +# scratch 就够了: ligent-static 是完全静态的, 连 libc 都不需要. # 没有任何攻击面可言 —— 镜像里只有一个二进制文件, 没有 shell、没有包管理器. FROM scratch -# CA 证书是**唯一**需要的外部文件:TLS 握手要它. +# CA 证书是**唯一**需要的外部文件: TLS 握手要它. # 注意 ligent 用的是编进 libcurl 的默认路径 /etc/ssl/certs/ca-certificates.crt # (libcurl 库不读 CURL_CA_BUNDLE, 那是 curl 命令行工具的行为), # 所以证书必须放在那个位置才行, 放别处不会被读到. @@ -124,7 +124,7 @@ COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certifica COPY --from=build /src/ligent-static /ligent # 说明一下这个镜像是干什么的、怎么用. -# 用 LABEL 而不是放个 README 文件:scratch 里没有 cat, 放文件也读不了. +# 用 LABEL 而不是放个 README 文件: scratch 里没有 cat, 放文件也读不了. LABEL org.opencontainers.image.title="ligent" \ org.opencontainers.image.description="纯 C 实现的 AI Agent (静态构建)" \ org.opencontainers.image.licenses="MIT" \ diff --git a/Makefile b/Makefile index 24cfb0f..8462b59 100644 --- a/Makefile +++ b/Makefile @@ -1,23 +1,23 @@ CC ?= cc -# 警告就是纪律:这一串是"改完就回不去"的那类问题 (影子变量、丢 const、 +# 警告就是纪律: 这一串是"改完就回不去"的那类问题 (影子变量、丢 const、 # 没有原型的函数). 加进来之后新代码写错会当场报出来, 而不是等哪天静态分析 -# 才捞到. 项目在这套 flags 下是零警告的 —— 注意这句话是**连 make test 一起算的**: +# 才捞到. 项目在这套 flags 下是零警告的 —— 注意这句话是**连 make test 一起算的**: # 测试程序用的是同一套 CFLAGS, 以前它们里面藏着 5 条 const 丢弃警告, 只有跑 # test-unit 才会看到, 光看 make 的输出是看不见的. CFLAGS ?= -std=c99 -D_GNU_SOURCE -O2 -g -Wall -Wextra -Wno-unused-parameter \ -Wshadow -Wwrite-strings -Wstrict-prototypes -Wmissing-prototypes \ -Wold-style-definition -Wpointer-arith \ -Wformat=2 -Wno-format-nonliteral -# -Wformat=2 里值钱的是 -Wformat-security:抓"把变量当格式串". 已经抓到一个 —— +# -Wformat=2 里值钱的是 -Wformat-security: 抓"把变量当格式串". 已经抓到一个 —— # doctor 里把 HTTPS_PROXY 的值直接喂给 field(), 而代理 URL 里带 % 是常事. -# -Wno-format-nonliteral 关掉的是它的噪声面:我们自己那套包装内部转发 fmt 的 +# -Wno-format-nonliteral 关掉的是它的噪声面: 我们自己那套包装内部转发 fmt 的 # 地方 (xvasprintf / err_result / …)天然就是非字面量. 可查的部分已经由 # LIGENT_PRINTF 属性在**调用点**查完了. CPPFLAGS += -Isrc -pthread -# 版本号的 -D 也挂在 CPPFLAGS 上:这样主程序、静态目标、以及二十来条测试规则 +# 版本号的 -D 也挂在 CPPFLAGS 上: 这样主程序、静态目标、以及二十来条测试规则 # 全都自动带上, 不用逐个改. 测试程序确实需要它 —— 它们会链 version.c. CPPFLAGS += $(VERSION_DEFS) -# -lm 是必须的:代码里调了 fabs, 而 gcc 会把它折成内建函数, 于是"不写 -lm +# -lm 是必须的: 代码里调了 fabs, 而 gcc 会把它折成内建函数, 于是"不写 -lm # 也能链接"是个假象 —— 换个编译器 (tcc)或加 -fno-builtin 就会露出来. # 显式写上, 不要靠编译器的好意. LDLIBS += -lcurl -lm -pthread @@ -25,7 +25,7 @@ LDLIBS += -lcurl -lm -pthread # make SAN=1 —— 打开 AddressSanitizer / UndefinedBehaviorSanitizer. # 链接行也要带, 否则只有目标文件被插桩、链接期运行时库没进来. ifeq ($(SAN),1) -# 注意 override:命令行上传的变量会屏蔽 makefile 里对它的一切赋值 (连 += 也算), +# 注意 override: 命令行上传的变量会屏蔽 makefile 里对它的一切赋值 (连 += 也算), # 所以 `make SAN=1 CFLAGS="-O1"` 不加 override 的话会**静默地不带 sanitizer** —— # 结果是一份没插桩却看起来「干净」的构建. 这个坑踩过一次, 别再踩. # -fno-sanitize-recover=all 让报错直接中止, 而不是报告完继续跑. @@ -47,13 +47,13 @@ OBJDIR := $(BUILDDIR)/$(BUILD_TAG) HEADERS := $(wildcard $(SRCDIR)/*.h) # ------------------------------------------------------------------ */ -# 版本号:CalVer + git 哈希, 构建期算出来注入 */ +# 版本号: CalVer + git 哈希, 构建期算出来注入 */ # ------------------------------------------------------------------ */ # # 源码里**不写死**版本号, 只留一个回退默认值 (见 src/version.h 的说明). # 这里取真值, 用 -D 注入. 算法和理由都在 tools/get-version.sh 里. # -# 用 := 而不是 = :这段要跑三次 git, 展开式变量会在每次引用时重跑一遍. +# 用 := 而不是 = : 这段要跑三次 git, 展开式变量会在每次引用时重跑一遍. VERSION_SCRIPT := $(SRCDIR)/../tools/get-version.sh LIGENT_VERSION := $(shell sh $(VERSION_SCRIPT) 2>/dev/null | sed -n 1p) LIGENT_GIT_HASH := $(shell sh $(VERSION_SCRIPT) 2>/dev/null | sed -n 2p) @@ -76,13 +76,13 @@ VERSION_DEFS = -DLIGENT_VERSION='"$(LIGENT_VERSION)"' \ -DLIGENT_BUILD_TIME='"$(LIGENT_BUILD_TIME)"' # ------------------------------------------------------------------ */ -# 版本号变了就自动清掉对象, 免得 make 拿旧版本号蒙混过关 */ +# 版本号变了就自动清掉对象, 以免 make 拿旧版本号蒙混过关 */ # ------------------------------------------------------------------ */ # -# 这是"make 看不见命令行变量变了"这个坑的第三个入口 (前两个:混用编译器 +# 这是"make 看不见命令行变量变了"这个坑的第三个入口 (前两个: 混用编译器 # 导致链接错误、make SAN=1 不重新插桩 —— 那两个是用 build// 分目录解决的). # -# 版本号只走 -D, 不进任何依赖关系, 于是: +# 版本号只走 -D, 不进任何依赖关系, 于是: # $ make && ./ligent --version # 2027.01.01+... # $ git tag -d v2027.01.01 # 把 tag 删了 # $ make # "无需做任何事" @@ -91,18 +91,18 @@ VERSION_DEFS = -DLIGENT_VERSION='"$(LIGENT_VERSION)"' \ # # 用一个戳文件记下上次构建用的版本串, 对不上就把**我们自己的**对象目录清掉. # -# 为什么不用 `make clean`:那个会连 build/static-prefix 和 build/static-src 一起删 +# 为什么不用 `make clean`: 那个会连 build/static-prefix 和 build/static-src 一起删 # (用户明确要的语义), 而重建静态依赖要联网下载 + 编译好几分钟. 为了一个版本号 # 字符串付那个代价不合理. 所以这里只删自己的产物, 精确地清. VERSION_STAMP := $(BUILDDIR)/.version-stamp -# 戳文件的内容:版本号 + 哈希 + 构建日期. 任意一项变了都要重编 —— +# 戳文件的内容: 版本号 + 哈希 + 构建日期. 任意一项变了都要重编 —— # 比如只切了 tag (版本号变)或只改了工作区 (.dirty 出现). -# 戳文件的内容:把**每一个**注入进二进制的字符串都算进去. 任意一项变了都要 +# 戳文件的内容: 把**每一个**注入进二进制的字符串都算进去. 任意一项变了都要 # 重编 —— 比如只切了 tag (版本号变)、只改了工作区 (.dirty 出现)、只换了 # 分支 (branch 变). 少算一项, 那一项就会在二进制里过期而 make 毫无察觉, # 正是这个机制要防的事. # -# 注意 BUILD_TIME 不在这里:它每秒都在变, 算进去等于每次 make 都全量重编. +# 注意 BUILD_TIME 不在这里: 它每秒都在变, 算进去等于每次 make 都全量重编. # 代价是同一分钟内改了源码、二进制里的时间戳可能停在上一分钟 —— 那无所谓, # 时间戳是"什么时候编的", 不是"源码什么时候改的", 而日期 (BUILD_DATE)在, # 粒度够用了. @@ -124,7 +124,7 @@ endif ifeq ($(NEED_VERSION_CLEAN),1) # 清掉再让 make 正常评估依赖 —— 这一步发生在**读 Makefile 的时候**, # 所以后面的 $(OBJS) 会看到"对象不存在", 自然全量重编. -# 在 $(shell) 里写文件是安全的:它只在真的需要清的时候跑一次. +# 在 $(shell) 里写文件是安全的: 它只在真的需要清的时候跑一次. $(shell rm -rf $(OBJDIR) $(BIN) && mkdir -p $(BUILDDIR) && printf '%s' '$(VERSION_FINGERPRINT)' > $(VERSION_STAMP)) endif @@ -147,34 +147,34 @@ $(OBJDIR)/%.o: $(SRCDIR)/%.c $(HEADERS) | $(OBJDIR) $(OBJDIR): mkdir -p $(OBJDIR) -# 清掉**一切**构建产物: +# 清掉**一切**构建产物: # build/ 各 <编译器>[-san] 对象目录、build/static、测试可执行文件、 # e2e 的临时文件, 以及 tools/build-static-deps.sh 编出来的 # static-prefix (静态库)和 static-src (源码 + 压缩包缓存) # ligent / ligent-static # tests/__pycache__ 跑测试时 python 生成的字节码 # -# 特意**不动** ligent.history:那不是构建产物, 是你的输入历史, 删了找不回来. +# 特意**不动** ligent.history: 那不是构建产物, 是你的输入历史, 删了找不回来. # (e2e 自己写的是 build/e2e.history, 在 build/ 里, 跟着一起清. ) clean: rm -rf $(BUILDDIR) $(BIN) $(BIN)-static tests/__pycache__ # ------------------------------------------------------------------ */ -# 静态构建:musl libc + 静态 libcurl */ +# 静态构建: musl libc + 静态 libcurl */ # ------------------------------------------------------------------ */ # -# 目标是一个**完全静态**的可执行文件:不依赖宿主机的 libc、libcurl、 +# 目标是一个**完全静态**的可执行文件: 不依赖宿主机的 libc、libcurl、 # OpenSSL. 拷到任何同架构的 Linux 上就能跑. # -# 为什么不能直接拿发行版的 libcurl.a 链接:那是针对 glibc 编的, 和 musl 混在 +# 为什么不能直接拿发行版的 libcurl.a 链接: 那是针对 glibc 编的, 和 musl 混在 # 一起会得到一堆 "undefined reference to __libc_start_main" 之类. 必须有一份 # **用 musl 工具链编出来的**静态 libcurl. 造它的办法见 tools/build-static-deps.sh. # -# 用法: +# 用法: # ./tools/build-static-deps.sh # 先把依赖编到 build/static-prefix # make static # 然后链接 # -# 变量 (都可覆盖): +# 变量 (都可覆盖): # STATIC_CC 默认依次找 musl-gcc / x86_64-linux-musl-gcc # STATIC_PREFIX 依赖所在的前缀 (默认 build/static-prefix) @@ -182,19 +182,19 @@ STATIC_CC ?= $(shell command -v musl-gcc 2>/dev/null || \ command -v x86_64-linux-musl-gcc 2>/dev/null) STATIC_PREFIX ?= $(BUILDDIR)/static-prefix STATIC_BIN := $(BIN)-static -# 静态目标用**独立的对象目录**:那边的 .o 是 musl 编的, 和这边的 glibc 目标 +# 静态目标用**独立的对象目录**: 那边的 .o 是 musl 编的, 和这边的 glibc 目标 # 文件混在一起会互相覆盖, 症状是莫名其妙的重链接或不重链接. STATIC_OBJDIR := $(BUILDDIR)/static STATIC_OBJS := $(patsubst $(SRCDIR)/%.c,$(STATIC_OBJDIR)/%.o,$(SRCS)) static-check: @test -n "$(STATIC_CC)" || { \ - echo "找不到 musl 工具链. 装一个, 或者指定 STATIC_CC:"; \ + echo "找不到 musl 工具链. 装一个, 或者指定 STATIC_CC: "; \ echo " Artix/Arch : sudo pacman -S musl # 提供 musl-gcc"; \ echo " 或者用交叉工具链: make static STATIC_CC=x86_64-linux-musl-gcc"; \ exit 1; } @test -d "$(STATIC_PREFIX)" || { \ - echo "找不到静态依赖前缀 $(STATIC_PREFIX). 先跑:"; \ + echo "找不到静态依赖前缀 $(STATIC_PREFIX). 先跑: "; \ echo " ./tools/build-static-deps.sh"; \ exit 1; } @test -x "$(STATIC_PREFIX)/bin/curl-config" || { \ @@ -205,14 +205,14 @@ static-check: $(STATIC_OBJDIR): mkdir -p $(STATIC_OBJDIR) -# -I$(STATIC_PREFIX)/include:musl-gcc **不搜 /usr/include**, 系统那份 curl 头 +# -I$(STATIC_PREFIX)/include: musl-gcc **不搜 /usr/include**, 系统那份 curl 头 # 也不是给 musl 用的 —— 必须用我们这份 prefix 里的头. STATIC_CPPFLAGS = -I$(STATIC_PREFIX)/include $(CPPFLAGS) $(STATIC_OBJDIR)/%.o: $(SRCDIR)/%.c $(HEADERS) | $(STATIC_OBJDIR) $(STATIC_CC) $(STATIC_CFLAGS) $(STATIC_CPPFLAGS) -c -o $@ $< -# 依赖列表交给 curl 自己报:它最清楚自己链了哪些库、什么顺序. +# 依赖列表交给 curl 自己报: 它最清楚自己链了哪些库、什么顺序. # 手写一串 -lssl -lcrypto -lz 是会过期的, 而且顺序错了链接就失败. static: static-check $(STATIC_OBJS) @CURL_CONFIG="$(STATIC_PREFIX)/bin/curl-config"; \ @@ -224,7 +224,7 @@ static: static-check $(STATIC_OBJS) file $(STATIC_BIN) 2>/dev/null || true; \ ldd $(STATIC_BIN) 2>&1 | head -2 || true -# -fno-pie / -no-pie:现代 gcc 默认编 PIE, 而静态链接一份非 PIC 的 .a +# -fno-pie / -no-pie: 现代 gcc 默认编 PIE, 而静态链接一份非 PIC 的 .a # (我们那份 libcurl.a 就是 -fno-pie 编的)会报 # "relocation R_X86_64_32 against ... can not be used when making a PIE object". # 两头都关掉, 省得踩. @@ -236,9 +236,9 @@ STATIC_LDFLAGS ?= -static -no-pie # ------------------------------------------------------------------ */ # # 普通构建的取舍是"能调试", 静态产物的取舍是"拷过去就能跑、越小越好", -# 所以这里单独一套开关. 实测 6.23 MB → 4.85 MB (-22%), 逐项收益: +# 所以这里单独一套开关. 实测 6.23 MB → 4.85 MB (-22%), 逐项收益: # -# -s 4.92 MB 链接期 strip. **单项最大**:-g 带进来的调试信息 +# -s 4.92 MB 链接期 strip. **单项最大**: -g 带进来的调试信息 # (551 KB) 加上符号表, 一共 1.31 MB —— 占了原产物的 # 五分之一, 而静态发布件根本用不上. # -ffunction-sections 4.91 MB 只对我们自己的 .o 生效, 所以几乎没用 @@ -252,7 +252,7 @@ STATIC_LDFLAGS ?= -static -no-pie # -g0 不生成调试信息 (否则每个 .o 都白白大一截, # 编译也慢, 反正最后要 strip) # -# 还能再小的地方 (不在本文件范围):3.49 MB 的 .text 几乎全是 libcurl + +# 还能再小的地方 (不在本文件范围): 3.49 MB 的 .text 几乎全是 libcurl + # OpenSSL. 要动它得在 tools/build-static-deps.sh 里给依赖也加 -Os 和 # -ffunction-sections, 让 --gc-sections 能真正丢进没被引用的 OpenSSL 代码. STATIC_RELEASE ?= 1 @@ -285,12 +285,12 @@ md_demo: $(OBJDIR)/md_demo # 每个测试各自把源文件重新编译一遍. # # 以前每条规则的依赖写的是源文件 ($(SRCDIR)/json.c 之类), recipe 是 -# `$(CC) … -o $@ $(SRCFILES)` —— 于是同一个源文件会被编很多遍:只改 util.c 一行, +# `$(CC) … -o $@ $(SRCFILES)` —— 于是同一个源文件会被编很多遍: 只改 util.c 一行, # 它会按依赖被重编 16 次, 实测 `make test-unit` 要 25 秒, 而真正的工作量只有 -# "编一个 util.o + 重链 17 个二进制". 现在依赖写成 .o:编译一次、链接多次, +# "编一个 util.o + 重链 17 个二进制". 现在依赖写成 .o: 编译一次、链接多次, # 同样的场景降到几秒. 而这些 .o 主构建本来就要用, 等于没有额外成本. # -# 代价:测试的依赖里多了一层 (.o 是否存在/是否过期由主构建的规则负责), 排查 +# 代价: 测试的依赖里多了一层 (.o 是否存在/是否过期由主构建的规则负责), 排查 # "为什么这个测试用的还是旧代码"要看 .o 的时间戳而不是源文件. $(SRCFILES) 仍然 # 有意义 —— $^ 里混着 $(HEADERS), 而头文件不能上链接行. @@ -377,7 +377,7 @@ $(OBJDIR)/fuzz: tests/fuzz.c $(OBJDIR)/json.o $(OBJDIR)/markdown.o \ $(OBJDIR)/util.o $(OBJDIR)/i18n.o | $(OBJDIR) $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS) -# 单元测试清单:加一个测试只要改这里 + 它自己那条依赖规则 +# 单元测试清单: 加一个测试只要改这里 + 它自己那条依赖规则 UNIT_TESTS := test_json test_tools test_agent test_config test_markdown \ test_http test_lineedit test_archive test_meta test_image \ test_capability test_priv test_attach test_models test_spinner \ @@ -426,9 +426,9 @@ test-e2e: $(BIN) # # README 里引用了"831 单元 + 314 e2e"这样的数字. 数字会随着加测试而过期, # 而过期的数字比不写更糟 —— 它是**错的信息**, 读的人没有理由怀疑它. -# (这件事就发生过:加完 JSON 的 UTF-8 测试后, README 还写着 797. ) +# (这件事就发生过: 加完 JSON 的 UTF-8 测试后, README 还写着 797. ) # -# 所以把"文档说的"和"实际跑出来的"对一遍. 不做成 make test 的一部分: +# 所以把"文档说的"和"实际跑出来的"对一遍. 不做成 make test 的一部分: # 它要跑一遍单元测试来数数, 比 test-unit 本身还慢. check-docs: $(addprefix $(OBJDIR)/,$(UNIT_TESTS)) $(BIN) @unit=0; \ @@ -442,13 +442,13 @@ check-docs: $(addprefix $(OBJDIR)/,$(UNIT_TESTS)) $(BIN) e2e_doc=$$(grep -oE '[0-9]+ e2e' README.md | grep -oE '[0-9]+' | head -1); \ bad=0; \ if [ "$$unit" != "$$unit_doc" ]; then \ - echo " ✗ 单元测试:README 说 $$unit_doc, 实际 $$unit"; bad=1; \ + echo " ✗ 单元测试: README 说 $$unit_doc, 实际 $$unit"; bad=1; \ else echo " ✓ 单元测试 $$unit 项, 与 README 一致"; fi; \ if [ "$$unit" != "$$unit_docs" ]; then \ - echo " ✗ SAN 那节:README 说 $$unit_docs, 实际 $$unit"; bad=1; \ + echo " ✗ SAN 那节: README 说 $$unit_docs, 实际 $$unit"; bad=1; \ else echo " ✓ SAN 那节的数量一致"; fi; \ if [ "$$e2e" != "$$e2e_doc" ]; then \ - echo " ✗ e2e:README 说 $$e2e_doc, 实际 $$e2e"; bad=1; \ + echo " ✗ e2e: README 说 $$e2e_doc, 实际 $$e2e"; bad=1; \ else echo " ✓ e2e $$e2e 项, 与 README 一致"; fi; \ if [ $$bad -eq 0 ]; then echo "文档里的数量与现实一致. "; \ else echo "README 里的数字已过期, 请更新 (数字对不上就是错的信息). "; exit 1; fi @@ -456,7 +456,7 @@ check-docs: $(addprefix $(OBJDIR)/,$(UNIT_TESTS)) $(BIN) # 拿同一套 e2e 打静态产物. 以前"静态构建可用"实际上只等于"它能启动" —— # 依赖脚本的自检只做 curl_easy_init + setopt, 连一个请求都不发; 现在这 300 # 多项跑一遍, 才真的说明它是可用的. -# 单独一个目标、不进 make test:要先编依赖, 慢. +# 单独一个目标、不进 make test: 要先编依赖, 慢. static-test: static @BIN="$(abspath $(STATIC_BIN))" bash tests/run_e2e.sh @@ -469,17 +469,17 @@ static-test: static CONTAINER ?= podman CONTAINER_IMAGE ?= ligent-static -# 版本号显式传给容器:容器里没有 .git, get-version.sh 只能回退到源码 mtime, +# 版本号显式传给容器: 容器里没有 .git, get-version.sh 只能回退到源码 mtime, # 那得到的是"文件日期"而不是"tag 日期". 这里在本机算好传进去. # GIT_HASH / GIT_BRANCH 同理 —— 二进制里的分支名应当反映**源码来自哪个分支**, # 而不是"在容器里看不到分支所以是 unknown". $(VERSION_SCRIPT) 见上面. CONTAINER_VERSION := $(LIGENT_VERSION) CONTAINER_GIT_HASH := $(LIGENT_GIT_HASH) CONTAINER_GIT_BRANCH := $(LIGENT_GIT_BRANCH) -# 传**完整时间戳**而不是只有日期:Dockerfile 里拿它当 SOURCE_DATE_EPOCH, +# 传**完整时间戳**而不是只有日期: Dockerfile 里拿它当 SOURCE_DATE_EPOCH, # 而 "2026-09-22" 被 date(1) 解析出来是那天的 00:00:00 UTC —— 于是容器产物 # 的 --version 里时间永远显示午夜. 传秒数就没有这个歧义, 而且仍然可复现. -# 设了 SOURCE_DATE_EPOCH 的话就沿用它 (那正是"可复现构建"要求的:同一份 +# 设了 SOURCE_DATE_EPOCH 的话就沿用它 (那正是"可复现构建"要求的: 同一份 # 源码无论何时构建都得到同样的时间戳). CONTAINER_BUILD_EPOCH := $(if $(SOURCE_DATE_EPOCH),$(SOURCE_DATE_EPOCH),$(shell date -u +%s)) @@ -491,7 +491,7 @@ docker-build: --build-arg BUILD_EPOCH='$(CONTAINER_BUILD_EPOCH)' \ -t $(CONTAINER_IMAGE) . -# 把产物从镜像里拷出来. 用 create+cp+rm 而不是 build 到本地: +# 把产物从镜像里拷出来. 用 create+cp+rm 而不是 build 到本地: # 镜像是分发单位, 本地文件是给人直接用的, 两者都要, 但不必让 Dockerfile # 同时干这两件事 (那需要挂载点或者 --output, podman/docker 的支持还不齐). docker-extract: docker-build @@ -502,15 +502,15 @@ docker-extract: docker-build @echo " 产物: $(STATIC_BIN) ($$(stat -c%s $(STATIC_BIN) 2>/dev/null || echo '?') 字节)" @file $(STATIC_BIN) 2>/dev/null || true -# 容器产物也要过一遍 e2e —— 和 static-test 一个道理:能跑起来不等于能用. +# 容器产物也要过一遍 e2e —— 和 static-test 一个道理: 能跑起来不等于能用. docker-test: docker-extract @BIN="$(abspath $(STATIC_BIN))" bash tests/run_e2e.sh docker-clean: -@$(CONTAINER) rmi -f $(CONTAINER_IMAGE) >/dev/null 2>&1 || true -# 对抗性模糊测试:单独一个目标, 跑得久一些 -# 对抗性模糊测试:单独一个目标, 轮数比 test-unit 里那个底线大得多 +# 对抗性模糊测试: 单独一个目标, 跑得久一些 +# 对抗性模糊测试: 单独一个目标, 轮数比 test-unit 里那个底线大得多 test-fuzz: $(OBJDIR)/fuzz @echo "==== 模糊测试 (20 万轮)====" @./$(OBJDIR)/fuzz 200000 @@ -523,7 +523,7 @@ test: test-unit test-e2e @echo @echo "全部测试通过. " -# 依赖统一由 $(HEADERS) 管:每个 .o 和每个测试都以全部头文件为依赖. +# 依赖统一由 $(HEADERS) 管: 每个 .o 和每个测试都以全部头文件为依赖. # 不精确, 但一共只有十几个文件、全量重编几秒钟, 换来的是「永远不会漏」. # 原先这里还有一段手写依赖表, 既冗余又不完整 (上次 agent.h 漏更新就是它 # 没跟上), 已删. @@ -536,24 +536,24 @@ $(OBJDIR)/reduce: tests/reduce.c $(OBJDIR)/markdown.o $(OBJDIR)/util.o \ # make analyze —— gcc 静态分析器 (-fanalyzer) */ # ------------------------------------------------------------------ */ # -# 它抓的是单靠警告看不出来的那类问题:读未初始化的值、空指针、资源泄漏. +# 它抓的是单靠警告看不出来的那类问题: 读未初始化的值、空指针、资源泄漏. # 第一次跑就捞出两个真 bug (attempt() 里 curl_easy_init 失败那条提前返回 # 会把未初始化的 cerr 当字符串读; POST 分支对 body==NULL 不设防). # -# 用 -O0 而不是平时的 -O2:开了优化之后 gcc 会内联, 而 -fanalyzer 的跨函数 +# 用 -O0 而不是平时的 -O2: 开了优化之后 gcc 会内联, 而 -fanalyzer 的跨函数 # 状态在遇到内联时并不可靠 —— image.c 里的 rand_suffix 在 -O1/-O2/-Os 下会 # 报一条 "leak of FILE 'f'", 而 -O0 下干净, fclose 也确实就写在 fread 下一行. # 分析器自己的分析是跨函数的, 不依赖 -O 级别, 所以 -O0 不会少查什么. # gcc 文档同样建议 -fanalyzer 配 -O0. # -# 单独一个目标、不进 make test:它比整个测试套件加起来还慢. +# 单独一个目标、不进 make test: 它比整个测试套件加起来还慢. analyze: @fail=0; \ for f in $(SRCS); do \ $(CC) $(CFLAGS) -O0 -fanalyzer -Werror $(CPPFLAGS) -c "$$f" -o /dev/null || fail=1; \ done; \ if [ $$fail -eq 0 ]; then \ - echo "静态分析通过:无告警. "; \ + echo "静态分析通过: 无告警. "; \ else \ echo "静态分析发现问题 (上面的 warning: 就是原因). "; exit 1; \ fi @@ -563,7 +563,7 @@ analyze: # ------------------------------------------------------------------ */ # # make 只按"源文件比目标文件新"决定要不要重编, 它**看不见命令行变量变了**. -# 于是有两类事故: +# 于是有两类事故: # # 1) make CC=tcc 之后再 make CC=clang —— 只有被 touch 过的那几个 .o 是 clang # 编的, 其余还是 tcc 编的. tcc 的目标文件要 tcc 自己的运行时辅助例程 @@ -571,13 +571,13 @@ analyze: # 我自己的构建就是这么炸的, 而且第一次还因为只用 ls 看文件大小而误判成 # "clang 编译通过". # -# 2) 更要命的一类:make 之后再 make SAN=1 —— 一个 .o 都不会重编. +# 2) 更要命的一类: make 之后再 make SAN=1 —— 一个 .o 都不会重编. # 链接行带着 -fsanitize=..., ASan 运行时也进了二进制, 于是它**看起来** # 是插桩过的; 实际 nm 里一个 __asan/__ubsan 符号都没有, 什么都不会检测. # 这意味着"没先 make clean 的 ASan 结论全是假绿灯". # -# 把标签写进路径之后这两种混用都不可能发生了:配置一变, 对应的目录就是空的, +# 把标签写进路径之后这两种混用都不可能发生了: 配置一变, 对应的目录就是空的, # 该编的自然会全编. 全量重编只要几秒, 值得. # -# 注意 STATIC_PREFIX / STATIC_OBJDIR 挂在 $(BUILDDIR) 上而不是 $(OBJDIR) 上: +# 注意 STATIC_PREFIX / STATIC_OBJDIR 挂在 $(BUILDDIR) 上而不是 $(OBJDIR) 上: # build/static-prefix 是 tools/build-static-deps.sh 写死的位置, 不能被标签带跑. diff --git a/README.zh.md b/README.zh.md index d8df9ed..c85c212 100644 --- a/README.zh.md +++ b/README.zh.md @@ -1,13 +1,13 @@ # ligent - C99 实现的命令行 AI Agent -一个用 C99 从零写成的命令行 AI Agent:直连 **OpenAI 兼容**的 Chat Completions +一个用 C99 从零写成的命令行 AI Agent: 直连 **OpenAI 兼容**的 Chat Completions 接口, 具备**原生 function calling** (工具调用)能力, 能真正读写文件、执行命令、 搜索代码, 并在终端里流式输出. 对话、工具调用、图片走的都是通用协议 (`image_url` + base64 内联, DeepSeek 和 MiMo 都收); **只有思考模式的字段是方言**, 不做跨厂商探测. 代价见 §9. -除 `libcurl` (HTTPS 传输)外, **不依赖任何第三方库**: +除 `libcurl` (HTTPS 传输)外, **不依赖任何第三方库**: JSON 解析器、SSE 流式协议解析、工具调用增量拼接、REPL 全部是本仓库自己实现的. ``` @@ -30,7 +30,7 @@ $ ligent "把 src/util.c 里的 xmalloc 改成失败时打印调用栈, 然后 ## 1. 编译 -依赖:`gcc` (或 `clang`)、`make`、`libcurl` 开发包. +依赖: `gcc` (或 `clang`)、`make`、`libcurl` 开发包. ```bash # Debian / Ubuntu @@ -54,7 +54,7 @@ make test # 跑单元测试 + 端到端测试 依赖只有 **libcurl** 和 libc, 外加一个 C99 编译器 (gcc / clang / tcc 都编得过). 跑测试还需要 Python 3 —— 端到端测试用本地 mock 服务端, **不联网、不需要密钥**. -其它目标: +其它目标: | 目标 | 作用 | |---|---| @@ -72,7 +72,7 @@ make test # 跑单元测试 + 端到端测试 ### 构建目录按编译器和配置分开 -产物不在 `build/` 正下方, 而是 `build/<编译器>[-san]/`: +产物不在 `build/` 正下方, 而是 `build/<编译器>[-san]/`: ``` build/cc/ # 默认 CC=cc @@ -81,23 +81,23 @@ build/tcc/ build/cc-san/ # make SAN=1 ``` -原因很实在:**make 看不见命令行变量变了**. `make CC=tcc` 之后再 `make CC=clang`, +原因很实在: **make 看不见命令行变量变了**. `make CC=tcc` 之后再 `make CC=clang`, 只有被改过的那几个 `.o` 是 clang 编的, 其余还是 tcc 的, 链接时就是一堆 -`__floatundidf` 之类的 undefined reference. 更危险的是 `make` 之后再 `make SAN=1`: +`__floatundidf` 之类的 undefined reference. 更危险的是 `make` 之后再 `make SAN=1`: 一个 `.o` 都不会重编, 而链接行带着 `-fsanitize=...`、ASan 运行时也确实进了二进制, 于是它**看起来**是插桩过的 —— 可 `nm` 里一个 `__asan`/`__ubsan` 符号都没有, 什么都不会检测. 换句话说, "没先 `make clean` 的 ASan 结论"全是假绿灯. -把标签写进路径之后这两种混用都不可能发生了:配置一变, 对应目录就是空的, 该编的 +把标签写进路径之后这两种混用都不可能发生了: 配置一变, 对应目录就是空的, 该编的 自然会全编. -`make clean` 清掉**一切**构建产物:各 `<编译器>[-san]` 对象目录、`build/static`、 +`make clean` 清掉**一切**构建产物: 各 `<编译器>[-san]` 对象目录、`build/static`、 测试可执行文件、e2e 的临时文件、`ligent` / `ligent-static`, 以及 `tools/build-static-deps.sh` 编出来的 `build/static-prefix` (静态库)和 `build/static-src` (源码 + 压缩包缓存), 外加 `tests/__pycache__`. 换句话说, `make clean` 之后 `git status --ignored` 应该只剩你自己的东西. -代价是明确的:**清掉依赖之后, 下次静态构建要重新联网下载再编译几分钟**: +代价是明确的: **清掉依赖之后, 下次静态构建要重新联网下载再编译几分钟**: ```bash make clean @@ -108,12 +108,12 @@ make static `make static` 自己**不会**去编依赖 (那等于让一个构建目标偷偷联网下载), 它会明确 报错并告诉你该跑上面那条命令. -特意**不动 `ligent.history`**:那不是构建产物, 是你的输入历史, 删了找不回来. +特意**不动 `ligent.history`**: 那不是构建产物, 是你的输入历史, 删了找不回来. (e2e 自己写的是 `build/e2e.history`, 在 `build/` 里, 跟着一起清. ) ### 让编译器检查我们自己的 printf -项目里有一层自己的 printf 风格包装:`xasprintf` / `sb_printf` / `err_result` / +项目里有一层自己的 printf 风格包装: `xasprintf` / `sb_printf` / `err_result` / `log_printf` / `spinner_notice` / `perr` / doctor 的 `field`·`marked`·`sub`, 一共 11 个函数、约 120 个调用点. 它们的格式串**编译器一个都没在看** —— 因为全项目 `__attribute__` 出现 0 次, 编译器不知道这些函数有 printf 语义. @@ -123,11 +123,11 @@ make static `__attribute__((format(printf, …)))`, tcc 没有这个机制、展开成空)把 11 个函数全标上, 再加 `-Wformat=2`. -结果:120 个调用点全部过了检查, 并额外抓到 doctor 里把 `HTTPS_PROXY` 的值**直接当 +结果: 120 个调用点全部过了检查, 并额外抓到 doctor 里把 `HTTPS_PROXY` 的值**直接当 格式串**的写法 —— 代理 URL 里带 `%` 是常事 (密码里的 `%40`、路径里的 `%2F`), 那会去读一个不存在的参数. 改成 `field("代理", "%s", …)`. -`-Wformat=2` 附带 `-Wformat-nonliteral`, 我们用 `-Wno-format-nonliteral` 关掉了: +`-Wformat=2` 附带 `-Wformat-nonliteral`, 我们用 `-Wno-format-nonliteral` 关掉了: 包装函数内部转发 `fmt` 的地方 (`xvasprintf` → `vsnprintf(fmt, ap)`)天然是非字面量, 而**调用点**那一侧已经由属性查完了, 留着只是噪声. `-Wformat-security` 保留着 —— 它正是抓到上面那个代理问题的那条. @@ -142,18 +142,18 @@ make static make analyze # gcc -fanalyzer, 独立目标, 故意不进 make test (它比全套测试还慢) ``` -它抓的是稀疏但致命的那类问题, 第一次跑就捞出两个真 bug:`attempt()` 里 +它抓的是稀疏但致命的那类问题, 第一次跑就捞出两个真 bug: `attempt()` 里 `curl_easy_init` 失败那条提前返回会把未初始化的 `cerr` 当字符串读; POST 分支对 `body == NULL` 不设防 (无条件 `strlen`). 两个都不在任何测试的覆盖里 —— 前者要 `curl_easy_init` 真的失败, 后者要一个当前调用方不会传的参数组合. -用 `-O0` 跑是有意的:开了优化之后 gcc 会内联, 而 `-fanalyzer` 的跨函数状态遇到 +用 `-O0` 跑是有意的: 开了优化之后 gcc 会内联, 而 `-fanalyzer` 的跨函数状态遇到 内联并不可靠 (`image.c` 的 `rand_suffix` 在 `-O1/-O2/-Os` 下会报一条并不存在的 FILE 泄漏, `fclose` 明明就写在 `fread` 下一行; `-O0` 下干净). 分析器自身就是跨 -函数分析的, 不依赖 `-O` 级别, 所以降级不会少查什么. 目标里带 `-Werror`:不然 +函数分析的, 不依赖 `-O` 级别, 所以降级不会少查什么. 目标里带 `-Werror`: 不然 它永远打印"通过"—— gcc 的分析器告警不影响退出码, 这是我第一版踩的坑. -### 换个编译器:tcc 也能编 +### 换个编译器: tcc 也能编 ```bash make CC=tcc # 全套测试 (3692 单元 + 318 e2e)实测通过 @@ -163,34 +163,34 @@ tcc 0.9.28 能把 ligent 完整编出来并链接成功, 产物 1.0 MB. CPU 密 (`fuzz 4000`)上 **218 ms vs gcc 的 171 ms, 只慢约 1.3 倍** —— 对 ligent 这种 瓶颈在网络的程序完全无所谓. -它换不来三样东西, 用之前得知道: +它换不来三样东西, 用之前得知道: -- **没有 `-fsanitize`**:`make SAN=1` 仍然要用 gcc/clang. +- **没有 `-fsanitize`**: `make SAN=1` 仍然要用 gcc/clang. - **没有那些严格警告** (`-Wshadow` / `-Wmissing-prototypes` 等). tcc 的诊断 弱得多, 所以"零警告"这条纪律只在 gcc/clang 下成立. - 没有优化器 (上面那 1.3 倍就是它的全部代价). -顺带一提, tcc 一上来就帮我们抓出两个**一直存在的构建脆弱点**:代码调了 `fabs` +顺带一提, tcc 一上来就帮我们抓出两个**一直存在的构建脆弱点**: 代码调了 `fabs` 却没链 `-lm` (gcc 把它折成内建函数, 所以看不出来), 以及 6 条测试链接规则漏了 `$(LDLIBS)`. 两个都补上了 —— 这类"某个编译器恰好替你兜住"的洞, 平时是看不见的. -### 静态构建:musl libc + 静态 libcurl +### 静态构建: musl libc + 静态 libcurl -想要一个**拷到哪都能跑**的可执行文件 (不依赖宿主机的 glibc、libcurl、OpenSSL): +想要一个**拷到哪都能跑**的可执行文件 (不依赖宿主机的 glibc、libcurl、OpenSSL): ```bash ./tools/build-static-deps.sh # 造一份 musl 编的静态 libcurl (要联网、几分钟) -make static # 产物:ligent-static +make static # 产物: ligent-static file ligent-static # → ELF 64-bit ... statically linked ``` **为什么不能直接拿发行版的 `libcurl.a` 链接. ** 那是针对 glibc 编的, 和 musl 混在一起会得到一堆 `undefined reference to __libc_start_main`. 必须有一份用 -musl 工具链编出来的静态 libcurl —— 这就是 `tools/build-static-deps.sh` 干的事: +musl 工具链编出来的静态 libcurl —— 这就是 `tools/build-static-deps.sh` 干的事: 用 musl 把 zlib、OpenSSL、curl 依次编到 `build/static-prefix/`, 最后**真的链一个 最小程序**自检 (静态链接最常栽在依赖顺序和缺库上, 而那只在链接时暴露). -产物是 `ligent-static`, 和常规构建**互不干扰**:静态目标用自己的对象目录 +产物是 `ligent-static`, 和常规构建**互不干扰**: 静态目标用自己的对象目录 (`build/static/`), 因为那边的 `.o` 是 musl 编的, 混在一起会互相覆盖. | 变量 | 默认 | 说明 | @@ -202,13 +202,13 @@ musl 工具链编出来的静态 libcurl —— 这就是 `tools/build-static-de ### 把静态产物压到最小 静态产物是"拷过去就能跑"的发布件, 不是拿来调试的, 所以它的取舍和常规构建相反. -**6.24 MB → 3.29 MB (-47%, 几乎腰斩)**, 分两半, 缺一半效果就大打折扣: +**6.24 MB → 3.29 MB (-47%, 几乎腰斩)**, 分两半, 缺一半效果就大打折扣: **A. 链接我们自己的东西 (Makefile, `STATIC_RELEASE=1`)** | 开关 | 效果 | |---|---| -| `-s` | 4.92 MB. **单项最大**:`-g` 带进来的调试信息 (551 KB)加符号表共 1.31 MB | +| `-s` | 4.92 MB. **单项最大**: `-g` 带进来的调试信息 (551 KB)加符号表共 1.31 MB | | `-ffunction-sections -fdata-sections` + `--gc-sections` | 让链接器能按函数丢没被引用的段 | | `-fno-asynchronous-unwind-tables -fno-unwind-tables` | `.eh_frame` 本来 514 KB, C 代码用不上 | | `-Os` / `-g0` | 优先体积; 不生成反正要 strip 掉的调试信息 | @@ -216,14 +216,14 @@ musl 工具链编出来的静态 libcurl —— 这就是 `tools/build-static-de **B. 链接依赖库 (`tools/build-static-deps.sh`)** -4.84 MB → 3.29 MB. 这一段**必须改依赖的编译选项**才有用:`-ffunction-sections` +4.84 MB → 3.29 MB. 这一段**必须改依赖的编译选项**才有用: `-ffunction-sections` 是**编译期**的事, 依赖的 `.a` 不按函数分段的话, `--gc-sections` 只能整份整份地丢, 几乎没有效果 (实测只对我们自己的 `.o` 开这三件套, 产物反而大了 1.6 KB —— 分段本身 也有开销). 给 zlib/OpenSSL/curl 都加上 `-Os -ffunction-sections -fdata-sections -fno-*-unwind-tables` 之后, libcurl+OpenSSL 的 `.text` 从 **3.45 MB 掉到 2.41 MB** —— 静态归档里本来就有一大堆我们永远走不到的算法和协议实现. -`CFLAGS_COMMON` 里带的这几个开关是**体积优先**的默认值; 要回到调试友好版: +`CFLAGS_COMMON` 里带的这几个开关是**体积优先**的默认值; 要回到调试友好版: ```bash CFLAGS_COMMON="-O2" ./tools/build-static-deps.sh ``` @@ -233,36 +233,36 @@ CFLAGS_COMMON="-O2" ./tools/build-static-deps.sh 旧选项编出来的依赖, 症状是"我明明加了 `-Os`, 产物怎么一点没小" —— 和 Makefile 里 对象目录按配置分标签是同一个坑的两处. -顺手排掉的:**`ld.gold` 不是更小而是更大** (4.89 MB vs 4.86 MB), `--icf=all` +顺手排掉的: **`ld.gold` 不是更小而是更大** (4.89 MB vs 4.86 MB), `--icf=all` 相同代码折叠在这份代码上一点没省, 所以没换链接器. -**验证方式** (这些数字都是量出来的, 不是估的): +**验证方式** (这些数字都是量出来的, 不是估的): ```bash make static # → 3,292,568 字节 make static-test # 拿同一套 318 项 e2e 打静态产物 (而不是只看它能不能启动) ``` -TLS 单独验:本地起 `openssl s_server` 拿自签证书, 让静态产物连它 —— 它会报 -`SSL certificate problem: self-signed certificate`. 这个**失败**恰恰是成功的证据: +TLS 单独验: 本地起 `openssl s_server` 拿自签证书, 让静态产物连它 —— 它会报 +`SSL certificate problem: self-signed certificate`. 这个**失败**恰恰是成功的证据: 能走到"证书不被信任"说明 TCP、ClientHello、证书解析、证书链校验整条 OpenSSL 路径 都通了. 这也是静态二进制的真实 HTTPS 路径**第一次被实际验证** (依赖脚本的自检 只做 `curl_easy_init` + `setopt`, 从不发请求, 验不到这一层). -顺带发现 (既有限制, 与本次改动无关):**libcurl 库不读 `CURL_CA_BUNDLE`**, +顺带发现 (既有限制, 与本次改动无关): **libcurl 库不读 `CURL_CA_BUNDLE`**, 只有 `curl` 命令行工具读它 —— `CURLINFO_CAINFO` 显示它永远用编进去的 `/etc/ssl/certs/ca-certificates.crt`. 所以 ligent 只能用系统 CA 包, 想指定别的 得改成显式 `CURLOPT_CAINFO`. -几个刻意的取舍: +几个刻意的取舍: - **依赖列表交给 `curl-config --static-libs`**, 不手写 `-lssl -lcrypto -lz`. curl 最清楚自己链了哪些库、什么顺序; 手写的那串会过期, 顺序错了还链不上. -- **`-fno-pie` / `-no-pie`**:现代 gcc 默认编 PIE, 而静态链接一份非 PIC 的 `.a` +- **`-fno-pie` / `-no-pie`**: 现代 gcc 默认编 PIE, 而静态链接一份非 PIC 的 `.a` 会报 `relocation R_X86_64_32 ... can not be used when making a PIE object`. -- **不用 nghttp2**:静态构建只留 ligent 真正要的东西, 于是它说 HTTP/1.1 —— +- **不用 nghttp2**: 静态构建只留 ligent 真正要的东西, 于是它说 HTTP/1.1 —— 服务端都支持, 我们也不需要多路复用. -- **CA 路径编进去** (`/etc/ssl/certs`):静态二进制跑到别的机器上, 得知道去哪 +- **CA 路径编进去** (`/etc/ssl/certs`): 静态二进制跑到别的机器上, 得知道去哪 找根证书. ### 在容器里构建 (CI/CD) @@ -277,11 +277,11 @@ make docker-test # 拿同一套 318 项 e2e 打容器产物 加 `CONTAINER=docker` 即可 —— 两者的 `build`/`run`/`cp` 参数在这里是一样的. **为什么用 Alpine 做构建环境. ** 静态目标要一份用 musl 编出来的 libcurl, -而 Alpine 本身就是 musl 发行版:`apk add build-base` 得到的 gcc 直接就是 musl +而 Alpine 本身就是 musl 发行版: `apk add build-base` 得到的 gcc 直接就是 musl 目标 (`--target=x86_64-alpine-linux-musl`), `tools/build-static-deps.sh` 里 那套"找 musl-gcc、配 spec 文件"的麻烦在容器里根本不存在. -**最终镜像 3.49 MB, 里面只有两个文件**:静态二进制和 CA 证书. +**最终镜像 3.49 MB, 里面只有两个文件**: 静态二进制和 CA 证书. ``` $ podman export $(podman create ligent-static) | tar -t @@ -291,11 +291,11 @@ ligent 用 `scratch` 做运行阶段 —— 静态链接的产物连 libc 都不需要, 所以镜像里没有 shell、没有包管理器、没有任何可被利用的东西. CA 证书放在 -`/etc/ssl/certs/ca-certificates.crt` 是**必须**的:libcurl 用的是编进去的这个 +`/etc/ssl/certs/ca-certificates.crt` 是**必须**的: libcurl 用的是编进去的这个 路径 (见上一条), 放别处它读不到. 版本号、分支、哈希都由 build-arg 注入 —— 容器里没有 `.git` (`.dockerignore` -把它排除了), 脚本在里面**问不出**这些, 只能由外面算好传进去: +把它排除了), 脚本在里面**问不出**这些, 只能由外面算好传进去: ```bash podman build \ @@ -307,7 +307,7 @@ podman build \ `make docker-build` 会自动在本机算好传进去, 不用手写. -两个容易踩的点: +两个容易踩的点: - **`BUILD_EPOCH` 必须传完整的秒数, 不能只传日期. ** 它被当成 `SOURCE_DATE_EPOCH`, 而 `date(1)` 把 `2026-09-22` 解析成那天 **00:00:00 @@ -316,10 +316,10 @@ podman build \ - **分支也要传. ** 不传的话二进制里会是 `unknown`, 而那**不是事实** —— 源码来自哪个分支是外面知道的, 只是容器里看不到而已. -Dockerfile 里有自检:产物必须存在且可执行、必须真的是静态的、传了 VERSION 就 +Dockerfile 里有自检: 产物必须存在且可执行、必须真的是静态的、传了 VERSION 就 绝不能报回退版本号, 否则构建失败. 这几条都是为了挡住"链接器没报错 ≠ 产物可用". -**实测** (同一份源码, 本机 + 容器): +**实测** (同一份源码, 本机 + 容器): ```console $ ./ligent --version # 本机, Artix + gcc 16.2.1 @@ -329,11 +329,11 @@ $ podman run --rm ligent-static --version # 容器, Alpine 3.20 + gcc 13.2.1 ligent 2026.09.22-209538e.dirty (master, 2026-09-22 04:44:33) [GCC 13.2.1 20240309] on linux/x86_64 ``` -编译器那一段如实反映**实际用的**工具链 —— 这正是它值得记进版本号的原因: +编译器那一段如实反映**实际用的**工具链 —— 这正是它值得记进版本号的原因: 两个产物的行为可能因编译器版本不同而有细微差异, 排查时得知道是哪个编的. 容器产物同样过了**全套 318 项 e2e** (`make docker-test`). -### 版本号:CalVer + git 哈希 +### 版本号: CalVer + git 哈希 ```console $ ligent --version @@ -344,7 +344,7 @@ ligent 2026.09.22-209538e.dirty (master, 2026-09-22 04:44:33) [GCC 16.2.1 202608 │ │ │ └ 构建时间 (UTC) └ 编译器 └ 目标平台 │ │ └ 分支 (detached HEAD 时为 unknown) │ └ git 短哈希, .dirty 表示工作区有未提交改动 - └ CalVer:发布日 + └ CalVer: 发布日 ``` 前两段 (`2026.09.22-209538e.dirty`)是**版本号主体**, 也是下面几节引用它时 @@ -356,15 +356,15 @@ ligent 2026.09.22-209538e.dirty (master, 2026-09-22 04:44:33) [GCC 16.2.1 202608 | `2026.02.14-1a2b3c4` | tag 之后又提交了 | | `2026.02.14-1a2b3c4.dirty` | 工作区还有没提交的改动 | -分隔符用 `-` 而不是 `git describe` 传统的 `+g`:我们只用 git, 不存在"区分是 +分隔符用 `-` 而不是 `git describe` 传统的 `+g`: 我们只用 git, 不存在"区分是 哪种 SCM"的需求 (那正是 `g` 前缀存在的理由), 而少两个符号念起来也顺. -**编译器与平台由编译期宏判断, 不由构建脚本传. ** 这是有意的:那份信息是 +**编译器与平台由编译期宏判断, 不由构建脚本传. ** 这是有意的: 那份信息是 "这份代码被什么编出来、给谁跑的", 编译器自己最清楚. 构建脚本去猜反而多一个 出错点, 而且**交叉编译时会猜错** —— `uname()` 说的是"现在跑在哪", `__linux__` 说的是"编给谁跑", 静态产物会被拷到别的机器上跑, 该记的是后者. -三个编译器各有一条腿, 因为它们的宏并不统一: +三个编译器各有一条腿, 因为它们的宏并不统一: | 编译器 | 取法 | |---|---| @@ -372,10 +372,10 @@ ligent 2026.09.22-209538e.dirty (master, 2026-09-22 04:44:33) [GCC 16.2.1 202608 | clang | `__clang_version__`, **要掐掉尾随空格** (它自带一个) | | tcc | 没有 `__VERSION__`, 只有整数 `__TINYC__`, 按"去掉小数点当十进制读"还原 | -判断顺序也有讲究:**clang 也定义 `__GNUC__`** (它假装自己是 gcc 4.2), 所以 +判断顺序也有讲究: **clang 也定义 `__GNUC__`** (它假装自己是 gcc 4.2), 所以 clang 必须排在 gcc 前面, 否则会被认成 GCC. - (这里踩过两个坑, 都是实测三个编译器才发现的:tcc 的版本号按 `/100` 算会得到 + (这里踩过两个坑, 都是实测三个编译器才发现的: tcc 的版本号按 `/100` 算会得到 `tcc 9.2.8`, 正确是 `/1000` → `0.9.28`; clang 的版本串带尾随空格, 会输出成 `Clang 22.1.8 ]`. 单测 gcc 永远发现不了这两个. ) @@ -385,7 +385,7 @@ clang 必须排在 gcc 前面, 否则会被认成 GCC. **为什么还要带哈希. ** CalVer 的粒度是"天", 同一天可能发好几次; 而且用户手上 的二进制未必来自 tag (可能自己改了源码). 带上哈希, 报 bug 时能精确定位. -`.dirty` 标记尤其重要:不带它的话, 改过源码的构建和干净构建会报**同样的** +`.dirty` 标记尤其重要: 不带它的话, 改过源码的构建和干净构建会报**同样的** 版本号, 排查时会被完全误导. 未跟踪的新文件也算脏 —— 它可能正是被编进去的那个 源文件. @@ -395,7 +395,7 @@ clang 必须排在 gcc 前面, 否则会被认成 GCC. 不会出现版本号不一致, 也不必为发版多做一个改源码的提交. 这是 dunst / curl 这类项目的通行做法. -取值的优先级 (`tools/get-version.sh` 里有完整注释): +取值的优先级 (`tools/get-version.sh` 里有完整注释): | 场景 | 结果 | |---|---| @@ -404,14 +404,14 @@ clang 必须排在 gcc 前面, 否则会被认成 GCC. | 没有 tag (tarball) | 回退到源码文件的日期 | | 没有 `sh` 或全失败 | `0.0.0-unknown`, **绝不让构建挂掉** | -`SOURCE_DATE_EPOCH` 也认 (可复现构建的标准约定):设了它, 同一份源码在不同 +`SOURCE_DATE_EPOCH` 也认 (可复现构建的标准约定): 设了它, 同一份源码在不同 时间编出来的二进制是逐字节相同的, CI 上才能校验产物哈希. 版本号只在 `--version` 和 `ligent doctor` 里出现, 不参与任何协议交互. `doctor` 把它放在最上面 —— 报 bug 时第一件要问的就是"你用的是哪个版本". **实测过了** (Artix, musl-gcc / gcc 16.2.1, zlib 1.3.1 + OpenSSL 3.0.15 + -curl 8.11.1): +curl 8.11.1): ``` ligent-static: ELF 64-bit LSB executable, x86-64, statically linked @@ -420,9 +420,9 @@ ldd ligent-static → 不是动态可执行文件 TLS 真握手验过 (本地 s_server, 见上一节) ``` -一路上真正会绊人的坑 (脚本里都处理了, 记下来免得重踩): +一路上真正会绊人的坑 (脚本里都处理了, 记下来以免重踩): -1. **`musl-gcc` 不搜 `/usr/include`. ** 于是 OpenSSL 挂在内核 UAPI 头上: +1. **`musl-gcc` 不搜 `/usr/include`. ** 于是 OpenSSL 挂在内核 UAPI 头上: `fatal error: linux/mman.h: No such file or directory`. 要用 `-idirafter /usr/include` —— musl 自己的头优先、系统头兜底. 写成 `-I` 就反了, glibc 的头会盖过 musl 的. @@ -439,24 +439,24 @@ TLS 真握手验过 (本地 s_server, 见上一节) 5. **下载地址会过期. ** zlib 把旧版本挪到了 `/fossils/`. 脚本对每个包试多个 地址 (官网 / fossils / GitHub release), 全失败才退出并告诉你怎么改版本. 6. **路径里有非 ASCII 字符时, `pkg-config` 会吐出非法 UTF-8. ** - 这是最阴的一个:只在"仓库路径带中文"这类情况下才出现, 而症状离原因非常远. + 这是最阴的一个: 只在"仓库路径带中文"这类情况下才出现, 而症状离原因非常远. - curl 的 configure 挑 OpenSSL 时, 只要 `/lib/pkgconfig/openssl.pc` 存在, 就走 `pkg-config` 那条路 (`configure` 里的 `PKGTEST` 逻辑); - 而 configure 开头就是 autoconf 的标准开头 `LC_ALL=C; export LC_ALL`, **脚本外面设 `LC_ALL=C.UTF-8` 压不住它** (我试过, 没用); - pkg-config 在 C locale 下用 `isprint()` 判断字节要不要转义, 每个 ≥ 0x80 - 的字节都被判成"不可打印", 于是它给路径里每个非 ASCII 字节前面加一个反斜杠: + 的字节都被判成"不可打印", 于是它给路径里每个非 ASCII 字节前面加一个反斜杠: `-L/…/开发/…` → `-L/…/\开\发/…`. 反斜杠后面跟 `E5` 不是合法续字节, 所以那串是**非法 UTF-8**; - curl 把它写进 `curl-config`、`libcurl.pc` 和一堆 Makefile (实测 26 个文件). - 后果不只是难看:`make static` 的 recipe 里那句 `echo " 依赖: $LIBS"` 会把 + 后果不只是难看: `make static` 的 recipe 里那句 `echo " 依赖: $LIBS"` 会把 `curl-config --static-libs` 的输出原样打出来 → 进工具输出 → 进对话历史 → 进请求体 → 服务端以 `invalid unicode code point` **拒掉整个请求**. 而且坏 数据已经留在历史里, 之后每一次请求都失败, 只能丢掉整轮对话. - 修法是**在 configure 期间把 `.pc` 藏起来** (藏不是删, 编完放回去):curl + 修法是**在 configure 期间把 `.pc` 藏起来** (藏不是删, 编完放回去): curl 看不见 `openssl.pc` 就退回"直接用 `-L$prefix/lib`"那条路, 路径干干净净. 脚本末尾还有一道 `iconv` 往返自检, curl-config 输出不是合法 UTF-8 就直接退出. @@ -465,17 +465,17 @@ TLS 真握手验过 (本地 s_server, 见上一节) ## 2. 配置 API key -默认就是 DeepSeek:`https://api.deepseek.com/v1` + `deepseek-flash`, -密钥默认从 **`$DEEPSEEK_API_KEY`** 读取. 所以最简单的用法是: +默认就是 DeepSeek: `https://api.deepseek.com/v1` + `deepseek-flash`, +密钥默认从 **`$DEEPSEEK_API_KEY`** 读取. 所以最简单的用法是: ```bash export DEEPSEEK_API_KEY=sk-xxxxxxxx ligent "你好" ``` -### 配置文件:只有一个位置 —— 二进制旁边 +### 配置文件: 只有一个位置 —— 二进制旁边 -配置**只**从可执行文件所在目录读取, 文件名固定 `ligent.json`: +配置**只**从可执行文件所在目录读取, 文件名固定 `ligent.json`: ``` /path/to/ligent ← 可执行文件 @@ -490,16 +490,16 @@ $EDITOR ./ligent.json **不做用户级查找** (没有 `~/.config`、没有 `$XDG_CONFIG_HOME`、没有 `~/.ligent.json`), **不做系统级查找** (没有 `/etc`), **也不看当前工作目录**. -这样整个程序是自包含的:拷到别的机器、塞进 U 盘、丢进容器, 配置跟着走. +这样整个程序是自包含的: 拷到别的机器、塞进 U 盘、丢进容器, 配置跟着走. -**用软链接时, 跟着软链接走. ** 如果你把 ligent 软链到 `~/bin`: +**用软链接时, 跟着软链接走. ** 如果你把 ligent 软链到 `~/bin`: ```bash ln -s /opt/ligent/ligent ~/bin/ligent ``` 那么配置目录是 `~/bin`, 而不是 `/opt/ligent` —— 你放在哪、就认哪. -四种调用方式结果一致:绝对路径、相对路径、链接链 (取第一层)、以及经 `$PATH` +四种调用方式结果一致: 绝对路径、相对路径、链接链 (取第一层)、以及经 `$PATH` 调用 (`argv[0]` 里没有斜杠的情况). 实现上优先用 `argv[0]` (不解析最后一段软链接), 只在拿不到时才退回 @@ -514,18 +514,18 @@ config_file : /home/you/ligent/ligent.json (不存在, 使用内置默认值) `--print-config` 会显示规范位置, 所以不用猜它在哪找. -想临时换一份配置时有两个显式入口 (都不是"默认查找位置"): +想临时换一份配置时有两个显式入口 (都不是"默认查找位置"): ```bash ligent -c /path/to/other.json "..." # 命令行显式指定 LIGENT_CONFIG=/path/to/other.json ligent "..." ``` -整体优先级:**内置默认值 → 配置文件 → 环境变量 → 命令行参数** +整体优先级: **内置默认值 → 配置文件 → 环境变量 → 命令行参数** (`api_key` 按上面的规则细化). -配置文件里可以用 `${VAR}` 引用环境变量, 避免把密钥明文写进磁盘: +配置文件里可以用 `${VAR}` 引用环境变量, 避免把密钥明文写进磁盘: ```json { @@ -535,7 +535,7 @@ LIGENT_CONFIG=/path/to/other.json ligent "..." } ``` -想接别家服务, 只改 `base_url` 和 `model` 即可, 例如: +想接别家服务, 只改 `base_url` 和 `model` 即可, 例如: | 服务 | base_url | model 示例 | |---|---|---| @@ -544,7 +544,7 @@ LIGENT_CONFIG=/path/to/other.json ligent "..." | 本地 Ollama | `http://localhost:11434/v1` | `qwen2.5:14b` | | 本地 vLLM | `http://localhost:8000/v1` | 你部署的模型名 | -其他可选环境变量:`LIGENT_BASE_URL`、`LIGENT_MODEL`、`LIGENT_SYSTEM`、 +其他可选环境变量: `LIGENT_BASE_URL`、`LIGENT_MODEL`、`LIGENT_SYSTEM`、 `LIGENT_TEMPERATURE`、`LIGENT_MAX_TOKENS`、`LIGENT_THINKING`、 `LIGENT_REASONING_EFFORT`、`LIGENT_TIMEOUT`、`LIGENT_MAX_STEPS`、 `LIGENT_STREAM`、`LIGENT_TOOLS`. @@ -552,7 +552,7 @@ LIGENT_CONFIG=/path/to/other.json ligent "..." ### 思考模式与级别 推理模型可以先"想"一段再回答. DeepSeek 的思考模式**默认就是开着的, 默认级别 `high`**, -所以什么都不配也能用; 想控制就加这两项: +所以什么都不配也能用; 想控制就加这两项: ```json { @@ -561,14 +561,14 @@ LIGENT_CONFIG=/path/to/other.json ligent "..." } ``` -- `thinking`:`"on"` / `"off"` / `"auto"`, 也接受 `true` / `false`. +- `thinking`: `"on"` / `"off"` / `"auto"`, 也接受 `true` / `false`. `auto` (默认)表示**请求里根本不带这个字段**, 完全由服务端决定. 开启时下发 `{"thinking":{"type":"enabled"}}`, 关闭则是 `disabled`. -- `reasoning_effort`:`minimal` / `low` / `medium` / `high` / `xhigh` / `max` / `ultra`, +- `reasoning_effort`: `minimal` / `low` / `medium` / `high` / `xhigh` / `max` / `ultra`, 留空则不发. DeepSeek 会把它映射到 `low` / `high` / `max` 三档 (`minimal`/`low`→low, `medium`/`high`/`xhigh`→high, `max`/`ultra`→max). -对应的开关:`--thinking on|off|auto`、`--effort LEVEL`、 +对应的开关: `--thinking on|off|auto`、`--effort LEVEL`、 `LIGENT_THINKING`、`LIGENT_REASONING_EFFORT`, 以及 REPL 里的 ``` @@ -578,7 +578,7 @@ LIGENT_CONFIG=/path/to/other.json ligent "..." ``` **思考过程不会显示, 也不会进上下文** (只累积、不回调). 但它必须跟着 assistant -消息一起回传:DeepSeek 要求**请求带 `tools` 时把历史里的 `reasoning_content` 全带上**, +消息一起回传: DeepSeek 要求**请求带 `tools` 时把历史里的 `reasoning_content` 全带上**, 漏了下一轮直接回 400. ligent 默认开着工具, 所以这一条是硬要求 —— 流式和非流式 两条解析路径都会保存它. @@ -593,9 +593,9 @@ ligent.json 的 api_key ← 你写死在配置里的 DEEPSEEK_API_KEY / OPENAI_API_KEY ← 兜底, 且只在端点确实是那一家时才读 ``` -最后一条是刻意的:**厂商专属的环境变量只在"确实在用这家"时才生效**. +最后一条是刻意的: **厂商专属的环境变量只在"确实在用这家"时才生效**. -否则会出这种事:配置文件里写的是 OpenRouter 的 key, 而 shell 里残留的 +否则会出这种事: 配置文件里写的是 OpenRouter 的 key, 而 shell 里残留的 `DEEPSEEK_API_KEY` 把它悄悄顶掉 —— 结果 A 家的密钥被发给了 B 家, 对方回一个含糊的 401, 你明明配对了却怎么都调不通, 排查方向还会被带偏. (OpenRouter 对格式不对的 key 回的是 `Missing Authentication header`, @@ -611,10 +611,10 @@ ligent **不会**去猜 `DEEPSEEK_API_KEY`, 而是明确告诉你"未配置 API ### 界面语言 界面文案 (帮助、错误、诊断、确认提示、等待动画)有中英两份. **发给模型的 -那些不在这里**:系统提示词、工具描述、工具回执只有中文 —— 那是模型读的信息, +那些不在这里**: 系统提示词、工具描述、工具回执只有中文 —— 那是模型读的信息, 跟界面语言是两件事 (见 §9). -优先级:`--lang` > `$LIGENT_LANG` > `LC_ALL` > `LC_MESSAGES` > `LANG`. +优先级: `--lang` > `$LIGENT_LANG` > `LC_ALL` > `LC_MESSAGES` > `LANG`. 都没设、或者认不出来 (`fr_FR` 这类), 按英文处理 —— `LANG=C` 也是英文, 这跟 POSIX 惯例一致. @@ -628,27 +628,27 @@ export LIGENT_LANG=en # 这个 shell 里都用英文 `tests/test_i18n.c` 都会逐条报出来 —— 漏翻这件事必须由机器发现, 不能靠人眼扫. `--print-config` 会打出当前生效的 `language`. -哪些**故意不译** (都不在这里, 也不是漏翻): +哪些**故意不译** (都不在这里, 也不是漏翻): -- **给模型看的**:系统提示词、工具描述、工具回执, 以及插进对话历史的说明 +- **给模型看的**: 系统提示词、工具描述、工具回执, 以及插进对话历史的说明 (`/include` 的文本块头、图片降级说明、`/shell` 的操作记录). 切成英文之后 模型读到的仍然是中文 —— 想换就得改提示词本身. -- **机器读的格式**:`/log` 导出里 `请求:` / `----- 响应头 -----` 这些标记 +- **机器读的格式**: `/log` 导出里 `请求:` / `----- 响应头 -----` 这些标记 (`tests/replay.py` 按它们切分往返)、`<已隐藏>` 这种脱敏占位符、 `cap_name()` 这类只给日志和断言用的状态常量. -`/tools` 列出来的那张表**不是**这一类:工具名后面那行是纯 UI, 文案在表里 +`/tools` 列出来的那张表**不是**这一类: 工具名后面那行是纯 UI, 文案在表里 (`tools.ui.*`), 跟着界面语言走; 发给模型的那份说明仍然只有中文. -`src/json.c` 是唯一"一份代码、两种语气"的地方, 用两个入口分开: +`src/json.c` 是唯一"一份代码、两种语气"的地方, 用两个入口分开: - `json_parse_ui()` —— 报错跟随界面语言. 配置、存档、服务端响应这些给人看的 路径用它, 用户看到的是 `line 1, column 3: expected an object key (a string)`. -- `json_parse()` —— 报错固定中文. 解析**工具参数**用它:那条结论会被原样回填给 +- `json_parse()` —— 报错固定中文. 解析**工具参数**用它: 那条结论会被原样回填给 模型, 属于"给模型看的信息", 不随界面语言变 (`agent.c` 只在这一处用). 两种语气共用一份文案表 (`json.err.*`), 只是各取一列. 这几条是 printf 模板而 -不是 `{name}`, 所以 `tests/test_i18n.c` 另有一遍机械比对:两种语言的转换符 +不是 `{name}`, 所以 `tests/test_i18n.c` 另有一遍机械比对: 两种语言的转换符 数量与类型必须逐字一致 —— `%zu` 写成 `%d` 会让 `printf` 读错参数. ## 3. 用法 @@ -672,14 +672,14 @@ ligent --print-config # 查看最终生效的配置 (key 已打码) -s, --system TEXT 覆盖 system 提示词 -t, --temperature N 采样温度 --max-tokens N 单次回复最大 token 数 - --thinking MODE 思考模式:on / off / auto (默认 auto = 由服务端决定) - --effort LEVEL 思考级别:minimal/low/medium/high/xhigh/max/ultra + --thinking MODE 思考模式: on / off / auto (默认 auto = 由服务端决定) + --effort LEVEL 思考级别: minimal/low/medium/high/xhigh/max/ultra --max-steps N 单轮任务里模型最多往返几次 (默认不限, 0 = 不限) --timeout N 请求超时秒数 --no-stream 关闭流式输出 --no-tools 禁用工具, 纯对话 --no-probe 首个请求前不探测模型能力 (不额外发请求) - --lang LANG 界面语言:auto / en / zh (默认 auto) + --lang LANG 界面语言: auto / en / zh (默认 auto) -q, --quiet 不打印工具的输出内容 -v, --verbose 打印 HTTP 细节 --color/--no-color 强制开关彩色输出 (同时控制 Markdown 渲染) @@ -690,7 +690,7 @@ ligent --print-config # 查看最终生效的配置 (key 已打码) ### 子命令 ``` -ligent doctor [--offline] 体检:配置、密钥、端点、网络逐环检查 +ligent doctor [--offline] 体检: 配置、密钥、端点、网络逐环检查 ``` `--offline` 跳过联网检查. @@ -701,7 +701,7 @@ ligent doctor [--offline] 体检:配置、密钥、端点、网络逐环 /help 帮助 /model [NAME] 切换模型; 不带名字则在线选 /exit, /quit 退出 /system TEXT 重设提示词并清空历史 /reset 清空对话历史 /cls 清屏 (不动对话历史) -/meta 元编辑模式:让模型帮你改这段历史 +/meta 元编辑模式: 让模型帮你改这段历史 /think [开关|级别] 思考模式与级别 /tools 列出可用工具 (已按能力隐藏的不列) /probe 现在测一次模型能力 (图片/工具); 无视 --no-probe 与缓存 /config 显示当前配置 /cd PATH 切换工作目录 @@ -713,7 +713,7 @@ ligent doctor [--offline] 体检:配置、密钥、端点、网络逐环 /back 删除最近一轮 (破坏性) /unback 撤销 /back ``` -`/reset` (别名 `/clear`)和 `/cls` 是两回事, 别敲混: +`/reset` (别名 `/clear`)和 `/cls` 是两回事, 别敲混: | | `/reset`、`/clear` | `/cls` | |---|---|---| @@ -721,18 +721,18 @@ ligent doctor [--offline] 体检:配置、密钥、端点、网络逐环 | 影响模型看到的上下文 | **是**, 历史没了 | 否, 一字不动 | | 可恢复 | `/unback` 不行, 只能 `/import` 旧存档 | 本来就没删东西 | -`/cls` 发的是 `\033[2J\033[3J\033[H`, 和 `clear(1)` 一致:可见区和回滚缓冲 +`/cls` 发的是 `\033[2J\033[3J\033[H`, 和 `clear(1)` 一致: 可见区和回滚缓冲 一起清. 所以清完之后往上滚也看不到旧内容了 —— 想找回来看 `/import` 那份存档. -### `/model`:不带参数就在线挑 +### `/model`: 不带参数就在线挑 -`/model NAME` 直接换. **不带参数**时会拉一次在线列表、打出来, 然后问你: +`/model NAME` 直接换. **不带参数**时会拉一次在线列表、打出来, 然后问你: ``` ligent> /model 正在获取模型列表 (https://api.deepseek.com/v1/models)... -服务端列出了 3 个模型: +服务端列出了 3 个模型: 1) deepseek-flash <- 当前 2) deepseek-chat @@ -742,18 +742,18 @@ ligent> /model 模型已切换为 deepseek-chat ``` -输入行里**既可以填序号, 也可以直接写模型名**. 后者是特意留的:列表里没有的 +输入行里**既可以填序号, 也可以直接写模型名**. 后者是特意留的: 列表里没有的 东西恰恰很常见 —— 内部模型、灰度模型、服务端没列出来的别名, 都得能填进去. -列表本身也是诚实的:顺序保持服务端给的 (不去替它排序), 重复项和缺 `id` 的 +列表本身也是诚实的: 顺序保持服务端给的 (不去替它排序), 重复项和缺 `id` 的 坏条目会被跳过. 序号越界会明确报出来 (`序号 9 超出范围 (1..3), 已取消. `), `Ctrl-C` 或直接回车取消. 拉列表走的是标准的 `GET {base_url}/models`. **拿不到就明说是什么情况**, -而不是笼统地报一句失败: +而不是笼统地报一句失败: ``` -拿不到模型列表:服务端返回了空的模型列表 (HTTP 200)—— 这个账号下可能没有可用的模型 +拿不到模型列表: 服务端返回了空的模型列表 (HTTP 200)—— 这个账号下可能没有可用的模型 可以直接用 /model <名字> 指定, 列表拿不到不影响这条. ``` @@ -763,16 +763,16 @@ ligent> /model > **为什么不做成方向键菜单. ** 试过一版, 撤了. 那种界面要靠 ANSI 反复重画, > 会把 stdout 搅乱 (重定向、日志、粘贴都跟着遭殃); 更要命的是它**只让你在 > 给定的选项里挑**, 恰好堵死了"填一个列表里没有的模型名"这条最需要的路. -> 所以这里是 `/import` 那一套:列表打在上面, 输入行是普通的一行, 随便写. +> 所以这里是 `/import` 那一套: 列表打在上面, 输入行是普通的一行, 随便写. -### `/include`:把文件附带给模型 +### `/include`: 把文件附带给模型 模型看不到你的文件, 除非你给它. `/include` 把文件**暂存**起来, 跟着下一条消息 -一起发出去 —— 因为附带文件的意义几乎总是配合一个提问: +一起发出去 —— 因为附带文件的意义几乎总是配合一个提问: ``` ligent> /include src/*.c -已附带 3 个文件: +已附带 3 个文件: src/agent.c src/json.c src/util.c @@ -781,13 +781,13 @@ ligent> 这三个文件里有没有重复的逻辑? ``` 不带参数则进**交互式挑选**, 可以逐层进目录 (这就是"递归"的走法 —— glob 本身 -是 shell 语义, `*` 不会跨目录): +是 shell 语义, `*` 不会跨目录): ``` ligent> /include 进入交互式选择. 序号进目录 / 选中文件, 也可以直接敲 glob 或路径, 回车结束. -当前目录:/home/you/proj +当前目录: /home/you/proj 1) src/ (目录) 2) README.md 12.3 KB @@ -797,21 +797,21 @@ ligent> /include 已选 0 个. 选择 > 1 ``` -同样是 `/import` 那种普通输入行, 不是方向键菜单:**序号、`..`、glob、路径都能 +同样是 `/import` 那种普通输入行, 不是方向键菜单: **序号、`..`、glob、路径都能 敲**, 空格分隔不支持的 (一次一个模式). `/include clear` 清空暂存, `/reset` 也会清. -几件事是有意为之的: +几件事是有意为之的: - **认内容, 不认扩展名. ** 叫 `.png` 的文本文件当文本带; 反过来, 内容真是图片 的才走图片那条路. - **图片不进历史. ** 图片存成本机备份, 历史里只放一个几十字节的引用, 发请求时 才展开成 base64 —— 和 `read_image` 完全同一条路. - **图片要先确认模型能看图. ** 遇到图片时才会去跑能力探测 (纯文本的附带不该为 - 了"可能出现的图"先花掉两个探测请求), 明确不支持就跳过并说明: + 了"可能出现的图"先花掉两个探测请求), 明确不支持就跳过并说明: ``` - 跳过 src/logo.png:当前模型不支持图片输入 + 跳过 src/logo.png: 当前模型不支持图片输入 没有附带任何文件 (1 个被跳过). ``` @@ -824,10 +824,10 @@ ligent> /include 输入行尾加 `\` 可以续行写多行任务. `Ctrl-C` 中断当前请求 (不会退出程序), `Ctrl-D` 退出. -### 输入历史:像 bash 一样跨进程 +### 输入历史: 像 bash 一样跨进程 你在 `ligent>` 敲过的东西会**实时**写进一个历史文件, 新的 ligent 实例启动时读回来, -上下方向键就能翻, 跟 bash 一样: +上下方向键就能翻, 跟 bash 一样: ``` ligent> ↑ → 上一条 @@ -835,18 +835,18 @@ ligent> ↑ ↑ → 再上一条 ligent> ↓ → 回来一条 ``` -- **落盘位置**:`<二进制目录>/ligent.history` (和 `ligent.json`、`ligent.archive/` - 同一个原则:只放二进制旁边). -- **实时**:每提交一条就 append 并 flush, 所以另一个终端里的 ligent (或者另一个 +- **落盘位置**: `<二进制目录>/ligent.history` (和 `ligent.json`、`ligent.archive/` + 同一个原则: 只放二进制旁边). +- **实时**: 每提交一条就 append 并 flush, 所以另一个终端里的 ligent (或者另一个 标签页)马上就能翻到, 不用等谁退出. -- **权限 0600**:历史里可能顺手敲过密钥, 不给同机其他用户读的机会. -- **一条输入 = 一条历史**:`\` 续行写出来的多行任务算**一条**, 上下键整条召回. -- **上限 1000 条**:启动时只读最新的 1000 条, 文件更长就就地截短, 不会无限增长. -- **连续重复不记**:和 bash 的 `ignoredups` 一个意思. -- **只记 REPL 里敲的**:单次任务模式 (`ligent "任务"`)的参数不算 —— 那不是 +- **权限 0600**: 历史里可能顺手敲过密钥, 不给同机其他用户读的机会. +- **一条输入 = 一条历史**: `\` 续行写出来的多行任务算**一条**, 上下键整条召回. +- **上限 1000 条**: 启动时只读最新的 1000 条, 文件更长就就地截短, 不会无限增长. +- **连续重复不记**: 和 bash 的 `ignoredups` 一个意思. +- **只记 REPL 里敲的**: 单次任务模式 (`ligent "任务"`)的参数不算 —— 那不是 "在提示符前敲的". `/shell` 里执行的命令也不算, 那是另一个 shell 的历史. -开关和覆盖: +开关和覆盖: ```bash ligent --no-history # 本次不读也不写 @@ -857,14 +857,14 @@ export LIGENT_HISTORY=/tmp/my.history # 同上, 环境变量 文件是一条历史一行. 条目里的反斜杠和换行会被转义 (`\` → `\\`, 换行 → `\n`), 保证一条占一行且能原样读回; 不含反斜杠的普通命令在文件里与输入完全一致. -### `/shell`:在子进程里干活 +### `/shell`: 在子进程里干活 ``` ligent> /shell 进入交互式 shell, 退出后整段会话记入上下文 ligent> /shell git log --oneline -5 带参数则直接执行, 不进入 shell ``` -交互式那条路用**伪终端**做双向搬运:子进程以为自己接在真终端上, 所以行编辑、 +交互式那条路用**伪终端**做双向搬运: 子进程以为自己接在真终端上, 所以行编辑、 颜色、Tab 补全、Ctrl-C、作业控制都照常工作; 我们在外面旁听并把输出记下来. ``` @@ -879,12 +879,12 @@ ligent> /shell 录制进上下文的内容已经清理过 —— 去掉 ANSI 控制序列、把 CRLF 归一成 LF、 丢掉退格, 否则模型看到的是一堆 `^[[32m`. 长度上限 100KB. -清理是**跨读取块**做的:转义序列被 `read()` 从中间切断也能接上, 不会串味. +清理是**跨读取块**做的: 转义序列被 `read()` 从中间切断也能接上, 不会串味. **全屏程序的内容完全不记进上下文. ** `nano`、`vi`、`vim`、`nvim`、`less`、`top` 这类程序会切到终端的"备用屏幕", 这段画面照常显示在你眼前, 但记录里只剩你敲的 命令行、下一行提示符, 以及退出编辑器之后真实的输出 —— 和人眼在终端里看到的 -一模一样. 中间留一对标记: +一模一样. 中间留一对标记: ``` [全屏程序运行中, 画面内容已省略] @@ -907,7 +907,7 @@ ANSI 兼容的, `TERM=dumb` 属于谎报. 补上之后, 即使是 `TERM=dumb`, ` **记入上下文但不会自动提问**, 你想问什么再问. 这样你在 shell 里翻了一圈之后, 可以直接说"刚才那个报错怎么修". -选 shell 的顺序是 `bash → ash → dash → sh`. 刻意**不看 `$SHELL`**: +选 shell 的顺序是 `bash → ash → dash → sh`. 刻意**不看 `$SHELL`**: 用户环境里的 `$SHELL` 可能是 xonsh 之类交互式外壳, 在容器或受限环境里 经常起不来, 反而把 `/shell` 弄坏. @@ -934,7 +934,7 @@ ligent> /back 删除最近一轮 (从最后一条 user 消息到末尾, 整 ligent> /unback 撤销, 把删掉的装回去 ``` -是**按轮**删的:模型调用的工具与结果必须整组一起走, 只删中间几条会让服务端 400. +是**按轮**删的: 模型调用的工具与结果必须整组一起走, 只删中间几条会让服务端 400. `/unback` 只在 `/back` 之后没有新对话时可用 —— 一旦你又问了什么, 就没有 "撤销"这一说了, 会明确拒绝而不是悄悄恢复到一半状态. @@ -948,7 +948,7 @@ ligent> /yolo 临时切换写操作确认 (只影响本次会话, 不进配 ### 写操作确认 (默认开启) 任何**会改动磁盘或执行命令**的工具 (`run_command` / `write_file` / `edit_file`) -在执行前都会先说明要做什么, 然后问一句: +在执行前都会先说明要做什么, 然后问一句: ``` 即将执行 run_command @@ -956,7 +956,7 @@ ligent> /yolo 临时切换写操作确认 (只影响本次会话, 不进配 继续?(Y/N/Always/neVer) ``` -四种回答: +四种回答: | 回答 | 含义 | |---|---| @@ -967,16 +967,16 @@ ligent> /yolo 临时切换写操作确认 (只影响本次会话, 不进配 `Always` / `neVer` 的作用域是**这一轮** —— 一条用户消息引发的那串工具调用. 下一条消息进来时自动复位, 所以它不会像 `/yolo` 那样"一直生效到你关掉", -也不会写进配置. 选了哪个都会当场提示你: +也不会写进配置. 选了哪个都会当场提示你: ``` 继续?(Y/N/Always/neVer) A 本轮后续的改动都不再询问. ``` -拒绝不等于失败:上层会把"用户拒绝了"作为工具结果回填给模型, 让它换个做法, +拒绝不等于失败: 上层会把"用户拒绝了"作为工具结果回填给模型, 让它换个做法, 而不是让整轮对话就此中断. 选了 `neVer` 的话, 回填的话会更硬 ("不要再尝试改动, -直接说明你的结论"), 免得模型一个接一个地试. +直接说明你的结论"), 以免模型一个接一个地试. 只读工具 (`read_file` / `list_dir`)不打扰. @@ -984,25 +984,25 @@ ligent> /yolo 临时切换写操作确认 (只影响本次会话, 不进配 > 此时直接按 Y 处理、不显示提示. 所以 `echo 任务 | ligent` 里的确认实际上是 > 不生效的 —— 那条路径要靠 `--no-confirm` 的对面, 也就是别指望它兜底. -关掉:`--no-confirm` / `LIGENT_CONFIRM=0` / 配置项 `confirm: false`. +关掉: `--no-confirm` / `LIGENT_CONFIRM=0` / 配置项 `confirm: false`. ### 自动存档 (默认开启) -在三个时间点自动把当前对话存进 `ligent.archive/`: +在三个时间点自动把当前对话存进 `ligent.archive/`: - **退出**时 (`/exit`、`Ctrl-D`、单次任务结束) -- **`/reset` 前** —— 免得清空历史时把刚才那段丢掉 -- **`/import` 前** —— 免得载入别的存档时覆盖掉当前的 +- **`/reset` 前** —— 以免清空历史时把刚才那段丢掉 +- **`/import` 前** —— 以免载入别的存档时覆盖掉当前的 只在"有内容"且"自上次存档后又变了"时才写, 所以不会产生一堆重复文件; 只有 system 一条的空对话不会留下存档. 关掉用 `--no-auto-save`. -### `/meta`:让模型帮你改对话历史 +### `/meta`: 让模型帮你改对话历史 `/cls` 清屏、`/reset` 清空、`/back` 退一轮 —— 这些都是粗粒度的. 真正难的是 "把第 3 轮里我那句'用 Python'删掉"、"第 5 轮助手那个结论是错的, 改掉". -`/meta` 就是干这个的:进入 `meta>` 子提示符, 那边是**另一个 AI**, 它的工作 +`/meta` 就是干这个的: 进入 `meta>` 子提示符, 那边是**另一个 AI**, 它的工作 对象不是继续对话, 而是**编辑这段历史**. ``` @@ -1015,16 +1015,16 @@ meta> 第 3 轮里我说的"用 Python"删掉 {/} read_messages {"from":5,"to":7} {/} replace_message {"index":5,"content":"帮我写个脚本"} 即将执行 replace_message - #5 的正文改成:帮我写个脚本 + #5 的正文改成: 帮我写个脚本 继续?(Y/N/Always/neVer) -[meta] 已改写 #5 (user):28 -> 15 字节. +[meta] 已改写 #5 (user): 28 -> 15 字节. meta> /exit -退出 meta 模式. 本次共改动 1 次, meta 自身消耗:3 次调用, 1240 输入 / 86 输出 token. -这段对话已被编辑过:下次 /export 会另起一根, 名字形如 <原名>_m<3位>. +退出 meta 模式. 本次共改动 1 次, meta 自身消耗: 3 次调用, 1240 输入 / 86 输出 token. +这段对话已被编辑过: 下次 /export 会另起一根, 名字形如 <原名>_m<3位>. ``` -meta 里的命令只有四个:`/exit` `/diff` (看当前历史, 只读)`/undo` `/help`. +meta 里的命令只有四个: `/exit` `/diff` (看当前历史, 只读)`/undo` `/help`. 其余输入都是给 meta 模型的指令. #### 和主模型完全隔离 @@ -1035,7 +1035,7 @@ meta 会话有自己的 system 提示词、自己的工具表, 分发走 `Agent` | 工具 | 作用 | 参数里带整段正文 | |---|---|---| -| `list_rounds [from,to]` | 轮目录:轮号、消息编号范围、条数、字节数、预览 | 否 | +| `list_rounds [from,to]` | 轮目录: 轮号、消息编号范围、条数、字节数、预览 | 否 | | `read_messages from,to` | 读指定消息的全文 (含工具调用参数) | 否 | | `replace_message index,content` | 改写某条消息的正文 | 是 | | `delete_messages indices` | 删若干条 (只限可安全单删的) | 否 | @@ -1045,13 +1045,13 @@ meta 会话有自己的 system 提示词、自己的工具表, 分发走 `Agent` #### 结构安全是硬约束 -对话历史不是一袋独立消息:`assistant` 带 `tool_calls` 时, 后面必须紧跟 -`tool_call_id` 一一对应的 `tool` 消息, 否则下一轮请求直接 400. 所以: +对话历史不是一袋独立消息: `assistant` 带 `tool_calls` 时, 后面必须紧跟 +`tool_call_id` 一一对应的 `tool` 消息, 否则下一轮请求直接 400. 所以: - `delete_messages` 只接受 **user 和不带 tool_calls 的 assistant**; 碰结构消息 一律拒绝, 并让模型改用 `delete_rounds` - 一批删除**先整体校验再动手** —— 宁可一条不改, 也不要改一半 -- 动作粒度以"轮"为单位:user → 中间所有工具往返 → 最终回答 +- 动作粒度以"轮"为单位: user → 中间所有工具往返 → 最终回答 - 删掉某轮的提问时, 工具会提醒"那轮剩下的回答现在没有对应的提问了" #### 四道安全网 @@ -1063,7 +1063,7 @@ meta 会话有自己的 system 提示词、自己的工具表, 分发走 `Agent` #### 改过之后另起一根 -只要成功改过, 下次 `/export` (或退出时的自动存档)就是**新根**: +只要成功改过, 下次 `/export` (或退出时的自动存档)就是**新根**: 名字 `<原名>_m<3位>`, `parent` 记空. ``` @@ -1079,37 +1079,37 @@ meta 自身的 token 消耗单独汇报, 不混进主对话的 `/usage`. #### 它不是压缩工具 -meta 模型的提示词里写着:**用户没要求压缩就不要压缩 —— 压缩会丢证据**. -它的价值在精修:删掉说错的前提、去掉被推翻的方案、改掉助手那次自信但错误的 +meta 模型的提示词里写着: **用户没要求压缩就不要压缩 —— 压缩会丢证据**. +它的价值在精修: 删掉说错的前提、去掉被推翻的方案、改掉助手那次自信但错误的 结论, 让这段历史不再诱导助手重蹈覆辙. > ⚠️ 让 AI 改自己的历史, 等于允许它改写"发生过什么". 排查问题时如果关键证据 > 被"压掉"就麻烦了 —— 所以默认不压缩、每步要确认、改动可撤销、原版有存档. -### 等待时的动画:两段标签 +### 等待时的动画: 两段标签 模型不输出思考过程是有意的, 但等待期间屏幕完全静止, 和"卡死了"看不出区别. -所以等待时会在 **stderr** 上转一个灰色的动画, 并且**按等待的性质分两段**: +所以等待时会在 **stderr** 上转一个灰色的动画, 并且**按等待的性质分两段**: ``` 等待响应.... 请求发出去了, 但一个字节都还没回来 (网络、排队、服务端没醒) 思考中...... 思维链开始流回来了, 模型确实在推理 ``` -这个区分是有用的:同样是等了 30 秒, "一直卡在 `等待响应`"和"一直在 `思考中`" +这个区分是有用的: 同样是等了 30 秒, "一直卡在 `等待响应`"和"一直在 `思考中`" 是两种完全不同的处境 —— 前者多半该去查网络或端点, 后者只是模型想得久. -切换的时机是**收到第一片 `reasoning_content`**, 不是在发出请求时猜的. 所以: +切换的时机是**收到第一片 `reasoning_content`**, 不是在发出请求时猜的. 所以: - 关掉思考 (`thinking: off`)或者用不支持思考的模型 → 只会看到 `等待响应`. - 非流式 (`--no-stream`)拿不到任何增量 → 也只会看到 `等待响应`. - 一收到**正文**动画立刻停并擦掉 —— 那时已经有东西可看了. -计时是按阶段算的:标签一变就从 0 重新计. 超过 5 秒会把秒数亮出来 +计时是按阶段算的: 标签一变就从 0 重新计. 超过 5 秒会把秒数亮出来 (`等待响应.... (12s)`), 进一步和卡顿区分. 只在 stderr 是终端时才转, 所以重定向或接管道时不会污染输出. -### 对话存档:文件名就是谱系 +### 对话存档: 文件名就是谱系 ``` ligent> /export 把当前对话存下来 @@ -1117,10 +1117,10 @@ ligent> /import 列出全部存档, 选一份载入 ligent> /import a1b2c3d4_a9x 按名字直接载入 (支持唯一前缀) ``` -存档放在**二进制所在目录**下的 `ligent.archive/` —— 和配置同一个原则: +存档放在**二进制所在目录**下的 `ligent.archive/` —— 和配置同一个原则: 整个程序连同它的数据都待在一个目录里, 拷走即走. -**命名不做时间戳, 而是把分支关系写进文件名**: +**命名不做时间戳, 而是把分支关系写进文件名**: ``` a1b2c3d4 从零开始的一段对话 @@ -1132,7 +1132,7 @@ a1b2c3d4_q4k 从 a1b2c3d4 分出的另一支 (兄弟) 新会话第一次存档时随机取 8 位小写字母数字作为根; 之后每存一次, 就在**当前所在的节点**后面接 `_` 加 3 位随机字符. -`/import` 之前那次保底存档会统一加上 `tmp_` 前缀: +`/import` 之前那次保底存档会统一加上 `tmp_` 前缀: ``` tmp_mimgkjex 换掉当前对话之前先留的一手 @@ -1154,12 +1154,12 @@ ligent.archive/ mvd2bl1h_q4k 另一支 ``` -**长度上限**:完整文件名 (含 `.json`)不超过 240 字符. 接一级需要 4 个字符, +**长度上限**: 完整文件名 (含 `.json`)不超过 240 字符. 接一级需要 4 个字符, 所以大约能到 58 层. 再往下接就**丢掉谱系、重新起一个 8 位根** (名字撑爆文件系统总不是个办法). 存档内容仍是自描述的 JSON, 并把 `name` 与 `parent` 也写进去 —— -这样即使文件被改名、或者谱系因超长而重置, 也能从内容里看出它从哪来: +这样即使文件被改名、或者谱系因超长而重置, 也能从内容里看出它从哪来: ```json { @@ -1173,14 +1173,14 @@ ligent.archive/ } ``` -`/import` 载入后会问一句要不要把这段历史打出来: +`/import` 载入后会问一句要不要把这段历史打出来: ``` 已载入 mvd2bl1h_cpc (7 条消息). 之后 /export 会存成 mvd2bl1h_cpc_xxx 是否输出这段历史的内容?(Y/n) ``` -回车 (默认 Y)就**完整**逐条打出来, 带角色标签和颜色: +回车 (默认 Y)就**完整**逐条打出来, 带角色标签和颜色: ``` -- 1/7 系统 -- @@ -1200,7 +1200,7 @@ ligent.archive/ [exit code: 0] ``` -"完整"是字面意思:**正文不截断、工具参数不省略、总量不封顶、`system` +"完整"是字面意思: **正文不截断、工具参数不省略、总量不封顶、`system` 提示词也全文展开**. 都回答了 Y, 就该看到全部 —— 截一半反而会让人以为历史里 本来就这么点东西. `tool_call_id` 是特意标出来的, 否则一堆工具输出看不出 谁在回应谁. @@ -1208,7 +1208,7 @@ ligent.archive/ 回 `n` 就只装进上下文、屏幕保持干净. 之后想随时再看一遍, 敲 `/history` —— 它打的就是同一份东西 (当前这段对话, 一条不落). -两点行为约定:存档里的 `system` 消息不会被带进来 (否则载入旧存档会把你现在 +两点行为约定: 存档里的 `system` 消息不会被带进来 (否则载入旧存档会把你现在 的提示词换掉), 当前生效的那条保留; 载入后对话开始时间重置. 空对话不会产生 存档. `/reset` 会断开谱系, 下一次存档另起一个根. @@ -1217,7 +1217,7 @@ ligent.archive/ ### 先跑一次体检 -配置调不通时, **先别猜**: +配置调不通时, **先别猜**: ```bash ligent doctor @@ -1238,20 +1238,20 @@ ligent doctor 连通性 [v] 可连接, TLS 校验通过 HTTP 状态 307 重定向 [x] 端点跳转到 https://example.invalid/v1/chat/completions - ligent 不自动跟随重定向:跨源跳转会丢掉 Authorization 头, + ligent 不自动跟随重定向: 跨源跳转会丢掉 Authorization 头, 服务端只会回 401 "Missing Authentication header", 看起来像是密钥没带, 实际是 URL 跳了. 把 base_url 直接改成上面那个地址即可. ``` -它专门盯着几类"报错信息会把人带偏"的问题:配置被残留环境变量顶掉、 +它专门盯着几类"报错信息会把人带偏"的问题: 配置被残留环境变量顶掉、 密钥与服务商不匹配、端点重定向导致伪 401、配置文件语法错误. -退出码:有问题为 1, 否则 0. +退出码: 有问题为 1, 否则 0. > doctor 在**配置本身加载失败时也能跑** —— 那正是最需要它的时刻. ### 行编辑 -REPL 自带行编辑器 (`src/lineedit.c`), 不依赖 readline, 也不用终端的行规程: +REPL 自带行编辑器 (`src/lineedit.c`), 不依赖 readline, 也不用终端的行规程: | 按键 | 作用 | |---|---| @@ -1264,18 +1264,18 @@ REPL 自带行编辑器 (`src/lineedit.c`), 不依赖 readline, 也不用终端 | `Ctrl-D` | 空行时退出; 行非空时删一个字符 | | **粘贴多行** | 整段插入, **不会被逐行执行** | -粘贴用 bracketed paste (`\033[?2004h`)实现:终端会把粘贴内容和真实按键区分开, +粘贴用 bracketed paste (`\033[?2004h`)实现: 终端会把粘贴内容和真实按键区分开, 收到粘贴块就整段放进缓冲区, 里面的换行不再触发提交. 所以可以直接把一段 多行需求、一个代码片段、一段日志粘进来, 编辑好再回车, 它会作为**一条**消息发出. `Ctrl+Shift+V`、中键、菜单里的粘贴走的都是这条路. **粘贴进来的内容会被清洗一遍. ** 因为 `read_paste()` 收的是终端给的原始字节、 -不经按键分类, 里面夹着控制字符就会让"编辑器的算盘"和"终端实际显示"对不上: +不经按键分类, 里面夹着控制字符就会让"编辑器的算盘"和"终端实际显示"对不上: | 字符 | 不清洗会怎样 | 清洗后 | |---|---|---| | `\t` | 终端按制表位 (8 的倍数)展开, 编辑器只当 1 列 —— 粘一段带缩进的代码, **光标从此偏掉, 之后每敲一个字都在错位置上** | 就地展开成空格, 缓冲区里不留制表符 | -| `ESC` | 被终端当成转义序列**真的执行掉**:粘贴内容能改颜色、甚至清屏 | 连同后面整条序列一起丢弃 | +| `ESC` | 被终端当成转义序列**真的执行掉**: 粘贴内容能改颜色、甚至清屏 | 连同后面整条序列一起丢弃 | | `\r` | 把终端光标拉回行首, 屏幕当场错位 | 丢弃 (CRLF 归一成 LF) | | `NUL` | 会把 C 字符串截断 | 丢弃 | @@ -1290,7 +1290,7 @@ REPL 自带行编辑器 (`src/lineedit.c`), 不依赖 readline, 也不用终端 CJK 规则 (中文按 2 列). 行尾 `\` 续行仍然可用, 续行的每一段都能独立编辑. **你敲的那一行整个是绿色的** (提示符 + 输入的文字), 翻历史或滚屏时一眼能 -看出哪些是自己的输入、哪些是模型输出: +看出哪些是自己的输入、哪些是模型输出: ``` ligent> 你好 ← 绿色, 整行都是输入 @@ -1307,13 +1307,13 @@ ligent> 你好 ← 绿色, 整行都是输入 ## 4. 终端渲染 -助手回复会做一层**流式 Markdown 渲染**:模型吐出的 `#`、`**粗体**` 等标记 +助手回复会做一层**流式 Markdown 渲染**: 模型吐出的 `#`、`**粗体**` 等标记 在终端里直接变成样式, 而不是原样显示. 工具输出 (文件内容、命令结果)**不做** 任何渲染, 保持原样, 避免破坏代码. ### 只用 16 色 -全部配色都落在最窄的 **16 色 ANSI** 里:前景用 30-37, 加粗/暗淡/斜体/下划线 +全部配色都落在最窄的 **16 色 ANSI** 里: 前景用 30-37, 加粗/暗淡/斜体/下划线 用 SGR 属性 1/2/3/4. 不用 90-97 那一段 (aixterm 的"亮色"), 也不用 256 色或 真彩. @@ -1324,10 +1324,10 @@ ligent> 你好 ← 绿色, 整行都是输入 这条约束由 `tools/check-colors.sh` 守着, `make test-unit` 里会跑. **它值得单独 一个脚本, 是因为颜色越界看代码看不出来** —— 一个越界的码在人眼里也只是"一个 -蓝色", 只有拿尺子量才知道它在哪一段. (这个检查抓到过一处:链接原本用的 +蓝色", 只有拿尺子量才知道它在哪一段. (这个检查抓到过一处: 链接原本用的 `\033[94m` 就是越界的亮蓝. ) -配色语义在各模块之间是一致的: +配色语义在各模块之间是一致的: | 用途 | 码 | 出现在 | |---|---|---| @@ -1338,7 +1338,7 @@ ligent> 你好 ← 绿色, 整行都是输入 | 强调 (工具名、标题) | 1 粗体 | 各模块 | | 链接 | 4;36 下划线+青 | markdown | -链接用**下划线**而不是只靠颜色:下划线是 SGR 属性, 在单色终端、色盲用户、 +链接用**下划线**而不是只靠颜色: 下划线是 SGR 属性, 在单色终端、色盲用户、 以及把颜色重映射过的主题里都还在 —— 颜色会丢, 属性不会. | 语法 | 效果 | @@ -1353,7 +1353,7 @@ ligent> 你好 ← 绿色, 整行都是输入 | `\| a \| b \|` 表格 | 带边框对齐的表格, 表头加粗, 支持 `:---` / `:---:` / `---:` 对齐 | | `[链接文字](url)` | `[链接文字]` 加下划线并着青色, `(url)` 原样保留 | -**完全不做渲染、逐字节原样保留**的四种情况: +**完全不做渲染、逐字节原样保留**的四种情况: | 语法 | 处理 | |---|---| @@ -1362,7 +1362,7 @@ ligent> 你好 ← 绿色, 整行都是输入 | 链接的 `(url)` 部分 | 保留原文 (不隐藏、不做超链接跳转) | | 行内代码的内容 | 不做任何解析 | -表格渲染示例 (列宽按**显示宽度**计算, 中文按 2 列, 所以边框能对齐): +表格渲染示例 (列宽按**显示宽度**计算, 中文按 2 列, 所以边框能对齐): ``` ┌──────┬──────┬──────┐ @@ -1375,7 +1375,7 @@ ligent> 你好 ← 绿色, 整行都是输入 ### 两个刻意的取舍 -**`_斜体_` 会渲染, 但下划线标识符不会被误伤. ** 按 CommonMark 规则禁用"词内强调": +**`_斜体_` 会渲染, 但下划线标识符不会被误伤. ** 按 CommonMark 规则禁用"词内强调": `foo_bar_baz`、`a_b_c` 原样保留; 与另一个下划线相邻时也一律当字面量, 所以 Python 的 `__init__`、`__main__` 不会被拆成斜体. 中文不算词字符, 因此 `中文_斜体_` 能正常生效. @@ -1386,17 +1386,17 @@ ligent> 你好 ← 绿色, 整行都是输入 **表格必须顶格写 `|`. ** 不带首竖线的写法 (`a | b` / `--- | ---`)按普通文本处理. -这条限制不是偷懒, 而是为了保证**输出与分片方式无关**:判断"这是不是表格"所需的信息 +这条限制不是偷懒, 而是为了保证**输出与分片方式无关**: 判断"这是不是表格"所需的信息 必须在行的第 0 个字节就能拿到. 如果允许"整行任意位置含竖线", 那么当 SSE 的分片边界 恰好落在竖线之前时, 行的前半段已经被当成普通文本输出、无法收回; 而同一行若整块到达 则会被识别为表格——**同样的内容, 渲染结果取决于网络怎么切的包**. 这个问题是模糊测试 -找出来的 (最小复现只有 4 字节:`s|\n-`). +找出来的 (最小复现只有 4 字节: `s|\n-`). ### 流式是这里的难点 SSE 的分片位置是随机的, `**bold**` 完全可能被切成 `**bo` / `ld**` 两片, 甚至两个 `*` 分别落在两片里. 所以渲染器是一个**带状态、可挂起**的状态机 - (`src/markdown.c`): + (`src/markdown.c`): - 遇到可能是标记前缀的尾部 (结尾的 `*`、行首的 `#`/`>`/`-`、`` ` ``、`[`), 它**不立即输出**, 而是把这段字节留在缓冲区里等下一个分片, 凑齐后再判定. @@ -1406,30 +1406,30 @@ SSE 的分片位置是随机的, `**bold**` 完全可能被切成 `**bo` / `ld** 防止异常输入把内存吃满或让输出长时间卡住. - 流式与非流式走同一个渲染器, 因此两种模式的输出**逐字节一致**. -`tests/test_markdown.c` 用一条不变量来锁住这个行为:把同一段文本分别按 +`tests/test_markdown.c` 用一条不变量来锁住这个行为: 把同一段文本分别按 **一次性 / 3 字节 / 逐字节** 三种方式喂进去, 输出必须完全相同. ### 想看效果? -仓库里带了一个独立的小演示程序, 不需要 API key: +仓库里带了一个独立的小演示程序, 不需要 API key: ```bash make demo # 编译并运行, 用内置示例展示所有语法 -./build/md_demo --plain # 对照:输出原始 Markdown -./build/md_demo -s # 流式模式:每 5 字节一片、间隔 12ms -./build/md_demo --compare # 自检:不同分片方式输出是否一致 +./build/md_demo --plain # 对照: 输出原始 Markdown +./build/md_demo -s # 流式模式: 每 5 字节一片、间隔 12ms +./build/md_demo --compare # 自检: 不同分片方式输出是否一致 ./build/md_demo README.md # 渲染任意 Markdown 文件 echo '# hi **there**' | ./build/md_demo ``` -`-s` 看起来和普通输出几乎一样, 这正是重点:分片边界落在 `**`、行首 `#`、 +`-s` 看起来和普通输出几乎一样, 这正是重点: 分片边界落在 `**`、行首 `#`、 表格中间都察觉不到——因为状态机会把可能被切断的标记暂存到下一片再判定. `--compare` 会把同一段文本按 1/2/3/5/7/13/64 字节以及**随机**分片各渲染一遍, 逐字节比对结果, 这也是 `make test` 会跑的一项. ### 什么时候不渲染 -只有输出到终端时才渲染. 以下情况会退化为**逐字节原样输出**: +只有输出到终端时才渲染. 以下情况会退化为**逐字节原样输出**: - 输出被重定向或接管道 (`ligent "..." > out.md`) - 显式 `--no-color` @@ -1445,36 +1445,36 @@ echo '# hi **there**' | ./build/md_demo | `run_command` | 执行 shell 命令, 合并捕获 stdout/stderr 与退出码, 超时后杀掉整个进程组 | `command`, `timeout` | | `read_file` | 按行读取文本文件, 带行号 (可直接喂给 `edit_file` 的 `line` 集合), 大文件分段读; **二进制文件拒绝** | `path`, `offset`, `limit` | | `write_file` | 创建/整体覆盖**文本**文件, 自动创建父目录; 目标是二进制文件时拒绝覆盖 | `path`, `content` | -| `edit_file` | 按**行号集合**做精确字符串替换:`line` 是集合型 (整数或整数数组), 替换只发生在集合圈住的行里且只要**行内唯一**; **省略 `line` = 全文件替换所有匹配** | `path`, `line`, `old_string`, `new_string`, `replace_all` | +| `edit_file` | 按**行号集合**做精确字符串替换: `line` 是集合型 (整数或整数数组), 替换只发生在集合圈住的行里且只要**行内唯一**; **省略 `line` = 全文件替换所有匹配** | `path`, `line`, `old_string`, `new_string`, `replace_all` | | `list_dir` | 列出目录, 标注类型与大小 | `path` | -| `read_image` | 读入本地图片交给模型看:本机留备份, 请求时以 base64 内联发出 | `path` | +| `read_image` | 读入本地图片交给模型看: 本机留备份, 请求时以 base64 内联发出 | `path` | 单次工具输出上限 100 KB, 超出会截断并提示, 避免一次 `cat` 把上下文撑爆. -### `edit_file`:行号集合钉住的替换 +### `edit_file`: 行号集合钉住的替换 -`line` 是**集合型**参数, 而且**可以整个省略**:省略 = 不设行范围 = **全文件替换 +`line` 是**集合型**参数, 而且**可以整个省略**: 省略 = 不设行范围 = **全文件替换 所有匹配** (就是编辑器里那个"全局替换"). 给了它就是集合型 —— 单个整数 (`"line": 41`)或整数数组 (`"line": [41, 42]`)都收, 行号就是 `read_file` 打出来 的那些 (从 1 开始). 给了行号之后, 替换只发生在这些行里, `old_string` 也只需 **在这些行里**唯一. -判定命中看的是 `old_string` 的**起点**:起点落在集合里的任意一行上就算数. +判定命中看的是 `old_string` 的**起点**: 起点落在集合里的任意一行上就算数. 所以 `old_string` 跨好几行时, 照 `read_file` 的显示把覆盖到的那几行都填进数组就行, -不必先赌起点到底是第 41 行还是第 42 行. 集合是**搜索范围**, 不是"每行都改一遍": +不必先赌起点到底是第 41 行还是第 42 行. 集合是**搜索范围**, 不是"每行都改一遍": 真改动的仍然只有唯一那一处 (`replace_all=true` 或省略 `line` 时才动全文件). -**省略 `line`** 时没有范围约束:整个文件都是搜索范围, 而且**命中几处就换几处** +**省略 `line`** 时没有范围约束: 整个文件都是搜索范围, 而且**命中几处就换几处** (不再要求唯一), 回执里会写明"全文件替换 N 处". 这是故意留着的逃生门 —— 有些 -改动本来就该全文件生效 (比如统一改个名字); 但它不是默认路径:不给行号就没法 +改动本来就该全文件生效 (比如统一改个名字); 但它不是默认路径: 不给行号就没法 声称"我知道改的是哪一处", 要改一处仍得给行号. -为什么不再只靠"全文唯一":行号是模型唯一的空间坐标, 而"全文唯一"要求整个文件里 +为什么不再只靠"全文唯一": 行号是模型唯一的空间坐标, 而"全文唯一"要求整个文件里 恰好只有一处 —— 文件一长这个条件经常不成立 (同一个 `return 0;` 到处都是), 模型 只能不断加长 `old_string` 去凑唯一性; 凑不成要么放弃, 要么赌一把改错地方. 把行号 钉住之后, "改哪一处"由坐标决定, 不再是个概率问题, `old_string` 也不必为了唯一而写长. -负面路径都给出**可照做的下一步**, 而不是干说一句"不行": +负面路径都给出**可照做的下一步**, 而不是干说一句"不行": | 情况 | 反馈 | |---|---| @@ -1488,56 +1488,56 @@ echo '# hi **there**' | ./build/md_demo | 省略了 `line`, 而文件里没有 `old_string` | 报"整个文件里没有", 并提示要给行范围就填 `line` | | 文件是二进制 | 拒绝并说明理由 (见下节) | -集合里的重复行号会被**去重** (`[1,1]` 等价于 `[1]`):不然同一处匹配会被数成两处, +集合里的重复行号会被**去重** (`[1,1]` 等价于 `[1]`): 不然同一处匹配会被数成两处, "行内只有一处"的判断就会莫名其妙地失败. -`replace_all=true` 的含义没变:替换文件里**所有**匹配; 但 `line` 集合仍必须圈住其中 +`replace_all=true` 的含义没变: 替换文件里**所有**匹配; 但 `line` 集合仍必须圈住其中 真实的一处 —— 等于强制模型先看过文件, 而不是凭印象做全文替换. ### 文本工具只处理文本 `read_file` / `write_file` / `edit_file` 遇到二进制文件一律**拒绝**, 而不是硬读或 -硬写. 判据只有一条:**出现 NUL 字节** (和 `/include` 的判据一致 —— 两处必须一样, +硬写. 判据只有一条: **出现 NUL 字节** (和 `/include` 的判据一致 —— 两处必须一样, 否则会出现"同一个文件 `/include` 说能带、`read_file` 说不能读"这种自相矛盾). -拒绝的理由是实的, 不是洁癖: +拒绝的理由是实的, 不是洁癖: - `read_file` 会把二进制读成乱码, 还会在第一个 NUL 处被截断 —— 模型看到的是**残缺** 的内容, 却没有任何提示说它是残缺的; -- `edit_file` 更糟:它在内存里按 C 字符串处理 (`strstr`/`strlen`), 文件里任何一个 +- `edit_file` 更糟: 它在内存里按 C 字符串处理 (`strstr`/`strlen`), 文件里任何一个 NUL 都会让后面的内容被当成结束, **改下去就是把原文件毁掉**. 所以它探的是全文件, 不像读类工具只看前 8 KB; - `write_file` 只写文本, 用文本整体覆盖一个二进制文件等于把原内容毁掉. -拒绝时会说清"第几字节有 NUL"以及**该改用什么**:看图片用 `read_image`, 看其它 +拒绝时会说清"第几字节有 NUL"以及**该改用什么**: 看图片用 `read_image`, 看其它 二进制用 `run_command` (`file` / `xxd` / `strings` / `od`), 要改二进制用 `printf` / `dd` / `perl -pi` 这类. -**搜索内容不在这套工具里**:模型要搜就用 `run_command` 里的真 `grep` +**搜索内容不在这套工具里**: 模型要搜就用 `run_command` 里的真 `grep` (`grep -rn 模式 目录`). 以前有个 `search_files` (后来改名 `grep`)的包装, 删掉的理由见下节. ### 工具命名约定 -工具名是协议的一部分:它每个请求都要重发, 还要被人、被模型、被代码里好几处引用. -所以它有一套明写的规矩 (`src/tools.c` 顶部有同一份, 两处必须同步): +工具名是协议的一部分: 它每个请求都要重发, 还要被人、被模型、被代码里好几处引用. +所以它有一套明写的规矩 (`src/tools.c` 顶部有同一份, 两处必须同步): -1. **形式**:全小写 snake_case, 动宾结构「动词_对象」. 动词必须来自白名单, - 而白名单**按域划分** —— 要用新动词, 先把它加进对应域: - - 文件与执行域:`read` / `write` / `edit` / `list` / `run` / `view` - - 历史编辑域 (`/meta`):`list` / `read` / `replace` / `delete` / `insert` / `undo` +1. **形式**: 全小写 snake_case, 动宾结构「动词_对象」. 动词必须来自白名单, + 而白名单**按域划分** —— 要用新动词, 先把它加进对应域: + - 文件与执行域: `read` / `write` / `edit` / `list` / `run` / `view` + - 历史编辑域 (`/meta`): `list` / `read` / `replace` / `delete` / `insert` / `undo` 两域动词不混用. 禁用 `do` / `handle` / `process` / `get` / `exec` 这类不说明对象的空动词. -2. **名字要能推出副作用**:会改盘的名字里不会出现 `read` / `list`. +2. **名字要能推出副作用**: 会改盘的名字里不会出现 `read` / `list`. 模型靠名字判断该不该先征求同意 —— 含糊的名字等于让它猜. -3. 不带 `ligent` / `ai` / `tool` 这类冗余词:工具表本身就在工具命名空间里. +3. 不带 `ligent` / `ai` / `tool` 这类冗余词: 工具表本身就在工具命名空间里. 4. **不与 REPL 命令同名** (`/shell`、`/cd`、`/model`、`/log`… 已被占用), 否则人和模型两边会串. 这也是执行命令的工具叫 `run_command` 而不叫 `shell` 的原因之一. 5. 不与别的工具只在词序、单复数或大小写上不同 (`read_file` / `file_read` 这种). -6. **长度 ≤ 24 字符**:工具表每个请求都要重发, 名字长度是重复成本. -7. **参数名与工具名同源**:路径一律 `path`, 内容 `content`. 同一个概念在两个工具里 +6. **长度 ≤ 24 字符**: 工具表每个请求都要重发, 名字长度是重复成本. +7. **参数名与工具名同源**: 路径一律 `path`, 内容 `content`. 同一个概念在两个工具里 必须同名. -8. **能不包装就不包装** (这条是人定的, 不是名字的规矩):模型手上有一个能跑任意 +8. **能不包装就不包装** (这条是人定的, 不是名字的规矩): 模型手上有一个能跑任意 命令的 `run_command`, 凡是"一条标准命令就能干、而且干得更好"的事, 就别再做一个 自己的版本 —— 多一个工具 = 多一份 schema、多一个要维护的边界、多一次被误用的 机会, 换来的只是省一次 `run_command`. @@ -1545,54 +1545,54 @@ echo '# hi **there**' | ./build/md_demo ### 为什么没有搜索工具 曾经有 `search_files` (后来按规矩改名 `grep`), 现在删了 —— 它是第 8 条规矩的 -反例:真 `grep` 支持正则、文件名过滤、上下文行 (`-A/-B/-C`)、大小写、多模式、 +反例: 真 `grep` 支持正则、文件名过滤、上下文行 (`-A/-B/-C`)、大小写、多模式、 二进制提示, 我们那版每一样都要自己写一遍, 还得跟着需求慢慢长 (第一版就叫错了 -名字:明明是搜内容, 却叫 `search_files`, 模型自然会去传 `pattern="*.c"`). +名字: 明明是搜内容, 却叫 `search_files`, 模型自然会去传 `pattern="*.c"`). 删掉之后工具表从 8 个变成 7 个. - (顺带一提:原来还有个子代理功能 `spawn_subagent`, 它拿不到 `run_command`, + (顺带一提: 原来还有个子代理功能 `spawn_subagent`, 它拿不到 `run_command`, 所以既搜不了内容、也没法真正独立干活, 等于只是"多花一份上下文把文件读一遍", 现在整个删掉了. ) 名字只定义一次 (`src/tools.h` 的 `TOOL_*` 宏), 工具表也只有一处 - (`src/tools.c` 的 `TOOLS[]`):schema、执行分发、要不要走确认、`/tools` 的输出 + (`src/tools.c` 的 `TOOLS[]`): schema、执行分发、要不要走确认、`/tools` 的输出 全从那张表生成. 所以**改名只需改 `.name` 一列** —— 这三样以前各有一份 `strcmp` 链, 改名必定漏一处, 而且漏了不报错 (只是静默地少认一个工具). -**不保留旧名别名**, 这是刻意的:schema 里只发新名, dispatch 再认旧名只会掩盖 +**不保留旧名别名**, 这是刻意的: schema 里只发新名, dispatch 再认旧名只会掩盖 "调用方没跟着改" (旧存档、用户自写的提示词、测试夹具都一样). 认不出来就明确报 `未知工具 "xxx"`, 比静默兼容更容易发现漏改. -### `read_image`:图片怎么进对话 +### `read_image`: 图片怎么进对话 模型自己看不到磁盘. 给它一个路径, 它得先把图**读进来**——这一步就是 -`read_image`: +`read_image`: ```bash ligent "看看 build/screenshot.png 里的报错是什么" ``` -内部做了四件事, 每件都有它的理由: +内部做了四件事, 每件都有它的理由: **1. 按内容认格式, 不认扩展名. ** 读文件头判断 PNG / JPEG / GIF / WebP. 一个叫 `.png` 的文本文件会被明确拒绝, 而不是发出去等一个莫名其妙的 400. -**2. 请求里走标准的 `image_url` + base64 内联. ** 也就是: +**2. 请求里走标准的 `image_url` + base64 内联. ** 也就是: ```json {"type": "image_url", "image_url": {"url": "data:image/png;base64,...."}} ``` -这是协议里图片仅有的两种走法之一 (另一种是外部 URL). **这是通用形状**: +这是协议里图片仅有的两种走法之一 (另一种是外部 URL). **这是通用形状**: DeepSeek 和 MiMo 都收, ligent 不去网上抓图, 所以只产出 base64 这一种. 图片只能出现在 `user` 消息里, 而工具结果只能是字符串. 所以工具跑完之后, ligent 会**补一条合成的 `user` 消息**承载图片. 它算附件、不算提问——`/back` 按轮删除、上下文回收时都按这个规则处理, 不会把它当成新的一轮. -**3. 但图片字节不进历史. ** base64 内联有个直接后果:字节会躺在会话里, +**3. 但图片字节不进历史. ** base64 内联有个直接后果: 字节会躺在会话里, 每轮请求都重发. 归档、`/history`、`/log` 全都跟着膨胀. 所以**存储形态和发送 -形态是分开的**: +形态是分开的**: ``` 存进历史 {"type": "ligent_image", "backup": "1700000000_a1b2.png"} @@ -1605,7 +1605,7 @@ ligent 会**补一条合成的 `user` 消息**承载图片. 它算附件、不 `/export` 出来的存档因此还是几十 KB, 而不是几十 MB. 两个内部块类型都带 `ligent_` 前缀, 就是为了万一哪天漏了展开、直接发出去, -服务端报的错能一眼看出是我们的问题: +服务端报的错能一眼看出是我们的问题: | 内部块 | 含义 | |---|---| @@ -1614,17 +1614,17 @@ ligent 会**补一条合成的 `user` 消息**承载图片. 它算附件、不 **4. 体积在本地就判掉. ** 图片会留在历史里, 所以一次超限不是一次失败, 而是从此**每一轮**都超限. 与其发出去等一个 400, 不如当场告诉模型"太大了"—— -而且**不备份**:一张永远发不出去的图, 留着备份没有意义. +而且**不备份**: 一张永远发不出去的图, 留着备份没有意义. 上限取各家文档里最严的一档 (DeepSeek 单图 32 MiB、请求体 48 MiB; MiMo -base64 字符串 50 MB): +base64 字符串 50 MB): | 限制 | 值 | |---|---| | 单张图 (base64 之后的长度) | 32 MiB | | 一次请求里图片的 base64 总量 | 44 MiB (给提示词/历史/工具描述留 4 MiB) | -注意判的是**编码后**的长度:base64 会膨胀 4/3, 所以一张 25 MiB 的原图就已经 +注意判的是**编码后**的长度: base64 会膨胀 4/3, 所以一张 25 MiB 的原图就已经 超了. 被拒时工具直接把话带给模型, 让它请用户先缩小或压缩. **5. 服务端要是还是拒了呢?** 本地拦的是格式和体积, 但服务端还可能因为别的 @@ -1633,28 +1633,28 @@ base64 字符串 50 MB): 所以 ligent 撞到 4xx 且请求里有图片时, 会把图片**从历史里就地摘掉**、换成 一句说明再重试一次. -给**模型**看的是能直接照做的一句话: +给**模型**看的是能直接照做的一句话: ``` [图片 1700000000_a1b2.png 不符合 API 所需的格式标准, 无法上传, 请规范化图片后重试] ``` 给**你**看的 (stderr)是移除处数和服务端的原话 —— 原始报错通常是给人看的英文 -错误码, 塞进上下文只会让模型分心, 但完全不显示又会让你没法排查: +错误码, 塞进上下文只会让模型分心, 但完全不显示又会让你没法排查: ``` 服务端拒绝了请求里的图片, 已把 1 处图片从对话里移除后重试... -服务端原话:HTTP 400 —— image rejected: unsupported image variant +服务端原话: HTTP 400 —— image rejected: unsupported image variant ``` -"就地"是要紧的:光在请求副本里摘掉, 历史里那条坏引用下一轮还会再犯. +"就地"是要紧的: 光在请求副本里摘掉, 历史里那条坏引用下一轮还会再犯. `read_image` 不写文件、不执行命令, 所以**不触发写操作确认**. 备份是副作用, 不是"改动你的东西". 它会不会出现在工具表里, 取决于模型收不收图片 —— 见下一节. -### 模型能力探测:怎么知道它收不收图片 +### 模型能力探测: 怎么知道它收不收图片 `read_image` 只对**能看图的模型**有意义. 可是**没有任何接口能问出来** —— DeepSeek 和 MiMo 的 `GET /v1/models` 都只返回 `id` / `object` / `owned_by`, @@ -1662,7 +1662,7 @@ DeepSeek 和 MiMo 的 `GET /v1/models` 都只返回 `id` / `object` / `owned_by` 的通病. 所以只能**发试探请求**问. ligent 在**首次请求之前**跑一个独立的"能力测试序列", -它不碰对话历史, 也不进历史: +它不碰对话历史, 也不进历史: | 步 | 发什么 | 结论 | |---|---|---| @@ -1670,37 +1670,37 @@ DeepSeek 和 MiMo 的 `GET /v1/models` 都只返回 `id` / `object` / `owned_by` | 2 | 同一句 + 一张 1×1 的图 + `tools` | 200 → 两样都支持, 到此为止 | | 3 | 拆开单独发 (图 / tools) | 第 2 步被拒时才知道是哪一个不行 | -关键在于**否定结论只在基线通过时才敢下**:单个试探 400 而基线 200, 差别就在 +关键在于**否定结论只在基线通过时才敢下**: 单个试探 400 而基线 200, 差别就在 那一样东西上. 这样不需要猜服务端的错误文案 ("不支持图片"、"invalid content"、 "multimodal not supported"……千家千样), 也就不会因为文案变了而误判. -**猜不准时一律按"支持"处理**:多暴露一个工具, 代价是模型可能白调一次 (还有 +**猜不准时一律按"支持"处理**: 多暴露一个工具, 代价是模型可能白调一次 (还有 4xx 降级兜底); 而错误地藏掉工具, 你会以为模型看不见图. 结果按 `base_url + model` 缓存, 所以**换模型会自动重测**, 切回来命中缓存. 探测完总会打一条灰字结论 —— 支持什么、不支持什么、以及我们因此**做了什么**, -都在同一行里, 不用去猜: +都在同一行里, 不用去猜: ``` -模型能力:多模态 (图片输入)支持 * 工具调用 支持 -模型能力:多模态 (图片输入)不支持 (已隐藏 read_image) * 工具调用 支持 -模型能力:多模态 (图片输入)未测 * 工具调用 未测 —— 没测出来的一律按「支持」处理 (HTTP 401) +模型能力: 多模态 (图片输入)支持 * 工具调用 支持 +模型能力: 多模态 (图片输入)不支持 (已隐藏 read_image) * 工具调用 支持 +模型能力: 多模态 (图片输入)未测 * 工具调用 未测 —— 没测出来的一律按「支持」处理 (HTTP 401) ``` 探测出"不支持"时把动作也写在同行, 是因为只报"不支持"而不说"我已经把工具藏了", 用户会以为工具还在. -代价要说清楚:**开启探测时, 每个进程的第一次请求之前会多出 2~4 个小请求** +代价要说清楚: **开启探测时, 每个进程的第一次请求之前会多出 2~4 个小请求** (常见情况是 2 个, 模型受限时才拆开). 不想要这个开销就用 `--no-probe` (或配置项 `"probe": false`)—— 那时不探测、什么结论都不下、 `read_image` 照常暴露, 也就是探测功能出现之前的行为. 想临时测一次 (比如用了 `--no-probe` 又想确认模型收不收图片), 在 REPL 里敲 -**`/probe`**:它绕开 `--no-probe` 与缓存, 重新跑一遍上面的序列并打出结论. +**`/probe`**: 它绕开 `--no-probe` 与缓存, 重新跑一遍上面的序列并打出结论. #### 为什么最后没走 Files API -早先这一路走的是 Files API:上传换一个 `file_id`, 请求里只带引用. 它确实能 +早先这一路走的是 Files API: 上传换一个 `file_id`, 请求里只带引用. 它确实能 让历史更小, 但**它不是一个标准**. 只有最外面那层 (`POST /v1/files` + multipart + `purpose`)因为抄 OpenAI 而看着像标准; 真正要紧的"在对话请求里 怎么引用"各家完全不同——OpenAI 只在 Responses API 里收 `input_file`, @@ -1714,7 +1714,7 @@ Anthropic 的图像走 `source`; MiMo 干脆明确写了不支持上传本地文 代价写在 §9 里, 不藏着. 思考模式则仍然是方言 (见 §9), 那一条没有通用替代. -> ⚠️ **安全提醒**:按需求本工具**不做沙箱限制**——模型可以执行任意 shell 命令、 +> ⚠️ **安全提醒**: 按需求本工具**不做沙箱限制**——模型可以执行任意 shell 命令、 > 读写任意路径. 建议在容器、虚拟机或独立用户下运行, 不要在存有重要数据的 > 生产环境里直接把 `--no-tools` 之外的限制交给模型自觉. @@ -1724,30 +1724,30 @@ Anthropic 的图像走 `source`; MiMo 干脆明确写了不支持上传本地文 src/ util.c/h xmalloc、动态字符串缓冲、文件读取、mkdir -p、${VAR} 展开 json.c/h 递归下降 JSON 解析器; UTF-8/代理对解码; 构造器; 紧凑/美化序列化 - http.c/h libcurl 薄封装:同步 POST、流式 POST (header 回调取状态码、 + http.c/h libcurl 薄封装: 同步 POST、流式 POST (header 回调取状态码、 写回调分流错误体)、Ctrl-C 中断 - llm.c/h OpenAI 兼容客户端:请求体构造 (含 thinking / reasoning_effort)、 + llm.c/h OpenAI 兼容客户端: 请求体构造 (含 thinking / reasoning_effort)、 SSE 逐行解析、tool_calls 按 index 增量拼接、 reasoning_content 累积与回传、非流式回退、错误体可读化 - tools.c/h 6 个工具的实现 + **工具表** (唯一真相:请求 schema、执行分发、 + tools.c/h 6 个工具的实现 + **工具表** (唯一真相: 请求 schema、执行分发、 是否要确认、/tools 的输出都从它生成; 命名约定写在表上方) capability.c/h 模型能力探测序列 (基线 → 合并 → 拆开), 按 base_url+model 缓存 - models.c/h GET {base_url}/models:拉取并解析模型列表 (保序、去重、容错) - attach.c/h /include:文件暂存、glob 展开、交互式挑选 (逐层进目录) - priv.c/h 提权:把命令包成 sudo 调用 (进程本身不提权) - lineedit.c/h REPL 行编辑器:raw 模式、光标移动、bracketed paste、UTF-8 感知, + models.c/h GET {base_url}/models: 拉取并解析模型列表 (保序、去重、容错) + attach.c/h /include: 文件暂存、glob 展开、交互式挑选 (逐层进目录) + priv.c/h 提权: 把命令包成 sudo 调用 (进程本身不提权) + lineedit.c/h REPL 行编辑器: raw 模式、光标移动、bracketed paste、UTF-8 感知, 以及输入历史的持久化 (实时追加 + 启动加载 + 上限截短) - doctor.c/h 环境体检:逐环检查并给出可操作的结论 - archive.c/h 对话存档:导出/列举/载入 (文件名即谱系) - image.c/h 图片:按文件头认格式、本机备份、请求前把备份展开成 base64 内联 - shell.c/h /shell:伪终端双向搬运 + 录制 - meta.c/h /meta:元编辑对话历史 (独立工具集 + 快照栈 + 结构安全校验) - spinner.c/h 等待动画:两段标签 (等待响应 → 思考中), 后台线程, + doctor.c/h 环境体检: 逐环检查并给出可操作的结论 + archive.c/h 对话存档: 导出/列举/载入 (文件名即谱系) + image.c/h 图片: 按文件头认格式、本机备份、请求前把备份展开成 base64 内联 + shell.c/h /shell: 伪终端双向搬运 + 录制 + meta.c/h /meta: 元编辑对话历史 (独立工具集 + 快照栈 + 结构安全校验) + spinner.c/h 等待动画: 两段标签 (等待响应 → 思考中), 后台线程, 只在 stderr 是终端时启用 i18n.c/h 界面文案表 (中英各一份)、语言探测、{name} 占位符替换 markdown.c/h 流式 Markdown 渲染状态机 (标记跨分片挂起、表格列宽对齐、 近似 wcwidth 的 CJK 宽度计算) - agent.c/h 主循环:消息历史管理、多步工具调用、流式打印 + agent.c/h 主循环: 消息历史管理、多步工具调用、流式打印 config.c/h 默认值 → 配置文件 → 环境变量 main.c 参数解析、单次任务模式、REPL tools/ @@ -1758,35 +1758,35 @@ tests/ test_agent.c 上下文不变量 (system 不重复、reset 语义) test_config.c 默认值、环境变量优先级、配置文件覆盖与 ${VAR} 展开 test_markdown.c Markdown 渲染 + 分片不变性 (一次性/3字节/逐字节 输出一致) - fuzz.c 对抗性模糊测试:随机/变异输入砸 JSON 与 Markdown 解析器 - reduce.c delta-debugging 最小化器:把失败输入缩到最短复现 + fuzz.c 对抗性模糊测试: 随机/变异输入砸 JSON 与 Markdown 解析器 + reduce.c delta-debugging 最小化器: 把失败输入缩到最短复现 test_http.c HTTP 层纯逻辑 (重试判定、上限取值) test_lineedit.c 行编辑器缓冲区与历史 (含 Home/End 在逻辑行内的语义、 历史文件转义往返 / 上限截短 / 文件不存在) test_spinner.c 等待动画的两段标签与幂等性 (把 stderr 接到临时文件后读回断言) - test_meta.c 元编辑:轮次切分、结构安全、七个工具、撤销、重新存根标记 - test_image.c 图片:格式嗅探、备份与索引、附件队列、损坏索引降级 + test_meta.c 元编辑: 轮次切分、结构安全、七个工具、撤销、重新存根标记 + test_image.c 图片: 格式嗅探、备份与索引、附件队列、损坏索引降级 test_capability.c 能力探测的纯逻辑 (名字映射、关掉探测时的语义、缓存查询) test_models.c /models 响应体解析 (标准形状、裸数组、去重、坏条目、空列表) - test_attach.c /include:glob 展开、二进制/超限跳过、图片引用、回调只在遇图时问 + test_attach.c /include: glob 展开、二进制/超限跳过、图片引用、回调只在遇图时问 repl_edit_check.py 带极简终端模拟器, 验证方向键/Home/End/退格/历史/多行粘贴 真的生效 (只对比字节流证明不了光标移动) repl_drive.py 用 pty 驱动 REPL 跑多轮对话 (管道模式无法测多轮行为) mock_server.py 最小 OpenAI 兼容 mock, 含 SSE 与分片 tool_calls - run_e2e.sh 端到端:流式、非流式、多步工具、错误处理、缺 key、管道、渲染 + run_e2e.sh 端到端: 流式、非流式、多步工具、错误处理、缺 key、管道、渲染 ``` ### 几个实现细节 -- **tool_calls 增量拼接**:流式响应里 `function.arguments` 会被服务端切成任意碎片, +- **tool_calls 增量拼接**: 流式响应里 `function.arguments` 会被服务端切成任意碎片, 客户端按 `index` 累积成完整 JSON 字符串后再整体解析, 而不是每片单独解析. -- **错误响应可读化**:非 2xx 时解析 `{"error": {"message": "..."}}` 并解码 `\uXXXX`, +- **错误响应可读化**: 非 2xx 时解析 `{"error": {"message": "..."}}` 并解码 `\uXXXX`, 而不是把原始 JSON 甩给用户. -- **不依赖流式**:如果服务端忽略 `stream` 参数直接返回整包 JSON, 客户端会自动回退解析. -- **行号集合钉住的替换**:`edit_file` 的 `line` (`read_file` 给的行号, 整数或整数 +- **不依赖流式**: 如果服务端忽略 `stream` 参数直接返回整包 JSON, 客户端会自动回退解析. +- **行号集合钉住的替换**: `edit_file` 的 `line` (`read_file` 给的行号, 整数或整数 数组都收)把替换钉在集合圈住的行里, 且只要行内唯一 —— 从机制上避免模型改错地方, 也不逼它为了"全文唯一"把 `old_string` 写成一整段. `line` 可以省略, 省略就是 - **全文件替换所有匹配**:逃生门留着 (有些改动本来就该全文件生效), 但它不是默认 + **全文件替换所有匹配**: 逃生门留着 (有些改动本来就该全文件生效), 但它不是默认 路径 —— 要改一处仍得给行号. ## 7. 测试 @@ -1802,7 +1802,7 @@ make demo # 编译并运行渲染效果演示 ### 模糊测试 `tests/fuzz.c` 用随机与变异输入去砸两个"解析不可信数据"的模块 (JSON 解析器、 -Markdown 渲染器), 检查四条不变量: +Markdown 渲染器), 检查四条不变量: | 模块 | 不变量 | |---|---| @@ -1824,17 +1824,17 @@ make build/fuzz build/reduce ./build/reduce build/md_fail.bin # 最小化最近一次失败 ``` -端到端测试不需要联网也不需要密钥:`tests/mock_server.py` 会在本地起一个 +端到端测试不需要联网也不需要密钥: `tests/mock_server.py` 会在本地起一个 OpenAI 兼容服务端, 按请求内容返回确定性的流式响应, 从而验证「模型请求工具 → 本地真实执行 → 结果回填 → 模型给出最终回答」的完整闭环. > 存档目录 (`<二进制目录>/ligent.archive`)是运行期状态而不是构建产物, e2e 却 -> 需要它干净, 所以脚本开机时会检查:**非空就先整体挪到 `/tmp/ligent.archive.saved.*`** +> 需要它干净, 所以脚本开机时会检查: **非空就先整体挪到 `/tmp/ligent.archive.saved.*`** > 并打印路径, 而不是直接删掉. 移不动就直接中止 (宁可测试不跑, 也不误删). > 挪走之后 e2e 才会在自己的那份临时目录里折腾 —— 跑两次也不会把你上一轮的 > 存档叠进去. -用 AddressSanitizer / UBSan 跑一遍: +用 AddressSanitizer / UBSan 跑一遍: ```bash make clean @@ -1844,7 +1844,7 @@ make SAN=1 test-unit `SAN=1` 在默认 `CFLAGS` 之上追加 `-fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all`, 链接行也带上 (只插桩目标文件、不进运行时库的话检测器根本不生效). -`-fno-sanitize-recover=all` 让报错直接中止而不是"报告后继续跑", 免得刷屏. +`-fno-sanitize-recover=all` 让报错直接中止而不是"报告后继续跑", 以免刷屏. 整套单元测试 (16 个可执行、3692 项)在这个配置下是干净的. ## 8. 稳定性设计 @@ -1861,29 +1861,29 @@ make SAN=1 test-unit | 响应体上限 | 声明或实际超过 32MB 主动中止; 流式靠 `Content-Length` 提前判断, 非流式另有累积上限 | | SSE 缓冲上限 | 单行 256KB、单次累积内容 32MB、单个工具调用参数 8MB. 正常 `data:` 行只有几十字节, 超长说明对端不是 SSE | | 工具输入上限 | `edit_file` 在读之前先看文件大小, 超过 32MB 直接拒绝 (它需要整个文件进内存); `read_file` 是分段读的, 不受此限 | -| 二进制检测 | 文本工具 (`read_file` / `write_file` / `edit_file`)拒收含 NUL 字节的文件:读类只看前 8 KB, `edit_file` 看全文件 (它按 C 字符串处理, 任何 NUL 都会把结果截断) | +| 二进制检测 | 文本工具 (`read_file` / `write_file` / `edit_file`)拒收含 NUL 字节的文件: 读类只看前 8 KB, `edit_file` 看全文件 (它按 C 字符串处理, 任何 NUL 都会把结果截断) | | 错误体上限 | 只保留 256KB 用于诊断 | -重试次数可配置:`--retries N` (默认 2, 0 = 不重试)/ `LIGENT_RETRIES` / 配置项 `retries`. +重试次数可配置: `--retries N` (默认 2, 0 = 不重试)/ `LIGENT_RETRIES` / 配置项 `retries`. ### 传输中断 流式请求中途断连时, **已经收到的部分内容会保留**成一条消息并提示"传输中断", 而不是整段丢弃 —— 否则屏幕上有这段文字、上下文里却没有, 后续对话会对不上. -### 上下文超限:如实报错, 不替你做决定 +### 上下文超限: 如实报错, 不替你做决定 历史超过模型窗口时, 服务端回一个 4xx. ligent **不会自动删掉任何一条消息** —— -它把服务端的原始错误原样报出来, 历史一个字节都不动: +它把服务端的原始错误原样报出来, 历史一个字节都不动: ``` ligent> 第二轮 -ligent: 出错:HTTP 400 — This model's maximum context length is 8192 tokens. +ligent: 出错: HTTP 400 — This model's maximum context length is 8192 tokens. However, your messages resulted in 9000 tokens. ``` 这里曾经有一整套"丢弃最老的一整轮再重试 (最多 5 次)"的机制, 配套 -`--no-auto-trim` / `auto_trim` / `LIGENT_AUTO_TRIM`. 它被**整个删掉**了, 理由: +`--no-auto-trim` / `auto_trim` / `LIGENT_AUTO_TRIM`. 它被**整个删掉**了, 理由: - 触发它的判据只是错误文案里的关键词 (`too long`、`上下文`、`超出`……), 任何 4xx 蹭到这些词都会触发裁剪 —— 代价却是**不可逆地**删掉几轮对话. @@ -1891,16 +1891,16 @@ ligent: 出错:HTTP 400 — This model's maximum context length is 8192 tokens - 它把"历史太大了"这件本该由用户决定的事 (`/back` 摘掉哪一轮、`/reset` 重来、 还是换一个窗口更大的模型)自动化掉了; 用户看到的是对话莫名其妙少了几轮. -现在怎么处理由你决定:`/back` 摘掉最近一轮 (可 `/unback` 撤销)、`/reset` 清空 +现在怎么处理由你决定: `/back` 摘掉最近一轮 (可 `/unback` 撤销)、`/reset` 清空 重来、`/export` 存档后另起一段, 或者直接换个模型. `/usage` 里的"最近一次输入 token"≈ 当前上下文大小, 可以据此判断还剩多少余量. (`/meta` 里让模型帮你做这件事仍然是可行的 —— 它删的是你看得见、也点过头的那些轮. ) -### 对话结构不变量:tool_calls 必须连着 tool +### 对话结构不变量: tool_calls 必须连着 tool -服务端对消息结构的要求很硬:**带 `tool_calls` 的 assistant 后面必须紧跟**与每个 -`tool_call_id` 一一对应的 `tool` 消息, 中间不能夹别的东西, 也不能少. 违反了就是: +服务端对消息结构的要求很硬: **带 `tool_calls` 的 assistant 后面必须紧跟**与每个 +`tool_call_id` 一一对应的 `tool` 消息, 中间不能夹别的东西, 也不能少. 违反了就是: ``` HTTP 400 — An assistant message with 'tool_calls' must be followed by tool @@ -1909,9 +1909,9 @@ following tool_calls message) ``` 麻烦在于这条历史**留在上下文里**——一旦写坏, 之后每一轮都会以同样的 400 失败, -用户只能 `/reset`. 所以 ligent 两头都做了: +用户只能 `/reset`. 所以 ligent 两头都做了: -**发之前先体检. ** 每次构造请求前扫一遍历史:缺的 `tool` 消息补一条占位 +**发之前先体检. ** 每次构造请求前扫一遍历史: 缺的 `tool` 消息补一条占位 ("这次调用没有拿到结果, 不要假设它执行过"), 孤儿 `tool` 消息 (找不到对应 调用的)丢掉, 并如实打一行说明. 没问题时它只是一次只读扫描, 不分配任何东西. @@ -1920,7 +1920,7 @@ following tool_calls message) —— 不修的话之后每一轮都会被服务端拒绝. ``` -**产生的时候别写坏. ** 体检是兜底, 正常路径不该走到它. 两个真实踩过的坑: +**产生的时候别写坏. ** 体检是兜底, 正常路径不该走到它. 两个真实踩过的坑: - **图片附件的位置. ** 图片只能出现在 `user` 消息里, 而 `tool` 消息的内容只能是 字符串, 所以 `read_image` 之后要补一条 `user` 消息. 曾经是"每推完一条 `tool` @@ -1932,24 +1932,24 @@ following tool_calls message) 本次调用没有执行"的 `tool` 消息再返回. 回归用例是第 53 段, 而且 `tests/mock_server.py` **和真服务端一样校验这个结构** - (`pairing_error`):以后任何写坏历史的回归都会在 e2e 里当场 400, 而不是等到 + (`pairing_error`): 以后任何写坏历史的回归都会在 e2e 里当场 400, 而不是等到 用户真拿它去连服务端才发现. ### 图片降级只在错误确实冲着图片来时动手 服务端 4xx 时会检查请求里有没有图片引用, 有就把图片从历史里摘掉再重试一次 - (见 §5). 这一条**加了关键词判断**:只有错误里出现 `image` / `vision` / + (见 §5). 这一条**加了关键词判断**: 只有错误里出现 `image` / `vision` / `multimodal` / `content type` / `图片` 这类词才动手. -理由是它会把图片**永久删掉**:一条"tool_calls 后面缺 tool 消息"的 400 也被当成 +理由是它会把图片**永久删掉**: 一条"tool_calls 后面缺 tool 消息"的 400 也被当成 图片问题处理的话, 图白丢, 而且真正的错误被我们的提示盖住了 —— 排障方向直接被 带偏. **不确定就不动手**, 宁可让错误如实报出来. 这终究是启发式; 主要防线还是 发送前就把格式和体积查掉. -### 提权:模型请求, 你输密码 +### 提权: 模型请求, 你输密码 `run_command` 有一个 `elevated` 参数. 模型撞到权限墙时带上它, 那一条命令就以 -root 执行: +root 执行: ``` run_command {"command": "apt install -y nginx", "elevated": true} @@ -1965,12 +1965,12 @@ run_command {"command": "apt install -y nginx", "elevated": true} **先说清楚为什么没有 `/su`、也没有"授权提示". ** - **`su` 能变 root, 不是因为 PAM 验证了密码**, 而是因为 `/usr/bin/su` 本身是 - setuid root:内核先把 root 给它, 它再验密码、验不过就降权. 普通进程调 + setuid root: 内核先把 root 给它, 它再验密码、验不过就降权. 普通进程调 `pam_authenticate()` 只能得到「密码对不对」, uid 一位都不变. 要让 ligent 内部认证后真的变 root, 唯一的路是把它编译成 setuid root —— 而这个程序的功能 是「让模型执行任意命令」, setuid root 的它等于把 root 远程执行做成一等功能. **那条路没走. ** -- **也不需要 ligent 再发明一道 y/N 授权**. 密码本身就是不可伪造的授权:模型敲 +- **也不需要 ligent 再发明一道 y/N 授权**. 密码本身就是不可伪造的授权: 模型敲 不出来, 你也不可能手滑敲出来. 前面再加一道"允许吗"只会是多余的摩擦, 还会给出 一个虚假的安全模型 —— 好像把关的是 ligent 的提示, 其实把关的是 sudo. - 所以没有常驻提权状态, 也就没有"忘了降权"这回事. **一次命令一次身份**; @@ -1984,18 +1984,18 @@ run_command {"command": "apt install -y nginx", "elevated": true} | 输出 | 仍然接管道 | 命令输出照常捕获回填给模型, 和普通 `run_command` 一样 | | 没有终端 | **直接拒绝, 不去试** | 否则就是一个在后台等一个没人能看见的密码提示的进程 | -想立刻收回授权:在 `/shell` 里敲 `sudo -k`, 或者等 sudo 自己的超时. +想立刻收回授权: 在 `/shell` 里敲 `sudo -k`, 或者等 sudo 自己的超时. -**踩过的坑, 值得单独记**:第一版把认证也放进通用的 `run_capture()`, 而它 +**踩过的坑, 值得单独记**: 第一版把认证也放进通用的 `run_capture()`, 而它 `setsid()` + 全接管道 —— 真机上认证 **100% 失败**. 更糟的是当时的 e2e 替身 sudo 无条件成功, 给了个**假绿**. 现在替身必须像真 sudo 一样要求终端, 并且有一条 断言专门盯"认证时 sudo 真的拿到了终端"; `tests/test_priv.c` 还钉住了"包装里不许 出现 `-n`"和"没终端时能识别出来"这两条. -### 排障:/log 导出收发记录 +### 排障: /log 导出收发记录 进程会把每一次请求的**请求体**、响应头、以及响应原文 (流式就是原始 SSE 字节) -滚动缓存在内存里. 不给这个问题事先准备什么, 出事了再捞: +滚动缓存在内存里. 不给这个问题事先准备什么, 出事了再捞: ``` ligent> /log # 看看现在缓存了多少 @@ -2005,13 +2005,13 @@ ligent> /log clear # 清空缓存 记录里**不含请求头**, 所以不会把 API key 写进去 (有测试断言). -它的真正用途是:**录一次真实会话, 之后离线回放**. ligent 的协议适配一直只在 +它的真正用途是: **录一次真实会话, 之后离线回放**. ligent 的协议适配一直只在 自建的 mock 服务端上验证过, 而 mock 是我们自己写的 —— 它接受什么, 完全取决于 我们**以为**服务端会接受什么. 有了这个文件, 真实服务端的响应就能当成测试夹具 反复使用, 不必每次都烧 token. `tests/replay.py` 就是这个回放器. 它不重写一个"模拟客户端", 而是当**离线 -服务端**:把录下来的响应原样发给一个真的 ligent, 于是 `http.c` 的分块读取、 +服务端**: 把录下来的响应原样发给一个真的 ligent, 于是 `http.c` 的分块读取、 `llm.c` 的 SSE 行切分与 `tool_calls` 增量重组, 全都在真实字节上跑一遍. ```bash @@ -2021,24 +2021,24 @@ python3 tests/replay.py --serve 8899 session.log --strict # 当离线服务端 ./ligent --no-probe --base-url http://127.0.0.1:8899/v1 "please run it" ``` -`--strict` 是它最有用的一半:它把每个进来的请求体与录制时那一份做**结构比对** +`--strict` 是它最有用的一半: 它把每个进来的请求体与录制时那一份做**结构比对** (顶层键、消息角色序列、工具名集合), 不一致就回 400 并在 stderr 打印差异. 于是"今天的客户端还在发同样的报文形状"变成了一条**会失败的断言**——改坏了 报文而没发现, 从此不可能. -回放时要给 ligent 加 `--no-probe`:能力探测会插进额外的请求, 而那几轮不在 +回放时要给 ligent 加 `--no-probe`: 能力探测会插进额外的请求, 而那几轮不在 录制里 (录制时也建议 `LIGENT_PROBE=0`, 两边一致). 录制用完之后回放器会明确 报"没有更多响应", 而不是静静回错东西. 仓库里冻了两份夹具 (`tests/fixtures/`), 都是对着 mock 录的 —— mock 故意把 -SSE 切成 1 字符、把 `tool_calls` 也切碎, 所以它们冻结的正是最值得冻结的形状: +SSE 切成 1 字符、把 `tool_calls` 也切碎, 所以它们冻结的正是最值得冻结的形状: | 夹具 | 冻住的是 | |---|---| | `session.log` | 流式 SSE + 被切碎的 `tool_calls` 重组 + 工具往返 + 最终回答 | | `error.log` | 一个 400 错误体, 以及错误信息的可读化 | -**录一份真实会话** (你自己的 key, 跑一次真问题): +**录一份真实会话** (你自己的 key, 跑一次真问题): ```bash ligent # 正常聊, 直到复现出你要冻住的那个行为 @@ -2051,7 +2051,7 @@ python3 tests/replay.py session.log --summary 内存上限 16MB, 超出后丢弃较早的部分 (排障时通常更关心刚刚发生了什么), 导出时会在文件开头注明发生了截断. -> 注意:`/log` 是 REPL 命令. 单次任务模式 (`ligent "任务"`)下记录同样会累积, +> 注意: `/log` 是 REPL 命令. 单次任务模式 (`ligent "任务"`)下记录同样会累积, > 但没有地方把它导出来. ### 对抗性测试 @@ -2062,16 +2062,16 @@ Markdown 渲染器); `tests/reduce.c` 把失败输入最小化成最短复现. ## 9. 已知限制 -- 界面文案只有中英两份, 而且只管"给人看"的部分:**工具描述、工具回执、 +- 界面文案只有中英两份, 而且只管"给人看"的部分: **工具描述、工具回执、 系统提示词不随界面语言变** (那些是给模型的信息). 切成英文之后模型收到的 仍然是中文回执 —— 想让模型侧也换语言, 得改提示词本身. - 只在 Linux 上验证过 (用到 `fork/setsid/pipe/poll`、`fnmatch`、POSIX 正则). - `--timeout` 只对非流式请求生效; 流式请求靠 120 秒无数据即断开的低速检测. -- 历史超过模型窗口时**只报错, 不自动回收** (见§8):要不要丢几轮、丢哪几轮由你 +- 历史超过模型窗口时**只报错, 不自动回收** (见§8): 要不要丢几轮、丢哪几轮由你 用 `/back`、`/reset`、`/meta` 决定. 单轮内工具输出过大 (超过上限被截断)时, 只能靠模型自己用 `offset/limit` 分段读. - 每轮都会重发完整历史, token 成本随轮数平方增长. -- 终端交互是行式 REPL, 不是全屏 TUI:有行内编辑与历史, 但**没有 Tab 补全**. +- 终端交互是行式 REPL, 不是全屏 TUI: 有行内编辑与历史, 但**没有 Tab 补全**. - 对话历史本身只存在进程内, 退出即丢 —— 要留就 `/export`. (输入历史是持久化的, 见下一节. ) - Markdown 渲染不处理嵌套列表缩进. @@ -2083,12 +2083,12 @@ Markdown 渲染器); `tests/reduce.c` 把失败输入最小化成最短复现. - `/include` 的 glob 是 shell 语义 (`glob(3)`), `*` 不跨目录; 要递归就进 交互式界面逐层进, 或者自己写多级模式. - `/include` 一次最多 32 个文件、单文件 256 KB、累计 1 MB. -- `/models` 的列表只影响"你能看到什么", 不影响"你能填什么":输入行里直接写 +- `/models` 的列表只影响"你能看到什么", 不影响"你能填什么": 输入行里直接写 名字可以指定任何模型, 包括服务端没列出来的内部模型. - `/history`、`/back`、`/unback`、`/export`、`/import` 报的"历史条数"**不含 system 提示词** —— 它是配置不是对话, 删掉唯一一轮之后应当显示 0 条. 只有 `/diff` (meta)报的是数组真实条数, 因为它要跟 `#0 #1 ...` 的下标对上. -- 图片只进不出:`read_image` 能让模型看图, 但模型没有画图能力. +- 图片只进不出: `read_image` 能让模型看图, 但模型没有画图能力. - 能力探测靠"发请求试", 因为**根本不存在**查询能力的接口. 代价是开启探测时 每个进程的第一次请求前会多出 2~4 个小请求 (换模型会重测, 切回来命中缓存). `--no-probe` 可以彻底关掉, 回到"全都暴露"的老行为. 探测结论不确定时按支持 @@ -2099,12 +2099,12 @@ Markdown 渲染器); `tests/reduce.c` 把失败输入最小化成最短复现. - 历史里的图片是对 `ligent.archive/images/` 的引用, **备份才是字节的真身**. 把 `ligent.archive` 删掉或移走, 图片就展开不出来, 会被降级成文字说明 (不会让对话卡死, 但图也就没了). `/export` 的存档同理, 不是自包含的. -- 图片只支持内联, **不支持直接给一个外部 URL**:ligent 不去网上抓图, `read_image` +- 图片只支持内联, **不支持直接给一个外部 URL**: ligent 不去网上抓图, `read_image` 只收本地文件. (协议上的另一种走法是外部 URL, 需要服务端自己去下载. ) - 单张图 base64 后超过 32 MiB、或一次请求图片总量超过 44 MiB 会被本地拒绝, 模型会被告知"图片太大", 需要先缩小或压缩. - 工具结果按字节截断, 不按 token 计算, 极大文件需要配合 `offset/limit` 分段读. -- 二进制检测只看"有没有 NUL 字节":`read_file` 只探前 8 KB, 所以一个 +- 二进制检测只看"有没有 NUL 字节": `read_file` 只探前 8 KB, 所以一个 **前 8 KB 干净、之后才有 NUL** 的文件仍会被当文本读 (内容会在第一个 NUL 处被 悄悄截断). `edit_file` 不受影响 —— 它看全文件. UTF-16、压缩包、可执行文件这类 在开头就有 NUL, 都能识别出来. @@ -2118,12 +2118,12 @@ Markdown 渲染器); `tests/reduce.c` 把失败输入最小化成最短复现. ligent 不做方言探测. 顺带一提, `reasoning_content` ligent 读的时候两种 名字都认, 但回传时**只写 `reasoning_content`** —— 对着用 `reasoning` 的 服务端可能会丢失思考上下文. -- `/shell` 的记录对全屏程序是"整段忽略画面"而不是"还原画面":`nano`/`vim` 这类 +- `/shell` 的记录对全屏程序是"整段忽略画面"而不是"还原画面": `nano`/`vim` 这类 用备用屏幕的程序, 记录里只剩命令行和退出后的输出. 极少数既不用备用屏幕、 又逐行重绘的程序 (`TERM` 被强行设成不认识的值时)只能靠压缩空白降噪; 真正逐屏还原需要一整套终端模拟器, 不在当前范围内. -**验证上的缺口** (这些地方是"我没验过", 不是"我验过没问题"): +**验证上的缺口** (这些地方是"我没验过", 不是"我验过没问题"): - **提权的真实路径没端到端跑过. ** e2e 里用的是桩 `sudo` (它要求有 tty 并把 argv 记下来, 专门守着 `setsid` 那个回归), 真实 sudo 的密码提示路径没有测过 @@ -2137,6 +2137,6 @@ Markdown 渲染器); `tests/reduce.c` 把失败输入最小化成最短复现. `CURLOPT_CAINFO`. - **`clang --analyze` 还剩 3 条告警**, 我判断是误报 (`fgets` 前已置 `errno=0`; `fread` 的 EOF 循环惯用法), 但没有能力证明. `gcc -fanalyzer` 在 `image.c` 报 - 的那条 FILE 泄漏同理:`-O0` 下干净、`-O1` 起才报、把函数原样抽出来单独编也 + 的那条 FILE 泄漏同理: `-O0` 下干净、`-O1` 起才报、把函数原样抽出来单独编也 不报, 我判断是内联导致的误报 —— 换个 gcc 版本结论可能变. - 打断 (Ctrl-C)路径只在几处有覆盖, 不全面. diff --git a/README2.md b/README2.md index 4f574b6..00b1f04 100644 --- a/README2.md +++ b/README2.md @@ -1,13 +1,13 @@ # ligent: Self-contained yet powerful command-line AI Agent, implemented in C99 -C99 从零写成的命令行 AI Agent:连接 **OpenAI 兼容**的 Chat Completions +C99 从零写成的命令行 AI Agent: 连接 **OpenAI 兼容**的 Chat Completions 接口, 具备**原生 function calling** (工具调用)能力, 能读写文件、执行命令、 搜索代码, 并在终端里流式输出. 对话、工具调用、图片走的都是通用协议 (`image_url` + base64 内联, DeepSeek 和 MiMo 都收); **只有思考模式的字段是方言**, 不做跨厂商探测. 代价见 §9. -除 `libcurl` (HTTPS 传输)外, **不依赖任何第三方库**: +除 `libcurl` (HTTPS 传输)外, **不依赖任何第三方库**: JSON 解析器、SSE 流式协议解析、工具调用增量拼接、REPL 全部是本仓库自己实现的. ``` @@ -30,7 +30,7 @@ $ ligent "把 src/util.c 里的 xmalloc 改成失败时打印调用栈, 然后 ## 1. 编译 -依赖:`gcc` (或 `clang`)、`make`、`libcurl` 开发包. +依赖: `gcc` (或 `clang`)、`make`、`libcurl` 开发包. ```bash # Debian / Ubuntu @@ -54,7 +54,7 @@ make test # 跑单元测试 + 端到端测试 依赖只有 **libcurl** 和 libc, 外加一个 C99 编译器 (gcc / clang / tcc 都编得过). 跑测试还需要 Python 3 —— 端到端测试用本地 mock 服务端, **不联网、不需要密钥**. -其它目标: +其它目标: | 目标 | 作用 | |---|---| @@ -72,7 +72,7 @@ make test # 跑单元测试 + 端到端测试 ### 构建目录按编译器和配置分开 -产物不在 `build/` 正下方, 而是 `build/<编译器>[-san]/`: +产物不在 `build/` 正下方, 而是 `build/<编译器>[-san]/`: ``` build/cc/ # 默认 CC=cc @@ -81,23 +81,23 @@ build/tcc/ build/cc-san/ # make SAN=1 ``` -原因很实在:**make 看不见命令行变量变了**. `make CC=tcc` 之后再 `make CC=clang`, +原因很实在: **make 看不见命令行变量变了**. `make CC=tcc` 之后再 `make CC=clang`, 只有被改过的那几个 `.o` 是 clang 编的, 其余还是 tcc 的, 链接时就是一堆 -`__floatundidf` 之类的 undefined reference. 更危险的是 `make` 之后再 `make SAN=1`: +`__floatundidf` 之类的 undefined reference. 更危险的是 `make` 之后再 `make SAN=1`: 一个 `.o` 都不会重编, 而链接行带着 `-fsanitize=...`、ASan 运行时也确实进了二进制, 于是它**看起来**是插桩过的 —— 可 `nm` 里一个 `__asan`/`__ubsan` 符号都没有, 什么都不会检测. 换句话说, "没先 `make clean` 的 ASan 结论"全是假绿灯. -把标签写进路径之后这两种混用都不可能发生了:配置一变, 对应目录就是空的, 该编的 +把标签写进路径之后这两种混用都不可能发生了: 配置一变, 对应目录就是空的, 该编的 自然会全编. -`make clean` 清掉**一切**构建产物:各 `<编译器>[-san]` 对象目录、`build/static`、 +`make clean` 清掉**一切**构建产物: 各 `<编译器>[-san]` 对象目录、`build/static`、 测试可执行文件、e2e 的临时文件、`ligent` / `ligent-static`, 以及 `tools/build-static-deps.sh` 编出来的 `build/static-prefix` (静态库)和 `build/static-src` (源码 + 压缩包缓存), 外加 `tests/__pycache__`. 换句话说, `make clean` 之后 `git status --ignored` 应该只剩你自己的东西. -代价是明确的:**清掉依赖之后, 下次静态构建要重新联网下载再编译几分钟**: +代价是明确的: **清掉依赖之后, 下次静态构建要重新联网下载再编译几分钟**: ```bash make clean @@ -108,12 +108,12 @@ make static `make static` 自己**不会**去编依赖 (那等于让一个构建目标偷偷联网下载), 它会明确 报错并告诉你该跑上面那条命令. -特意**不动 `ligent.history`**:那不是构建产物, 是你的输入历史, 删了找不回来. +特意**不动 `ligent.history`**: 那不是构建产物, 是你的输入历史, 删了找不回来. (e2e 自己写的是 `build/e2e.history`, 在 `build/` 里, 跟着一起清. ) ### 让编译器检查我们自己的 printf -项目里有一层自己的 printf 风格包装:`xasprintf` / `sb_printf` / `err_result` / +项目里有一层自己的 printf 风格包装: `xasprintf` / `sb_printf` / `err_result` / `log_printf` / `spinner_notice` / `perr` / doctor 的 `field`·`marked`·`sub`, 一共 11 个函数、约 120 个调用点. 它们的格式串**编译器一个都没在看** —— 因为全项目 `__attribute__` 出现 0 次, 编译器不知道这些函数有 printf 语义. @@ -123,11 +123,11 @@ make static `__attribute__((format(printf, …)))`, tcc 没有这个机制、展开成空)把 11 个函数全标上, 再加 `-Wformat=2`. -结果:120 个调用点全部过了检查, 并额外抓到 doctor 里把 `HTTPS_PROXY` 的值**直接当 +结果: 120 个调用点全部过了检查, 并额外抓到 doctor 里把 `HTTPS_PROXY` 的值**直接当 格式串**的写法 —— 代理 URL 里带 `%` 是常事 (密码里的 `%40`、路径里的 `%2F`), 那会去读一个不存在的参数. 改成 `field("代理", "%s", …)`. -`-Wformat=2` 附带 `-Wformat-nonliteral`, 我们用 `-Wno-format-nonliteral` 关掉了: +`-Wformat=2` 附带 `-Wformat-nonliteral`, 我们用 `-Wno-format-nonliteral` 关掉了: 包装函数内部转发 `fmt` 的地方 (`xvasprintf` → `vsnprintf(fmt, ap)`)天然是非字面量, 而**调用点**那一侧已经由属性查完了, 留着只是噪声. `-Wformat-security` 保留着 —— 它正是抓到上面那个代理问题的那条. @@ -142,18 +142,18 @@ make static make analyze # gcc -fanalyzer, 独立目标, 故意不进 make test (它比全套测试还慢) ``` -它抓的是稀疏但致命的那类问题, 第一次跑就捞出两个真 bug:`attempt()` 里 +它抓的是稀疏但致命的那类问题, 第一次跑就捞出两个真 bug: `attempt()` 里 `curl_easy_init` 失败那条提前返回会把未初始化的 `cerr` 当字符串读; POST 分支对 `body == NULL` 不设防 (无条件 `strlen`). 两个都不在任何测试的覆盖里 —— 前者要 `curl_easy_init` 真的失败, 后者要一个当前调用方不会传的参数组合. -用 `-O0` 跑是有意的:开了优化之后 gcc 会内联, 而 `-fanalyzer` 的跨函数状态遇到 +用 `-O0` 跑是有意的: 开了优化之后 gcc 会内联, 而 `-fanalyzer` 的跨函数状态遇到 内联并不可靠 (`image.c` 的 `rand_suffix` 在 `-O1/-O2/-Os` 下会报一条并不存在的 FILE 泄漏, `fclose` 明明就写在 `fread` 下一行; `-O0` 下干净). 分析器自身就是跨 -函数分析的, 不依赖 `-O` 级别, 所以降级不会少查什么. 目标里带 `-Werror`:不然 +函数分析的, 不依赖 `-O` 级别, 所以降级不会少查什么. 目标里带 `-Werror`: 不然 它永远打印"通过"—— gcc 的分析器告警不影响退出码, 这是我第一版踩的坑. -### 换个编译器:tcc 也能编 +### 换个编译器: tcc 也能编 ```bash make CC=tcc # 全套测试 (3692 单元 + 318 e2e)实测通过 @@ -163,34 +163,34 @@ tcc 0.9.28 能把 ligent 完整编出来并链接成功, 产物 1.0 MB. CPU 密 (`fuzz 4000`)上 **218 ms vs gcc 的 171 ms, 只慢约 1.3 倍** —— 对 ligent 这种 瓶颈在网络的程序完全无所谓. -它换不来三样东西, 用之前得知道: +它换不来三样东西, 用之前得知道: -- **没有 `-fsanitize`**:`make SAN=1` 仍然要用 gcc/clang. +- **没有 `-fsanitize`**: `make SAN=1` 仍然要用 gcc/clang. - **没有那些严格警告** (`-Wshadow` / `-Wmissing-prototypes` 等). tcc 的诊断 弱得多, 所以"零警告"这条纪律只在 gcc/clang 下成立. - 没有优化器 (上面那 1.3 倍就是它的全部代价). -顺带一提, tcc 一上来就帮我们抓出两个**一直存在的构建脆弱点**:代码调了 `fabs` +顺带一提, tcc 一上来就帮我们抓出两个**一直存在的构建脆弱点**: 代码调了 `fabs` 却没链 `-lm` (gcc 把它折成内建函数, 所以看不出来), 以及 6 条测试链接规则漏了 `$(LDLIBS)`. 两个都补上了 —— 这类"某个编译器恰好替你兜住"的洞, 平时是看不见的. -### 静态构建:musl libc + 静态 libcurl +### 静态构建: musl libc + 静态 libcurl -想要一个**拷到哪都能跑**的可执行文件 (不依赖宿主机的 glibc、libcurl、OpenSSL): +想要一个**拷到哪都能跑**的可执行文件 (不依赖宿主机的 glibc、libcurl、OpenSSL): ```bash ./tools/build-static-deps.sh # 造一份 musl 编的静态 libcurl (要联网、几分钟) -make static # 产物:ligent-static +make static # 产物: ligent-static file ligent-static # → ELF 64-bit ... statically linked ``` **为什么不能直接拿发行版的 `libcurl.a` 链接. ** 那是针对 glibc 编的, 和 musl 混在一起会得到一堆 `undefined reference to __libc_start_main`. 必须有一份用 -musl 工具链编出来的静态 libcurl —— 这就是 `tools/build-static-deps.sh` 干的事: +musl 工具链编出来的静态 libcurl —— 这就是 `tools/build-static-deps.sh` 干的事: 用 musl 把 zlib、OpenSSL、curl 依次编到 `build/static-prefix/`, 最后**真的链一个 最小程序**自检 (静态链接最常栽在依赖顺序和缺库上, 而那只在链接时暴露). -产物是 `ligent-static`, 和常规构建**互不干扰**:静态目标用自己的对象目录 +产物是 `ligent-static`, 和常规构建**互不干扰**: 静态目标用自己的对象目录 (`build/static/`), 因为那边的 `.o` 是 musl 编的, 混在一起会互相覆盖. | 变量 | 默认 | 说明 | @@ -202,13 +202,13 @@ musl 工具链编出来的静态 libcurl —— 这就是 `tools/build-static-de ### 把静态产物压到最小 静态产物是"拷过去就能跑"的发布件, 不是拿来调试的, 所以它的取舍和常规构建相反. -**6.24 MB → 3.29 MB (-47%, 几乎腰斩)**, 分两半, 缺一半效果就大打折扣: +**6.24 MB → 3.29 MB (-47%, 几乎腰斩)**, 分两半, 缺一半效果就大打折扣: **A. 链接我们自己的东西 (Makefile, `STATIC_RELEASE=1`)** | 开关 | 效果 | |---|---| -| `-s` | 4.92 MB. **单项最大**:`-g` 带进来的调试信息 (551 KB)加符号表共 1.31 MB | +| `-s` | 4.92 MB. **单项最大**: `-g` 带进来的调试信息 (551 KB)加符号表共 1.31 MB | | `-ffunction-sections -fdata-sections` + `--gc-sections` | 让链接器能按函数丢没被引用的段 | | `-fno-asynchronous-unwind-tables -fno-unwind-tables` | `.eh_frame` 本来 514 KB, C 代码用不上 | | `-Os` / `-g0` | 优先体积; 不生成反正要 strip 掉的调试信息 | @@ -216,14 +216,14 @@ musl 工具链编出来的静态 libcurl —— 这就是 `tools/build-static-de **B. 链接依赖库 (`tools/build-static-deps.sh`)** -4.84 MB → 3.29 MB. 这一段**必须改依赖的编译选项**才有用:`-ffunction-sections` +4.84 MB → 3.29 MB. 这一段**必须改依赖的编译选项**才有用: `-ffunction-sections` 是**编译期**的事, 依赖的 `.a` 不按函数分段的话, `--gc-sections` 只能整份整份地丢, 几乎没有效果 (实测只对我们自己的 `.o` 开这三件套, 产物反而大了 1.6 KB —— 分段本身 也有开销). 给 zlib/OpenSSL/curl 都加上 `-Os -ffunction-sections -fdata-sections -fno-*-unwind-tables` 之后, libcurl+OpenSSL 的 `.text` 从 **3.45 MB 掉到 2.41 MB** —— 静态归档里本来就有一大堆我们永远走不到的算法和协议实现. -`CFLAGS_COMMON` 里带的这几个开关是**体积优先**的默认值; 要回到调试友好版: +`CFLAGS_COMMON` 里带的这几个开关是**体积优先**的默认值; 要回到调试友好版: ```bash CFLAGS_COMMON="-O2" ./tools/build-static-deps.sh ``` @@ -233,36 +233,36 @@ CFLAGS_COMMON="-O2" ./tools/build-static-deps.sh 旧选项编出来的依赖, 症状是"我明明加了 `-Os`, 产物怎么一点没小" —— 和 Makefile 里 对象目录按配置分标签是同一个坑的两处. -顺手排掉的:**`ld.gold` 不是更小而是更大** (4.89 MB vs 4.86 MB), `--icf=all` +顺手排掉的: **`ld.gold` 不是更小而是更大** (4.89 MB vs 4.86 MB), `--icf=all` 相同代码折叠在这份代码上一点没省, 所以没换链接器. -**验证方式** (这些数字都是量出来的, 不是估的): +**验证方式** (这些数字都是量出来的, 不是估的): ```bash make static # → 3,292,568 字节 make static-test # 拿同一套 318 项 e2e 打静态产物 (而不是只看它能不能启动) ``` -TLS 单独验:本地起 `openssl s_server` 拿自签证书, 让静态产物连它 —— 它会报 -`SSL certificate problem: self-signed certificate`. 这个**失败**恰恰是成功的证据: +TLS 单独验: 本地起 `openssl s_server` 拿自签证书, 让静态产物连它 —— 它会报 +`SSL certificate problem: self-signed certificate`. 这个**失败**恰恰是成功的证据: 能走到"证书不被信任"说明 TCP、ClientHello、证书解析、证书链校验整条 OpenSSL 路径 都通了. 这也是静态二进制的真实 HTTPS 路径**第一次被实际验证** (依赖脚本的自检 只做 `curl_easy_init` + `setopt`, 从不发请求, 验不到这一层). -顺带发现 (既有限制, 与本次改动无关):**libcurl 库不读 `CURL_CA_BUNDLE`**, +顺带发现 (既有限制, 与本次改动无关): **libcurl 库不读 `CURL_CA_BUNDLE`**, 只有 `curl` 命令行工具读它 —— `CURLINFO_CAINFO` 显示它永远用编进去的 `/etc/ssl/certs/ca-certificates.crt`. 所以 ligent 只能用系统 CA 包, 想指定别的 得改成显式 `CURLOPT_CAINFO`. -几个刻意的取舍: +几个刻意的取舍: - **依赖列表交给 `curl-config --static-libs`**, 不手写 `-lssl -lcrypto -lz`. curl 最清楚自己链了哪些库、什么顺序; 手写的那串会过期, 顺序错了还链不上. -- **`-fno-pie` / `-no-pie`**:现代 gcc 默认编 PIE, 而静态链接一份非 PIC 的 `.a` +- **`-fno-pie` / `-no-pie`**: 现代 gcc 默认编 PIE, 而静态链接一份非 PIC 的 `.a` 会报 `relocation R_X86_64_32 ... can not be used when making a PIE object`. -- **不用 nghttp2**:静态构建只留 ligent 真正要的东西, 于是它说 HTTP/1.1 —— +- **不用 nghttp2**: 静态构建只留 ligent 真正要的东西, 于是它说 HTTP/1.1 —— 服务端都支持, 我们也不需要多路复用. -- **CA 路径编进去** (`/etc/ssl/certs`):静态二进制跑到别的机器上, 得知道去哪 +- **CA 路径编进去** (`/etc/ssl/certs`): 静态二进制跑到别的机器上, 得知道去哪 找根证书. ### 在容器里构建 (CI/CD) @@ -277,11 +277,11 @@ make docker-test # 拿同一套 318 项 e2e 打容器产物 加 `CONTAINER=docker` 即可 —— 两者的 `build`/`run`/`cp` 参数在这里是一样的. **为什么用 Alpine 做构建环境. ** 静态目标要一份用 musl 编出来的 libcurl, -而 Alpine 本身就是 musl 发行版:`apk add build-base` 得到的 gcc 直接就是 musl +而 Alpine 本身就是 musl 发行版: `apk add build-base` 得到的 gcc 直接就是 musl 目标 (`--target=x86_64-alpine-linux-musl`), `tools/build-static-deps.sh` 里 那套"找 musl-gcc、配 spec 文件"的麻烦在容器里根本不存在. -**最终镜像 3.49 MB, 里面只有两个文件**:静态二进制和 CA 证书. +**最终镜像 3.49 MB, 里面只有两个文件**: 静态二进制和 CA 证书. ``` $ podman export $(podman create ligent-static) | tar -t @@ -291,11 +291,11 @@ ligent 用 `scratch` 做运行阶段 —— 静态链接的产物连 libc 都不需要, 所以镜像里没有 shell、没有包管理器、没有任何可被利用的东西. CA 证书放在 -`/etc/ssl/certs/ca-certificates.crt` 是**必须**的:libcurl 用的是编进去的这个 +`/etc/ssl/certs/ca-certificates.crt` 是**必须**的: libcurl 用的是编进去的这个 路径 (见上一条), 放别处它读不到. 版本号、分支、哈希都由 build-arg 注入 —— 容器里没有 `.git` (`.dockerignore` -把它排除了), 脚本在里面**问不出**这些, 只能由外面算好传进去: +把它排除了), 脚本在里面**问不出**这些, 只能由外面算好传进去: ```bash podman build \ @@ -307,7 +307,7 @@ podman build \ `make docker-build` 会自动在本机算好传进去, 不用手写. -两个容易踩的点: +两个容易踩的点: - **`BUILD_EPOCH` 必须传完整的秒数, 不能只传日期. ** 它被当成 `SOURCE_DATE_EPOCH`, 而 `date(1)` 把 `2026-09-22` 解析成那天 **00:00:00 @@ -316,10 +316,10 @@ podman build \ - **分支也要传. ** 不传的话二进制里会是 `unknown`, 而那**不是事实** —— 源码来自哪个分支是外面知道的, 只是容器里看不到而已. -Dockerfile 里有自检:产物必须存在且可执行、必须真的是静态的、传了 VERSION 就 +Dockerfile 里有自检: 产物必须存在且可执行、必须真的是静态的、传了 VERSION 就 绝不能报回退版本号, 否则构建失败. 这几条都是为了挡住"链接器没报错 ≠ 产物可用". -**实测** (同一份源码, 本机 + 容器): +**实测** (同一份源码, 本机 + 容器): ```console $ ./ligent --version # 本机, Artix + gcc 16.2.1 @@ -329,11 +329,11 @@ $ podman run --rm ligent-static --version # 容器, Alpine 3.20 + gcc 13.2.1 ligent 2026.09.22-209538e.dirty (master, 2026-09-22 04:44:33) [GCC 13.2.1 20240309] on linux/x86_64 ``` -编译器那一段如实反映**实际用的**工具链 —— 这正是它值得记进版本号的原因: +编译器那一段如实反映**实际用的**工具链 —— 这正是它值得记进版本号的原因: 两个产物的行为可能因编译器版本不同而有细微差异, 排查时得知道是哪个编的. 容器产物同样过了**全套 318 项 e2e** (`make docker-test`). -### 版本号:CalVer + git 哈希 +### 版本号: CalVer + git 哈希 ```console $ ligent --version @@ -344,7 +344,7 @@ ligent 2026.09.22-209538e.dirty (master, 2026-09-22 04:44:33) [GCC 16.2.1 202608 │ │ │ └ 构建时间 (UTC) └ 编译器 └ 目标平台 │ │ └ 分支 (detached HEAD 时为 unknown) │ └ git 短哈希, .dirty 表示工作区有未提交改动 - └ CalVer:发布日 + └ CalVer: 发布日 ``` 前两段 (`2026.09.22-209538e.dirty`)是**版本号主体**, 也是下面几节引用它时 @@ -356,15 +356,15 @@ ligent 2026.09.22-209538e.dirty (master, 2026-09-22 04:44:33) [GCC 16.2.1 202608 | `2026.02.14-1a2b3c4` | tag 之后又提交了 | | `2026.02.14-1a2b3c4.dirty` | 工作区还有没提交的改动 | -分隔符用 `-` 而不是 `git describe` 传统的 `+g`:我们只用 git, 不存在"区分是 +分隔符用 `-` 而不是 `git describe` 传统的 `+g`: 我们只用 git, 不存在"区分是 哪种 SCM"的需求 (那正是 `g` 前缀存在的理由), 而少两个符号念起来也顺. -**编译器与平台由编译期宏判断, 不由构建脚本传. ** 这是有意的:那份信息是 +**编译器与平台由编译期宏判断, 不由构建脚本传. ** 这是有意的: 那份信息是 "这份代码被什么编出来、给谁跑的", 编译器自己最清楚. 构建脚本去猜反而多一个 出错点, 而且**交叉编译时会猜错** —— `uname()` 说的是"现在跑在哪", `__linux__` 说的是"编给谁跑", 静态产物会被拷到别的机器上跑, 该记的是后者. -三个编译器各有一条腿, 因为它们的宏并不统一: +三个编译器各有一条腿, 因为它们的宏并不统一: | 编译器 | 取法 | |---|---| @@ -372,10 +372,10 @@ ligent 2026.09.22-209538e.dirty (master, 2026-09-22 04:44:33) [GCC 16.2.1 202608 | clang | `__clang_version__`, **要掐掉尾随空格** (它自带一个) | | tcc | 没有 `__VERSION__`, 只有整数 `__TINYC__`, 按"去掉小数点当十进制读"还原 | -判断顺序也有讲究:**clang 也定义 `__GNUC__`** (它假装自己是 gcc 4.2), 所以 +判断顺序也有讲究: **clang 也定义 `__GNUC__`** (它假装自己是 gcc 4.2), 所以 clang 必须排在 gcc 前面, 否则会被认成 GCC. - (这里踩过两个坑, 都是实测三个编译器才发现的:tcc 的版本号按 `/100` 算会得到 + (这里踩过两个坑, 都是实测三个编译器才发现的: tcc 的版本号按 `/100` 算会得到 `tcc 9.2.8`, 正确是 `/1000` → `0.9.28`; clang 的版本串带尾随空格, 会输出成 `Clang 22.1.8 ]`. 单测 gcc 永远发现不了这两个. ) @@ -385,7 +385,7 @@ clang 必须排在 gcc 前面, 否则会被认成 GCC. **为什么还要带哈希. ** CalVer 的粒度是"天", 同一天可能发好几次; 而且用户手上 的二进制未必来自 tag (可能自己改了源码). 带上哈希, 报 bug 时能精确定位. -`.dirty` 标记尤其重要:不带它的话, 改过源码的构建和干净构建会报**同样的** +`.dirty` 标记尤其重要: 不带它的话, 改过源码的构建和干净构建会报**同样的** 版本号, 排查时会被完全误导. 未跟踪的新文件也算脏 —— 它可能正是被编进去的那个 源文件. @@ -395,7 +395,7 @@ clang 必须排在 gcc 前面, 否则会被认成 GCC. 不会出现版本号不一致, 也不必为发版多做一个改源码的提交. 这是 dunst / curl 这类项目的通行做法. -取值的优先级 (`tools/get-version.sh` 里有完整注释): +取值的优先级 (`tools/get-version.sh` 里有完整注释): | 场景 | 结果 | |---|---| @@ -404,14 +404,14 @@ clang 必须排在 gcc 前面, 否则会被认成 GCC. | 没有 tag (tarball) | 回退到源码文件的日期 | | 没有 `sh` 或全失败 | `0.0.0-unknown`, **绝不让构建挂掉** | -`SOURCE_DATE_EPOCH` 也认 (可复现构建的标准约定):设了它, 同一份源码在不同 +`SOURCE_DATE_EPOCH` 也认 (可复现构建的标准约定): 设了它, 同一份源码在不同 时间编出来的二进制是逐字节相同的, CI 上才能校验产物哈希. 版本号只在 `--version` 和 `ligent doctor` 里出现, 不参与任何协议交互. `doctor` 把它放在最上面 —— 报 bug 时第一件要问的就是"你用的是哪个版本". **实测过了** (Artix, musl-gcc / gcc 16.2.1, zlib 1.3.1 + OpenSSL 3.0.15 + -curl 8.11.1): +curl 8.11.1): ``` ligent-static: ELF 64-bit LSB executable, x86-64, statically linked @@ -420,9 +420,9 @@ ldd ligent-static → 不是动态可执行文件 TLS 真握手验过 (本地 s_server, 见上一节) ``` -一路上真正会绊人的坑 (脚本里都处理了, 记下来免得重踩): +一路上真正会绊人的坑 (脚本里都处理了, 记下来以免重踩): -1. **`musl-gcc` 不搜 `/usr/include`. ** 于是 OpenSSL 挂在内核 UAPI 头上: +1. **`musl-gcc` 不搜 `/usr/include`. ** 于是 OpenSSL 挂在内核 UAPI 头上: `fatal error: linux/mman.h: No such file or directory`. 要用 `-idirafter /usr/include` —— musl 自己的头优先、系统头兜底. 写成 `-I` 就反了, glibc 的头会盖过 musl 的. @@ -439,24 +439,24 @@ TLS 真握手验过 (本地 s_server, 见上一节) 5. **下载地址会过期. ** zlib 把旧版本挪到了 `/fossils/`. 脚本对每个包试多个 地址 (官网 / fossils / GitHub release), 全失败才退出并告诉你怎么改版本. 6. **路径里有非 ASCII 字符时, `pkg-config` 会吐出非法 UTF-8. ** - 这是最阴的一个:只在"仓库路径带中文"这类情况下才出现, 而症状离原因非常远. + 这是最阴的一个: 只在"仓库路径带中文"这类情况下才出现, 而症状离原因非常远. - curl 的 configure 挑 OpenSSL 时, 只要 `/lib/pkgconfig/openssl.pc` 存在, 就走 `pkg-config` 那条路 (`configure` 里的 `PKGTEST` 逻辑); - 而 configure 开头就是 autoconf 的标准开头 `LC_ALL=C; export LC_ALL`, **脚本外面设 `LC_ALL=C.UTF-8` 压不住它** (我试过, 没用); - pkg-config 在 C locale 下用 `isprint()` 判断字节要不要转义, 每个 ≥ 0x80 - 的字节都被判成"不可打印", 于是它给路径里每个非 ASCII 字节前面加一个反斜杠: + 的字节都被判成"不可打印", 于是它给路径里每个非 ASCII 字节前面加一个反斜杠: `-L/…/开发/…` → `-L/…/\开\发/…`. 反斜杠后面跟 `E5` 不是合法续字节, 所以那串是**非法 UTF-8**; - curl 把它写进 `curl-config`、`libcurl.pc` 和一堆 Makefile (实测 26 个文件). - 后果不只是难看:`make static` 的 recipe 里那句 `echo " 依赖: $LIBS"` 会把 + 后果不只是难看: `make static` 的 recipe 里那句 `echo " 依赖: $LIBS"` 会把 `curl-config --static-libs` 的输出原样打出来 → 进工具输出 → 进对话历史 → 进请求体 → 服务端以 `invalid unicode code point` **拒掉整个请求**. 而且坏 数据已经留在历史里, 之后每一次请求都失败, 只能丢掉整轮对话. - 修法是**在 configure 期间把 `.pc` 藏起来** (藏不是删, 编完放回去):curl + 修法是**在 configure 期间把 `.pc` 藏起来** (藏不是删, 编完放回去): curl 看不见 `openssl.pc` 就退回"直接用 `-L$prefix/lib`"那条路, 路径干干净净. 脚本末尾还有一道 `iconv` 往返自检, curl-config 输出不是合法 UTF-8 就直接退出. @@ -465,17 +465,17 @@ TLS 真握手验过 (本地 s_server, 见上一节) ## 2. 配置 API key -默认就是 DeepSeek:`https://api.deepseek.com/v1` + `deepseek-flash`, -密钥默认从 **`$DEEPSEEK_API_KEY`** 读取. 所以最简单的用法是: +默认就是 DeepSeek: `https://api.deepseek.com/v1` + `deepseek-flash`, +密钥默认从 **`$DEEPSEEK_API_KEY`** 读取. 所以最简单的用法是: ```bash export DEEPSEEK_API_KEY=sk-xxxxxxxx ligent "你好" ``` -### 配置文件:只有一个位置 —— 二进制旁边 +### 配置文件: 只有一个位置 —— 二进制旁边 -配置**只**从可执行文件所在目录读取, 文件名固定 `ligent.json`: +配置**只**从可执行文件所在目录读取, 文件名固定 `ligent.json`: ``` /path/to/ligent ← 可执行文件 @@ -490,16 +490,16 @@ $EDITOR ./ligent.json **不做用户级查找** (没有 `~/.config`、没有 `$XDG_CONFIG_HOME`、没有 `~/.ligent.json`), **不做系统级查找** (没有 `/etc`), **也不看当前工作目录**. -这样整个程序是自包含的:拷到别的机器、塞进 U 盘、丢进容器, 配置跟着走. +这样整个程序是自包含的: 拷到别的机器、塞进 U 盘、丢进容器, 配置跟着走. -**用软链接时, 跟着软链接走. ** 如果你把 ligent 软链到 `~/bin`: +**用软链接时, 跟着软链接走. ** 如果你把 ligent 软链到 `~/bin`: ```bash ln -s /opt/ligent/ligent ~/bin/ligent ``` 那么配置目录是 `~/bin`, 而不是 `/opt/ligent` —— 你放在哪、就认哪. -四种调用方式结果一致:绝对路径、相对路径、链接链 (取第一层)、以及经 `$PATH` +四种调用方式结果一致: 绝对路径、相对路径、链接链 (取第一层)、以及经 `$PATH` 调用 (`argv[0]` 里没有斜杠的情况). 实现上优先用 `argv[0]` (不解析最后一段软链接), 只在拿不到时才退回 @@ -514,18 +514,18 @@ config_file : /home/you/ligent/ligent.json (不存在, 使用内置默认值) `--print-config` 会显示规范位置, 所以不用猜它在哪找. -想临时换一份配置时有两个显式入口 (都不是"默认查找位置"): +想临时换一份配置时有两个显式入口 (都不是"默认查找位置"): ```bash ligent -c /path/to/other.json "..." # 命令行显式指定 LIGENT_CONFIG=/path/to/other.json ligent "..." ``` -整体优先级:**内置默认值 → 配置文件 → 环境变量 → 命令行参数** +整体优先级: **内置默认值 → 配置文件 → 环境变量 → 命令行参数** (`api_key` 按上面的规则细化). -配置文件里可以用 `${VAR}` 引用环境变量, 避免把密钥明文写进磁盘: +配置文件里可以用 `${VAR}` 引用环境变量, 避免把密钥明文写进磁盘: ```json { @@ -535,7 +535,7 @@ LIGENT_CONFIG=/path/to/other.json ligent "..." } ``` -想接别家服务, 只改 `base_url` 和 `model` 即可, 例如: +想接别家服务, 只改 `base_url` 和 `model` 即可, 例如: | 服务 | base_url | model 示例 | |---|---|---| @@ -544,7 +544,7 @@ LIGENT_CONFIG=/path/to/other.json ligent "..." | 本地 Ollama | `http://localhost:11434/v1` | `qwen2.5:14b` | | 本地 vLLM | `http://localhost:8000/v1` | 你部署的模型名 | -其他可选环境变量:`LIGENT_BASE_URL`、`LIGENT_MODEL`、`LIGENT_SYSTEM`、 +其他可选环境变量: `LIGENT_BASE_URL`、`LIGENT_MODEL`、`LIGENT_SYSTEM`、 `LIGENT_TEMPERATURE`、`LIGENT_MAX_TOKENS`、`LIGENT_THINKING`、 `LIGENT_REASONING_EFFORT`、`LIGENT_TIMEOUT`、`LIGENT_MAX_STEPS`、 `LIGENT_STREAM`、`LIGENT_TOOLS`. @@ -552,7 +552,7 @@ LIGENT_CONFIG=/path/to/other.json ligent "..." ### 思考模式与级别 推理模型可以先"想"一段再回答. DeepSeek 的思考模式**默认就是开着的, 默认级别 `high`**, -所以什么都不配也能用; 想控制就加这两项: +所以什么都不配也能用; 想控制就加这两项: ```json { @@ -561,14 +561,14 @@ LIGENT_CONFIG=/path/to/other.json ligent "..." } ``` -- `thinking`:`"on"` / `"off"` / `"auto"`, 也接受 `true` / `false`. +- `thinking`: `"on"` / `"off"` / `"auto"`, 也接受 `true` / `false`. `auto` (默认)表示**请求里根本不带这个字段**, 完全由服务端决定. 开启时下发 `{"thinking":{"type":"enabled"}}`, 关闭则是 `disabled`. -- `reasoning_effort`:`minimal` / `low` / `medium` / `high` / `xhigh` / `max` / `ultra`, +- `reasoning_effort`: `minimal` / `low` / `medium` / `high` / `xhigh` / `max` / `ultra`, 留空则不发. DeepSeek 会把它映射到 `low` / `high` / `max` 三档 (`minimal`/`low`→low, `medium`/`high`/`xhigh`→high, `max`/`ultra`→max). -对应的开关:`--thinking on|off|auto`、`--effort LEVEL`、 +对应的开关: `--thinking on|off|auto`、`--effort LEVEL`、 `LIGENT_THINKING`、`LIGENT_REASONING_EFFORT`, 以及 REPL 里的 ``` @@ -578,7 +578,7 @@ LIGENT_CONFIG=/path/to/other.json ligent "..." ``` **思考过程不会显示, 也不会进上下文** (只累积、不回调). 但它必须跟着 assistant -消息一起回传:DeepSeek 要求**请求带 `tools` 时把历史里的 `reasoning_content` 全带上**, +消息一起回传: DeepSeek 要求**请求带 `tools` 时把历史里的 `reasoning_content` 全带上**, 漏了下一轮直接回 400. ligent 默认开着工具, 所以这一条是硬要求 —— 流式和非流式 两条解析路径都会保存它. @@ -593,9 +593,9 @@ ligent.json 的 api_key ← 你写死在配置里的 DEEPSEEK_API_KEY / OPENAI_API_KEY ← 兜底, 且只在端点确实是那一家时才读 ``` -最后一条是刻意的:**厂商专属的环境变量只在"确实在用这家"时才生效**. +最后一条是刻意的: **厂商专属的环境变量只在"确实在用这家"时才生效**. -否则会出这种事:配置文件里写的是 OpenRouter 的 key, 而 shell 里残留的 +否则会出这种事: 配置文件里写的是 OpenRouter 的 key, 而 shell 里残留的 `DEEPSEEK_API_KEY` 把它悄悄顶掉 —— 结果 A 家的密钥被发给了 B 家, 对方回一个含糊的 401, 你明明配对了却怎么都调不通, 排查方向还会被带偏. (OpenRouter 对格式不对的 key 回的是 `Missing Authentication header`, @@ -611,10 +611,10 @@ ligent **不会**去猜 `DEEPSEEK_API_KEY`, 而是明确告诉你"未配置 API ### 界面语言 界面文案 (帮助、错误、诊断、确认提示、等待动画)有中英两份. **发给模型的 -那些不在这里**:系统提示词、工具描述、工具回执只有中文 —— 那是模型读的信息, +那些不在这里**: 系统提示词、工具描述、工具回执只有中文 —— 那是模型读的信息, 跟界面语言是两件事 (见 §9). -优先级:`--lang` > `$LIGENT_LANG` > `LC_ALL` > `LC_MESSAGES` > `LANG`. +优先级: `--lang` > `$LIGENT_LANG` > `LC_ALL` > `LC_MESSAGES` > `LANG`. 都没设、或者认不出来 (`fr_FR` 这类), 按英文处理 —— `LANG=C` 也是英文, 这跟 POSIX 惯例一致. @@ -628,27 +628,27 @@ export LIGENT_LANG=en # 这个 shell 里都用英文 `tests/test_i18n.c` 都会逐条报出来 —— 漏翻这件事必须由机器发现, 不能靠人眼扫. `--print-config` 会打出当前生效的 `language`. -哪些**故意不译** (都不在这里, 也不是漏翻): +哪些**故意不译** (都不在这里, 也不是漏翻): -- **给模型看的**:系统提示词、工具描述、工具回执, 以及插进对话历史的说明 +- **给模型看的**: 系统提示词、工具描述、工具回执, 以及插进对话历史的说明 (`/include` 的文本块头、图片降级说明、`/shell` 的操作记录). 切成英文之后 模型读到的仍然是中文 —— 想换就得改提示词本身. -- **机器读的格式**:`/log` 导出里 `请求:` / `----- 响应头 -----` 这些标记 +- **机器读的格式**: `/log` 导出里 `请求:` / `----- 响应头 -----` 这些标记 (`tests/replay.py` 按它们切分往返)、`<已隐藏>` 这种脱敏占位符、 `cap_name()` 这类只给日志和断言用的状态常量. -`/tools` 列出来的那张表**不是**这一类:工具名后面那行是纯 UI, 文案在表里 +`/tools` 列出来的那张表**不是**这一类: 工具名后面那行是纯 UI, 文案在表里 (`tools.ui.*`), 跟着界面语言走; 发给模型的那份说明仍然只有中文. -`src/json.c` 是唯一"一份代码、两种语气"的地方, 用两个入口分开: +`src/json.c` 是唯一"一份代码、两种语气"的地方, 用两个入口分开: - `json_parse_ui()` —— 报错跟随界面语言. 配置、存档、服务端响应这些给人看的 路径用它, 用户看到的是 `line 1, column 3: expected an object key (a string)`. -- `json_parse()` —— 报错固定中文. 解析**工具参数**用它:那条结论会被原样回填给 +- `json_parse()` —— 报错固定中文. 解析**工具参数**用它: 那条结论会被原样回填给 模型, 属于"给模型看的信息", 不随界面语言变 (`agent.c` 只在这一处用). 两种语气共用一份文案表 (`json.err.*`), 只是各取一列. 这几条是 printf 模板而 -不是 `{name}`, 所以 `tests/test_i18n.c` 另有一遍机械比对:两种语言的转换符 +不是 `{name}`, 所以 `tests/test_i18n.c` 另有一遍机械比对: 两种语言的转换符 数量与类型必须逐字一致 —— `%zu` 写成 `%d` 会让 `printf` 读错参数. ## 3. 用法 @@ -672,14 +672,14 @@ ligent --print-config # 查看最终生效的配置 (key 已打码) -s, --system TEXT 覆盖 system 提示词 -t, --temperature N 采样温度 --max-tokens N 单次回复最大 token 数 - --thinking MODE 思考模式:on / off / auto (默认 auto = 由服务端决定) - --effort LEVEL 思考级别:minimal/low/medium/high/xhigh/max/ultra + --thinking MODE 思考模式: on / off / auto (默认 auto = 由服务端决定) + --effort LEVEL 思考级别: minimal/low/medium/high/xhigh/max/ultra --max-steps N 单轮任务里模型最多往返几次 (默认不限, 0 = 不限) --timeout N 请求超时秒数 --no-stream 关闭流式输出 --no-tools 禁用工具, 纯对话 --no-probe 首个请求前不探测模型能力 (不额外发请求) - --lang LANG 界面语言:auto / en / zh (默认 auto) + --lang LANG 界面语言: auto / en / zh (默认 auto) -q, --quiet 不打印工具的输出内容 -v, --verbose 打印 HTTP 细节 --color/--no-color 强制开关彩色输出 (同时控制 Markdown 渲染) @@ -690,7 +690,7 @@ ligent --print-config # 查看最终生效的配置 (key 已打码) ### 子命令 ``` -ligent doctor [--offline] 体检:配置、密钥、端点、网络逐环检查 +ligent doctor [--offline] 体检: 配置、密钥、端点、网络逐环检查 ``` `--offline` 跳过联网检查. @@ -701,7 +701,7 @@ ligent doctor [--offline] 体检:配置、密钥、端点、网络逐环 /help 帮助 /model [NAME] 切换模型; 不带名字则在线选 /exit, /quit 退出 /system TEXT 重设提示词并清空历史 /reset 清空对话历史 /cls 清屏 (不动对话历史) -/meta 元编辑模式:让模型帮你改这段历史 +/meta 元编辑模式: 让模型帮你改这段历史 /think [开关|级别] 思考模式与级别 /tools 列出可用工具 (已按能力隐藏的不列) /probe 现在测一次模型能力 (图片/工具); 无视 --no-probe 与缓存 /config 显示当前配置 /cd PATH 切换工作目录 @@ -713,7 +713,7 @@ ligent doctor [--offline] 体检:配置、密钥、端点、网络逐环 /back 删除最近一轮 (破坏性) /unback 撤销 /back ``` -`/reset` (别名 `/clear`)和 `/cls` 是两回事, 别敲混: +`/reset` (别名 `/clear`)和 `/cls` 是两回事, 别敲混: | | `/reset`、`/clear` | `/cls` | |---|---|---| @@ -721,18 +721,18 @@ ligent doctor [--offline] 体检:配置、密钥、端点、网络逐环 | 影响模型看到的上下文 | **是**, 历史没了 | 否, 一字不动 | | 可恢复 | `/unback` 不行, 只能 `/import` 旧存档 | 本来就没删东西 | -`/cls` 发的是 `\033[2J\033[3J\033[H`, 和 `clear(1)` 一致:可见区和回滚缓冲 +`/cls` 发的是 `\033[2J\033[3J\033[H`, 和 `clear(1)` 一致: 可见区和回滚缓冲 一起清. 所以清完之后往上滚也看不到旧内容了 —— 想找回来看 `/import` 那份存档. -### `/model`:不带参数就在线挑 +### `/model`: 不带参数就在线挑 -`/model NAME` 直接换. **不带参数**时会拉一次在线列表、打出来, 然后问你: +`/model NAME` 直接换. **不带参数**时会拉一次在线列表、打出来, 然后问你: ``` ligent> /model 正在获取模型列表 (https://api.deepseek.com/v1/models)... -服务端列出了 3 个模型: +服务端列出了 3 个模型: 1) deepseek-flash <- 当前 2) deepseek-chat @@ -742,18 +742,18 @@ ligent> /model 模型已切换为 deepseek-chat ``` -输入行里**既可以填序号, 也可以直接写模型名**. 后者是特意留的:列表里没有的 +输入行里**既可以填序号, 也可以直接写模型名**. 后者是特意留的: 列表里没有的 东西恰恰很常见 —— 内部模型、灰度模型、服务端没列出来的别名, 都得能填进去. -列表本身也是诚实的:顺序保持服务端给的 (不去替它排序), 重复项和缺 `id` 的 +列表本身也是诚实的: 顺序保持服务端给的 (不去替它排序), 重复项和缺 `id` 的 坏条目会被跳过. 序号越界会明确报出来 (`序号 9 超出范围 (1..3), 已取消. `), `Ctrl-C` 或直接回车取消. 拉列表走的是标准的 `GET {base_url}/models`. **拿不到就明说是什么情况**, -而不是笼统地报一句失败: +而不是笼统地报一句失败: ``` -拿不到模型列表:服务端返回了空的模型列表 (HTTP 200)—— 这个账号下可能没有可用的模型 +拿不到模型列表: 服务端返回了空的模型列表 (HTTP 200)—— 这个账号下可能没有可用的模型 可以直接用 /model <名字> 指定, 列表拿不到不影响这条. ``` @@ -763,16 +763,16 @@ ligent> /model > **为什么不做成方向键菜单. ** 试过一版, 撤了. 那种界面要靠 ANSI 反复重画, > 会把 stdout 搅乱 (重定向、日志、粘贴都跟着遭殃); 更要命的是它**只让你在 > 给定的选项里挑**, 恰好堵死了"填一个列表里没有的模型名"这条最需要的路. -> 所以这里是 `/import` 那一套:列表打在上面, 输入行是普通的一行, 随便写. +> 所以这里是 `/import` 那一套: 列表打在上面, 输入行是普通的一行, 随便写. -### `/include`:把文件附带给模型 +### `/include`: 把文件附带给模型 模型看不到你的文件, 除非你给它. `/include` 把文件**暂存**起来, 跟着下一条消息 -一起发出去 —— 因为附带文件的意义几乎总是配合一个提问: +一起发出去 —— 因为附带文件的意义几乎总是配合一个提问: ``` ligent> /include src/*.c -已附带 3 个文件: +已附带 3 个文件: src/agent.c src/json.c src/util.c @@ -781,13 +781,13 @@ ligent> 这三个文件里有没有重复的逻辑? ``` 不带参数则进**交互式挑选**, 可以逐层进目录 (这就是"递归"的走法 —— glob 本身 -是 shell 语义, `*` 不会跨目录): +是 shell 语义, `*` 不会跨目录): ``` ligent> /include 进入交互式选择. 序号进目录 / 选中文件, 也可以直接敲 glob 或路径, 回车结束. -当前目录:/home/you/proj +当前目录: /home/you/proj 1) src/ (目录) 2) README.md 12.3 KB @@ -797,21 +797,21 @@ ligent> /include 已选 0 个. 选择 > 1 ``` -同样是 `/import` 那种普通输入行, 不是方向键菜单:**序号、`..`、glob、路径都能 +同样是 `/import` 那种普通输入行, 不是方向键菜单: **序号、`..`、glob、路径都能 敲**, 空格分隔不支持的 (一次一个模式). `/include clear` 清空暂存, `/reset` 也会清. -几件事是有意为之的: +几件事是有意为之的: - **认内容, 不认扩展名. ** 叫 `.png` 的文本文件当文本带; 反过来, 内容真是图片 的才走图片那条路. - **图片不进历史. ** 图片存成本机备份, 历史里只放一个几十字节的引用, 发请求时 才展开成 base64 —— 和 `read_image` 完全同一条路. - **图片要先确认模型能看图. ** 遇到图片时才会去跑能力探测 (纯文本的附带不该为 - 了"可能出现的图"先花掉两个探测请求), 明确不支持就跳过并说明: + 了"可能出现的图"先花掉两个探测请求), 明确不支持就跳过并说明: ``` - 跳过 src/logo.png:当前模型不支持图片输入 + 跳过 src/logo.png: 当前模型不支持图片输入 没有附带任何文件 (1 个被跳过). ``` @@ -824,10 +824,10 @@ ligent> /include 输入行尾加 `\` 可以续行写多行任务. `Ctrl-C` 中断当前请求 (不会退出程序), `Ctrl-D` 退出. -### 输入历史:像 bash 一样跨进程 +### 输入历史: 像 bash 一样跨进程 你在 `ligent>` 敲过的东西会**实时**写进一个历史文件, 新的 ligent 实例启动时读回来, -上下方向键就能翻, 跟 bash 一样: +上下方向键就能翻, 跟 bash 一样: ``` ligent> ↑ → 上一条 @@ -835,18 +835,18 @@ ligent> ↑ ↑ → 再上一条 ligent> ↓ → 回来一条 ``` -- **落盘位置**:`<二进制目录>/ligent.history` (和 `ligent.json`、`ligent.archive/` - 同一个原则:只放二进制旁边). -- **实时**:每提交一条就 append 并 flush, 所以另一个终端里的 ligent (或者另一个 +- **落盘位置**: `<二进制目录>/ligent.history` (和 `ligent.json`、`ligent.archive/` + 同一个原则: 只放二进制旁边). +- **实时**: 每提交一条就 append 并 flush, 所以另一个终端里的 ligent (或者另一个 标签页)马上就能翻到, 不用等谁退出. -- **权限 0600**:历史里可能顺手敲过密钥, 不给同机其他用户读的机会. -- **一条输入 = 一条历史**:`\` 续行写出来的多行任务算**一条**, 上下键整条召回. -- **上限 1000 条**:启动时只读最新的 1000 条, 文件更长就就地截短, 不会无限增长. -- **连续重复不记**:和 bash 的 `ignoredups` 一个意思. -- **只记 REPL 里敲的**:单次任务模式 (`ligent "任务"`)的参数不算 —— 那不是 +- **权限 0600**: 历史里可能顺手敲过密钥, 不给同机其他用户读的机会. +- **一条输入 = 一条历史**: `\` 续行写出来的多行任务算**一条**, 上下键整条召回. +- **上限 1000 条**: 启动时只读最新的 1000 条, 文件更长就就地截短, 不会无限增长. +- **连续重复不记**: 和 bash 的 `ignoredups` 一个意思. +- **只记 REPL 里敲的**: 单次任务模式 (`ligent "任务"`)的参数不算 —— 那不是 "在提示符前敲的". `/shell` 里执行的命令也不算, 那是另一个 shell 的历史. -开关和覆盖: +开关和覆盖: ```bash ligent --no-history # 本次不读也不写 @@ -857,14 +857,14 @@ export LIGENT_HISTORY=/tmp/my.history # 同上, 环境变量 文件是一条历史一行. 条目里的反斜杠和换行会被转义 (`\` → `\\`, 换行 → `\n`), 保证一条占一行且能原样读回; 不含反斜杠的普通命令在文件里与输入完全一致. -### `/shell`:在子进程里干活 +### `/shell`: 在子进程里干活 ``` ligent> /shell 进入交互式 shell, 退出后整段会话记入上下文 ligent> /shell git log --oneline -5 带参数则直接执行, 不进入 shell ``` -交互式那条路用**伪终端**做双向搬运:子进程以为自己接在真终端上, 所以行编辑、 +交互式那条路用**伪终端**做双向搬运: 子进程以为自己接在真终端上, 所以行编辑、 颜色、Tab 补全、Ctrl-C、作业控制都照常工作; 我们在外面旁听并把输出记下来. ``` @@ -879,12 +879,12 @@ ligent> /shell 录制进上下文的内容已经清理过 —— 去掉 ANSI 控制序列、把 CRLF 归一成 LF、 丢掉退格, 否则模型看到的是一堆 `^[[32m`. 长度上限 100KB. -清理是**跨读取块**做的:转义序列被 `read()` 从中间切断也能接上, 不会串味. +清理是**跨读取块**做的: 转义序列被 `read()` 从中间切断也能接上, 不会串味. **全屏程序的内容完全不记进上下文. ** `nano`、`vi`、`vim`、`nvim`、`less`、`top` 这类程序会切到终端的"备用屏幕", 这段画面照常显示在你眼前, 但记录里只剩你敲的 命令行、下一行提示符, 以及退出编辑器之后真实的输出 —— 和人眼在终端里看到的 -一模一样. 中间留一对标记: +一模一样. 中间留一对标记: ``` [全屏程序运行中, 画面内容已省略] @@ -907,7 +907,7 @@ ANSI 兼容的, `TERM=dumb` 属于谎报. 补上之后, 即使是 `TERM=dumb`, ` **记入上下文但不会自动提问**, 你想问什么再问. 这样你在 shell 里翻了一圈之后, 可以直接说"刚才那个报错怎么修". -选 shell 的顺序是 `bash → ash → dash → sh`. 刻意**不看 `$SHELL`**: +选 shell 的顺序是 `bash → ash → dash → sh`. 刻意**不看 `$SHELL`**: 用户环境里的 `$SHELL` 可能是 xonsh 之类交互式外壳, 在容器或受限环境里 经常起不来, 反而把 `/shell` 弄坏. @@ -934,7 +934,7 @@ ligent> /back 删除最近一轮 (从最后一条 user 消息到末尾, 整 ligent> /unback 撤销, 把删掉的装回去 ``` -是**按轮**删的:模型调用的工具与结果必须整组一起走, 只删中间几条会让服务端 400. +是**按轮**删的: 模型调用的工具与结果必须整组一起走, 只删中间几条会让服务端 400. `/unback` 只在 `/back` 之后没有新对话时可用 —— 一旦你又问了什么, 就没有 "撤销"这一说了, 会明确拒绝而不是悄悄恢复到一半状态. @@ -948,7 +948,7 @@ ligent> /yolo 临时切换写操作确认 (只影响本次会话, 不进配 ### 写操作确认 (默认开启) 任何**会改动磁盘或执行命令**的工具 (`run_command` / `write_file` / `edit_file`) -在执行前都会先说明要做什么, 然后问一句: +在执行前都会先说明要做什么, 然后问一句: ``` 即将执行 run_command @@ -956,7 +956,7 @@ ligent> /yolo 临时切换写操作确认 (只影响本次会话, 不进配 继续?(Y/N/Always/neVer) ``` -四种回答: +四种回答: | 回答 | 含义 | |---|---| @@ -967,16 +967,16 @@ ligent> /yolo 临时切换写操作确认 (只影响本次会话, 不进配 `Always` / `neVer` 的作用域是**这一轮** —— 一条用户消息引发的那串工具调用. 下一条消息进来时自动复位, 所以它不会像 `/yolo` 那样"一直生效到你关掉", -也不会写进配置. 选了哪个都会当场提示你: +也不会写进配置. 选了哪个都会当场提示你: ``` 继续?(Y/N/Always/neVer) A 本轮后续的改动都不再询问. ``` -拒绝不等于失败:上层会把"用户拒绝了"作为工具结果回填给模型, 让它换个做法, +拒绝不等于失败: 上层会把"用户拒绝了"作为工具结果回填给模型, 让它换个做法, 而不是让整轮对话就此中断. 选了 `neVer` 的话, 回填的话会更硬 ("不要再尝试改动, -直接说明你的结论"), 免得模型一个接一个地试. +直接说明你的结论"), 以免模型一个接一个地试. 只读工具 (`read_file` / `list_dir`)不打扰. @@ -984,25 +984,25 @@ ligent> /yolo 临时切换写操作确认 (只影响本次会话, 不进配 > 此时直接按 Y 处理、不显示提示. 所以 `echo 任务 | ligent` 里的确认实际上是 > 不生效的 —— 那条路径要靠 `--no-confirm` 的对面, 也就是别指望它兜底. -关掉:`--no-confirm` / `LIGENT_CONFIRM=0` / 配置项 `confirm: false`. +关掉: `--no-confirm` / `LIGENT_CONFIRM=0` / 配置项 `confirm: false`. ### 自动存档 (默认开启) -在三个时间点自动把当前对话存进 `ligent.archive/`: +在三个时间点自动把当前对话存进 `ligent.archive/`: - **退出**时 (`/exit`、`Ctrl-D`、单次任务结束) -- **`/reset` 前** —— 免得清空历史时把刚才那段丢掉 -- **`/import` 前** —— 免得载入别的存档时覆盖掉当前的 +- **`/reset` 前** —— 以免清空历史时把刚才那段丢掉 +- **`/import` 前** —— 以免载入别的存档时覆盖掉当前的 只在"有内容"且"自上次存档后又变了"时才写, 所以不会产生一堆重复文件; 只有 system 一条的空对话不会留下存档. 关掉用 `--no-auto-save`. -### `/meta`:让模型帮你改对话历史 +### `/meta`: 让模型帮你改对话历史 `/cls` 清屏、`/reset` 清空、`/back` 退一轮 —— 这些都是粗粒度的. 真正难的是 "把第 3 轮里我那句'用 Python'删掉"、"第 5 轮助手那个结论是错的, 改掉". -`/meta` 就是干这个的:进入 `meta>` 子提示符, 那边是**另一个 AI**, 它的工作 +`/meta` 就是干这个的: 进入 `meta>` 子提示符, 那边是**另一个 AI**, 它的工作 对象不是继续对话, 而是**编辑这段历史**. ``` @@ -1015,16 +1015,16 @@ meta> 第 3 轮里我说的"用 Python"删掉 {/} read_messages {"from":5,"to":7} {/} replace_message {"index":5,"content":"帮我写个脚本"} 即将执行 replace_message - #5 的正文改成:帮我写个脚本 + #5 的正文改成: 帮我写个脚本 继续?(Y/N/Always/neVer) -[meta] 已改写 #5 (user):28 -> 15 字节. +[meta] 已改写 #5 (user): 28 -> 15 字节. meta> /exit -退出 meta 模式. 本次共改动 1 次, meta 自身消耗:3 次调用, 1240 输入 / 86 输出 token. -这段对话已被编辑过:下次 /export 会另起一根, 名字形如 <原名>_m<3位>. +退出 meta 模式. 本次共改动 1 次, meta 自身消耗: 3 次调用, 1240 输入 / 86 输出 token. +这段对话已被编辑过: 下次 /export 会另起一根, 名字形如 <原名>_m<3位>. ``` -meta 里的命令只有四个:`/exit` `/diff` (看当前历史, 只读)`/undo` `/help`. +meta 里的命令只有四个: `/exit` `/diff` (看当前历史, 只读)`/undo` `/help`. 其余输入都是给 meta 模型的指令. #### 和主模型完全隔离 @@ -1035,7 +1035,7 @@ meta 会话有自己的 system 提示词、自己的工具表, 分发走 `Agent` | 工具 | 作用 | 参数里带整段正文 | |---|---|---| -| `list_rounds [from,to]` | 轮目录:轮号、消息编号范围、条数、字节数、预览 | 否 | +| `list_rounds [from,to]` | 轮目录: 轮号、消息编号范围、条数、字节数、预览 | 否 | | `read_messages from,to` | 读指定消息的全文 (含工具调用参数) | 否 | | `replace_message index,content` | 改写某条消息的正文 | 是 | | `delete_messages indices` | 删若干条 (只限可安全单删的) | 否 | @@ -1045,13 +1045,13 @@ meta 会话有自己的 system 提示词、自己的工具表, 分发走 `Agent` #### 结构安全是硬约束 -对话历史不是一袋独立消息:`assistant` 带 `tool_calls` 时, 后面必须紧跟 -`tool_call_id` 一一对应的 `tool` 消息, 否则下一轮请求直接 400. 所以: +对话历史不是一袋独立消息: `assistant` 带 `tool_calls` 时, 后面必须紧跟 +`tool_call_id` 一一对应的 `tool` 消息, 否则下一轮请求直接 400. 所以: - `delete_messages` 只接受 **user 和不带 tool_calls 的 assistant**; 碰结构消息 一律拒绝, 并让模型改用 `delete_rounds` - 一批删除**先整体校验再动手** —— 宁可一条不改, 也不要改一半 -- 动作粒度以"轮"为单位:user → 中间所有工具往返 → 最终回答 +- 动作粒度以"轮"为单位: user → 中间所有工具往返 → 最终回答 - 删掉某轮的提问时, 工具会提醒"那轮剩下的回答现在没有对应的提问了" #### 四道安全网 @@ -1063,7 +1063,7 @@ meta 会话有自己的 system 提示词、自己的工具表, 分发走 `Agent` #### 改过之后另起一根 -只要成功改过, 下次 `/export` (或退出时的自动存档)就是**新根**: +只要成功改过, 下次 `/export` (或退出时的自动存档)就是**新根**: 名字 `<原名>_m<3位>`, `parent` 记空. ``` @@ -1079,37 +1079,37 @@ meta 自身的 token 消耗单独汇报, 不混进主对话的 `/usage`. #### 它不是压缩工具 -meta 模型的提示词里写着:**用户没要求压缩就不要压缩 —— 压缩会丢证据**. -它的价值在精修:删掉说错的前提、去掉被推翻的方案、改掉助手那次自信但错误的 +meta 模型的提示词里写着: **用户没要求压缩就不要压缩 —— 压缩会丢证据**. +它的价值在精修: 删掉说错的前提、去掉被推翻的方案、改掉助手那次自信但错误的 结论, 让这段历史不再诱导助手重蹈覆辙. > ⚠️ 让 AI 改自己的历史, 等于允许它改写"发生过什么". 排查问题时如果关键证据 > 被"压掉"就麻烦了 —— 所以默认不压缩、每步要确认、改动可撤销、原版有存档. -### 等待时的动画:两段标签 +### 等待时的动画: 两段标签 模型不输出思考过程是有意的, 但等待期间屏幕完全静止, 和"卡死了"看不出区别. -所以等待时会在 **stderr** 上转一个灰色的动画, 并且**按等待的性质分两段**: +所以等待时会在 **stderr** 上转一个灰色的动画, 并且**按等待的性质分两段**: ``` 等待响应.... 请求发出去了, 但一个字节都还没回来 (网络、排队、服务端没醒) 思考中...... 思维链开始流回来了, 模型确实在推理 ``` -这个区分是有用的:同样是等了 30 秒, "一直卡在 `等待响应`"和"一直在 `思考中`" +这个区分是有用的: 同样是等了 30 秒, "一直卡在 `等待响应`"和"一直在 `思考中`" 是两种完全不同的处境 —— 前者多半该去查网络或端点, 后者只是模型想得久. -切换的时机是**收到第一片 `reasoning_content`**, 不是在发出请求时猜的. 所以: +切换的时机是**收到第一片 `reasoning_content`**, 不是在发出请求时猜的. 所以: - 关掉思考 (`thinking: off`)或者用不支持思考的模型 → 只会看到 `等待响应`. - 非流式 (`--no-stream`)拿不到任何增量 → 也只会看到 `等待响应`. - 一收到**正文**动画立刻停并擦掉 —— 那时已经有东西可看了. -计时是按阶段算的:标签一变就从 0 重新计. 超过 5 秒会把秒数亮出来 +计时是按阶段算的: 标签一变就从 0 重新计. 超过 5 秒会把秒数亮出来 (`等待响应.... (12s)`), 进一步和卡顿区分. 只在 stderr 是终端时才转, 所以重定向或接管道时不会污染输出. -### 对话存档:文件名就是谱系 +### 对话存档: 文件名就是谱系 ``` ligent> /export 把当前对话存下来 @@ -1117,10 +1117,10 @@ ligent> /import 列出全部存档, 选一份载入 ligent> /import a1b2c3d4_a9x 按名字直接载入 (支持唯一前缀) ``` -存档放在**二进制所在目录**下的 `ligent.archive/` —— 和配置同一个原则: +存档放在**二进制所在目录**下的 `ligent.archive/` —— 和配置同一个原则: 整个程序连同它的数据都待在一个目录里, 拷走即走. -**命名不做时间戳, 而是把分支关系写进文件名**: +**命名不做时间戳, 而是把分支关系写进文件名**: ``` a1b2c3d4 从零开始的一段对话 @@ -1132,7 +1132,7 @@ a1b2c3d4_q4k 从 a1b2c3d4 分出的另一支 (兄弟) 新会话第一次存档时随机取 8 位小写字母数字作为根; 之后每存一次, 就在**当前所在的节点**后面接 `_` 加 3 位随机字符. -`/import` 之前那次保底存档会统一加上 `tmp_` 前缀: +`/import` 之前那次保底存档会统一加上 `tmp_` 前缀: ``` tmp_mimgkjex 换掉当前对话之前先留的一手 @@ -1154,12 +1154,12 @@ ligent.archive/ mvd2bl1h_q4k 另一支 ``` -**长度上限**:完整文件名 (含 `.json`)不超过 240 字符. 接一级需要 4 个字符, +**长度上限**: 完整文件名 (含 `.json`)不超过 240 字符. 接一级需要 4 个字符, 所以大约能到 58 层. 再往下接就**丢掉谱系、重新起一个 8 位根** (名字撑爆文件系统总不是个办法). 存档内容仍是自描述的 JSON, 并把 `name` 与 `parent` 也写进去 —— -这样即使文件被改名、或者谱系因超长而重置, 也能从内容里看出它从哪来: +这样即使文件被改名、或者谱系因超长而重置, 也能从内容里看出它从哪来: ```json { @@ -1173,14 +1173,14 @@ ligent.archive/ } ``` -`/import` 载入后会问一句要不要把这段历史打出来: +`/import` 载入后会问一句要不要把这段历史打出来: ``` 已载入 mvd2bl1h_cpc (7 条消息). 之后 /export 会存成 mvd2bl1h_cpc_xxx 是否输出这段历史的内容?(Y/n) ``` -回车 (默认 Y)就**完整**逐条打出来, 带角色标签和颜色: +回车 (默认 Y)就**完整**逐条打出来, 带角色标签和颜色: ``` -- 1/7 系统 -- @@ -1200,7 +1200,7 @@ ligent.archive/ [exit code: 0] ``` -"完整"是字面意思:**正文不截断、工具参数不省略、总量不封顶、`system` +"完整"是字面意思: **正文不截断、工具参数不省略、总量不封顶、`system` 提示词也全文展开**. 都回答了 Y, 就该看到全部 —— 截一半反而会让人以为历史里 本来就这么点东西. `tool_call_id` 是特意标出来的, 否则一堆工具输出看不出 谁在回应谁. @@ -1208,7 +1208,7 @@ ligent.archive/ 回 `n` 就只装进上下文、屏幕保持干净. 之后想随时再看一遍, 敲 `/history` —— 它打的就是同一份东西 (当前这段对话, 一条不落). -两点行为约定:存档里的 `system` 消息不会被带进来 (否则载入旧存档会把你现在 +两点行为约定: 存档里的 `system` 消息不会被带进来 (否则载入旧存档会把你现在 的提示词换掉), 当前生效的那条保留; 载入后对话开始时间重置. 空对话不会产生 存档. `/reset` 会断开谱系, 下一次存档另起一个根. @@ -1217,7 +1217,7 @@ ligent.archive/ ### 先跑一次体检 -配置调不通时, **先别猜**: +配置调不通时, **先别猜**: ```bash ligent doctor @@ -1238,20 +1238,20 @@ ligent doctor 连通性 [v] 可连接, TLS 校验通过 HTTP 状态 307 重定向 [x] 端点跳转到 https://example.invalid/v1/chat/completions - ligent 不自动跟随重定向:跨源跳转会丢掉 Authorization 头, + ligent 不自动跟随重定向: 跨源跳转会丢掉 Authorization 头, 服务端只会回 401 "Missing Authentication header", 看起来像是密钥没带, 实际是 URL 跳了. 把 base_url 直接改成上面那个地址即可. ``` -它专门盯着几类"报错信息会把人带偏"的问题:配置被残留环境变量顶掉、 +它专门盯着几类"报错信息会把人带偏"的问题: 配置被残留环境变量顶掉、 密钥与服务商不匹配、端点重定向导致伪 401、配置文件语法错误. -退出码:有问题为 1, 否则 0. +退出码: 有问题为 1, 否则 0. > doctor 在**配置本身加载失败时也能跑** —— 那正是最需要它的时刻. ### 行编辑 -REPL 自带行编辑器 (`src/lineedit.c`), 不依赖 readline, 也不用终端的行规程: +REPL 自带行编辑器 (`src/lineedit.c`), 不依赖 readline, 也不用终端的行规程: | 按键 | 作用 | |---|---| @@ -1264,18 +1264,18 @@ REPL 自带行编辑器 (`src/lineedit.c`), 不依赖 readline, 也不用终端 | `Ctrl-D` | 空行时退出; 行非空时删一个字符 | | **粘贴多行** | 整段插入, **不会被逐行执行** | -粘贴用 bracketed paste (`\033[?2004h`)实现:终端会把粘贴内容和真实按键区分开, +粘贴用 bracketed paste (`\033[?2004h`)实现: 终端会把粘贴内容和真实按键区分开, 收到粘贴块就整段放进缓冲区, 里面的换行不再触发提交. 所以可以直接把一段 多行需求、一个代码片段、一段日志粘进来, 编辑好再回车, 它会作为**一条**消息发出. `Ctrl+Shift+V`、中键、菜单里的粘贴走的都是这条路. **粘贴进来的内容会被清洗一遍. ** 因为 `read_paste()` 收的是终端给的原始字节、 -不经按键分类, 里面夹着控制字符就会让"编辑器的算盘"和"终端实际显示"对不上: +不经按键分类, 里面夹着控制字符就会让"编辑器的算盘"和"终端实际显示"对不上: | 字符 | 不清洗会怎样 | 清洗后 | |---|---|---| | `\t` | 终端按制表位 (8 的倍数)展开, 编辑器只当 1 列 —— 粘一段带缩进的代码, **光标从此偏掉, 之后每敲一个字都在错位置上** | 就地展开成空格, 缓冲区里不留制表符 | -| `ESC` | 被终端当成转义序列**真的执行掉**:粘贴内容能改颜色、甚至清屏 | 连同后面整条序列一起丢弃 | +| `ESC` | 被终端当成转义序列**真的执行掉**: 粘贴内容能改颜色、甚至清屏 | 连同后面整条序列一起丢弃 | | `\r` | 把终端光标拉回行首, 屏幕当场错位 | 丢弃 (CRLF 归一成 LF) | | `NUL` | 会把 C 字符串截断 | 丢弃 | @@ -1290,7 +1290,7 @@ REPL 自带行编辑器 (`src/lineedit.c`), 不依赖 readline, 也不用终端 CJK 规则 (中文按 2 列). 行尾 `\` 续行仍然可用, 续行的每一段都能独立编辑. **你敲的那一行整个是绿色的** (提示符 + 输入的文字), 翻历史或滚屏时一眼能 -看出哪些是自己的输入、哪些是模型输出: +看出哪些是自己的输入、哪些是模型输出: ``` ligent> 你好 ← 绿色, 整行都是输入 @@ -1307,13 +1307,13 @@ ligent> 你好 ← 绿色, 整行都是输入 ## 4. 终端渲染 -助手回复会做一层**流式 Markdown 渲染**:模型吐出的 `#`、`**粗体**` 等标记 +助手回复会做一层**流式 Markdown 渲染**: 模型吐出的 `#`、`**粗体**` 等标记 在终端里直接变成样式, 而不是原样显示. 工具输出 (文件内容、命令结果)**不做** 任何渲染, 保持原样, 避免破坏代码. ### 只用 16 色 -全部配色都落在最窄的 **16 色 ANSI** 里:前景用 30-37, 加粗/暗淡/斜体/下划线 +全部配色都落在最窄的 **16 色 ANSI** 里: 前景用 30-37, 加粗/暗淡/斜体/下划线 用 SGR 属性 1/2/3/4. 不用 90-97 那一段 (aixterm 的"亮色"), 也不用 256 色或 真彩. @@ -1324,10 +1324,10 @@ ligent> 你好 ← 绿色, 整行都是输入 这条约束由 `tools/check-colors.sh` 守着, `make test-unit` 里会跑. **它值得单独 一个脚本, 是因为颜色越界看代码看不出来** —— 一个越界的码在人眼里也只是"一个 -蓝色", 只有拿尺子量才知道它在哪一段. (这个检查抓到过一处:链接原本用的 +蓝色", 只有拿尺子量才知道它在哪一段. (这个检查抓到过一处: 链接原本用的 `\033[94m` 就是越界的亮蓝. ) -配色语义在各模块之间是一致的: +配色语义在各模块之间是一致的: | 用途 | 码 | 出现在 | |---|---|---| @@ -1338,7 +1338,7 @@ ligent> 你好 ← 绿色, 整行都是输入 | 强调 (工具名、标题) | 1 粗体 | 各模块 | | 链接 | 4;36 下划线+青 | markdown | -链接用**下划线**而不是只靠颜色:下划线是 SGR 属性, 在单色终端、色盲用户、 +链接用**下划线**而不是只靠颜色: 下划线是 SGR 属性, 在单色终端、色盲用户、 以及把颜色重映射过的主题里都还在 —— 颜色会丢, 属性不会. | 语法 | 效果 | @@ -1353,7 +1353,7 @@ ligent> 你好 ← 绿色, 整行都是输入 | `\| a \| b \|` 表格 | 带边框对齐的表格, 表头加粗, 支持 `:---` / `:---:` / `---:` 对齐 | | `[链接文字](url)` | `[链接文字]` 加下划线并着青色, `(url)` 原样保留 | -**完全不做渲染、逐字节原样保留**的四种情况: +**完全不做渲染、逐字节原样保留**的四种情况: | 语法 | 处理 | |---|---| @@ -1362,7 +1362,7 @@ ligent> 你好 ← 绿色, 整行都是输入 | 链接的 `(url)` 部分 | 保留原文 (不隐藏、不做超链接跳转) | | 行内代码的内容 | 不做任何解析 | -表格渲染示例 (列宽按**显示宽度**计算, 中文按 2 列, 所以边框能对齐): +表格渲染示例 (列宽按**显示宽度**计算, 中文按 2 列, 所以边框能对齐): ``` ┌──────┬──────┬──────┐ @@ -1375,7 +1375,7 @@ ligent> 你好 ← 绿色, 整行都是输入 ### 两个刻意的取舍 -**`_斜体_` 会渲染, 但下划线标识符不会被误伤. ** 按 CommonMark 规则禁用"词内强调": +**`_斜体_` 会渲染, 但下划线标识符不会被误伤. ** 按 CommonMark 规则禁用"词内强调": `foo_bar_baz`、`a_b_c` 原样保留; 与另一个下划线相邻时也一律当字面量, 所以 Python 的 `__init__`、`__main__` 不会被拆成斜体. 中文不算词字符, 因此 `中文_斜体_` 能正常生效. @@ -1386,17 +1386,17 @@ ligent> 你好 ← 绿色, 整行都是输入 **表格必须顶格写 `|`. ** 不带首竖线的写法 (`a | b` / `--- | ---`)按普通文本处理. -这条限制不是偷懒, 而是为了保证**输出与分片方式无关**:判断"这是不是表格"所需的信息 +这条限制不是偷懒, 而是为了保证**输出与分片方式无关**: 判断"这是不是表格"所需的信息 必须在行的第 0 个字节就能拿到. 如果允许"整行任意位置含竖线", 那么当 SSE 的分片边界 恰好落在竖线之前时, 行的前半段已经被当成普通文本输出、无法收回; 而同一行若整块到达 则会被识别为表格——**同样的内容, 渲染结果取决于网络怎么切的包**. 这个问题是模糊测试 -找出来的 (最小复现只有 4 字节:`s|\n-`). +找出来的 (最小复现只有 4 字节: `s|\n-`). ### 流式是这里的难点 SSE 的分片位置是随机的, `**bold**` 完全可能被切成 `**bo` / `ld**` 两片, 甚至两个 `*` 分别落在两片里. 所以渲染器是一个**带状态、可挂起**的状态机 - (`src/markdown.c`): + (`src/markdown.c`): - 遇到可能是标记前缀的尾部 (结尾的 `*`、行首的 `#`/`>`/`-`、`` ` ``、`[`), 它**不立即输出**, 而是把这段字节留在缓冲区里等下一个分片, 凑齐后再判定. @@ -1406,30 +1406,30 @@ SSE 的分片位置是随机的, `**bold**` 完全可能被切成 `**bo` / `ld** 防止异常输入把内存吃满或让输出长时间卡住. - 流式与非流式走同一个渲染器, 因此两种模式的输出**逐字节一致**. -`tests/test_markdown.c` 用一条不变量来锁住这个行为:把同一段文本分别按 +`tests/test_markdown.c` 用一条不变量来锁住这个行为: 把同一段文本分别按 **一次性 / 3 字节 / 逐字节** 三种方式喂进去, 输出必须完全相同. ### 想看效果? -仓库里带了一个独立的小演示程序, 不需要 API key: +仓库里带了一个独立的小演示程序, 不需要 API key: ```bash make demo # 编译并运行, 用内置示例展示所有语法 -./build/md_demo --plain # 对照:输出原始 Markdown -./build/md_demo -s # 流式模式:每 5 字节一片、间隔 12ms -./build/md_demo --compare # 自检:不同分片方式输出是否一致 +./build/md_demo --plain # 对照: 输出原始 Markdown +./build/md_demo -s # 流式模式: 每 5 字节一片、间隔 12ms +./build/md_demo --compare # 自检: 不同分片方式输出是否一致 ./build/md_demo README.md # 渲染任意 Markdown 文件 echo '# hi **there**' | ./build/md_demo ``` -`-s` 看起来和普通输出几乎一样, 这正是重点:分片边界落在 `**`、行首 `#`、 +`-s` 看起来和普通输出几乎一样, 这正是重点: 分片边界落在 `**`、行首 `#`、 表格中间都察觉不到——因为状态机会把可能被切断的标记暂存到下一片再判定. `--compare` 会把同一段文本按 1/2/3/5/7/13/64 字节以及**随机**分片各渲染一遍, 逐字节比对结果, 这也是 `make test` 会跑的一项. ### 什么时候不渲染 -只有输出到终端时才渲染. 以下情况会退化为**逐字节原样输出**: +只有输出到终端时才渲染. 以下情况会退化为**逐字节原样输出**: - 输出被重定向或接管道 (`ligent "..." > out.md`) - 显式 `--no-color` @@ -1445,36 +1445,36 @@ echo '# hi **there**' | ./build/md_demo | `run_command` | 执行 shell 命令, 合并捕获 stdout/stderr 与退出码, 超时后杀掉整个进程组 | `command`, `timeout` | | `read_file` | 按行读取文本文件, 带行号 (可直接喂给 `edit_file` 的 `line` 集合), 大文件分段读; **二进制文件拒绝** | `path`, `offset`, `limit` | | `write_file` | 创建/整体覆盖**文本**文件, 自动创建父目录; 目标是二进制文件时拒绝覆盖 | `path`, `content` | -| `edit_file` | 按**行号集合**做精确字符串替换:`line` 是集合型 (整数或整数数组), 替换只发生在集合圈住的行里且只要**行内唯一**; **省略 `line` = 全文件替换所有匹配** | `path`, `line`, `old_string`, `new_string`, `replace_all` | +| `edit_file` | 按**行号集合**做精确字符串替换: `line` 是集合型 (整数或整数数组), 替换只发生在集合圈住的行里且只要**行内唯一**; **省略 `line` = 全文件替换所有匹配** | `path`, `line`, `old_string`, `new_string`, `replace_all` | | `list_dir` | 列出目录, 标注类型与大小 | `path` | -| `read_image` | 读入本地图片交给模型看:本机留备份, 请求时以 base64 内联发出 | `path` | +| `read_image` | 读入本地图片交给模型看: 本机留备份, 请求时以 base64 内联发出 | `path` | 单次工具输出上限 100 KB, 超出会截断并提示, 避免一次 `cat` 把上下文撑爆. -### `edit_file`:行号集合钉住的替换 +### `edit_file`: 行号集合钉住的替换 -`line` 是**集合型**参数, 而且**可以整个省略**:省略 = 不设行范围 = **全文件替换 +`line` 是**集合型**参数, 而且**可以整个省略**: 省略 = 不设行范围 = **全文件替换 所有匹配** (就是编辑器里那个"全局替换"). 给了它就是集合型 —— 单个整数 (`"line": 41`)或整数数组 (`"line": [41, 42]`)都收, 行号就是 `read_file` 打出来 的那些 (从 1 开始). 给了行号之后, 替换只发生在这些行里, `old_string` 也只需 **在这些行里**唯一. -判定命中看的是 `old_string` 的**起点**:起点落在集合里的任意一行上就算数. +判定命中看的是 `old_string` 的**起点**: 起点落在集合里的任意一行上就算数. 所以 `old_string` 跨好几行时, 照 `read_file` 的显示把覆盖到的那几行都填进数组就行, -不必先赌起点到底是第 41 行还是第 42 行. 集合是**搜索范围**, 不是"每行都改一遍": +不必先赌起点到底是第 41 行还是第 42 行. 集合是**搜索范围**, 不是"每行都改一遍": 真改动的仍然只有唯一那一处 (`replace_all=true` 或省略 `line` 时才动全文件). -**省略 `line`** 时没有范围约束:整个文件都是搜索范围, 而且**命中几处就换几处** +**省略 `line`** 时没有范围约束: 整个文件都是搜索范围, 而且**命中几处就换几处** (不再要求唯一), 回执里会写明"全文件替换 N 处". 这是故意留着的逃生门 —— 有些 -改动本来就该全文件生效 (比如统一改个名字); 但它不是默认路径:不给行号就没法 +改动本来就该全文件生效 (比如统一改个名字); 但它不是默认路径: 不给行号就没法 声称"我知道改的是哪一处", 要改一处仍得给行号. -为什么不再只靠"全文唯一":行号是模型唯一的空间坐标, 而"全文唯一"要求整个文件里 +为什么不再只靠"全文唯一": 行号是模型唯一的空间坐标, 而"全文唯一"要求整个文件里 恰好只有一处 —— 文件一长这个条件经常不成立 (同一个 `return 0;` 到处都是), 模型 只能不断加长 `old_string` 去凑唯一性; 凑不成要么放弃, 要么赌一把改错地方. 把行号 钉住之后, "改哪一处"由坐标决定, 不再是个概率问题, `old_string` 也不必为了唯一而写长. -负面路径都给出**可照做的下一步**, 而不是干说一句"不行": +负面路径都给出**可照做的下一步**, 而不是干说一句"不行": | 情况 | 反馈 | |---|---| @@ -1488,56 +1488,56 @@ echo '# hi **there**' | ./build/md_demo | 省略了 `line`, 而文件里没有 `old_string` | 报"整个文件里没有", 并提示要给行范围就填 `line` | | 文件是二进制 | 拒绝并说明理由 (见下节) | -集合里的重复行号会被**去重** (`[1,1]` 等价于 `[1]`):不然同一处匹配会被数成两处, +集合里的重复行号会被**去重** (`[1,1]` 等价于 `[1]`): 不然同一处匹配会被数成两处, "行内只有一处"的判断就会莫名其妙地失败. -`replace_all=true` 的含义没变:替换文件里**所有**匹配; 但 `line` 集合仍必须圈住其中 +`replace_all=true` 的含义没变: 替换文件里**所有**匹配; 但 `line` 集合仍必须圈住其中 真实的一处 —— 等于强制模型先看过文件, 而不是凭印象做全文替换. ### 文本工具只处理文本 `read_file` / `write_file` / `edit_file` 遇到二进制文件一律**拒绝**, 而不是硬读或 -硬写. 判据只有一条:**出现 NUL 字节** (和 `/include` 的判据一致 —— 两处必须一样, +硬写. 判据只有一条: **出现 NUL 字节** (和 `/include` 的判据一致 —— 两处必须一样, 否则会出现"同一个文件 `/include` 说能带、`read_file` 说不能读"这种自相矛盾). -拒绝的理由是实的, 不是洁癖: +拒绝的理由是实的, 不是洁癖: - `read_file` 会把二进制读成乱码, 还会在第一个 NUL 处被截断 —— 模型看到的是**残缺** 的内容, 却没有任何提示说它是残缺的; -- `edit_file` 更糟:它在内存里按 C 字符串处理 (`strstr`/`strlen`), 文件里任何一个 +- `edit_file` 更糟: 它在内存里按 C 字符串处理 (`strstr`/`strlen`), 文件里任何一个 NUL 都会让后面的内容被当成结束, **改下去就是把原文件毁掉**. 所以它探的是全文件, 不像读类工具只看前 8 KB; - `write_file` 只写文本, 用文本整体覆盖一个二进制文件等于把原内容毁掉. -拒绝时会说清"第几字节有 NUL"以及**该改用什么**:看图片用 `read_image`, 看其它 +拒绝时会说清"第几字节有 NUL"以及**该改用什么**: 看图片用 `read_image`, 看其它 二进制用 `run_command` (`file` / `xxd` / `strings` / `od`), 要改二进制用 `printf` / `dd` / `perl -pi` 这类. -**搜索内容不在这套工具里**:模型要搜就用 `run_command` 里的真 `grep` +**搜索内容不在这套工具里**: 模型要搜就用 `run_command` 里的真 `grep` (`grep -rn 模式 目录`). 以前有个 `search_files` (后来改名 `grep`)的包装, 删掉的理由见下节. ### 工具命名约定 -工具名是协议的一部分:它每个请求都要重发, 还要被人、被模型、被代码里好几处引用. -所以它有一套明写的规矩 (`src/tools.c` 顶部有同一份, 两处必须同步): +工具名是协议的一部分: 它每个请求都要重发, 还要被人、被模型、被代码里好几处引用. +所以它有一套明写的规矩 (`src/tools.c` 顶部有同一份, 两处必须同步): -1. **形式**:全小写 snake_case, 动宾结构「动词_对象」. 动词必须来自白名单, - 而白名单**按域划分** —— 要用新动词, 先把它加进对应域: - - 文件与执行域:`read` / `write` / `edit` / `list` / `run` / `view` - - 历史编辑域 (`/meta`):`list` / `read` / `replace` / `delete` / `insert` / `undo` +1. **形式**: 全小写 snake_case, 动宾结构「动词_对象」. 动词必须来自白名单, + 而白名单**按域划分** —— 要用新动词, 先把它加进对应域: + - 文件与执行域: `read` / `write` / `edit` / `list` / `run` / `view` + - 历史编辑域 (`/meta`): `list` / `read` / `replace` / `delete` / `insert` / `undo` 两域动词不混用. 禁用 `do` / `handle` / `process` / `get` / `exec` 这类不说明对象的空动词. -2. **名字要能推出副作用**:会改盘的名字里不会出现 `read` / `list`. +2. **名字要能推出副作用**: 会改盘的名字里不会出现 `read` / `list`. 模型靠名字判断该不该先征求同意 —— 含糊的名字等于让它猜. -3. 不带 `ligent` / `ai` / `tool` 这类冗余词:工具表本身就在工具命名空间里. +3. 不带 `ligent` / `ai` / `tool` 这类冗余词: 工具表本身就在工具命名空间里. 4. **不与 REPL 命令同名** (`/shell`、`/cd`、`/model`、`/log`… 已被占用), 否则人和模型两边会串. 这也是执行命令的工具叫 `run_command` 而不叫 `shell` 的原因之一. 5. 不与别的工具只在词序、单复数或大小写上不同 (`read_file` / `file_read` 这种). -6. **长度 ≤ 24 字符**:工具表每个请求都要重发, 名字长度是重复成本. -7. **参数名与工具名同源**:路径一律 `path`, 内容 `content`. 同一个概念在两个工具里 +6. **长度 ≤ 24 字符**: 工具表每个请求都要重发, 名字长度是重复成本. +7. **参数名与工具名同源**: 路径一律 `path`, 内容 `content`. 同一个概念在两个工具里 必须同名. -8. **能不包装就不包装** (这条是人定的, 不是名字的规矩):模型手上有一个能跑任意 +8. **能不包装就不包装** (这条是人定的, 不是名字的规矩): 模型手上有一个能跑任意 命令的 `run_command`, 凡是"一条标准命令就能干、而且干得更好"的事, 就别再做一个 自己的版本 —— 多一个工具 = 多一份 schema、多一个要维护的边界、多一次被误用的 机会, 换来的只是省一次 `run_command`. @@ -1545,54 +1545,54 @@ echo '# hi **there**' | ./build/md_demo ### 为什么没有搜索工具 曾经有 `search_files` (后来按规矩改名 `grep`), 现在删了 —— 它是第 8 条规矩的 -反例:真 `grep` 支持正则、文件名过滤、上下文行 (`-A/-B/-C`)、大小写、多模式、 +反例: 真 `grep` 支持正则、文件名过滤、上下文行 (`-A/-B/-C`)、大小写、多模式、 二进制提示, 我们那版每一样都要自己写一遍, 还得跟着需求慢慢长 (第一版就叫错了 -名字:明明是搜内容, 却叫 `search_files`, 模型自然会去传 `pattern="*.c"`). +名字: 明明是搜内容, 却叫 `search_files`, 模型自然会去传 `pattern="*.c"`). 删掉之后工具表从 8 个变成 7 个. - (顺带一提:原来还有个子代理功能 `spawn_subagent`, 它拿不到 `run_command`, + (顺带一提: 原来还有个子代理功能 `spawn_subagent`, 它拿不到 `run_command`, 所以既搜不了内容、也没法真正独立干活, 等于只是"多花一份上下文把文件读一遍", 现在整个删掉了. ) 名字只定义一次 (`src/tools.h` 的 `TOOL_*` 宏), 工具表也只有一处 - (`src/tools.c` 的 `TOOLS[]`):schema、执行分发、要不要走确认、`/tools` 的输出 + (`src/tools.c` 的 `TOOLS[]`): schema、执行分发、要不要走确认、`/tools` 的输出 全从那张表生成. 所以**改名只需改 `.name` 一列** —— 这三样以前各有一份 `strcmp` 链, 改名必定漏一处, 而且漏了不报错 (只是静默地少认一个工具). -**不保留旧名别名**, 这是刻意的:schema 里只发新名, dispatch 再认旧名只会掩盖 +**不保留旧名别名**, 这是刻意的: schema 里只发新名, dispatch 再认旧名只会掩盖 "调用方没跟着改" (旧存档、用户自写的提示词、测试夹具都一样). 认不出来就明确报 `未知工具 "xxx"`, 比静默兼容更容易发现漏改. -### `read_image`:图片怎么进对话 +### `read_image`: 图片怎么进对话 模型自己看不到磁盘. 给它一个路径, 它得先把图**读进来**——这一步就是 -`read_image`: +`read_image`: ```bash ligent "看看 build/screenshot.png 里的报错是什么" ``` -内部做了四件事, 每件都有它的理由: +内部做了四件事, 每件都有它的理由: **1. 按内容认格式, 不认扩展名. ** 读文件头判断 PNG / JPEG / GIF / WebP. 一个叫 `.png` 的文本文件会被明确拒绝, 而不是发出去等一个莫名其妙的 400. -**2. 请求里走标准的 `image_url` + base64 内联. ** 也就是: +**2. 请求里走标准的 `image_url` + base64 内联. ** 也就是: ```json {"type": "image_url", "image_url": {"url": "data:image/png;base64,...."}} ``` -这是协议里图片仅有的两种走法之一 (另一种是外部 URL). **这是通用形状**: +这是协议里图片仅有的两种走法之一 (另一种是外部 URL). **这是通用形状**: DeepSeek 和 MiMo 都收, ligent 不去网上抓图, 所以只产出 base64 这一种. 图片只能出现在 `user` 消息里, 而工具结果只能是字符串. 所以工具跑完之后, ligent 会**补一条合成的 `user` 消息**承载图片. 它算附件、不算提问——`/back` 按轮删除、上下文回收时都按这个规则处理, 不会把它当成新的一轮. -**3. 但图片字节不进历史. ** base64 内联有个直接后果:字节会躺在会话里, +**3. 但图片字节不进历史. ** base64 内联有个直接后果: 字节会躺在会话里, 每轮请求都重发. 归档、`/history`、`/log` 全都跟着膨胀. 所以**存储形态和发送 -形态是分开的**: +形态是分开的**: ``` 存进历史 {"type": "ligent_image", "backup": "1700000000_a1b2.png"} @@ -1605,7 +1605,7 @@ ligent 会**补一条合成的 `user` 消息**承载图片. 它算附件、不 `/export` 出来的存档因此还是几十 KB, 而不是几十 MB. 两个内部块类型都带 `ligent_` 前缀, 就是为了万一哪天漏了展开、直接发出去, -服务端报的错能一眼看出是我们的问题: +服务端报的错能一眼看出是我们的问题: | 内部块 | 含义 | |---|---| @@ -1614,17 +1614,17 @@ ligent 会**补一条合成的 `user` 消息**承载图片. 它算附件、不 **4. 体积在本地就判掉. ** 图片会留在历史里, 所以一次超限不是一次失败, 而是从此**每一轮**都超限. 与其发出去等一个 400, 不如当场告诉模型"太大了"—— -而且**不备份**:一张永远发不出去的图, 留着备份没有意义. +而且**不备份**: 一张永远发不出去的图, 留着备份没有意义. 上限取各家文档里最严的一档 (DeepSeek 单图 32 MiB、请求体 48 MiB; MiMo -base64 字符串 50 MB): +base64 字符串 50 MB): | 限制 | 值 | |---|---| | 单张图 (base64 之后的长度) | 32 MiB | | 一次请求里图片的 base64 总量 | 44 MiB (给提示词/历史/工具描述留 4 MiB) | -注意判的是**编码后**的长度:base64 会膨胀 4/3, 所以一张 25 MiB 的原图就已经 +注意判的是**编码后**的长度: base64 会膨胀 4/3, 所以一张 25 MiB 的原图就已经 超了. 被拒时工具直接把话带给模型, 让它请用户先缩小或压缩. **5. 服务端要是还是拒了呢?** 本地拦的是格式和体积, 但服务端还可能因为别的 @@ -1633,28 +1633,28 @@ base64 字符串 50 MB): 所以 ligent 撞到 4xx 且请求里有图片时, 会把图片**从历史里就地摘掉**、换成 一句说明再重试一次. -给**模型**看的是能直接照做的一句话: +给**模型**看的是能直接照做的一句话: ``` [图片 1700000000_a1b2.png 不符合 API 所需的格式标准, 无法上传, 请规范化图片后重试] ``` 给**你**看的 (stderr)是移除处数和服务端的原话 —— 原始报错通常是给人看的英文 -错误码, 塞进上下文只会让模型分心, 但完全不显示又会让你没法排查: +错误码, 塞进上下文只会让模型分心, 但完全不显示又会让你没法排查: ``` 服务端拒绝了请求里的图片, 已把 1 处图片从对话里移除后重试... -服务端原话:HTTP 400 —— image rejected: unsupported image variant +服务端原话: HTTP 400 —— image rejected: unsupported image variant ``` -"就地"是要紧的:光在请求副本里摘掉, 历史里那条坏引用下一轮还会再犯. +"就地"是要紧的: 光在请求副本里摘掉, 历史里那条坏引用下一轮还会再犯. `read_image` 不写文件、不执行命令, 所以**不触发写操作确认**. 备份是副作用, 不是"改动你的东西". 它会不会出现在工具表里, 取决于模型收不收图片 —— 见下一节. -### 模型能力探测:怎么知道它收不收图片 +### 模型能力探测: 怎么知道它收不收图片 `read_image` 只对**能看图的模型**有意义. 可是**没有任何接口能问出来** —— DeepSeek 和 MiMo 的 `GET /v1/models` 都只返回 `id` / `object` / `owned_by`, @@ -1662,7 +1662,7 @@ DeepSeek 和 MiMo 的 `GET /v1/models` 都只返回 `id` / `object` / `owned_by` 的通病. 所以只能**发试探请求**问. ligent 在**首次请求之前**跑一个独立的"能力测试序列", -它不碰对话历史, 也不进历史: +它不碰对话历史, 也不进历史: | 步 | 发什么 | 结论 | |---|---|---| @@ -1670,37 +1670,37 @@ DeepSeek 和 MiMo 的 `GET /v1/models` 都只返回 `id` / `object` / `owned_by` | 2 | 同一句 + 一张 1×1 的图 + `tools` | 200 → 两样都支持, 到此为止 | | 3 | 拆开单独发 (图 / tools) | 第 2 步被拒时才知道是哪一个不行 | -关键在于**否定结论只在基线通过时才敢下**:单个试探 400 而基线 200, 差别就在 +关键在于**否定结论只在基线通过时才敢下**: 单个试探 400 而基线 200, 差别就在 那一样东西上. 这样不需要猜服务端的错误文案 ("不支持图片"、"invalid content"、 "multimodal not supported"……千家千样), 也就不会因为文案变了而误判. -**猜不准时一律按"支持"处理**:多暴露一个工具, 代价是模型可能白调一次 (还有 +**猜不准时一律按"支持"处理**: 多暴露一个工具, 代价是模型可能白调一次 (还有 4xx 降级兜底); 而错误地藏掉工具, 你会以为模型看不见图. 结果按 `base_url + model` 缓存, 所以**换模型会自动重测**, 切回来命中缓存. 探测完总会打一条灰字结论 —— 支持什么、不支持什么、以及我们因此**做了什么**, -都在同一行里, 不用去猜: +都在同一行里, 不用去猜: ``` -模型能力:多模态 (图片输入)支持 * 工具调用 支持 -模型能力:多模态 (图片输入)不支持 (已隐藏 read_image) * 工具调用 支持 -模型能力:多模态 (图片输入)未测 * 工具调用 未测 —— 没测出来的一律按「支持」处理 (HTTP 401) +模型能力: 多模态 (图片输入)支持 * 工具调用 支持 +模型能力: 多模态 (图片输入)不支持 (已隐藏 read_image) * 工具调用 支持 +模型能力: 多模态 (图片输入)未测 * 工具调用 未测 —— 没测出来的一律按「支持」处理 (HTTP 401) ``` 探测出"不支持"时把动作也写在同行, 是因为只报"不支持"而不说"我已经把工具藏了", 用户会以为工具还在. -代价要说清楚:**开启探测时, 每个进程的第一次请求之前会多出 2~4 个小请求** +代价要说清楚: **开启探测时, 每个进程的第一次请求之前会多出 2~4 个小请求** (常见情况是 2 个, 模型受限时才拆开). 不想要这个开销就用 `--no-probe` (或配置项 `"probe": false`)—— 那时不探测、什么结论都不下、 `read_image` 照常暴露, 也就是探测功能出现之前的行为. 想临时测一次 (比如用了 `--no-probe` 又想确认模型收不收图片), 在 REPL 里敲 -**`/probe`**:它绕开 `--no-probe` 与缓存, 重新跑一遍上面的序列并打出结论. +**`/probe`**: 它绕开 `--no-probe` 与缓存, 重新跑一遍上面的序列并打出结论. #### 为什么最后没走 Files API -早先这一路走的是 Files API:上传换一个 `file_id`, 请求里只带引用. 它确实能 +早先这一路走的是 Files API: 上传换一个 `file_id`, 请求里只带引用. 它确实能 让历史更小, 但**它不是一个标准**. 只有最外面那层 (`POST /v1/files` + multipart + `purpose`)因为抄 OpenAI 而看着像标准; 真正要紧的"在对话请求里 怎么引用"各家完全不同——OpenAI 只在 Responses API 里收 `input_file`, @@ -1714,7 +1714,7 @@ Anthropic 的图像走 `source`; MiMo 干脆明确写了不支持上传本地文 代价写在 §9 里, 不藏着. 思考模式则仍然是方言 (见 §9), 那一条没有通用替代. -> ⚠️ **安全提醒**:按需求本工具**不做沙箱限制**——模型可以执行任意 shell 命令、 +> ⚠️ **安全提醒**: 按需求本工具**不做沙箱限制**——模型可以执行任意 shell 命令、 > 读写任意路径. 建议在容器、虚拟机或独立用户下运行, 不要在存有重要数据的 > 生产环境里直接把 `--no-tools` 之外的限制交给模型自觉. @@ -1724,30 +1724,30 @@ Anthropic 的图像走 `source`; MiMo 干脆明确写了不支持上传本地文 src/ util.c/h xmalloc、动态字符串缓冲、文件读取、mkdir -p、${VAR} 展开 json.c/h 递归下降 JSON 解析器; UTF-8/代理对解码; 构造器; 紧凑/美化序列化 - http.c/h libcurl 薄封装:同步 POST、流式 POST (header 回调取状态码、 + http.c/h libcurl 薄封装: 同步 POST、流式 POST (header 回调取状态码、 写回调分流错误体)、Ctrl-C 中断 - llm.c/h OpenAI 兼容客户端:请求体构造 (含 thinking / reasoning_effort)、 + llm.c/h OpenAI 兼容客户端: 请求体构造 (含 thinking / reasoning_effort)、 SSE 逐行解析、tool_calls 按 index 增量拼接、 reasoning_content 累积与回传、非流式回退、错误体可读化 - tools.c/h 6 个工具的实现 + **工具表** (唯一真相:请求 schema、执行分发、 + tools.c/h 6 个工具的实现 + **工具表** (唯一真相: 请求 schema、执行分发、 是否要确认、/tools 的输出都从它生成; 命名约定写在表上方) capability.c/h 模型能力探测序列 (基线 → 合并 → 拆开), 按 base_url+model 缓存 - models.c/h GET {base_url}/models:拉取并解析模型列表 (保序、去重、容错) - attach.c/h /include:文件暂存、glob 展开、交互式挑选 (逐层进目录) - priv.c/h 提权:把命令包成 sudo 调用 (进程本身不提权) - lineedit.c/h REPL 行编辑器:raw 模式、光标移动、bracketed paste、UTF-8 感知, + models.c/h GET {base_url}/models: 拉取并解析模型列表 (保序、去重、容错) + attach.c/h /include: 文件暂存、glob 展开、交互式挑选 (逐层进目录) + priv.c/h 提权: 把命令包成 sudo 调用 (进程本身不提权) + lineedit.c/h REPL 行编辑器: raw 模式、光标移动、bracketed paste、UTF-8 感知, 以及输入历史的持久化 (实时追加 + 启动加载 + 上限截短) - doctor.c/h 环境体检:逐环检查并给出可操作的结论 - archive.c/h 对话存档:导出/列举/载入 (文件名即谱系) - image.c/h 图片:按文件头认格式、本机备份、请求前把备份展开成 base64 内联 - shell.c/h /shell:伪终端双向搬运 + 录制 - meta.c/h /meta:元编辑对话历史 (独立工具集 + 快照栈 + 结构安全校验) - spinner.c/h 等待动画:两段标签 (等待响应 → 思考中), 后台线程, + doctor.c/h 环境体检: 逐环检查并给出可操作的结论 + archive.c/h 对话存档: 导出/列举/载入 (文件名即谱系) + image.c/h 图片: 按文件头认格式、本机备份、请求前把备份展开成 base64 内联 + shell.c/h /shell: 伪终端双向搬运 + 录制 + meta.c/h /meta: 元编辑对话历史 (独立工具集 + 快照栈 + 结构安全校验) + spinner.c/h 等待动画: 两段标签 (等待响应 → 思考中), 后台线程, 只在 stderr 是终端时启用 i18n.c/h 界面文案表 (中英各一份)、语言探测、{name} 占位符替换 markdown.c/h 流式 Markdown 渲染状态机 (标记跨分片挂起、表格列宽对齐、 近似 wcwidth 的 CJK 宽度计算) - agent.c/h 主循环:消息历史管理、多步工具调用、流式打印 + agent.c/h 主循环: 消息历史管理、多步工具调用、流式打印 config.c/h 默认值 → 配置文件 → 环境变量 main.c 参数解析、单次任务模式、REPL tools/ @@ -1758,35 +1758,35 @@ tests/ test_agent.c 上下文不变量 (system 不重复、reset 语义) test_config.c 默认值、环境变量优先级、配置文件覆盖与 ${VAR} 展开 test_markdown.c Markdown 渲染 + 分片不变性 (一次性/3字节/逐字节 输出一致) - fuzz.c 对抗性模糊测试:随机/变异输入砸 JSON 与 Markdown 解析器 - reduce.c delta-debugging 最小化器:把失败输入缩到最短复现 + fuzz.c 对抗性模糊测试: 随机/变异输入砸 JSON 与 Markdown 解析器 + reduce.c delta-debugging 最小化器: 把失败输入缩到最短复现 test_http.c HTTP 层纯逻辑 (重试判定、上限取值) test_lineedit.c 行编辑器缓冲区与历史 (含 Home/End 在逻辑行内的语义、 历史文件转义往返 / 上限截短 / 文件不存在) test_spinner.c 等待动画的两段标签与幂等性 (把 stderr 接到临时文件后读回断言) - test_meta.c 元编辑:轮次切分、结构安全、七个工具、撤销、重新存根标记 - test_image.c 图片:格式嗅探、备份与索引、附件队列、损坏索引降级 + test_meta.c 元编辑: 轮次切分、结构安全、七个工具、撤销、重新存根标记 + test_image.c 图片: 格式嗅探、备份与索引、附件队列、损坏索引降级 test_capability.c 能力探测的纯逻辑 (名字映射、关掉探测时的语义、缓存查询) test_models.c /models 响应体解析 (标准形状、裸数组、去重、坏条目、空列表) - test_attach.c /include:glob 展开、二进制/超限跳过、图片引用、回调只在遇图时问 + test_attach.c /include: glob 展开、二进制/超限跳过、图片引用、回调只在遇图时问 repl_edit_check.py 带极简终端模拟器, 验证方向键/Home/End/退格/历史/多行粘贴 真的生效 (只对比字节流证明不了光标移动) repl_drive.py 用 pty 驱动 REPL 跑多轮对话 (管道模式无法测多轮行为) mock_server.py 最小 OpenAI 兼容 mock, 含 SSE 与分片 tool_calls - run_e2e.sh 端到端:流式、非流式、多步工具、错误处理、缺 key、管道、渲染 + run_e2e.sh 端到端: 流式、非流式、多步工具、错误处理、缺 key、管道、渲染 ``` ### 几个实现细节 -- **tool_calls 增量拼接**:流式响应里 `function.arguments` 会被服务端切成任意碎片, +- **tool_calls 增量拼接**: 流式响应里 `function.arguments` 会被服务端切成任意碎片, 客户端按 `index` 累积成完整 JSON 字符串后再整体解析, 而不是每片单独解析. -- **错误响应可读化**:非 2xx 时解析 `{"error": {"message": "..."}}` 并解码 `\uXXXX`, +- **错误响应可读化**: 非 2xx 时解析 `{"error": {"message": "..."}}` 并解码 `\uXXXX`, 而不是把原始 JSON 甩给用户. -- **不依赖流式**:如果服务端忽略 `stream` 参数直接返回整包 JSON, 客户端会自动回退解析. -- **行号集合钉住的替换**:`edit_file` 的 `line` (`read_file` 给的行号, 整数或整数 +- **不依赖流式**: 如果服务端忽略 `stream` 参数直接返回整包 JSON, 客户端会自动回退解析. +- **行号集合钉住的替换**: `edit_file` 的 `line` (`read_file` 给的行号, 整数或整数 数组都收)把替换钉在集合圈住的行里, 且只要行内唯一 —— 从机制上避免模型改错地方, 也不逼它为了"全文唯一"把 `old_string` 写成一整段. `line` 可以省略, 省略就是 - **全文件替换所有匹配**:逃生门留着 (有些改动本来就该全文件生效), 但它不是默认 + **全文件替换所有匹配**: 逃生门留着 (有些改动本来就该全文件生效), 但它不是默认 路径 —— 要改一处仍得给行号. ## 7. 测试 @@ -1802,7 +1802,7 @@ make demo # 编译并运行渲染效果演示 ### 模糊测试 `tests/fuzz.c` 用随机与变异输入去砸两个"解析不可信数据"的模块 (JSON 解析器、 -Markdown 渲染器), 检查四条不变量: +Markdown 渲染器), 检查四条不变量: | 模块 | 不变量 | |---|---| @@ -1824,17 +1824,17 @@ make build/fuzz build/reduce ./build/reduce build/md_fail.bin # 最小化最近一次失败 ``` -端到端测试不需要联网也不需要密钥:`tests/mock_server.py` 会在本地起一个 +端到端测试不需要联网也不需要密钥: `tests/mock_server.py` 会在本地起一个 OpenAI 兼容服务端, 按请求内容返回确定性的流式响应, 从而验证「模型请求工具 → 本地真实执行 → 结果回填 → 模型给出最终回答」的完整闭环. > 存档目录 (`<二进制目录>/ligent.archive`)是运行期状态而不是构建产物, e2e 却 -> 需要它干净, 所以脚本开机时会检查:**非空就先整体挪到 `/tmp/ligent.archive.saved.*`** +> 需要它干净, 所以脚本开机时会检查: **非空就先整体挪到 `/tmp/ligent.archive.saved.*`** > 并打印路径, 而不是直接删掉. 移不动就直接中止 (宁可测试不跑, 也不误删). > 挪走之后 e2e 才会在自己的那份临时目录里折腾 —— 跑两次也不会把你上一轮的 > 存档叠进去. -用 AddressSanitizer / UBSan 跑一遍: +用 AddressSanitizer / UBSan 跑一遍: ```bash make clean @@ -1844,7 +1844,7 @@ make SAN=1 test-unit `SAN=1` 在默认 `CFLAGS` 之上追加 `-fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all`, 链接行也带上 (只插桩目标文件、不进运行时库的话检测器根本不生效). -`-fno-sanitize-recover=all` 让报错直接中止而不是"报告后继续跑", 免得刷屏. +`-fno-sanitize-recover=all` 让报错直接中止而不是"报告后继续跑", 以免刷屏. 整套单元测试 (16 个可执行、3692 项)在这个配置下是干净的. ## 8. 稳定性设计 @@ -1861,29 +1861,29 @@ make SAN=1 test-unit | 响应体上限 | 声明或实际超过 32MB 主动中止; 流式靠 `Content-Length` 提前判断, 非流式另有累积上限 | | SSE 缓冲上限 | 单行 256KB、单次累积内容 32MB、单个工具调用参数 8MB. 正常 `data:` 行只有几十字节, 超长说明对端不是 SSE | | 工具输入上限 | `edit_file` 在读之前先看文件大小, 超过 32MB 直接拒绝 (它需要整个文件进内存); `read_file` 是分段读的, 不受此限 | -| 二进制检测 | 文本工具 (`read_file` / `write_file` / `edit_file`)拒收含 NUL 字节的文件:读类只看前 8 KB, `edit_file` 看全文件 (它按 C 字符串处理, 任何 NUL 都会把结果截断) | +| 二进制检测 | 文本工具 (`read_file` / `write_file` / `edit_file`)拒收含 NUL 字节的文件: 读类只看前 8 KB, `edit_file` 看全文件 (它按 C 字符串处理, 任何 NUL 都会把结果截断) | | 错误体上限 | 只保留 256KB 用于诊断 | -重试次数可配置:`--retries N` (默认 2, 0 = 不重试)/ `LIGENT_RETRIES` / 配置项 `retries`. +重试次数可配置: `--retries N` (默认 2, 0 = 不重试)/ `LIGENT_RETRIES` / 配置项 `retries`. ### 传输中断 流式请求中途断连时, **已经收到的部分内容会保留**成一条消息并提示"传输中断", 而不是整段丢弃 —— 否则屏幕上有这段文字、上下文里却没有, 后续对话会对不上. -### 上下文超限:如实报错, 不替你做决定 +### 上下文超限: 如实报错, 不替你做决定 历史超过模型窗口时, 服务端回一个 4xx. ligent **不会自动删掉任何一条消息** —— -它把服务端的原始错误原样报出来, 历史一个字节都不动: +它把服务端的原始错误原样报出来, 历史一个字节都不动: ``` ligent> 第二轮 -ligent: 出错:HTTP 400 — This model's maximum context length is 8192 tokens. +ligent: 出错: HTTP 400 — This model's maximum context length is 8192 tokens. However, your messages resulted in 9000 tokens. ``` 这里曾经有一整套"丢弃最老的一整轮再重试 (最多 5 次)"的机制, 配套 -`--no-auto-trim` / `auto_trim` / `LIGENT_AUTO_TRIM`. 它被**整个删掉**了, 理由: +`--no-auto-trim` / `auto_trim` / `LIGENT_AUTO_TRIM`. 它被**整个删掉**了, 理由: - 触发它的判据只是错误文案里的关键词 (`too long`、`上下文`、`超出`……), 任何 4xx 蹭到这些词都会触发裁剪 —— 代价却是**不可逆地**删掉几轮对话. @@ -1891,16 +1891,16 @@ ligent: 出错:HTTP 400 — This model's maximum context length is 8192 tokens - 它把"历史太大了"这件本该由用户决定的事 (`/back` 摘掉哪一轮、`/reset` 重来、 还是换一个窗口更大的模型)自动化掉了; 用户看到的是对话莫名其妙少了几轮. -现在怎么处理由你决定:`/back` 摘掉最近一轮 (可 `/unback` 撤销)、`/reset` 清空 +现在怎么处理由你决定: `/back` 摘掉最近一轮 (可 `/unback` 撤销)、`/reset` 清空 重来、`/export` 存档后另起一段, 或者直接换个模型. `/usage` 里的"最近一次输入 token"≈ 当前上下文大小, 可以据此判断还剩多少余量. (`/meta` 里让模型帮你做这件事仍然是可行的 —— 它删的是你看得见、也点过头的那些轮. ) -### 对话结构不变量:tool_calls 必须连着 tool +### 对话结构不变量: tool_calls 必须连着 tool -服务端对消息结构的要求很硬:**带 `tool_calls` 的 assistant 后面必须紧跟**与每个 -`tool_call_id` 一一对应的 `tool` 消息, 中间不能夹别的东西, 也不能少. 违反了就是: +服务端对消息结构的要求很硬: **带 `tool_calls` 的 assistant 后面必须紧跟**与每个 +`tool_call_id` 一一对应的 `tool` 消息, 中间不能夹别的东西, 也不能少. 违反了就是: ``` HTTP 400 — An assistant message with 'tool_calls' must be followed by tool @@ -1909,9 +1909,9 @@ following tool_calls message) ``` 麻烦在于这条历史**留在上下文里**——一旦写坏, 之后每一轮都会以同样的 400 失败, -用户只能 `/reset`. 所以 ligent 两头都做了: +用户只能 `/reset`. 所以 ligent 两头都做了: -**发之前先体检. ** 每次构造请求前扫一遍历史:缺的 `tool` 消息补一条占位 +**发之前先体检. ** 每次构造请求前扫一遍历史: 缺的 `tool` 消息补一条占位 ("这次调用没有拿到结果, 不要假设它执行过"), 孤儿 `tool` 消息 (找不到对应 调用的)丢掉, 并如实打一行说明. 没问题时它只是一次只读扫描, 不分配任何东西. @@ -1920,7 +1920,7 @@ following tool_calls message) —— 不修的话之后每一轮都会被服务端拒绝. ``` -**产生的时候别写坏. ** 体检是兜底, 正常路径不该走到它. 两个真实踩过的坑: +**产生的时候别写坏. ** 体检是兜底, 正常路径不该走到它. 两个真实踩过的坑: - **图片附件的位置. ** 图片只能出现在 `user` 消息里, 而 `tool` 消息的内容只能是 字符串, 所以 `read_image` 之后要补一条 `user` 消息. 曾经是"每推完一条 `tool` @@ -1932,24 +1932,24 @@ following tool_calls message) 本次调用没有执行"的 `tool` 消息再返回. 回归用例是第 53 段, 而且 `tests/mock_server.py` **和真服务端一样校验这个结构** - (`pairing_error`):以后任何写坏历史的回归都会在 e2e 里当场 400, 而不是等到 + (`pairing_error`): 以后任何写坏历史的回归都会在 e2e 里当场 400, 而不是等到 用户真拿它去连服务端才发现. ### 图片降级只在错误确实冲着图片来时动手 服务端 4xx 时会检查请求里有没有图片引用, 有就把图片从历史里摘掉再重试一次 - (见 §5). 这一条**加了关键词判断**:只有错误里出现 `image` / `vision` / + (见 §5). 这一条**加了关键词判断**: 只有错误里出现 `image` / `vision` / `multimodal` / `content type` / `图片` 这类词才动手. -理由是它会把图片**永久删掉**:一条"tool_calls 后面缺 tool 消息"的 400 也被当成 +理由是它会把图片**永久删掉**: 一条"tool_calls 后面缺 tool 消息"的 400 也被当成 图片问题处理的话, 图白丢, 而且真正的错误被我们的提示盖住了 —— 排障方向直接被 带偏. **不确定就不动手**, 宁可让错误如实报出来. 这终究是启发式; 主要防线还是 发送前就把格式和体积查掉. -### 提权:模型请求, 你输密码 +### 提权: 模型请求, 你输密码 `run_command` 有一个 `elevated` 参数. 模型撞到权限墙时带上它, 那一条命令就以 -root 执行: +root 执行: ``` run_command {"command": "apt install -y nginx", "elevated": true} @@ -1965,12 +1965,12 @@ run_command {"command": "apt install -y nginx", "elevated": true} **先说清楚为什么没有 `/su`、也没有"授权提示". ** - **`su` 能变 root, 不是因为 PAM 验证了密码**, 而是因为 `/usr/bin/su` 本身是 - setuid root:内核先把 root 给它, 它再验密码、验不过就降权. 普通进程调 + setuid root: 内核先把 root 给它, 它再验密码、验不过就降权. 普通进程调 `pam_authenticate()` 只能得到「密码对不对」, uid 一位都不变. 要让 ligent 内部认证后真的变 root, 唯一的路是把它编译成 setuid root —— 而这个程序的功能 是「让模型执行任意命令」, setuid root 的它等于把 root 远程执行做成一等功能. **那条路没走. ** -- **也不需要 ligent 再发明一道 y/N 授权**. 密码本身就是不可伪造的授权:模型敲 +- **也不需要 ligent 再发明一道 y/N 授权**. 密码本身就是不可伪造的授权: 模型敲 不出来, 你也不可能手滑敲出来. 前面再加一道"允许吗"只会是多余的摩擦, 还会给出 一个虚假的安全模型 —— 好像把关的是 ligent 的提示, 其实把关的是 sudo. - 所以没有常驻提权状态, 也就没有"忘了降权"这回事. **一次命令一次身份**; @@ -1984,18 +1984,18 @@ run_command {"command": "apt install -y nginx", "elevated": true} | 输出 | 仍然接管道 | 命令输出照常捕获回填给模型, 和普通 `run_command` 一样 | | 没有终端 | **直接拒绝, 不去试** | 否则就是一个在后台等一个没人能看见的密码提示的进程 | -想立刻收回授权:在 `/shell` 里敲 `sudo -k`, 或者等 sudo 自己的超时. +想立刻收回授权: 在 `/shell` 里敲 `sudo -k`, 或者等 sudo 自己的超时. -**踩过的坑, 值得单独记**:第一版把认证也放进通用的 `run_capture()`, 而它 +**踩过的坑, 值得单独记**: 第一版把认证也放进通用的 `run_capture()`, 而它 `setsid()` + 全接管道 —— 真机上认证 **100% 失败**. 更糟的是当时的 e2e 替身 sudo 无条件成功, 给了个**假绿**. 现在替身必须像真 sudo 一样要求终端, 并且有一条 断言专门盯"认证时 sudo 真的拿到了终端"; `tests/test_priv.c` 还钉住了"包装里不许 出现 `-n`"和"没终端时能识别出来"这两条. -### 排障:/log 导出收发记录 +### 排障: /log 导出收发记录 进程会把每一次请求的**请求体**、响应头、以及响应原文 (流式就是原始 SSE 字节) -滚动缓存在内存里. 不给这个问题事先准备什么, 出事了再捞: +滚动缓存在内存里. 不给这个问题事先准备什么, 出事了再捞: ``` ligent> /log # 看看现在缓存了多少 @@ -2005,13 +2005,13 @@ ligent> /log clear # 清空缓存 记录里**不含请求头**, 所以不会把 API key 写进去 (有测试断言). -它的真正用途是:**录一次真实会话, 之后离线回放**. ligent 的协议适配一直只在 +它的真正用途是: **录一次真实会话, 之后离线回放**. ligent 的协议适配一直只在 自建的 mock 服务端上验证过, 而 mock 是我们自己写的 —— 它接受什么, 完全取决于 我们**以为**服务端会接受什么. 有了这个文件, 真实服务端的响应就能当成测试夹具 反复使用, 不必每次都烧 token. `tests/replay.py` 就是这个回放器. 它不重写一个"模拟客户端", 而是当**离线 -服务端**:把录下来的响应原样发给一个真的 ligent, 于是 `http.c` 的分块读取、 +服务端**: 把录下来的响应原样发给一个真的 ligent, 于是 `http.c` 的分块读取、 `llm.c` 的 SSE 行切分与 `tool_calls` 增量重组, 全都在真实字节上跑一遍. ```bash @@ -2021,24 +2021,24 @@ python3 tests/replay.py --serve 8899 session.log --strict # 当离线服务端 ./ligent --no-probe --base-url http://127.0.0.1:8899/v1 "please run it" ``` -`--strict` 是它最有用的一半:它把每个进来的请求体与录制时那一份做**结构比对** +`--strict` 是它最有用的一半: 它把每个进来的请求体与录制时那一份做**结构比对** (顶层键、消息角色序列、工具名集合), 不一致就回 400 并在 stderr 打印差异. 于是"今天的客户端还在发同样的报文形状"变成了一条**会失败的断言**——改坏了 报文而没发现, 从此不可能. -回放时要给 ligent 加 `--no-probe`:能力探测会插进额外的请求, 而那几轮不在 +回放时要给 ligent 加 `--no-probe`: 能力探测会插进额外的请求, 而那几轮不在 录制里 (录制时也建议 `LIGENT_PROBE=0`, 两边一致). 录制用完之后回放器会明确 报"没有更多响应", 而不是静静回错东西. 仓库里冻了两份夹具 (`tests/fixtures/`), 都是对着 mock 录的 —— mock 故意把 -SSE 切成 1 字符、把 `tool_calls` 也切碎, 所以它们冻结的正是最值得冻结的形状: +SSE 切成 1 字符、把 `tool_calls` 也切碎, 所以它们冻结的正是最值得冻结的形状: | 夹具 | 冻住的是 | |---|---| | `session.log` | 流式 SSE + 被切碎的 `tool_calls` 重组 + 工具往返 + 最终回答 | | `error.log` | 一个 400 错误体, 以及错误信息的可读化 | -**录一份真实会话** (你自己的 key, 跑一次真问题): +**录一份真实会话** (你自己的 key, 跑一次真问题): ```bash ligent # 正常聊, 直到复现出你要冻住的那个行为 @@ -2051,7 +2051,7 @@ python3 tests/replay.py session.log --summary 内存上限 16MB, 超出后丢弃较早的部分 (排障时通常更关心刚刚发生了什么), 导出时会在文件开头注明发生了截断. -> 注意:`/log` 是 REPL 命令. 单次任务模式 (`ligent "任务"`)下记录同样会累积, +> 注意: `/log` 是 REPL 命令. 单次任务模式 (`ligent "任务"`)下记录同样会累积, > 但没有地方把它导出来. ### 对抗性测试 @@ -2062,16 +2062,16 @@ Markdown 渲染器); `tests/reduce.c` 把失败输入最小化成最短复现. ## 9. 已知限制 -- 界面文案只有中英两份, 而且只管"给人看"的部分:**工具描述、工具回执、 +- 界面文案只有中英两份, 而且只管"给人看"的部分: **工具描述、工具回执、 系统提示词不随界面语言变** (那些是给模型的信息). 切成英文之后模型收到的 仍然是中文回执 —— 想让模型侧也换语言, 得改提示词本身. - 只在 Linux 上验证过 (用到 `fork/setsid/pipe/poll`、`fnmatch`、POSIX 正则). - `--timeout` 只对非流式请求生效; 流式请求靠 120 秒无数据即断开的低速检测. -- 历史超过模型窗口时**只报错, 不自动回收** (见§8):要不要丢几轮、丢哪几轮由你 +- 历史超过模型窗口时**只报错, 不自动回收** (见§8): 要不要丢几轮、丢哪几轮由你 用 `/back`、`/reset`、`/meta` 决定. 单轮内工具输出过大 (超过上限被截断)时, 只能靠模型自己用 `offset/limit` 分段读. - 每轮都会重发完整历史, token 成本随轮数平方增长. -- 终端交互是行式 REPL, 不是全屏 TUI:有行内编辑与历史, 但**没有 Tab 补全**. +- 终端交互是行式 REPL, 不是全屏 TUI: 有行内编辑与历史, 但**没有 Tab 补全**. - 对话历史本身只存在进程内, 退出即丢 —— 要留就 `/export`. (输入历史是持久化的, 见下一节. ) - Markdown 渲染不处理嵌套列表缩进. @@ -2083,12 +2083,12 @@ Markdown 渲染器); `tests/reduce.c` 把失败输入最小化成最短复现. - `/include` 的 glob 是 shell 语义 (`glob(3)`), `*` 不跨目录; 要递归就进 交互式界面逐层进, 或者自己写多级模式. - `/include` 一次最多 32 个文件、单文件 256 KB、累计 1 MB. -- `/models` 的列表只影响"你能看到什么", 不影响"你能填什么":输入行里直接写 +- `/models` 的列表只影响"你能看到什么", 不影响"你能填什么": 输入行里直接写 名字可以指定任何模型, 包括服务端没列出来的内部模型. - `/history`、`/back`、`/unback`、`/export`、`/import` 报的"历史条数"**不含 system 提示词** —— 它是配置不是对话, 删掉唯一一轮之后应当显示 0 条. 只有 `/diff` (meta)报的是数组真实条数, 因为它要跟 `#0 #1 ...` 的下标对上. -- 图片只进不出:`read_image` 能让模型看图, 但模型没有画图能力. +- 图片只进不出: `read_image` 能让模型看图, 但模型没有画图能力. - 能力探测靠"发请求试", 因为**根本不存在**查询能力的接口. 代价是开启探测时 每个进程的第一次请求前会多出 2~4 个小请求 (换模型会重测, 切回来命中缓存). `--no-probe` 可以彻底关掉, 回到"全都暴露"的老行为. 探测结论不确定时按支持 @@ -2099,12 +2099,12 @@ Markdown 渲染器); `tests/reduce.c` 把失败输入最小化成最短复现. - 历史里的图片是对 `ligent.archive/images/` 的引用, **备份才是字节的真身**. 把 `ligent.archive` 删掉或移走, 图片就展开不出来, 会被降级成文字说明 (不会让对话卡死, 但图也就没了). `/export` 的存档同理, 不是自包含的. -- 图片只支持内联, **不支持直接给一个外部 URL**:ligent 不去网上抓图, `read_image` +- 图片只支持内联, **不支持直接给一个外部 URL**: ligent 不去网上抓图, `read_image` 只收本地文件. (协议上的另一种走法是外部 URL, 需要服务端自己去下载. ) - 单张图 base64 后超过 32 MiB、或一次请求图片总量超过 44 MiB 会被本地拒绝, 模型会被告知"图片太大", 需要先缩小或压缩. - 工具结果按字节截断, 不按 token 计算, 极大文件需要配合 `offset/limit` 分段读. -- 二进制检测只看"有没有 NUL 字节":`read_file` 只探前 8 KB, 所以一个 +- 二进制检测只看"有没有 NUL 字节": `read_file` 只探前 8 KB, 所以一个 **前 8 KB 干净、之后才有 NUL** 的文件仍会被当文本读 (内容会在第一个 NUL 处被 悄悄截断). `edit_file` 不受影响 —— 它看全文件. UTF-16、压缩包、可执行文件这类 在开头就有 NUL, 都能识别出来. @@ -2118,12 +2118,12 @@ Markdown 渲染器); `tests/reduce.c` 把失败输入最小化成最短复现. ligent 不做方言探测. 顺带一提, `reasoning_content` ligent 读的时候两种 名字都认, 但回传时**只写 `reasoning_content`** —— 对着用 `reasoning` 的 服务端可能会丢失思考上下文. -- `/shell` 的记录对全屏程序是"整段忽略画面"而不是"还原画面":`nano`/`vim` 这类 +- `/shell` 的记录对全屏程序是"整段忽略画面"而不是"还原画面": `nano`/`vim` 这类 用备用屏幕的程序, 记录里只剩命令行和退出后的输出. 极少数既不用备用屏幕、 又逐行重绘的程序 (`TERM` 被强行设成不认识的值时)只能靠压缩空白降噪; 真正逐屏还原需要一整套终端模拟器, 不在当前范围内. -**验证上的缺口** (这些地方是"我没验过", 不是"我验过没问题"): +**验证上的缺口** (这些地方是"我没验过", 不是"我验过没问题"): - **提权的真实路径没端到端跑过. ** e2e 里用的是桩 `sudo` (它要求有 tty 并把 argv 记下来, 专门守着 `setsid` 那个回归), 真实 sudo 的密码提示路径没有测过 @@ -2137,6 +2137,6 @@ Markdown 渲染器); `tests/reduce.c` 把失败输入最小化成最短复现. `CURLOPT_CAINFO`. - **`clang --analyze` 还剩 3 条告警**, 我判断是误报 (`fgets` 前已置 `errno=0`; `fread` 的 EOF 循环惯用法), 但没有能力证明. `gcc -fanalyzer` 在 `image.c` 报 - 的那条 FILE 泄漏同理:`-O0` 下干净、`-O1` 起才报、把函数原样抽出来单独编也 + 的那条 FILE 泄漏同理: `-O0` 下干净、`-O1` 起才报、把函数原样抽出来单独编也 不报, 我判断是内联导致的误报 —— 换个 gcc 版本结论可能变. - 打断 (Ctrl-C)路径只在几处有覆盖, 不全面. diff --git a/ligent.example.json b/ligent.example.json index d2f7bb2..f5e42a3 100644 --- a/ligent.example.json +++ b/ligent.example.json @@ -20,5 +20,5 @@ "_probe注释": "首个请求前先试探模型收不收图片/tools, 据此决定暴露哪些工具; 关掉就不额外发请求", "probe": true, - "system": "可选:完全覆盖内置的 system 提示词" + "system": "可选: 完全覆盖内置的 system 提示词" } diff --git a/src/agent.c b/src/agent.c index e550069..b843ae7 100644 --- a/src/agent.c +++ b/src/agent.c @@ -749,7 +749,7 @@ int agent_run(Agent *a) /* "这一轮"的定义就在这儿: 一条用户消息引发的一整串工具调用. * 确认提示上的 Always/neVer 只活到这里, 下一条消息进来时复位. */ confirm_reset_turn(); - int img_dropped = 0; /* 图片被服务端拒了只降级一次, 免得来回打转 */ + int img_dropped = 0; /* 图片被服务端拒了只降级一次, 以免来回打转 */ agent_ensure_caps(a); @@ -800,7 +800,7 @@ int agent_run(Agent *a) tf("agent.img.rejected", "n", i18n_num(n), NULL), col(a, C_OFF)); /* 模型那边只收到一句能照做的中文; 原始报错留在这里, - * 免得"为什么被拒"这个信息彻底消失 */ + * 以免"为什么被拒"这个信息彻底消失 */ fprintf(stderr, "%s%s%s\n", col(a, C_DIM), tf("agent.img.server_said", "error", err, NULL), col(a, C_OFF)); diff --git a/src/agent.h b/src/agent.h index 3494800..2627d48 100644 --- a/src/agent.h +++ b/src/agent.h @@ -1,4 +1,4 @@ -/* agent.h - agent 主循环:多轮对话 + 工具调用 */ +/* agent.h - agent 主循环: 多轮对话 + 工具调用 */ #ifndef LIGENT_AGENT_H #define LIGENT_AGENT_H @@ -21,7 +21,7 @@ struct Agent { int show_tool_output; int confirm; /* 写操作与命令执行前是否询问 */ int probe_caps; /* 首个请求前跑一次能力探测序列 */ - Capabilities caps; /* 探测结果:决定要不要暴露 read_image / 工具表 */ + Capabilities caps; /* 探测结果: 决定要不要暴露 read_image / 工具表 */ /* 工具分发. 默认指向 tools.c 里那套; meta 会话换成自己的, * 于是两边的工具集天然隔离 —— 主模型看不到也调不到 meta 的工具, @@ -51,7 +51,7 @@ char *agent_default_system_prompt(void); void agent_add_system(Agent *a, const char *prompt); void agent_add_user(Agent *a, const char *text); -/* 加一条 user 消息:文本 + 若干内容块 (parts 所有权转移, 可为 NULL). +/* 加一条 user 消息: 文本 + 若干内容块 (parts 所有权转移, 可为 NULL). * /include 用它把文件和图片跟用户这一句话一起发出去. */ void agent_add_user_parts(Agent *a, const char *text, JsonValue *parts); @@ -59,7 +59,7 @@ void agent_add_user_parts(Agent *a, const char *text, JsonValue *parts); * 探测被关掉时什么都不做. */ void agent_ensure_caps(Agent *a); -/* 手动探测一次:**无视** --no-probe 与缓存, 重新测 (REPL 的 /probe 用它). +/* 手动探测一次: **无视** --no-probe 与缓存, 重新测 (REPL 的 /probe 用它). * 与自动探测的唯一区别就是这两点 —— 打出来的结论是同一份. */ void agent_probe_now(Agent *a); @@ -74,7 +74,7 @@ void agent_reset(Agent *a); * 返回 0 正常结束, -1 出错, 1 被用户中断. */ int agent_run(Agent *a); -/* 结构体检:保证 assistant(tool_calls) 后面紧跟对应的 tool 消息. +/* 结构体检: 保证 assistant(tool_calls) 后面紧跟对应的 tool 消息. * 没问题返回 NULL; 修过则返回重建好的数组 (调用方负责替换旧的). * *out_fixed 回填补/删的处数. */ JsonValue *agent_repair_tool_pairing(const JsonValue *messages, int *out_fixed); @@ -82,7 +82,7 @@ JsonValue *agent_repair_tool_pairing(const JsonValue *messages, int *out_fixed); /* 当前历史的消息条数 (含 system 提示词, 内部比对用) */ size_t agent_history_len(const Agent *a); -/* 面向用户的"历史消息数":不含 system 提示词 */ +/* 面向用户的"历史消息数": 不含 system 提示词 */ size_t agent_conversation_len(const Agent *a); /* 用 given 整体替换历史 (取得所有权). 存档里的 system 消息会被丢掉, @@ -92,13 +92,13 @@ void agent_replace_history(Agent *a, JsonValue *messages); /* 摘掉最近一整轮 (最后一条 user 消息及其之后的一切), 返回被摘掉的消息数组, * 没有可摘的则返回 NULL. 调用者负责释放. - * 用于 /back:模型调用的工具与结果必须整组一起走, 不能只删中间几条. */ + * 用于 /back: 模型调用的工具与结果必须整组一起走, 不能只删中间几条. */ JsonValue *agent_take_last_round(Agent *a); /* 服务端回了 4xx (图片引用失效的重传就靠它触发) */ int agent_is_http_4xx(const char *err); -/* 这条 user 消息是否开启新的一轮. 判据见 agent.c:**只有图片附件、没有文字的** +/* 这条 user 消息是否开启新的一轮. 判据见 agent.c: **只有图片附件、没有文字的** * user 消息不算新的一轮 —— 它是 read_image 补上去的, 按它切轮会把紧邻的 * assistant(tool_calls) 与 tool 结果拆散 (下一轮请求直接 400). * meta.c 切轮时必须用同一个判据, 否则它报的"轮号"和主循环的口径对不上. */ diff --git a/src/archive.c b/src/archive.c index 9b50218..60732fe 100644 --- a/src/archive.c +++ b/src/archive.c @@ -62,7 +62,7 @@ int archive_next_name(const char *bindir, const char *lineage, char suffix[ARCHIVE_META_LEN + 1]; random_alnum(suffix, ARCHIVE_BRANCH_LEN); if (meta) { - /* _m<3位>:标记 + 随机, 长度 4 与普通分支的 3 区分开 */ + /* _m<3位>: 标记 + 随机, 长度 4 与普通分支的 3 区分开 */ char tail[ARCHIVE_META_LEN + 1]; tail[0] = ARCHIVE_META_MARK; memcpy(tail + 1, suffix, ARCHIVE_BRANCH_LEN + 1); diff --git a/src/archive.h b/src/archive.h index 89edee7..07d1f85 100644 --- a/src/archive.h +++ b/src/archive.h @@ -1,6 +1,6 @@ -/* archive.h - 对话存档:导出与载入 +/* archive.h - 对话存档: 导出与载入 * - * 命名方式刻意做成"文件名即谱系": + * 命名方式刻意做成"文件名即谱系": * * a1b2c3d4 从零开始的一段对话 * a1b2c3d4_a9x 从 a1b2c3d4 分出来的分支 @@ -10,7 +10,7 @@ * 实际文件不同步而出错. 代价是名字会越来越长, 所以超过长度上限时 * 重新起一个 8 位随机根 (谱系就此断开, 但至少不会撑爆文件名). * - * 存档放在「二进制所在目录」下的 ligent.archive/ 里 —— 和配置同一个原则: + * 存档放在「二进制所在目录」下的 ligent.archive/ 里 —— 和配置同一个原则: * 整个程序连同它的数据都待在一个目录里, 拷走即走. */ #ifndef LIGENT_ARCHIVE_H @@ -24,7 +24,7 @@ /* 根名与每级分支后缀的长度 */ #define ARCHIVE_ROOT_LEN 8 #define ARCHIVE_BRANCH_LEN 3 -/* meta 改过的对话另起一个根, 但名字里留住出处:<原名>_m<3位>. +/* meta 改过的对话另起一个根, 但名字里留住出处: <原名>_m<3位>. * 标记是 'm', 所以"下划线后有 4 个字符"只可能是 meta 根, * 而普通分支固定是 3 个 —— 按长度就能分开, 不会歧义. */ #define ARCHIVE_META_MARK 'm' @@ -40,11 +40,11 @@ typedef struct { long count; /* 消息条数; -1 表示读取失败 */ } ArchiveEntry; -/* 存档目录:/ligent.archive (malloc) */ +/* 存档目录: /ligent.archive (malloc) */ char *archive_dir(const char *bindir); /* 临时存档的前缀. /import 前那次保底存档用它, 以便和用户主动存下来的分支 - * 区分开:它只是"换掉当前对话之前先留一手", 不属于分支树的一部分. */ + * 区分开: 它只是"换掉当前对话之前先留一手", 不属于分支树的一部分. */ #define ARCHIVE_TMP_PREFIX "tmp_" /* 生成下一个可用的文件名 (不含 .json), 保证在目录里不冲突. @@ -59,7 +59,7 @@ int archive_next_name(const char *bindir, const char *lineage, char *errbuf, size_t errlen); /* 导出. lineage 为来源存档名 (NULL/空 = 新根), prefix 见上. - * meta=1 表示这份是 meta 编辑过的:名字用 _m<3位>, 且 parent 记空. + * meta=1 表示这份是 meta 编辑过的: 名字用 _m<3位>, 且 parent 记空. * 成功返回 0, out_path 与 out_name 均为 malloc (可为 NULL). */ int archive_export(const JsonValue *messages, const char *bindir, const char *lineage, const char *prefix, int meta, diff --git a/src/attach.c b/src/attach.c index 23a7bfd..d32de57 100644 --- a/src/attach.c +++ b/src/attach.c @@ -1,4 +1,4 @@ -/* attach.c - /include 的实现:暂存、glob 展开、交互式挑选 */ +/* attach.c - /include 的实现: 暂存、glob 展开、交互式挑选 */ #include "attach.h" #include "i18n.h" #include "image.h" @@ -21,7 +21,7 @@ static size_t g_text_bytes; /* 文本累计字节数, 用来卡总量 */ /* 每个块对应的来源路径, 只用于给用户看. * - * 刻意**不**把它塞进块里:那会跟着请求一起发给服务端, 而 content 块里多一个 + * 刻意**不**把它塞进块里: 那会跟着请求一起发给服务端, 而 content 块里多一个 * 自造字段是可以被严格的服务端判 400 的. 显示用的东西就该留在本地. */ static char **g_names; static size_t g_names_n, g_names_cap; @@ -100,7 +100,7 @@ void attach_print(void) /* 单个文件的处理 */ /* ------------------------------------------------------------------ */ -/* 二进制判定 (判据与 8 KB 窗口都在 util.h, 和 read_file 共用一份): +/* 二进制判定 (判据与 8 KB 窗口都在 util.h, 和 read_file 共用一份): * 以前这里扫的是**整个文件**而 read_file 只看前 8 KB, 于是同一个文件会出现 * "/include 说它是二进制、read_file 说它能读"这种自相矛盾的说法. */ @@ -132,7 +132,7 @@ static int add_one(const char *path, AttachVisionFn can_image, void *ud, char *err = NULL; char *backup = image_backup_from_path(path, NULL, NULL, &err); if (!backup) { - /* 超限/写不了备份:把原话带出去 */ + /* 超限/写不了备份: 把原话带出去 */ static char buf[400]; snprintf(buf, sizeof buf, "%s", err ? err : t("attach.err.read_failed")); @@ -171,7 +171,7 @@ static int add_one(const char *path, AttachVisionFn can_image, void *ud, } /* 头 + 正文. 头用 ===== 包起来, 模型一眼能看出文件从哪开始. - * 文件不以换行结尾时补一个, 免得下一个文件头黏在最后一行后面. */ + * 文件不以换行结尾时补一个, 以免下一个文件头黏在最后一行后面. */ StrBuf sb; sb_init(&sb); sb_printf(&sb, "===== %s (%zu 字节)=====\n", path, len); @@ -206,10 +206,10 @@ int attach_glob(const char *pattern, AttachVisionFn can_image, void *ud, glob_t g; memset(&g, 0, sizeof g); - /* 认 ~/. **用 #ifdef 包起来**:GLOB_TILDE 是 GNU 扩展, glibc 一定有, + /* 认 ~/. **用 #ifdef 包起来**: GLOB_TILDE 是 GNU 扩展, glibc 一定有, * 而 musl 那边不敢打包票 (它挂在编译期 —— 没有这个宏就是编译失败, * 静态 musl 构建会直接挂在这儿). 有就带, 没有就退化成普通路径展开. - * 不开 GLOB_BRACE:{a,b} 是 shell 特有写法, 开了会让用户以为别的 shell + * 不开 GLOB_BRACE: {a,b} 是 shell 特有写法, 开了会让用户以为别的 shell * 语法也能用. */ int gflags = 0; #ifdef GLOB_TILDE @@ -252,7 +252,7 @@ int attach_glob(const char *pattern, AttachVisionFn can_image, void *ud, } /* ------------------------------------------------------------------ */ -/* 交互式挑选:逐层进入目录 */ +/* 交互式挑选: 逐层进入目录 */ /* ------------------------------------------------------------------ */ #define PICK_MAX_SHOW 200 @@ -301,7 +301,7 @@ static void free_list(char **l, size_t n) free(l); } -/* 相对 dir 解析用户输入:可能是序号、目录名、路径或 glob. +/* 相对 dir 解析用户输入: 可能是序号、目录名、路径或 glob. * 返回选中的路径列表 (malloc 数组), *n 回填个数. */ static char **resolve_input(const char *dir, const char *input, size_t *n) { @@ -410,7 +410,7 @@ int attach_pick(const char *start, AttachVisionFn can_image, void *ud, char *picked = xstrdup(items[idx - 1]); size_t pl = strlen(picked); if (pl > 0 && picked[pl - 1] == '/') { - /* 进目录:去掉尾斜杠 */ + /* 进目录: 去掉尾斜杠 */ picked[pl - 1] = '\0'; free(dir); dir = picked; @@ -431,7 +431,7 @@ int attach_pick(const char *start, AttachVisionFn can_image, void *ud, continue; } - /* 其它:当成 glob / 路径 / 目录名 */ + /* 其它: 当成 glob / 路径 / 目录名 */ size_t rn = 0; char **res = resolve_input(dir, ans, &rn); free(line); diff --git a/src/attach.h b/src/attach.h index b1259ea..8c15896 100644 --- a/src/attach.h +++ b/src/attach.h @@ -1,15 +1,15 @@ -/* attach.h - /include:把文件附带进下一条消息 +/* attach.h - /include: 把文件附带进下一条消息 * - * 设计成"暂存"而不是"立刻发出去":附带文件的意义在于配合一个提问 + * 设计成"暂存"而不是"立刻发出去": 附带文件的意义在于配合一个提问 * ("这几个文件有什么问题"), 所以 /include 先把文件攒起来, 下一条消息 * 带上它们一起发. 暂存是进程内状态, 发完就清空. * - * 附带的文件在会话里有两种形态: + * 附带的文件在会话里有两种形态: * 文本 -> 直接作为 text 块 (就是内容本身) * 图片 -> 存成本机备份, 历史里只放 ligent_image 引用 —— 和 read_image * 完全同一条路, 图片字节不进 JSON, 构造请求时才展开成 base64. * - * 图片有额外前提:**模型得能看图**. 所以附带图片前会先用能力探测的结论 + * 图片有额外前提: **模型得能看图**. 所以附带图片前会先用能力探测的结论 * 确认一次, 不支持就跳过并如实说明, 而不是把图塞进去让服务端 400. */ #ifndef LIGENT_ATTACH_H @@ -38,7 +38,7 @@ int attach_has_image(void); /* 遇到图片时回调一次, 问"当前模型能收图吗", 返回 0 就跳过这张图. * - * 做成回调而不是一个布尔入参:判断"能不能收图"要跑能力探测, 而探测是要发 + * 做成回调而不是一个布尔入参: 判断"能不能收图"要跑能力探测, 而探测是要发 * 请求的. 纯文本的 /include 不该为了一张图都没有的一次附带去探测模型. */ typedef int (*AttachVisionFn)(void *ud); @@ -49,7 +49,7 @@ typedef int (*AttachVisionFn)(void *ud); int attach_glob(const char *pattern, AttachVisionFn can_image, void *ud, size_t *skipped, char *err, size_t errlen); -/* 交互式挑选:打印目录列表, 用普通输入行逐层进入 (可递归), +/* 交互式挑选: 打印目录列表, 用普通输入行逐层进入 (可递归), * 也能直接敲 glob 或路径. 从 dir 开始 (NULL = 当前目录). * 返回新增的文件数; 用户取消返回 0. */ int attach_pick(const char *dir, AttachVisionFn can_image, void *ud, diff --git a/src/capability.c b/src/capability.c index 55efc20..7a7c540 100644 --- a/src/capability.c +++ b/src/capability.c @@ -1,4 +1,4 @@ -/* capability.c - 模型能力探测:基线 -> 合并 -> 单独拆开 */ +/* capability.c - 模型能力探测: 基线 -> 合并 -> 单独拆开 */ #include "capability.h" #include "http.h" #include "i18n.h" @@ -9,13 +9,13 @@ #include #include -/* 一张 1x1 的 PNG. 探测只需要"一个合法的图片字节流", 越小越好: +/* 一张 1x1 的 PNG. 探测只需要"一个合法的图片字节流", 越小越好: * 服务端会把它缩放后按图计费, 尺寸再大也换不来更多信息. */ static const char PROBE_PNG_B64[] = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLv" "AAAAAElFTkSuQmCC"; -/* 缓存:按 base_url + model 索引. 同一个进程里换模型会自然重测, +/* 缓存: 按 base_url + model 索引. 同一个进程里换模型会自然重测, * 切回来则命中. 条数很少 (人手切的模型个数). */ #define CAP_CACHE_MAX 8 @@ -69,9 +69,9 @@ static char *chat_url(const char *base_url) /* 一个试探请求的结论 */ /* 注意别叫 R_OK —— 里那个是 access() 的权限位, 会撞名 */ typedef enum { - PROBE_OK = 0, /* 2xx:接受 */ - PROBE_REJECT = 1, /* 400:明确拒绝 (只有在基线通过时才有意义) */ - PROBE_UNKNOWN = 2 /* 网络、401/403/404/429/5xx……:什么也说明不了 */ + PROBE_OK = 0, /* 2xx: 接受 */ + PROBE_REJECT = 1, /* 400: 明确拒绝 (只有在基线通过时才有意义) */ + PROBE_UNKNOWN = 2 /* 网络、401/403/404/429/5xx……: 什么也说明不了 */ } ProbeResult; static ProbeResult probe(const LlmConfig *cfg, const char *url, @@ -82,7 +82,7 @@ static ProbeResult probe(const LlmConfig *cfg, const char *url, JsonValue *body = json_new_object(); json_object_set_string(body, "model", cfg->model); json_object_set(body, "messages", json_clone(messages)); - /* 只让它回一个 token:探测要的是"收不收", 不是回复内容. + /* 只让它回一个 token: 探测要的是"收不收", 不是回复内容. * 不设这个的话, 默认开着思考的模型会为了回答"1"生成一大段推理. */ json_object_set_number(body, "max_tokens", 1); json_object_set_bool(body, "stream", 0); @@ -108,7 +108,7 @@ static ProbeResult probe(const LlmConfig *cfg, const char *url, long status = 0; char local_err[256]; local_err[0] = '\0'; - /* 探测不重试:它只是一次判断, 服务端抖一下还有兜底 (一律按支持处理), + /* 探测不重试: 它只是一次判断, 服务端抖一下还有兜底 (一律按支持处理), * 不值得为此让每个进程启动都多等几个退避周期. */ HttpRetry no_retry = HTTP_RETRY_NONE; int rc = http_post(url, cfg->api_key, text, cfg->timeout, verbose, @@ -126,7 +126,7 @@ static ProbeResult probe(const LlmConfig *cfg, const char *url, return PROBE_UNKNOWN; } -/* 探测用的消息:一句 "1", 可选带一张图 */ +/* 探测用的消息: 一句 "1", 可选带一张图 */ static JsonValue *probe_messages(int with_image) { JsonValue *msgs = json_new_array(); @@ -185,20 +185,20 @@ static void run_sequence(const LlmConfig *cfg, Capabilities *out, } out->conclusive = 1; - /* --- 2. 合并试探:图片 + tools --- */ + /* --- 2. 合并试探: 图片 + tools --- */ JsonValue *both = probe_messages(1); ProbeResult combined = probe(cfg, url, both, 1, verbose, &status, errbuf, errlen); json_free(both); if (combined == PROBE_OK) { - /* 常见情况:两样都支持, 两个请求收工 */ + /* 常见情况: 两样都支持, 两个请求收工 */ out->vision = out->tools = CAP_YES; if (verbose) fprintf(stderr, "%s", t("capability.note.both_ok")); free(url); return; } - /* --- 3. 拆开单独试:才知道是哪一个不行 --- */ + /* --- 3. 拆开单独试: 才知道是哪一个不行 --- */ JsonValue *img = probe_messages(1); ProbeResult vr = probe(cfg, url, img, 0, verbose, &status, errbuf, errlen); json_free(img); @@ -247,7 +247,7 @@ int cap_get(const LlmConfig *cfg, int enable, Capabilities *out, const char *base = (cfg && cfg->base_url) ? cfg->base_url : ""; if (!enable) { - /* 探测被关掉:不下任何结论, 一切都按"支持" */ + /* 探测被关掉: 不下任何结论, 一切都按"支持" */ out->vision = out->tools = CAP_UNKNOWN; out->probed = 0; out->conclusive = 0; diff --git a/src/capability.h b/src/capability.h index c476487..4859eb8 100644 --- a/src/capability.h +++ b/src/capability.h @@ -1,25 +1,25 @@ /* capability.h - 模型能力探测 (vision / tools) * - * 为什么需要它:OpenAI 兼容层**没有任何查询能力的接口**. DeepSeek 和 MiMo 的 + * 为什么需要它: OpenAI 兼容层**没有任何查询能力的接口**. DeepSeek 和 MiMo 的 * GET /v1/models 都只返回 id / object / owned_by, 没有 capability / modality * 字段. 所以"这个模型收不收图片"只能靠**发一个试探请求**问出来. * - * 这里叫"序列"是因为它不是一次请求, 而是有先后的几步: + * 这里叫"序列"是因为它不是一次请求, 而是有先后的几步: * - * 1. 基线:一句纯文本 + max_tokens=1 + * 1. 基线: 一句纯文本 + max_tokens=1 * └ 失败 → 什么都别下结论 (可能是 key、网络、模型名的问题) * └ 成功 → 说明模型是活的, 后面的否定结论才有意义 - * 2. 合并试探:图片 + tools 一起发 + * 2. 合并试探: 图片 + tools 一起发 * └ 200 → 两样都支持 (常见情况, 到此为止, 只花了 2 个请求) * └ 非 200 → 拆开单独试, 才知道是哪一个不行 - * 3. 单独试探:图片、tools 各发一次 + * 3. 单独试探: 图片、tools 各发一次 * - * 关键在于**否定结论只在基线成功时才敢下**:单个试探 400 而基线 200, 说明 + * 关键在于**否定结论只在基线成功时才敢下**: 单个试探 400 而基线 200, 说明 * 差别就在那一样东西上. 这样不需要去猜服务端的错误文案 ("不支持图片"、 * "invalid content"、"multimodal not supported"…… 各家写法都不一样), * 也就不会因为文案变化而误判. * - * 猜不准的时候一律按"支持"处理:多暴露一个工具, 代价是模型可能白调一次 + * 猜不准的时候一律按"支持"处理: 多暴露一个工具, 代价是模型可能白调一次 * (我们还有 4xx 降级兜底); 而错误地藏掉工具, 用户会以为模型看不见图. * * 结果按 base_url + model 缓存, 所以**换模型会自动重新测**, 切回来则命中缓存. diff --git a/src/config.c b/src/config.c index d698e2a..e3a5b25 100644 --- a/src/config.c +++ b/src/config.c @@ -30,7 +30,7 @@ void config_defaults(LigentConfig *c) c->reasoning_effort = NULL; /* 不发送 */ c->timeout = 300; c->stream = 1; - /* max_steps <= 0 = 不限制单轮往返次数 (默认). 这是**默认**值:模型陷进 + /* max_steps <= 0 = 不限制单轮往返次数 (默认). 这是**默认**值: 模型陷进 * "调工具 → 看结果 → 再调" 的循环时不会再被我们自己掐断, 由用户 Ctrl-C * (或模型自己收尾)来结束. 要恢复上限就设个正数. */ c->max_steps = 0; @@ -94,7 +94,7 @@ int config_parse_thinking(const char *s, int *out) return -1; } -/* 配置文件里 thinking 既可以是布尔也可以是字符串: +/* 配置文件里 thinking 既可以是布尔也可以是字符串: * `"thinking": true` 比 `"thinking": "on"` 自然, 两种都收. */ static int load_thinking(LigentConfig *c, const JsonValue *v, const char *path, char *errbuf, size_t errlen) @@ -115,7 +115,7 @@ static int load_thinking(LigentConfig *c, const JsonValue *v, /* ---- 定位二进制所在目录 ---- * - * 关键点:路径要"跟着用户看到的那个文件走". 如果 ligent 是个软链接, + * 关键点: 路径要"跟着用户看到的那个文件走". 如果 ligent 是个软链接, * 用户是把它当 ./bin/ligent 用的, 配置就该在 ./bin/ 旁边, 而不是被 * 悄悄挪到源文件所在的目录去. * @@ -185,7 +185,7 @@ static char *dir_of_canonical(const char *path) char *config_binary_dir(const char *argv0) { - /* 优先 argv[0]:它反映用户实际调用的那个路径 */ + /* 优先 argv[0]: 它反映用户实际调用的那个路径 */ if (argv0 && *argv0) { if (strchr(argv0, '/')) { if (is_self(argv0)) { @@ -193,7 +193,7 @@ char *config_binary_dir(const char *argv0) if (d) return d; } } else { - /* 只是个名字:去 $PATH 里找 (返回的仍是软链接本身) */ + /* 只是个名字: 去 $PATH 里找 (返回的仍是软链接本身) */ char *full = search_path(argv0); if (full) { char *d = dir_of_canonical(full); @@ -203,7 +203,7 @@ char *config_binary_dir(const char *argv0) } } - /* 兜底:问内核可执行文件在哪. 注意这会解析软链接, + /* 兜底: 问内核可执行文件在哪. 注意这会解析软链接, * 只在 argv[0] 给不出答案时使用 */ char buf[PATH_MAX]; ssize_t n = readlink("/proc/self/exe", buf, sizeof buf - 1); @@ -227,7 +227,7 @@ char *config_default_path(const char *argv0) /* ---- 查找要加载的配置文件 ---- */ -/* 只认两个来源:命令行的显式指定, 以及二进制目录下的 ligent.json. +/* 只认两个来源: 命令行的显式指定, 以及二进制目录下的 ligent.json. * 不做用户级 / 系统级 / 当前工作目录的查找. */ static char *find_config_file(const char *explicit_path, const char *argv0) { @@ -279,7 +279,7 @@ int config_load_file(LigentConfig *c, const char *path, free(expanded); } if ((s = json_get_string(root, "model"))) set_str(&c->model, s); - /* 空字符串视为"没写":内置提示词里带着工具使用规范, + /* 空字符串视为"没写": 内置提示词里带着工具使用规范, * 静默丢掉会让 agent 行为明显变差; 而且下发一条空的 system 消息 * 既浪费又没有意义. */ if ((s = json_get_string(root, "system"))) { @@ -351,7 +351,7 @@ void config_apply_env(LigentConfig *c) /* 厂商专属的环境变量只在"确实在用这家"时才读. * - * 否则会出这种事:配置文件里写的是 OpenRouter 的 key, + * 否则会出这种事: 配置文件里写的是 OpenRouter 的 key, * 而 shell 里残留的 DEEPSEEK_API_KEY 把它悄悄顶掉, * 结果 A 家的密钥被发给了 B 家 —— 对方只会回一个含糊的 401. * 用户明明配对了却怎么都调不通, 排查方向还会被带偏. */ @@ -359,7 +359,7 @@ void config_apply_env(LigentConfig *c) int use_deepseek = strstr(url, "deepseek") != NULL; int use_openai = strstr(url, "api.openai.com") != NULL; - /* api_key 优先级: + /* api_key 优先级: * LIGENT_API_KEY (显式覆盖) > 配置文件 > 对应厂商的环境变量兜底 */ const char *key = getenv("LIGENT_API_KEY"); if (key && *key) { @@ -395,7 +395,7 @@ void config_apply_env(LigentConfig *c) c->auto_save = !(strcmp(v, "0") == 0 || strcmp(v, "false") == 0); if ((v = getenv("LIGENT_PROBE")) && *v) c->probe = !(strcmp(v, "0") == 0 || strcmp(v, "false") == 0); - /* 环境变量里写错了不报错、直接忽略:环境变量是临时开关, + /* 环境变量里写错了不报错、直接忽略: 环境变量是临时开关, * 为它把整个程序拦下来太粗暴. 配置文件和命令行则严格校验. */ if ((v = getenv("LIGENT_THINKING")) && *v) { int t; @@ -412,7 +412,7 @@ int config_init(LigentConfig *c, const char *explicit_path, const char *argv0, config_defaults(c); if (errbuf && errlen) errbuf[0] = '\0'; - /* 记下二进制目录:配置和以后任何数据都只放这里 */ + /* 记下二进制目录: 配置和以后任何数据都只放这里 */ free(c->config_dir); c->config_dir = config_binary_dir(argv0); diff --git a/src/config.h b/src/config.h index 884c166..9467d33 100644 --- a/src/config.h +++ b/src/config.h @@ -1,6 +1,6 @@ -/* config.h - 配置:内置默认值 < 配置文件 < 环境变量 < 命令行 +/* config.h - 配置: 内置默认值 < 配置文件 < 环境变量 < 命令行 * - * 唯一的配置文件位置是**二进制所在目录**下的 ligent.json: + * 唯一的配置文件位置是**二进制所在目录**下的 ligent.json: * * /path/to/ligent ← 可执行文件 * /path/to/ligent.json ← 配置 (不存在则用内置默认值) @@ -30,7 +30,7 @@ typedef struct { char *system; /* NULL = 使用内置提示词 */ double temperature; /* < 0 = 不发送 */ long max_tokens; /* <= 0 = 不发送 */ - int thinking; /* 思考模式:THINKING_AUTO / _OFF / _ON, 见下 */ + int thinking; /* 思考模式: THINKING_AUTO / _OFF / _ON, 见下 */ char *reasoning_effort; /* 思考级别; NULL/空 = 不发送 */ long timeout; /* 秒 */ int stream; @@ -46,7 +46,7 @@ typedef struct { void config_defaults(LigentConfig *c); -/* 加载配置:先读配置文件, 再用环境变量覆盖. 返回 0 成功. +/* 加载配置: 先读配置文件, 再用环境变量覆盖. 返回 0 成功. * * explicit_path 非 NULL 时只读该文件 (命令行 -c 的显式指定); * 为 NULL 时读 <二进制目录>/ligent.json. @@ -80,7 +80,7 @@ const char *config_thinking_name(int thinking); * 优先 /proc/self/exe, 其次 argv0 里的路径, 最后在 $PATH 里查 */ char *config_binary_dir(const char *argv0); -/* 配置文件的规范位置 (malloc):<二进制目录>/ligent.json */ +/* 配置文件的规范位置 (malloc): <二进制目录>/ligent.json */ char *config_default_path(const char *argv0); /* 打印当前生效配置 (api_key 打码) */ diff --git a/src/confirm.c b/src/confirm.c index 4fab869..f63bf67 100644 --- a/src/confirm.c +++ b/src/confirm.c @@ -1,7 +1,7 @@ /* confirm.c - 写操作确认 */ #include "confirm.h" #include "i18n.h" -#include "tools.h" /* TOOL_* 宏:工具名只在那里定义一次 */ +#include "tools.h" /* TOOL_* 宏: 工具名只在那里定义一次 */ #include "util.h" #include @@ -69,7 +69,7 @@ static void describe(const char *name, const JsonValue *args) char lines[96]; preview(path, 160, buf, sizeof buf); tool_lines_label(args, lines, sizeof lines); - /* 行号必须打出来:用户要确认的第一件事就是"改的是哪几行". + /* 行号必须打出来: 用户要确认的第一件事就是"改的是哪几行". * 没给 line 就是全文件替换 (工具不拒绝它), 摘要里直说"全文件". */ const char *scope = lines[0] ? lines : t("tool.call.all_lines"); if (json_get_bool(args, "replace_all", 0)) @@ -77,7 +77,7 @@ static void describe(const char *name, const JsonValue *args) fprintf(stderr, " %s %s\"%s\" [%s]%s\n", t("confirm.lbl.edit"), c(BOLD), buf, scope, c(OFF)); /* 然后摆出**带行号**的新旧对照, 再问 Y/n —— 用户就是照着它决定放不放行. - * 旧版这里打的是"替换 <旧> -> <新>":两边各截 60 字符、压成一行, 看不出 + * 旧版这里打的是"替换 <旧> -> <新>": 两边各截 60 字符、压成一行, 看不出 * 到底动了几行、动的是哪几行, 长一点的改动更是只剩两个省略号. */ tool_render_diff(stderr, args, " ", g_color); } else if (path) { @@ -107,7 +107,7 @@ int confirm_tool(const char *name, const JsonValue *args) { if (!g_enabled) return 1; - /* 本轮已经表过态就不再打扰. Always/neVer 只作用于这一轮: + /* 本轮已经表过态就不再打扰. Always/neVer 只作用于这一轮: * 下一条用户消息进来时 agent_run 会把策略复位成 ASK. */ if (g_turn == CONFIRM_ALWAYS) return 1; if (g_turn == CONFIRM_NEVER) return 0; @@ -115,12 +115,12 @@ int confirm_tool(const char *name, const JsonValue *args) * 去回一句"未知工具". strcmp(NULL, ...) 会直接崩. */ if (!name) return 1; - /* 非交互输入:没有人能回答, 按默认值 (Y)放行. + /* 非交互输入: 没有人能回答, 按默认值 (Y)放行. * 这也是"默认 Y"的自然推论 —— 问不到就等于不反对. */ if (!isatty(STDIN_FILENO)) return 1; /* 提权命令会在参数里明说 elevated, describe() 已经把它打出来了 —— - * 这里不需要额外的身份标注:没有常驻提权状态, 一次命令一次身份. */ + * 这里不需要额外的身份标注: 没有常驻提权状态, 一次命令一次身份. */ fprintf(stderr, "\n%s %s %s%s\n", c(YEL), t("confirm.lbl.about"), name ? name : "?", c(OFF)); describe(name, args); @@ -133,7 +133,7 @@ int confirm_tool(const char *name, const JsonValue *args) errno = 0; if (!fgets(line, sizeof line, stdin)) { fputc('\n', stderr); - /* 这里必须区分两种"读不到": + /* 这里必须区分两种"读不到": * * EINTR 用户按了 Ctrl-C. 他是在说"别问了" —— 必须拒绝. * 真 EOF stdin 关了, 没人能回答 —— 才按默认值放行. diff --git a/src/confirm.h b/src/confirm.h index 5d02cbe..bbe8993 100644 --- a/src/confirm.h +++ b/src/confirm.h @@ -14,7 +14,7 @@ void confirm_init(int enabled); void confirm_set_enabled(int on); int confirm_enabled(void); -/* 询问是否允许执行某个会改动东西的工具:显示将要做什么, 然后问 (Y/n). +/* 询问是否允许执行某个会改动东西的工具: 显示将要做什么, 然后问 (Y/n). * 返回 1 允许, 0 拒绝. 回车即默认允许. * * 拒绝时返回 0 而不是让整轮失败 —— 上层会把"用户拒绝了"作为工具结果 @@ -37,10 +37,10 @@ int confirm_turn_mode(void); int confirm_tool(const char *name, const JsonValue *args); -/* 可选:自定义"这个工具要做什么"的说明. +/* 可选: 自定义"这个工具要做什么"的说明. * 返回 malloc 的文本 (每行自带缩进), 返回 NULL 表示回退到内置描述. * - * meta 的工具需要它:那些参数里带整段消息内容, 内置描述既打不出来 + * meta 的工具需要它: 那些参数里带整段消息内容, 内置描述既打不出来 * 也不该打出来 —— 得总结成人话 ("压缩 2..6 轮, 6 条 -> 1 条"). */ typedef char *(*ConfirmDescriber)(const char *name, const JsonValue *args); void confirm_set_describer(ConfirmDescriber fn); diff --git a/src/doctor.c b/src/doctor.c index 6d23c7f..90a9d17 100644 --- a/src/doctor.c +++ b/src/doctor.c @@ -1,11 +1,11 @@ /* doctor.c - 环境体检 * - * 这是把前几轮踩过的坑固化成检查项: + * 这是把前几轮踩过的坑固化成检查项: * 1. 配置文件解析失败却只给一句"语法错误" (现在带行列号) * 2. shell 里残留的厂商环境变量顶掉配置文件里的密钥 * 3. base_url 重定向导致 Authorization 头被丢掉, 服务端回 401 * 4. 密钥与服务商不匹配 (把 DeepSeek 的 key 发给 OpenRouter) - * 这些问题的共同点是:报错信息指向的方向是错的, 靠猜要花很久. + * 这些问题的共同点是: 报错信息指向的方向是错的, 靠猜要花很久. */ #include "doctor.h" #include "version.h" @@ -40,11 +40,11 @@ static const char *c(const char *code) { return color_on ? code : ""; } static void section(const char *name) { - /* 括号也随语言走:英文里写【binary】很别扭 */ + /* 括号也随语言走: 英文里写【binary】很别扭 */ printf("\n%s%s%s\n", c(BOLD), tf("fmt.section", "name", name, NULL), c(OFF)); } -/* 按"显示宽度"补齐:中文一个字占 3 字节但只有 2 列, +/* 按"显示宽度"补齐: 中文一个字占 3 字节但只有 2 列, * 用 %-16s 会把中文标签那一列全部顶歪 */ static void label(const char *s, size_t width) { @@ -155,7 +155,7 @@ static void check_binary(const char *argv0) { section(t("doctor.sec.binary")); - /* 版本放最前面:报 bug 时第一件要问的就是"你用的是哪个版本". + /* 版本放最前面: 报 bug 时第一件要问的就是"你用的是哪个版本". * 带 git hash 才能在对方改过源码的情况下知道到底是哪份代码. */ field(t("doctor.lbl.version"), "%s", ligent_version_full()); @@ -226,7 +226,7 @@ static void check_config_file(const LigentConfig *cfg, const char *argv0, char *text = read_entire_file(cfg->config_path, &len); if (text) v = json_parse(text, NULL, 0); if (v) { - /* 这份清单必须与 config.c 的 config_load_file 认识的键一一对应: + /* 这份清单必须与 config.c 的 config_load_file 认识的键一一对应: * 它是"配置文件里到底设了哪些键"的唯一依据, 多写一个不存在的键、 * 或者漏掉一个真键, 这一行说的就是假话. (以前这里写的是 "log", * 而配置文件根本没有这个键, 同时真有的 "probe" 没有列出来. ) */ @@ -371,7 +371,7 @@ static void check_env(const LigentConfig *cfg) } } - /* 最坑的一种:厂商变量设了, 但这次用的是配置文件里的密钥 */ + /* 最坑的一种: 厂商变量设了, 但这次用的是配置文件里的密钥 */ const char *ds = getenv("DEEPSEEK_API_KEY"); const char *ok = getenv("OPENAI_API_KEY"); int used_from_env = cfg->api_key_env != NULL; @@ -435,7 +435,7 @@ static void check_network(const LigentConfig *cfg, int verbose) if (!proxy) proxy = getenv("https_proxy"); if (!proxy) proxy = getenv("ALL_PROXY"); if (!proxy) proxy = getenv("all_proxy"); - /* 一定要过 "%s":代理 URL 里带 % 是常事 (密码 %40、路径 %2F), + /* 一定要过 "%s": 代理 URL 里带 % 是常事 (密码 %40、路径 %2F), * 直接当格式串会读到不存在的参数. */ field(t("doctor.lbl.proxy"), "%s", proxy && *proxy ? proxy : t("doctor.val.proxy_unset")); @@ -483,7 +483,7 @@ static void check_network(const LigentConfig *cfg, int verbose) sub("%s", t("doctor.sub.auth_not_network")); } } else if (status == 404) { - /* 404 也不能当成"密钥没问题":请求多半在匹配到路由之前就被回绝了, + /* 404 也不能当成"密钥没问题": 请求多半在匹配到路由之前就被回绝了, * 这次探测根本没走到认证那一步. 而且 404 本身通常就是 base_url 写错了. */ marked(2, t("doctor.lbl.endpoint"), "%s", tf("doctor.val.endpoint_404", "url", url, NULL)); diff --git a/src/doctor.h b/src/doctor.h index 81d073e..eadbdb1 100644 --- a/src/doctor.h +++ b/src/doctor.h @@ -4,7 +4,7 @@ #include "config.h" -/* 把整条链路逐环检查一遍:二进制定位、配置文件、密钥来源与格式、 +/* 把整条链路逐环检查一遍: 二进制定位、配置文件、密钥来源与格式、 * 端点、环境变量干扰、参数合理性、网络连通性与重定向. * * do_network = 0 时跳过联网检查. diff --git a/src/http.c b/src/http.c index 1cb3933..2923104 100644 --- a/src/http.c +++ b/src/http.c @@ -1,4 +1,4 @@ -/* http.c - libcurl 封装:同步 POST、流式 POST、瞬态失败重试退避 */ +/* http.c - libcurl 封装: 同步 POST、流式 POST、瞬态失败重试退避 */ #include "http.h" #include "i18n.h" #include "util.h" @@ -19,7 +19,7 @@ volatile sig_atomic_t g_ligent_interrupt = 0; static char errsink[1]; /* ------------------------------------------------------------------ */ -/* 收发记录:进程内滚动缓存 */ +/* 收发记录: 进程内滚动缓存 */ /* ------------------------------------------------------------------ */ /* 上限. 一场长会话可能产生很多流量, 不能无限涨; @@ -36,7 +36,7 @@ static void log_append(const void *data, size_t n) if (!g_log_init) { sb_init(&g_log); g_log_init = 1; } const char *p = data; - if (n > LOG_MAX) { /* 单块就超限:只留尾部 */ + if (n > LOG_MAX) { /* 单块就超限: 只留尾部 */ p += n - LOG_MAX; n = LOG_MAX; g_log_truncated = 1; @@ -97,7 +97,7 @@ void http_log_free(void) int http_log_save(const char *path, size_t *out_bytes, char *errbuf, size_t errlen) { - /* 和其它 HTTP 入口一个规矩:没有落点也不能空手写字 */ + /* 和其它 HTTP 入口一个规矩: 没有落点也不能空手写字 */ if (!errbuf || !errlen) { errbuf = errsink; errlen = 1; } if (out_bytes) *out_bytes = 0; FILE *f = fopen(path, "a"); @@ -156,7 +156,7 @@ int http_status_retryable(long status) } } -/* 注意这里刻意不含 CURLE_OPERATION_TIMEDOUT: +/* 注意这里刻意不含 CURLE_OPERATION_TIMEDOUT: * 请求超时说明服务端很可能已经处理完了, 重发会重复扣费. */ static int curl_code_retryable(CURLcode rc) { @@ -238,7 +238,7 @@ static void ctx_free(HttpCtx *c) } /* 必须按 curl 的契约返回 size_t —— 返回值不等于 n 会被当成传输失败. - * 以前这里写的 int:(int)n 在 x86-64 上碰巧是对的 (写 eax 会清零 rax 高位), + * 以前这里写的 int: (int)n 在 x86-64 上碰巧是对的 (写 eax 会清零 rax 高位), * 所以 gcc 不吭声、测试全绿, 只有 clang 借 libcurl 的类型检查把它指了出来. * 换个 ABI, 高 32 位的垃圾值就会被当成「处理了多少字节」. */ static size_t header_cb(char *buffer, size_t size, size_t nitems, void *ud) @@ -298,7 +298,7 @@ static size_t write_cb(char *ptr, size_t size, size_t nmemb, void *ud) return r; } - /* 非流式:带上限累积, 超了就中止传输 */ + /* 非流式: 带上限累积, 超了就中止传输 */ if (c->body.len + n > HTTP_MAX_BODY) { c->overflow = 1; return 0; @@ -384,7 +384,7 @@ static CURLcode attempt(const char *url, const char *api_key, const char *body, /* 必须**在最前面**清空. 下面 curl_easy_init 失败会直接 return, 而调用方 * 随后会拿 cerr 去判断 `*cerr ? cerr : curl_easy_strerror(rc)` —— 原先 * 这行在两百行之后、日志区段下面, 于是那条提前返回的路径会把一个未初始化 - * 的栈数组当字符串读. (gcc -fanalyzer 抓到的, 测试跑不出来:curl_easy_init + * 的栈数组当字符串读. (gcc -fanalyzer 抓到的, 测试跑不出来: curl_easy_init * 几乎不会失败. ) */ if (cerr && cerrlen) cerr[0] = '\0'; @@ -393,7 +393,7 @@ static CURLcode attempt(const char *url, const char *api_key, const char *body, struct curl_slist *headers = make_headers(api_key); - /* 长度先算好:POSTFIELDSIZE 要用, 而 body 允许为 NULL (GET 探测). */ + /* 长度先算好: POSTFIELDSIZE 要用, 而 body 允许为 NULL (GET 探测). */ size_t blen = body ? strlen(body) : 0; curl_easy_setopt(curl, CURLOPT_URL, url); @@ -408,7 +408,7 @@ static CURLcode attempt(const char *url, const char *api_key, const char *body, curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE_LARGE, (curl_off_t)blen); } curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); - /* 刻意不跟随重定向: + /* 刻意不跟随重定向: * 1) libcurl 跨源重定向时会主动丢掉 Authorization 头 (安全设计), * 结果是 401 "Missing Authentication header" —— 错误指向完全错的方向, * 用户会去查自己的 key, 而真正的问题是 URL 跳转了; @@ -473,7 +473,7 @@ int http_probe(const char *url, const char *api_key, long timeout_s, int verbose ctx.probe = 1; char cerr[CURL_ERROR_SIZE]; - /* 把密钥带上:探测的目的之一就是验证"这把密钥能不能过", + /* 把密钥带上: 探测的目的之一就是验证"这把密钥能不能过", * 不带密钥去问, 401 只说明端点要认证, 说明不了密钥好不好. */ CURLcode rc = attempt(url, api_key, NULL, timeout_s, verbose, &ctx, cerr, sizeof cerr); @@ -505,7 +505,7 @@ static int should_retry(const HttpRetry *rt, int attempt, CURLcode rc, return -1; } - /* 重定向单独处理:这几乎总是 base_url 写错了 */ + /* 重定向单独处理: 这几乎总是 base_url 写错了 */ if (rc == CURLE_OK && ctx->status >= 300 && ctx->status < 400) { snprintf(errbuf, errlen, "%s", tf("http.err.redirect", "status", i18n_num(ctx->status), @@ -568,7 +568,7 @@ static void normalize_retry(const HttpRetry *in, HttpRetry *out) /* ------------------------------------------------------------------ */ /* POST 与 GET 只差一个标志位, 重试、退避、错误体处理完全一样 —— - * 合成一个实现, 免得两条路各修一遍 bug. */ + * 合成一个实现, 以免两条路各修一遍 bug. */ static int http_request(int is_get, const char *url, const char *api_key, const char *body, long timeout_s, int verbose, const HttpRetry *retry, @@ -696,7 +696,7 @@ int http_post_stream(const char *url, const char *api_key, const char *body, return -1; } - /* 已经吐过数据就不再重试:重发会把用户已经看到的内容再来一遍 */ + /* 已经吐过数据就不再重试: 重发会把用户已经看到的内容再来一遍 */ if (ctx.delivered > 0) { if (rc != CURLE_OK) snprintf(errbuf, errlen, "%s", diff --git a/src/http.h b/src/http.h index 0c2946f..156f7cf 100644 --- a/src/http.h +++ b/src/http.h @@ -1,4 +1,4 @@ -/* http.h - libcurl 的薄封装:同步 POST、流式 POST、瞬态失败重试 */ +/* http.h - libcurl 的薄封装: 同步 POST、流式 POST、瞬态失败重试 */ #ifndef LIGENT_HTTP_H #define LIGENT_HTTP_H @@ -9,11 +9,11 @@ /* 全局中断标志 (Ctrl-C 置位, 由 main 安装信号处理器) */ extern volatile sig_atomic_t g_ligent_interrupt; -/* libcurl 流式写回调, 语义同 libcurl CURLOPT_WRITEFUNCTION: +/* libcurl 流式写回调, 语义同 libcurl CURLOPT_WRITEFUNCTION: * 返回已处理的字节数; 返回不等于 nmemb*size 的值即中止传输 */ typedef size_t (*HttpSink)(char *data, size_t size, size_t nmemb, void *userdata); -/* 重试策略. 只重试"服务端明确报错"或"连接根本没建立"的情况: +/* 重试策略. 只重试"服务端明确报错"或"连接根本没建立"的情况: * 超时 (OPERATION_TIMEDOUT)不重试 —— 服务端可能已经处理完并计费了, * 重发会造成重复扣费. */ typedef struct { @@ -54,7 +54,7 @@ int http_status_retryable(long status); /* 把 data 里出现的 secret 全部替换成 <已隐藏> 后写入 out. * 用"按密钥值替换"而不是"匹配 Authorization 头", 是因为 libcurl 有多条 - * 输出通道都会带出密钥:除了 HEADER_OUT 那段, HTTP/2 还会在 TEXT 消息里 + * 输出通道都会带出密钥: 除了 HEADER_OUT 那段, HTTP/2 还会在 TEXT 消息里 * 打一行 "[HTTP/2] [1] [authorization: Bearer sk-...]". */ void http_write_redacted(FILE *out, const char *data, size_t size, const char *secret); @@ -75,7 +75,7 @@ int http_get(const char *url, const char *api_key, char **out_body, long *out_status, int *out_attempts, char *errbuf, size_t errlen); -/* 探测:发一个 GET (不跟随重定向、不带请求体), 用于 doctor 检查 +/* 探测: 发一个 GET (不跟随重定向、不带请求体), 用于 doctor 检查 * 连通性、TLS 与是否发生重定向. * out_location 为 3xx 时的 Location (malloc, 可为 NULL). */ /* api_key 为 NULL 表示不带认证发出去. doctor 要拿它区分"端点要求认证但你没配 @@ -86,7 +86,7 @@ int http_probe(const char *url, const char *api_key, long timeout_s, int verbose char *errbuf, size_t errlen); /* 流式 POST. 2xx 时响应体逐块交给 sink; 非 2xx 时响应体被收集到 out_errbody. - * 注意:一旦有数据交给了 sink (见 out_delivered), 就不再重试 —— + * 注意: 一旦有数据交给了 sink (见 out_delivered), 就不再重试 —— * 否则会把已经显示过的内容重复输出一遍. */ int http_post_stream(const char *url, const char *api_key, const char *body, long timeout_s, int verbose, const HttpRetry *retry, diff --git a/src/i18n.c b/src/i18n.c index 043fb23..5963c1b 100644 --- a/src/i18n.c +++ b/src/i18n.c @@ -1,7 +1,7 @@ /* i18n.c - 界面文案表与查找 * * 表里每条两种语言各一份. 条目按 key 排序 (不需要, 但好查). - * 测试会遍历这张表:两种语言都不许空, 占位符集合必须一致. + * 测试会遍历这张表: 两种语言都不许空, 占位符集合必须一致. */ #include "i18n.h" @@ -74,12 +74,12 @@ M("cli.help", "ligent - 纯 C 实现的 AI Agent (OpenAI 兼容接口)\n" "\n" -"用法:\n" +"用法: \n" " ligent [选项] [任务描述...] 带任务描述时一次性执行后退出\n" " ligent [选项] 进入交互式 REPL\n" " echo \"任务\" | ligent 从标准输入读取任务\n" "\n" -"选项:\n" +"选项: \n" " -m, --model NAME 模型名 (默认 deepseek-flash)\n" " -u, --base-url URL API 基地址 (默认 https://api.deepseek.com/v1)\n" " -k, --api-key KEY API key (默认读 $DEEPSEEK_API_KEY)\n" @@ -89,8 +89,8 @@ M("cli.help", " -s, --system TEXT 覆盖 system 提示词\n" " -t, --temperature N 采样温度\n" " --max-tokens N 单次回复的最大 token 数\n" -" --thinking MODE 思考模式:on / off / auto (默认 auto = 由服务端决定)\n" -" --effort LEVEL 思考级别:minimal/low/medium/high/xhigh/max/ultra\n" +" --thinking MODE 思考模式: on / off / auto (默认 auto = 由服务端决定)\n" +" --effort LEVEL 思考级别: minimal/low/medium/high/xhigh/max/ultra\n" " --max-steps N 单轮任务里模型最多往返几次 (默认不限, 0 = 不限)\n" " --timeout N 单次请求超时秒数 (默认 300)\n" " --retries N 瞬态失败 (429/5xx/连接失败)的重试次数 (默认 2, 0 = 不重试)\n" @@ -99,7 +99,7 @@ M("cli.help", " --no-confirm 不再询问写操作与命令执行 (默认会问)\n" " --no-auto-save 退出/重置/导入前不自动存档 (默认会存)\n" " --no-probe 首个请求前不探测模型能力 (不额外发请求)\n" -" --lang LANG 界面语言:auto / en / zh (默认 auto)\n" +" --lang LANG 界面语言: auto / en / zh (默认 auto)\n" " -q, --quiet 不打印工具的输出内容\n" " -v, --verbose 打印 HTTP 细节与调试信息\n" " --color / --no-color 强制开关彩色输出\n" @@ -107,24 +107,24 @@ M("cli.help", " -h, --help 显示本帮助\n" " -V, --version 显示版本\n" "\n" -"配置文件 (JSON):\n" +"配置文件 (JSON): \n" " 只读「二进制所在目录」下的 ligent.json, 例如 /usr/local/bin/ligent 对应\n" " /usr/local/bin/ligent.json. 不存在则使用内置默认值. \n" " 不做用户级 (~/.config)和系统级 (/etc)查找, 也不看当前工作目录. \n" " 临时换一份可用 -c PATH 或 $LIGENT_CONFIG. \n" "\n" -"配置项:base_url, api_key, model, system, temperature, max_tokens,\n" +"配置项: base_url, api_key, model, system, temperature, max_tokens,\n" " thinking, reasoning_effort,\n" " timeout, stream, max_steps, tools, retries,\n" " confirm, auto_save, probe\n" "\n" -"REPL 命令:/help /exit /reset /cls /meta /model /system /tools /probe\n" +"REPL 命令: /help /exit /reset /cls /meta /model /system /tools /probe\n" " /think /config /cd /history /log /export /import /shell /usage\n" " /include\n" " /back /unback /yolo\n" "\n" -"子命令:\n" -" ligent doctor [--offline] 体检:配置、密钥、端点、网络逐环检查\n"), +"子命令: \n" +" ligent doctor [--offline] 体检: 配置、密钥、端点、网络逐环检查\n"), M("cli.err.bad_effort", "ligent: --effort accepts only {list} (got \"{value}\")\n", @@ -146,10 +146,10 @@ M("cli.err.no_api_key", " with something like {\"api_key\": \"${DEEPSEEK_API_KEY}\", \"model\": \"deepseek-flash\"}\n" " 3) ligent --api-key sk-xxx \"hi\"\n", "ligent: 未配置 API key. \n" - " 方式一:export DEEPSEEK_API_KEY=sk-xxx\n" - " 方式二:在二进制所在目录建一个 ligent.json (见 --print-config 显示的路径), \n" + " 方式一: export DEEPSEEK_API_KEY=sk-xxx\n" + " 方式二: 在二进制所在目录建一个 ligent.json (见 --print-config 显示的路径), \n" " 内容形如 {\"api_key\": \"${DEEPSEEK_API_KEY}\", \"model\": \"deepseek-flash\"}\n" - " 方式三:ligent --api-key sk-xxx \"你好\"\n"), + " 方式三: ligent --api-key sk-xxx \"你好\"\n"), M("cli.err.unknown_option", "ligent: unknown option {option} (try --help)\n", "ligent: 未知选项 {option} (--help 查看用法)\n"), @@ -189,12 +189,12 @@ M("cfg.thinking.off", "off (sends thinking.type=disabled)", "关闭 (发 thinking.type=disabled)"), M("cfg.thinking.on", "on (sends thinking.type=enabled)", "开启 (发 thinking.type=enabled)"), -M("cfg.err.bad_json", "{path}: {error}", "{path}:{error}"), +M("cfg.err.bad_json", "{path}: {error}", "{path}: {error}"), M("cfg.err.bad_effort", "{path}: reasoning_effort accepts only {list} (or leave it out)", - "{path}:reasoning_effort 只能是 {list} (或留空不发)"), + "{path}: reasoning_effort 只能是 {list} (或留空不发)"), M("cfg.err.not_object", "{path}: the top level must be a JSON object ({...})", - "{path}:顶层必须是 JSON 对象 ({...})"), + "{path}: 顶层必须是 JSON 对象 ({...})"), M("cfg.err.parse_ignored", "ligent: config file could not be parsed; using built-in defaults instead\n" " {error}", @@ -204,7 +204,7 @@ M("cfg.err.read_failed", "cannot read config file {path}", "无法读取配置文件 {path}"), M("cfg.err.thinking_type", "{path}: thinking accepts only true/false or \"on\"/\"off\"/\"auto\"", - "{path}:thinking 只能是 true/false, 或 \"on\"/\"off\"/\"auto\""), + "{path}: thinking 只能是 true/false, 或 \"on\"/\"off\"/\"auto\""), /* ---- 写操作确认 ---- */ M("confirm.always", "no more prompts for the rest of this turn.", @@ -225,15 +225,15 @@ M("confirm.prompt", " Continue? (Y/N/Always/neVer) ", " 继续?(Y/N/Always/neVer) "), M("confirm.val.empty", "(empty)", "(空)"), -/* ---- doctor:体检 ---- */ -/* 分节标题的括号:中文用【】, 英文用 [] */ +/* ---- doctor: 体检 ---- */ +/* 分节标题的括号: 中文用【】, 英文用 [] */ M("fmt.section", "[{name}]", "【{name}】"), M("doctor.conclusion.bad", "Bottom line: {problems} problem(s), {warnings} warning(s).", - "结论:{problems} 个问题, {warnings} 处提醒. "), + "结论: {problems} 个问题, {warnings} 处提醒. "), M("doctor.conclusion.next", "start with the items marked [x].", "先处理标 [x] 的项. "), M("doctor.conclusion.ok", "Bottom line: no problems found.", - "结论:没发现问题. "), + "结论: 没发现问题. "), M("doctor.env.note.all_proxy", "proxy for all protocols", "通用代理"), M("doctor.env.note.api_key", "explicit override, highest priority", "显式覆盖, 优先级最高"), @@ -289,22 +289,22 @@ M("doctor.sub.404_base_url", "多半是 base_url 写错了 (比如少了 /v1); 先照下面「重定向」那节的方法核对"), M("doctor.sub.404_no_key_info", "this probe says nothing about the key: the request was refused before auth", - "这次探测说明不了密钥好不好:请求没走到认证就被回绝了"), + "这次探测说明不了密钥好不好: 请求没走到认证就被回绝了"), M("doctor.sub.all_ignored", "the whole file was ignored; this run uses built-in defaults", "整个文件被忽略, 本次运行全部使用内置默认值. "), M("doctor.sub.auth_not_network", "this is not a network problem: it connects, there is just no key", - "这不是网络问题:连得上, 只是没带钥匙"), + "这不是网络问题: 连得上, 只是没带钥匙"), M("doctor.sub.auth_redirect", "also check whether base_url was redirected: see the redirect section below", - "也确认一下 base_url 是不是被跳转过:详见下面「重定向」"), + "也确认一下 base_url 是不是被跳转过: 详见下面「重定向」"), M("doctor.sub.max_tokens_huge", " far above the usual output cap (mostly <= 65536); may be rejected or truncated", " 远超常见模型的输出上限 (多数 <= 65536), 可能被拒或被截断"), M("doctor.sub.redirect_1", "ligent does not follow redirects: a cross-origin hop drops the Authorization header,", - "ligent 不自动跟随重定向:跨源跳转会丢掉 Authorization 头, "), + "ligent 不自动跟随重定向: 跨源跳转会丢掉 Authorization 头, "), M("doctor.sub.redirect_2", "the server then replies 401 \"Missing Authentication header\", which looks like a missing key,", "服务端只会回 401 \"Missing Authentication header\", 看起来像是密钥没带, "), @@ -330,29 +330,29 @@ M("doctor.val.bad_scheme", "URL must start with http:// or https://", "URL 必须以 http:// 或 https:// 开头"), M("doctor.val.base_url_unset", "(not set)", "(未设置)"), M("doctor.val.chars", "({n} chars)", " ({n} 字符)"), -M("doctor.val.connect_failed", "failed: {error}", "失败:{error}"), +M("doctor.val.connect_failed", "failed: {error}", "失败: {error}"), M("doctor.val.connect_ok", "reachable, TLS verified (for HTTPS)", "可连接, TLS 校验通过 (HTTPS 时)"), M("doctor.val.empty", "(empty)", "(空)"), M("doctor.val.endpoint_404", "HTTP 404 — no such address: {url}", - "HTTP 404 —— 这个地址不存在:{url}"), + "HTTP 404 —— 这个地址不存在: {url}"), M("doctor.val.env_ignored", "{var} is set but unused this run (the key came from {source}). " "If that is not what you want, unset it or use LIGENT_API_KEY", "{var} 已设置, 但本次没用上 (密钥来自「{source}」). " "如果这不是你想要的, unset 它或改用 LIGENT_API_KEY"), -M("doctor.val.env_set", "set: {value}", "已设置:{value}"), +M("doctor.val.env_set", "set: {value}", "已设置: {value}"), M("doctor.val.env_unset", "not set", "未设置"), M("doctor.val.exists_unloadable", "present but not loaded (the top level must be a JSON object)", "存在但加载失败 (顶层必须是 JSON 对象)"), M("doctor.val.exists_unparsable", "present but unparsable: {error}", - "存在但无法解析:{error}"), + "存在但无法解析: {error}"), M("doctor.val.how_to_set_key", "export DEEPSEEK_API_KEY=... or -k KEY or api_key in the config file", "export DEEPSEEK_API_KEY=... 或 -k KEY 或配置文件 api_key"), M("doctor.val.https", "HTTPS", "HTTPS"), -M("doctor.val.load_failed", "load failed: {error}", "加载失败:{error}"), +M("doctor.val.load_failed", "load failed: {error}", "加载失败: {error}"), M("doctor.val.location_missing", "(the server sent no Location)", "(服务端未提供 Location)"), M("doctor.val.loaded", "loaded", "已加载"), @@ -401,10 +401,10 @@ M("doctor.val.unknown", "(unknown)", "(未知)"), /* ---- 其它 UI ---- */ M("main.auto_saved", "auto-archived ({why}){temp}: {path}", - "已自动存档 ({why}){temp}:{path}"), + "已自动存档 ({why}){temp}: {path}"), M("main.auto_saved.temp", " [temp]", "〔临时〕"), M("main.err.auto_save_failed", "ligent: auto-archive failed: {error}\n", - "ligent: 自动存档失败:{error}\n"), + "ligent: 自动存档失败: {error}\n"), M("repl.banner.hint", "Type a task to start; /help lists commands, Ctrl-D exits.", "输入任务开始对话, /help 查看命令, Ctrl-D 退出. "), M("repl.bye", "Bye.", "再见. "), @@ -419,16 +419,16 @@ M("repl.cancelled", "已取消. "), M("repl.colon", ":", - ":"), + ": "), M("repl.export.done", "exported {n} messages: {lineage}{arrow}{name}\n", - "已导出 {n} 条消息:{lineage}{arrow}{name}\n"), + "已导出 {n} 条消息: {lineage}{arrow}{name}\n"), M("repl.export.empty", "this conversation is still empty, nothing to export.", "当前对话还没有内容, 无需导出. "), M("repl.export.failed", "export failed: {error}\n", - "导出失败:{error}\n"), + "导出失败: {error}\n"), M("repl.export.name_limit", "(name hit the length cap; the lineage starts a new root here)", " (名字已达长度上限, 谱系在这里重新起头)"), @@ -440,7 +440,7 @@ M("repl.include.cleared", "已清空待附带的文件. "), M("repl.include.failed", "attaching failed: {error}\n", - "附带失败:{error}\n"), + "附带失败: {error}\n"), M("repl.include.next_hint", "they go out with your next message. /include clear cancels.", "下一条消息会带上它们. 用 /include clear 取消. "), @@ -461,13 +461,13 @@ M("repl.model.fetching", "正在获取模型列表 ({url}/models)...\n"), M("repl.model.list_failed", "could not fetch the model list: {error}\n", - "拿不到模型列表:{error}\n"), + "拿不到模型列表: {error}\n"), M("repl.model.list_failed_hint", "you can still name one directly: /model NAME (the list is not required).", "可以直接用 /model <名字> 指定, 列表拿不到不影响这条. "), M("repl.model.list_header", "\nthe server lists {n} models:\n\n", - "\n服务端列出了 {n} 个模型:\n\n"), + "\n服务端列出了 {n} 个模型: \n\n"), M("repl.model.pick_prompt", "\nselect a number, or type a model name (Enter cancels) > ", "\n选择序号, 或直接输入模型名 (回车取消)> "), @@ -476,7 +476,7 @@ M("repl.model.row", " %2zu) %s%s\n"), M("repl.model.same", "model unchanged: {model}\n", - "模型未变:{model}\n"), + "模型未变: {model}\n"), M("repl.model.switched", "switched to model {model}\n", "模型已切换为 {model}\n"), @@ -512,22 +512,22 @@ M("repl.back.none", "没有可以回退的对话轮次. "), M("repl.cd.current", "current directory: {dir}\n", - "当前目录:{dir}\n"), + "当前目录: {dir}\n"), M("repl.cd_failed", "cd failed", "cd 失败"), M("repl.help", "/help show this help\n/exit, /quit exit\n/reset clear the conversation (keeps the system prompt)\n/cls clear the screen (history untouched)\n/meta meta mode: edit the conversation with the model's help\n/model [NAME] switch model; with no name, pick from the online list\n/include [GLOB] attach files to the model; no argument = pick interactively, clear cancels\n/system TEXT set a new system prompt and clear the history\n/tools list available tools\n/probe probe model capabilities now (vision/tools); ignores --no-probe and the cache\n/think show thinking mode and effort\n/think on|off|auto switch thinking mode (auto = server decides)\n/think LEVEL set reasoning effort (minimal/low/medium/high/xhigh/max/ultra)\n/config show the effective config\n/cd PATH change working directory\n/history print the whole conversation (every message, tools included)\n/usage show token usage\n/shell [CMD] open an interactive shell, or run a single command\n/back roll back the most recent round\n/unback undo the last /back\n/yolo toggle write confirmation for this session\n/log [FILE] export the wire log (no argument = show size, /log clear = drop it)\n/export save this conversation under ligent.archive/\n/import load one of the archives (asks before printing the history)\n", - "/help 显示本帮助\n/exit, /quit 退出\n/reset 清空对话历史 (保留 system 提示词)\n/cls 清屏 (只动屏幕, 不动对话历史)\n/meta 进入 meta 模式:用模型辅助编辑对话历史\n/model [NAME] 切换模型; 不带名字则拉取在线列表交互式选择\n/include [GLOB] 把文件附带给模型; 不带参数则交互式挑选, clear 取消\n/system TEXT 重新设置 system 提示词, 并清空历史\n/tools 列出可用工具\n/probe 现在测一次模型能力 (图片/工具); 无视 --no-probe 与缓存\n/think 查看思考模式与级别\n/think on|off|auto 切换思考模式 (auto = 由服务端决定)\n/think LEVEL 设置思考级别 (minimal/low/medium/high/xhigh/max/ultra)\n/config 显示当前生效配置\n/cd PATH 切换工作目录\n/history 把当前对话完整打印出来 (每条消息, 含工具调用与结果)\n/usage 显示 token 用量\n/shell [命令] 进入交互式 shell, 或直接执行一条命令\n/back 回退最近一轮对话\n/unback 撤销刚才的 /back\n/yolo 临时开关写操作确认\n/log [FILE] 导出收发记录 (无参数看大小, /log clear 清空)\n/export 把当前对话存到 ligent.archive/\n/import 从存档里选一份载入 (默认会问要不要打印历史)\n"), + "/help 显示本帮助\n/exit, /quit 退出\n/reset 清空对话历史 (保留 system 提示词)\n/cls 清屏 (只动屏幕, 不动对话历史)\n/meta 进入 meta 模式: 用模型辅助编辑对话历史\n/model [NAME] 切换模型; 不带名字则拉取在线列表交互式选择\n/include [GLOB] 把文件附带给模型; 不带参数则交互式挑选, clear 取消\n/system TEXT 重新设置 system 提示词, 并清空历史\n/tools 列出可用工具\n/probe 现在测一次模型能力 (图片/工具); 无视 --no-probe 与缓存\n/think 查看思考模式与级别\n/think on|off|auto 切换思考模式 (auto = 由服务端决定)\n/think LEVEL 设置思考级别 (minimal/low/medium/high/xhigh/max/ultra)\n/config 显示当前生效配置\n/cd PATH 切换工作目录\n/history 把当前对话完整打印出来 (每条消息, 含工具调用与结果)\n/usage 显示 token 用量\n/shell [命令] 进入交互式 shell, 或直接执行一条命令\n/back 回退最近一轮对话\n/unback 撤销刚才的 /back\n/yolo 临时开关写操作确认\n/log [FILE] 导出收发记录 (无参数看大小, /log clear 清空)\n/export 把当前对话存到 ligent.archive/\n/import 从存档里选一份载入 (默认会问要不要打印历史)\n"), M("repl.import.done", "loaded {name} ({n} messages). Future /export will save as {name}_xxx\n", "已载入 {name} ({n} 条消息). 之后 /export 会存成 {name}_xxx\n"), M("repl.import.list_header", "\n{dir} holds {n} archives:\n\n", - "\n{dir} 里有 {n} 份存档:\n\n"), + "\n{dir} 里有 {n} 份存档: \n\n"), M("repl.import.load_failed", "loading failed: {error}\n", - "载入失败:{error}\n"), + "载入失败: {error}\n"), M("repl.import.none", "no archives yet ({dir} is empty). Save one with /export first.\n", "还没有任何存档 ({dir} 是空的). 先用 /export 存一份. \n"), @@ -560,22 +560,22 @@ M("repl.system.builtin", "(内置默认)"), M("repl.system.current", "current system prompt:", - "当前 system 提示词:"), + "当前 system 提示词: "), M("repl.system.updated", "system prompt updated and history cleared.", "system 提示词已更新, 历史已清空. "), M("repl.think.bad", "not recognised: \"{value}\".\n mode: on / off / auto\n effort: {list}\n", - "无法识别 \"{value}\". \n 开关:on / off / auto\n 级别:{list}\n"), + "无法识别 \"{value}\". \n 开关: on / off / auto\n 级别: {list}\n"), M("repl.think.effort", "reasoning effort: %s\n", - "思考级别:%s\n"), + "思考级别: %s\n"), M("repl.think.effort_set", "reasoning effort set to {level} (this session only).\n", "思考级别已设为 {level} (只影响本次会话). \n"), M("repl.think.mode", "thinking mode: %s\n", - "思考模式:%s\n"), + "思考模式: %s\n"), M("repl.tools.disabled", "tools are disabled (--no-tools).", "工具已禁用 (--no-tools). "), @@ -638,7 +638,7 @@ M("repl.history.count", "历史消息 {n} 条 (不含 system 提示词). \n"), M("repl.history.full", "printing all {total} messages below (system prompt included):\n", - "下面完整打印全部 {total} 条消息 (含 system 提示词):\n"), + "下面完整打印全部 {total} 条消息 (含 system 提示词): \n"), M("repl.log.cleared", "wire log cleared.", "收发记录已清空. "), @@ -647,7 +647,7 @@ M("repl.log.empty", "还没有任何收发记录, {file} 未写入内容. \n"), M("repl.log.save_failed", "export failed: {error}\n", - "导出失败:{error}\n"), + "导出失败: {error}\n"), M("repl.log.saved", "appended {n} bytes of wire log to {file}\n", "已把 {n} 字节收发记录追加写入 {file}\n"), @@ -671,21 +671,21 @@ M("repl.shell.recorded", "已把 {n} 字节会话记录加入上下文 (不自动提问). \n"), M("repl.shell.start_failed", "could not start the shell: {error}\n", - "启动 shell 失败:{error}\n"), + "启动 shell 失败: {error}\n"), M("repl.turn_failed", "this turn failed; the history is kept. Keep asking, or /reset to clear it.\n", "本轮失败, 历史保留, 可继续提问或 /reset 清空. \n"), M("repl.unknown_command", "unknown command: {cmd} (/help lists them)\n", - "未知命令:{cmd} (/help 查看帮助)\n"), -M("agent.call.elevated", "as root: {cmd}", "以 root:{cmd}"), + "未知命令: {cmd} (/help 查看帮助)\n"), +M("agent.call.elevated", "as root: {cmd}", "以 root: {cmd}"), M("agent.call.write", "\"{path}\" ({n} bytes)", "\"{path}\" ({n} 字节)"), M("agent.caps.baseline_failed", "the baseline request did not go through", "基线请求未通过"), M("agent.caps.line", "model capabilities: multimodal (image input) {vision} * tool calls {tools}{note}", - "模型能力:多模态 (图片输入){vision} * 工具调用 {tools}{note}"), + "模型能力: 多模态 (图片输入){vision} * 工具调用 {tools}{note}"), M("agent.caps.no.tools", "no (tool table no longer sent)", "不支持 (不再发送工具表)"), @@ -706,7 +706,7 @@ M("agent.empty_reply", "(模型没有返回任何内容)"), M("agent.error", "error: {error}\n", - "错误:{error}\n"), + "错误: {error}\n"), M("agent.image.degraded", "{n} image reference(s) could not be expanded (local backup missing or over the size cap); they were downgraded to a text note in place, no retry.", "有 {n} 处图片引用展开不出来 (本机备份缺失或体积超限), 已就地降级成文字说明, 不再重试. "), @@ -715,7 +715,7 @@ M("agent.img.rejected", "服务端拒绝了请求里的图片, 已把 {n} 处图片从对话里移除后重试...\n"), M("agent.img.server_said", "the server said: {error}", - "服务端原话:{error}"), + "服务端原话: {error}"), M("agent.interrupted", "[interrupted]", "[已中断]"), @@ -730,7 +730,7 @@ M("agent.output.empty", "(无输出)"), M("agent.stream.broken", " transfer interrupted; the partial content above is all that arrived: {why}", - " 传输中断, 以上是已收到的部分内容:{why}"), + " 传输中断, 以上是已收到的部分内容: {why}"), M("agent.structure.fixed", "the conversation structure was broken (an assistant tool_calls message was not paired with its tool replies); repaired {n} place(s) — without this every later round would be rejected by the server.", "对话结构有问题 (assistant 的 tool_calls 与 tool 消息对不上), 已自动修复 {n} 处 —— 不修的话之后每一轮都会被服务端拒绝. "), @@ -748,10 +748,10 @@ M("attach.err.binary", "看起来是二进制文件"), M("attach.err.glob_failed", "glob failed: {pattern}", - "展开失败:{pattern}"), + "展开失败: {pattern}"), M("attach.err.no_match", "no files matched: {pattern}", - "没有匹配到文件:{pattern}"), + "没有匹配到文件: {pattern}"), M("attach.err.no_pattern", "no pattern given", "没有给出模式"), @@ -775,7 +775,7 @@ M("attach.err.unreadable", "读不到"), M("attach.glob.skipped", " skipped {file}: {why}", - " 跳过 {file}:{why}"), + " 跳过 {file}: {why}"), M("attach.image_mark", " [image]", " 〔图片〕"), @@ -784,7 +784,7 @@ M("attach.pick.cancelled", "已取消选择. "), M("attach.pick.dir", "\ncurrent directory: {dir}\n\n", - "\n当前目录:{dir}\n\n"), + "\n当前目录: {dir}\n\n"), M("attach.pick.empty_dir", "(empty directory, or no permission to read it)", " (空目录, 或者没有权限读)"), @@ -802,7 +802,7 @@ M("attach.pick.more", " ...还有 {n} 项没列出来\n"), M("attach.pick.no_match", "no match: {input}\n", - "没有匹配到:{input}\n"), + "没有匹配到: {input}\n"), M("attach.pick.parent", " .) .. go up one level", " .) .. 返回上级"), @@ -814,12 +814,12 @@ M("attach.pick.selected", "已选中 {file}\n"), M("attach.pick.skipped", "skipped {file}: {why}", - "跳过 {file}:{why}"), + "跳过 {file}: {why}"), -/* ---- 存档 (archive.c):/export /import 的底层错误 ---- */ +/* ---- 存档 (archive.c): /export /import 的底层错误 ---- */ M("archive.err.mkdir", "cannot create the archive directory {dir}: {error}", - "无法创建存档目录 {dir}:{error}"), + "无法创建存档目录 {dir}: {error}"), M("archive.err.name_exhausted", "could not come up with a non-clashing file name in 64 tries", "连续 64 次都没能生成不冲突的文件名"), @@ -831,7 +831,7 @@ M("archive.err.not_archive", "{path} 不是 ligent 存档 (缺少 format 标记)"), M("archive.err.not_json", "{path} is not valid JSON: {error}", - "{path} 不是合法 JSON:{error}"), + "{path} 不是合法 JSON: {error}"), M("archive.err.read_failed", "cannot read {path}", "无法读取 {path}"), @@ -840,7 +840,7 @@ M("archive.err.write_failed", "写入 {path} 时出错"), M("archive.err.write_open", "cannot write {path}: {error}", - "无法写入 {path}:{error}"), + "无法写入 {path}: {error}"), /* ---- 模型列表 (models.c)---- */ M("models.err.empty_body", @@ -870,25 +870,25 @@ M("http.err.interrupted", "已中断"), M("http.err.log_open", "cannot write {path}: {error}", - "无法写入 {path}:{error}"), + "无法写入 {path}: {error}"), M("http.err.log_write", "error while writing {path}", "写入 {path} 时出错"), M("http.err.network", "network error: {error}", - "网络错误:{error}"), + "网络错误: {error}"), M("http.err.network_partial", "network error: {error} (some content was already shown, not retrying)", - "网络错误:{error} (已输出部分内容, 不重试)"), + "网络错误: {error} (已输出部分内容, 不重试)"), M("http.err.network_retried", "network error: {error} (after {n} retries)", - "网络错误:{error} (已重试 {n} 次)"), + "网络错误: {error} (已重试 {n} 次)"), M("http.err.redirect", "HTTP {status} redirects to {location} — point base_url at that address " "instead. (ligent does not follow redirects: a cross-origin hop would drop " "the Authorization header, and the server would only answer 401)", "HTTP {status} 重定向到 {location} —— 请直接把 base_url 改成该地址. " - " (ligent 不自动跟随重定向:跨源跳转会丢掉 Authorization 头, " + " (ligent 不自动跟随重定向: 跨源跳转会丢掉 Authorization 头, " "服务端只会返回 401)"), M("http.err.status_partial", "HTTP {status} (some content was already shown, not retrying)", @@ -902,7 +902,7 @@ M("http.log.header", M("http.log.truncated", "# note: the in-memory limit of {limit} bytes was reached, " "earlier parts were dropped\n", - "# 注意:内存上限 {limit} 字节, 较早的部分已被丢弃\n"), + "# 注意: 内存上限 {limit} 字节, 较早的部分已被丢弃\n"), M("http.retry.notice", "ligent: request failed, retrying in {delay} ms (attempt {n})", "ligent: 请求失败, {delay} 毫秒后重试 (第 {n} 次)"), @@ -919,7 +919,7 @@ M("llm.err.auth_hint", "请确认它属于 {url}, 并检查 -k / $DEEPSEEK_API_KEY / ligent.json 里配的是哪一个. "), M("llm.err.bad_json", "the response is not valid JSON: {error}", - "响应不是合法 JSON:{error}"), + "响应不是合法 JSON: {error}"), M("llm.err.no_api_key", "no API key (set $DEEPSEEK_API_KEY or write one into the config file)", "缺少 API key (设置 $DEEPSEEK_API_KEY 环境变量或写入配置文件)"), @@ -932,7 +932,7 @@ M("llm.err.overflow", "响应内容超过上限 (正文 {content} 字节 / 工具参数 {args} 字节), 已中止"), M("llm.err.server", "server error: {error}", - "服务端错误:{error}"), + "服务端错误: {error}"), M("llm.err.unparsable_response", "cannot parse the server response (neither streaming nor JSON)", "无法解析服务端响应 (非流式且非 JSON)"), @@ -952,16 +952,16 @@ M("llm.val.unknown", /* ---- /shell (shell.c)---- */ M("shell.err.fork", "fork failed: {error}", - "fork 失败:{error}"), + "fork 失败: {error}"), M("shell.err.no_pty", "cannot create a pty: {error}", - "无法创建伪终端:{error}"), + "无法创建伪终端: {error}"), M("shell.err.no_slave_name", "cannot get the pty slave device name", "无法取得伪终端从设备名"), M("shell.err.pty_init", "pty initialization failed: {error}", - "伪终端初始化失败:{error}"), + "伪终端初始化失败: {error}"), /* ---- 能力探测 (capability.c)---- */ M("capability.note.baseline_failed", @@ -975,11 +975,11 @@ M("capability.note.both_ok", M("capability.note.split", "[capability probe] the combined probe failed; splitting it: " "vision={vision} tools={tools}\n", - "[能力探测] 合并试探未通过, 拆开结果:图片={vision} tools={tools}\n"), + "[能力探测] 合并试探未通过, 拆开结果: 图片={vision} tools={tools}\n"), -/* ---- JSON 解析诊断 (json.c):给人看的那份 ---- +/* ---- JSON 解析诊断 (json.c): 给人看的那份 ---- * - * 这一组是"两条腿":json_parse() 的报错固定中文 (它会被原样回填给模型, + * 这一组是"两条腿": json_parse() 的报错固定中文 (它会被原样回填给模型, * 属于给模型看的信息); json_parse_ui() 的报错跟随界面语言 (配置、存档、 * 服务端响应这些给人看的路径). 文案只有这一份, 两种走法各取一列. * @@ -988,10 +988,10 @@ M("capability.note.split", * 机械比对 (见 printf_specs). */ M("json.err.at_end", "line %zu, column %zu: %s (input ends here)", - "第 %zu 行第 %zu 列:%s (输入在此处结束)"), + "第 %zu 行第 %zu 列: %s (输入在此处结束)"), M("json.err.at_got", "line %zu, column %zu: %s, got \"%s\"", - "第 %zu 行第 %zu 列:%s, 实际读到 \"%s\""), + "第 %zu 行第 %zu 列: %s, 实际读到 \"%s\""), M("json.err.array_unterminated", "array is not closed", "数组未闭合"), M("json.err.bad_number_got", "invalid number '%s'", "非法的数字 '%s'"), M("json.err.empty_input", "input is empty", "输入为空"), @@ -1008,7 +1008,7 @@ M("json.err.too_deep", "JSON 嵌套过深 (超过 %d 层)"), M("json.err.trailing", "extra content after the JSON: '%.20s'", - "JSON 末尾存在多余内容:'%.20s'"), + "JSON 末尾存在多余内容: '%.20s'"), M("json.err.u_bad_hex", "invalid \\u hex digit '%c'", "非法的 \\u 十六进制字符 '%c'"), @@ -1038,22 +1038,22 @@ M("json.err.want_true", "invalid literal (expected true)", "非法字面量 (期望 true)"), -/* ---- 工具参数的呈现 (tools.c 的 tool_lines_label / tool_render_diff): +/* ---- 工具参数的呈现 (tools.c 的 tool_lines_label / tool_render_diff): * 调用摘要、确认提示、执行后的展示共用同一份 ---- */ M("tool.call.all_lines", "whole file", "全文件"), M("tool.call.scope_all", "{scope} (all matches)", "{scope} (所有匹配)"), M("tool.diff.more", "... ({n} more lines)", "... (还有 {n} 行)"), -/* ---- /tools 的列表 (tools.c):纯给人看的一行 ---- */ +/* ---- /tools 的列表 (tools.c): 纯给人看的一行 ---- */ M("tools.ui.edit_file", "replace inside a set of lines (omit line to replace everywhere)", "按行号集合精确替换 (省略 line = 全文件替换)"), M("tools.ui.header", "available tools:", - "可用工具:"), + "可用工具: "), M("tools.ui.hidden", " ({name} is hidden: the current model does not accept image input)", - " ({name} 已隐藏:当前模型不接受图片输入)"), + " ({name} 已隐藏: 当前模型不接受图片输入)"), M("tools.ui.list_dir", "list a directory", "列出目录"), @@ -1076,7 +1076,7 @@ M("util.oom", "ligent: out of memory\n", "ligent: 内存分配失败\n"), -/* ---- meta 模式 (meta.c):只有给人看的那部分, 工具回执仍是中文 ---- */ +/* ---- meta 模式 (meta.c): 只有给人看的那部分, 工具回执仍是中文 ---- */ M("meta.already_in", "already in meta mode.", "已经在 meta 模式里了. "), @@ -1085,16 +1085,16 @@ M("meta.desc.delete_rounds", " 轮 {from}..{to} 整轮删除\n"), M("meta.desc.insert", " inserting {role} after #{after}: {preview}\n", - " 在 #{after} 之后插入 {role}:{preview}\n"), + " 在 #{after} 之后插入 {role}: {preview}\n"), M("meta.desc.replace", " rewriting #{index}: {preview}\n", - " #{index} 的正文改成:{preview}\n"), + " #{index} 的正文改成: {preview}\n"), M("meta.desc.undo", " undo the last edit\n", " 撤销上一次改动\n"), M("meta.diff.header", "current history: {n} message(s), {bytes} bytes:\n", - "当前历史 {n} 条消息, {bytes} 字节:\n"), + "当前历史 {n} 条消息, {bytes} 字节: \n"), M("meta.diff.no_text", "(no text)", "(无正文)"), @@ -1117,13 +1117,13 @@ M("meta.exit.edits", M("meta.exit.lineage", "this conversation has been edited: the next /export starts a new root, " "named like _m<3 digits>.", - "这段对话已被编辑过:下次 /export 会另起一根, 名字形如 <原名>_m<3位>. "), + "这段对话已被编辑过: 下次 /export 会另起一根, 名字形如 <原名>_m<3位>. "), M("meta.exit.no_edits", "no edits this time, ", "本次没有改动, "), M("meta.exit.usage", "meta itself used {calls} call(s), {in} in / {out} out tokens.\n", - "meta 自身消耗:{calls} 次调用, {in} 输入 / {out} 输出 token. \n"), + "meta 自身消耗: {calls} 次调用, {in} 输入 / {out} 输出 token. \n"), M("meta.help", "/exit, /quit leave meta mode, back to the main conversation\n" "/diff show the conversation as it stands (changes nothing)\n" @@ -1140,7 +1140,7 @@ M("meta.help", "/undo 撤销上一次改动\n" "/help 显示本帮助\n" "\n" - "其余输入都是给 meta 模型的指令, 例如:\n" + "其余输入都是给 meta 模型的指令, 例如: \n" " 把第 3 轮里我说的 "用 Python" 删掉\n" " 第 5 轮助手那个结论是错的, 改掉\n" " 第 2 到 4 轮在绕圈子, 压成一段\n"), @@ -1165,7 +1165,7 @@ M("meta.unknown_cmd", #define NMSGS (sizeof MSGS / sizeof MSGS[0]) /* tf() 用一圈静态缓冲轮流返回字符串 (这样调用点不必自己 malloc). - * 轮转的层数就是**嵌套调用**的上限:`tf(a, "x", tf(b, ...), NULL)` 里的内层 + * 轮转的层数就是**嵌套调用**的上限: `tf(a, "x", tf(b, ...), NULL)` 里的内层 * 返回值必须在外层拼完之前一直有效, 嵌套超过 TF_BUFS 层就会踩坏自己. */ #define TF_BUFS 8 #define TF_BUFLEN 4096 @@ -1205,7 +1205,7 @@ int i18n_parse_lang(const char *name) if (!name || !*name) return -1; char lang[16]; size_t k = 0; - /* 语言码取到第一个 '_' / '-' / '.' / '@' 之前:"zh_CN.UTF-8" → "zh" */ + /* 语言码取到第一个 '_' / '-' / '.' / '@' 之前: "zh_CN.UTF-8" → "zh" */ for (const char *p = name; *p && k < sizeof lang - 1; p++) { if (*p == '_' || *p == '-' || *p == '.' || *p == '@') break; lang[k++] = (char)tolower((unsigned char)*p); @@ -1225,7 +1225,7 @@ int i18n_lang_from_env(void) if (!v || !*v) continue; int lang = i18n_parse_lang(v); if (lang >= 0) return lang; - /* 认不出来的语言码 (fr_FR、ja_JP…):我们只有两种翻译, + /* 认不出来的语言码 (fr_FR、ja_JP…): 我们只有两种翻译, * 与其猜, 不如按"没设"处理, 最后落到英文. */ return LIGENT_LANG_EN; } @@ -1305,7 +1305,7 @@ const char *tf(const char *key, ...) p = e + 1; goto next; } - /* 没给这个占位符的值:原样留着, 方便一眼看出漏了参数 */ + /* 没给这个占位符的值: 原样留着, 方便一眼看出漏了参数 */ if (o + want + 2 < TF_BUFLEN) { out[o++] = '{'; memcpy(out + o, p + 1, want); diff --git a/src/i18n.h b/src/i18n.h index 0948156..1d724c4 100644 --- a/src/i18n.h +++ b/src/i18n.h @@ -1,10 +1,10 @@ /* i18n.h - 界面文案的多语言表 * - * 只覆盖**给人看**的文案:帮助、错误、诊断、确认提示、spinner 标签. + * 只覆盖**给人看**的文案: 帮助、错误、诊断、确认提示、spinner 标签. * 送给模型的那些 (系统提示词、工具描述、工具回执)**不在这里**, * 它们跟界面语言是两件事, 见 README「界面语言」一节. * - * 键用点分 ID (`err.unknown_option`)而不是中文原文:这样两种语言的 + * 键用点分 ID (`err.unknown_option`)而不是中文原文: 这样两种语言的 * 条目能机械对齐 —— 少一条、占位符对不上, 测试都会报出来. */ #ifndef LIGENT_I18N_H @@ -24,13 +24,13 @@ typedef struct { void i18n_set_lang(int lang); int i18n_lang(void); -/* 解析语言名:"zh"、"zh_CN"、"zh-Hans.UTF-8" → ZH; +/* 解析语言名: "zh"、"zh_CN"、"zh-Hans.UTF-8" → ZH; * "en"、"C"、"POSIX" → EN; * "auto"、空、认不出来的 → -1 (调用方自行决定回退). */ int i18n_parse_lang(const char *name); -/* 按环境探测:LIGENT_LANG > LC_ALL > LC_MESSAGES > LANG. - * 都没有或都认不出来 → EN (POSIX 惯例:C locale 是英文). */ +/* 按环境探测: LIGENT_LANG > LC_ALL > LC_MESSAGES > LANG. + * 都没有或都认不出来 → EN (POSIX 惯例: C locale 是英文). */ int i18n_lang_from_env(void); /* 初始化. lang 为 -1 时按环境探测. */ @@ -40,15 +40,15 @@ void i18n_init(int lang); const char *i18n_lang_name(void); /* 查表取当前语言的文案. 未知键返回 "[?key?]" —— 显眼, - * 免得漏掉的条目静默变成空白. */ + * 以免漏掉的条目静默变成空白. */ const char *t(const char *key); -/* 带占位符:模板里写 {name}, 调用时按名字成对给值, 最后传 NULL. +/* 带占位符: 模板里写 {name}, 调用时按名字成对给值, 最后传 NULL. * tf("err.unknown_option", "option", arg, NULL) * 按名字匹配 (不是按位置), 所以译文可以调整语序. */ const char *tf(const char *key, ...); -/* 整数转字符串 (静态轮转缓冲), 专供 tf 的参数位置用: +/* 整数转字符串 (静态轮转缓冲), 专供 tf 的参数位置用: * tf("k", "n", i18n_num(count), NULL) * 省得每个调用点都开一个 char buf[32]. */ const char *i18n_num(long long v); diff --git a/src/image.c b/src/image.c index 1bcee4e..852c8bd 100644 --- a/src/image.c +++ b/src/image.c @@ -92,7 +92,7 @@ char *image_backup_save(const char *bindir, const unsigned char *data, size_t n, } /* ------------------------------------------------------------------ */ -/* 请求前展开:ligent_image -> image_url + base64 */ +/* 请求前展开: ligent_image -> image_url + base64 */ /* ------------------------------------------------------------------ */ /* 内部块类型. 带 ligent_ 前缀是为了万一漏了展开、直接发出去时, @@ -152,7 +152,7 @@ static char *backup_to_data_url(const char *name, size_t *budget, const char **w return NULL; } - /* 体积要先算再编码:编码本身就要吃一份内存, 超限的话没必要做 */ + /* 体积要先算再编码: 编码本身就要吃一份内存, 超限的话没必要做 */ size_t b64len = base64_encoded_len(len); if (b64len > IMAGE_MAX_B64) { free(raw); @@ -220,9 +220,9 @@ JsonValue *image_expand_for_request(JsonValue *messages, int *degraded) continue; } - /* 展开不出来:把历史里这条坏引用就地换成文字说明, + /* 展开不出来: 把历史里这条坏引用就地换成文字说明, * 否则它每轮都会再失败一次. */ - char *text = xasprintf("[图片 %s 无法交给模型:%s]", + char *text = xasprintf("[图片 %s 无法交给模型: %s]", name && *name ? name : "?", why); JsonValue *note = make_note(text); json_array_push(wc, to_wire_part(note)); @@ -251,7 +251,7 @@ char *image_backup_from_path(const char *path, size_t *out_len, size_t len = 0; char *raw = read_entire_file(path, &len); if (!raw) { - if (out_msg) *out_msg = xasprintf("读不到图片 %s:%s", path, strerror(errno)); + if (out_msg) *out_msg = xasprintf("读不到图片 %s: %s", path, strerror(errno)); return NULL; } @@ -259,7 +259,7 @@ char *image_backup_from_path(const char *path, size_t *out_len, if (image_sniff((const unsigned char *)raw, len, &ext, &mime) != 0) { free(raw); if (out_msg) *out_msg = xasprintf( - "不认识的图片格式:%s. 支持 PNG / JPEG / GIF / WebP" + "不认识的图片格式: %s. 支持 PNG / JPEG / GIF / WebP" " (按文件内容判断, 不看扩展名)", path); return NULL; } @@ -269,7 +269,7 @@ char *image_backup_from_path(const char *path, size_t *out_len, if (b64len > IMAGE_MAX_B64) { free(raw); if (out_msg) *out_msg = xasprintf( - "图片太大, 没法交给模型看:%s 有 %.1f MiB, base64 内联后 %.1f MiB, " + "图片太大, 没法交给模型看: %s 有 %.1f MiB, base64 内联后 %.1f MiB, " "超过内联上限 %.0f MiB. " " (图片会留在对话历史里, 所以这里当场拒掉, 而不是发出去等报错. " "如果要看大图, 请先缩小尺寸或压缩后再读. )", diff --git a/src/image.h b/src/image.h index 002db88..fdcb9a0 100644 --- a/src/image.h +++ b/src/image.h @@ -1,11 +1,11 @@ -/* image.h - 图片:格式嗅探、本地备份、请求前展开 +/* image.h - 图片: 格式嗅探、本地备份、请求前展开 * - * 协议层面图片只有两条路能走:给一个外部 URL, 或者把 base64 内联进请求. + * 协议层面图片只有两条路能走: 给一个外部 URL, 或者把 base64 内联进请求. * ligent 不替用户去网上抓图, 所以 read_image 只收本地文件, 产出 base64 内联. * - * 但 base64 内联有个直接后果:图片字节会躺在会话历史里, 每轮请求都重发一遍. + * 但 base64 内联有个直接后果: 图片字节会躺在会话历史里, 每轮请求都重发一遍. * 归档、/history、/log 全都会跟着膨胀. 所以历史的**存储形态**和**发送形态** - * 是分开的: + * 是分开的: * * 存进历史 {"type":"ligent_image","backup":"1700000000_a1b2.png"} * 发给服务端 {"type":"image_url","image_url":{"url":"data:image/png;base64,...."}} @@ -14,7 +14,7 @@ * 真正构造请求时才读出来编码. 备份目录是 /ligent.archive/images/. * * 两个内部块类型都带 ligent_ 前缀, 就是为了万一哪天漏了展开、直接把它发出去, - * 服务端报的错能一眼看出是我们的问题, 而不是一个莫名其妙的 400: + * 服务端报的错能一眼看出是我们的问题, 而不是一个莫名其妙的 400: * ligent_image 引用一张本地备份 * ligent_note 展开失败后的文字说明 (引用读不到了、或体积超了) */ @@ -28,16 +28,16 @@ /* ---- 内联图片的上限 ---- * * 取各家文档里最严的一档 (ligent 同时对着 DeepSeek 和 MiMo 跑, 两边都发了 - * 文档里的数字): - * DeepSeek:请求体 48 MiB; 单张 (base64 / 外部 URL)32 MiB - * MiMo :base64 字符串 50 MB + * 文档里的数字): + * DeepSeek: 请求体 48 MiB; 单张 (base64 / 外部 URL)32 MiB + * MiMo : base64 字符串 50 MB * 于是单张按 32 MiB 的 **base64 字符串** 算 —— 注意是编码后的长度, 一张 - * 24 MiB 的原图编码后就已经超了. 宁可在这里拒掉, 也别发出去等一个 400: + * 24 MiB 的原图编码后就已经超了. 宁可在这里拒掉, 也别发出去等一个 400: * 图片是躺在历史里的, 一次超限会变成每轮都超限. */ #define IMAGE_MAX_B64 (32u * 1024 * 1024) /* 一个请求里所有内联图片的 base64 总量. 48 MiB 的请求体还要装提示词、历史、 - * 工具描述, 给它们留 4 MiB. 这条是兜底:read_image 一次最多挂 8 张, + * 工具描述, 给它们留 4 MiB. 这条是兜底: read_image 一次最多挂 8 张, * 每张都卡在 32 MiB 的话, 加起来早就冲爆请求体了. */ #define IMAGE_MAX_TOTAL_B64 (44u * 1024 * 1024) @@ -46,7 +46,7 @@ int image_sniff(const unsigned char *data, size_t n, const char **ext, const char **mime); -/* 备份目录:/ligent.archive/images (malloc) */ +/* 备份目录: /ligent.archive/images (malloc) */ char *image_backup_dir(const char *bindir); /* 备份文件所在目录下的完整路径 (malloc). name 是 image_backup_save 返回的名字 */ @@ -62,14 +62,14 @@ char *image_backup_save(const char *bindir, const unsigned char *data, size_t n, void image_set_bindir(const char *dir); const char *image_bindir(void); -/* 读一个本地图片文件:按内容认格式、判体积、落备份. +/* 读一个本地图片文件: 按内容认格式、判体积、落备份. * * 成功返回备份文件名 (malloc), *out_len 回填原始字节数、*out_mime 回填 MIME * (字面量)、*out_msg 置 NULL. * 失败返回 NULL, *out_msg 是一句能直接给模型/用户看的中文说明 (malloc). * * 体积超限的图**不备份** —— 一张发不出去的图, 留着备份没有意义. - * read_image 和 /include 都走这一条, 免得两处的判断慢慢长歪. */ + * read_image 和 /include 都走这一条, 以免两处的判断慢慢长歪. */ char *image_backup_from_path(const char *path, size_t *out_len, const char **out_mime, char **out_msg); @@ -88,7 +88,7 @@ JsonValue *image_pending_take(void); * (调用者 json_free). 原 messages 不变, 除非有引用展开不出来 —— 那种情况 * 会**就地**把它降级成 ligent_note, 把坏引用从历史里清掉. * - * 为什么就地降级:那条引用躺在历史里, 不修的话每一轮都会重新失败一次, + * 为什么就地降级: 那条引用躺在历史里, 不修的话每一轮都会重新失败一次, * 用户除了 /reset 没有别的路. 这和上下文超限要丢老轮次是同一个道理. * * *degraded (可传 NULL)回填就地降级的处数, 调用方据此提示用户. @@ -101,7 +101,7 @@ int image_has_refs(const JsonValue *messages); /* 把历史里所有图片引用就地降级成文字说明 ("不符合 API 所需的格式标准, * 无法上传, 请规范化图片后重试"), 返回处理处数. - * 不给理由参数:给模型的话要能直接照做, 服务端的原始英文报错由调用方打到 + * 不给理由参数: 给模型的话要能直接照做, 服务端的原始英文报错由调用方打到 * stderr, 不往上下文里塞. */ int image_degrade_all(JsonValue *messages); diff --git a/src/json.c b/src/json.c index 6a62271..228a455 100644 --- a/src/json.c +++ b/src/json.c @@ -319,7 +319,7 @@ LIGENT_PRINTF(2, 3) static void perr(Parser *ps, const char *fmt, ...) snprintf(ps->err, ps->errlen, jfmt(ps->ui, "json.err.at_end"), line, col, detail); - /* 最后统一净化:错误信息里不允许出现原始控制字符. + /* 最后统一净化: 错误信息里不允许出现原始控制字符. * 像 "意外的字符 '%c'" 这种格式化会直接把控制字符带进消息本体, * 原样打到终端上会把用户的屏幕搞乱. */ for (char *q = ps->err; *q; q++) { @@ -434,13 +434,13 @@ static JsonValue *parse_string(Parser *ps) return v; } -/* 严格按 RFC 8259 扫描一个数字: +/* 严格按 RFC 8259 扫描一个数字: * -?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)? * * 以前这里是"能吃多少吃多少", 于是 01、5.、1e、1e+ 全都被当成合法数字 —— * 而 strtod("1e") 会老老实实返回 1, 那个残缺的指数就被静默吞掉了. * 配置文件和存档都是手写的, 把写错的数字当成另一个数继续用下去, 用户只会在 - * 很久之后从一个完全不相干的现象里发现它. 所以语法必须自己严查一遍: + * 很久之后从一个完全不相干的现象里发现它. 所以语法必须自己严查一遍: * strtod 只负责最后那步文本 -> double 的转换. */ static JsonValue *parse_number(Parser *ps) { @@ -470,12 +470,12 @@ static JsonValue *parse_number(Parser *ps) double d = strtod(tmp, &endp); int all = (endp && *endp == '\0'); free(tmp); - if (!all) goto bad; /* 语法扫过了 strtod 却不认:不该发生 */ + if (!all) goto bad; /* 语法扫过了 strtod 却不认: 不该发生 */ return json_new_number(d); } bad: - /* 回退到数字的起点再报错:这样行列号指向"这个数字从哪开始", + /* 回退到数字的起点再报错: 这样行列号指向"这个数字从哪开始", * 而不是指向我们自己刚消费掉的那个字节. */ { char *tmp = xstrndup(start, (size_t)(ps->p - start)); @@ -601,7 +601,7 @@ done: return result; } -/* ui != 0:报错跟随界面语言; = 0:固定中文 (给模型看的那份). +/* ui != 0: 报错跟随界面语言; = 0: 固定中文 (给模型看的那份). * 见上面 jfmt 的说明与 README「界面语言」. */ static JsonValue *parse_impl(const char *text, char *errbuf, size_t errlen, int ui) { @@ -666,7 +666,7 @@ static void dump_string(StrBuf *sb, const char *s) if (c < 0x20) { sb_printf(sb, "\\u%04x", c); i++; continue; } if (c < 0x80) { sb_putc(sb, (char)c); i++; continue; } - /* 非 ASCII 字节:只有构成**合法** UTF-8 序列时才能原样进 JSON 字符串. + /* 非 ASCII 字节: 只有构成**合法** UTF-8 序列时才能原样进 JSON 字符串. * 以前这里是无条件 sb_putc, 于是工具输出里一个坏字节 (例如某条命令在 * 非 UTF-8 locale 下打出来的路径, 或者被截断的多字节字符)就会让**整个 * 请求体**不是合法 UTF-8, 服务端直接回 @@ -675,13 +675,13 @@ static void dump_string(StrBuf *sb, const char *s) * 除非 /reset 把整轮对话丢掉. * * 兜在出口而不是只在入口清理, 是因为出口才是能保证"发出去的永远合法" - * 的那一层:对话内容还可能来自存档、粘贴、/include 进来的文件…… + * 的那一层: 对话内容还可能来自存档、粘贴、/include 进来的文件…… * 这里统一把非法字节换成 U+FFFD, 既让请求成立, 也让模型看到"这里有个 * 无法解码的字符", 而不是收到一段乱码. */ size_t n = utf8_seq_len(s + i, len - i); if (n == 0) { sb_puts(sb, "\\ufffd"); i++; continue; } /* U+FFFD 一律写成 \ufffd 转义. 它是上面替换坏字节时用的字符, 如果这里 - * 让 EF BF BD 原样输出, dump→parse→dump 就不稳定了:第一次是 \ufffd, + * 让 EF BF BD 原样输出, dump→parse→dump 就不稳定了: 第一次是 \ufffd, * 解析回来变成真的 U+FFFD, 第二次就原样吐三个字节. * fuzz 的"dump 幂等"这条不变量就是被这个当场抓住的 —— 幂等是有价值的 * 性质, 它保证存档反复读写在字节层面也是收敛的. 给这一个字符定死一个 @@ -703,7 +703,7 @@ static void dump_number(StrBuf *sb, double n) if (isnan(n) || isinf(n)) { sb_puts(sb, "null"); return; } char buf[64]; /* 先判范围, 再转换. double -> long long 在值超出范围时是 UB, 而 && 的 - * 求值顺序保不住这个转换:原来它写在条件最左边, 服务端回一个 1e300 + * 求值顺序保不住这个转换: 原来它写在条件最左边, 服务端回一个 1e300 * 就会踩到 (clang 的 UBSan 报 float-cast-overflow; gcc 不插桩这条转换, * 所以 make SAN=1 抓不到). */ if (fabs(n) < 1e15 && n == (double)(long long)n) diff --git a/src/json.h b/src/json.h index 6539d83..bf70228 100644 --- a/src/json.h +++ b/src/json.h @@ -39,11 +39,11 @@ struct JsonValue { } u; }; -/* ---- 解析:失败返回 NULL 并写入 errbuf ---- */ +/* ---- 解析: 失败返回 NULL 并写入 errbuf ---- */ JsonValue *json_parse(const char *text, char *errbuf, size_t errlen); /* 同上, 但报错跟随界面语言 (给人看的配置/存档/响应报错用这个). - * json_parse 的报错固定中文:那些会原样回填给模型 (工具参数解析), + * json_parse 的报错固定中文: 那些会原样回填给模型 (工具参数解析), * 属于"给模型看的信息", 不随界面语言变. */ JsonValue *json_parse_ui(const char *text, char *errbuf, size_t errlen); @@ -85,7 +85,7 @@ void json_object_set_bool(JsonValue *obj, const char *key, int value); void json_object_set_null(JsonValue *obj, const char *key); void json_object_remove(JsonValue *obj, const char *key); -/* ---- 序列化:返回 malloc 的字符串 ---- */ +/* ---- 序列化: 返回 malloc 的字符串 ---- */ char *json_dump(const JsonValue *v); /* 紧凑格式 */ char *json_dump_pretty(const JsonValue *v); /* 2 空格缩进 */ /* 把任意 C 字符串转义成 JSON 字符串字面量 (含首尾引号) */ diff --git a/src/lineedit.c b/src/lineedit.c index 5d3be99..7b55a42 100644 --- a/src/lineedit.c +++ b/src/lineedit.c @@ -216,7 +216,7 @@ static char *hist_decode(const char *s) } /* 把内存里最新的 n 条重写回文件 (先写临时文件再 rename, - * 免得写到一半被杀掉就把历史弄没了). */ + * 以免写到一半被杀掉就把历史弄没了). */ static void hist_rewrite(const char *path, size_t n) { if (g_hist_len < n) n = g_hist_len; @@ -291,7 +291,7 @@ int line_history_append(const char *path, const char *line) { if (!path || !*path || !line || !*line) return 0; - /* 0600:历史里可能有密钥, 不给同机其他用户读的机会 */ + /* 0600: 历史里可能有密钥, 不给同机其他用户读的机会 */ int fd = open(path, O_WRONLY | O_APPEND | O_CREAT, 0600); if (fd < 0) return -1; FILE *f = fdopen(fd, "a"); @@ -304,7 +304,7 @@ int line_history_append(const char *path, const char *line) int rc = 0; if (sb.data && fwrite(sb.data, 1, sb.len, f) != sb.len) rc = -1; if (fputc('\n', f) == EOF) rc = -1; - /* 立刻 flush:另一个 ligent 实例要能马上翻到这条 */ + /* 立刻 flush: 另一个 ligent 实例要能马上翻到这条 */ if (fflush(f) != 0) rc = -1; if (fclose(f) != 0) rc = -1; sb_free(&sb); @@ -341,7 +341,7 @@ static int tty_raw(struct termios *saved) struct termios raw = *saved; raw.c_iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON); - /* 刻意保留 OPOST:\n 仍要能正常换行, 否则输出会阶梯状错位 */ + /* 刻意保留 OPOST: \n 仍要能正常换行, 否则输出会阶梯状错位 */ raw.c_cflag |= CS8; raw.c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG); raw.c_cc[VMIN] = 1; @@ -448,13 +448,13 @@ typedef struct { static size_t ansi_skip(const char *s, size_t n) { size_t i = 1; - if (i < n && s[i] == '[') { /* CSI:一直吃到终结字节 */ + if (i < n && s[i] == '[') { /* CSI: 一直吃到终结字节 */ i++; while (i < n && !(s[i] >= '@' && s[i] <= '~')) i++; if (i < n) i++; return i; } - if (i < n && s[i] == ']') { /* OSC:到 BEL 或 ST 结束 */ + if (i < n && s[i] == ']') { /* OSC: 到 BEL 或 ST 结束 */ i++; while (i < n) { if (s[i] == '\a') { i++; break; } @@ -486,7 +486,7 @@ static void advance_pos(const char *s, size_t n, int cols, int *row, int *col) } /* 打印完之后, 终端光标视觉上停在第几行第几列. - * 关键:字符正好填满一行时终端处于"待换行"状态, 光标仍停在本行末列. */ + * 关键: 字符正好填满一行时终端处于"待换行"状态, 光标仍停在本行末列. */ static void end_pos(const char *prompt, const char *buf, size_t len, int cols, int *row, int *col) { @@ -509,7 +509,7 @@ static void want_pos(const char *prompt, const char *buf, size_t pos, if (*col >= cols) { (*row)++; *col = 0; } } -/* 输入行整行着色:提示符和你敲进去的字都是绿的, 和模型的输出区分开. +/* 输入行整行着色: 提示符和你敲进去的字都是绿的, 和模型的输出区分开. * 颜色由这里统一加, 调用方传进来的 prompt 保持纯文本 —— 否则提示符自己 * 带一次 reset, 后面输入的正文就掉回默认色了. */ #define C_INPUT "\033[32m" @@ -547,7 +547,7 @@ static void refresh(Editor *e) if (e->lb->len) fwrite(e->lb->buf, 1, e->lb->len, stdout); if (g_input_color) fputs(C_INPUT_OFF, stdout); - /* 从"打印结束处"移到目标位置:先回列 0, 再上下行, 再右移 */ + /* 从"打印结束处"移到目标位置: 先回列 0, 再上下行, 再右移 */ int up = erow - trow; putchar('\r'); if (up > 0) printf("\033[%dA", up); @@ -560,14 +560,14 @@ static void refresh(Editor *e) /* 把粘贴进来的内容清洗成"编辑器能正确渲染"的文本. * - * 为什么必须清洗:read_paste 收下的是终端给的原始字节, 而缓冲区会被 - * 逐字节回显到终端上. 里面只要有控制字符, 两边的算盘就对不上: + * 为什么必须清洗: read_paste 收下的是终端给的原始字节, 而缓冲区会被 + * 逐字节回显到终端上. 里面只要有控制字符, 两边的算盘就对不上: * - \t 终端按制表位展开 (下一个 8 的倍数), 编辑器却只当 1 列, * 粘一段带缩进的代码, 光标就从此偏掉, 后面每敲一个字都在错位置上; * - ESC 会被终端当成转义序列真的执行掉 —— 粘贴内容能改颜色、清屏; * - \r 把终端光标拉回行首, 屏幕当场错位; * - NUL 会把 C 字符串截断. - * 处理办法:\n 保留 (多行粘贴整段提交靠它), \t 就地展开成空格 (这样 + * 处理办法: \n 保留 (多行粘贴整段提交靠它), \t 就地展开成空格 (这样 * 缓冲区里不留制表符, 宽度怎么算都对), ESC 连同后面整条序列一起丢掉, * 其余控制字符丢弃. * diff --git a/src/lineedit.h b/src/lineedit.h index cbd3c13..b752990 100644 --- a/src/lineedit.h +++ b/src/lineedit.h @@ -1,6 +1,6 @@ /* lineedit.h - REPL 的行编辑器 * - * 为什么需要它:原来的 REPL 用 fgets 直接读, 终端停在规范模式, + * 为什么需要它: 原来的 REPL 用 fgets 直接读, 终端停在规范模式, * 方向键发出的转义序列 (\x1b[D 之类)会被当成普通字节读进来, * 显示成 ^[[D 而不是移动光标. 要有行内编辑就必须自己进 raw 模式接管按键. * @@ -37,7 +37,7 @@ void linebuf_kill_to_end(LineBuf *lb); /* 删到行尾 */ void linebuf_kill_to_start(LineBuf *lb); /* 删到行首 */ void linebuf_kill_word(LineBuf *lb); /* 往回删一个词 */ -/* 输入行是否着色 (整行:提示符 + 你敲进去的字). +/* 输入行是否着色 (整行: 提示符 + 你敲进去的字). * 传进来的 prompt 应当是纯文本 —— 颜色由编辑器统一加在两头. */ void line_edit_set_color(int enabled); @@ -67,7 +67,7 @@ const char *line_history_get(size_t i); * (\ -> \\, 换行 -> \n), 保证读回来和写出去一模一样. 不含反斜杠的 * 普通命令在文件里与输入完全一致, 肉眼可读. * - * 时间戳之类的元数据刻意不记:目的只是"上下键能翻到", 不是审计. */ + * 时间戳之类的元数据刻意不记: 目的只是"上下键能翻到", 不是审计. */ /* 从 path 读取历史, 追加到当前历史之后. 最多保留最新的 keep 条; * 文件本身更长时会被就地截短 (否则会无限增长). diff --git a/src/llm.c b/src/llm.c index fe4eddd..0427431 100644 --- a/src/llm.c +++ b/src/llm.c @@ -1,4 +1,4 @@ -/* llm.c - OpenAI 兼容接口:请求构造、SSE 流式解析、tool_calls 增量拼接 */ +/* llm.c - OpenAI 兼容接口: 请求构造、SSE 流式解析、tool_calls 增量拼接 */ #include "llm.h" #include "config.h" /* THINKING_AUTO / _ON / _OFF */ #include "http.h" @@ -90,7 +90,7 @@ static char *extract_error_message(const char *body) return msg; } -/* 401/403 时补一句提示. 服务端的原文经常把人引向错误的方向: +/* 401/403 时补一句提示. 服务端的原文经常把人引向错误的方向: * OpenRouter 对"格式不对的 key"回的是 "Missing Authentication header", * 听起来像是请求里没带认证头, 实际上带了, 只是 key 不属于这家. */ static void append_auth_hint(char *errbuf, size_t errlen, @@ -104,7 +104,7 @@ static void append_auth_hint(char *errbuf, size_t errlen, if (n == 0) { snprintf(masked, sizeof masked, "%s", t("llm.mask.empty")); } else { - /* 只露前几个字符:够认出是哪一把, 又不至于把密钥打进日志 */ + /* 只露前几个字符: 够认出是哪一把, 又不至于把密钥打进日志 */ char head[8]; snprintf(head, sizeof head, "%.*s", n <= 10 ? 4 : 7, key); snprintf(masked, sizeof masked, "%s", @@ -252,7 +252,7 @@ static void handle_payload(SseCtx *c, const char *payload, size_t len) /* 思考过程. DeepSeek 用 reasoning_content, 有些 OpenAI 兼容实现用 * reasoning. 只累积、不回调 —— 用户明确要求不显示, 但它必须随 - * assistant 消息一起回传:请求带 tools 时服务端要求这么做, + * assistant 消息一起回传: 请求带 tools 时服务端要求这么做, * 漏了会在下一轮直接 400. */ const char *think = json_get_string(d, "reasoning_content"); if (!think) think = json_get_string(d, "reasoning"); @@ -326,7 +326,7 @@ static void feed(SseCtx *c, const char *data, size_t len) buf[c->linebuf.len] = '\0'; } - /* 残行过长:丢弃, 保证内存有界 */ + /* 残行过长: 丢弃, 保证内存有界 */ if (c->linebuf.len > SSE_MAX_LINE) sb_clear(&c->linebuf); } @@ -388,7 +388,7 @@ static LlmResponse *from_sse(SseCtx *c) else json_object_set_null(r->message, "content"); - /* 思考过程跟着 assistant 消息一起进历史. 用户看不到它, 但它必须原样回传: + /* 思考过程跟着 assistant 消息一起进历史. 用户看不到它, 但它必须原样回传: * 只要请求里带 tools, 服务端就要求把历史里的 reasoning_content 全带上, * 否则下一轮直接 400. */ if (c->reasoning.len > 0) diff --git a/src/main.c b/src/main.c index 9f7ddad..68735e9 100644 --- a/src/main.c +++ b/src/main.c @@ -1,4 +1,4 @@ -/* main.c - 入口:命令行解析、单次任务模式、交互式 REPL */ +/* main.c - 入口: 命令行解析、单次任务模式、交互式 REPL */ #include "config.h" #include "agent.h" #include "tools.h" @@ -41,7 +41,7 @@ static void install_signals(void) memset(&sa, 0, sizeof sa); sa.sa_handler = on_sigint; sigemptyset(&sa.sa_mask); - sa.sa_flags = 0; /* 不设 SA_RESTART:让 read/fgets 被 EINTR 打断 */ + sa.sa_flags = 0; /* 不设 SA_RESTART: 让 read/fgets 被 EINTR 打断 */ sigaction(SIGINT, &sa, NULL); signal(SIGPIPE, SIG_IGN); } @@ -113,7 +113,7 @@ static void print_banner(const LigentConfig *c, const Agent *a) /* 存档目录跟二进制走, 和配置同一个原则 */ static const char *g_bindir; -/* 当前对话的谱系:最近一次写出或载入的存档名 (不含 .json). +/* 当前对话的谱系: 最近一次写出或载入的存档名 (不含 .json). * 下次存档就在它后面接一级分支, 于是文件名本身就记录了分支关系. */ static char *g_lineage; @@ -153,7 +153,7 @@ static void auto_save(Agent *a, LigentConfig *c, const char *why, char *path = NULL, *name = NULL; char err[512]; - /* meta 改过就另起一根:<原名>_m<3位>, parent 记空 */ + /* meta 改过就另起一根: <原名>_m<3位>, parent 记空 */ int meta = meta_reroot_pending(); if (archive_export(a->messages, g_bindir ? g_bindir : ".", g_lineage, prefix, meta, a->started_at, time(NULL), @@ -190,12 +190,12 @@ static void apply_model(Agent *a, LigentConfig *c, const char *name) printf("%s", tf("repl.model.switched", "model", c->model, NULL)); } -/* /model 不带参数:拉一次在线列表, 打印出来, 然后用**普通输入行**问要哪个. +/* /model 不带参数: 拉一次在线列表, 打印出来, 然后用**普通输入行**问要哪个. * - * 刻意不用"光标选择"那种菜单:一是它会用 ANSI 反复重画、把 stdout 搅乱 + * 刻意不用"光标选择"那种菜单: 一是它会用 ANSI 反复重画、把 stdout 搅乱 * (重定向、日志、粘贴都会跟着遭殃), 二是**没法输入列表里没有的东西** —— - * 而"列表里没有"恰恰很常见:内部模型、灰度模型、服务端没列出来的别名. - * 所以这里是 /import 那一套:列表打在上面, 输入行里既可以填序号, 也可以 + * 而"列表里没有"恰恰很常见: 内部模型、灰度模型、服务端没列出来的别名. + * 所以这里是 /import 那一套: 列表打在上面, 输入行里既可以填序号, 也可以 * 直接写模型名, 回车取消. * * 拉不到列表就明说原因, 并提示还能直接用 /model <名字> —— 不能因为网络不行 @@ -267,7 +267,7 @@ static int include_can_image(void *ud) Agent *a = ud; agent_ensure_caps(a); /* 只有**明确不支持**才拦. 未测 (探测被关掉或结论不确定)按支持放行, - * 和别处一个原则:宁可让服务端说不行, 也不错误地藏掉东西. */ + * 和别处一个原则: 宁可让服务端说不行, 也不错误地藏掉东西. */ return a->caps.vision != CAP_NO; } @@ -344,7 +344,7 @@ static void cmd_export(Agent *a, LigentConfig *c) free(name); } -/* 按名字找一份存档:支持完整文件名、不含 .json 的名字、以及唯一前缀 */ +/* 按名字找一份存档: 支持完整文件名、不含 .json 的名字、以及唯一前缀 */ static const ArchiveEntry *find_entry(const ArchiveEntry *list, size_t n, const char *want) { @@ -361,9 +361,9 @@ static const ArchiveEntry *find_entry(const ArchiveEntry *list, size_t n, return hit; } -/* 载入存档:给了名字就直接载入, 否则交互式选一份. +/* 载入存档: 给了名字就直接载入, 否则交互式选一份. * 只把历史装回去, 不重放历史内容. */ -/* 问一个是非题, (Y/n) 语义:直接回车算"是". +/* 问一个是非题, (Y/n) 语义: 直接回车算"是". * 非终端时没人能回答, 按默认值返回 1 —— 和 confirm_tool 一致. */ static int ask_yes_no(const char *question) { @@ -384,7 +384,7 @@ static int ask_yes_no(const char *question) /* /import 载入后用户可能想看看这段历史到底聊了什么. * - * 这里是**完整**转储:不截断正文、不省略工具参数、不砍总量. 既然用户 + * 这里是**完整**转储: 不截断正文、不省略工具参数、不砍总量. 既然用户 * 明确回答了 Y, 就该看到全部 —— 截一半反而会让人以为历史里就这么点东西. * 所以只做排版, 不做取舍. * @@ -530,10 +530,10 @@ static void cmd_import(Agent *a, const char *arg) } char *loaded = xstrdup(pick->name); agent_replace_history(a, msgs); /* 取得所有权 */ - /* 载入之后再数:换成和 /history 一样的口径 (不含 system), + /* 载入之后再数: 换成和 /history 一样的口径 (不含 system), * 否则"已载入 N 条"和"历史消息 M 条"会差 1, 看着像出了错 */ size_t count = agent_conversation_len(a); - /* 输入历史不跟着清:它现在是一份持久的、跨会话的用户资源 (像 bash 的 + /* 输入历史不跟着清: 它现在是一份持久的、跨会话的用户资源 (像 bash 的 * HISTFILE), 记的是"我在 ligent> 敲过什么", 和当前装的是哪段对话无关. */ set_lineage(loaded); /* 之后的分支都挂在它下面 */ printf("%s", tf("repl.import.done", "name", loaded, @@ -551,7 +551,7 @@ static void repl_help(void) fputs(t("repl.help"), stdout); } -/* 带参数命令的匹配:/name 之后必须是空白或字符串结束. +/* 带参数命令的匹配: /name 之后必须是空白或字符串结束. * * 只用前缀匹配是不够的 —— 那样 "/modelxxx" 会被当成 "/model" 并把 xxx * 当作模型名去切模型, "/thinkfoo" 也会落进 /think 分支. 命中返回参数起点 @@ -583,7 +583,7 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line) auto_save(a, c, t("repl.why.before_reset"), NULL); agent_reset(a); /* 刚存的是"上一段"对话, 和新开的这段没关系了, 所以把判重状态清掉. - * 不清的话会漏存:判重只看 (条数, started_at), 而 time() 只有秒级 + * 不清的话会漏存: 判重只看 (条数, started_at), 而 time() 只有秒级 * 精度 —— 同一秒里重置、又聊出同样多条消息, 退出时就会被当成 * "内容没变"而跳过存档. */ g_saved_count = 0; @@ -631,12 +631,12 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line) ? c->reasoning_effort : t("cfg.val.server_default")); } } else if (strcmp(cmd, "/meta") == 0) { - /* 进 meta 前先留一份保底存档:里面全是不可逆的删改. - * 用 tmp_ 前缀, 免得它变成以后分支的祖先. */ + /* 进 meta 前先留一份保底存档: 里面全是不可逆的删改. + * 用 tmp_ 前缀, 以免它变成以后分支的祖先. */ auto_save(a, c, t("repl.why.before_meta"), ARCHIVE_TMP_PREFIX); int r = meta_session(a, c); if (r == 1) { - /* 历史被改过:/back 的撤销栈基于旧历史, 作废; + /* 历史被改过: /back 的撤销栈基于旧历史, 作废; * 存档判重也清掉, 保证退出时一定存下这个新版本. */ back_undo_clear(); g_saved_count = 0; @@ -723,14 +723,14 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line) } } else if ((arg = cmd_arg(cmd, "/shell")) != NULL) { if (*arg) { - /* 带参数:直接执行它, 不进入交互式 shell */ + /* 带参数: 直接执行它, 不进入交互式 shell */ JsonValue *args = json_new_object(); json_object_set_string(args, "command", arg); int is_err = 0; char *r = tool_execute(TOOL_RUN_COMMAND, args, &is_err); json_free(args); puts(r); - char *msg = xasprintf("我在 shell 里执行了:%s\n\n结果是:\n%s", + char *msg = xasprintf("我在 shell 里执行了: %s\n\n结果是: \n%s", arg, r); agent_add_user(a, msg); free(msg); @@ -746,7 +746,7 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line) } else { if (rec[0]) { char *msg = xasprintf( - "我在交互式 shell 里做了以下操作 (shell 退出码 %d):\n\n" + "我在交互式 shell 里做了以下操作 (shell 退出码 %d): \n\n" "```console\n%s\n```\n", code, rec); agent_add_user(a, msg); printf("\n%s\n", @@ -792,7 +792,7 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line) * 而下面列出来的是**含** system 的全部消息 —— 两个数不一样, 所以必须 * 说清楚, 否则用户会以为打印漏了或者多了一条. * - * 为什么要连 system 一起列:那是"模型被要求怎么做"的原文, 想核对提示词 + * 为什么要连 system 一起列: 那是"模型被要求怎么做"的原文, 想核对提示词 * 时只有这里能看到全文 (/config 里那行会把多行提示词挤成一团). */ printf("%s", tf("repl.history.count", "n", i18n_num((long long)agent_conversation_len(a)), NULL)); @@ -813,12 +813,12 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line) /* 输入历史文件; NULL 表示禁用 (--no-history). */ static char *g_hist_path = NULL; -/* 提示符保持纯文本:绿色由行编辑器统一加在整个输入行 (提示符 + 你敲的字)上. +/* 提示符保持纯文本: 绿色由行编辑器统一加在整个输入行 (提示符 + 你敲的字)上. * 在这里塞颜色的话, 提示符自带的 reset 会让后面的输入掉回默认色. */ #define PROMPT_MAIN "ligent> " #define PROMPT_CONT " ... " -/* 读一条完整输入. 支持行尾反斜杠续行:每段都由行编辑器独立编辑, +/* 读一条完整输入. 支持行尾反斜杠续行: 每段都由行编辑器独立编辑, * 因此方向键、Home/End、历史在续行的每一段里都可用. */ static char *read_prompt_line(const char *prompt, int *interrupted) { @@ -839,7 +839,7 @@ static char *read_prompt_line(const char *prompt, int *interrupted) sb_free(&sb); return NULL; } - /* EOF:缓冲区里还有内容就把它交出去, 否则终止 */ + /* EOF: 缓冲区里还有内容就把它交出去, 否则终止 */ if (sb.len > 0) { if (sb.data[sb.len - 1] != '\n') sb_putc(&sb, '\n'); return sb_detach(&sb); @@ -863,7 +863,7 @@ static char *read_prompt_line(const char *prompt, int *interrupted) free(seg); if (!cont) { sb_putc(&sb, '\n'); - /* 历史记的是"这一整条输入":多行续行算一条, 上下键能整条召回. + /* 历史记的是"这一整条输入": 多行续行算一条, 上下键能整条召回. * 续行的中间段不单独记. 这里借尾部那个换行临时当结束符. */ if (sb.len > 1) { sb.data[sb.len - 1] = '\0'; @@ -930,7 +930,7 @@ int main(int argc, char **argv) const char *explicit_config = NULL; char err[512]; - /* 界面语言:--lang > LIGENT_LANG > LC_ALL/LC_MESSAGES/LANG > 英文. + /* 界面语言: --lang > LIGENT_LANG > LC_ALL/LC_MESSAGES/LANG > 英文. * 先粗扫一遍 argv, 好让 --help 和 --lang 的先后顺序不影响结果 —— * 解析循环一遇到 --help 就直接返回, 那之后再写 --lang 就来不及了. */ { @@ -1054,7 +1054,7 @@ int main(int argc, char **argv) } else if (strcmp(arg, "--stream") == 0) { cli.stream_set = 1; cli.stream_val = 1; } else if (strcmp(arg, "doctor") == 0 && !cli.doctor && cli.prompt.len == 0) { - cli.doctor = 1; /* 子命令:ligent doctor */ + cli.doctor = 1; /* 子命令: ligent doctor */ } else if (strcmp(arg, "--offline") == 0) { cli.offline = 1; } else if (strcmp(arg, "--no-confirm") == 0) { @@ -1088,7 +1088,7 @@ int main(int argc, char **argv) fprintf(stderr, "%s", tf("cli.err.bad_lang", "value", val, NULL)); return 2; } - i18n_init(l); /* "auto" 也是 -1:交给环境探测 */ + i18n_init(l); /* "auto" 也是 -1: 交给环境探测 */ } else if (strcmp(arg, "--print-config") == 0) { cli.print_config = 1; } else if (strcmp(arg, "--") == 0) { @@ -1107,7 +1107,7 @@ int main(int argc, char **argv) #undef NEED_VAL } - /* 配置:默认值 → 文件 → 环境变量 → 命令行 */ + /* 配置: 默认值 → 文件 → 环境变量 → 命令行 */ /* doctor 要能在"配置本身坏了"的时候跑起来 —— 那正是最需要它的时刻 */ char cfg_error[512]; cfg_error[0] = '\0'; @@ -1198,7 +1198,7 @@ int main(int argc, char **argv) g_bindir = cfg.config_dir; image_set_bindir(cfg.config_dir); /* 理由见 run_repl 里那段注释 */ - /* 输入历史文件:命令行 > 环境变量 > <二进制目录>/ligent.history. + /* 输入历史文件: 命令行 > 环境变量 > <二进制目录>/ligent.history. * 和配置一样, 默认只放二进制旁边, 不碰用户级/系统级目录. */ { const char *env = getenv("LIGENT_HISTORY"); @@ -1211,7 +1211,7 @@ int main(int argc, char **argv) } else { g_hist_path = xasprintf("%s/ligent.history", cfg.config_dir); } - /* 读不回来不是错误:第一次跑本来就没有这个文件, 只读的目录也不该 + /* 读不回来不是错误: 第一次跑本来就没有这个文件, 只读的目录也不该 * 拦住 REPL. 加载失败静默降级成"这次没有历史". */ if (g_hist_path) line_history_load(g_hist_path, LIGENT_HISTORY_KEEP); } @@ -1223,7 +1223,7 @@ int main(int argc, char **argv) exit_code = rc == 0 ? 0 : 1; auto_save(&agent, &cfg, t("repl.why.exit"), NULL); } else if (!isatty(fileno(stdin))) { - /* 管道输入:把 stdin 当任务 */ + /* 管道输入: 把 stdin 当任务 */ char *text = read_stdin_all(); char *trimmed = str_trim(text); if (*trimmed) { diff --git a/src/markdown.c b/src/markdown.c index 15ea9de..9254ceb 100644 --- a/src/markdown.c +++ b/src/markdown.c @@ -1,6 +1,6 @@ /* markdown.c - 流式 Markdown 渲染状态机 * - * 核心约束:SSE 的分片位置是任意的, 一个标记 (`**`、行首的 `#`、`[`) + * 核心约束: SSE 的分片位置是任意的, 一个标记 (`**`、行首的 `#`、`[`) * 完全可能被切成两片. 因此遇到"可能是标记前缀"的尾部时不能立即输出, * 必须把这段字节留在缓冲区里等下一个分片凑齐后再判定. */ @@ -13,26 +13,26 @@ /* 样式定义 (硬编码 SGR, 不依赖 terminfo) * - * **只用 16 色范围内的码**:前景 30-37, 加粗/暗淡/斜体用 1/2/3. + * **只用 16 色范围内的码**: 前景 30-37, 加粗/暗淡/斜体用 1/2/3. * 不用 90-97 那一段 (aixterm 的"亮色")—— 它虽然到处都是, 但严格讲不属于 * 最窄的 16 色 ANSI, 而这段代码是硬编码 SGR、不做终端能力探测的 (连 terminfo * 都不查). 既然不探测, 就该待在**所有终端都认**的那一档里. * * 原来链接用的是亮蓝 (aixterm 的 94), 是这里唯一一个越界的码. 改成 - * "\033[4;36m" (下划线 + 青):链接在终端里的传统表示本来就是下划线, + * "\033[4;36m" (下划线 + 青): 链接在终端里的传统表示本来就是下划线, * 而且青色和行内代码、列表符号同色系, 视觉上"这些是标记不是正文"是一致的. * 更重要的是下划线是**属性** (SGR 4), 比颜色更可靠 —— 单色终端、色盲用户、 * 以及把颜色重映射过的主题里, 下划线都还在. * - * (这里不写出那个越界码的字面量, 免得以后 grep `\[9[0-7]` 做审计时被注释 + * (这里不写出那个越界码的字面量, 以免以后 grep `\[9[0-7]` 做审计时被注释 * 里的这一行报成假阳性. ) */ #define MD_RESET "\033[0m" #define MD_BOLD "\033[1m" #define MD_DIM "\033[2m" #define MD_ITALIC "\033[3m" -#define MD_CODE "\033[36m" /* 行内代码:青 */ +#define MD_CODE "\033[36m" /* 行内代码: 青 */ #define MD_BULLET "\033[36m" -#define MD_LINK "\033[4;36m" /* 链接文字:下划线 + 青 */ +#define MD_LINK "\033[4;36m" /* 链接文字: 下划线 + 青 */ #define MD_H1 "\033[1;36m" /* 粗体青 */ #define MD_H2 "\033[1;35m" /* 粗体品红 */ #define MD_H3 "\033[1;34m" /* 粗体蓝 */ @@ -41,7 +41,7 @@ /* 等待更多数据 (当前字节可能是一个被切断的标记) */ #define MD_NEED_MORE ((size_t)-1) -/* 待处理缓冲上限:遇到始终等不到结束标记的超长行时直接吐出, 保证内存有界 */ +/* 待处理缓冲上限: 遇到始终等不到结束标记的超长行时直接吐出, 保证内存有界 */ #define MD_MAX_PENDING (64 * 1024) /* 候选表格行超过这个长度就不再当表格等下去, 避免长行被卡住 */ #define MD_MAX_TABLE_LINE 4096 @@ -72,7 +72,7 @@ struct MdRenderer { int table_rows; /* 置位后表示"不会再有新数据", 所有挂起都必须就地解开. - * 这是"输出与分片方式无关"的关键:挂起只允许发生在还有后续数据时, + * 这是"输出与分片方式无关"的关键: 挂起只允许发生在还有后续数据时, * 到 EOF 一律按固定规则判定, 决策因此只依赖字节流本身. */ int eof; }; @@ -109,7 +109,7 @@ static void close_line_styles(MdRenderer *r) /* 显示宽度相关工具已移到 util.c (行编辑器也要用) */ /* ------------------------------------------------------------------ */ -/* 表格:解析 */ +/* 表格: 解析 */ /* ------------------------------------------------------------------ */ typedef struct { @@ -155,7 +155,7 @@ static void row_split(const char *line, size_t n, TblRow *out) } } -/* 分隔单元格::?-+:? */ +/* 分隔单元格: :?-+:? */ static int is_delim_cell(const char *c, size_t n) { size_t i = 0, dashes = 0; @@ -166,7 +166,7 @@ static int is_delim_cell(const char *c, size_t n) return i == n; } -/* 表头行 + 分隔行是否构成合法表格: +/* 表头行 + 分隔行是否构成合法表格: * 两者单元格数必须相同, 且第二行每格都是 :?-+:? * (这条校验同时挡掉了 "cat a | grep b" 跟着一行 "--------" 的误判) */ static int table_head_ok(const char *h, size_t hn, const char *d, size_t dn) @@ -191,7 +191,7 @@ static int count_pipes(const char *s, size_t n) } /* ------------------------------------------------------------------ */ -/* 表格:渲染 */ +/* 表格: 渲染 */ /* ------------------------------------------------------------------ */ static void emit_padded(MdRenderer *r, const char *cell, size_t width, TblAlign al) @@ -320,7 +320,7 @@ static void table_flush(MdRenderer *r) } /* ------------------------------------------------------------------ */ -/* 块级语法:只在行首调用 */ +/* 块级语法: 只在行首调用 */ /* 返回消耗的字节数; 0 表示"不是块级语法, 按普通字符处理"; * MD_NEED_MORE 表示需要更多数据才能判定 */ /* ------------------------------------------------------------------ */ @@ -333,8 +333,8 @@ static size_t scan_line(const char *s, size_t len, size_t i, int *has_nl) return j - i; } -/* 规则表:从上往下依次尝试. - * 关键约定:每条规则只有在"确实消费了字节"时才 return; +/* 规则表: 从上往下依次尝试. + * 关键约定: 每条规则只有在"确实消费了字节"时才 return; * 判定为"不适用"时必须继续往下走, 绝不能中途 return 0 —— * 否则会跳过后面所有规则 (早期版本正是因此让表格探测吃掉了列表规则). */ static size_t try_block(MdRenderer *r, size_t i) @@ -342,7 +342,7 @@ static size_t try_block(MdRenderer *r, size_t i) const char *s = r->in.data; size_t len = r->in.len; - /* ---- 围栏代码块内部:只认闭合行, 其余一律当代码内容 ---- */ + /* ---- 围栏代码块内部: 只认闭合行, 其余一律当代码内容 ---- */ if (r->in_fence) { if (s[i] != r->fence_char) return 0; size_t run = 0; @@ -379,7 +379,7 @@ static size_t try_block(MdRenderer *r, size_t i) return ln + (has_nl ? 1 : 0); } table_flush(r); - /* 表格到此结束. 注意不 return:当前行可能是个标题/列表, + /* 表格到此结束. 注意不 return: 当前行可能是个标题/列表, * 要继续往下走让它被正常渲染. */ } @@ -389,7 +389,7 @@ static size_t try_block(MdRenderer *r, size_t i) while (i + run < len && s[i + run] == '#') run++; if (i + run >= len && !r->eof) { if (run <= 6) return MD_NEED_MORE; - /* run > 6:确定不是标题, 继续往下判断 */ + /* run > 6: 确定不是标题, 继续往下判断 */ } else if (run >= 1 && run <= 6 && (s[i + run] == ' ' || s[i + run] == '\t')) { static const char *codes[6] = { @@ -432,14 +432,14 @@ static size_t try_block(MdRenderer *r, size_t i) else { r->line_start = 0; } return (j - i) + (has_nl ? 1 : 0); } - /* 少于 3 个反引号:交给行内代码逻辑 */ + /* 少于 3 个反引号: 交给行内代码逻辑 */ } /* ---- 无序列表 / 分隔线 ---- */ if (s[i] == '-' || s[i] == '*' || s[i] == '+') { if (i + 1 >= len) { if (!r->eof) return MD_NEED_MORE; /* 可能是 "- " 列表或 "---" */ - /* EOF 且只剩这一个字符:当字面量 */ + /* EOF 且只剩这一个字符: 当字面量 */ } else { if (s[i + 1] == ' ') { /* 保留模型输出的原始标记字符并上色, 不替换成 • */ @@ -471,13 +471,13 @@ static size_t try_block(MdRenderer *r, size_t i) } } - /* ---- 表格起始:表头行 + 分隔行. 放在最后, 因为它需要看两行 ---- */ + /* ---- 表格起始: 表头行 + 分隔行. 放在最后, 因为它需要看两行 ---- */ { int nl1; size_t ln1 = scan_line(s, len, i, &nl1); /* 表格必须顶格以 '|' 开头. - * 这个判断在行的第 0 个字节就能做出 —— 这是关键:只要"是否挂起" + * 这个判断在行的第 0 个字节就能做出 —— 这是关键: 只要"是否挂起" * 依赖行内更靠后的内容 (例如"整行是否含竖线"), 那么当分片边界 * 落在竖线之前时, 行的前半段已经被当成普通文本输出了, 再也无法回头, * 结果就会随分片方式而变. */ @@ -550,7 +550,7 @@ static void feed_enabled(MdRenderer *r, const char *text, size_t len) continue; } - /* ---- 围栏代码块内部:逐字原样输出 ---- */ + /* ---- 围栏代码块内部: 逐字原样输出 ---- */ if (r->in_fence) { put(r, s + i, 1); r->line_start = 0; @@ -558,7 +558,7 @@ static void feed_enabled(MdRenderer *r, const char *text, size_t len) continue; } - /* ---- 行内代码内部:只有反引号有意义, 内容不做任何解析 ---- */ + /* ---- 行内代码内部: 只有反引号有意义, 内容不做任何解析 ---- */ if (r->in_code) { if (c == '`') { style(r, MD_DIM); @@ -583,8 +583,8 @@ static void feed_enabled(MdRenderer *r, const char *text, size_t len) continue; } if (remain < 2) { - /* 末尾的单个 '*':可能是 '**' 的前半个, 必须等下一个分片. - * 只有到 EOF 才能确定后面没有第二个 '*', 此时按固定规则解开: + /* 末尾的单个 '*': 可能是 '**' 的前半个, 必须等下一个分片. + * 只有到 EOF 才能确定后面没有第二个 '*', 此时按固定规则解开: * 正在斜体就当作收尾, 否则当字面量. */ if (!r->eof) break; if (r->in_italic) { @@ -616,19 +616,19 @@ static void feed_enabled(MdRenderer *r, const char *text, size_t len) continue; } - /* ---- _斜体_:按 CommonMark 规则禁用词内强调, 避免误伤 foo_bar_baz ---- */ + /* ---- _斜体_: 按 CommonMark 规则禁用词内强调, 避免误伤 foo_bar_baz ---- */ if (c == '_') { int prev = (i > 0) ? (unsigned char)s[i - 1] : r->last_ch; if (i + 1 >= r->in.len) { if (!r->eof) break; /* 需要看后一个字符 */ - put(r, "_", 1); /* EOF:没有后一个字符, 当字面量 */ + put(r, "_", 1); /* EOF: 没有后一个字符, 当字面量 */ r->line_start = 0; i++; continue; } int next = (unsigned char)s[i + 1]; - /* 与另一个下划线相邻时一律当字面量: + /* 与另一个下划线相邻时一律当字面量: * 这样 Python 的 __init__、__main__ 不会被拆成斜体 */ if (prev == '_' || next == '_') { put(r, "_", 1); @@ -656,11 +656,11 @@ static void feed_enabled(MdRenderer *r, const char *text, size_t len) continue; } - /* ---- [链接文字]:蓝色高亮, 其余部分 (含 (url))原样保留 ---- */ + /* ---- [链接文字]: 蓝色高亮, 其余部分 (含 (url))原样保留 ---- */ if (c == '[') { if (remain < 2) { if (!r->eof) break; /* 需要看后一个字符 */ - put(r, "[", 1); /* EOF:当字面量 */ + put(r, "[", 1); /* EOF: 当字面量 */ r->line_start = 0; i++; continue; @@ -716,7 +716,7 @@ static void feed_enabled(MdRenderer *r, const char *text, size_t len) r->in.data[r->in.len] = '\0'; } - /* 兜底:等待中的残留过大就原样吐出, 保证内存有界 */ + /* 兜底: 等待中的残留过大就原样吐出, 保证内存有界 */ if (r->in.len > MD_MAX_PENDING) { close_line_styles(r); put(r, r->in.data, r->in.len); @@ -740,12 +740,12 @@ void md_finish(MdRenderer *r) { if (!r) return; if (r->enabled) { - /* 关键:置 eof 后把残留重新过一遍状态机, 让所有挂起按固定规则解开. + /* 关键: 置 eof 后把残留重新过一遍状态机, 让所有挂起按固定规则解开. * 这样"一次性喂"与"分片喂"在结尾处的结果必然一致. */ r->eof = 1; feed_enabled(r, "", 0); if (r->in_table) table_flush(r); - if (r->in.len > 0) { /* 兜底:正常不该走到 */ + if (r->in.len > 0) { /* 兜底: 正常不该走到 */ close_line_styles(r); put(r, r->in.data, r->in.len); r->in.len = 0; diff --git a/src/markdown.h b/src/markdown.h index 4e3ec46..9bca79f 100644 --- a/src/markdown.h +++ b/src/markdown.h @@ -1,13 +1,13 @@ /* markdown.h - 面向终端输出的流式 Markdown 渲染器 * - * 设计目标:在"纯行式 REPL"的前提下把常见 Markdown 变成 ANSI 样式, + * 设计目标: 在"纯行式 REPL"的前提下把常见 Markdown 变成 ANSI 样式, * 且必须能处理任意分片 (SSE 的一小片文本可能把一个标记切成两半). * - * 渲染: + * 渲染: * 块级 # ~ ###### 标题、> 引用、- * + 无序列表、--- 分隔线、| 表格 * 行内 **粗体**、*斜体* / _斜体_、`行内代码`、[链接文字] (蓝色高亮) * - * 不渲染 (原样保留, 一个字节都不丢): + * 不渲染 (原样保留, 一个字节都不丢): * 围栏代码块 (含 ``` 标记本身)、图片 ![alt](src)、链接的 (url) 部分、 * 行内代码的内容 */ @@ -29,7 +29,7 @@ void md_set_out(MdRenderer *r, FILE *out); /* 喂入一段文本 (NUL 结尾). 内部保持状态, 可被任意切分 */ void md_feed(MdRenderer *r, const char *text); -/* 一条消息结束:冲刷残留、闭合未闭合的样式 */ +/* 一条消息结束: 冲刷残留、闭合未闭合的样式 */ void md_finish(MdRenderer *r); void md_free(MdRenderer *r); diff --git a/src/meta.c b/src/meta.c index e1e5fe3..c91da35 100644 --- a/src/meta.c +++ b/src/meta.c @@ -1,10 +1,10 @@ -/* meta.c - /meta:用模型辅助编辑对话历史 +/* meta.c - /meta: 用模型辅助编辑对话历史 * - * 结构上是一个"第二个 Agent":它有自己的 messages (meta 自己的会话)、 + * 结构上是一个"第二个 Agent": 它有自己的 messages (meta 自己的会话)、 * 自己的 tools、自己的 system 提示词, 但共用主对话的 LLM 配置. * agent_run() 一行不改就能跑它 —— 工具分发已经从硬编码改成了函数指针. * - * 三条硬约束 (决定了下面所有工具的形状): + * 三条硬约束 (决定了下面所有工具的形状): * 1. assistant 带 tool_calls 时, 后面必须紧跟一一对应的 tool 消息; * 2. system 必须在最前, 且不归 meta 管 (那是 /system 的事); * 3. 改完之后, 历史必须仍是一段合法的对话. @@ -45,7 +45,7 @@ static const char *META_SYSTEM_PROMPT = " 不要顺手把整轮都删了. \n" "3. 用户没要求压缩就不要压缩 —— 压缩会丢证据. \n" "4. 每次改动后消息编号会重排. 要继续改, 先重新 list_rounds. \n" -"5. 改完汇报:改了什么、为什么、增减多少. 不要复述历史. \n" +"5. 改完汇报: 改了什么、为什么、增减多少. 不要复述历史. \n" "\n" "## 红线\n" "- 带 tool_calls 的 assistant 消息和紧随其后的 tool 结果是不可拆的整体, 否则会造成400错误. \n" @@ -59,7 +59,7 @@ static const char *META_SYSTEM_PROMPT = "\n" "用中文回复 (除非用户用了别的语言). "; -/* 快照栈:每次改动前压一份, 供 /undo 和 undo_last_edit 回退 */ +/* 快照栈: 每次改动前压一份, 供 /undo 和 undo_last_edit 回退 */ #define META_UNDO_MAX 10 /* 单条消息在目录/明细里的预览长度 */ @@ -103,7 +103,7 @@ int meta_reroot_pending(void) { return g_edited_ever; } void meta_reroot_clear(void) { g_edited_ever = 0; } /* ------------------------------------------------------------------ */ -/* 历史视图:把消息按"轮"切开 */ +/* 历史视图: 把消息按"轮"切开 */ /* ------------------------------------------------------------------ */ static const char *role_of(const JsonValue *m) @@ -136,7 +136,7 @@ int meta_rounds_build(const JsonValue *messages, MetaRounds *out) size_t n = json_array_size(messages); if (n == 0) return 0; out->head = (strcmp(role_of(json_array_get(messages, 0)), "system") == 0) ? 1 : 0; - if (out->head >= n) return 0; /* 只有一条 system:没有可编辑的东西 */ + if (out->head >= n) return 0; /* 只有一条 system: 没有可编辑的东西 */ size_t cap = 8; out->starts = xmalloc(cap * sizeof(size_t)); @@ -145,7 +145,7 @@ int meta_rounds_build(const JsonValue *messages, MetaRounds *out) /* 轮起点必须用 agent_opens_round —— 和主循环 (/back、请求前的结构体检) * 同一个判据. 以前这里是"任何 role == user"都算新的一轮, 于是 read_image - * 补的那条"只有图片附件"的 user 消息会被当成一轮的开头:/meta 报出的轮号 + * 补的那条"只有图片附件"的 user 消息会被当成一轮的开头: /meta 报出的轮号 * 和主循环的口径对不上, 模型照着轮号删下去, 删掉的也不是它以为的那一轮. */ for (size_t i = out->head + 1; i < n; i++) { if (!agent_opens_round(json_array_get(messages, i))) continue; @@ -182,7 +182,7 @@ static void snap_push(MetaCtx *c) if (c->snap_n < META_UNDO_MAX) { c->snap[c->snap_n] = copy; } else { - /* 满了:丢掉最老的一份, 整体前移 */ + /* 满了: 丢掉最老的一份, 整体前移 */ json_free(c->snap[0]); memmove(c->snap, c->snap + 1, (META_UNDO_MAX - 1) * sizeof(JsonValue *)); c->snap[META_UNDO_MAX - 1] = copy; @@ -280,7 +280,7 @@ static int arg_range(const JsonValue *args, long lo, long hi, return *from <= *to; } -/* 改动之后的统一收尾:打标记、清掉和旧历史绑定的状态 */ +/* 改动之后的统一收尾: 打标记、清掉和旧历史绑定的状态 */ static void after_edit(MetaCtx *c) { c->edited = 1; @@ -306,7 +306,7 @@ static char *m_list_rounds(const JsonValue *args, int *is_error) if (!arg_range(args, 1, (long)r.count, &from, &to)) { meta_rounds_free(&r); *is_error = 1; - return xstrdup("from/to 不合法:轮次编号从 1 开始. "); + return xstrdup("from/to 不合法: 轮次编号从 1 开始. "); } StrBuf sb; @@ -343,7 +343,7 @@ static char *m_read_messages(const JsonValue *args, int *is_error) long from, to; if (!arg_range(args, 0, (long)total - 1, &from, &to)) { *is_error = 1; - return xstrdup("from/to 不合法:消息编号是 #N, 从 0 开始. "); + return xstrdup("from/to 不合法: 消息编号是 #N, 从 0 开始. "); } StrBuf sb; @@ -378,7 +378,7 @@ static char *m_read_messages(const JsonValue *args, int *is_error) free(l); } } - /* 输出上限和普通工具一致, 免得一次读太多把 meta 自己的上下文撑爆 */ + /* 输出上限和普通工具一致, 以免一次读太多把 meta 自己的上下文撑爆 */ if (sb.len > LIGENT_TOOL_OUTPUT_LIMIT) { sb.len = LIGENT_TOOL_OUTPUT_LIMIT; sb.data[sb.len] = '\0'; @@ -419,13 +419,13 @@ static char *m_replace_message(const JsonValue *args, int *is_error) return xstrdup("这是 tool 结果, 改它会让模型以为工具返回了别的东西. " "如果内容有问题, 应该改的是产生它的那次调用. "); } - /* 结构本身不动:只换 content, tool_calls 原样保留 */ + /* 结构本身不动: 只换 content, tool_calls 原样保留 */ const char *old = json_get_string(m, "content"); size_t oldb = old ? strlen(old) : 0; snap_push(c); json_object_set_string(m, "content", content); after_edit(c); - return xasprintf("已改写 #%ld (%s):%zu -> %zu 字节. ", i, role, oldb, strlen(content)); + return xasprintf("已改写 #%ld (%s): %zu -> %zu 字节. ", i, role, oldb, strlen(content)); } static char *m_delete_messages(const JsonValue *args, int *is_error) @@ -440,7 +440,7 @@ static char *m_delete_messages(const JsonValue *args, int *is_error) return xstrdup("缺少 indices (消息编号数组, 例如 [3,5]). "); } - /* 先整体校验再动手:宁可一条不改, 也不要改一半 */ + /* 先整体校验再动手: 宁可一条不改, 也不要改一半 */ size_t n = json_array_size(arr); size_t *idx = xmalloc(n * sizeof(size_t)); for (size_t k = 0; k < n; k++) { @@ -468,7 +468,7 @@ static char *m_delete_messages(const JsonValue *args, int *is_error) idx[k] = (size_t)i; } - /* 去重, 并从大到小删, 免得删一个前面就错位 */ + /* 去重, 并从大到小删, 以免删一个前面就错位 */ for (size_t a = 0; a < n; a++) for (size_t b = a + 1; b < n; b++) if (idx[b] > idx[a]) { size_t t = idx[a]; idx[a] = idx[b]; idx[b] = t; } @@ -489,8 +489,8 @@ static char *m_delete_messages(const JsonValue *args, int *is_error) after_edit(c); size_t after = history_bytes(msgs); - return xasprintf("已删除 %zu 条消息:%zu -> %zu 字节 (-%zu). \n" - "注意:如果删掉的是某轮里的提问, 那轮剩下的回答现在没有" + return xasprintf("已删除 %zu 条消息: %zu -> %zu 字节 (-%zu). \n" + "注意: 如果删掉的是某轮里的提问, 那轮剩下的回答现在没有" "对应的提问了, 必要时把整轮一起删掉. ", uniq, before, after, before - after); } @@ -509,7 +509,7 @@ static char *m_delete_rounds(const JsonValue *args, int *is_error) if (!arg_range(args, 1, (long)r.count, &from, &to)) { meta_rounds_free(&r); *is_error = 1; - return xstrdup("from/to 不合法:轮次编号从 1 开始. "); + return xstrdup("from/to 不合法: 轮次编号从 1 开始. "); } if (from == 1 && to == (long)r.count) { meta_rounds_free(&r); @@ -531,7 +531,7 @@ static char *m_delete_rounds(const JsonValue *args, int *is_error) after_edit(c); size_t after = history_bytes(msgs); - return xasprintf("已删除轮 %ld..%ld (%zu 条消息):%zu -> %zu 字节 (-%zu). ", + return xasprintf("已删除轮 %ld..%ld (%zu 条消息): %zu -> %zu 字节 (-%zu). ", from, to, cnt, before, after, before - after); } @@ -594,7 +594,7 @@ typedef struct { static const MetaTool TOOLS[] = { { "list_rounds", - "看这份对话的目录:每轮一行 (轮号、消息编号范围、条数、字节数、开头预览). " + "看这份对话的目录: 每轮一行 (轮号、消息编号范围、条数、字节数、开头预览). " "动手前先看它. ", "{\"type\":\"object\",\"properties\":{" "\"from\":{\"type\":\"number\",\"description\":\"起始轮号 (从 1 开始), 默认 1\"}," @@ -680,7 +680,7 @@ char *meta_tools_call(const char *name, const JsonValue *args, int *is_error) for (size_t i = 0; i < TOOL_COUNT; i++) if (strcmp(TOOLS[i].name, name) == 0) return TOOLS[i].fn(args, is_error); *is_error = 1; - return xasprintf("未知工具:%s. 可用的只有列表里那几个. ", name); + return xasprintf("未知工具: %s. 可用的只有列表里那几个. ", name); } int meta_tools_mutating(const char *name) @@ -690,7 +690,7 @@ int meta_tools_mutating(const char *name) return 0; } -/* 让确认提示说人话:meta 工具的参数字段名和普通工具完全不同, +/* 让确认提示说人话: meta 工具的参数字段名和普通工具完全不同, * 内置描述要么打不出来、要么会把整段消息内容刷屏. */ static char *meta_describe(const char *name, const JsonValue *args) { @@ -746,7 +746,7 @@ static void meta_help(void) fputs(t("meta.help"), stdout); } -/* /diff:把当前历史按消息列出来 (只读, 不走模型) */ +/* /diff: 把当前历史按消息列出来 (只读, 不走模型) */ static void meta_diff(Agent *target) { size_t total = json_array_size(target->messages); @@ -776,7 +776,7 @@ int meta_session(Agent *target, LigentConfig *cfg) Agent m; agent_init(&m); - m.llm = target->llm; /* 有意借用:同一个后端、同一个模型 */ + m.llm = target->llm; /* 有意借用: 同一个后端、同一个模型 */ m.llm.stream = target->llm.stream; m.color = target->color; m.show_tool_output = 1; @@ -791,10 +791,10 @@ int meta_session(Agent *target, LigentConfig *cfg) agent_add_system(&m, sp); free(sp); - /* 确认提示改说人话:meta 工具的参数字段和普通工具完全不同, + /* 确认提示改说人话: meta 工具的参数字段和普通工具完全不同, * 内置描述在那儿要么打不出来、要么会把整段消息内容刷屏. */ confirm_set_describer(meta_describe); - line_edit_set_ink("\033[35m"); /* 品红:和主提示符 (绿)分开 */ + line_edit_set_ink("\033[35m"); /* 品红: 和主提示符 (绿)分开 */ puts(t("meta.enter")); puts(t("meta.enter.hint")); diff --git a/src/meta.h b/src/meta.h index 2663939..0f800dd 100644 --- a/src/meta.h +++ b/src/meta.h @@ -1,9 +1,9 @@ -/* meta.h - /meta:用模型辅助编辑对话历史 +/* meta.h - /meta: 用模型辅助编辑对话历史 * - * 和主对话的区别:主模型是来"干活"的, meta 模型是来"改历史"的. + * 和主对话的区别: 主模型是来"干活"的, meta 模型是来"改历史"的. * 它不是那场对话的参与者, 而是拿用户指令去删改那段记录的编辑者. * - * 两边工具集完全隔离:meta 会话的 Agent 用的是 meta_tools_schema() 和 + * 两边工具集完全隔离: meta 会话的 Agent 用的是 meta_tools_schema() 和 * meta_exec_tool(), 主模型那边是 tools.c 那套. 主模型就算瞎编一个 * delete_rounds 也只会拿到"未知工具". */ @@ -23,10 +23,10 @@ void meta_reroot_clear(void); * 清掉 /back 撤销栈、让退出时必定重新存档). */ int meta_session(Agent *target, LigentConfig *cfg); -/* 供单测用:把一段历史按轮切开. 返回 0 表示"没有可编辑的轮次" +/* 供单测用: 把一段历史按轮切开. 返回 0 表示"没有可编辑的轮次" * (空历史, 或只有一条 system). * - * 轮的定义与主循环**共用同一个判据** (agent_opens_round):一轮从一条带文字的 + * 轮的定义与主循环**共用同一个判据** (agent_opens_round): 一轮从一条带文字的 * user 消息开始, 到下一条这样的消息之前; read_image 补的那条"只有图片附件"的 * user 消息不算新的一轮. head 是第一条可编辑消息的下标 (跳过 system). */ typedef struct { @@ -38,7 +38,7 @@ typedef struct { int meta_rounds_build(const JsonValue *messages, MetaRounds *out); void meta_rounds_free(MetaRounds *r); -/* 供单测用:这条消息能不能被单独删掉 (false = 结构消息, 牵连 tool_calls 配对) */ +/* 供单测用: 这条消息能不能被单独删掉 (false = 结构消息, 牵连 tool_calls 配对) */ int meta_deletable(const JsonValue *messages, size_t index); /* ---- 工具分发 ---- diff --git a/src/models.c b/src/models.c index 96a6e93..eab033e 100644 --- a/src/models.c +++ b/src/models.c @@ -56,7 +56,7 @@ char **models_parse(const char *json, size_t *out_n) json_free(root); if (n == 0) { free(list); return NULL; } - /* 保序:服务端给的顺序是它自己的偏好, 我们不去替它排序 */ + /* 保序: 服务端给的顺序是它自己的偏好, 我们不去替它排序 */ if (out_n) *out_n = n; return list; } @@ -122,7 +122,7 @@ char **models_fetch(const LigentConfig *cfg, size_t *out_n, tf("models.err.empty_list", "status", i18n_num(status), NULL)); else if (body && *body) { - /* 只回显开头一段:服务端偶尔回一大坨 HTML, 全塞进错误里没法读 */ + /* 只回显开头一段: 服务端偶尔回一大坨 HTML, 全塞进错误里没法读 */ char head[121]; snprintf(head, sizeof head, "%.120s", body); snprintf(err, errlen, "%s", diff --git a/src/models.h b/src/models.h index 3a30901..80749c3 100644 --- a/src/models.h +++ b/src/models.h @@ -1,6 +1,6 @@ /* models.h - 拉取并解析服务端的模型列表 (GET {base_url}/models) * - * 只为了 /model 不带参数时的那个交互式选择列表. OpenAI 兼容的响应体长这样: + * 只为了 /model 不带参数时的那个交互式选择列表. OpenAI 兼容的响应体长这样: * * {"object": "list", "data": [{"id": "deepseek-chat", ...}, ...]} * @@ -17,7 +17,7 @@ /* 从 /models 的响应体里取模型名. 保序、去重, 返回 malloc 的字符串数组 * (元素也是 malloc), *out_n 回填个数. 解析不出任何模型名时返回 NULL. * - * 兼容两种形状:{"data":[...]} 和裸数组 [...]. 纯函数, 可单测. */ + * 兼容两种形状: {"data":[...]} 和裸数组 [...]. 纯函数, 可单测. */ char **models_parse(const char *json, size_t *out_n); /* 响应体是不是"格式没问题、但里面一个模型都没有"? @@ -25,7 +25,7 @@ char **models_parse(const char *json, size_t *out_n); * 这两种情况的下一步动作完全不同, 含糊地说成"拿不到列表"就是误导. */ int models_is_empty_list(const char *json); -/* 打一次网络:GET {base_url}/models, 解析出模型名. +/* 打一次网络: GET {base_url}/models, 解析出模型名. * 成功返回数组 (调用方用 models_free 释放), 失败返回 NULL 并把原因写进 err. * verbose 会打印 HTTP 细节. */ char **models_fetch(const LigentConfig *cfg, size_t *out_n, diff --git a/src/priv.c b/src/priv.c index d9d07d5..d5a4a72 100644 --- a/src/priv.c +++ b/src/priv.c @@ -1,4 +1,4 @@ -/* priv.c - 提权:经 sudo 执行单条命令 */ +/* priv.c - 提权: 经 sudo 执行单条命令 */ #include "priv.h" #include "util.h" @@ -23,8 +23,8 @@ char **priv_sudo_argv(const char *user, char *const argv[]) while (argv[n]) n++; const char *u = (user && *user) ? user : "root"; - /* 刻意不带 -n:需要密码时就让 sudo 自己问. - * 也不带 -S:那要求把密码从 stdin 喂进去, 等于让 ligent 经手密码. */ + /* 刻意不带 -n: 需要密码时就让 sudo 自己问. + * 也不带 -S: 那要求把密码从 stdin 喂进去, 等于让 ligent 经手密码. */ /* execvp 的签名要 char *const[], 但这里放进去的都是字面量和调用方的 * 指针, **没有任何人会去改写它们** (execvp 本身也不会). 所以这里显式 * 转换, 而不是把整条链路都改成非 const —— 那会把 const 正确性一路丢掉. */ diff --git a/src/priv.h b/src/priv.h index 8ca8bb9..96c8770 100644 --- a/src/priv.h +++ b/src/priv.h @@ -1,6 +1,6 @@ -/* priv.h - 提权:经 sudo 执行单条命令 +/* priv.h - 提权: 经 sudo 执行单条命令 * - * 先说清楚这个模块**不做**什么, 以及为什么: + * 先说清楚这个模块**不做**什么, 以及为什么: * * PAM 认证不等于获得权限. su 之所以能变 root, 是因为 /usr/bin/su 这个文件 * 本身是 setuid root —— 内核先把 root 给它, 它再验密码、验不过就降权. 普通 @@ -9,16 +9,16 @@ * "让模型执行任意命令", setuid root 的它等于把 root 远程执行做成一等功能. * 所以不走那条路. * - * 也不做"授权提示". **密码本身就是授权**:模型敲不出来, 你也不可能手滑敲出来. + * 也不做"授权提示". **密码本身就是授权**: 模型敲不出来, 你也不可能手滑敲出来. * 再加一道 y/N 只会是多余的摩擦, 还会给出一个虚假的安全模型 —— 好像把关的是 - * ligent 的提示, 其实真正把关的是 sudo. 所以这里只做两件事: + * ligent 的提示, 其实真正把关的是 sudo. 所以这里只做两件事: * * 1. 把命令包成 `sudo -u <用户> -- ` 交出去 (**不经 shell**, * 所以路径/命令里的引号、换行、`;` 注入不进 root 那条命令里); * 2. 需要密码时, **让终端留在 sudo 手上**, 由它自己问、自己存 —— ligent * 不读、不存、不转发密码. * - * 唯一由 ligent 把守的是"别卡死":没有控制终端就**根本不去试**, 直接说清楚. + * 唯一由 ligent 把守的是"别卡死": 没有控制终端就**根本不去试**, 直接说清楚. * 否则就会起一个在后台等一个没人能看见的密码提示的进程. */ #ifndef LIGENT_PRIV_H @@ -26,12 +26,12 @@ #include -/* 有可用的控制终端吗?判据是能不能打开 /dev/tty, **不是** isatty(0): +/* 有可用的控制终端吗?判据是能不能打开 /dev/tty, **不是** isatty(0): * `echo x | ligent ...` 时 stdin 是管道, 但终端还在, sudo 照样问得出来. */ int priv_have_tty(void); /* 把 argv 包成 `sudo -u -- <原 argv>` (user 为空则 "root"). - * 故意**不带 -n**:需要密码时就让 sudo 自己去问. + * 故意**不带 -n**: 需要密码时就让 sudo 自己去问. * 返回 malloc 的数组, 元素是借用的指针 —— 调用方 free 数组本身即可. */ char **priv_sudo_argv(const char *user, char *const argv[]); diff --git a/src/shell.c b/src/shell.c index 3e267ae..c10b11f 100644 --- a/src/shell.c +++ b/src/shell.c @@ -18,7 +18,7 @@ /* bash → ash → dash → sh. * - * 刻意不看 $SHELL:用户环境里的 $SHELL 可能是 xonsh 之类交互式外壳, + * 刻意不看 $SHELL: 用户环境里的 $SHELL 可能是 xonsh 之类交互式外壳, * 在容器/受限环境里经常起不来, 反而把 /shell 弄坏. 返回的路径一定存在. */ static const char *pick_shell(void) { @@ -30,11 +30,11 @@ static const char *pick_shell(void) return "/bin/sh"; } -/* 把终端输出整理成"能读的记录": +/* 把终端输出整理成"能读的记录": * 去掉 ANSI 转义、把 CRLF 归一成 LF、丢掉退格与其它控制字符, 并处理全屏程序. * 不清理的话模型看到的是一堆 ^[[32m 之类的噪音. * - * 状态必须跨 read() 保存, 原因有三个: + * 状态必须跨 read() 保存, 原因有三个: * - 转义序列可能正好被 read() 从中间切断, 分两次解析会串味; * - 全屏程序的备用屏幕进入/退出天然跨越多次 read; * - 连续空格要和后面的字符一起看才压得准. */ @@ -179,7 +179,7 @@ char *shell_interactive(int *out_code, char *errbuf, size_t errlen) ioctl(master, TIOCSWINSZ, &ws); /* 全屏程序 (nano/vim/less/top)靠"备用屏幕"切换来还原退出前的画面, - * 但它们只在 TERM 声称支持时才这么做:TERM 为空或 dumb 时 nano 会退化成 + * 但它们只在 TERM 声称支持时才这么做: TERM 为空或 dumb 时 nano 会退化成 * 逐行重绘, 几百个空格把记录淹掉, 也没法整段忽略. * * 而 ligent 自己的 REPL 就在用颜色、光标定位和括号粘贴 —— 这个终端至少是 @@ -200,7 +200,7 @@ char *shell_interactive(int *out_code, char *errbuf, size_t errlen) } pid_t pid = fork(); - /* 子进程已经继承了改过的 TERM, 父进程立刻还原, 免得影响 ligent 自己. + /* 子进程已经继承了改过的 TERM, 父进程立刻还原, 以免影响 ligent 自己. * 必须显式判断 pid != 0 —— 子进程也会从 fork() 返回并往下走. */ if (pid != 0 && term_restore) { if (term_was_set) setenv("TERM", term_saved, 1); @@ -228,7 +228,7 @@ char *shell_interactive(int *out_code, char *errbuf, size_t errlen) _exit(127); } - /* ---- 父进程:把终端切到原始模式, 双向搬运 ---- */ + /* ---- 父进程: 把终端切到原始模式, 双向搬运 ---- */ struct termios saved; int raw = (tty_raw(&saved) == 0); @@ -269,7 +269,7 @@ char *shell_interactive(int *out_code, char *errbuf, size_t errlen) fflush(stdout); append_clean(&clean, &rec, buf, (size_t)r); } else if (r == 0 || (r < 0 && errno == EIO)) { - eof = 1; /* 从设备关闭:Linux 上会得到 EIO */ + eof = 1; /* 从设备关闭: Linux 上会得到 EIO */ } else if (r < 0 && errno != EAGAIN && errno != EINTR) { eof = 1; } @@ -295,9 +295,9 @@ char *shell_interactive(int *out_code, char *errbuf, size_t errlen) else if (WIFSIGNALED(status)) code = 128 + WTERMSIG(status); if (out_code) *out_code = code; - clean_flush_spaces(&clean, &rec); /* 收尾:还压在手里的空格 */ + clean_flush_spaces(&clean, &rec); /* 收尾: 还压在手里的空格 */ /* 全屏程序被 Ctrl-C/SIGKILL 干掉时会来不及发退出序列, 补一个收尾标记, - * 免得记录里留下一个没有配对的"开始". */ + * 以免记录里留下一个没有配对的"开始". */ if (clean.in_alt && rec.len < LIGENT_SHELL_MAX) sb_puts(&rec, "[全屏程序结束]\n"); return sb_detach(&rec); diff --git a/src/shell.h b/src/shell.h index 5762973..9621d92 100644 --- a/src/shell.h +++ b/src/shell.h @@ -6,7 +6,7 @@ /* 起一个交互式 shell, 用户在里面随便敲; 退出时把整段会话录制下来. * - * 用伪终端 (pty)做双向搬运:子进程以为自己接在真终端上, 所以行编辑、 + * 用伪终端 (pty)做双向搬运: 子进程以为自己接在真终端上, 所以行编辑、 * 颜色、Ctrl-C、作业控制都照常工作; 我们在外面旁听并把输出记下来. * * out_code 回填子进程退出码. diff --git a/src/spinner.c b/src/spinner.c index 55b8e66..f62a7c4 100644 --- a/src/spinner.c +++ b/src/spinner.c @@ -20,7 +20,7 @@ static volatile int g_running; static int g_started; static int g_enabled; static char g_label[64]; -/* 当前这段等待的起点. 换标签时重置, 于是秒数反映的是"这个阶段等了多久": +/* 当前这段等待的起点. 换标签时重置, 于是秒数反映的是"这个阶段等了多久": * 先看到"等待响应 (1s)", 切成"思考中"后重新从 0 数起. */ static struct timespec g_t0; @@ -63,7 +63,7 @@ static void *worker(void *arg) fflush(stderr); i = (i + 1) % 4; - /* 睡到下一帧. 这里必须用条件变量定时等, 不能 nanosleep: + /* 睡到下一帧. 这里必须用条件变量定时等, 不能 nanosleep: * spinner_stop() 要 pthread_join, nanosleep 的话就得干等这一觉 * 睡满 —— 每次收尾白搭最多 250ms, 而这笔账直接加在用户感知的 * 延迟上 (每轮模型往返、每个工具来回都要付一次). */ @@ -99,10 +99,10 @@ void spinner_start(const char *label) pthread_once(&g_cond_once, init_cond); /* 保证 g_cond 用的是单调时钟 */ pthread_mutex_lock(&g_lock); if (!g_started) { - /* 先把标签算成**一份副本**再写进 g_label:label 有可能就是 g_label 自己 + /* 先把标签算成**一份副本**再写进 g_label: label 有可能就是 g_label 自己 * —— spinner_notice() 从一条重试提示里恢复动画时正是这么调的. 以前这里 * 先执行 g_label[0] = '\0', 于是要读的那个字符串被自己清空, 标签退化成 - * 默认的"思考中":每次重试退避之后, 屏幕上的"等待响应"都会变成"思考中", + * 默认的"思考中": 每次重试退避之后, 屏幕上的"等待响应"都会变成"思考中", * 而这两段等待的含义恰恰是它要区分的. */ char want[sizeof g_label]; snprintf(want, sizeof want, "%s", diff --git a/src/spinner.h b/src/spinner.h index c20e295..9c87897 100644 --- a/src/spinner.h +++ b/src/spinner.h @@ -1,9 +1,9 @@ /* spinner.h - 等待指示器 * - * 存在的意义:模型不输出思考过程是好事, 但等待期间屏幕上什么都没有, + * 存在的意义: 模型不输出思考过程是好事, 但等待期间屏幕上什么都没有, * 和"卡死了"看起来一模一样. 一个灰色的动画能把这个歧义消掉. * - * 分两段显示, 因为这两种等待的性质不一样: + * 分两段显示, 因为这两种等待的性质不一样: * 刚发出请求、还没收到任何东西 → "等待响应" (卡在网络或排队) * 开始收到思维链 → "思考中" (模型确实在推理) * 一收到正文就停 —— 那时已经有东西可看了. @@ -30,7 +30,7 @@ void spinner_set_label(const char *label); * 流式输出开始前必须先调用它, 否则内容会和动画抢同一行. */ void spinner_stop(void); -/* 在动画中间插一条提示 (例如"稍后重试"):先停并清行, 打印, 再继续转. +/* 在动画中间插一条提示 (例如"稍后重试"): 先停并清行, 打印, 再继续转. * 未在转时就是普通打印. */ void spinner_notice(const char *fmt, ...) LIGENT_PRINTF(1, 2); diff --git a/src/tools.c b/src/tools.c index 8032110..4453f44 100644 --- a/src/tools.c +++ b/src/tools.c @@ -1,6 +1,6 @@ -/* tools.c - 工具实现:shell、文件读写、编辑、列目录、搜索 +/* tools.c - 工具实现: shell、文件读写、编辑、列目录、搜索 * - * 设计取向:这是"全权执行"模式, 不做沙箱限制; 但仍对输出做截断, + * 设计取向: 这是"全权执行"模式, 不做沙箱限制; 但仍对输出做截断, * 避免一次命令把上下文撑爆. */ #include "tools.h" @@ -64,7 +64,7 @@ LIGENT_PRINTF(1, 2) static char *err_result(const char *fmt, ...) va_start(ap, fmt); char *body = xvasprintf(fmt, ap); va_end(ap); - char *out = xasprintf("错误:%s", body); + char *out = xasprintf("错误: %s", body); free(body); return out; } @@ -90,7 +90,7 @@ static int file_is_binary(const char *path, long *at) return looks_binary(probe, n, at); } -/* 统一的拒绝说法:先摆事实 (第几字节有 NUL), 再说这个工具为什么只收文本、 +/* 统一的拒绝说法: 先摆事实 (第几字节有 NUL), 再说这个工具为什么只收文本、 * 以及该改用什么. 只说"不行"等于让模型原地打转. */ static char *binary_err(const char *path, long at, const char *why) { @@ -111,41 +111,41 @@ static char *tool_run_command(const JsonValue *args, int *is_error) const char *cmd = arg_str(args, "command"); if (!cmd || !*cmd) { *is_error = 1; - return xstrdup("错误:缺少必填参数 command"); + return xstrdup("错误: 缺少必填参数 command"); } double t = json_get_number(args, "timeout", CMD_DEFAULT_TIMEOUT); if (t <= 0) t = CMD_DEFAULT_TIMEOUT; if (t > CMD_MAX_TIMEOUT) t = CMD_MAX_TIMEOUT; int64_t timeout_ms = (int64_t)(t * 1000); - /* elevated:以 root 执行这一条命令. + /* elevated: 以 root 执行这一条命令. * - * 不做额外的"授权提示" —— 密码本身就是授权:模型敲不出来, 你也不可能 + * 不做额外的"授权提示" —— 密码本身就是授权: 模型敲不出来, 你也不可能 * 手滑敲出来. 确认提示 (run_command 本来就有的)负责让你**看清要跑什么**, * sudo 负责**不可伪造的授权**, 各司其职. - * 没有控制终端就根本不去试:那会起一个等不到输入的进程. */ + * 没有控制终端就根本不去试: 那会起一个等不到输入的进程. */ int elevated = json_get_bool(args, "elevated", 0); if (elevated && !priv_have_tty()) { *is_error = 1; return xstrdup( - "错误:这条命令需要提权 (elevated), 但当前没有可用的控制终端" + "错误: 这条命令需要提权 (elevated), 但当前没有可用的控制终端" " (/dev/tty), sudo 没法问密码. 请在终端里运行 ligent, " "或者换一个不需要提权的做法. "); } int fds[2]; if (pipe(fds) != 0) - { *is_error = 1; return err_result("pipe 失败:%s", strerror(errno)); } + { *is_error = 1; return err_result("pipe 失败: %s", strerror(errno)); } pid_t pid = fork(); if (pid < 0) { close(fds[0]); close(fds[1]); *is_error = 1; - return err_result("fork 失败:%s", strerror(errno)); + return err_result("fork 失败: %s", strerror(errno)); } if (pid == 0) { - /* 非提权:独立进程组, 超时时可以整组杀掉. - * 提权:**不 setsid** —— sudo 是打开 /dev/tty 问密码的, 摘掉控制终端 + /* 非提权: 独立进程组, 超时时可以整组杀掉. + * 提权: **不 setsid** —— sudo 是打开 /dev/tty 问密码的, 摘掉控制终端 * 它就永远问不出来 (这正是上一版认证必然失败的成因). * 代价是提权这条没法整组杀, 超时只杀直接子进程. */ if (!elevated) setsid(); @@ -155,9 +155,9 @@ static char *tool_run_command(const JsonValue *args, int *is_error) dup2(fds[1], STDERR_FILENO); close(fds[0]); close(fds[1]); - /* argv 形式执行:提权时是 `sudo -u root -- /bin/sh -c <命令>`, + /* argv 形式执行: 提权时是 `sudo -u root -- /bin/sh -c <命令>`, * 命令内容不会被多解析一层. */ - /* 同上:execvp 要 char*[], 这里放进去的都不会被改写 */ + /* 同上: execvp 要 char*[], 这里放进去的都不会被改写 */ char *plain[] = { (char *)"/bin/sh", (char *)"-c", (char *)cmd, NULL }; char **w = elevated ? priv_sudo_argv(NULL, plain) : plain; execvp(w[0], w); @@ -232,7 +232,7 @@ static char *tool_run_command(const JsonValue *args, int *is_error) if (elevated && exit_code != 0) { char why[512] = ""; priv_explain(exit_code, out.data, why, sizeof why); - sb_printf(&result, "\n[提权命令失败:%s]", why); + sb_printf(&result, "\n[提权命令失败: %s]", why); if (!out.data || !*out.data) sb_puts(&result, "\n (没有捕获到输出. sudo 的提示是直接写到终端上的, " @@ -268,7 +268,7 @@ static char *tool_read_file(const JsonValue *args, int *is_error) { REQUIRE_ERR_PTR(is_error); const char *path = arg_str(args, "path"); - if (!path || !*path) { *is_error = 1; return xstrdup("错误:缺少必填参数 path"); } + if (!path || !*path) { *is_error = 1; return xstrdup("错误: 缺少必填参数 path"); } double off = json_get_number(args, "offset", 1); double lim = json_get_number(args, "limit", 2000); @@ -277,7 +277,7 @@ static char *tool_read_file(const JsonValue *args, int *is_error) if (limit > 20000) limit = 20000; FILE *f = fopen(path, "rb"); - if (!f) { *is_error = 1; return err_result("无法打开 %s:%s", path, strerror(errno)); } + if (!f) { *is_error = 1; return err_result("无法打开 %s: %s", path, strerror(errno)); } /* 二进制文件按文本读出来是乱码, 而且会在 NUL 处被截断 —— 直接拒绝. * 文件已经打开了, 就地探一遍前 8 KB (窗口与判据见 util.h), 不必再开一次. */ @@ -301,12 +301,12 @@ static char *tool_read_file(const JsonValue *args, int *is_error) * "read_file 打出来的行号可以直接喂给 edit_file 的 line". * * 以前这里是 fgets + 固定 64 KB 缓冲, 于是"一行比缓冲长"时它会被切成好几块、 - * 每块各带一个行号:一个「70000 字节的单行 + 两行」的文件, read_file 说第 3 行 + * 每块各带一个行号: 一个「70000 字节的单行 + 两行」的文件, read_file 说第 3 行 * 是 "second", 而 edit_file 认为第 3 行是 "third". 模型照 read_file 的行号去 * 改, 要么改错地方、要么报越界 —— 而它没有任何办法察觉. * * 单行的字节数靠"上一块是不是以换行结尾" (in_line)来跟踪, 所以仍然**不** - * 需要把整个文件读进内存:一行再长也只占 line[] 这么多, 超出输出上限就如实 + * 需要把整个文件读进内存: 一行再长也只占 line[] 这么多, 超出输出上限就如实 * 说明这一行读不出来, 而不是把它伪装成若干行. */ StrBuf sb; sb_init(&sb); @@ -344,7 +344,7 @@ static char *tool_read_file(const JsonValue *args, int *is_error) if (sb.len + n > LIGENT_TOOL_OUTPUT_LIMIT) { /* 撞的是**字节**上限, 而且撞在"正在写的这一行"上 (前面那些行都已经 - * 完整给出). 分两种:这一行连一个字节都没进去 (前缀已写、内容为 0), + * 完整给出). 分两种: 这一行连一个字节都没进去 (前缀已写、内容为 0), * 还是只给了它开头. 两种情况都要如实说, 不能让调用方以为拿到的是 * 一份完整的行 —— 也不能把剩下的部分伪装成"下一行". */ cut_by_bytes = 1; @@ -381,7 +381,7 @@ static char *tool_read_file(const JsonValue *args, int *is_error) } if (truncated) { if (cut_by_bytes && partial_line > 0) - /* 撞的是字节上限:给出的是**半行**, 所以不能再说"从下一行继续" */ + /* 撞的是字节上限: 给出的是**半行**, 所以不能再说"从下一行继续" */ sb_printf(&sb, "... [第 %ld 行本身就超过 %u 字节的输出上限, 这里只给了它的开头; " "要看后面的部分, 用 run_command (例如 sed -n '%ldp' <文件> | cut -c1-4000)]", @@ -401,10 +401,10 @@ static char *tool_write_file(const JsonValue *args, int *is_error) REQUIRE_ERR_PTR(is_error); const char *path = arg_str(args, "path"); JsonValue *cv = json_object_get(args, "content"); - if (!path || !*path) { *is_error = 1; return xstrdup("错误:缺少必填参数 path"); } + if (!path || !*path) { *is_error = 1; return xstrdup("错误: 缺少必填参数 path"); } if (!cv || cv->type != JSON_STRING) { *is_error = 1; - return xstrdup("错误:缺少必填参数 content (必须是字符串)"); + return xstrdup("错误: 缺少必填参数 content (必须是字符串)"); } const char *content = cv->u.string; @@ -417,7 +417,7 @@ static char *tool_write_file(const JsonValue *args, int *is_error) } free(dir); - /* 别拿文本去整体覆盖一个二进制文件:那等于把原内容毁掉, 而模型多半没 + /* 别拿文本去整体覆盖一个二进制文件: 那等于把原内容毁掉, 而模型多半没 * 意识到 (它只是想"写个文件"). 要真的换掉它, 先删或用 run_command. */ { long at = 0; @@ -429,7 +429,7 @@ static char *tool_write_file(const JsonValue *args, int *is_error) } } - /* 落盘前再看一眼中断标志. 确认环节已经会拦下 Ctrl-C, 这里是纵深防御: + /* 落盘前再看一眼中断标志. 确认环节已经会拦下 Ctrl-C, 这里是纵深防御: * "任何改盘的动作之前都检查中断"应当是一条无例外的约定, * 而不是依赖"恰好上一层拦住了". */ if (g_ligent_interrupt) { @@ -438,14 +438,14 @@ static char *tool_write_file(const JsonValue *args, int *is_error) } FILE *f = fopen(path, "wb"); - if (!f) { *is_error = 1; return err_result("无法写入 %s:%s", path, strerror(errno)); } + if (!f) { *is_error = 1; return err_result("无法写入 %s: %s", path, strerror(errno)); } size_t n = strlen(content); size_t written = fwrite(content, 1, n, f); if (fclose(f) != 0 && written == n) written = (size_t)-1; if (written != n) { *is_error = 1; - return err_result("写入 %s 失败:%s", path, strerror(errno)); + return err_result("写入 %s 失败: %s", path, strerror(errno)); } return xasprintf("已写入 %zu 字节到 %s", n, path); } @@ -454,35 +454,35 @@ static char *tool_write_file(const JsonValue *args, int *is_error) /* edit_file */ /* ------------------------------------------------------------------ */ /* - * 替换受**行号集合**制约:old_string 的**起点**必须落在 line 圈住的行里, 且在 + * 替换受**行号集合**制约: old_string 的**起点**必须落在 line 圈住的行里, 且在 * 这些行里只有一处匹配. * - * line 是**集合型, 而且可以整个省略**:省略 = 不设行范围, 等于**全文件替换所有 + * line 是**集合型, 而且可以整个省略**: 省略 = 不设行范围, 等于**全文件替换所有 * 匹配** (就是编辑器里那个"全局替换"). 给了它就是集合型 —— 单个整数 (等价于 * 只有一个元素的集合)或整数数组都收, `"line": 12` 和 `"line": [12]` 是同一件事. * 集合里的行号就是 read_file 打出来的那些 (1 起). * - * 为什么是集合而不是单个行号:模型看到的常是一整块内容 ("第 10~13 行是这段 + * 为什么是集合而不是单个行号: 模型看到的常是一整块内容 ("第 10~13 行是这段 * 函数"), 但决定命中与否的是 old_string 的**起点**落在哪一行; 只给一个行号时 * 它得先数清第一行到底是 10 还是 11, 数错一次就白烧一轮. 给了集合之后, 照 * read_file 的显示把覆盖到的那几行都填进来就行. 集合只是**搜索范围**, 不是 - * "每行都改一遍":真改动的仍是唯一那一处 (replace_all 或省略 line 时才动全文件). + * "每行都改一遍": 真改动的仍是唯一那一处 (replace_all 或省略 line 时才动全文件). * - * 为什么不再只靠"全文唯一"来定位:行号是模型唯一的空间坐标 (read_file 给的), + * 为什么不再只靠"全文唯一"来定位: 行号是模型唯一的空间坐标 (read_file 给的), * 而"全文唯一"要求整个文件里恰好只有一处 —— 文件一长这个条件经常不成立 * (同一个 `return 0;` 到处都是), 模型只能不断加长上下文去凑唯一性; 凑不成 * 就只能放弃, 或者赌一把改错地方. 把行号钉住之后, "改哪一处"由坐标决定, * 不再是个概率问题; old_string 也不必再为"唯一"而写得很长. * - * 省略 line 是**故意留着**的逃生门:有些改动本来就该全文件生效 (比如统一改个 + * 省略 line 是**故意留着**的逃生门: 有些改动本来就该全文件生效 (比如统一改个 * 名字), 但不给行号就没法声称"我知道改的是哪一处" —— 回执里也会直说这是全文件 * 替换. 要"改一处"仍得给行号, 模型没法靠省略来回避看文件. * - * replace_all 仍在, 含义不变:改文件里**所有**匹配. 但 line 集合必须圈住其中 + * replace_all 仍在, 含义不变: 改文件里**所有**匹配. 但 line 集合必须圈住其中 * 真实的一处 —— 这等于强制模型先看过文件, 而不是凭印象做全文替换. */ -/* 文件按 read_file 的口径有多少行:末尾的换行不算新的一行. +/* 文件按 read_file 的口径有多少行: 末尾的换行不算新的一行. * (read_file 用 fgets 读, 末尾那个"空行"不会产生输出, 两边口径必须一致, * 否则 read_file 显示的行号和这里的越界判断会互相矛盾. ) */ static long count_lines(const char *s, size_t len) @@ -520,13 +520,13 @@ static int cmp_long(const void *a, const void *b) return x < y ? -1 : (x > y ? 1 : 0); } -/* 把 line 参数解析成升序去重后的行号数组:写进 out (容量 cap), 返回元素个数 +/* 把 line 参数解析成升序去重后的行号数组: 写进 out (容量 cap), 返回元素个数 * (>=1). 失败返回 -1, 并把给模型看的说法写进 *err (调用方 free). * - * 两种写法都收:整数 (历史写法)和整数数组 (集合). 所以老调用和老录制里的 + * 两种写法都收: 整数 (历史写法)和整数数组 (集合). 所以老调用和老录制里的 * `"line": 41` 仍然照常工作, 不必区分. * - * 去重不是洁癖:同一个行号处理两遍, 同一处匹配会被数成两处, + * 去重不是洁癖: 同一个行号处理两遍, 同一处匹配会被数成两处, * 于是"行内只有一处"的判断会莫名其妙地失败. */ static long parse_line_set(const JsonValue *lv, long *out, size_t cap, char **err) { @@ -535,13 +535,13 @@ static long parse_line_set(const JsonValue *lv, long *out, size_t cap, char **er size_t n = is_arr ? json_array_size(lv) : 1; if (is_arr && n == 0) { - *err = xstrdup("错误:line 是空数组 —— 要么至少给一个行号 (read_file " + *err = xstrdup("错误: line 是空数组 —— 要么至少给一个行号 (read_file " "打出来的那个, 从 1 开始), 要么把 line 整个省略" " (省略 = 全文件替换所有匹配). "); return -1; } if (n > cap) { - *err = xasprintf("错误:line 里有 %zu 个行号, 一次最多 %zu 个. " + *err = xasprintf("错误: line 里有 %zu 个行号, 一次最多 %zu 个. " "集合是用来圈住 old_string 覆盖的那几行的 —— " "要改的地方太多, 先 read_file 看清再分几次改. ", n, cap); return -1; @@ -549,16 +549,16 @@ static long parse_line_set(const JsonValue *lv, long *out, size_t cap, char **er for (size_t i = 0; i < n; i++) { const JsonValue *e = is_arr ? json_array_get(lv, i) : lv; - /* 先把范围夹住再判断是不是整数:JSON 里什么都可能来, 而把 1e300 + /* 先把范围夹住再判断是不是整数: JSON 里什么都可能来, 而把 1e300 * 直接转成 long 是 UB (UBsan 会当场报出来). */ double d = (e->type == JSON_NUMBER) ? e->u.number : 0.0; if (e->type != JSON_NUMBER || !(d >= 1.0) || d > 9.0e18 || d != (double)(long)d) { if (is_arr) - *err = xasprintf("错误:line 里的第 %zu 个不是合法的行号 —— " + *err = xasprintf("错误: line 里的第 %zu 个不是合法的行号 —— " "要的是 read_file 打出来的 >=1 的整数行号. ", i + 1); else - *err = xasprintf("错误:line=%g 不合法 —— 要的是 read_file " + *err = xasprintf("错误: line=%g 不合法 —— 要的是 read_file " "打出来的 >=1 的整数行号. ", d); return -1; } @@ -573,7 +573,7 @@ static long parse_line_set(const JsonValue *lv, long *out, size_t cap, char **er } /* 把行号集合渲染成 "3、4、5" (报错与回执里用, 给模型看). - * 超过 8 个就只列前 8 个:再往下只是噪声, 源文件的行号本来就在 read_file 里. */ + * 超过 8 个就只列前 8 个: 再往下只是噪声, 源文件的行号本来就在 read_file 里. */ static void lines_text(const long *v, long n, char *buf, size_t cap) { size_t off = 0; @@ -593,27 +593,27 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) const char *old_s = arg_str(args, "old_string"); JsonValue *nv = json_object_get(args, "new_string"); JsonValue *lv = json_object_get(args, "line"); - if (!path || !*path) { *is_error = 1; return xstrdup("错误:缺少必填参数 path"); } - if (!old_s) { *is_error = 1; return xstrdup("错误:缺少必填参数 old_string"); } + if (!path || !*path) { *is_error = 1; return xstrdup("错误: 缺少必填参数 path"); } + if (!old_s) { *is_error = 1; return xstrdup("错误: 缺少必填参数 old_string"); } if (!nv || nv->type != JSON_STRING) { *is_error = 1; - return xstrdup("错误:缺少必填参数 new_string (必须是字符串)"); + return xstrdup("错误: 缺少必填参数 new_string (必须是字符串)"); } - /* line 可以**整个省略**:省略 = 不设行范围 = 全文件替换所有匹配 ("全局替换"). + /* line 可以**整个省略**: 省略 = 不设行范围 = 全文件替换所有匹配 ("全局替换"). * 类型给错了 (字符串之类)仍然当场报错 —— 那是手滑, 不是"想全局". */ int global = (lv == NULL); if (!global && lv->type != JSON_NUMBER && lv->type != JSON_ARRAY) { *is_error = 1; - return xstrdup("错误:line 必须是整数或整数数组 (集合型)——" + return xstrdup("错误: line 必须是整数或整数数组 (集合型)——" "行号就是 read_file 打出来的那些, 从 1 开始. " "要全文件替换所有匹配, 就把 line 整个省略. "); } const char *new_s = nv->u.string; int replace_all = json_get_bool(args, "replace_all", 0); - if (!*old_s) { *is_error = 1; return xstrdup("错误:old_string 不能为空"); } + if (!*old_s) { *is_error = 1; return xstrdup("错误: old_string 不能为空"); } - /* line 是集合:解析成升序去重的行号数组. 上界 (超过文件总行数)要读过文件 + /* line 是集合: 解析成升序去重的行号数组. 上界 (超过文件总行数)要读过文件 * 才知道, 放在后面查; 这里只管写法本身对不对. 省略 line 时集合是空的, * 下面所有"按行"的检查都跳过, 直接当全文件替换. */ long lines[EDIT_LINE_MAX]; @@ -624,11 +624,11 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) char *perr = NULL; nlines = parse_line_set(lv, lines, EDIT_LINE_MAX, &perr); if (nlines < 0) { *is_error = 1; return perr; } - /* 报错与回执里要说清"是哪几行":模型据此判断行号有没有被理解对 */ + /* 报错与回执里要说清"是哪几行": 模型据此判断行号有没有被理解对 */ lines_text(lines, nlines, req, sizeof req); } - /* 先看大小再读:edit_file 需要把整个文件放进内存, + /* 先看大小再读: edit_file 需要把整个文件放进内存, * 对超大文件直接拒绝, 避免把内存吃干. * 用 stat 而不是读完再看, 否则拒绝之前就已经分配了. */ struct stat st; @@ -642,9 +642,9 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) size_t flen = 0; char *src = read_entire_file(path, &flen); - if (!src) { *is_error = 1; return err_result("无法读取 %s:%s", path, strerror(errno)); } + if (!src) { *is_error = 1; return err_result("无法读取 %s: %s", path, strerror(errno)); } - /* 二进制拒绝. 这里看**全文件** (不像读类工具只看前 8 KB):edit_file 在内存里 + /* 二进制拒绝. 这里看**全文件** (不像读类工具只看前 8 KB): edit_file 在内存里 * 是按 C 字符串处理的 (strstr/strlen), 文件里任何一个 NUL 都会让后面的内容 * 被当成结束 —— 改下去就是把原文件毁掉. */ { @@ -659,7 +659,7 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) } } - /* 上界检查:下界 (>=1)和写法在 parse_line_set 里已经查过, 这里要文件总行数. + /* 上界检查: 下界 (>=1)和写法在 parse_line_set 里已经查过, 这里要文件总行数. * 省略 line 时没有行号可查. */ if (!global) { long total_lines = count_lines(src, flen); @@ -678,7 +678,7 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) } size_t olen = strlen(old_s); - /* 搜索范围:给了集合就是集合圈住的那几行 —— 只认起点落在其中某一行上的匹配 + /* 搜索范围: 给了集合就是集合圈住的那几行 —— 只认起点落在其中某一行上的匹配 * (old_string 可以跨行), 集合已去重所以同一处不会被数两遍; 省略 line 就是 * 整个文件, 那时命中几处换几处 (见下面的替换分支). */ size_t hits = 0; @@ -710,7 +710,7 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) free(src); *is_error = 1; return err_result("%s 整个文件里没有 old_string (注意缩进与换行必须逐字符" - "一致). 要给行范围就填 line:整数或整数数组. ", path); + "一致). 要给行范围就填 line: 整数或整数数组. ", path); } if (hits == 0) { @@ -776,7 +776,7 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) } free(src); - if (g_ligent_interrupt) { /* 同 write_file:改盘前一律先看中断 */ + if (g_ligent_interrupt) { /* 同 write_file: 改盘前一律先看中断 */ sb_free(&out); *is_error = 1; return xstrdup("[已中断] 用户中断了这次操作, 文件没有被改动. "); @@ -786,7 +786,7 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) if (!f) { sb_free(&out); *is_error = 1; - return err_result("无法写入 %s:%s", path, strerror(errno)); + return err_result("无法写入 %s: %s", path, strerror(errno)); } size_t w = fwrite(out.data, 1, out.len, f); int wok = (w == out.len); @@ -797,7 +797,7 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) *is_error = 1; return err_result("写入 %s 失败", path); } - /* 回执里必须带上行号:模型据此确认"改的就是我以为的那一处". 省略 line 是 + /* 回执里必须带上行号: 模型据此确认"改的就是我以为的那一处". 省略 line 是 * 全文件替换, 回执也得直说 —— 否则它会以为只改了一处. */ if (global) return xasprintf("已在 %s 全文件替换 %ld 处 (写入 %zu 字节; line 省略 = " @@ -825,7 +825,7 @@ static char *tool_list_dir(const JsonValue *args, int *is_error) if (!path || !*path) path = "."; DIR *d = opendir(path); - if (!d) { *is_error = 1; return err_result("无法打开目录 %s:%s", path, strerror(errno)); } + if (!d) { *is_error = 1; return err_result("无法打开目录 %s: %s", path, strerror(errno)); } char **list = NULL; size_t count = 0, cap = 0; @@ -848,7 +848,7 @@ static char *tool_list_dir(const JsonValue *args, int *is_error) StrBuf out; sb_init(&out); - sb_printf(&out, "目录 %s (%zu 个条目):\n", path, count); + sb_printf(&out, "目录 %s (%zu 个条目): \n", path, count); for (size_t i = 0; i < count; i++) { char full[PATH_MAX]; snprintf(full, sizeof full, "%s/%s", path, list[i]); @@ -869,15 +869,15 @@ static char *tool_list_dir(const JsonValue *args, int *is_error) return sb_detach(&out); } -/* read_image:把本地图片交给模型看. +/* read_image: 把本地图片交给模型看. * - * 协议里图片只有两条路:外部 URL, 或者 base64 内联. ligent 不替用户去网上抓图, + * 协议里图片只有两条路: 外部 URL, 或者 base64 内联. ligent 不替用户去网上抓图, * 所以只收本地文件、产出 base64 内联. * - * 图片字节不进历史:这里只把图片存成本地备份, 历史里挂一个对备份的引用, + * 图片字节不进历史: 这里只把图片存成本地备份, 历史里挂一个对备份的引用, * 真正构造请求时才读出来编码 (见 image.c 的 image_expand_for_request). * - * 尺寸在**本地**就判掉. 理由是图片会留在历史里:一次超限不是一次失败, + * 尺寸在**本地**就判掉. 理由是图片会留在历史里: 一次超限不是一次失败, * 而是从此每一轮都超限. 与其发出去等一个 400, 不如当场告诉模型"太大了", * 并且**不备份** —— 一张永远发不出去的图, 留着备份没有意义. * @@ -887,7 +887,7 @@ static char *tool_read_image(const JsonValue *args, int *is_error) { REQUIRE_ERR_PTR(is_error); const char *path = arg_str(args, "path"); - if (!path || !*path) { *is_error = 1; return xstrdup("错误:缺少必填参数 path"); } + if (!path || !*path) { *is_error = 1; return xstrdup("错误: 缺少必填参数 path"); } size_t len = 0; const char *mime = NULL; @@ -910,41 +910,41 @@ static char *tool_read_image(const JsonValue *args, int *is_error) } /* ------------------------------------------------------------------ */ -/* 工具表:所有工具的**唯一真相** */ +/* 工具表: 所有工具的**唯一真相** */ /* ------------------------------------------------------------------ */ /* * 命名约定 (改名前先读这里; README §5 有同一份, 两处必须同步) * - * 1. 形式:全小写 snake_case, 动宾结构「动词_对象」. 动词必须来自白名单, - * 而白名单**按域划分** —— 要用新动词, 先把它加进对应域的白名单: - * 文件与执行域:read / write / edit / list / run / view - * 历史编辑域: list / read / replace / delete / insert / undo (meta.c) + * 1. 形式: 全小写 snake_case, 动宾结构「动词_对象」. 动词必须来自白名单, + * 而白名单**按域划分** —— 要用新动词, 先把它加进对应域的白名单: + * 文件与执行域: read / write / edit / list / run / view + * 历史编辑域: list / read / replace / delete / insert / undo (meta.c) * 两域的动词不混用 (run 不会出现在历史编辑域, undo 也不会出现在文件域). * 禁用 do / handle / process / get / exec 这类不说明对象的空动词. - * 2. 名字必须能推出副作用:会改盘的名字里不会出现 read / list. + * 2. 名字必须能推出副作用: 会改盘的名字里不会出现 read / list. * 模型靠名字判断该不该先征求同意 (.mutating 决定要不要问), * 含糊的名字等于让它猜. * 3. 不带 ligent / ai / tool 这类冗余词 —— 工具表本身就在工具命名空间里. * 4. 不与 REPL 命令同名 (/shell、/cd、/model、/log… 都已经被占了), * 否则人和模型两边会串. 这是 run_command 不叫 shell 的原因之一. * 5. 不与别的工具只在词序、单复数或大小写上不同 (read_file / file_read 这种). - * 6. 长度 <= 24 字符:工具表**每个请求都要重发**, 名字长度是重复成本. - * 7. 参数名与工具名同源:路径一律 path, 内容 content, 正则 pattern, + * 6. 长度 <= 24 字符: 工具表**每个请求都要重发**, 名字长度是重复成本. + * 7. 参数名与工具名同源: 路径一律 path, 内容 content, 正则 pattern, * 文件名过滤 include. 同一个概念在两个工具里必须同名. * - * 这张表同时生成四样东西:请求里的 schema (tools_schema)、执行分发 + * 这张表同时生成四样东西: 请求里的 schema (tools_schema)、执行分发 * (tool_execute)、要不要走确认 (tool_mutating)、/tools 的输出 * (tools_print_list). 所以**改名只需要改 .name 一列** —— 以前这四处各有 * 一份 strcmp 链, 改名必定漏一处, 而且漏了不报错 (只是静默地少认一个名字). * - * 两条约束写在这里, 因为从表里看不出来: + * 两条约束写在这里, 因为从表里看不出来: * - needs_vision == 1 的工具会在"探测到模型不收图片"时被摘掉 * (agent.c 的 tools_for_request 会调 tools_drop). * * 顺序 = 请求里工具表的顺序, 也是 /tools 的打印顺序. 改顺序不影响行为, * 但会让 tests/fixtures 里录制的请求对不上, 所以顺序变动要连着 fixtures 一起改. * - * 为什么**没有**搜索内容的工具 (曾经有过 search_files, 后来改名 grep, 最后删了): + * 为什么**没有**搜索内容的工具 (曾经有过 search_files, 后来改名 grep, 最后删了): * 模型用 run_command 里的真 grep 就行 —— `grep -rn` 支持正则、文件名过滤、 * 上下文行、大小写、多模式, 我们这个包装每一样都要自己重写一遍, 还得跟着 * 需求慢慢长 (第一个版本就是"搜内容"却叫 search_files, 让人以为按文件名搜). @@ -953,7 +953,7 @@ static char *tool_read_image(const JsonValue *args, int *is_error) typedef struct { const char *name; - const char *type; /* JSON-Schema 类型:string / integer / boolean; + const char *type; /* JSON-Schema 类型: string / integer / boolean; * 数组写成 "元素类型[]" (如 "integer[]"), * 交给 tools_schema 展开成 {type:array, items:{...}} */ const char *desc; @@ -978,7 +978,7 @@ static const ToolParam P_RUN_COMMAND[] = { "要执行的 shell 命令 (由 /bin/sh -c 执行, 支持管道与重定向)", 1 }, { "elevated", "boolean", "置 true 时以 root 执行这一条命令. **只在真的撞到权限错误之后才用**, " - "不要预防性地加. 加了之后:你会先被要求确认 (看清要跑什么), " + "不要预防性地加. 加了之后: 你会先被要求确认 (看清要跑什么), " "再由 sudo 向用户要密码 —— 密码是用户敲的, 你敲不出来. " "所以被拒绝时不要反复重试, 说明你需要用户亲自做什么即可. ", 0 }, { "timeout", "integer", "超时秒数, 默认 60, 最大 600", 0 }, @@ -1002,9 +1002,9 @@ static const ToolParam P_WRITE_FILE[] = { static const ToolParam P_EDIT_FILE[] = { { "path", "string", "要修改的文件路径", 1 }, { "line", "integer[]", - "old_string **起点**所在的行号, 集合型, **可以整个省略**:单个整数 (`12`)" + "old_string **起点**所在的行号, 集合型, **可以整个省略**: 单个整数 (`12`)" "或整数数组 (`[12,13]`)都收, 行号就是 read_file 打出来的那些 (从 1 开始). " - "数组的用法:old_string 跨几行、又不想赌起点是第几行时, 把覆盖到的那几行" + "数组的用法: old_string 跨几行、又不想赌起点是第几行时, 把覆盖到的那几行" "都填进来; 替换只发生在这些行里. **省略 line = 全文件替换所有匹配**, ", 0 }, { "old_string", "string", "被替换的原文, 必须与文件内容逐字符一致 (含缩进), 且**在 line 圈住的行里**" @@ -1052,7 +1052,7 @@ static const ToolDef TOOLS[] = { }, { TOOL_EDIT_FILE, - "在文件中做精确字符串替换. line 是集合型且**可省略**:给一个整数 (`12`)" + "在文件中做精确字符串替换. line 是集合型且**可省略**: 给一个整数 (`12`)" "或整数数组 (`[12,13]`)—— 把 old_string 覆盖到的那几行都填进来, 起点到底" "是第几行就不必赌; 替换只发生在这些行里, old_string 也只需在这些行里唯一. " "**省略 line = 全文件替换所有匹配** (就是编辑器的\"全局替换\"), 只在你确实" @@ -1081,7 +1081,7 @@ static const ToolDef TOOLS[] = { /* 按名字找定义, 找不到返回 NULL. * - * **故意不保留旧名别名**:schema 里只发新名, dispatch 再认旧名只会掩盖 + * **故意不保留旧名别名**: schema 里只发新名, dispatch 再认旧名只会掩盖 * "调用方没跟着改"这件事 (旧存档、用户自写的提示词、测试夹具都一样). * 名字即协议, 认不出来的就明确报"未知工具", 比静默兼容更容易发现漏改. */ static const ToolDef *find_tool(const char *name) @@ -1113,7 +1113,7 @@ JsonValue *tools_schema(void) JsonValue *req = json_new_array(); for (const ToolParam *p = d->params; p && p->name; p++) { JsonValue *one = json_new_object(); - /* "整数[]" 这种写法表示数组:展开成 {"type":"array","items":{"type":"整数"}}. + /* "整数[]" 这种写法表示数组: 展开成 {"type":"array","items":{"type":"整数"}}. * 后缀只认结尾的 "[]", 元素类型照抄前面那段. */ const char *br = strstr(p->type, "[]"); if (br && br[2] == '\0') { @@ -1153,7 +1153,7 @@ void tools_drop(JsonValue *tools, const char *name) } } -/* hide_image:探测到模型不收图片时为 1 —— /tools 不该列一个已经藏起来的工具, +/* hide_image: 探测到模型不收图片时为 1 —— /tools 不该列一个已经藏起来的工具, * 否则用户看到的是谎话. 列表本身从工具表生成, 所以它和真正发出去的工具表 * 天然一致 (以前这里是手写的一串 puts, 就漏过一个工具). */ void tools_print_list(int hide_image) @@ -1165,7 +1165,7 @@ void tools_print_list(int hide_image) printf("%s\n", tf("tools.ui.hidden", "name", d->name, NULL)); continue; } - /* 文案在 i18n 表里 (键就在上面那一列):/tools 是纯给人看的东西, + /* 文案在 i18n 表里 (键就在上面那一列): /tools 是纯给人看的东西, * 而表里的 desc 是发给模型的, 两者本来就该分开 */ printf(" %-18s%s\n", d->name, t(d->ui_key)); } @@ -1177,7 +1177,7 @@ int tool_mutating(const char *name) return d ? d->mutating : 0; } -/* line 是集合型参数:整数或整数数组. 渲染成 "3" / "3,4,5" (超过 8 个就省略 +/* line 是集合型参数: 整数或整数数组. 渲染成 "3" / "3,4,5" (超过 8 个就省略 * 尾部)—— 用户/模型要确认的第一件事就是"改的是哪几行", 但也不能把终端刷满. * 一个合法行号都没渲染出来时留空串, 调用方据此走"没给行号"那条文案. */ void tool_lines_label(const JsonValue *args, char *out, size_t cap) @@ -1201,8 +1201,8 @@ void tool_lines_label(const JsonValue *args, char *out, size_t cap) /* ---- edit_file 的新旧对照 (带行号)---- * - * 行号取自 line 参数:替换就发生在那里, 所以两侧的起始行号相同 —— 新内容落在 - * 旧内容原来的位置上. line 省略 (全文件替换)时没有确定的起点, 就**不标**行号: + * 行号取自 line 参数: 替换就发生在那里, 所以两侧的起始行号相同 —— 新内容落在 + * 旧内容原来的位置上. line 省略 (全文件替换)时没有确定的起点, 就**不标**行号: * 编一个出来比不标更糟. */ #define DIFF_MAX_LINES 8 @@ -1212,7 +1212,7 @@ void tool_lines_label(const JsonValue *args, char *out, size_t cap) #define DIFF_DIM "\033[2m" #define DIFF_OFF "\033[0m" -/* 起始行号:line 是集合型, 取最小的那个; 没给 line 则返回 0 (= 不标行号) */ +/* 起始行号: line 是集合型, 取最小的那个; 没给 line 则返回 0 (= 不标行号) */ static long diff_base_line(const JsonValue *args) { JsonValue *lv = json_object_get(args, "line"); @@ -1277,10 +1277,10 @@ int tool_render_diff(FILE *out, const JsonValue *args, char *tool_execute(const char *name, const JsonValue *args, int *is_error) { if (is_error) *is_error = 0; - if (!name) return xstrdup("错误:工具名为空"); + if (!name) return xstrdup("错误: 工具名为空"); if (!args || args->type != JSON_OBJECT) { if (is_error) *is_error = 1; - return xstrdup("错误:工具参数必须是 JSON 对象"); + return xstrdup("错误: 工具参数必须是 JSON 对象"); } const ToolDef *d = find_tool(name); @@ -1288,7 +1288,7 @@ char *tool_execute(const char *name, const JsonValue *args, int *is_error) if (is_error) *is_error = 1; return err_result("未知工具 \"%s\"", name); } - /* fn == NULL 是一道守卫:表里目前没有这种工具, 但留着这条检查 —— + /* fn == NULL 是一道守卫: 表里目前没有这种工具, 但留着这条检查 —— * 将来若加一个"由上层执行"的工具, 模型瞎编参数时也能得到明确回答, * 而不是静默什么都不干. */ if (!d->fn) { diff --git a/src/tools.h b/src/tools.h index efe3e5f..918edc6 100644 --- a/src/tools.h +++ b/src/tools.h @@ -4,11 +4,11 @@ #include "json.h" -#include /* FILE:tool_render_diff 要往 stdout / stderr 写 */ +#include /* FILE: tool_render_diff 要往 stdout / stderr 写 */ /* ---- 工具名 ---- * - * 名字是协议的一部分:它进 schema、进执行分发、进 /tools 的输出. 所以**名字 + * 名字是协议的一部分: 它进 schema、进执行分发、进 /tools 的输出. 所以**名字 * 只在这里定义一次**, 别处一律用宏 —— 改名时漏一处不会编译报错, 只会静默地 * 少认一个工具. * @@ -30,7 +30,7 @@ JsonValue *tools_schema(void); void tools_drop(JsonValue *tools, const char *name); /* 所有工具都通过 *is_error 回报失败. **调用方必须给一个有效指针** —— - * 这条约定在每个工具函数的入口用 assert 钉住 (见 tools.c 的 REQUIRE_ERR_PTR): + * 这条约定在每个工具函数的入口用 assert 钉住 (见 tools.c 的 REQUIRE_ERR_PTR): * 它既是文档, 也让静态分析不必为每个 `*is_error = 1` 报一遍"可能解引用空指针" * (那 30 多条噪声会把真正的问题淹掉). */ #define REQUIRE_ERR_PTR(p) do { assert((p) != NULL); } while (0) diff --git a/src/util.c b/src/util.c index 46c025f..7225af2 100644 --- a/src/util.c +++ b/src/util.c @@ -110,7 +110,7 @@ void sb_clear(StrBuf *sb) void sb_reserve(StrBuf *sb, size_t extra) { - /* 提前返回要求 data 非空:不能只看容量够不够. + /* 提前返回要求 data 非空: 不能只看容量够不够. * 代码里其实维护着「data == NULL 当且仅当 cap == 0」这个不变量 (sb_init / * sb_free / sb_detach 三处), 但那是隐式的、没人强制; 一旦哪次改动让它不成立, * 这里就会带着 NULL 指针回去, 下一行 memcpy 才炸, 离原因很远. @@ -215,7 +215,7 @@ char *read_entire_file(const char *path, size_t *out_len) while ((n = fread(buf, 1, sizeof buf, f)) > 0) sb_append(&sb, buf, n); /* fread 返回 0 既可能是 EOF, 也可能是读错误. 分不清的话就会把**截断的 - * 内容**当成功返回 —— 对图片尤其糟:会拿半张图去备份、去上传. */ + * 内容**当成功返回 —— 对图片尤其糟: 会拿半张图去备份、去上传. */ int bad = ferror(f); fclose(f); if (bad) { sb_free(&sb); return NULL; } @@ -299,7 +299,7 @@ size_t utf8_seq_len(const char *s, size_t len) return n; } -/* 近似 wcwidth:东亚宽字符返回 2, 覆盖常用 CJK 区间 */ +/* 近似 wcwidth: 东亚宽字符返回 2, 覆盖常用 CJK 区间 */ int cp_width(unsigned cp) { if (cp < 32) return 0; @@ -408,7 +408,7 @@ void random_alnum(char *out, size_t n) fclose(f); } if (got < n) { - /* 没有 urandom (或读短了):时间 + pid 播种, 够避免撞名即可 */ + /* 没有 urandom (或读短了): 时间 + pid 播种, 够避免撞名即可 */ unsigned seed = (unsigned)time(NULL) ^ ((unsigned)getpid() << 16); for (size_t i = got; i < n; i++) { seed = seed * 1103515245u + 12345u; @@ -425,7 +425,7 @@ void random_alnum(char *out, size_t n) size_t base64_encoded_len(size_t n) { - /* 每 3 字节出 4 个字符, 不足的补 '='. 先算长度再决定要不要编码: + /* 每 3 字节出 4 个字符, 不足的补 '='. 先算长度再决定要不要编码: * 一张 24 MiB 的图编码后是 32 MiB, 正好卡在内联上限上. */ return ((n + 2) / 3) * 4; } diff --git a/src/util.h b/src/util.h index 35a653c..2d814ab 100644 --- a/src/util.h +++ b/src/util.h @@ -1,4 +1,4 @@ -/* util.h - 基础工具:内存分配、动态字符串缓冲 */ +/* util.h - 基础工具: 内存分配、动态字符串缓冲 */ #ifndef LIGENT_UTIL_H #define LIGENT_UTIL_H @@ -18,7 +18,7 @@ # define LIGENT_PRINTF(fmt_idx, arg_idx) #endif -/* ---- 内存:失败即退出, 避免到处判空 ---- */ +/* ---- 内存: 失败即退出, 避免到处判空 ---- */ void *xmalloc(size_t n); void *xcalloc(size_t n, size_t size); void *xrealloc(void *p, size_t n); @@ -58,15 +58,15 @@ char *expand_env(const char *src); /* ---- 文本 / 二进制的判据 (唯一定义处) ---- * - * 判据只有一条:**出现 NUL 字节**. 文本文件里不该有它, 而 UTF-16、压缩包、 + * 判据只有一条: **出现 NUL 字节**. 文本文件里不该有它, 而 UTF-16、压缩包、 * 可执行文件、稀疏文件这类都会中招. * * 探测窗口是 8 KB —— 读类工具没必要为了拒绝一个 10 GB 的文件把它整个读一遍. - * 判据和窗口必须由所有"处理文本"的地方共用:以前 tools.c 和 attach.c 各写了 + * 判据和窗口必须由所有"处理文本"的地方共用: 以前 tools.c 和 attach.c 各写了 * 一份, 窗口还不一样 (一个看前 8 KB、一个看全文件), 于是同一个文件会出现 * "/include 说它是二进制、read_file 说它能读"这种自相矛盾的说法. * - * 唯一的例外是 edit_file 的**全文件**检查:它在内存里按 C 字符串处理, + * 唯一的例外是 edit_file 的**全文件**检查: 它在内存里按 C 字符串处理, * 任何位置的 NUL 都会让结果从那里被截断, 所以它不能只看开头. */ #define LIGENT_BIN_PROBE (8 * 1024) @@ -92,15 +92,15 @@ char *base64_encode(const unsigned char *data, size_t n); /* ---- UTF-8 与终端显示宽度 ---- */ /* 解码一个码点, *adv 为消耗的字节数 (输入非法时退化为 1 字节). - * 注意它是**宽松**的:不校验续字节格式、过长编码、代理区, 只按首字节定长度. + * 注意它是**宽松**的: 不校验续字节格式、过长编码、代理区, 只按首字节定长度. * 想判断"这段字节是不是合法 UTF-8"要用下面的 utf8_seq_len. */ unsigned utf8_decode(const char *s, size_t len, size_t *adv); -/* s 处是否是一个**合法** UTF-8 序列:合法返回序列长度 (1-4), 非法/被截断返回 0. - * 比 utf8_decode 严格 —— 它另外拒绝:续字节格式不对、过长编码、UTF-16 代理区 +/* s 处是否是一个**合法** UTF-8 序列: 合法返回序列长度 (1-4), 非法/被截断返回 0. + * 比 utf8_decode 严格 —— 它另外拒绝: 续字节格式不对、过长编码、UTF-16 代理区 * (U+D800..U+DFFF)、以及超出 U+10FFFF 的编码. 这些在 JSON 里同样是非法的, * 所以序列化前必须按这个判据过一遍. */ size_t utf8_seq_len(const char *s, size_t len); -/* 近似 wcwidth:东亚宽字符返回 2, 控制字符返回 0 */ +/* 近似 wcwidth: 东亚宽字符返回 2, 控制字符返回 0 */ int cp_width(unsigned cp); /* 从 i 处前进/后退一个字符, 返回新下标 (不越界) */ size_t utf8_next(const char *s, size_t n, size_t i); diff --git a/src/version.c b/src/version.c index 1ddd897..64a8eb2 100644 --- a/src/version.c +++ b/src/version.c @@ -5,7 +5,7 @@ #include /* 长度账 (最坏情况都要放得下, 否则 gcc 会报 -Wformat-truncation, - * 而那个警告是对的 —— 截断的版本串比报错更糟): + * 而那个警告是对的 —— 截断的版本串比报错更糟): * 主体 2026.09.22-abcdef7.dirty 25 * 分支 LIGENT_BRANCH_MAX 40 (见下, 截断) * 时间 2026-09-22 04:32:50 19 @@ -23,10 +23,10 @@ /* 编译器串和平台串的缓冲尺寸 (在 ligent_compiler_string/platform_string 里用) */ #define LIGENT_COMPILER_MAX 96 #define LIGENT_PLATFORM_MAX 64 -/* 主体:CalVer(10) + "-" + hash(7) + ".dirty"(6) + 余量 = 160 够宽松 */ +/* 主体: CalVer(10) + "-" + hash(7) + ".dirty"(6) + 余量 = 160 够宽松 */ #define LIGENT_VERBODY_MAX 160 -/* branch 显示缓冲:截断到 LIGENT_BRANCH_MAX 个字符, 每个 UTF-8 字符最多 4 +/* branch 显示缓冲: 截断到 LIGENT_BRANCH_MAX 个字符, 每个 UTF-8 字符最多 4 * 字节, 再加 "..."(3) 和 NUL */ #define LIGENT_BRANCH_BUF (LIGENT_BRANCH_MAX * 4 + 8) /* where = branch + ", " + date(10) + " " + time(8) */ @@ -42,7 +42,7 @@ static char buf_full[LIGENT_FULL_BUF]; /* 编译器标识 */ /* ------------------------------------------------------------------ */ /* - * 顺序有讲究:clang 也定义 __GNUC__ (它假装自己是 gcc 4.2), 所以 clang 必须 + * 顺序有讲究: clang 也定义 __GNUC__ (它假装自己是 gcc 4.2), 所以 clang 必须 * 排在 GCC 前面判断. tcc 不定义 __GNUC__, 放哪都行, 但放在一起更清楚. * * tcc 只给一个整数 __TINYC__ (0.9.28 -> 928, 也就是去掉小数点当十进制读). @@ -76,8 +76,8 @@ static const char *compiler_version(void) return buf; #elif defined(__TINYC__) /* tcc 没有 __VERSION__, 只有整数 __TINYC__. - * 编码方式是**去掉小数点当十进制读**:0.9.28 -> 928. - * 所以主版本要 /1000 而不是 /100: + * 编码方式是**去掉小数点当十进制读**: 0.9.28 -> 928. + * 所以主版本要 /1000 而不是 /100: * 928 / 1000 = 0 <- 对 * 928 / 100 = 9 <- 错 (第一版就是这么写的, 输出成 "tcc 9.2.8") * 这对 1000 以上也成立 (1.0.0 -> 1000, 1000/1000 = 1). */ @@ -156,7 +156,7 @@ const char *ligent_platform_string(void) const char *ligent_version_string(void) { - /* 格式:-[.dirty] + /* 格式: -[.dirty] * hash 为 "unknown" 时直接省掉它 */ if (strcmp(LIGENT_GIT_HASH, "unknown") == 0) snprintf(buf_short, sizeof buf_short, "%s", LIGENT_VERSION); @@ -181,7 +181,7 @@ const char *ligent_version_full(void) { const char *v = ligent_version_string(); - /* 括号里:分支 + 构建时间. 这两个是"这份二进制从哪来的", 排查时都要. + /* 括号里: 分支 + 构建时间. 这两个是"这份二进制从哪来的", 排查时都要. * 分支在 detached HEAD (CI 上常见)时是 unknown, 那也照实说. */ char branch[LIGENT_BRANCH_BUF]; branch_display(branch, sizeof branch); @@ -193,7 +193,7 @@ const char *ligent_version_full(void) snprintf(where, sizeof where, "%s, %s %s", branch, LIGENT_BUILD_DATE, LIGENT_BUILD_TIME); - /* 整体: + /* 整体: * ligent 2026.09.22-209538e (master, 2026-09-22 04:32:04) * [GCC 16.2.1 20260810] on linux/x86_64 */ snprintf(buf_full, sizeof buf_full, "%s (%s) [%s] on %s", diff --git a/src/version.h b/src/version.h index 7ef97a3..fe03779 100644 --- a/src/version.h +++ b/src/version.h @@ -1,6 +1,6 @@ /* version.h - 版本号与构建信息 * - * 版本号由**构建系统注入**, 不写死在源码里: + * 版本号由**构建系统注入**, 不写死在源码里: * * LIGENT_VERSION CalVer (YYYY.MM.DD), 来自 git tag; 没有 tag 时用 * 源码里这个回退值 @@ -9,16 +9,16 @@ * LIGENT_BUILD_DATE 构建日期 (UTC, YYYY-MM-DD) * LIGENT_BUILD_TIME 构建时间 (UTC, HH:MM:SS) * - * 编译器与目标平台**不走注入**, 在 version.c 里用编译期宏判断 (见下): + * 编译器与目标平台**不走注入**, 在 version.c 里用编译期宏判断 (见下): * 那两项是"这份代码被什么编出来、给谁跑的", 编译器自己最清楚, 让构建脚本去猜 * 反而多一个出错点. * - * 为什么不让源码当"唯一真相":那样每发一次版就要改一次源码、多一个提交, 而且 + * 为什么不让源码当"唯一真相": 那样每发一次版就要改一次源码、多一个提交, 而且 * 从 tarball 还是从 git 编出来的版本号会不一致. 让构建期算出来、编进去, 源码 * 只留一个**回退默认值** (给"没有构建系统"的场景, 比如有人直接拿 gcc 编 * src 下的所有 .c), 这是 dunst/curl 这类项目的通行做法. * - * 为什么是 CalVer 而不是 SemVer:ligent 是个终端工具, 不是被别人链接的库. + * 为什么是 CalVer 而不是 SemVer: ligent 是个终端工具, 不是被别人链接的库. * CalVer 表达的是"这是哪天的东西", 对一个还在快速演进的工具比"第几个大版本" * 有用得多 —— 用户看到 2026.02.14 立刻知道新旧, 看到 1.4.2 还得去查. 真到了 * 需要表达"破坏性变更"的时候, git 历史和 README 比版本号数字更能说清楚. @@ -26,7 +26,7 @@ #ifndef LIGENT_VERSION_H #define LIGENT_VERSION_H -/* 回退值:只在没有通过 -D 注入时生效 (直接拿 gcc 编、或从没有 .git 的 +/* 回退值: 只在没有通过 -D 注入时生效 (直接拿 gcc 编、或从没有 .git 的 * tarball 编). 改这个值等于"手动打了一个版本", 正常发布流程不该动它. */ #ifndef LIGENT_VERSION # define LIGENT_VERSION "0.0.0-unknown" @@ -49,14 +49,14 @@ #endif /* ------------------------------------------------------------------ */ -/* 编译器与平台:用编译期宏判断, 不靠构建脚本传 */ +/* 编译器与平台: 用编译期宏判断, 不靠构建脚本传 */ /* ------------------------------------------------------------------ */ /* - * 为什么要区分这么多分支:__VERSION__ 是 gcc/clang 的约定, **tcc 没有这个宏** + * 为什么要区分这么多分支: __VERSION__ 是 gcc/clang 的约定, **tcc 没有这个宏** * (它只有 __TINYC__ 加一个数字), 而 tcc 是这个项目明确支持的编译器之一 * (make CC=tcc 是过全套测试的). 所以 tcc 那条腿得单独拼. * - * 平台同理用 __linux__ / __APPLE__ 这些宏, **不用 uname()**:宏描述的是 + * 平台同理用 __linux__ / __APPLE__ 这些宏, **不用 uname()**: 宏描述的是 * "这份二进制是给哪个平台编的", uname() 描述的是"现在跑在哪" —— 静态产物 * 会被拷到别的机器上跑, 交叉编译时两者也不一致. 版本串里该记的是前者. */ @@ -67,11 +67,11 @@ const char *ligent_compiler_string(void); /* 目标平台, 例如 "linux/x86_64" */ const char *ligent_platform_string(void); -/* 版本号主体:-, 例如 2026.09.22-209538e +/* 版本号主体: -, 例如 2026.09.22-209538e * hash 未知时只有 CalVer. */ const char *ligent_version_string(void); -/* 完整一行, 给 --version 和 doctor 用: +/* 完整一行, 给 --version 和 doctor 用: * ligent 2026.09.22-209538e (master, 2026-09-22 04:32:04) [GCC 16.2.1] on linux/x86_64 * 分配在静态缓冲里, 第二次调用会覆盖第一次的结果 —— 只给打印用. */ const char *ligent_version_full(void); diff --git a/tests/confirm_check.py b/tests/confirm_check.py index f97174d..41da933 100755 --- a/tests/confirm_check.py +++ b/tests/confirm_check.py @@ -8,12 +8,12 @@ NULL; 代码把 NULL 当成"没人能回答, 按默认值放行". 于是**在确 上按 Ctrl-C 等于批准这次操作**. run_command 侥幸自己查了中断标志才没执行, write_file / edit_file 没查, 文件照写. -这个脚本把三种回答都跑一遍, 直接看磁盘上有没有留下东西: +这个脚本把三种回答都跑一遍, 直接看磁盘上有没有留下东西: Ctrl-C -> 必须拒绝 n -> 必须拒绝 y -> 必须执行 (否则就是"修过头"了) -用法: +用法: confirm_check.py """ import os @@ -27,14 +27,14 @@ import time PROBE = "build/tmp/confirm_probe.txt" PROBE1 = "build/tmp/confirm_probe1.txt" PROBE2 = "build/tmp/confirm_probe2.txt" -# 只认稳定前缀:选项列表是会变的 (现在是 Y/N/Always/neVer), +# 只认稳定前缀: 选项列表是会变的 (现在是 Y/N/Always/neVer), # 写死整串会在改提示文案时把测试弄坏. MARK = "继续?" PROMPT_READY = b"\x1b[?2004h" def run_case(binary, base, answer, wait_confirm=6.0): - """跑一轮:触发 write_file → 等确认提示出现 → 回答 → 看文件在不在. """ + """跑一轮: 触发 write_file → 等确认提示出现 → 回答 → 看文件在不在. """ try: os.unlink(PROBE) except OSError: @@ -107,7 +107,7 @@ def run_case(binary, base, answer, wait_confirm=6.0): def run_turn_scope(binary, base): """Always / neVer 的作用域必须是"这一轮", 不是整个会话. - 一轮里让模型连写两个文件:第一次问、答 Always, 第二次不该再问. + 一轮里让模型连写两个文件: 第一次问、答 Always, 第二次不该再问. 然后开新的一轮 (下一条用户消息), 必须**重新问** —— 会话级的开关是 /yolo, 不是这个. @@ -178,7 +178,7 @@ def run_turn_scope(binary, base): pump(1.2) - # ---- 第一轮:连写两个 ---- + # ---- 第一轮: 连写两个 ---- base_ready = state["ready"] os.write(fd, "WRITE2_TEST 写两个文件\n".encode()) wait_for(has_prompt, 8.0) @@ -190,7 +190,7 @@ def run_turn_scope(binary, base): both_written = os.path.exists(PROBE1) and os.path.exists(PROBE2) - # ---- 第二轮:换一条用户消息, 必须重新问 ---- + # ---- 第二轮: 换一条用户消息, 必须重新问 ---- base_ready = state["ready"] before = prompt_count() os.write(fd, "WRITE_LAST_TEST 再来一轮\n".encode()) @@ -237,7 +237,7 @@ def main(): fails += 1 print(" ✗ %s%s" % (name, (" " + detail) if detail else "")) - # 三种回答:(说明, 发送的字节, 期望文件是否被写) + # 三种回答: (说明, 发送的字节, 期望文件是否被写) cases = [ ("在确认提示上按 Ctrl-C", b"\x03", False), ("答 n", b"n\n", False), @@ -246,15 +246,15 @@ def main(): for label, answer, expect_written in cases: got, written = run_case(binary, base, answer) - check("%s:等到了确认提示" % label, got, "没等到确认提示") - check("%s:文件%s" % (label, "被写入" if expect_written else "没被写入"), + check("%s: 等到了确认提示" % label, got, "没等到确认提示") + check("%s: 文件%s" % (label, "被写入" if expect_written else "没被写入"), written == expect_written, "实际 %s" % ("写了" if written else "没写")) - # 作用域:Always 只活这一轮 + # 作用域: Always 只活这一轮 n1, both, again, n2 = run_turn_scope(binary, base) - check("Always:同一轮里只问一次", n1 == 1, "实际问了 %d 次" % n1) - check("Always:同轮的第二次改动被放行", both, "第二个文件没写") + check("Always: 同一轮里只问一次", n1 == 1, "实际问了 %d 次" % n1) + check("Always: 同轮的第二次改动被放行", both, "第二个文件没写") check("新一轮会重新询问 (作用域仅限本轮)", again and n2 >= 1, "新一轮没有询问") @@ -263,7 +263,7 @@ def main(): os.unlink(p) except OSError: pass - # 这里**不要** rmtree("build/tmp"):那是 e2e 各段共用的临时目录, 删掉它 + # 这里**不要** rmtree("build/tmp"): 那是 e2e 各段共用的临时目录, 删掉它 # 会连带清掉别人放进去的东西 (我就因此丢过两次日志), 而它自己的三个文件 # 上面已经删干净了. 测试辅助脚本只该收拾自己造的东西. diff --git a/tests/fixtures/error.log b/tests/fixtures/error.log index 869c3aa..8408ffa 100644 --- a/tests/fixtures/error.log +++ b/tests/fixtures/error.log @@ -3,7 +3,7 @@ ===== 2026-09-21 17:09:35 ===== 请求: POST http://127.0.0.1:8882/v1/chat/completions ----- 请求体 (5533 字节) ----- -{"model":"deepseek-flash","messages":[{"role":"system","content":"你是用户的 AI 助手\n\n你可以调用工具来读写文件和执行命令. \n工作原则:\n1. 先观察再动手:不确定文件内容或目录结构时, 先用 list_dir / read_file 查看, 不要凭猜测写代码. 搜内容用 run_command (如 grep -rn 模式 目录). \n2. 修改已有文件优先用 edit_file:它**必须**带上 read_file 显示的行号 line, 替换只发生在那一行里. 只有创建新文件或需要整体重写时才用 write_file. \n3. 工具报错时, 先读错误信息定位原因, 再决定换一种方式; 不要重复执行同一个失败的命令. \n4. 回答用中文, 简洁直接, 不要把大段代码重复贴回来. 完成任务后用一两句话说明改了什么、验证结果如何. \n5. 不要执行破坏性的操作 (例如删除用户数据、强制推送), 除非用户明确要求. \n6. read_file / write_file / edit_file 只处理文本:二进制文件会被拒绝. 看图片用 read_image, 看其它二进制用 run_command (file / xxd / strings). \n\n除非必要, 否则不要执行命令.\n除非用户说明, 不得直接或间接(如使用curl)获取任何在线资源.\n"},{"role":"user","content":"ERROR_TEST 制造一个服务端错误"}],"tools":[{"type":"function","function":{"name":"run_command","description":"在工作目录中执行 shell 命令, 返回合并后的 stdout/stderr 与退出码. 适合编译、运行测试、git 操作等. ","parameters":{"type":"object","properties":{"command":{"type":"string","description":"要执行的 shell 命令 (由 /bin/sh -c 执行, 支持管道与重定向)"},"elevated":{"type":"boolean","description":"置 true 时以 root 执行这一条命令. **只在真的撞到权限错误之后才用**, 不要预防性地加. 加了之后:你会先被要求确认 (看清要跑什么), 再由 sudo 向用户要密码 —— 密码是用户敲的, 你敲不出来. 所以被拒绝时不要反复重试, 说明你需要用户亲自做什么即可. "},"timeout":{"type":"integer","description":"超时秒数, 默认 60, 最大 600"}},"required":["command"]}}},{"type":"function","function":{"name":"read_file","description":"读取文本文件内容, 返回带行号的文本; 行号可以直接喂给 edit_file 的 line. 大文件请用 offset/limit 分段读取. **二进制文件会被拒绝** (它只处理文本). ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径, 相对于工作目录或绝对路径"},"offset":{"type":"integer","description":"起始行号 (从 1 开始), 默认 1"},"limit":{"type":"integer","description":"最多读取的行数, 默认 2000"}},"required":["path"]}}},{"type":"function","function":{"name":"write_file","description":"创建或整体覆盖一个文本文件 (会覆盖原有内容). 修改已有文件优先用 edit_file. 目标已经是二进制文件时会被拒绝覆盖. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径; 父目录不存在会自动创建. **已经存在的二进制文件会被拒绝覆盖**"},"content":{"type":"string","description":"要写入的完整文件内容"}},"required":["path","content"]}}},{"type":"function","function":{"name":"edit_file","description":"在文件中做精确字符串替换. **必须给 line** (read_file 打出来的行号):替换只发生在那一行里, old_string 也只需在那一行里唯一 —— 不必为了\"全文唯一\"把 old_string 写得很长. 行号对不上会报出 old_string 真正在哪几行, 照着改 line 即可. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"要修改的文件路径"},"line":{"type":"integer","description":"old_string 所在的行号, **必填**. 就是 read_file 打出来的那个行号 (从 1 开始)—— 没见过这个文件就先 read_file 看一眼. 替换只会发生在这一行里. "},"old_string":{"type":"string","description":"被替换的原文, 必须与文件内容逐字符一致 (含缩进), 且**在 line 那一行里**只有一处匹配 (匹配可以跨行, 起点落在该行即可)"},"new_string":{"type":"string","description":"替换后的新文本"},"replace_all":{"type":"boolean","description":"为 true 时替换**全文件**里所有匹配 (line 仍必须指向其中真实的一处), 默认 false (只替换 line 那一行上的那一处)"}},"required":["path","line","old_string","new_string"]}}},{"type":"function","function":{"name":"list_dir","description":"列出目录内容, 含文件类型与大小. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"目录路径, 默认当前目录"}},"required":[]}}},{"type":"function","function":{"name":"read_image","description":"读取一张本地图片并把它交给你看 (截图、设计稿、图表、报错弹窗都行). 图片会以 base64 内联进请求, 本机留一份备份、历史里只记对备份的引用. 需要看图片内容时用它, 不要试着用 read_file 去读二进制. 图片超过内联体积上限时会被直接拒绝, 这时请让用户先缩小或压缩. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"本地图片路径. 支持 PNG / JPEG / GIF / WebP (按文件内容判断, 不看扩展名)"}},"required":["path"]}}}],"tool_choice":"auto","stream":true} +{"model":"deepseek-flash","messages":[{"role":"system","content":"你是用户的 AI 助手\n\n你可以调用工具来读写文件和执行命令. \n工作原则: \n1. 先观察再动手: 不确定文件内容或目录结构时, 先用 list_dir / read_file 查看, 不要凭猜测写代码. 搜内容用 run_command (如 grep -rn 模式 目录). \n2. 修改已有文件优先用 edit_file: 它**必须**带上 read_file 显示的行号 line, 替换只发生在那一行里. 只有创建新文件或需要整体重写时才用 write_file. \n3. 工具报错时, 先读错误信息定位原因, 再决定换一种方式; 不要重复执行同一个失败的命令. \n4. 回答用中文, 简洁直接, 不要把大段代码重复贴回来. 完成任务后用一两句话说明改了什么、验证结果如何. \n5. 不要执行破坏性的操作 (例如删除用户数据、强制推送), 除非用户明确要求. \n6. read_file / write_file / edit_file 只处理文本: 二进制文件会被拒绝. 看图片用 read_image, 看其它二进制用 run_command (file / xxd / strings). \n\n除非必要, 否则不要执行命令.\n除非用户说明, 不得直接或间接(如使用curl)获取任何在线资源.\n"},{"role":"user","content":"ERROR_TEST 制造一个服务端错误"}],"tools":[{"type":"function","function":{"name":"run_command","description":"在工作目录中执行 shell 命令, 返回合并后的 stdout/stderr 与退出码. 适合编译、运行测试、git 操作等. ","parameters":{"type":"object","properties":{"command":{"type":"string","description":"要执行的 shell 命令 (由 /bin/sh -c 执行, 支持管道与重定向)"},"elevated":{"type":"boolean","description":"置 true 时以 root 执行这一条命令. **只在真的撞到权限错误之后才用**, 不要预防性地加. 加了之后: 你会先被要求确认 (看清要跑什么), 再由 sudo 向用户要密码 —— 密码是用户敲的, 你敲不出来. 所以被拒绝时不要反复重试, 说明你需要用户亲自做什么即可. "},"timeout":{"type":"integer","description":"超时秒数, 默认 60, 最大 600"}},"required":["command"]}}},{"type":"function","function":{"name":"read_file","description":"读取文本文件内容, 返回带行号的文本; 行号可以直接喂给 edit_file 的 line. 大文件请用 offset/limit 分段读取. **二进制文件会被拒绝** (它只处理文本). ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径, 相对于工作目录或绝对路径"},"offset":{"type":"integer","description":"起始行号 (从 1 开始), 默认 1"},"limit":{"type":"integer","description":"最多读取的行数, 默认 2000"}},"required":["path"]}}},{"type":"function","function":{"name":"write_file","description":"创建或整体覆盖一个文本文件 (会覆盖原有内容). 修改已有文件优先用 edit_file. 目标已经是二进制文件时会被拒绝覆盖. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径; 父目录不存在会自动创建. **已经存在的二进制文件会被拒绝覆盖**"},"content":{"type":"string","description":"要写入的完整文件内容"}},"required":["path","content"]}}},{"type":"function","function":{"name":"edit_file","description":"在文件中做精确字符串替换. **必须给 line** (read_file 打出来的行号): 替换只发生在那一行里, old_string 也只需在那一行里唯一 —— 不必为了\"全文唯一\"把 old_string 写得很长. 行号对不上会报出 old_string 真正在哪几行, 照着改 line 即可. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"要修改的文件路径"},"line":{"type":"integer","description":"old_string 所在的行号, **必填**. 就是 read_file 打出来的那个行号 (从 1 开始)—— 没见过这个文件就先 read_file 看一眼. 替换只会发生在这一行里. "},"old_string":{"type":"string","description":"被替换的原文, 必须与文件内容逐字符一致 (含缩进), 且**在 line 那一行里**只有一处匹配 (匹配可以跨行, 起点落在该行即可)"},"new_string":{"type":"string","description":"替换后的新文本"},"replace_all":{"type":"boolean","description":"为 true 时替换**全文件**里所有匹配 (line 仍必须指向其中真实的一处), 默认 false (只替换 line 那一行上的那一处)"}},"required":["path","line","old_string","new_string"]}}},{"type":"function","function":{"name":"list_dir","description":"列出目录内容, 含文件类型与大小. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"目录路径, 默认当前目录"}},"required":[]}}},{"type":"function","function":{"name":"read_image","description":"读取一张本地图片并把它交给你看 (截图、设计稿、图表、报错弹窗都行). 图片会以 base64 内联进请求, 本机留一份备份、历史里只记对备份的引用. 需要看图片内容时用它, 不要试着用 read_file 去读二进制. 图片超过内联体积上限时会被直接拒绝, 这时请让用户先缩小或压缩. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"本地图片路径. 支持 PNG / JPEG / GIF / WebP (按文件内容判断, 不看扩展名)"}},"required":["path"]}}}],"tool_choice":"auto","stream":true} ----- 响应头 ----- HTTP/1.0 400 Bad Request Server: BaseHTTP/0.6 Python/3.14.7 diff --git a/tests/fixtures/session.log b/tests/fixtures/session.log index c14421d..02d9c6b 100644 --- a/tests/fixtures/session.log +++ b/tests/fixtures/session.log @@ -3,7 +3,7 @@ ===== 2026-09-21 17:09:24 ===== 请求: POST http://127.0.0.1:8881/v1/chat/completions ----- 请求体 (5508 字节) ----- -{"model":"deepseek-flash","messages":[{"role":"system","content":"你是用户的 AI 助手\n\n你可以调用工具来读写文件和执行命令. \n工作原则:\n1. 先观察再动手:不确定文件内容或目录结构时, 先用 list_dir / read_file 查看, 不要凭猜测写代码. 搜内容用 run_command (如 grep -rn 模式 目录). \n2. 修改已有文件优先用 edit_file:它**必须**带上 read_file 显示的行号 line, 替换只发生在那一行里. 只有创建新文件或需要整体重写时才用 write_file. \n3. 工具报错时, 先读错误信息定位原因, 再决定换一种方式; 不要重复执行同一个失败的命令. \n4. 回答用中文, 简洁直接, 不要把大段代码重复贴回来. 完成任务后用一两句话说明改了什么、验证结果如何. \n5. 不要执行破坏性的操作 (例如删除用户数据、强制推送), 除非用户明确要求. \n6. read_file / write_file / edit_file 只处理文本:二进制文件会被拒绝. 看图片用 read_image, 看其它二进制用 run_command (file / xxd / strings). \n\n除非必要, 否则不要执行命令.\n除非用户说明, 不得直接或间接(如使用curl)获取任何在线资源.\n"},{"role":"user","content":"please run it"}],"tools":[{"type":"function","function":{"name":"run_command","description":"在工作目录中执行 shell 命令, 返回合并后的 stdout/stderr 与退出码. 适合编译、运行测试、git 操作等. ","parameters":{"type":"object","properties":{"command":{"type":"string","description":"要执行的 shell 命令 (由 /bin/sh -c 执行, 支持管道与重定向)"},"elevated":{"type":"boolean","description":"置 true 时以 root 执行这一条命令. **只在真的撞到权限错误之后才用**, 不要预防性地加. 加了之后:你会先被要求确认 (看清要跑什么), 再由 sudo 向用户要密码 —— 密码是用户敲的, 你敲不出来. 所以被拒绝时不要反复重试, 说明你需要用户亲自做什么即可. "},"timeout":{"type":"integer","description":"超时秒数, 默认 60, 最大 600"}},"required":["command"]}}},{"type":"function","function":{"name":"read_file","description":"读取文本文件内容, 返回带行号的文本; 行号可以直接喂给 edit_file 的 line. 大文件请用 offset/limit 分段读取. **二进制文件会被拒绝** (它只处理文本). ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径, 相对于工作目录或绝对路径"},"offset":{"type":"integer","description":"起始行号 (从 1 开始), 默认 1"},"limit":{"type":"integer","description":"最多读取的行数, 默认 2000"}},"required":["path"]}}},{"type":"function","function":{"name":"write_file","description":"创建或整体覆盖一个文本文件 (会覆盖原有内容). 修改已有文件优先用 edit_file. 目标已经是二进制文件时会被拒绝覆盖. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径; 父目录不存在会自动创建. **已经存在的二进制文件会被拒绝覆盖**"},"content":{"type":"string","description":"要写入的完整文件内容"}},"required":["path","content"]}}},{"type":"function","function":{"name":"edit_file","description":"在文件中做精确字符串替换. **必须给 line** (read_file 打出来的行号):替换只发生在那一行里, old_string 也只需在那一行里唯一 —— 不必为了\"全文唯一\"把 old_string 写得很长. 行号对不上会报出 old_string 真正在哪几行, 照着改 line 即可. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"要修改的文件路径"},"line":{"type":"integer","description":"old_string 所在的行号, **必填**. 就是 read_file 打出来的那个行号 (从 1 开始)—— 没见过这个文件就先 read_file 看一眼. 替换只会发生在这一行里. "},"old_string":{"type":"string","description":"被替换的原文, 必须与文件内容逐字符一致 (含缩进), 且**在 line 那一行里**只有一处匹配 (匹配可以跨行, 起点落在该行即可)"},"new_string":{"type":"string","description":"替换后的新文本"},"replace_all":{"type":"boolean","description":"为 true 时替换**全文件**里所有匹配 (line 仍必须指向其中真实的一处), 默认 false (只替换 line 那一行上的那一处)"}},"required":["path","line","old_string","new_string"]}}},{"type":"function","function":{"name":"list_dir","description":"列出目录内容, 含文件类型与大小. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"目录路径, 默认当前目录"}},"required":[]}}},{"type":"function","function":{"name":"read_image","description":"读取一张本地图片并把它交给你看 (截图、设计稿、图表、报错弹窗都行). 图片会以 base64 内联进请求, 本机留一份备份、历史里只记对备份的引用. 需要看图片内容时用它, 不要试着用 read_file 去读二进制. 图片超过内联体积上限时会被直接拒绝, 这时请让用户先缩小或压缩. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"本地图片路径. 支持 PNG / JPEG / GIF / WebP (按文件内容判断, 不看扩展名)"}},"required":["path"]}}}],"tool_choice":"auto","stream":true} +{"model":"deepseek-flash","messages":[{"role":"system","content":"你是用户的 AI 助手\n\n你可以调用工具来读写文件和执行命令. \n工作原则: \n1. 先观察再动手: 不确定文件内容或目录结构时, 先用 list_dir / read_file 查看, 不要凭猜测写代码. 搜内容用 run_command (如 grep -rn 模式 目录). \n2. 修改已有文件优先用 edit_file: 它**必须**带上 read_file 显示的行号 line, 替换只发生在那一行里. 只有创建新文件或需要整体重写时才用 write_file. \n3. 工具报错时, 先读错误信息定位原因, 再决定换一种方式; 不要重复执行同一个失败的命令. \n4. 回答用中文, 简洁直接, 不要把大段代码重复贴回来. 完成任务后用一两句话说明改了什么、验证结果如何. \n5. 不要执行破坏性的操作 (例如删除用户数据、强制推送), 除非用户明确要求. \n6. read_file / write_file / edit_file 只处理文本: 二进制文件会被拒绝. 看图片用 read_image, 看其它二进制用 run_command (file / xxd / strings). \n\n除非必要, 否则不要执行命令.\n除非用户说明, 不得直接或间接(如使用curl)获取任何在线资源.\n"},{"role":"user","content":"please run it"}],"tools":[{"type":"function","function":{"name":"run_command","description":"在工作目录中执行 shell 命令, 返回合并后的 stdout/stderr 与退出码. 适合编译、运行测试、git 操作等. ","parameters":{"type":"object","properties":{"command":{"type":"string","description":"要执行的 shell 命令 (由 /bin/sh -c 执行, 支持管道与重定向)"},"elevated":{"type":"boolean","description":"置 true 时以 root 执行这一条命令. **只在真的撞到权限错误之后才用**, 不要预防性地加. 加了之后: 你会先被要求确认 (看清要跑什么), 再由 sudo 向用户要密码 —— 密码是用户敲的, 你敲不出来. 所以被拒绝时不要反复重试, 说明你需要用户亲自做什么即可. "},"timeout":{"type":"integer","description":"超时秒数, 默认 60, 最大 600"}},"required":["command"]}}},{"type":"function","function":{"name":"read_file","description":"读取文本文件内容, 返回带行号的文本; 行号可以直接喂给 edit_file 的 line. 大文件请用 offset/limit 分段读取. **二进制文件会被拒绝** (它只处理文本). ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径, 相对于工作目录或绝对路径"},"offset":{"type":"integer","description":"起始行号 (从 1 开始), 默认 1"},"limit":{"type":"integer","description":"最多读取的行数, 默认 2000"}},"required":["path"]}}},{"type":"function","function":{"name":"write_file","description":"创建或整体覆盖一个文本文件 (会覆盖原有内容). 修改已有文件优先用 edit_file. 目标已经是二进制文件时会被拒绝覆盖. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径; 父目录不存在会自动创建. **已经存在的二进制文件会被拒绝覆盖**"},"content":{"type":"string","description":"要写入的完整文件内容"}},"required":["path","content"]}}},{"type":"function","function":{"name":"edit_file","description":"在文件中做精确字符串替换. **必须给 line** (read_file 打出来的行号): 替换只发生在那一行里, old_string 也只需在那一行里唯一 —— 不必为了\"全文唯一\"把 old_string 写得很长. 行号对不上会报出 old_string 真正在哪几行, 照着改 line 即可. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"要修改的文件路径"},"line":{"type":"integer","description":"old_string 所在的行号, **必填**. 就是 read_file 打出来的那个行号 (从 1 开始)—— 没见过这个文件就先 read_file 看一眼. 替换只会发生在这一行里. "},"old_string":{"type":"string","description":"被替换的原文, 必须与文件内容逐字符一致 (含缩进), 且**在 line 那一行里**只有一处匹配 (匹配可以跨行, 起点落在该行即可)"},"new_string":{"type":"string","description":"替换后的新文本"},"replace_all":{"type":"boolean","description":"为 true 时替换**全文件**里所有匹配 (line 仍必须指向其中真实的一处), 默认 false (只替换 line 那一行上的那一处)"}},"required":["path","line","old_string","new_string"]}}},{"type":"function","function":{"name":"list_dir","description":"列出目录内容, 含文件类型与大小. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"目录路径, 默认当前目录"}},"required":[]}}},{"type":"function","function":{"name":"read_image","description":"读取一张本地图片并把它交给你看 (截图、设计稿、图表、报错弹窗都行). 图片会以 base64 内联进请求, 本机留一份备份、历史里只记对备份的引用. 需要看图片内容时用它, 不要试着用 read_file 去读二进制. 图片超过内联体积上限时会被直接拒绝, 这时请让用户先缩小或压缩. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"本地图片路径. 支持 PNG / JPEG / GIF / WebP (按文件内容判断, 不看扩展名)"}},"required":["path"]}}}],"tool_choice":"auto","stream":true} ----- 响应头 ----- HTTP/1.0 200 OK Server: BaseHTTP/0.6 Python/3.14.7 @@ -29,7 +29,7 @@ data: [DONE] ===== 2026-09-21 17:09:24 ===== 请求: POST http://127.0.0.1:8881/v1/chat/completions ----- 请求体 (5765 字节) ----- -{"model":"deepseek-flash","messages":[{"role":"system","content":"你是用户的 AI 助手\n\n你可以调用工具来读写文件和执行命令. \n工作原则:\n1. 先观察再动手:不确定文件内容或目录结构时, 先用 list_dir / read_file 查看, 不要凭猜测写代码. 搜内容用 run_command (如 grep -rn 模式 目录). \n2. 修改已有文件优先用 edit_file:它**必须**带上 read_file 显示的行号 line, 替换只发生在那一行里. 只有创建新文件或需要整体重写时才用 write_file. \n3. 工具报错时, 先读错误信息定位原因, 再决定换一种方式; 不要重复执行同一个失败的命令. \n4. 回答用中文, 简洁直接, 不要把大段代码重复贴回来. 完成任务后用一两句话说明改了什么、验证结果如何. \n5. 不要执行破坏性的操作 (例如删除用户数据、强制推送), 除非用户明确要求. \n6. read_file / write_file / edit_file 只处理文本:二进制文件会被拒绝. 看图片用 read_image, 看其它二进制用 run_command (file / xxd / strings). \n\n除非必要, 否则不要执行命令.\n除非用户说明, 不得直接或间接(如使用curl)获取任何在线资源.\n"},{"role":"user","content":"please run it"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"run_command","arguments":"{\"command\": \"echo hello-from-tool\"}"}}]},{"role":"tool","tool_call_id":"call_1","content":"hello-from-tool\n[exit code: 0]"}],"tools":[{"type":"function","function":{"name":"run_command","description":"在工作目录中执行 shell 命令, 返回合并后的 stdout/stderr 与退出码. 适合编译、运行测试、git 操作等. ","parameters":{"type":"object","properties":{"command":{"type":"string","description":"要执行的 shell 命令 (由 /bin/sh -c 执行, 支持管道与重定向)"},"elevated":{"type":"boolean","description":"置 true 时以 root 执行这一条命令. **只在真的撞到权限错误之后才用**, 不要预防性地加. 加了之后:你会先被要求确认 (看清要跑什么), 再由 sudo 向用户要密码 —— 密码是用户敲的, 你敲不出来. 所以被拒绝时不要反复重试, 说明你需要用户亲自做什么即可. "},"timeout":{"type":"integer","description":"超时秒数, 默认 60, 最大 600"}},"required":["command"]}}},{"type":"function","function":{"name":"read_file","description":"读取文本文件内容, 返回带行号的文本; 行号可以直接喂给 edit_file 的 line. 大文件请用 offset/limit 分段读取. **二进制文件会被拒绝** (它只处理文本). ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径, 相对于工作目录或绝对路径"},"offset":{"type":"integer","description":"起始行号 (从 1 开始), 默认 1"},"limit":{"type":"integer","description":"最多读取的行数, 默认 2000"}},"required":["path"]}}},{"type":"function","function":{"name":"write_file","description":"创建或整体覆盖一个文本文件 (会覆盖原有内容). 修改已有文件优先用 edit_file. 目标已经是二进制文件时会被拒绝覆盖. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径; 父目录不存在会自动创建. **已经存在的二进制文件会被拒绝覆盖**"},"content":{"type":"string","description":"要写入的完整文件内容"}},"required":["path","content"]}}},{"type":"function","function":{"name":"edit_file","description":"在文件中做精确字符串替换. **必须给 line** (read_file 打出来的行号):替换只发生在那一行里, old_string 也只需在那一行里唯一 —— 不必为了\"全文唯一\"把 old_string 写得很长. 行号对不上会报出 old_string 真正在哪几行, 照着改 line 即可. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"要修改的文件路径"},"line":{"type":"integer","description":"old_string 所在的行号, **必填**. 就是 read_file 打出来的那个行号 (从 1 开始)—— 没见过这个文件就先 read_file 看一眼. 替换只会发生在这一行里. "},"old_string":{"type":"string","description":"被替换的原文, 必须与文件内容逐字符一致 (含缩进), 且**在 line 那一行里**只有一处匹配 (匹配可以跨行, 起点落在该行即可)"},"new_string":{"type":"string","description":"替换后的新文本"},"replace_all":{"type":"boolean","description":"为 true 时替换**全文件**里所有匹配 (line 仍必须指向其中真实的一处), 默认 false (只替换 line 那一行上的那一处)"}},"required":["path","line","old_string","new_string"]}}},{"type":"function","function":{"name":"list_dir","description":"列出目录内容, 含文件类型与大小. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"目录路径, 默认当前目录"}},"required":[]}}},{"type":"function","function":{"name":"read_image","description":"读取一张本地图片并把它交给你看 (截图、设计稿、图表、报错弹窗都行). 图片会以 base64 内联进请求, 本机留一份备份、历史里只记对备份的引用. 需要看图片内容时用它, 不要试着用 read_file 去读二进制. 图片超过内联体积上限时会被直接拒绝, 这时请让用户先缩小或压缩. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"本地图片路径. 支持 PNG / JPEG / GIF / WebP (按文件内容判断, 不看扩展名)"}},"required":["path"]}}}],"tool_choice":"auto","stream":true} +{"model":"deepseek-flash","messages":[{"role":"system","content":"你是用户的 AI 助手\n\n你可以调用工具来读写文件和执行命令. \n工作原则: \n1. 先观察再动手: 不确定文件内容或目录结构时, 先用 list_dir / read_file 查看, 不要凭猜测写代码. 搜内容用 run_command (如 grep -rn 模式 目录). \n2. 修改已有文件优先用 edit_file: 它**必须**带上 read_file 显示的行号 line, 替换只发生在那一行里. 只有创建新文件或需要整体重写时才用 write_file. \n3. 工具报错时, 先读错误信息定位原因, 再决定换一种方式; 不要重复执行同一个失败的命令. \n4. 回答用中文, 简洁直接, 不要把大段代码重复贴回来. 完成任务后用一两句话说明改了什么、验证结果如何. \n5. 不要执行破坏性的操作 (例如删除用户数据、强制推送), 除非用户明确要求. \n6. read_file / write_file / edit_file 只处理文本: 二进制文件会被拒绝. 看图片用 read_image, 看其它二进制用 run_command (file / xxd / strings). \n\n除非必要, 否则不要执行命令.\n除非用户说明, 不得直接或间接(如使用curl)获取任何在线资源.\n"},{"role":"user","content":"please run it"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"run_command","arguments":"{\"command\": \"echo hello-from-tool\"}"}}]},{"role":"tool","tool_call_id":"call_1","content":"hello-from-tool\n[exit code: 0]"}],"tools":[{"type":"function","function":{"name":"run_command","description":"在工作目录中执行 shell 命令, 返回合并后的 stdout/stderr 与退出码. 适合编译、运行测试、git 操作等. ","parameters":{"type":"object","properties":{"command":{"type":"string","description":"要执行的 shell 命令 (由 /bin/sh -c 执行, 支持管道与重定向)"},"elevated":{"type":"boolean","description":"置 true 时以 root 执行这一条命令. **只在真的撞到权限错误之后才用**, 不要预防性地加. 加了之后: 你会先被要求确认 (看清要跑什么), 再由 sudo 向用户要密码 —— 密码是用户敲的, 你敲不出来. 所以被拒绝时不要反复重试, 说明你需要用户亲自做什么即可. "},"timeout":{"type":"integer","description":"超时秒数, 默认 60, 最大 600"}},"required":["command"]}}},{"type":"function","function":{"name":"read_file","description":"读取文本文件内容, 返回带行号的文本; 行号可以直接喂给 edit_file 的 line. 大文件请用 offset/limit 分段读取. **二进制文件会被拒绝** (它只处理文本). ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径, 相对于工作目录或绝对路径"},"offset":{"type":"integer","description":"起始行号 (从 1 开始), 默认 1"},"limit":{"type":"integer","description":"最多读取的行数, 默认 2000"}},"required":["path"]}}},{"type":"function","function":{"name":"write_file","description":"创建或整体覆盖一个文本文件 (会覆盖原有内容). 修改已有文件优先用 edit_file. 目标已经是二进制文件时会被拒绝覆盖. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径; 父目录不存在会自动创建. **已经存在的二进制文件会被拒绝覆盖**"},"content":{"type":"string","description":"要写入的完整文件内容"}},"required":["path","content"]}}},{"type":"function","function":{"name":"edit_file","description":"在文件中做精确字符串替换. **必须给 line** (read_file 打出来的行号): 替换只发生在那一行里, old_string 也只需在那一行里唯一 —— 不必为了\"全文唯一\"把 old_string 写得很长. 行号对不上会报出 old_string 真正在哪几行, 照着改 line 即可. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"要修改的文件路径"},"line":{"type":"integer","description":"old_string 所在的行号, **必填**. 就是 read_file 打出来的那个行号 (从 1 开始)—— 没见过这个文件就先 read_file 看一眼. 替换只会发生在这一行里. "},"old_string":{"type":"string","description":"被替换的原文, 必须与文件内容逐字符一致 (含缩进), 且**在 line 那一行里**只有一处匹配 (匹配可以跨行, 起点落在该行即可)"},"new_string":{"type":"string","description":"替换后的新文本"},"replace_all":{"type":"boolean","description":"为 true 时替换**全文件**里所有匹配 (line 仍必须指向其中真实的一处), 默认 false (只替换 line 那一行上的那一处)"}},"required":["path","line","old_string","new_string"]}}},{"type":"function","function":{"name":"list_dir","description":"列出目录内容, 含文件类型与大小. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"目录路径, 默认当前目录"}},"required":[]}}},{"type":"function","function":{"name":"read_image","description":"读取一张本地图片并把它交给你看 (截图、设计稿、图表、报错弹窗都行). 图片会以 base64 内联进请求, 本机留一份备份、历史里只记对备份的引用. 需要看图片内容时用它, 不要试着用 read_file 去读二进制. 图片超过内联体积上限时会被直接拒绝, 这时请让用户先缩小或压缩. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"本地图片路径. 支持 PNG / JPEG / GIF / WebP (按文件内容判断, 不看扩展名)"}},"required":["path"]}}}],"tool_choice":"auto","stream":true} ----- 响应头 ----- HTTP/1.0 200 OK Server: BaseHTTP/0.6 Python/3.14.7 @@ -51,7 +51,7 @@ data: [DONE] ===== 2026-09-21 17:09:24 ===== 请求: POST http://127.0.0.1:8881/v1/chat/completions ----- 请求体 (5905 字节) ----- -{"model":"deepseek-flash","messages":[{"role":"system","content":"你是用户的 AI 助手\n\n你可以调用工具来读写文件和执行命令. \n工作原则:\n1. 先观察再动手:不确定文件内容或目录结构时, 先用 list_dir / read_file 查看, 不要凭猜测写代码. 搜内容用 run_command (如 grep -rn 模式 目录). \n2. 修改已有文件优先用 edit_file:它**必须**带上 read_file 显示的行号 line, 替换只发生在那一行里. 只有创建新文件或需要整体重写时才用 write_file. \n3. 工具报错时, 先读错误信息定位原因, 再决定换一种方式; 不要重复执行同一个失败的命令. \n4. 回答用中文, 简洁直接, 不要把大段代码重复贴回来. 完成任务后用一两句话说明改了什么、验证结果如何. \n5. 不要执行破坏性的操作 (例如删除用户数据、强制推送), 除非用户明确要求. \n6. read_file / write_file / edit_file 只处理文本:二进制文件会被拒绝. 看图片用 read_image, 看其它二进制用 run_command (file / xxd / strings). \n\n除非必要, 否则不要执行命令.\n除非用户说明, 不得直接或间接(如使用curl)获取任何在线资源.\n"},{"role":"user","content":"please run it"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"run_command","arguments":"{\"command\": \"echo hello-from-tool\"}"}}]},{"role":"tool","tool_call_id":"call_1","content":"hello-from-tool\n[exit code: 0]"},{"role":"assistant","content":"FINAL: hello-from-tool / [exit code: 0]"},{"role":"user","content":"ERROR_TEST 制造一个服务端错误"}],"tools":[{"type":"function","function":{"name":"run_command","description":"在工作目录中执行 shell 命令, 返回合并后的 stdout/stderr 与退出码. 适合编译、运行测试、git 操作等. ","parameters":{"type":"object","properties":{"command":{"type":"string","description":"要执行的 shell 命令 (由 /bin/sh -c 执行, 支持管道与重定向)"},"elevated":{"type":"boolean","description":"置 true 时以 root 执行这一条命令. **只在真的撞到权限错误之后才用**, 不要预防性地加. 加了之后:你会先被要求确认 (看清要跑什么), 再由 sudo 向用户要密码 —— 密码是用户敲的, 你敲不出来. 所以被拒绝时不要反复重试, 说明你需要用户亲自做什么即可. "},"timeout":{"type":"integer","description":"超时秒数, 默认 60, 最大 600"}},"required":["command"]}}},{"type":"function","function":{"name":"read_file","description":"读取文本文件内容, 返回带行号的文本; 行号可以直接喂给 edit_file 的 line. 大文件请用 offset/limit 分段读取. **二进制文件会被拒绝** (它只处理文本). ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径, 相对于工作目录或绝对路径"},"offset":{"type":"integer","description":"起始行号 (从 1 开始), 默认 1"},"limit":{"type":"integer","description":"最多读取的行数, 默认 2000"}},"required":["path"]}}},{"type":"function","function":{"name":"write_file","description":"创建或整体覆盖一个文本文件 (会覆盖原有内容). 修改已有文件优先用 edit_file. 目标已经是二进制文件时会被拒绝覆盖. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径; 父目录不存在会自动创建. **已经存在的二进制文件会被拒绝覆盖**"},"content":{"type":"string","description":"要写入的完整文件内容"}},"required":["path","content"]}}},{"type":"function","function":{"name":"edit_file","description":"在文件中做精确字符串替换. **必须给 line** (read_file 打出来的行号):替换只发生在那一行里, old_string 也只需在那一行里唯一 —— 不必为了\"全文唯一\"把 old_string 写得很长. 行号对不上会报出 old_string 真正在哪几行, 照着改 line 即可. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"要修改的文件路径"},"line":{"type":"integer","description":"old_string 所在的行号, **必填**. 就是 read_file 打出来的那个行号 (从 1 开始)—— 没见过这个文件就先 read_file 看一眼. 替换只会发生在这一行里. "},"old_string":{"type":"string","description":"被替换的原文, 必须与文件内容逐字符一致 (含缩进), 且**在 line 那一行里**只有一处匹配 (匹配可以跨行, 起点落在该行即可)"},"new_string":{"type":"string","description":"替换后的新文本"},"replace_all":{"type":"boolean","description":"为 true 时替换**全文件**里所有匹配 (line 仍必须指向其中真实的一处), 默认 false (只替换 line 那一行上的那一处)"}},"required":["path","line","old_string","new_string"]}}},{"type":"function","function":{"name":"list_dir","description":"列出目录内容, 含文件类型与大小. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"目录路径, 默认当前目录"}},"required":[]}}},{"type":"function","function":{"name":"read_image","description":"读取一张本地图片并把它交给你看 (截图、设计稿、图表、报错弹窗都行). 图片会以 base64 内联进请求, 本机留一份备份、历史里只记对备份的引用. 需要看图片内容时用它, 不要试着用 read_file 去读二进制. 图片超过内联体积上限时会被直接拒绝, 这时请让用户先缩小或压缩. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"本地图片路径. 支持 PNG / JPEG / GIF / WebP (按文件内容判断, 不看扩展名)"}},"required":["path"]}}}],"tool_choice":"auto","stream":true} +{"model":"deepseek-flash","messages":[{"role":"system","content":"你是用户的 AI 助手\n\n你可以调用工具来读写文件和执行命令. \n工作原则: \n1. 先观察再动手: 不确定文件内容或目录结构时, 先用 list_dir / read_file 查看, 不要凭猜测写代码. 搜内容用 run_command (如 grep -rn 模式 目录). \n2. 修改已有文件优先用 edit_file: 它**必须**带上 read_file 显示的行号 line, 替换只发生在那一行里. 只有创建新文件或需要整体重写时才用 write_file. \n3. 工具报错时, 先读错误信息定位原因, 再决定换一种方式; 不要重复执行同一个失败的命令. \n4. 回答用中文, 简洁直接, 不要把大段代码重复贴回来. 完成任务后用一两句话说明改了什么、验证结果如何. \n5. 不要执行破坏性的操作 (例如删除用户数据、强制推送), 除非用户明确要求. \n6. read_file / write_file / edit_file 只处理文本: 二进制文件会被拒绝. 看图片用 read_image, 看其它二进制用 run_command (file / xxd / strings). \n\n除非必要, 否则不要执行命令.\n除非用户说明, 不得直接或间接(如使用curl)获取任何在线资源.\n"},{"role":"user","content":"please run it"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"run_command","arguments":"{\"command\": \"echo hello-from-tool\"}"}}]},{"role":"tool","tool_call_id":"call_1","content":"hello-from-tool\n[exit code: 0]"},{"role":"assistant","content":"FINAL: hello-from-tool / [exit code: 0]"},{"role":"user","content":"ERROR_TEST 制造一个服务端错误"}],"tools":[{"type":"function","function":{"name":"run_command","description":"在工作目录中执行 shell 命令, 返回合并后的 stdout/stderr 与退出码. 适合编译、运行测试、git 操作等. ","parameters":{"type":"object","properties":{"command":{"type":"string","description":"要执行的 shell 命令 (由 /bin/sh -c 执行, 支持管道与重定向)"},"elevated":{"type":"boolean","description":"置 true 时以 root 执行这一条命令. **只在真的撞到权限错误之后才用**, 不要预防性地加. 加了之后: 你会先被要求确认 (看清要跑什么), 再由 sudo 向用户要密码 —— 密码是用户敲的, 你敲不出来. 所以被拒绝时不要反复重试, 说明你需要用户亲自做什么即可. "},"timeout":{"type":"integer","description":"超时秒数, 默认 60, 最大 600"}},"required":["command"]}}},{"type":"function","function":{"name":"read_file","description":"读取文本文件内容, 返回带行号的文本; 行号可以直接喂给 edit_file 的 line. 大文件请用 offset/limit 分段读取. **二进制文件会被拒绝** (它只处理文本). ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径, 相对于工作目录或绝对路径"},"offset":{"type":"integer","description":"起始行号 (从 1 开始), 默认 1"},"limit":{"type":"integer","description":"最多读取的行数, 默认 2000"}},"required":["path"]}}},{"type":"function","function":{"name":"write_file","description":"创建或整体覆盖一个文本文件 (会覆盖原有内容). 修改已有文件优先用 edit_file. 目标已经是二进制文件时会被拒绝覆盖. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径; 父目录不存在会自动创建. **已经存在的二进制文件会被拒绝覆盖**"},"content":{"type":"string","description":"要写入的完整文件内容"}},"required":["path","content"]}}},{"type":"function","function":{"name":"edit_file","description":"在文件中做精确字符串替换. **必须给 line** (read_file 打出来的行号): 替换只发生在那一行里, old_string 也只需在那一行里唯一 —— 不必为了\"全文唯一\"把 old_string 写得很长. 行号对不上会报出 old_string 真正在哪几行, 照着改 line 即可. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"要修改的文件路径"},"line":{"type":"integer","description":"old_string 所在的行号, **必填**. 就是 read_file 打出来的那个行号 (从 1 开始)—— 没见过这个文件就先 read_file 看一眼. 替换只会发生在这一行里. "},"old_string":{"type":"string","description":"被替换的原文, 必须与文件内容逐字符一致 (含缩进), 且**在 line 那一行里**只有一处匹配 (匹配可以跨行, 起点落在该行即可)"},"new_string":{"type":"string","description":"替换后的新文本"},"replace_all":{"type":"boolean","description":"为 true 时替换**全文件**里所有匹配 (line 仍必须指向其中真实的一处), 默认 false (只替换 line 那一行上的那一处)"}},"required":["path","line","old_string","new_string"]}}},{"type":"function","function":{"name":"list_dir","description":"列出目录内容, 含文件类型与大小. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"目录路径, 默认当前目录"}},"required":[]}}},{"type":"function","function":{"name":"read_image","description":"读取一张本地图片并把它交给你看 (截图、设计稿、图表、报错弹窗都行). 图片会以 base64 内联进请求, 本机留一份备份、历史里只记对备份的引用. 需要看图片内容时用它, 不要试着用 read_file 去读二进制. 图片超过内联体积上限时会被直接拒绝, 这时请让用户先缩小或压缩. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"本地图片路径. 支持 PNG / JPEG / GIF / WebP (按文件内容判断, 不看扩展名)"}},"required":["path"]}}}],"tool_choice":"auto","stream":true} ----- 响应头 ----- HTTP/1.0 200 OK Server: BaseHTTP/0.6 Python/3.14.7 diff --git a/tests/fuzz.c b/tests/fuzz.c index 324ede1..78d17b9 100644 --- a/tests/fuzz.c +++ b/tests/fuzz.c @@ -1,19 +1,19 @@ -/* fuzz.c - 对抗性模糊测试:JSON 解析器 + Markdown 渲染器 +/* fuzz.c - 对抗性模糊测试: JSON 解析器 + Markdown 渲染器 * * 这两个模块是"解析不可信输入"的地方 (服务端响应、模型输出), * 也是最可能出崩溃/越界/泄漏的地方. 所以用随机输入去砸它们. * - * 检查的不变量: + * 检查的不变量: * JSON * 1. 任何输入都不能崩溃、不能泄漏 (靠 ASan/UBSan/LeakSanitizer) * 2. 解析成功 => dump 再 parse 再 dump, 两次 dump 必须一致 (幂等) * 3. 随机生成的合法 JSON 必须解析成功并完整往返 * Markdown * 4. 任何输入都不能崩溃、不能泄漏 - * 5. 分片不变性:一次性喂 与 任意分片喂 的输出必须逐字节一致 + * 5. 分片不变性: 一次性喂 与 任意分片喂 的输出必须逐字节一致 * 6. 不含任何元字符的输入必须原样输出 * - * 用法: fuzz [迭代次数] [随机种子] + * 用法: fuzz [迭代次数] [随机种子] */ #include "json.h" #include "markdown.h" @@ -104,7 +104,7 @@ static int fuzz_json_once(char *err, size_t errlen) int mutate = (int)rnd_below(2); if (mutate) { - /* 变异模式:先造合法 JSON, 再随机破坏 */ + /* 变异模式: 先造合法 JSON, 再随机破坏 */ gen_json(&sb, 0); size_t n = sb.len; if (n == 0) { sb_free(&sb); return 0; } @@ -156,7 +156,7 @@ static int fuzz_json_once(char *err, size_t errlen) /* 交给解析器的必须是真正 NUL 结尾的缓冲区 */ if (strlen(input) != inlen) { - snprintf(err, errlen, "测试器自身出错:缓冲区未正确结尾"); + snprintf(err, errlen, "测试器自身出错: 缓冲区未正确结尾"); sb_free(&sb); return 1; } @@ -167,7 +167,7 @@ static int fuzz_json_once(char *err, size_t errlen) char *d2 = NULL; JsonValue *v2 = json_parse(d1, err, errlen); if (!v2) { - snprintf(err, errlen, "解析成功的 JSON 却无法重新解析:%.120s", d1); + snprintf(err, errlen, "解析成功的 JSON 却无法重新解析: %.120s", d1); free(d1); json_free(v); sb_free(&sb); @@ -175,7 +175,7 @@ static int fuzz_json_once(char *err, size_t errlen) } d2 = json_dump(v2); if (strcmp(d1, d2) != 0) { - snprintf(err, errlen, "dump 不幂等:\n 输入 %.200s\n 一次 %.200s\n 二次 %.200s", + snprintf(err, errlen, "dump 不幂等: \n 输入 %.200s\n 一次 %.200s\n 二次 %.200s", input, d1, d2); free(d1); free(d2); json_free(v); json_free(v2); @@ -264,7 +264,7 @@ static int fuzz_md_once(char *err, size_t errlen) char *whole = md_render(src, len, 0); if (!whole) { sb_free(&sb); return 0; } if (strlen(whole) > MD_RENDER_LIMIT * 4) { - snprintf(err, errlen, "输出异常膨胀:输入 %zu 字节 => %zu 字节", + snprintf(err, errlen, "输出异常膨胀: 输入 %zu 字节 => %zu 字节", len, strlen(whole)); free(whole); sb_free(&sb); return 1; @@ -292,7 +292,7 @@ static int fuzz_md_once(char *err, size_t errlen) /* 无元字符 => 必须逐字节原样 */ if (pure) { if (strcmp(whole, src) != 0) { - snprintf(err, errlen, "纯净文本被改写了:\n 输入 %.300s\n 输出 %.300s", + snprintf(err, errlen, "纯净文本被改写了: \n 输入 %.300s\n 输出 %.300s", src, whole); free(whole); sb_free(&sb); return 1; @@ -315,13 +315,13 @@ int main(int argc, char **argv) char err[512]; long json_fail = 0, md_fail = 0; - printf("模糊测试:JSON %ld 轮 + Markdown %ld 轮 (种子 %llu)\n", + printf("模糊测试: JSON %ld 轮 + Markdown %ld 轮 (种子 %llu)\n", iters, iters, (unsigned long long)RNG); for (long i = 0; i < iters; i++) { err[0] = '\0'; if (fuzz_json_once(err, sizeof err)) { - printf(" ✗ JSON 第 %ld 轮失败:%s\n", i, err); + printf(" ✗ JSON 第 %ld 轮失败: %s\n", i, err); if (++json_fail >= 3) break; } } @@ -330,7 +330,7 @@ int main(int argc, char **argv) for (long i = 0; i < iters; i++) { err[0] = '\0'; if (fuzz_md_once(err, sizeof err)) { - printf(" ✗ Markdown 第 %ld 轮失败:%s\n", i, err); + printf(" ✗ Markdown 第 %ld 轮失败: %s\n", i, err); if (++md_fail >= 3) break; } } diff --git a/tests/mock_server.py b/tests/mock_server.py index 8d13c50..82a9c5e 100644 --- a/tests/mock_server.py +++ b/tests/mock_server.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """mock_server.py - 最小的 OpenAI 兼容服务端, 用于端到端测试 ligent. -不依赖任何第三方包. 行为完全由请求内容决定, 保持确定性: +不依赖任何第三方包. 行为完全由请求内容决定, 保持确定性: - 用户消息含 ERROR_TEST -> 返回 HTTP 400 + error 对象 - 尚未有 tool 结果 -> 流式返回一个 tool_call (run_command echo ...) @@ -25,7 +25,7 @@ def sse(wfile, obj): def pairing_error(msgs): - """服务端的硬要求:带 tool_calls 的 assistant 后面必须**紧跟**与每个 + """服务端的硬要求: 带 tool_calls 的 assistant 后面必须**紧跟**与每个 tool_call_id 一一对应的 tool 消息, 中间不能夹别的东西. 真实服务端就是这么回 400 的, mock 也照做 —— 这样任何会写坏历史的回归 @@ -120,7 +120,7 @@ class Handler(BaseHTTPRequestHandler): def do_GET(self): # doctor 的连通性/重定向检查走 GET. # /redirect 开头故意返回 307, 用来验证客户端能把重定向指出来. - # doctor 的认证探测:端点要求认证, 且只认 "Bearer good-key". + # doctor 的认证探测: 端点要求认证, 且只认 "Bearer good-key". # 带对 key -> 200 (认证过了) # 带错 key -> 401 (密钥被拒) # 不带 key -> 401 (端点要求认证, 而你没配) @@ -140,7 +140,7 @@ class Handler(BaseHTTPRequestHandler): # MODEL_LIST=bare 时改回裸数组, MODEL_LIST=empty 时不返回任何模型, # 两种兼容/异常形状都由 e2e 覆盖. if "/models" in self.path: - # 模式从**文件**读, 不从环境变量读:mock 是脚本开头起好的, + # 模式从**文件**读, 不从环境变量读: mock 是脚本开头起好的, # 给客户端进程设的 env 它根本看不见 (第一版就是这么假通过的). mode = "" mode_file = os.environ.get("MODELS_MODE_FILE", "") @@ -157,7 +157,7 @@ class Handler(BaseHTTPRequestHandler): elif mode == "garbage": payload = b"not json at all" else: - # 默认列表里**故意包含当前模型** (deepseek-flash): + # 默认列表里**故意包含当前模型** (deepseek-flash): # 这样才验得了"光标初始落在当前项上". # 另外塞一个重复项和一个缺 id 的坏条目, 验去重与跳过. payload = json.dumps({"object": "list", "data": [ @@ -191,7 +191,7 @@ class Handler(BaseHTTPRequestHandler): length = int(self.headers.get("Content-Length", "0")) raw = self.rfile.read(length) - # ---- UTF-8 严格校验:真服务端就是这么拒的 ---- + # ---- UTF-8 严格校验: 真服务端就是这么拒的 ---- # DeepSeek 对请求体里非法的 UTF-8 回的是 # 400 Failed to parse the request body as JSON: # messages[43].content: invalid unicode code point @@ -208,7 +208,7 @@ class Handler(BaseHTTPRequestHandler): return # ---- 假的 Files API ---- - # 只做够用的部分:认 multipart 里有没有图片内容, 发一个递增的 file_id. + # 只做够用的部分: 认 multipart 里有没有图片内容, 发一个递增的 file_id. try: body = json.loads(raw or b"{}") except Exception: @@ -237,7 +237,7 @@ class Handler(BaseHTTPRequestHandler): tool_msgs = [m for m in messages if m.get("role") == "tool"] tools_sent = body.get("tools") - # ---- 结构校验:和真服务端一样严 ---- + # ---- 结构校验: 和真服务端一样严 ---- perr = pairing_error(messages) if perr: self.reject(400, perr) @@ -246,7 +246,7 @@ class Handler(BaseHTTPRequestHandler): # ---- 能力探测序列 ---- # ligent 会在第一次真实请求之前发几个试探请求 (基线 / 图片 / tools), # 它们会被真实处理, 所以这里必须排在所有标记分支之前. - # 辨认靠形状:刚好一条 user 消息, 内容是 "1", 或者带一张 1x1 的图. + # 辨认靠形状: 刚好一条 user 消息, 内容是 "1", 或者带一张 1x1 的图. def looks_like_probe(msgs): if len(msgs) != 1 or msgs[0].get("role") != "user": return False @@ -264,7 +264,7 @@ class Handler(BaseHTTPRequestHandler): if looks_like_probe(messages): has_image = isinstance(messages[0].get("content"), list) - # 用模型名模拟"不支持某能力"的服务端:400 是明确的拒绝信号 + # 用模型名模拟"不支持某能力"的服务端: 400 是明确的拒绝信号 if "no-vision" in model and has_image: return self.reject(400, "unsupported content type: image_url") if "no-tools" in model and tools_sent: @@ -274,7 +274,7 @@ class Handler(BaseHTTPRequestHandler): self.send_text("1", stream, model) return - # 提权:让模型发一个带 elevated 的 run_command + # 提权: 让模型发一个带 elevated 的 run_command if "ELEVATED_TEST" in first_user: if not tool_msgs: args = json.dumps({"command": "echo 提权后的输出", "elevated": True}) @@ -293,7 +293,7 @@ class Handler(BaseHTTPRequestHandler): self.send_text("ELEV_SAW[%s]" % got.replace("\n", " | "), stream, model) return - # /include:把收到的 user 内容块报回来. + # /include: 把收到的 user 内容块报回来. # 用 first_user 判断——附带之后最新那条 user 是带块的消息, last_user 会漂. if "INCLUDE_TEST" in first_user: texts, imgs, names = 0, 0, [] @@ -330,7 +330,7 @@ class Handler(BaseHTTPRequestHandler): stream, model) return - # 权限/参数校验:客户端必须把 tools 传上来 + # 权限/参数校验: 客户端必须把 tools 传上来 assert tools_sent, "请求里没有 tools 字段" if "ERROR_TEST" in first_user: @@ -384,7 +384,7 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(payload) return - # 复刻 OpenRouter 对"key 格式不对"的回应:头其实带了, + # 复刻 OpenRouter 对"key 格式不对"的回应: 头其实带了, # 但文案说的是 "Missing Authentication header", 容易把人引偏 if "AUTHFAIL_TEST" in first_user: payload = json.dumps({"error": {"message": "Missing Authentication header", @@ -396,7 +396,7 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(payload) return - # 重定向:用于验证客户端不静默跟随 (跨源跟随会丢掉 Authorization, + # 重定向: 用于验证客户端不静默跟随 (跨源跟随会丢掉 Authorization, # 服务端只会返回 401 "Missing Authentication header", 误导用户去查 key) if "REDIRECT_TEST" in first_user: self.send_response(307) @@ -443,7 +443,7 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(payload) return - # 超过客户端响应体上限的超大响应:客户端应当主动中止而不是被撑爆 + # 超过客户端响应体上限的超大响应: 客户端应当主动中止而不是被撑爆 if "HUGE_TEST" in first_user: total = 40 * 1024 * 1024 self.send_response(200) @@ -459,7 +459,7 @@ class Handler(BaseHTTPRequestHandler): pass # 客户端主动断开是预期行为 return - # 畸形 SSE:各种不合法的片段, 客户端必须不崩溃且给出合理结果 + # 畸形 SSE: 各种不合法的片段, 客户端必须不崩溃且给出合理结果 if "GARBAGE_TEST" in first_user: self.send_response(200) self.send_header("Content-Type", "text/event-stream") @@ -479,7 +479,7 @@ class Handler(BaseHTTPRequestHandler): self.wfile.flush() return - # 传输中途断掉:声明了 Content-Length 却提前关闭连接 + # 传输中途断掉: 声明了 Content-Length 却提前关闭连接 if "TRUNCATE_TEST" in first_user: self.send_response(200) self.send_header("Content-Type", "text/event-stream") @@ -537,7 +537,7 @@ class Handler(BaseHTTPRequestHandler): self.wfile.flush() return - # 思考模式:验证 reasoning_content 既能被累积, 也能随 assistant 消息 + # 思考模式: 验证 reasoning_content 既能被累积, 也能随 assistant 消息 # 回传. DeepSeek 要求请求带 tools 时把历史里的 reasoning_content 全带上, # 漏了下一轮就 400, 所以这里第二轮专门检查它有没有回来. if "THINK_TEST" in first_user: @@ -562,7 +562,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Content-Type", "text/event-stream") self.send_header("Cache-Control", "no-cache") self.end_headers() - # 思考过程分片下发:客户端必须累积, 而不是只留最后一片 + # 思考过程分片下发: 客户端必须累积, 而不是只留最后一片 for part in ("先想一下", "要不要调用工具. "): sse(self.wfile, {"choices": [{"index": 0, "delta": {"reasoning_content": part}, @@ -587,8 +587,8 @@ class Handler(BaseHTTPRequestHandler): body.get("reasoning_effort") or "none"), stream, model) return - # 慢速响应:先静默一段 (网络/排队), 再吐思维链, 再静默一段, - # 最后给正文. 用来验证等待动画的两段标签:"等待响应" → "思考中". + # 慢速响应: 先静默一段 (网络/排队), 再吐思维链, 再静默一段, + # 最后给正文. 用来验证等待动画的两段标签: "等待响应" → "思考中". # 名字刻意不含 "THINK_TEST" —— 上面那些标记都是按子串匹配的, # 叫 SLOWTHINK_TEST 会被 THINK_TEST 那个分支先截胡. if "SLOWRESP_TEST" in first_user: @@ -604,7 +604,7 @@ class Handler(BaseHTTPRequestHandler): self.send_text("答案", stream=True, model=model) return - # meta 模式:第一轮让 meta 模型调 delete_rounds 删掉第 2 轮, + # meta 模式: 第一轮让 meta 模型调 delete_rounds 删掉第 2 轮, # 第二轮收工. 用来端到端验证 /meta 的完整链路. if "META_TEST" in first_user: if not tool_msgs: @@ -626,7 +626,7 @@ class Handler(BaseHTTPRequestHandler): self.send_text("已删除第 2 轮. ", stream, model) return - # 按"最后一条用户消息"触发:上面那些模式都看 first_user, + # 按"最后一条用户消息"触发: 上面那些模式都看 first_user, # 同一个进程里的第二轮就再也触发不了了, 没法验证"作用域仅限本轮". if "WRITE_LAST_TEST" in last_user: if messages[-1].get("role") == "user": # 新一轮的开头 @@ -656,7 +656,7 @@ class Handler(BaseHTTPRequestHandler): self.send_text("第二轮写完了", stream, model) return - # 发出一个 write_file 调用:用来验证"在确认提示上按 Ctrl-C"到底 + # 发出一个 write_file 调用: 用来验证"在确认提示上按 Ctrl-C"到底 # 会不会把文件写下去 if "WRITE_TEST" in first_user: if not tool_msgs: @@ -686,7 +686,7 @@ class Handler(BaseHTTPRequestHandler): self.send_text("写完了", stream, model) return - # 读一个**含非法 UTF-8 字节**的文件. 工具输出里出现坏字节的来源很日常: + # 读一个**含非法 UTF-8 字节**的文件. 工具输出里出现坏字节的来源很日常: # 某条命令在非 UTF-8 locale 下打出来的路径、被截断的多字节字符、二进制 # 文件里混的一小段文本. 这段字节会进对话历史, 然后被序列化进请求体 —— # 如果序列化器原样吐出, 服务端就会 400, 而且之后每次请求都 400. @@ -715,7 +715,7 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(b"data: [DONE]\n\n") self.wfile.flush() return - # 第二轮:能走到这里就说明请求体过了上面的 UTF-8 校验. + # 第二轮: 能走到这里就说明请求体过了上面的 UTF-8 校验. # 把工具消息里有没有替换字符也顺带报出来, 便于断言. got = "" for m in tool_msgs: @@ -725,8 +725,8 @@ class Handler(BaseHTTPRequestHandler): self.send_text("读完了, 替换字符=%d" % got.count("\ufffd"), stream, model) return - # 一轮里连写两个文件:用来验证 Always/neVer 的作用域就是"这一轮" - # (名字刻意不含 "WRITE_TEST", 免得被上面那个分支先截胡) + # 一轮里连写两个文件: 用来验证 Always/neVer 的作用域就是"这一轮" + # (名字刻意不含 "WRITE_TEST", 以免被上面那个分支先截胡) if "WRITE2_TEST" in first_user: n = len(tool_msgs) if n < 2: @@ -759,8 +759,8 @@ class Handler(BaseHTTPRequestHandler): # 让模型去读一个"扩展名是 .png、内容不是图片"的文件, # 验证 read_image 按内容判断、并明确拒绝 - # 两种"读不了":内容不是图片、以及大得内联不进去. - # 注意:mock 的标记靠子串匹配, 是平铺的命名空间 —— 上面第 249 行的 + # 两种"读不了": 内容不是图片、以及大得内联不进去. + # 注意: mock 的标记靠子串匹配, 是平铺的命名空间 —— 上面第 249 行的 # HUGE_TEST 分支会吃掉任何含 "HUGE_TEST" 的标记, 所以这里叫 OVERSIZE. # 两者都该在**本地**被拒 —— 工具返回错误, 之后就没有图片块可发. if "IMAGE_BAD_TEST" in last_user or "IMAGE_OVERSIZE_TEST" in last_user: @@ -780,9 +780,9 @@ class Handler(BaseHTTPRequestHandler): return # 一条 assistant 里发**两个**工具调用, 第一个是 read_image. - # 这是"图片附件插在两条 tool 消息中间"那个 bug 的最小复现形状: + # 这是"图片附件插在两条 tool 消息中间"那个 bug 的最小复现形状: # 只要附件没被攒到最后一起推, 上面的结构校验就会回 400. - # 注意用 first_user:图片附件本身也是一条 user 消息, 而且排在最后, + # 注意用 first_user: 图片附件本身也是一条 user 消息, 而且排在最后, # 所以第二轮请求的 last_user 是那个附件 (一个 list), 不是这条标记. if "IMAGE_PAIR_TEST" in first_user: if not tool_msgs: @@ -808,7 +808,7 @@ class Handler(BaseHTTPRequestHandler): % (len(found), len(tool_msgs)), stream, model) return - # 图片:先让模型调一次 read_image, 下一轮把收到的图片内联块报回来. + # 图片: 先让模型调一次 read_image, 下一轮把收到的图片内联块报回来. # Files API 已经不用了 —— 请求里应该直接是 image_url + data: base64. if "IMAGE_TEST" in first_user: if not tool_msgs: @@ -835,8 +835,8 @@ class Handler(BaseHTTPRequestHandler): self.wfile.flush() return - # 第二轮:请求里应该已经带上内联图片了. - # 报文形状:{"type":"image_url","image_url":{"url":"data:image/png;base64,..."}} + # 第二轮: 请求里应该已经带上内联图片了. + # 报文形状: {"type":"image_url","image_url":{"url":"data:image/png;base64,..."}} found, notes = [], [] for m in messages: c = m.get("content") @@ -856,7 +856,7 @@ class Handler(BaseHTTPRequestHandler): elif part.get("type") == "text" and m.get("role") == "user": notes.append(part.get("text", "")) - # IMAGE_REJECT_TEST:第一次故意说服务端不收这张图, + # IMAGE_REJECT_TEST: 第一次故意说服务端不收这张图, # 客户端应该把图片从历史里摘掉再试 (而不是每轮都失败) if "IMAGE_REJECT_TEST" in last_user and not STATE.get("told_once"): STATE["told_once"] = 1 @@ -874,7 +874,7 @@ class Handler(BaseHTTPRequestHandler): self.send_text(reply, stream, model) return - # edit_file 的**调用展示**:成功时屏幕上该是"路径 + 行号"加 -/+ 新旧对照, + # edit_file 的**调用展示**: 成功时屏幕上该是"路径 + 行号"加 -/+ 新旧对照, # 而不是一整段参数 JSON (old_string/new_string 原样打出来会刷屏). # EDITTEST_OK 成功剧本, EDITTEST_FAIL 行号越界剧本. if "EDITTEST_OK" in first_user or "EDITTEST_FAIL" in first_user: @@ -883,7 +883,7 @@ class Handler(BaseHTTPRequestHandler): stream, model) return line = 999 if "EDITTEST_FAIL" in first_user else 2 - # 12 行的 old_string:既覆盖"多行改动"的显示, 也覆盖"超过 8 行要折叠" + # 12 行的 old_string: 既覆盖"多行改动"的显示, 也覆盖"超过 8 行要折叠" old = "\n".join("OLD%d" % i for i in range(1, 13)) if line == 2 else "OLD" args = json.dumps({"path": "build/tmp/edit_probe.txt", "line": line, "old_string": old, "new_string": "NEW"}) @@ -909,7 +909,7 @@ class Handler(BaseHTTPRequestHandler): self.wfile.flush() return - # 多步 (4 次工具往返才收工):用来钉住"往返次数默认不限". + # 多步 (4 次工具往返才收工): 用来钉住"往返次数默认不限". # 默认配置下必须跑得完; 显式设了正数上限时要停在那个上限上并如实报出. if "MANYSTEPS_TEST" in first_user: done = len(tool_msgs) diff --git a/tests/reduce.c b/tests/reduce.c index 8cce0e8..a67e6bc 100644 --- a/tests/reduce.c +++ b/tests/reduce.c @@ -1,7 +1,7 @@ /* reduce.c - 把 fuzz 找到的失败输入最小化到最短复现样例 * - * 判定 (oracle):同一段输入, "一次性喂" 与 "逐字节喂" 的渲染结果不一致. - * 用法: build/reduce build/md_fail.bin + * 判定 (oracle): 同一段输入, "一次性喂" 与 "逐字节喂" 的渲染结果不一致. + * 用法: build/reduce build/md_fail.bin */ #include "markdown.h" #include "util.h" @@ -9,7 +9,7 @@ #include #include -/* 注意:md_feed 只接受 NUL 结尾的字符串, 而最小化过程中的缓冲区 +/* 注意: md_feed 只接受 NUL 结尾的字符串, 而最小化过程中的缓冲区 * 是"长度受限"的, 所以必须先做一份带结尾符的副本. */ static char *render(const char *src, size_t len, size_t chunk) { diff --git a/tests/repl_drive.py b/tests/repl_drive.py index 3b6e192..bac307c 100644 --- a/tests/repl_drive.py +++ b/tests/repl_drive.py @@ -4,7 +4,7 @@ 交互式行为 (多轮上下文、/命令、行编辑)没法用管道测 —— 管道模式会把 整段 stdin 当成一个任务. 所以这里开一个伪终端, 逐条发送提示词. -用法: +用法: repl_drive.py --bin ./ligent --base-url URL --prompt "第一轮" --prompt "第二轮" """ import argparse @@ -40,7 +40,7 @@ def drive(binary, args, prompts, wait_per_prompt=2.0, quiet_after=1.5, def drain(timeout, idle=0.5, until_ready=False): """等到"回到提示符" (until_ready), 或者输出彻底安静下来. - 必须按标记等, 不能只按安静等:等待动画每 250ms 画一帧, pty 永远 + 必须按标记等, 不能只按安静等: 等待动画每 250ms 画一帧, pty 永远 不会安静超过 idle, 于是每次都要白等满 timeout —— 而 mock 其实几 毫秒就答复了. 这是整个 e2e 从 126 秒掉下来的关键. """ @@ -115,7 +115,7 @@ def main(): help="原始按键序列 (支持 \\x1b 转义), 按顺序发送") a = ap.parse_args() - # --no-history:被驱动的实例不该往用户真实的 ligent.history 里写测试垃圾. + # --no-history: 被驱动的实例不该往用户真实的 ligent.history 里写测试垃圾. # 需要历史的调用方用 --extra=--history PATH (排在后面, 会覆盖这一条). args = ["--api-key", a.api_key, "--base-url", a.base_url, "--no-color", "--no-confirm", "--no-auto-save", "--no-history"] + a.extra diff --git a/tests/repl_edit_check.py b/tests/repl_edit_check.py index 9e4903a..50a08e8 100755 --- a/tests/repl_edit_check.py +++ b/tests/repl_edit_check.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """repl_edit_check.py - 验证 REPL 的行编辑行为 (方向键、Home/End、退格、历史) -只抓字节流是不够的:光标移动是靠转义序列实现的, 必须把输出回放到一个 +只抓字节流是不够的: 光标移动是靠转义序列实现的, 必须把输出回放到一个 终端模型里才能知道"屏幕上最终长什么样". 所以这里带一个极简终端模拟器, 只实现本编辑器用到的那几条控制序列. """ @@ -18,7 +18,7 @@ ROWS = 200 class Term: - """极简终端模型:只实现 \\r \\n \\b、CSI 光标移动、CSI J/K 清屏""" + """极简终端模型: 只实现 \\r \\n \\b、CSI 光标移动、CSI J/K 清屏""" def __init__(self, cols=COLS, rows=ROWS): self.cols, self.rows = cols, rows @@ -100,7 +100,7 @@ class Term: def to_bytes(s): # 把源码里写的十六进制转义与真实非 ASCII 字符一起转成字节. - # 不能直接用 unicode_escape:一条串里既有转义又有中文时, latin-1 那步会报错. + # 不能直接用 unicode_escape: 一条串里既有转义又有中文时, latin-1 那步会报错. if isinstance(s, bytes): return s out = bytearray() @@ -147,7 +147,7 @@ def run(binary, args, keys, keyset_settle=0.10, settle=0.45, idle=0.03): def drain(timeout, idle=idle, until_ready=False): """安静下来就返回, 不把整个 timeout 睡满. - idle 取得很小 (0.03s)是有依据的:行编辑器每次按键都在一个 fflush + idle 取得很小 (0.03s)是有依据的: 行编辑器每次按键都在一个 fflush 里把整屏重绘写完, 所以一次 read 之后基本就没了. 而提交输入触发请求 时, 等待动画每 250ms 一帧, 永远不会安静, 于是照样等到 timeout —— 那条路径的行为和以前一致, 不会因为 idle 调小而抢跑. @@ -179,7 +179,7 @@ def run(binary, args, keys, keyset_settle=0.10, settle=0.45, idle=0.03): drain(settle) # 刻意不发 Ctrl-C (它会在光标处打出 ^C 标记并覆盖两个字), - # 直接关掉 pty:最后那次重绘就是缓冲区的真值 + # 直接关掉 pty: 最后那次重绘就是缓冲区的真值 try: os.close(fd) except OSError: @@ -194,7 +194,7 @@ def run(binary, args, keys, keyset_settle=0.10, settle=0.45, idle=0.03): def wait_ready(fd, timeout=3.0): """等到行编辑器回到提示符 (它会打开括号粘贴). - 裸 pty 会话里不要用 time.sleep 猜时间:猜短了会 flaky, 猜长了每处都在 + 裸 pty 会话里不要用 time.sleep 猜时间: 猜短了会 flaky, 猜长了每处都在 白等. 进程退出 (EOF)也算等到. """ READY = b"\x1b[?2004h" end = time.time() + timeout @@ -226,19 +226,19 @@ def main(): binary = sys.argv[1] if len(sys.argv) > 1 else './ligent' base = sys.argv[2] if len(sys.argv) > 2 else None # 编辑测试不会真的发请求, 但程序在缺 key 时会直接退出, 所以必须给一个 - # 默认关掉确认与自动存档:它们会打断被测行为. + # 默认关掉确认与自动存档: 它们会打断被测行为. # 专门测确认的用例用 --extra 显式打开. args = ['--no-color', '--no-confirm', '--no-auto-save', '--api-key', 'test', '--base-url', base or 'http://127.0.0.1:1/v1'] - # 被驱动的实例会读写输入历史. 整轮都指到临时文件:既不往用户真实的 + # 被驱动的实例会读写输入历史. 整轮都指到临时文件: 既不往用户真实的 # ligent.history 里写测试垃圾, 方向键用例的结果也不会取决于开发者机器 # 上碰巧有什么历史. scratch = os.path.join(tempfile.gettempdir(), "ligent_edit_hist_%d" % os.getpid()) os.environ["LIGENT_HISTORY"] = scratch cases = [] - # 方向键左移后插入:光标必须落在中间 + # 方向键左移后插入: 光标必须落在中间 cases.append(("左方向键移动光标后插入", ['hello world'] + [LEFT] * 5 + ['X'], 'ligent> hello Xworld')) @@ -274,7 +274,7 @@ def main(): 'ligent> cd')) PASTE = '\\x1b[200~' PASTE_END = '\\x1b[201~' - cases.append(("粘贴多行:整段插入, 不逐行提交", + cases.append(("粘贴多行: 整段插入, 不逐行提交", [PASTE + 'line1\nline2\nline3' + PASTE_END], ['ligent> line1', 'line2', 'line3'])) cases.append(("粘贴后可继续编辑", @@ -294,7 +294,7 @@ def main(): 'ligent> 中文Xabc')) # 粘贴进来的原始字节里可能夹着控制字符. read_paste 是原样收下的 (不走 - # classify), 所以必须清洗:终端按制表位展开 \t, 编辑器只当 1 列; + # classify), 所以必须清洗: 终端按制表位展开 \t, 编辑器只当 1 列; # ESC 会被终端真的执行掉; \r 把光标拉回行首. cases.append(("粘贴里的制表符按制表位展开", [PASTE + 'a\\tb' + PASTE_END, 'X'], @@ -338,15 +338,15 @@ def main(): # 上面的用例都带 --no-color, 所以这里单独补一组彩色的. color_args = [a for a in args if a != '--no-color'] + ['--color'] color_cases = [ - ("彩色提示符:左移后插入", + ("彩色提示符: 左移后插入", ['hello world'] + [LEFT] * 5 + ['X'], 'ligent> hello Xworld'), - ("彩色提示符:Home 后插入", + ("彩色提示符: Home 后插入", ['world', '\\x01', 'hello '], 'ligent> hello world'), - ("彩色提示符:End 后追加", + ("彩色提示符: End 后追加", ['abc', '\\x05', '!'], 'ligent> abc!'), - ("彩色提示符:中文按字符移动", + ("彩色提示符: 中文按字符移动", ['中文abc'] + [LEFT] * 3 + ['X'], 'ligent> 中文Xabc'), - ("彩色提示符:退格", + ("彩色提示符: 退格", ['abcd', '\\x7f'], 'ligent> abc'), ] for name, keys, want in color_cases: @@ -363,7 +363,7 @@ def main(): print(" 实际: %r" % got) # 上面那些用例只比屏幕内容, 而颜色在终端模型里是被剥掉的 —— 包没包住 - # 输入的字看不出来. 所以这里直接查字节:绿色区间必须一直延续到用户敲的 + # 输入的字看不出来. 所以这里直接查字节: 绿色区间必须一直延续到用户敲的 # 内容之后. 只包提示符的话, 提示符自带的 reset 会让输入掉回默认色. raw = run(binary, color_args, ['greenme']) txt = raw.decode('utf-8', 'replace') @@ -378,7 +378,7 @@ def main(): print(" ✗ 输入的字也被包在绿色里") print(" 绿色区间: %r" % inside) - # 粘贴多行后提交:必须作为"一条"消息发出去 (回显里能看到内嵌换行) + # 粘贴多行后提交: 必须作为"一条"消息发出去 (回显里能看到内嵌换行) if base: raw = run(binary, args, [PASTE + 'ECHO_TEST 第一行\n第二行\n第三行' + PASTE_END, '\\r'], @@ -393,7 +393,7 @@ def main(): print(" ✗ 粘贴的多行作为一条消息提交") print(" 屏幕尾部: %r" % term.nonempty()[-6:]) - # 上下方向键取历史:需要真的提交一行, 所以只有在给了服务端时才测 + # 上下方向键取历史: 需要真的提交一行, 所以只有在给了服务端时才测 if base: raw = run(binary, args, ['recalldemo', '\\r'], keyset_settle=0.8, settle=0.6, idle=0.5) @@ -412,15 +412,15 @@ def main(): print(" 提示符行: %r" % lines[-3:]) cases.append(("history", [], "ligent> firstline")) - # 历史要跨进程:上一个实例敲过的, 新实例按上方向键就能翻到. - # 用独立的 LIGENT_HISTORY, 免得受其他用例和真实历史文件影响. + # 历史要跨进程: 上一个实例敲过的, 新实例按上方向键就能翻到. + # 用独立的 LIGENT_HISTORY, 以免受其他用例和真实历史文件影响. hist = os.path.join(tempfile.gettempdir(), "ligent_hist_check_%d" % os.getpid()) for p in (hist, hist + ".tmp"): if os.path.exists(p): os.unlink(p) os.environ["LIGENT_HISTORY"] = hist try: - # 实时性:进程还活着、还没退出, 文件里就该已经有这条了 + # 实时性: 进程还活着、还没退出, 文件里就该已经有这条了 pid, fd = pty.fork() if pid == 0: os.environ['TERM'] = 'xterm-256color' @@ -478,7 +478,7 @@ def main(): term = Term() term.feed(raw.decode('utf-8', 'replace')) prompts = [l for l in term.nonempty() if l.startswith('ligent> ')] - # 期望:Ctrl-C 丢掉了 "garbage", 之后那一行只含 "typed" + # 期望: Ctrl-C 丢掉了 "garbage", 之后那一行只含 "typed" last = prompts[-1] if prompts else '' ok = len(prompts) >= 2 and 'garbage' not in last and 'typed' in last if not ok: diff --git a/tests/replay.py b/tests/replay.py index ad8a7ad..8b0c6d6 100644 --- a/tests/replay.py +++ b/tests/replay.py @@ -1,25 +1,25 @@ #!/usr/bin/env python3 """replay.py - 把 /log 录下来的真实会话当成测试夹具回放. -为什么需要它:ligent 的协议适配一直只在自建的 mock 服务端上验证过, 而 mock +为什么需要它: ligent 的协议适配一直只在自建的 mock 服务端上验证过, 而 mock 是我们自己写的 —— 它接受什么, 完全取决于**我们以为**服务端会接受什么. 真实 服务端的差异是最大的未知数. /log 导出的是**原始字节** (请求体、响应头、未经解析的响应原文, 流式就是原始 SSE), 正好够原样伺候回去. 所以这里不重写一个"模拟客户端", 而是当**离线 -服务端**:把录下来的响应按顺序发给一个真的 ligent 实例, 于是 http.c 的分块 +服务端**: 把录下来的响应按顺序发给一个真的 ligent 实例, 于是 http.c 的分块 读取、llm.c 的 SSE 行切分与 tool_calls 增量重组, 全都在真实字节上跑. 用法: replay.py --summary FILE 这份录制里有什么 replay.py --serve PORT FILE [--strict] 当离线服务端 - replay.py --check FILE 自检:能不能解析出结构完整的往返 + replay.py --check FILE 自检: 能不能解析出结构完整的往返 --strict 会把每个进来的请求体与录制时那一份做**结构比对** (顶层键、消息角色 序列、工具名集合), 不一致就回 400 并在 stderr 打印差异. 这是这个工具最有用 -的一半:它把"今天的客户端还在发同样的报文形状"变成一条会失败的断言. +的一半: 它把"今天的客户端还在发同样的报文形状"变成一条会失败的断言. -注意回放时要给 ligent 加 --no-probe:能力探测会插进额外的请求, 而那几轮 +注意回放时要给 ligent 加 --no-probe: 能力探测会插进额外的请求, 而那几轮 不在录制里. 录制时也建议关掉 (LIGENT_PROBE=0), 两边一致. """ import argparse @@ -34,7 +34,7 @@ import threading def parse_log(text): """把一份 /log 导出切成若干次 HTTP 往返. - 格式 (见 http.c 的记录点): + 格式 (见 http.c 的记录点): ===== ===== 请求: POST ----- 请求体 (N 字节) ----- @@ -67,7 +67,7 @@ def parse_log(text): ex["url"] = parts[1] if len(parts) > 1 else "" i += 1 - # 请求体:按标记里给出的字节数精确取, 比按行取稳 + # 请求体: 按标记里给出的字节数精确取, 比按行取稳 if i < len(lines) and lines[i].startswith("----- 请求体 ("): n = int(lines[i].split("(")[1].split(" ")[0]) i += 1 @@ -91,7 +91,7 @@ def parse_log(text): i += 1 i += 1 # 跳过空行 - # 响应原文:直到"结束"行 + # 响应原文: 直到"结束"行 start = i while i < len(lines) and not lines[i].startswith("----- 结束: "): i += 1 @@ -112,7 +112,7 @@ def parse_log(text): def shape_of(req_bytes): - """一个请求体的**结构指纹**:只看形状, 不看内容. + """一个请求体的**结构指纹**: 只看形状, 不看内容. 内容每轮都不一样 (历史在长), 但形状必须稳定 —— 顶层键、消息角色序列、 工具名集合. 客户端一旦改了报文形状, 这里就会显示出来. @@ -173,7 +173,7 @@ class ReplayServer: if want != got: self.mismatch += 1 sys.stderr.write( - "replay: 第 %d 个请求的**结构**和录制时不一致:\n" + "replay: 第 %d 个请求的**结构**和录制时不一致: \n" " 录制时: %s\n" " 现在 : %s\n" % (self.pos, json.dumps(want, ensure_ascii=False), @@ -232,7 +232,7 @@ def handle_conn(conn, srv): head += "Content-Length: %d\r\nConnection: close\r\n\r\n" % len(resp) conn.sendall(head.encode() + resp) except Exception as e: # noqa: BLE001 - 服务端不该因一个连接死掉 - sys.stderr.write("replay: 连接处理出错:%s\n" % e) + sys.stderr.write("replay: 连接处理出错: %s\n" % e) finally: try: conn.close() @@ -258,7 +258,7 @@ def main(): ex = parse_log(text) if a.summary or a.check: - print("%s:%d 次往返" % (a.file, len(ex))) + print("%s: %d 次往返" % (a.file, len(ex))) for i, e in enumerate(ex): r = e["resp"] kind = "SSE" if r.lstrip().startswith(b"data:") else "JSON" diff --git a/tests/run_e2e.sh b/tests/run_e2e.sh index 286fdb4..57ce3fa 100755 --- a/tests/run_e2e.sh +++ b/tests/run_e2e.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash -# run_e2e.sh - 端到端测试:用本地 mock 服务端跑通 ligent 的完整链路 +# run_e2e.sh - 端到端测试: 用本地 mock 服务端跑通 ligent 的完整链路 set -u # 把脚本自己的 stdin 断掉. # -# 这不是洁癖:ligent 在写操作前会问 (Y/n), 而它在 stdin 不是终端时默认放行. +# 这不是洁癖: ligent 在写操作前会问 (Y/n), 而它在 stdin 不是终端时默认放行. # 于是"直接调用 $BIN"的用例在 CI 里一路通过, 在开发者的终端里却会停下来 # 等人敲回车 —— 同一份脚本, 两种行为. 交互用例都走 pty 驱动脚本, 不靠这里 # 的 stdin, 所以断开它不会误伤任何东西. @@ -12,7 +12,7 @@ exec "$LIGENT_CONFIG" -# 存档目录 (<二进制目录>/ligent.archive)是**运行期状态**, 不是构建产物: +# 存档目录 (<二进制目录>/ligent.archive)是**运行期状态**, 不是构建产物: # 里面是用户自己的 /export 存档和图片备份. 而下面几十段用例为了隔离会反复 # `rm -rf` 它, 被测二进制又常常就在仓库根 —— 也就是说直接跑 e2e 会把人家的 -# 存档删掉. 所以开机先看一眼:非空就整体挪到临时目录 (打印路径, 内容不动), +# 存档删掉. 所以开机先看一眼: 非空就整体挪到临时目录 (打印路径, 内容不动), # 之后也不再碰它. 挪不动就直接停 —— 宁可测试不跑, 也不能把东西删了. ARCHIVE_DIR="$(dirname "$BIN")/ligent.archive" if [ -d "$ARCHIVE_DIR" ] && [ -n "$(ls -A "$ARCHIVE_DIR" 2>/dev/null)" ]; then SAVED_ARCHIVE="$(mktemp -d "${TMPDIR:-/tmp}/ligent.archive.saved.XXXXXX")" if mv "$ARCHIVE_DIR" "$SAVED_ARCHIVE/ligent.archive"; then - echo "注意:$ARCHIVE_DIR 里有东西, 已整体移到" + echo "注意: $ARCHIVE_DIR 里有东西, 已整体移到" echo " $SAVED_ARCHIVE/ligent.archive (e2e 需要这个目录是干净的)" echo else @@ -180,7 +180,7 @@ printf '%s' "$OUT" | grep -q $'\033\[' && r=1 || r=0 check "--no-color 时不含任何 ANSI 转义" $r echo -echo "== 9. 网络健壮性:瞬态失败重试 ==" +echo "== 9. 网络健壮性: 瞬态失败重试 ==" OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "RETRY_TEST" 2>&1)" contains "重试后成功" "$OUT"; check "503 之后重试并最终成功" $? contains "重试 (第 1 次)" "$OUT"; check "第一次失败后有退避重试" $? @@ -202,7 +202,7 @@ contains "已重试 1 次" "$OUT"; check "错误信息说明重试了几次" $? contains "mock 持续不可用" "$OUT"; check "服务端的说明被透传" $? echo -echo "== 12. 流式传输中途断连:保留已收到的内容 ==" +echo "== 12. 流式传输中途断连: 保留已收到的内容 ==" OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "TRUNCATE_TEST" 2>&1)" contains "半途崩溃" "$OUT"; check "已收到的部分内容被保留" $? contains "传输中断" "$OUT"; check "明确提示传输中断" $? @@ -225,8 +225,8 @@ OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no contains "超过" "$OUT"; check "非流式路径同样有上限" $? echo -echo "== 15. 上下文超限:如实报错, 历史一个字节都不动 ==" -# 这里以前测的是"超限后自动丢弃最老的一轮再重试". 那套机制已经整个删掉: +echo "== 15. 上下文超限: 如实报错, 历史一个字节都不动 ==" +# 这里以前测的是"超限后自动丢弃最老的一轮再重试". 那套机制已经整个删掉: # 判据只是错误文案里的关键词, 代价却是**不可逆地**删掉几轮对话. 现在的行为是 # 把服务端的原始错误报出来、历史原样保留, 怎么处理由用户决定 (/back、/reset). OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ @@ -236,7 +236,7 @@ contains "第一轮" "$OUT"; check "第一轮正常" $? contains "maximum context length" "$OUT"; check "超限时报出服务端的原始错误" $? contains "已丢弃最老" "$OUT" && r=1 || r=0 check "不再自动丢弃历史" $r -# 两轮 user + 第一轮的 assistant = 3 条:第二轮报错没有留下残缺消息, 也没删东西 +# 两轮 user + 第一轮的 assistant = 3 条: 第二轮报错没有留下残缺消息, 也没删东西 contains "历史消息 3 条" "$OUT" check "历史条数原样 (system 不计)" $? @@ -267,7 +267,7 @@ contains "example.invalid" "$OUT"; check "报出重定向目标地址" $? echo echo "== 18. --verbose 不得泄露 API key ==" -# 必须有 --no-confirm:这一轮会触发工具调用, 缺了它就会在真终端上问 (Y/n), +# 必须有 --no-confirm: 这一轮会触发工具调用, 缺了它就会在真终端上问 (Y/n), # 整个套件停在那里等人敲回车. 脚本开头的 exec &1 \ | sed -u 's/^/ /' | tee build/edit_check.out @@ -372,7 +372,7 @@ PYEOF check "存档是自描述的 JSON (name/parent/created/messages)" $? echo -echo "== 30b. 谱系命名:分支看得见 ==" +echo "== 30b. 谱系命名: 分支看得见 ==" AROOT="$(ls "$ARCHIVE_DIR" | sed 's/\.json$//' | head -1)" OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --extra=--auto-save --prompt "/import $AROOT" --prompt "n" --prompt "分支甲" \ @@ -424,7 +424,7 @@ OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --prompt "/import $SECRET" --prompt "y" --prompt "/exit" --wait 1.2 2>&1)" contains "紫水晶" "$OUT"; check "回答 y 时打印历史内容" $? contains "助手" "$OUT"; check "打印时带角色标签" $? -# 完整输出:system 提示词也展开, 不是只报个体积 +# 完整输出: system 提示词也展开, 不是只报个体积 contains "你是用户的 AI 助手" "$OUT"; check "system 提示词全文展开" $? # 工具调用的参数要完整, 不能只报字节数 contains '"command": "echo hello-from-tool"' "$OUT"; check "工具调用参数完整打印" $? @@ -449,7 +449,7 @@ printf '%s' "$OUT" | grep -q $'\033\[2J' ; check "发出清屏转义序列" $? N1="$(printf '%s' "$OUT" | tr '\r' '\n' | grep -oE '历史消息 [0-9]+ 条' | head -1)" N2="$(printf '%s' "$OUT" | tr '\r' '\n' | grep -oE '历史消息 [0-9]+ 条' | tail -1)" [ -n "$N1" ] && [ "$N1" = "$N2" ]; check "/cls 前后历史条数不变 ($N1)" $? -# /cls 和 /clear 是两回事:/clear 是 /reset 的别名 +# /cls 和 /clear 是两回事: /clear 是 /reset 的别名 contains "已清空对话历史" "$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --prompt "你好" --prompt "/clear" --wait 1.2 2>&1)" check "/clear 仍然清空对话 (没被 /cls 抢走)" $? @@ -609,7 +609,7 @@ check "关闭确认期间不再询问" $r echo echo "== 41. 思考模式与级别 ==" -# 默认:不发 thinking / reasoning_effort, 由服务端决定 +# 默认: 不发 thinking / reasoning_effort, 由服务端决定 OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color \ "THINK_TEST 默认" 2>&1)" contains "THINK[echo=yes thinking=none effort=none]" "$OUT"; check "默认不发思考字段" $? @@ -640,7 +640,7 @@ OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --wait 0.6 2>&1)" contains "由服务端决定" "$OUT"; check "/think 无参数显示当前设置" $? contains "思考级别已设为 high" "$OUT"; check "/think LEVEL 设置级别" $? -contains "思考级别:high" "$OUT"; check "级别被记住" $? +contains "思考级别: high" "$OUT"; check "级别被记住" $? contains "关闭 (发 thinking.type=disabled)" "$OUT"; check "/think off 关闭思考" $? contains "无法识别" "$OUT"; check "/think 非法值给出提示" $? @@ -666,7 +666,7 @@ echo "== 44. /meta 元编辑对话历史 ==" ARCHIVE_DIR="$(dirname "$BIN")/ligent.archive" rm -rf "$ARCHIVE_DIR" -# 全都在同一个进程里做完:谱系 (g_lineage)是进程内状态, +# 全都在同一个进程里做完: 谱系 (g_lineage)是进程内状态, # 换一个 ligent 进程它就不知道上一份存档叫什么, 会另起一个新根. OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --extra=--no-confirm --extra=--no-auto-save --extra=--no-history \ @@ -702,7 +702,7 @@ python3 - "$ARCHIVE_DIR/$META_NAME.json" <<'PYEOF' import json, sys d = json.load(open(sys.argv[1], encoding="utf-8")) msgs = d.get("messages", []) -# 结构必须仍然自洽:带 tool_calls 的 assistant 后面必须紧跟 tool +# 结构必须仍然自洽: 带 tool_calls 的 assistant 后面必须紧跟 tool ok = True for i, m in enumerate(msgs): if m.get("role") == "assistant" and m.get("tool_calls"): @@ -713,7 +713,7 @@ PYEOF check "删改之后工具调用配对仍然完整" $? # meta 模式里的未知命令不该被当成给模型的指令 -# 单引号很关键:双引号里 bash 会先把 $((6*7)) 算成 42, +# 单引号很关键: 双引号里 bash 会先把 $((6*7)) 算成 42, # 那断言就成了"回显里有没有 42", 毫无意义. # 命令写成"输出和输入不同"的样子, 因为行编辑器会把敲进去的字原样回显, # 直接 grep 命令文本永远命中, 证明不了它有没有被执行. @@ -727,7 +727,7 @@ rm -rf "$ARCHIVE_DIR" echo echo "== 45. 确认提示上的每种回答 ==" -# 重点:在确认提示上按 Ctrl-C 必须等于拒绝. +# 重点: 在确认提示上按 Ctrl-C 必须等于拒绝. # confirm_tool 用 fgets 读输入, 而 SIGINT 会让它 EINTR 返回 NULL —— # 以前 NULL 被当成"没人能回答, 按默认值放行", 于是 Ctrl-C 批准了写盘. OUT="$(python3 tests/confirm_check.py "$BIN" "$BASE" 2>&1)" @@ -745,7 +745,7 @@ contains "连通性" "$OUT"; check "报告了连通性" $? contains "HTTP 状态" "$OUT"; check "报告了 HTTP 状态码" $? contains "结论" "$OUT"; check "给出了结论" $? -# 认证这一节必须区分三种情况:密钥对 / 密钥错 / 根本没配. +# 认证这一节必须区分三种情况: 密钥对 / 密钥错 / 根本没配. # 以前 http_probe 压根不带密钥, 于是任何要求认证的端点都回 401, 而 doctor # 一律说"密钥被拒绝" —— 把用户引到一个根本不存在的问题上. AUTHED="http://127.0.0.1:$PORT/authed" @@ -758,7 +758,7 @@ OUT="$(env -u LIGENT_API_KEY -u DEEPSEEK_API_KEY -u OPENAI_API_KEY \ contains "端点要求认证, 但当前没有配置 API key" "$OUT"; check "没配密钥时不说"被拒绝"" $? contains "不是网络问题" "$OUT"; check "并说明这跟网络无关" $? -# 404 也不能被说成"密钥没问题":请求根本没走到认证 +# 404 也不能被说成"密钥没问题": 请求根本没走到认证 OUT="$(env -u LIGENT_API_KEY "$BIN" doctor --api-key good-key \ --base-url "http://127.0.0.1:$PORT/nope" 2>&1)" contains "这个地址不存在" "$OUT"; check "404 报地址不对" $? @@ -773,8 +773,8 @@ contains "example.invalid" "$OUT"; check "报出跳转目标" $? contains "不自动跟随重定向" "$OUT"; check "解释了为什么不跟随" $? echo -echo "== 48. read_image:图片以 base64 内联发出, 历史里只留备份引用 ==" -# 自己造测试图:一张最小的合法 PNG, 和一个"扩展名是 .png、内容不是图片"的文件, +echo "== 48. read_image: 图片以 base64 内联发出, 历史里只留备份引用 ==" +# 自己造测试图: 一张最小的合法 PNG, 和一个"扩展名是 .png、内容不是图片"的文件, # 以及一张故意超过内联上限的大图. 不能依赖 build/ 里已有的东西 —— # make clean 会把它整个删掉. mkdir -p build/tmp @@ -803,7 +803,7 @@ OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ contains "已读入图片" "$OUT"; check "read_image 读入成功" $? contains "base64" "$OUT"; check "工具结果说明了会以 base64 内联" $? -# 关键:模型在下一轮**真的看到了**内联图片 (图片只能出现在 user 消息里, +# 关键: 模型在下一轮**真的看到了**内联图片 (图片只能出现在 user 消息里, # 而 read_image 是工具、结果只能是字符串, 所以中间补了一条附件消息) contains "IMAGE[data:image/png:" "$OUT"; check "模型收到了内联 base64 图片" $? contains "历史消息 5 条" "$OUT" @@ -821,7 +821,7 @@ sys.exit(0 if ok else 1) PYEOF check "备份名形如 <时间戳>_<4位>." $? -# 这是这一版最要紧的不变量:**归档里不能有图片字节**. +# 这是这一版最要紧的不变量: **归档里不能有图片字节**. # 请求里发的是 base64, 但存进历史 / 归档的必须是那个几十字节的引用. python3 - "$ARCHIVE_DIR" "build/tmp/dot.png" <<'PYEOF' import base64, glob, json, sys @@ -829,7 +829,7 @@ import base64, glob, json, sys d, png_path = sys.argv[1], sys.argv[2] raw = open(glob.glob(d + "/*.json")[0], encoding="utf-8").read() -# 判据只能是**图片载荷本身**. 不能拿 "base64" 这个词, 也不能拿 "data:image": +# 判据只能是**图片载荷本身**. 不能拿 "base64" 这个词, 也不能拿 "data:image": # 工具结果里会正当地写"base64 内联后约…", mock 的回复里还会出现 # "IMAGE[data:image/png:92]" 这种对收到内容的描述. 真正该消失的是那串载荷. payload = base64.b64encode(open(png_path, "rb").read()).decode() @@ -862,7 +862,7 @@ check "归档里只有对备份的引用、没有图片字节" $? rm -rf "$ARCHIVE_DIR" echo -echo "== 49. read_image:服务端拒图时把图片摘掉重试, 而不是每轮都失败 ==" +echo "== 49. read_image: 服务端拒图时把图片摘掉重试, 而不是每轮都失败 ==" rm -rf "$ARCHIVE_DIR" # 三轮必须在**同一个** repl 进程里. mock 的 told_once 是一次性的, 重开一个 # 进程它就不再拒了; 而且"降级要就地生效"这件事本身也只能在同一会话里验. @@ -880,7 +880,7 @@ contains "IMAGE[none]" "$OUT"; check "重试时请求里确实没有图片了" $ contains "不符合 API 所需的格式标准" "$OUT"; check "模型收到了"请规范化图片"的说明" $? contains "请规范化图片" "$OUT"; check "说明里给了下一步动作" $? -# 降级是**就地**的:同一会话的下一轮也不该再有图片, +# 降级是**就地**的: 同一会话的下一轮也不该再有图片, # 否则每轮都会重新踩同一个 4xx, 用户只能 /reset. LAST_IMG="$(printf '%s\n' "$OUT" | grep -o 'IMAGE\[[^]]*\]' | tail -1)" [ "$LAST_IMG" = "IMAGE[none]" ]; check "下一轮也是干净的 ($LAST_IMG)" $? @@ -890,14 +890,14 @@ N="$(ls "$ARCHIVE_DIR"/images/*.png 2>/dev/null | wc -l)" [ "$N" -eq 1 ]; check "备份没有被重复写 ($N 份)" $? rm -rf "$ARCHIVE_DIR" -echo "== 50. read_image:不认识的格式、以及过大的图, 都要在本地拒绝 ==" +echo "== 50. read_image: 不认识的格式、以及过大的图, 都要在本地拒绝 ==" rm -rf "$ARCHIVE_DIR" OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --extra=--no-confirm --extra=--no-auto-save --extra=--no-history \ --prompt "IMAGE_BAD_TEST 读一个假图片" --prompt "/exit" --wait 1.3 2>&1)" contains "不认识的图片格式" "$OUT"; check "按内容判断并拒绝 (扩展名是 .png 也没用)" $? -# 过大的图:本地当场拒, 不备份、不发请求 +# 过大的图: 本地当场拒, 不备份、不发请求 OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --extra=--no-confirm --extra=--no-auto-save --extra=--no-history \ --prompt "IMAGE_OVERSIZE_TEST 读一张大图" --prompt "/exit" --wait 1.5 2>&1)" @@ -907,7 +907,7 @@ N="$(ls "$ARCHIVE_DIR"/images/*.png 2>/dev/null | wc -l)" [ "$N" -eq 0 ]; check "被拒的图不备份 ($N 份)" $? rm -rf "$ARCHIVE_DIR" -echo "== 51. 能力探测序列:按模型能力决定暴露哪些工具 ==" +echo "== 51. 能力探测序列: 按模型能力决定暴露哪些工具 ==" # 这一段自己把 LIGENT_PROBE 打开 (脚本全局是关的, 理由见文件开头). PROBE="LIGENT_PROBE=1" run_probe() { # run_probe <模型名> <额外参数...> @@ -917,17 +917,17 @@ run_probe() { # run_probe <模型名> <额外参数...> --extra="--model" --extra="$model" "$@" 2>&1 } -# --- 正常模型:探测跑过, read_image 在 --- +# --- 正常模型: 探测跑过, read_image 在 --- OUT="$(run_probe probe-model --prompt "TOOLS_ECHO_TEST 看看工具表" --prompt "/exit" --wait 2.0)" contains "TOOLS[" "$OUT"; check "探测之后请求正常发出" $? printf '%s\n' "$OUT" | grep -q 'TOOLS\[[^]]*read_image' -check "支持图片的模型:工具表里有 read_image" $? +check "支持图片的模型: 工具表里有 read_image" $? printf '%s\n' "$OUT" | grep -q 'TOOLS\[[^]]*run_command' check "其他工具不受影响" $? -contains "模型能力:多模态 (图片输入)支持" "$OUT"; check "探测后打一条结论日志" $? +contains "模型能力: 多模态 (图片输入)支持" "$OUT"; check "探测后打一条结论日志" $? contains "工具调用 支持" "$OUT"; check "日志里也说明工具调用能力" $? -# --- 模型明确不收图片:read_image 必须从工具表里消失 --- +# --- 模型明确不收图片: read_image 必须从工具表里消失 --- OUT="$(run_probe no-vision-model --prompt "TOOLS_ECHO_TEST 看看工具表" \ --prompt "/tools" --prompt "/exit" --wait 2.0)" contains "多模态 (图片输入)不支持 (已隐藏 read_image)" "$OUT" @@ -942,13 +942,13 @@ printf '%s\n' "$OUT" | grep -q 'TOOLS\[[^]]*run_command' check "只摘掉 read_image, 别的工具留着" $? contains "read_image 已隐藏" "$OUT"; check "/tools 不列已经藏掉的工具" $? -# --- 模型完全不收 tools:整张表都不发 --- +# --- 模型完全不收 tools: 整张表都不发 --- OUT="$(run_probe no-tools-model --prompt "TOOLS_ECHO_TEST 看看工具表" --prompt "/exit" --wait 2.0)" contains "工具调用 不支持 (不再发送工具表)" "$OUT" check "如实说明了工具调用能力与动作" $? contains "TOOLS[]" "$OUT"; check "工具表确实是空的" $? -# --- 探测结论不确定:一律按"支持"处理, 宁可多暴露 --- +# --- 探测结论不确定: 一律按"支持"处理, 宁可多暴露 --- # broken-probe 模型只在**探测请求**上回 401, 真实请求照常. OUT="$(run_probe broken-probe-model --prompt "TOOLS_ECHO_TEST 看看工具表" --prompt "/exit" --wait 2.0)" contains "多模态 (图片输入)未测" "$OUT"; check "探不出来时如实说明是未测" $? @@ -956,11 +956,11 @@ contains "没测出来的一律按「支持」处理" "$OUT"; check "说明按 printf '%s\n' "$OUT" | grep -q 'TOOLS\[[^]]*read_image' check "结论不确定时不藏工具 (宁可多暴露)" $? -# --- 关掉探测:一个试探请求都不发, 工具表保持完整 --- +# --- 关掉探测: 一个试探请求都不发, 工具表保持完整 --- OUT="$(env LIGENT_PROBE=0 python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --extra=--no-confirm --extra=--no-auto-save --extra=--no-history \ --prompt "TOOLS_ECHO_TEST 看看工具表" --prompt "/exit" --wait 1.6 2>&1)" -if printf '%s\n' "$OUT" | grep -q '检测模型能力\|模型能力:'; then +if printf '%s\n' "$OUT" | grep -q '检测模型能力\|模型能力: '; then check "--no-probe 时既不该发探测请求, 也不该有结论日志" 1 else check "--no-probe 时既不该发探测请求, 也不该有结论日志" 0 @@ -968,8 +968,8 @@ fi printf '%s\n' "$OUT" | grep -q 'TOOLS\[[^]]*read_image' check "--no-probe 时工具表保持完整" $? -echo "== 52. /model 不带参数:打印在线列表, 输入行里选序号或直接写名字 ==" -# 这一段刻意不用光标菜单, 而是 /import 那种"列表 + 普通输入行": +echo "== 52. /model 不带参数: 打印在线列表, 输入行里选序号或直接写名字 ==" +# 这一段刻意不用光标菜单, 而是 /import 那种"列表 + 普通输入行": # 既能填序号, 也能直接写一个**列表里没有**的模型名 (内部模型就走这条). run_model() { # run_model <要依次输入的行...>; 第一行固定是 /model local a=() @@ -979,7 +979,7 @@ run_model() { # run_model <要依次输入的行...>; 第一行固定是 /model --prompt "/model" "${a[@]}" --prompt "/exit" --wait 1.4 2>&1 } -# --- 列表本身:编号、当前模型被标出、去重与坏条目被跳过 --- +# --- 列表本身: 编号、当前模型被标出、去重与坏条目被跳过 --- OUT="$(run_model "2")" contains "正在获取模型列表 ($BASE/models)" "$OUT"; check "用的是标准的 /models 路径" $? contains "服务端列出了 3 个模型" "$OUT" @@ -991,7 +991,7 @@ contains "选择序号, 或直接输入模型名" "$OUT"; check "输入行的提 # --- 按序号选 --- contains "模型已切换为 alpha-model" "$OUT"; check "填序号能选中对应模型" $? -# --- 直接写一个**列表里没有**的名字:这是内部模型的入口, 必须允许 --- +# --- 直接写一个**列表里没有**的名字: 这是内部模型的入口, 必须允许 --- OUT="$(run_model "internal-model-v9")" contains "模型已切换为 internal-model-v9" "$OUT" check "能直接写列表里没有的模型名 (内部模型走这条)" $? @@ -1010,7 +1010,7 @@ else check "取消之后什么都没改" 0 fi -# --- 序号越界:报错并取消, 不能当成模型名去用 --- +# --- 序号越界: 报错并取消, 不能当成模型名去用 --- OUT="$(run_model "9")" contains "序号 9 超出范围" "$OUT"; check "序号越界要报出来" $? if printf '%s\n' "$OUT" | grep -q "模型已切换"; then @@ -1036,7 +1036,7 @@ else check "带参数时不去拉列表" 0 fi -# --- 服务端返回空列表:说清楚, 并提示还能直接指定名字 --- +# --- 服务端返回空列表: 说清楚, 并提示还能直接指定名字 --- printf 'empty' > build/tmp/models_mode OUT="$(run_model "1")" rm -f build/tmp/models_mode @@ -1044,7 +1044,7 @@ contains "拿不到模型列表" "$OUT"; check "空列表时如实报错" $? contains "空的模型列表" "$OUT"; check "说清是空列表而不是网络故障" $? contains "/model <名字>" "$OUT"; check "提示还能直接指定名字" $? -# --- 服务端返回的不是 JSON:也不能崩, 同样给出可用的退路 --- +# --- 服务端返回的不是 JSON: 也不能崩, 同样给出可用的退路 --- printf 'garbage' > build/tmp/models_mode OUT="$(run_model "1")" rm -f build/tmp/models_mode @@ -1058,11 +1058,11 @@ rm -f build/tmp/models_mode contains "服务端列出了 2 个模型" "$OUT"; check "裸数组形状也能解析" $? contains "模型已切换为 beta-model" "$OUT"; check "裸数组里也能按序号选" $? -echo "== 53. 一条 assistant 里两个工具调用 + 图片:附件不能插在 tool 消息中间 ==" +echo "== 53. 一条 assistant 里两个工具调用 + 图片: 附件不能插在 tool 消息中间 ==" # 回归用例. 曾经的写法是"每推完一条 tool 消息就把图片附件推出去", 于是 # assistant 带两个调用、第一个是 read_image 时, 历史变成 # assistant(tool_calls) / tool(读图) / user(附件) / tool(另一个) -# 中间那条 user 把 tool 段截断了, 服务端回 400: +# 中间那条 user 把 tool 段截断了, 服务端回 400: # An assistant message with 'tool_calls' must be followed by tool messages # responding to each 'tool_call_id'. (insufficient tool messages ...) # 而且这段历史留在上下文里, 之后每一轮都会 400 —— 用户只能 /reset. @@ -1090,11 +1090,11 @@ else fi rm -rf "$ARCHIVE_DIR" -# 顺带验一下修复路径本身:mock 校验会让坏历史当场 400, 所以这里换个角度 —— +# 顺带验一下修复路径本身: mock 校验会让坏历史当场 400, 所以这里换个角度 —— # 直接看 agent_repair_tool_pairing 的单元测试 (test_agent). echo "== 54. /back 报的条数不含 system 提示词 ==" -# 用户报过一个:聊一句「hi」再 /back, 屏幕显示"历史剩 1 条", 但那段对话其实 +# 用户报过一个: 聊一句「hi」再 /back, 屏幕显示"历史剩 1 条", 但那段对话其实 # 已经空了 —— 那 1 条是 system 提示词. system 是配置不是对话, 不该算进"历史". # 现在 /history、/back、/unback、/export、/import 统一用"不含 system"的口径. OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ @@ -1107,7 +1107,7 @@ check "/history 也报 0 条 (与 /back 口径一致)" $? contains "历史回到 4 条" "$OUT"; check "/unback 之后回到 4 条" $? contains "不含 system" "$OUT"; check "说清楚这个口径不含 system 提示词" $? -echo "== 55. /include:把文件附带进下一条消息 ==" +echo "== 55. /include: 把文件附带进下一条消息 ==" mkdir -p build/tmp/inc/sub printf 'int main(void){return 0;}\n' > build/tmp/inc/a.c printf 'hello text\n' > build/tmp/inc/b.txt @@ -1131,7 +1131,7 @@ inc() { # inc ; 先切到场景目录 (/cd 是 REPL 命令, 没有同 --prompt "/cd build/tmp/inc" "$@" --wait 1.5 2>&1 } -# --- glob 参数:命中几个就附带几个 --- +# --- glob 参数: 命中几个就附带几个 --- OUT="$(inc --prompt "/include *.c" --prompt "INCLUDE_TEST 看看" --prompt "/exit")" contains "已附带 1 个文件" "$OUT"; check "glob 展开并暂存" $? contains "INC[text=2 img=0 files=a.c]" "$OUT" @@ -1143,11 +1143,11 @@ OUT="$(inc --prompt "/include *.c" --prompt "/include *.txt" \ --prompt "INCLUDE_TEST 两个" --prompt "/exit")" contains "INC[text=3 img=0" "$OUT"; check "两次附带的文件都发出去了" $? -# --- 图片:存备份、以 ligent_image 引用参与请求 --- +# --- 图片: 存备份、以 ligent_image 引用参与请求 --- OUT="$(inc --prompt "/include pic.png" --prompt "INCLUDE_TEST 看图" --prompt "/export" --prompt "/exit")" contains "〔图片〕" "$OUT"; check "识别出是图片并标注" $? contains "INC[text=1 img=1" "$OUT"; check "图片以 image_url 内联发出" $? -# 备份必须落在**二进制所在目录**, 不是当前工作目录 —— 这一条是有来历的: +# 备份必须落在**二进制所在目录**, 不是当前工作目录 —— 这一条是有来历的: # image_set_bindir() 曾经从来没被调用过, 于是备份跟着 cwd 走, /cd 到别处 # 带图就会写到那儿, 之后从别处发消息会因为找不到备份把图降级掉. # 测试一直从二进制所在目录跑, "." 恰好等于正确值, 所以一直没暴露. @@ -1171,7 +1171,7 @@ rm -rf "$ARCHIVE_DIR" # --- 内容不是图片的 .png 按文本附带 (认内容不认扩展名) --- OUT="$(inc --prompt "/include fake.png" --prompt "INCLUDE_TEST 假的" --prompt "/exit")" contains "INC[text=2 img=0 files=fake.png]" "$OUT" -check "按内容判断:不是图片就当文本带" $? +check "按内容判断: 不是图片就当文本带" $? # --- 二进制被跳过 --- printf '\x00\x01\x02binary' > build/tmp/inc/bin.dat @@ -1183,14 +1183,14 @@ contains "二进制" "$OUT"; check "说清跳过原因" $? OUT="$(inc --prompt "/include 不存在的模式*.zzz" --prompt "/exit")" contains "没有匹配到文件" "$OUT"; check "匹配不到要说出来" $? -# --- 交互式:进目录、退回来、选文件 --- +# --- 交互式: 进目录、退回来、选文件 --- # 序号 3 是 sub/ (a.c / b.txt / sub 排序后), 进去再回车结束; # 然后回头选一个文件. OUT="$(inc --prompt "/include" --prompt "3" --prompt "" \ --prompt "/include" --prompt "1" --prompt "" --prompt "/exit")" contains "进入交互式选择" "$OUT"; check "无参数进入交互式" $? contains " (目录)" "$OUT"; check "目录被标出来" $? -contains "当前目录:" "$OUT"; check "显示了当前目录" $? +contains "当前目录: " "$OUT"; check "显示了当前目录" $? contains "已选中" "$OUT"; check "选中后有回执" $? # --- clear --- @@ -1199,7 +1199,7 @@ OUT="$(inc --prompt "/include *.c" --prompt "/include clear" \ contains "已清空待附带的文件" "$OUT"; check "/include clear 清空暂存" $? contains "INC[text=1 img=0" "$OUT"; check "清空之后只发用户那句话" $? -# --- 模型不支持视觉时:文本照带, 图片跳过并说明 --- +# --- 模型不支持视觉时: 文本照带, 图片跳过并说明 --- OUT="$(env LIGENT_PROBE=1 python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --extra=--no-confirm --extra=--no-auto-save --extra=--no-history \ --extra=--model --extra=no-vision-model \ @@ -1220,8 +1220,8 @@ OUT="$(env LIGENT_PROBE=1 python3 tests/repl_drive.py --bin "$BIN" --base-url "$ contains "INC[text=1 img=1" "$OUT"; check "支持视觉时图片正常附带" $? rm -rf "$ARCHIVE_DIR" -echo "== 56. run_command elevated:经 sudo 提权执行单条命令 ==" -# 全程用**替身 sudo**:不需要真权限, 也永远不会碰到谁的真实密码. +echo "== 56. run_command elevated: 经 sudo 提权执行单条命令 ==" +# 全程用**替身 sudo**: 不需要真权限, 也永远不会碰到谁的真实密码. # 它把 argv 记下来, 于是"ligent 到底怎么调 sudo 的"能断言到参数级. FAKEBIN="$ROOT/build/tmp/fakebin" mkdir -p "$FAKEBIN" @@ -1242,7 +1242,7 @@ elev() { # elev <额外环境变量...> -- PATH="$FAKEBIN:$PATH" FAKE_SUDO_LOG="$SULOG" "$@" 2>&1 } -# --- 模型请求提权:命令经 sudo 以 root 跑, 输出照常被捕获 --- +# --- 模型请求提权: 命令经 sudo 以 root 跑, 输出照常被捕获 --- OUT="$(elev python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --extra=--no-confirm --extra=--no-auto-save --extra=--no-history \ --prompt "ELEVATED_TEST 提权跑个命令" --prompt "/exit" --wait 1.8)" @@ -1250,10 +1250,10 @@ contains "提权后的输出" "$OUT"; check "提权命令真的执行了, 输出 contains "ELEV_SAW[提权后的输出" "$OUT"; check "输出回填给了模型" $? grep -q '^-u root -- /bin/sh -c echo 提权后的输出$' "$SULOG" check "调用形式是 sudo -u root -- (argv 形式, 不经多余 shell)" $? -# 这一条是这次设计的核心:**不能带 -n**. 带 -n 就意味着"不许提问", +# 这一条是这次设计的核心: **不能带 -n**. 带 -n 就意味着"不许提问", # 于是需要密码时直接失败 —— 而用户明确要的是"提权要有身份验证". printf '%s\n' "$(cat "$SULOG")" | grep -q -- '-n' && r=1 || r=0 -[ "$r" -eq 0 ]; check "不带 -n:需要密码时要让 sudo 能问出来" $? +[ "$r" -eq 0 ]; check "不带 -n: 需要密码时要让 sudo 能问出来" $? # --- 确认提示必须先把要跑的东西摆出来 --- OUT="$(elev python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ @@ -1262,7 +1262,7 @@ OUT="$(elev python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ contains "即将执行 run_command" "$OUT"; check "提权命令同样过确认提示" $? contains "提权后的输出" "$OUT"; check "确认之后才执行" $? -# --- 认证失败:模型要收到能理解的话, 而不是一片空白 --- +# --- 认证失败: 模型要收到能理解的话, 而不是一片空白 --- OUT="$(PATH="$FAKEBIN:$PATH" FAKE_SUDO_LOG="$SULOG" FAKE_SUDO_REJECT=1 \ python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --extra=--no-confirm --extra=--no-auto-save --extra=--no-history \ @@ -1271,7 +1271,7 @@ contains "提权命令失败" "$OUT"; check "提权失败要告诉模型" $? contains "认证没有通过" "$OUT"; check "说清是认证没过 (而不是命令本身的问题)" $? contains "不要反复重试" "$OUT"; check "明确叫它别反复重试" $? -# --- 没有控制终端时:直接拒绝, 绝不发起一个等不到输入的进程 --- +# --- 没有控制终端时: 直接拒绝, 绝不发起一个等不到输入的进程 --- # setsid 摘掉控制终端, 正是"没人能输密码"的情形. if command -v setsid >/dev/null 2>&1; then OUT="$(setsid "$BIN" --api-key test --base-url "$BASE" --no-color \ @@ -1283,11 +1283,11 @@ if command -v setsid >/dev/null 2>&1; then # 而且它必须是**立刻**失败, 不能卡在那儿等密码 contains "没有可用的控制终端" "$OUT" || true else - echo " (跳过:没有 setsid)" + echo " (跳过: 没有 setsid)" fi -echo "== 57. 离线回放:把 /log 录下来的真实会话当夹具 ==" -# 这是 /log 存在的**真正理由**:录一次真的, 之后离线反复回放. +echo "== 57. 离线回放: 把 /log 录下来的真实会话当夹具 ==" +# 这是 /log 存在的**真正理由**: 录一次真的, 之后离线反复回放. # 录的是原始字节 (请求体 + 未解析的响应原文), 所以回放时 http.c 的分块读取、 # llm.c 的 SSE 行切分与 tool_calls 增量重组, 全都在真实字节上跑. FIX="$ROOT/tests/fixtures/session.log" @@ -1311,7 +1311,7 @@ sys.exit(0 if s.connect_ex(('127.0.0.1',$1))==0 else 1)" && break done } -# --- 用离线服务端跑**真的** ligent:它应当沿着录制里的路径走完 --- +# --- 用离线服务端跑**真的** ligent: 它应当沿着录制里的路径走完 --- replay_serve 8899 "$FIX" --strict OUT="$("$BIN" --api-key test --base-url "http://127.0.0.1:8899/v1" --no-color \ --no-confirm --no-auto-save --no-history --no-probe "please run it" 2>&1)" @@ -1320,7 +1320,7 @@ kill $RPID 2>/dev/null; wait $RPID 2>/dev/null [ "$RC" -eq 0 ]; check "在录制上跑完一轮 (退出码 $RC)" $? contains "hello-from-tool" "$OUT"; check "工具调用从被切碎的 SSE 里正确重组" $? contains "FINAL:" "$OUT"; check "拿到了录制里的最终回答" $? -# strict:请求的**结构**必须和录制时一致 —— 这才是"报文形状没变"的断言 +# strict: 请求的**结构**必须和录制时一致 —— 这才是"报文形状没变"的断言 if grep -q "结构" "$RLOG"; then check "请求结构与录制一致" 1 grep -A2 "结构" "$RLOG" | head -4 @@ -1328,7 +1328,7 @@ else check "请求结构与录制一致 (strict 没报差异)" 0 fi -# --- 错误体:录下来的 400 也要能被原样解析出可读信息 --- +# --- 错误体: 录下来的 400 也要能被原样解析出可读信息 --- replay_serve 8898 "$ROOT/tests/fixtures/error.log" --strict OUT="$("$BIN" --api-key test --base-url "http://127.0.0.1:8898/v1" --no-color \ --no-confirm --no-auto-save --no-history --no-probe --retries 0 \ @@ -1340,7 +1340,7 @@ contains "mock 模拟的服务端错误" "$OUT"; check "错误体从真实字节 # --- 请求比录制多时, 回放器要明确报出来 (而不是静静回错东西)--- replay_serve 8897 "$ROOT/tests/fixtures/error.log" --strict -# 录制里只有 1 次往返. 跑两次:第二次就没东西可回了. +# 录制里只有 1 次往返. 跑两次: 第二次就没东西可回了. for _ in 1 2; do "$BIN" --api-key test --base-url "http://127.0.0.1:8897/v1" --no-color \ --no-confirm --no-auto-save --no-history --no-probe --retries 0 \ @@ -1350,8 +1350,8 @@ kill $RPID 2>/dev/null; wait $RPID 2>/dev/null contains "没有更多响应" "$(cat "$RLOG")" check "录制用完后明确报出来 (而不是静静回错东西)" $? -echo "== 58. 工具输出里混进非法 UTF-8:不能让之后每个请求都被拒 ==" -# 坏字节的来源很日常:某条命令在非 UTF-8 locale 下打出来的路径、被截断的多 +echo "== 58. 工具输出里混进非法 UTF-8: 不能让之后每个请求都被拒 ==" +# 坏字节的来源很日常: 某条命令在非 UTF-8 locale 下打出来的路径、被截断的多 # 字节字符、二进制文件里混的一小段. 它会跟着工具结果进对话历史, 再被序列化 # 进请求体 —— 序列化器若原样吐出, DeepSeek 回 # 400 ... invalid unicode code point @@ -1366,10 +1366,10 @@ case "$OUT" in *"替换字符=0"*) rc=1;; # 一个都没替换 —— 坏字节原样发出去了 *) rc=0;; esac -check "请求体是合法 UTF-8:服务端没拒, 且坏字节被换成了 U+FFFD" $rc +check "请求体是合法 UTF-8: 服务端没拒, 且坏字节被换成了 U+FFFD" $rc contains "读完了" "$OUT"; check "这一轮正常跑完" $? -echo "== 59. 界面语言:--lang / LIGENT_LANG / LC_* ==" +echo "== 59. 界面语言: --lang / LIGENT_LANG / LC_* ==" # 前面几十段都跑在顶层锁死的 zh 下 (见文件开头的 export). 这一段专门走英文, # 因为"翻译漏了"和"翻译错了"只有真的把每条路径跑一遍才看得出来. OUT="$(LIGENT_LANG=en "$BIN" --help 2>&1)" @@ -1404,19 +1404,19 @@ contains "只接受 auto / en / zh" "$OUT"; check "拒绝时说清可选值" $? # doctor 是最长的一段界面文案, 分节括号也要跟着语言走 OUT="$(LIGENT_LANG=en "$BIN" doctor --offline 2>&1)" -# 注意:方括号在 case 模式里是字符类, 所以这里只断言括号里的文字 +# 注意: 方括号在 case 模式里是字符类, 所以这里只断言括号里的文字 contains "binary & config location" "$OUT"; check "英文分节标题" $? contains "Bottom line:" "$OUT"; check "英文结论行" $? OUT="$(LIGENT_LANG=zh "$BIN" doctor --offline 2>&1)" contains "【二进制与配置位置】" "$OUT"; check "中文分节用【】" $? -contains "结论:" "$OUT"; check "中文结论行" $? +contains "结论: " "$OUT"; check "中文结论行" $? echo -echo "== 60. 单轮往返次数:默认不限, 正数才是上限 ==" +echo "== 60. 单轮往返次数: 默认不限, 正数才是上限 ==" # 默认 (配置文件里没写 max_steps)要能跑完 4 次工具往返 —— 这正是"默认不限" -# 的可观测形式:任何小上限都会在这里当场暴露. +# 的可观测形式: 任何小上限都会在这里当场暴露. OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "MANYSTEPS_TEST" 2>&1)" -contains "STEPS_DONE[4]" "$OUT"; check "默认不限往返次数:4 次工具往返跑完" $? +contains "STEPS_DONE[4]" "$OUT"; check "默认不限往返次数: 4 次工具往返跑完" $? contains "run_command" "$OUT"; check "每一步的工具调用都真实执行了" $? # 显式写 0 也是不限 (0 = 不限是这条接口的约定) @@ -1442,9 +1442,9 @@ OUT="$("$BIN" --print-config 2>&1)" contains "max_steps : 不限" "$OUT"; check "--print-config 显示不限" $? echo -echo "== 61. edit_file 的调用行:\"路径\" [行号], 成功后给带行号的新旧对照 ==" +echo "== 61. edit_file 的调用行: \"路径\" [行号], 成功后给带行号的新旧对照 ==" # 以前这一行是把整个参数 JSON 原样打出来 (截 256 字节)—— 对 edit_file 来说那是 -# 一整段 old_string/new_string:既刷屏, 又看不出到底要改哪里. 现在摘要只有 +# 一整段 old_string/new_string: 既刷屏, 又看不出到底要改哪里. 现在摘要只有 # "哪个文件、哪几行" (路径带引号、行号用方括号), 改动内容用带行号的 -/+ 两段摆开. mkprobe() { python3 -c " @@ -1460,7 +1460,7 @@ check "调用行是 \"路径\" [行号], 不再抖 JSON" $? contains " │ - 2 OLD1" "$OUT"; check "旧内容逐行显示, 且带文件行号" $? contains " │ - 9 OLD8" "$OUT"; check "行号随行递增 (2..9)" $? contains " │ + 2 NEW" "$OUT" -check "新内容也带行号 (两侧起始行号相同:替换就落在原处)" $? +check "新内容也带行号 (两侧起始行号相同: 替换就落在原处)" $? contains "还有 4 行" "$OUT"; check "每侧最多 8 行, 其余折成一句" $? contains " │ - 10 OLD9" "$OUT" && r=1 || r=0 check "第 9 行起不再逐行铺开" $r @@ -1469,10 +1469,10 @@ check "参数 JSON 一个字节都不上屏" $r contains "DONE[" "$OUT"; check "工具确实执行了" $? echo -echo "== 62. 确认提示:先摆带行号的新旧对照, 再问 Y/n ==" +echo "== 62. 确认提示: 先摆带行号的新旧对照, 再问 Y/n ==" # 这一版把确认提示里的"替换 <旧> -> <新>"删掉了 —— 两边各截 60 字符、压成一行, # 看不出动了几行、动的是哪几行. 现在摆的是和事后展示同一份带行号 diff, -# 而且必须在提问**之前**:先让人点头、再让人看改什么, 那是反的. +# 而且必须在提问**之前**: 先让人点头、再让人看改什么, 那是反的. mkprobe OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --extra=--confirm --extra=--no-auto-save --extra=--no-color \ @@ -1490,7 +1490,7 @@ i_diff = t.find("- 2 OLD1") i_ask = t.find("继续?") raise SystemExit(0 if 0 <= i_diff < i_ask else 1) PY -check "顺序:先摆 diff、再提问" $? +check "顺序: 先摆 diff、再提问" $? contains "替换:" "$OUT" && r=1 || r=0 check "不再出现「替换: 旧 -> 新」那一行" $r # 确认过的改动不该在执行后再摆一遍 diff (否则像是要改两次) @@ -1498,7 +1498,7 @@ contains "已在 build/tmp/edit_probe.txt 第 2 行替换 1 处" "$OUT" check "确认之后只给一句回执, 不重复 diff" $? echo -echo "== 63. edit_file 失败时:同样只有摘要 + 错误 ==" +echo "== 63. edit_file 失败时: 同样只有摘要 + 错误 ==" mkprobe OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "EDITTEST_FAIL" 2>&1)" contains '{/} edit_file "build/tmp/edit_probe.txt" [999]' "$OUT" @@ -1508,14 +1508,14 @@ contains '"old_string"' "$OUT" && r=1 || r=0 check "失败时同样不抖 JSON" $r echo -echo "== 64. /probe:手动测一次能力 (绕开 --no-probe 与缓存)==" +echo "== 64. /probe: 手动测一次能力 (绕开 --no-probe 与缓存)==" # 这一段的 e2e 全局设了 LIGENT_PROBE=0 (各处用例都按"请求序列"写, 别被探测整体推后), -# 正好拿来验证 /probe 的两条语义:无视那个开关, 也不吃缓存 (敲两次就测两次). +# 正好拿来验证 /probe 的两条语义: 无视那个开关, 也不吃缓存 (敲两次就测两次). OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --prompt "/probe" --wait 1.5 \ --prompt "/probe" --wait 1.5 --prompt "/exit" 2>&1)" -contains "模型能力:" "$OUT"; check "--no-probe 下 /probe 仍会真的探测" $? -n=$(printf '%s' "$OUT" | grep -c "模型能力:") +contains "模型能力: " "$OUT"; check "--no-probe 下 /probe 仍会真的探测" $? +n=$(printf '%s' "$OUT" | grep -c "模型能力: ") [ "${n:-0}" -ge 2 ]; check "每次 /probe 都重测一遍 (不吃缓存), 实际 $n 次" $? contains "支持" "$OUT"; check "结论照启动时的样子打出来" $? @@ -1526,7 +1526,7 @@ OUT="$("$BIN" --help 2>&1)" contains "/probe" "$OUT"; check "--help 的 REPL 命令列表里也有" $? echo -echo "== 65. /history:一行摘要 + 完整对话 ==" +echo "== 65. /history: 一行摘要 + 完整对话 ==" # 摘要行报的是"不含 system"的口径 (与 /usage、/back、/export 一致), 下面列的 # 是含 system 的全部消息 —— 两个数不同, 所以第二行得自己说清楚, 否则用户会 # 以为打印漏了或者多了一条. diff --git a/tests/shell_screen_check.py b/tests/shell_screen_check.py index b4205d0..cf4b136 100644 --- a/tests/shell_screen_check.py +++ b/tests/shell_screen_check.py @@ -3,13 +3,13 @@ 交互式 shell 的原始输出里混着转义序列、重绘空格和全屏程序的整屏画面. 不清洗的话模型看到的就是几百个空格加一串光标定位; 清过头又会把命令和 -状态行一起吃掉. 这里用 printf 造出确定的输入来钉住行为: +状态行一起吃掉. 这里用 printf 造出确定的输入来钉住行为: 1. 备用屏幕 (vim/less/top 走这条路)——画面必须省略, 只留首尾标记; 2. 成片空格 (nano/top 靠空格重绘)——必须压掉, 但短缩进要原样保留; 3. 大输出里夹着的备用屏幕——跨 read 也要能认出来. -用法: +用法: shell_screen_check.py """ import glob @@ -38,7 +38,7 @@ def drive(binary, base, commands, wait=1.6, term="xterm-256color"): else: os.environ["TERM"] = term - # 每轮都清空归档:归档名是随机的, 按文件名排序取"最后一个"会拿到上一轮. + # 每轮都清空归档: 归档名是随机的, 按文件名排序取"最后一个"会拿到上一轮. bindir = os.path.dirname(os.path.abspath(binary)) shutil.rmtree(os.path.join(bindir, "ligent.archive"), ignore_errors=True) @@ -48,7 +48,7 @@ def drive(binary, base, commands, wait=1.6, term="xterm-256color"): os._exit(127) # 行编辑器每次回到提示符都会打开括号粘贴 —— 用它当"轮到输入了"的信号. - # 只按"输出安静"等是不行的:等待动画每 250ms 画一帧, pty 永远不会安静 + # 只按"输出安静"等是不行的: 等待动画每 250ms 画一帧, pty 永远不会安静 # 超过 idle, 于是每次都要白等满整个额度. PROMPT_READY = b"\x1b[?2004h" ready_seen = 0 @@ -135,7 +135,7 @@ def main(): fails += 1 print(" ✗ %s%s" % (name, (" " + detail) if detail else "")) - # ---- 1. 备用屏幕:画面省略, 只留首尾标记 ---- + # ---- 1. 备用屏幕: 画面省略, 只留首尾标记 ---- print("备用屏幕 (vim/less 路径)") rec = drive(binary, base, [ r"printf '\033[?1049h" + ALT_TEXT + r"\033[?1049l'", @@ -154,7 +154,7 @@ def main(): check("标记之外的输出还在", NORMAL_TEXT in rec) check("记录里没有裸转义符", "\x1b" not in rec) - # ---- 2. 成片空格:压掉, 但缩进保留 ---- + # ---- 2. 成片空格: 压掉, 但缩进保留 ---- print("成片空格 (nano/top 重绘路径)") rec = drive(binary, base, [ r"printf 'A'; printf '%0.s ' $(seq 1 200); printf 'B\n'", @@ -210,7 +210,7 @@ def main(): check("拿到记录", False, "没有生成归档") continue check("%s 时补上可用 TERM" % label, "TERM_IS=xterm-256color" in rec, - "实际记录:%s" % rec.strip().replace("\n", " | ")[-80:]) + "实际记录: %s" % rec.strip().replace("\n", " | ")[-80:]) shutil.rmtree(archive, ignore_errors=True) print("\n%d 项检查, %d 项失败" % (total, fails)) diff --git a/tests/test_agent.c b/tests/test_agent.c index 0ac7090..69b3383 100644 --- a/tests/test_agent.c +++ b/tests/test_agent.c @@ -77,7 +77,7 @@ static char *dump_history(const Agent *a) return json_dump(a->messages); } -/* 配对不变量:任何 assistant(tool_calls) 后面必须**紧跟**同 id 的 tool 消息. +/* 配对不变量: 任何 assistant(tool_calls) 后面必须**紧跟**同 id 的 tool 消息. * 服务端就是这么要求的, 违反了下一轮直接 400 —— 所以每次动过历史之后都要查. */ static void check_pairing(Agent *a) { @@ -100,7 +100,7 @@ static void check_pairing(Agent *a) /* 一轮一轮摘下去 (/back 走的就是这条路), 历史在每一步都必须仍然自洽. * * 这里原来测的是"上下文超限自动裁剪" (agent_drop_oldest_round)—— 那套机制 - * 已经**整个删掉**:它的判据只是错误文案里的关键词, 却会不可逆地删掉最老的 + * 已经**整个删掉**: 它的判据只是错误文案里的关键词, 却会不可逆地删掉最老的 * 几轮对话. 现在超限就是如实报错、历史一个字节都不动, 怎么处理由用户决定 * (/back 摘一轮、/reset 重来). 但"摘掉一整轮之后 tool 配对不能散"这条 * 不变量与由谁触发无关, 所以留着继续测. */ @@ -160,7 +160,7 @@ static void test_take_last_round(void) free(h); json_free(r); - /* 关键:工具调用与结果必须整组一起走 */ + /* 关键: 工具调用与结果必须整组一起走 */ r = agent_take_last_round(&a); CHECK(r && json_array_size(r) == 4, "第一轮应 4 条, 实际 %zu", r ? json_array_size(r) : 0); @@ -188,7 +188,7 @@ static void test_take_last_round(void) } /* ------------------------------------------------------------------ */ -/* 结构体检:assistant(tool_calls) 与 tool 消息的配对 */ +/* 结构体检: assistant(tool_calls) 与 tool 消息的配对 */ /* ------------------------------------------------------------------ */ /* 造一条 assistant, 带 count 个工具调用 (id 依次是 c0, c1, ...) */ @@ -262,7 +262,7 @@ static void test_repair_pairing(void) { int fixed = 0; - puts("结构体检:本来没问题就不该动它"); + puts("结构体检: 本来没问题就不该动它"); JsonValue *ok = json_new_array(); json_array_push(ok, mk_plain("user", "跑一下")); json_array_push(ok, mk_assistant(2)); @@ -275,8 +275,8 @@ static void test_repair_pairing(void) CHECK(fixed == 0, "合法历史的修复处数应为 0"); json_free(ok); - puts("缺 tool 消息:补齐, 且补在原位"); - /* 复现真实报错:assistant 带两个调用, 中间夹了一条 user (图省事直接 + puts("缺 tool 消息: 补齐, 且补在原位"); + /* 复现真实报错: assistant 带两个调用, 中间夹了一条 user (图省事直接 * 模仿当年那个 bug 的形状), 第二个调用没人回应 */ JsonValue *bad = json_new_array(); json_array_push(bad, mk_plain("user", "看看图")); @@ -289,9 +289,9 @@ static void test_repair_pairing(void) CHECK(r != NULL, "对不上时应该返回重建好的数组"); if (r) { CHECK(pairing_ok(r), "修完之后配对必须成立"); - /* 两处:给 c1 补一条占位 (它没被回应), 再把错位的那条 c1 结果丢掉 + /* 两处: 给 c1 补一条占位 (它没被回应), 再把错位的那条 c1 结果丢掉 * (它被 user 消息隔开, 已经不在 tool 段里了 —— 留着就是孤儿). - * 丢掉真实结果听着可惜, 但结构坏了只能二选一:要么丢它, 要么留一段 + * 丢掉真实结果听着可惜, 但结构坏了只能二选一: 要么丢它, 要么留一段 * 服务端必然拒绝的历史. 模型会从占位消息里知道该重跑. */ CHECK(fixed == 2, "应补 1 处、丢 1 处孤儿, 实际 %d", fixed); CHECK(json_array_size(r) == 5, @@ -307,7 +307,7 @@ static void test_repair_pairing(void) } json_free(bad); - puts("末尾悬空:assistant 之后再没有消息"); + puts("末尾悬空: assistant 之后再没有消息"); JsonValue *tail = json_new_array(); json_array_push(tail, mk_plain("user", "跑一下")); json_array_push(tail, mk_assistant(1)); @@ -317,7 +317,7 @@ static void test_repair_pairing(void) json_free(r); json_free(tail); - puts("孤儿 tool 消息:没有对应的 tool_calls, 要丢掉"); + puts("孤儿 tool 消息: 没有对应的 tool_calls, 要丢掉"); JsonValue *orphan = json_new_array(); json_array_push(orphan, mk_plain("user", "你好")); json_array_push(orphan, mk_tool("谁也不是")); @@ -343,19 +343,19 @@ static void test_repair_pairing(void) } /* ------------------------------------------------------------------ */ -/* 面向用户的历史口径:不含 system */ +/* 面向用户的历史口径: 不含 system */ /* ------------------------------------------------------------------ */ static void test_conversation_len(void) { - puts("历史条数:system 是配置, 不该算进对话"); + puts("历史条数: system 是配置, 不该算进对话"); Agent a; agent_init(&a); CHECK(agent_history_len(&a) == 0, "空 agent 的数组长度是 0"); CHECK(agent_conversation_len(&a) == 0, "空 agent 的对话长度也是 0"); - /* 只有 system:数组里 1 条, 但对话是空的 */ + /* 只有 system: 数组里 1 条, 但对话是空的 */ agent_add_system(&a, "你是助手"); CHECK(agent_history_len(&a) == 1, "只有 system 时数组长度 1"); CHECK(agent_conversation_len(&a) == 0, @@ -395,7 +395,7 @@ int main(void) agent_add_user(&a, "U1"); CHECK(agent_history_len(&a) == 2, "应 2 条"); - /* 关键不变量:重复设置 system 应替换而不是追加 */ + /* 关键不变量: 重复设置 system 应替换而不是追加 */ agent_add_system(&a, "S2"); CHECK(count_role(&a, "system") == 1, "重复设置 system 不应叠加, 实际 %zu 条", count_role(&a, "system")); diff --git a/tests/test_archive.c b/tests/test_archive.c index 92b6bd8..e3c87c5 100644 --- a/tests/test_archive.c +++ b/tests/test_archive.c @@ -37,38 +37,38 @@ int main(void) if (!dir) { perror("mkdtemp"); return 2; } char err[256]; - puts("根名:8 位小写字母数字"); + puts("根名: 8 位小写字母数字"); char *n1 = NULL; CHECK(archive_next_name(dir, NULL, NULL, 0, &n1, err, sizeof err) == 0, "生成失败: %s", err); CHECK(n1 && strlen(n1) == ARCHIVE_ROOT_LEN, "根名应为 %d 位, 实际 %s", ARCHIVE_ROOT_LEN, n1 ? n1 : "(null)"); - CHECK(n1 && is_alnum_lower(n1, strlen(n1)), "根名字符集应为 [a-z0-9]:%s", n1); + CHECK(n1 && is_alnum_lower(n1, strlen(n1)), "根名字符集应为 [a-z0-9]: %s", n1); free(n1); - /* 空字符串与 NULL 等价:都表示"没有来源" */ + /* 空字符串与 NULL 等价: 都表示"没有来源" */ char *n2 = NULL; CHECK(archive_next_name(dir, "", NULL, 0, &n2, err, sizeof err) == 0, "空 lineage 应可用"); CHECK(n2 && strlen(n2) == ARCHIVE_ROOT_LEN, "空 lineage 也应给出根名"); free(n2); - puts("分支名:来源 + _ + 3 位"); + puts("分支名: 来源 + _ + 3 位"); char *b = NULL; CHECK(archive_next_name(dir, "a1b2c3d4", NULL, 0, &b, err, sizeof err) == 0, "分支生成失败: %s", err); - CHECK(b && strncmp(b, "a1b2c3d4_", 9) == 0, "应以来源加下划线开头:%s", b); - CHECK(b && strlen(b) == 8 + 1 + ARCHIVE_BRANCH_LEN, "分支长度不对:%s", b); - CHECK(b && is_alnum_lower(b + 9, ARCHIVE_BRANCH_LEN), "后缀字符集不对:%s", b); + CHECK(b && strncmp(b, "a1b2c3d4_", 9) == 0, "应以来源加下划线开头: %s", b); + CHECK(b && strlen(b) == 8 + 1 + ARCHIVE_BRANCH_LEN, "分支长度不对: %s", b); + CHECK(b && is_alnum_lower(b + 9, ARCHIVE_BRANCH_LEN), "后缀字符集不对: %s", b); free(b); /* 多次生成应当不同 (随机性) */ char *x1 = NULL, *x2 = NULL; archive_next_name(dir, "a1b2c3d4", NULL, 0, &x1, err, sizeof err); archive_next_name(dir, "a1b2c3d4", NULL, 0, &x2, err, sizeof err); - CHECK(x1 && x2 && strcmp(x1, x2) != 0, "两次生成不应相同:%s / %s", x1, x2); + CHECK(x1 && x2 && strcmp(x1, x2) != 0, "两次生成不应相同: %s / %s", x1, x2); free(x1); free(x2); - puts("长度上限:接不下就重新起根"); - /* 边界:来源长度 + 1 + 3 + ".json" <= 240 */ + puts("长度上限: 接不下就重新起根"); + /* 边界: 来源长度 + 1 + 3 + ".json" <= 240 */ size_t maxlineage = ARCHIVE_NAME_MAX - 1 - ARCHIVE_BRANCH_LEN - strlen(".json"); char *ok = xmalloc(maxlineage + 1); memset(ok, 'a', maxlineage); @@ -78,7 +78,7 @@ int main(void) CHECK(archive_next_name(dir, ok, NULL, 0, &edge, err, sizeof err) == 0, "边界生成失败"); CHECK(edge && strncmp(edge, "aaa", 3) == 0, "刚好装得下时应继续接分支"); CHECK(edge && strlen(edge) + strlen(".json") <= ARCHIVE_NAME_MAX, - "完整文件名不应超上限:%zu", edge ? strlen(edge) + 5 : 0); + "完整文件名不应超上限: %zu", edge ? strlen(edge) + 5 : 0); free(edge); /* 再多一位就装不下了 */ @@ -91,22 +91,22 @@ int main(void) "超限时应退回 %d 位根名, 实际 %s (%zu 位)", ARCHIVE_ROOT_LEN, fresh ? fresh : "(null)", fresh ? strlen(fresh) : 0); free(fresh); - puts("前缀:临时存档"); + puts("前缀: 临时存档"); char *tp = NULL; CHECK(archive_next_name(dir, "a1b2c3d4", "tmp_", 0, &tp, err, sizeof err) == 0, "带前缀生成失败"); CHECK(tp && strncmp(tp, "tmp_a1b2c3d4_", 13) == 0, - "前缀应在最前面、其后才是分支名:%s", tp ? tp : "(null)"); + "前缀应在最前面、其后才是分支名: %s", tp ? tp : "(null)"); free(tp); char *tr = NULL; CHECK(archive_next_name(dir, NULL, "tmp_", 0, &tr, err, sizeof err) == 0, "带前缀建根失败"); CHECK(tr && strncmp(tr, "tmp_", 4) == 0 && strlen(tr) == 4 + ARCHIVE_ROOT_LEN, - "新根也应带前缀:%s", tr ? tr : "(null)"); + "新根也应带前缀: %s", tr ? tr : "(null)"); free(tr); - /* 前缀要算进长度上限:原本刚好装得下的, 加了 tmp_ 就该退回新根 */ + /* 前缀要算进长度上限: 原本刚好装得下的, 加了 tmp_ 就该退回新根 */ char *edgep = NULL; CHECK(archive_next_name(dir, ok, "tmp_", 0, &edgep, err, sizeof err) == 0, "边界失败"); CHECK(edgep && strlen(edgep) == 4 + ARCHIVE_ROOT_LEN, @@ -130,8 +130,8 @@ int main(void) &path, &name, err, sizeof err) == 0, "导出失败: %s", err); CHECK(name && strncmp(name, "parentname_", 11) == 0, - "导出名应挂在来源下:%s", name ? name : "(null)"); - CHECK(path && access(path, R_OK) == 0, "文件应存在:%s", path ? path : ""); + "导出名应挂在来源下: %s", name ? name : "(null)"); + CHECK(path && access(path, R_OK) == 0, "文件应存在: %s", path ? path : ""); char *created = NULL, *saved = NULL, *model = NULL, *iname = NULL, *iparent = NULL; JsonValue *back = archive_load(path, &created, &saved, &model, &iname, &iparent, @@ -140,13 +140,13 @@ int main(void) CHECK(back && json_array_size(back) == 1, "消息条数应为 1"); CHECK(iname && name && strcmp(iname, name) == 0, "内容里的 name 应与文件名一致"); CHECK(iparent && strcmp(iparent, "parentname") == 0, - "内容里的 parent 应记下来源:%s", iparent ? iparent : "(null)"); + "内容里的 parent 应记下来源: %s", iparent ? iparent : "(null)"); CHECK(model && strcmp(model, "test-model") == 0, "model 应往返"); CHECK(created && strstr(created, "2023-") != NULL, - "created 应可读:%s", created ? created : "(null)"); + "created 应可读: %s", created ? created : "(null)"); /* saved 是"这份存档写出时的时间", 与 created (这段对话的开始时间)是两回事 */ CHECK(saved && strstr(saved, "2023-") != NULL, - "saved 应可读:%s", saved ? saved : "(null)"); + "saved 应可读: %s", saved ? saved : "(null)"); json_free(back); json_free(msgs); free(created); free(saved); free(model); free(iname); free(iparent); diff --git a/tests/test_attach.c b/tests/test_attach.c index 6753a90..8db06d0 100644 --- a/tests/test_attach.c +++ b/tests/test_attach.c @@ -53,7 +53,7 @@ static size_t count_type(const JsonValue *parts, const char *type) } /* 记一下回调被问了几次 —— 它只在遇到图片时该被问到. - * 答案和计数放在一起, 免得"计数版只实现了一种答案"这种测试自己的错. */ + * 答案和计数放在一起, 以免"计数版只实现了一种答案"这种测试自己的错. */ static int ask_count; static int ask_answer; static int counting_ask(void *ud) { (void)ud; ask_count++; return ask_answer; } @@ -86,7 +86,7 @@ int main(void) free(big); } - puts("glob 展开:命中几个就暂存几个"); + puts("glob 展开: 命中几个就暂存几个"); attach_clear(); size_t skipped = 0; char err[256]; @@ -127,7 +127,7 @@ int main(void) added = attach_glob(f_big, NULL, NULL, &skipped, err, sizeof err); CHECK(added == 0 && skipped == 1, "太大的文本被跳过 (added=%d)", added); - puts("图片:认内容不认扩展名, 并走备份引用"); + puts("图片: 认内容不认扩展名, 并走备份引用"); attach_clear(); added = attach_glob(f_png, NULL, NULL, &skipped, err, sizeof err); CHECK(added == 1, "png 被收下"); @@ -151,7 +151,7 @@ int main(void) puts("匹配不到 / 参数非法"); attach_clear(); added = attach_glob("/tmp/绝对不存在的模式*.zzz", NULL, NULL, &skipped, err, sizeof err); - CHECK(added == -1 && err[0], "匹配不到返回 -1 并给出原因:%s", err); + CHECK(added == -1 && err[0], "匹配不到返回 -1 并给出原因: %s", err); added = attach_glob(NULL, NULL, NULL, &skipped, err, sizeof err); CHECK(added == -1, "NULL 模式返回 -1"); added = attach_glob("", NULL, NULL, &skipped, err, sizeof err); diff --git a/tests/test_capability.c b/tests/test_capability.c index 7060575..895adcd 100644 --- a/tests/test_capability.c +++ b/tests/test_capability.c @@ -1,10 +1,10 @@ /* test_capability.c - 能力探测的纯逻辑部分 * * 探测序列本身 (基线 -> 合并 -> 拆开)要发 HTTP, 属于端到端范畴, - * 由 e2e 第 51 段对着 mock 覆盖:正常模型 / 不收图片 / 不收 tools / + * 由 e2e 第 51 段对着 mock 覆盖: 正常模型 / 不收图片 / 不收 tools / * 探测失败 (401)/ --no-probe 五种走法都在那儿验. * - * 这里只测不需要网络的:名字映射、关闭探测时的语义、缓存查询. + * 这里只测不需要网络的: 名字映射、关闭探测时的语义、缓存查询. * 其中"关闭探测必须给未测而不是不支持"那条是回归测试 —— 那句话看着像废话, * 但 CAP_NO 恰好是 0, 而 Agent 是 memset 出来的, 忘了显式初始化就会一声不响地 * 把所有工具摘掉. 这个 bug 真的发生过一次. @@ -44,8 +44,8 @@ int main(void) /* 任何不是 YES/NO 的值都该落到"未测", 而不是碰巧落到某一个 */ CHECK(strcmp(cap_name(42), "未测") == 0, "越界值应落到未测"); - puts("关掉探测:一律未测, 绝不冒充结论"); - /* 用可变数组而不是字面量:LlmConfig 的这些字段是 char * (真要释放的), + puts("关掉探测: 一律未测, 绝不冒充结论"); + /* 用可变数组而不是字面量: LlmConfig 的这些字段是 char * (真要释放的), * 而 -Wwrite-strings 下 "字面量" 是 const char * —— 直接赋值会被警告. */ char model[] = "some-model"; char base_url[] = "http://127.0.0.1:1/v1"; @@ -66,7 +66,7 @@ int main(void) CHECK(c.tools == CAP_UNKNOWN, "禁用时 tools 应为未测, 实际 %d", c.tools); CHECK(c.conclusive == 0, "禁用时不该声称有结论"); - /* 关键回归:禁用时的结果拿去决定工具表, 必须"全都留着" */ + /* 关键回归: 禁用时的结果拿去决定工具表, 必须"全都留着" */ CHECK(c.vision != CAP_NO, "禁用时不能表现为不支持 (否则会藏掉 read_image)"); CHECK(c.tools != CAP_NO, "禁用时不能表现为不支持 (否则会摘掉整张工具表)"); diff --git a/tests/test_config.c b/tests/test_config.c index c74ec82..b0a99f3 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -50,7 +50,7 @@ static void test_defaults(void) CHECK(c.api_key && c.api_key[0] == '\0', "默认 api_key 应为空"); CHECK(c.stream == 1, "默认应开启流式"); CHECK(c.use_tools == 1, "默认应启用工具"); - /* 默认不限往返次数:0 (或负数)= 不限, 只有正数才是上限 */ + /* 默认不限往返次数: 0 (或负数)= 不限, 只有正数才是上限 */ CHECK(c.max_steps == 0, "默认应不限往返次数, 实际 %d", c.max_steps); config_free(&c); } @@ -66,7 +66,7 @@ static void test_env_key(void) config_apply_env(&c); CHECK(c.api_key[0] == '\0', "无环境变量时 key 应为空"); - /* 只设 DEEPSEEK_API_KEY:默认就应读它 */ + /* 只设 DEEPSEEK_API_KEY: 默认就应读它 */ setenv("DEEPSEEK_API_KEY", "sk-deepseek", 1); config_apply_env(&c); CHECK(strcmp(c.api_key, "sk-deepseek") == 0, @@ -82,7 +82,7 @@ static void test_env_key(void) * 把 OpenAI 的密钥发给 DeepSeek 的端点是没有意义的 */ unset_all(); setenv("OPENAI_API_KEY", "sk-openai", 1); - /* 模拟"配置文件没写 api_key":必须先清掉上一步留下的值 */ + /* 模拟"配置文件没写 api_key": 必须先清掉上一步留下的值 */ free(c.api_key); c.api_key = xstrdup(""); config_apply_env(&c); @@ -121,7 +121,7 @@ static void test_env_others(void) unset_all(); config_free(&c); - /* 环境变量是临时开关:写错了应当被忽略, 而不是把程序拦下来 */ + /* 环境变量是临时开关: 写错了应当被忽略, 而不是把程序拦下来 */ config_defaults(&c); setenv("LIGENT_THINKING", "随便写的", 1); setenv("LIGENT_REASONING_EFFORT", "超高", 1); @@ -305,7 +305,7 @@ static void test_file_override(void) unset_all(); } -/* 配置只认二进制所在目录:不做用户级、系统级, 也不做当前目录查找 */ +/* 配置只认二进制所在目录: 不做用户级、系统级, 也不做当前目录查找 */ static void test_binary_dir_lookup(void) { puts("配置位置 = 二进制所在目录"); @@ -370,11 +370,11 @@ static int load_temp_config(LigentConfig *c, const char *json) } /* 厂商专属的环境变量不能越权覆盖配置文件里写好的密钥. - * 这正是用户踩到的坑:配置写了 OpenRouter 的 key, + * 这正是用户踩到的坑: 配置写了 OpenRouter 的 key, * shell 里残留的 DEEPSEEK_API_KEY 把它顶掉, 于是 A 家的密钥发给了 B 家. */ static void test_env_key_precedence(void) { - puts("api_key 优先级:厂商环境变量不得越权"); + puts("api_key 优先级: 厂商环境变量不得越权"); unset_all(); setenv("DEEPSEEK_API_KEY", "sk-leftover-in-shell", 1); @@ -421,12 +421,12 @@ static void test_env_key_precedence(void) unset_all(); } -/* 软链接:配置目录要跟着"用户实际调用那个文件"走. +/* 软链接: 配置目录要跟着"用户实际调用那个文件"走. * 否则把 ligent 软链到 ~/bin 之后, 配置会被悄悄挪到源文件所在的目录, * 用户放在 ~/bin 旁边的 ligent.json 就永远读不到. */ static void test_symlink_dir(void) { - puts("软链接:配置目录跟着软链接走"); + puts("软链接: 配置目录跟着软链接走"); char self[PATH_MAX]; ssize_t n = readlink("/proc/self/exe", self, sizeof self - 1); @@ -444,7 +444,7 @@ static void test_symlink_dir(void) return; } - /* 核心:指向软链接时取软链接所在目录, 而不是源文件所在目录 */ + /* 核心: 指向软链接时取软链接所在目录, 而不是源文件所在目录 */ char *got = config_binary_dir(link); CHECK(got && strcmp(got, dir) == 0, "应取软链接所在目录 %s, 实际 %s", dir, got ? got : "(null)"); diff --git a/tests/test_http.c b/tests/test_http.c index cb95f39..a42c4b8 100644 --- a/tests/test_http.c +++ b/tests/test_http.c @@ -22,7 +22,7 @@ static int checks = 0; int main(void) { - puts("重试判定:只重试服务端明确报错的情况"); + puts("重试判定: 只重试服务端明确报错的情况"); /* 应当重试 */ long retryable[] = { 408, 409, 429, 500, 502, 503, 504 }; @@ -30,7 +30,7 @@ int main(void) CHECK(http_status_retryable(retryable[i]) == 1, "HTTP %ld 应当可重试", retryable[i]); - /* 绝不应当重试:这些是确定性错误, 重试只是浪费时间和钱 */ + /* 绝不应当重试: 这些是确定性错误, 重试只是浪费时间和钱 */ long permanent[] = { 400, 401, 403, 404, 405, 413, 415, 422, 501, 200, 201, 204, 301, 0, -1 }; for (size_t i = 0; i < sizeof permanent / sizeof permanent[0]; i++) @@ -53,7 +53,7 @@ int main(void) size_t sz = 0; FILE *f = open_memstream(&buf, &sz); - /* 这条正是曾经漏出去的:libcurl 的 HTTP/2 调试行走的是 CURLINFO_TEXT, + /* 这条正是曾经漏出去的: libcurl 的 HTTP/2 调试行走的是 CURLINFO_TEXT, * 不是 HEADER_OUT, 所以只处理 HEADER_OUT 的做法根本挡不住 */ const char *h2 = "== Info: [HTTP/2] [1] [authorization: Bearer sk-0f114e3dac8249f0a36fd8fd5c517360]\n"; http_write_redacted(f, h2, strlen(h2), key); @@ -84,12 +84,12 @@ int main(void) free(buf); } - puts("\n空请求体:POST 分支也要扛得住 body == NULL"); + puts("\n空请求体: POST 分支也要扛得住 body == NULL"); { /* body 允许为 NULL (http_probe 就是这么调的), 但 POST 分支以前写的是 * 无条件的 strlen(body) —— 传 NULL 会在 curl_easy_setopt 期间就段错误. * - * 这里故意用一个格式非法的 URL:libcurl 会在真正联网之前就拒绝它, + * 这里故意用一个格式非法的 URL: libcurl 会在真正联网之前就拒绝它, * 所以本测试仍然不碰网络. 而 strlen(NULL) 发生在设置 POSTFIELDS 期间、 * curl_easy_perform 之前, 也就是说旧代码在这里必崩 —— 这个断言对旧 * 代码是有效的回归守卫, 不是走过场. */ diff --git a/tests/test_i18n.c b/tests/test_i18n.c index a0b66fc..43698b7 100644 --- a/tests/test_i18n.c +++ b/tests/test_i18n.c @@ -1,8 +1,8 @@ /* test_i18n.c - 界面文案表 * - * 这张表是"每种语言一条". 它最容易出的错不是逻辑, 而是漏:加了键忘了翻, + * 这张表是"每种语言一条". 它最容易出的错不是逻辑, 而是漏: 加了键忘了翻, * 或者译文里少了个占位符 ({path} 写成 {paths})—— 用户看到的就是一句缺字的话. - * 所以这里的检查全部是机械的:遍历表, 逐条比对. + * 所以这里的检查全部是机械的: 遍历表, 逐条比对. * * 文案本身好不好不在这里管; "英文界面是不是每句都有着落"才是. */ @@ -71,7 +71,7 @@ static int printf_specs(const char *s, char (*out)[16], int max) return n; } -/* 有没有中日韩字符 (含全角标点:〔〕 ()). 英文那一列里出现它, 基本就是 +/* 有没有中日韩字符 (含全角标点: 〔〕 ()). 英文那一列里出现它, 基本就是 * "照抄了中文"或者"美化用的框线忘了换" —— 用户看到的是英文句子中间卡着一对 * 中文括号. 顺带把"英文栏里混进日文假名"这种复制粘贴也一并挡住. */ static int has_cjk(const char *s) @@ -106,12 +106,12 @@ int main(void) CHECK(!has_cjk(m->en ? m->en : ""), "%s 的英文里混进了中日韩字符", m->key); - /* key 唯一性:比较是 O(n²), 但每个 key 只记一条检查 —— 写成循环里 + /* key 唯一性: 比较是 O(n²), 但每个 key 只记一条检查 —— 写成循环里 * CHECK 的话, check-docs 数出来的"单元项数"就没意义了. */ int dup = 0; for (size_t j = i + 1; j < n; j++) if (strcmp(tab[j].key, m->key) == 0) dup = 1; - CHECK(!dup, "key 重复:%s", m->key); + CHECK(!dup, "key 重复: %s", m->key); char pe[16][32], pz[16][32]; int ne = placeholders(m->en ? m->en : "", pe, 16); @@ -122,32 +122,32 @@ int main(void) int found = 0; for (int b = 0; b < nz; b++) if (strcmp(pe[a], pz[b]) == 0) found = 1; - CHECK(found, "%s:英文的 {%s} 在中文里没有对应", m->key, pe[a]); + CHECK(found, "%s: 英文的 {%s} 在中文里没有对应", m->key, pe[a]); } /* 译文里多出一个原文没有的占位符, 同样会在运行时漏字 */ for (int a = 0; a < nz && a < ne; a++) { int found = 0; for (int b = 0; b < ne; b++) if (strcmp(pz[a], pe[b]) == 0) found = 1; - CHECK(found, "%s:中文的 {%s} 在英文里没有对应", m->key, pz[a]); + CHECK(found, "%s: 中文的 {%s} 在英文里没有对应", m->key, pz[a]); } } /* 表里有一部分键是**当 printf 模板用的** (repl.usage.*、repl.model.row、 - * json.* …):调用方直接把 t(key) 交给 printf/snprintf. 这些条目的 + * json.* …): 调用方直接把 t(key) 交给 printf/snprintf. 这些条目的 * {name} 检查管不到, 得单独比转换符 —— 少一个 %s 会读到别的参数, * 类型写错 (%d 对 %zu)更是直接踩内存. */ - puts("printf 模板:两种语言的转换符必须一模一样"); + puts("printf 模板: 两种语言的转换符必须一模一样"); for (size_t i = 0; i < n; i++) { const LigentMsg *m = &tab[i]; char se[16][16], sz[16][16]; int ce = printf_specs(m->en ? m->en : "", se, 16); int cz = printf_specs(m->zh ? m->zh : "", sz, 16); if (ce == 0 && cz == 0) continue; /* 不是模板, 跳过 */ - CHECK(ce == cz, "%s:转换符个数不同 (en %d / zh %d)", m->key, ce, cz); + CHECK(ce == cz, "%s: 转换符个数不同 (en %d / zh %d)", m->key, ce, cz); for (int a = 0; a < ce && a < cz; a++) CHECK(strcmp(se[a], sz[a]) == 0, - "%s:第 %d 个转换符不同 (en %s / zh %s)", + "%s: 第 %d 个转换符不同 (en %s / zh %s)", m->key, a + 1, se[a], sz[a]); } @@ -179,7 +179,7 @@ int main(void) const char *two = tf("doctor.val.match_bad", "guess", "OpenAI", "owner", "DeepSeek", NULL); CHECK(strstr(two, "DeepSeek") && strstr(two, "OpenAI"), "两个值都要在"); - CHECK(strstr(two, "{") == NULL, "不该有没填上的占位符:%s", two); + CHECK(strstr(two, "{") == NULL, "不该有没填上的占位符: %s", two); puts("语言名解析"); CHECK(i18n_parse_lang("zh") == LIGENT_LANG_ZH, "zh"); @@ -192,7 +192,7 @@ int main(void) CHECK(i18n_parse_lang("") < 0, "空串返回 -1"); CHECK(i18n_parse_lang(NULL) < 0, "NULL 返回 -1"); - puts("环境探测:LIGENT_LANG > LC_ALL > LC_MESSAGES > LANG, 认不出/没设 -> 英文"); + puts("环境探测: LIGENT_LANG > LC_ALL > LC_MESSAGES > LANG, 认不出/没设 -> 英文"); unsetenv("LIGENT_LANG"); unsetenv("LC_ALL"); unsetenv("LC_MESSAGES"); diff --git a/tests/test_image.c b/tests/test_image.c index 4b0eb83..a71603e 100644 --- a/tests/test_image.c +++ b/tests/test_image.c @@ -56,7 +56,7 @@ static void test_sniff(void) static void test_backup(void) { - puts("本地备份:命名、内容、不重复"); + puts("本地备份: 命名、内容、不重复"); char tmpl[] = "/tmp/ligent_img_XXXXXX"; char *dir = mkdtemp(tmpl); CHECK(dir != NULL, "建临时目录失败"); @@ -70,11 +70,11 @@ static void test_backup(void) if (!b1) { return; } /* 名字形如 <上传时间戳>_<4位随机>. */ - CHECK(strlen(b1) > 5, "名字不该太短:%s", b1); + CHECK(strlen(b1) > 5, "名字不该太短: %s", b1); char *dot = strrchr(b1, '.'); - CHECK(dot && strcmp(dot, ".png") == 0, "扩展名应从嗅探结果来:%s", b1); - CHECK(dot - b1 >= 7, "时间戳 + 下划线 + 4 位随机:%s", b1); - CHECK(b1[dot - b1 - 5] == '_', "下划线位置应在随机后缀前:%s", b1); + CHECK(dot && strcmp(dot, ".png") == 0, "扩展名应从嗅探结果来: %s", b1); + CHECK(dot - b1 >= 7, "时间戳 + 下划线 + 4 位随机: %s", b1); + CHECK(b1[dot - b1 - 5] == '_', "下划线位置应在随机后缀前: %s", b1); char *p1 = image_backup_path(dir, b1); size_t len = 0; @@ -84,7 +84,7 @@ static void test_backup(void) /* 再存一次应该拿到不同的名字 (随机后缀), 而不是互相覆盖 */ char *b2 = image_backup_save(dir, data, n, "png"); - CHECK(b2 && strcmp(b1, b2) != 0, "两次备份不该同号:%s / %s", + CHECK(b2 && strcmp(b1, b2) != 0, "两次备份不该同号: %s / %s", b1, b2 ? b2 : "(null)"); free(b2); @@ -97,8 +97,8 @@ static void test_backup(void) static void test_base64(void) { - puts("base64:长度、补位、往返"); - /* 用几组长度覆盖三种补位情况:3n、3n+1、3n+2 */ + puts("base64: 长度、补位、往返"); + /* 用几组长度覆盖三种补位情况: 3n、3n+1、3n+2 */ struct { const char *in; const char *want; } cases[] = { { "", "" }, { "f", "Zg==" }, @@ -164,7 +164,7 @@ static JsonValue *history_with_ref(const char *backup) static void test_expand(void) { - puts("请求前展开:引用 -> image_url + base64"); + puts("请求前展开: 引用 -> image_url + base64"); char tmpl[] = "/tmp/ligent_imgx_XXXXXX"; char *dir = mkdtemp(tmpl); CHECK(dir != NULL, "建临时目录失败"); @@ -202,7 +202,7 @@ static void test_expand(void) CHECK(url && strlen(url) == 22 + base64_encoded_len(n), "data URL 长度应等于前缀 + base64 长度"); - /* 关键不变量:原始历史没被改动, 且里面没有图片字节 */ + /* 关键不变量: 原始历史没被改动, 且里面没有图片字节 */ char *orig = json_dump(msgs); CHECK(strstr(orig, "ligent_image") != NULL, "原历史里应还是引用"); CHECK(strstr(orig, "base64") == NULL, "原历史里不该出现 base64"); @@ -210,7 +210,7 @@ static void test_expand(void) free(orig); json_free(wire); - /* --- 备份没了:就地降级 --- */ + /* --- 备份没了: 就地降级 --- */ char *bpath = image_backup_path(dir, backup); CHECK(unlink(bpath) == 0, "删掉备份文件"); free(bpath); @@ -227,7 +227,7 @@ static void test_expand(void) dt ? dt : "(null)"); json_free(wire); - /* 降级是**就地**的:原历史里的坏引用应该已经被换掉, 否则每轮都会重试 */ + /* 降级是**就地**的: 原历史里的坏引用应该已经被换掉, 否则每轮都会重试 */ char *after = json_dump(msgs); CHECK(strstr(after, "ligent_image") == NULL, "原历史里的坏引用应已被清掉"); CHECK(strstr(after, "ligent_note") != NULL, "应换成内部说明块"); @@ -239,11 +239,11 @@ static void test_expand(void) CHECK(degraded == 0, "第二次不该再降级"); json_free(wire); - /* image_has_refs:降级之后应为 0, 这正是 4xx 兜底的判据 */ + /* image_has_refs: 降级之后应为 0, 这正是 4xx 兜底的判据 */ CHECK(image_has_refs(msgs) == 0, "降级后历史里不该还有图片引用"); json_free(msgs); - /* --- 4xx 兜底:image_degrade_all --- */ + /* --- 4xx 兜底: image_degrade_all --- */ JsonValue *m2 = history_with_ref(backup); CHECK(image_has_refs(m2) == 1, "有引用时应报 1"); int dn = image_degrade_all(m2); @@ -280,7 +280,7 @@ static void test_expand(void) static void test_pending(void) { - puts("待发附件:取走即清空"); + puts("待发附件: 取走即清空"); image_pending_take(); /* 先清干净 */ image_pending_add("1700000000_aaaa.png"); diff --git a/tests/test_json.c b/tests/test_json.c index 1562f1e..54b6560 100644 --- a/tests/test_json.c +++ b/tests/test_json.c @@ -79,7 +79,7 @@ static void test_numbers(void) CHECK(json_number_value(json_array_get(v, 5), 0) == 123456789012.0, "[5]"); json_free(v); - /* 严格按 RFC 8259 扫描数字:这些以前全都被接受 —— strtod 会把 "1e" 读成 1、 + /* 严格按 RFC 8259 扫描数字: 这些以前全都被接受 —— strtod 会把 "1e" 读成 1、 * "5." 读成 5, 于是一个手写错的数字会被静默当成另一个数用下去 (配置文件、 * 存档都是手写的, 用户只会在很久之后从一个不相干的现象里发现它). */ const char *bad_nums[] = { @@ -167,14 +167,14 @@ static void test_nested_depth(void) json_free(ok); } -/* 报错必须能定位:只有"语法错了"而不知道错在哪, 等于没有报错 */ +/* 报错必须能定位: 只有"语法错了"而不知道错在哪, 等于没有报错 */ static void test_error_location(void) { puts("test_error_location"); char err[256]; JsonValue *v; - /* 尾随逗号:行号必须指向真正出问题的那一行 */ + /* 尾随逗号: 行号必须指向真正出问题的那一行 */ v = json_parse("{\n \"a\": 1,\n}\n", err, sizeof err); CHECK(v == NULL, "尾随逗号应报错"); CHECK(strstr(err, "第 3 行") != NULL, "应报出行号: %s", err); @@ -182,7 +182,7 @@ static void test_error_location(void) CHECK(strstr(err, "}") != NULL, "应回显出错处原文: %s", err); json_free(v); - /* 未闭合的数组:位置应指向输入结束处 */ + /* 未闭合的数组: 位置应指向输入结束处 */ v = json_parse("[1, 2\n", err, sizeof err); CHECK(v == NULL, "未闭合数组应报错"); CHECK(strstr(err, "第 2 行") != NULL, "未闭合应指向结尾: %s", err); @@ -225,8 +225,8 @@ static void test_error_location(void) * 服务端会因为请求体里有非法 UTF-8 直接回 * 400 ... invalid unicode code point * 而那段坏数据一旦进了对话历史, 之后每次请求都会失败 —— 除非丢掉整轮对话. - * 坏字节的来源很日常:某条命令在非 UTF-8 locale 下打出来的路径、被截断的多 - * 字节字符、二进制垃圾. 所以这条不能只靠"在入口清理", 必须在序列化器这层兜住: + * 坏字节的来源很日常: 某条命令在非 UTF-8 locale 下打出来的路径、被截断的多 + * 字节字符、二进制垃圾. 所以这条不能只靠"在入口清理", 必须在序列化器这层兜住: * 出口是唯一能保证"发出去的永远合法"的地方. * * 这个校验函数故意用 utf8_seq_len 自己走一遍, 而不是信任 dump 的实现 —— @@ -246,7 +246,7 @@ static void test_dump_utf8_safety(void) { puts("test_dump_utf8_safety"); - /* utf8_seq_len:合法序列返回长度 */ + /* utf8_seq_len: 合法序列返回长度 */ CHECK(utf8_seq_len("\xe4\xb8\xad", 3) == 3, "合法 CJK 应是 3 字节"); CHECK(utf8_seq_len("A", 1) == 1, "ASCII 应是 1 字节"); CHECK(utf8_seq_len("\xf0\x9f\x98\x80", 4) == 4, "emoji 应是 4 字节"); diff --git a/tests/test_lineedit.c b/tests/test_lineedit.c index 8ea6c36..df4a134 100644 --- a/tests/test_lineedit.c +++ b/tests/test_lineedit.c @@ -71,7 +71,7 @@ static void test_history_file(void) CHECK(strcmp(line_history_get(1), "/think high") == 0, "第 1 条应还原"); line_history_clear(); - /* 反斜杠和换行必须能原样往返:一条历史在文件里只占一行 */ + /* 反斜杠和换行必须能原样往返: 一条历史在文件里只占一行 */ unlink(path); line_history_add("echo C:\\Users\\me"); line_history_append(path, "echo C:\\Users\\me"); @@ -97,7 +97,7 @@ static void test_history_file(void) CHECK(strcmp(line_history_get(2), "中文历史") == 0, "中文应原样还原"); line_history_clear(); - /* 超过上限:只留最新的 keep 条, 并且文件被就地截短 */ + /* 超过上限: 只留最新的 keep 条, 并且文件被就地截短 */ unlink(path); for (int i = 0; i < 10; i++) { char *s = xasprintf("cmd-%d", i); @@ -168,7 +168,7 @@ int main(void) BUFEQ(lb, "eXllo"); linebuf_free(&lb); - puts("UTF-8:按字符而非字节移动"); + puts("UTF-8: 按字符而非字节移动"); linebuf_init(&lb); linebuf_set(&lb, "中文abc"); /* 中(3) 文(3) a b c = 9 字节 */ CHECK(lb.len == 9, "应为 9 字节, 实际 %zu", lb.len); @@ -216,7 +216,7 @@ int main(void) linebuf_end(&lb); CHECK(lb.pos == lb.len, "单行时 End 到行尾"); - /* 多行 (粘贴进来的那种):Home/End 只在本行内走 */ + /* 多行 (粘贴进来的那种): Home/End 只在本行内走 */ linebuf_set(&lb, "aaa\nbbb\nccc"); /* 长度 11 */ CHECK(lb.len == 11, "应为 11 字节, 实际 %zu", lb.len); linebuf_home(&lb); diff --git a/tests/test_markdown.c b/tests/test_markdown.c index 52440a1..da6abda 100644 --- a/tests/test_markdown.c +++ b/tests/test_markdown.c @@ -1,6 +1,6 @@ /* test_markdown.c - 流式 Markdown 渲染器测试 * - * 最关键的不变量:把同一段文本任意切分后逐片喂入, 输出必须与一次性喂入 + * 最关键的不变量: 把同一段文本任意切分后逐片喂入, 输出必须与一次性喂入 * 完全一致. SSE 的分片位置是随机的, 这条不成立就会漏出"半个星号". */ #include "markdown.h" @@ -191,7 +191,7 @@ static void test_bold_italic(void) CHECK(strcmp(vis, "斜\n") == 0, "斜体标记应被吃掉: [%s]", vis); free(vis); free(o); - /* 字面星号:乘法 */ + /* 字面星号: 乘法 */ o = render("2 * 3 * 4\n", 1); CHECK(strcmp(o, "2 * 3 * 4\n") == 0, "乘法星号应原样输出: [%s]", o); free(o); @@ -206,7 +206,7 @@ static void test_bold_italic(void) CHECK(strstr(o, "\033[0m\n") != NULL, "行尾应复位样式: [%s]", o); free(o); - /* 嵌套:粗体里的斜体 */ + /* 嵌套: 粗体里的斜体 */ o = render("**粗 *斜* 粗**\n", 1); vis = strip_ansi(o); CHECK(strcmp(vis, "粗 斜 粗\n") == 0, "嵌套可见文本: [%s]", vis); @@ -216,7 +216,7 @@ static void test_bold_italic(void) static void test_code(void) { puts("代码"); - /* 行内代码:保留反引号 (便于复制), 内容着色 */ + /* 行内代码: 保留反引号 (便于复制), 内容着色 */ char *o = render("用 `ls -la` 看看\n", 1); char *vis = strip_ansi(o); CHECK(strcmp(vis, "用 `ls -la` 看看\n") == 0, @@ -238,7 +238,7 @@ static void test_code(void) "语言标识与内容应完整保留: [%s]", vis); free(vis); free(o); - /* 未闭合的代码块:不能吞掉后面的内容 */ + /* 未闭合的代码块: 不能吞掉后面的内容 */ o = render("```\ncode\n", 1); vis = strip_ansi(o); CHECK(strcmp(vis, "```\ncode\n") == 0, "未闭合代码块: [%s]", vis); @@ -263,7 +263,7 @@ static void test_blocks(void) CHECK(strstr(o, "\033[36m+\033[0m") != NULL, "列表标记 + 应着色: [%s]", o); free(vis); free(o); - /* 表格不受影响:仍然是框线字符 */ + /* 表格不受影响: 仍然是框线字符 */ o = render("| a |\n|---|\n| 1 |\n", 1); vis = strip_ansi(o); CHECK(strstr(vis, "┌") && strstr(vis, "│") && strstr(vis, "└"), @@ -309,8 +309,8 @@ static void test_passthrough(void) static void test_fence_verbatim(void) { - puts("围栏代码块:全部不渲染"); - /* 纯代码块:输出里不应出现任何 ANSI 转义 */ + puts("围栏代码块: 全部不渲染"); + /* 纯代码块: 输出里不应出现任何 ANSI 转义 */ char *o = render("```c\nint x = 1; // **不是粗体**\n```\n", 1); CHECK(strstr(o, "\033") == NULL, "代码块内不应有任何 ANSI 转义: [%s]", o); @@ -367,7 +367,7 @@ static void test_underscore_italic(void) CHECK(strstr(o, "\033[3m斜体") != NULL, "中文紧邻应生效: [%s]", o); free(o); - /* 关键保护:下划线标识符不能被当成斜体 */ + /* 关键保护: 下划线标识符不能被当成斜体 */ o = render("foo_bar_baz 和 __init__ 和 a_b_c\n", 1); CHECK(strstr(o, "\033[3m") == NULL, "下划线标识符不应被渲染为斜体: [%s]", o); @@ -378,7 +378,7 @@ static void test_underscore_italic(void) static void test_link_bracket(void) { - puts("链接:[] 下划线+青色高亮, 其余原样"); + puts("链接: [] 下划线+青色高亮, 其余原样"); char *o = render("见 [文档](https://example.com/a) 说明\n", 1); /* 这里是下划线 + 青 (SGR 4;36), 不是亮蓝. 整个项目只用 16 色范围内的 * 码, 理由见 src/markdown.c 顶部和 tools/check-colors.sh. */ @@ -405,7 +405,7 @@ static void test_link_bracket(void) static void test_tables(void) { puts("表格"); - /* ASCII 表格:逐字节比对完整边框输出 */ + /* ASCII 表格: 逐字节比对完整边框输出 */ char *o = render("| a | b |\n|---|---|\n| 1 | 22 |\n", 1); char *vis = strip_ansi(o); const char *want = @@ -443,7 +443,7 @@ static void test_tables(void) free(vis); free(o); /* 顶格必须写 '|'. 不带首竖线的写法按普通文本处理 —— - * 这是为保证"输出与分片方式无关"而做的取舍: + * 这是为保证"输出与分片方式无关"而做的取舍: * 判断必须在行的第 0 个字节就能做出, 否则分片落在竖线之前时, * 行的前半段已被当作普通文本输出, 结果会随分片方式而变. */ o = render("a | b\n--- | ---\n1 | 2\n", 1); @@ -452,13 +452,13 @@ static void test_tables(void) "无首竖线应原样输出: [%s]", vis); free(vis); free(o); - /* 首竖线前有缩进同理:不认 */ + /* 首竖线前有缩进同理: 不认 */ o = render(" | a |\n |---|\n", 1); vis = strip_ansi(o); CHECK(strstr(vis, "┌") == NULL, "缩进的表格不识别: [%s]", vis); free(vis); free(o); - /* 误判防护:普通文本里出现竖线 + 下一行是短横线, 不能当成表格 */ + /* 误判防护: 普通文本里出现竖线 + 下一行是短横线, 不能当成表格 */ o = render("运行 ls |\n-\n", 1); vis = strip_ansi(o); CHECK(strstr(vis, "┌") == NULL, "竖线+短横线不应误判成表格: [%s]", vis); @@ -491,7 +491,7 @@ static void test_tables(void) static void test_long_line_bounded(void) { puts("超长待处理缓冲应有界"); - /* 一个没有换行的超长 fence 信息行:渲染器不能无限缓存 */ + /* 一个没有换行的超长 fence 信息行: 渲染器不能无限缓存 */ StrBuf sb; sb_init(&sb); sb_puts(&sb, "```"); diff --git a/tests/test_meta.c b/tests/test_meta.c index 5b1e1bd..4ed7d1a 100644 --- a/tests/test_meta.c +++ b/tests/test_meta.c @@ -1,4 +1,4 @@ -/* test_meta.c - meta 历史编辑:轮次切分、结构安全、各工具的行为 */ +/* test_meta.c - meta 历史编辑: 轮次切分、结构安全、各工具的行为 */ #include "meta.h" #include "json.h" #include "util.h" @@ -50,7 +50,7 @@ static JsonValue *assistant_call(const char *id, const char *name, const char *a return m; } -/* 造一段典型历史: +/* 造一段典型历史: * #0 system * #1 user A ← 轮 1 * #2 assistant(tool_calls) @@ -98,7 +98,7 @@ static void test_rounds(void) meta_rounds_free(&r); json_free(n); - /* 只有 system:没有可编辑的东西 */ + /* 只有 system: 没有可编辑的东西 */ JsonValue *only = json_new_array(); json_array_push(only, msg("system", "s")); CHECK(meta_rounds_build(only, &r) == 0, "只有 system 时不该切出轮次"); @@ -108,7 +108,7 @@ static void test_rounds(void) /* read_image 补的那条"只有图片附件、没有文字"的 user 消息**不算新的一轮** —— * 判据必须与主循环 (agent_opens_round)一致, 否则 /meta 报的轮号会和主循环的 * 口径对不上, 模型照轮号删下去, 删掉的不是它以为的那一轮. - * 结构:system / user(text) / assistant(tool_calls) / tool / user(只有图) / assistant */ + * 结构: system / user(text) / assistant(tool_calls) / tool / user(只有图) / assistant */ JsonValue *withimg = json_new_array(); json_array_push(withimg, msg("system", "s")); json_array_push(withimg, msg("user", "看图")); @@ -135,7 +135,7 @@ static void test_rounds(void) static void test_deletable(void) { - puts("结构安全:哪些消息能单独删"); + puts("结构安全: 哪些消息能单独删"); JsonValue *m = sample(); CHECK(meta_deletable(m, 0) == 0, "system 不可删"); CHECK(meta_deletable(m, 1) == 1, "user 可删"); @@ -152,7 +152,7 @@ static char *call(Agent *a, const char *name, const char *json) { char err[256]; JsonValue *args = json_parse(json, err, sizeof err); - if (!args) { printf(" (测试自身出错:%s)\n", err); return xstrdup(""); } + if (!args) { printf(" (测试自身出错: %s)\n", err); return xstrdup(""); } int is_err = 0; char *r = meta_tools_call(name, args, &is_err); json_free(args); @@ -175,7 +175,7 @@ static void test_replace(void) int err = 0; char *r = call(&a, "replace_message", "{\"index\":1,\"content\":\"帮我用 C 写\"}"); - CHECK(err == 0 && r && strstr(r, "已改写") != NULL, "应改写成功:%s", r ? r : ""); + CHECK(err == 0 && r && strstr(r, "已改写") != NULL, "应改写成功: %s", r ? r : ""); JsonValue *m1 = json_array_get(a.messages, 1); CHECK(strcmp(json_get_string(m1, "content"), "帮我用 C 写") == 0, "内容应被替换"); CHECK(strcmp(json_get_string(m1, "role"), "user") == 0, "角色不该变"); @@ -190,7 +190,7 @@ static void test_replace(void) /* tool 结果和 system 都不该被动 */ r = call(&a, "replace_message", "{\"index\":3,\"content\":\"x\"}"); - CHECK(r && strstr(r, "tool 结果") != NULL, "改 tool 结果应被拒绝:%s", r ? r : ""); + CHECK(r && strstr(r, "tool 结果") != NULL, "改 tool 结果应被拒绝: %s", r ? r : ""); free(r); r = call(&a, "replace_message", "{\"index\":0,\"content\":\"x\"}"); CHECK(r && strstr(r, "system") != NULL, "改 system 应被拒绝"); @@ -223,15 +223,15 @@ static void test_delete_safety(void) CHECK(json_array_size(a.messages) == before, "仍不该改动"); free(r); - /* 一条合法一条非法:整批都不该动 (先校验后执行) */ + /* 一条合法一条非法: 整批都不该动 (先校验后执行) */ r = call(&a, "delete_messages", "{\"indices\":[1,2]}"); CHECK(r && strstr(r, "不可拆") != NULL, "混了非法项应整体拒绝"); CHECK(json_array_size(a.messages) == before, "整体拒绝时不该改一半"); free(r); - /* 合法删除:删掉第 1 轮里那句 user, 并给上下文提示 */ + /* 合法删除: 删掉第 1 轮里那句 user, 并给上下文提示 */ r = call(&a, "delete_messages", "{\"indices\":[1]}"); - CHECK(r && strstr(r, "已删除 1 条") != NULL, "合法删除应成功:%s", r ? r : ""); + CHECK(r && strstr(r, "已删除 1 条") != NULL, "合法删除应成功: %s", r ? r : ""); CHECK(strstr(r, "没有对应的提问") != NULL, "删掉提问后应提示后续回答失去上下文"); CHECK(json_array_size(a.messages) == before - 1, "条数应减一"); free(r); @@ -254,9 +254,9 @@ static void test_delete_rounds(void) size_t before = json_array_size(a.messages); char *r = call(&a, "delete_rounds", "{\"from\":2,\"to\":2}"); - CHECK(r && strstr(r, "已删除轮 2..2") != NULL, "应删掉第 2 轮:%s", r ? r : ""); + CHECK(r && strstr(r, "已删除轮 2..2") != NULL, "应删掉第 2 轮: %s", r ? r : ""); CHECK(json_array_size(a.messages) == before - 2, "第 2 轮是 2 条消息"); - /* 剩下的结构必须还是自洽的:#2 的 tool_calls 后面仍跟着 tool */ + /* 剩下的结构必须还是自洽的: #2 的 tool_calls 后面仍跟着 tool */ CHECK(meta_deletable(a.messages, 2) == 0, "#2 仍应是结构消息"); CHECK(strcmp(json_get_string(json_array_get(a.messages, 3), "role"), "tool") == 0, "tool 结果应仍紧跟它的调用"); @@ -269,7 +269,7 @@ static void test_delete_rounds(void) /* 越界 */ r = call(&a, "delete_rounds", "{\"from\":99,\"to\":99}"); - CHECK(r && (strstr(r, "不合法") != NULL), "越界轮号应报错:%s", r ? r : ""); + CHECK(r && (strstr(r, "不合法") != NULL), "越界轮号应报错: %s", r ? r : ""); free(r); meta_tools_detach(); @@ -286,7 +286,7 @@ static void test_insert_and_undo(void) size_t before = json_array_size(a.messages); char *r = call(&a, "insert_message", "{\"after\":8,\"role\":\"user\",\"content\":\"这个项目必须用 C99\"}"); - CHECK(r && strstr(r, "插入一条 user") != NULL, "应插入成功:%s", r ? r : ""); + CHECK(r && strstr(r, "插入一条 user") != NULL, "应插入成功: %s", r ? r : ""); CHECK(json_array_size(a.messages) == before + 1, "条数应加一"); CHECK(strcmp(json_get_string(json_array_get(a.messages, 9), "content"), "这个项目必须用 C99") == 0, "应插在 #8 之后"); @@ -321,7 +321,7 @@ static void test_read_tools(void) meta_tools_attach(&a); char *r = call(&a, "list_rounds", "{}"); - CHECK(r && strstr(r, "共 3 轮") != NULL, "应报告 3 轮:%s", r ? r : ""); + CHECK(r && strstr(r, "共 3 轮") != NULL, "应报告 3 轮: %s", r ? r : ""); CHECK(r && strstr(r, "轮 1") != NULL && strstr(r, "轮 3") != NULL, "应列出每轮"); CHECK(r && strstr(r, "Python") != NULL, "应带开头预览"); free(r); diff --git a/tests/test_models.c b/tests/test_models.c index 41c6a9e..a42d101 100644 --- a/tests/test_models.c +++ b/tests/test_models.c @@ -3,9 +3,9 @@ * 只测纯解析. 拉取 (GET {base_url}/models)要走网络, 由 e2e 第 52 段对着 * mock 覆盖, 那里还顺带验了交互式选择本身 (方向键、确认、取消). * - * 为什么要容忍这么多种形状:OpenAI 兼容是个很大的家庭, /models 的返回花样 + * 为什么要容忍这么多种形状: OpenAI 兼容是个很大的家庭, /models 的返回花样 * 比文档里写的多 —— 见过裸数组、见过重复列同一模型、见过元素里没有 id. - * 解析器要么跳过, 要么去重, 但**不能**因此整个失败:列表里少一个模型远好过 + * 解析器要么跳过, 要么去重, 但**不能**因此整个失败: 列表里少一个模型远好过 * 一个都选不了. */ #include "models.h" @@ -29,7 +29,7 @@ static int checks = 0; static void test_standard(void) { - puts("标准形状:{\"data\":[{\"id\":...}]}"); + puts("标准形状: {\"data\":[{\"id\":...}]}"); const char *json = "{\"object\":\"list\",\"data\":[" "{\"id\":\"alpha\",\"object\":\"model\",\"owned_by\":\"x\"}," diff --git a/tests/test_priv.c b/tests/test_priv.c index 7bcbd59..23dde8d 100644 --- a/tests/test_priv.c +++ b/tests/test_priv.c @@ -1,8 +1,8 @@ /* test_priv.c - 提权模块里那几条"不能搞错"的不变量 * - * 这个模块摔过一跤:上一版把认证路径也 setsid + 接管道, 于是 sudo 拿不到 + * 这个模块摔过一跤: 上一版把认证路径也 setsid + 接管道, 于是 sudo 拿不到 * 控制终端, 真机上认证 100% 失败 (而替身 sudo 无条件成功, 给了个假绿). - * 所以这里把两条硬不变量钉住: + * 所以这里把两条硬不变量钉住: * * 1. **提权调用里不能有 -n**. 带 -n 就等于"不许提问", 需要密码时直接失败, * 而用户要的恰恰是"提权要有身份验证". @@ -41,7 +41,7 @@ static int argv_has(char *const *a, const char *want) int main(void) { puts("包装成 sudo -u <用户> -- "); - /* 显式转换, 和 priv.c 里 w[0] = (char *)"sudo" 是同一个理由: + /* 显式转换, 和 priv.c 里 w[0] = (char *)"sudo" 是同一个理由: * priv_sudo_argv 的签名要 char *const[] (execvp 的要求), 而这里放进去的 * 字面量没有任何人会改写. 把整条链路改成非 const 才是真的丢 const 正确性. */ char *plain[] = { (char *)"/bin/sh", (char *)"-c", (char *)"rm -rf /tmp/x", NULL }; @@ -51,17 +51,17 @@ int main(void) CHECK(strcmp(w[1], "-u") == 0 && strcmp(w[2], "root") == 0, "-u <用户> 紧跟其后"); CHECK(strcmp(w[3], "--") == 0, - "必须有 -- :否则命令里的选项会被 sudo 当成自己的"); + "必须有 -- : 否则命令里的选项会被 sudo 当成自己的"); CHECK(strcmp(w[4], "/bin/sh") == 0 && strcmp(w[5], "-c") == 0 && strcmp(w[6], "rm -rf /tmp/x") == 0, "原 argv 原样接在后面, 不被拆开"); CHECK(w[7] == NULL, "数组要 NULL 结尾"); /* 命门外的那条不变量 */ CHECK(!argv_has(w, "-n"), - "**不能带 -n**:带 -n 就不许提问, 需要密码时直接失败 —— " + "**不能带 -n**: 带 -n 就不许提问, 需要密码时直接失败 —— " "而提权的要点就是让 sudo 能问密码"); CHECK(!argv_has(w, "-S"), - "也不能带 -S:那要求把密码从 stdin 喂进去, 等于让 ligent 经手密码"); + "也不能带 -S: 那要求把密码从 stdin 喂进去, 等于让 ligent 经手密码"); free(w); puts("用户名为空时按 root"); @@ -82,11 +82,11 @@ int main(void) priv_explain(1, "pluv is not in the sudoers file.", err, sizeof err); CHECK(strstr(err, "sudo") != NULL && strstr(err, "权限") != NULL, "没有 sudo 权限要说清楚, 实际 %s", err); - /* 认不出来就原样透传:宁可给原文, 也不编一句可能不对的解释 */ + /* 认不出来就原样透传: 宁可给原文, 也不编一句可能不对的解释 */ priv_explain(1, "some brand new message", err, sizeof err); CHECK(strstr(err, "some brand new message") != NULL, "认不出来要透传原文, 实际 %s", err); - priv_explain(127, "无法执行 sudo:No such file or directory", err, sizeof err); + priv_explain(127, "无法执行 sudo: No such file or directory", err, sizeof err); CHECK(strstr(err, "没有 sudo") != NULL, "没有 sudo 时要说清楚, 实际 %s", err); puts("没有控制终端时能识别出来"); diff --git a/tests/test_spinner.c b/tests/test_spinner.c index 2ffc324..54bcb32 100644 --- a/tests/test_spinner.c +++ b/tests/test_spinner.c @@ -51,7 +51,7 @@ static void capture_end(int fd, int saved, char *buf, size_t buflen) static void test_two_phase(void) { - puts("两段式标签:等待响应 → 思考中"); + puts("两段式标签: 等待响应 → 思考中"); char path[64]; int saved = -1; int fd = capture_begin(path, sizeof path, &saved); @@ -61,7 +61,7 @@ static void test_two_phase(void) spinner_init(1); spinner_start("等待响应"); usleep(400 * 1000); - /* 每来一片思维链都会调一次:第一次生效, 后面几次都是同一标签 */ + /* 每来一片思维链都会调一次: 第一次生效, 后面几次都是同一标签 */ spinner_set_label("思考中"); usleep(120 * 1000); spinner_set_label("思考中"); @@ -93,13 +93,13 @@ static void test_idempotent(void) CHECK(fd >= 0, "无法重定向 stderr"); if (fd < 0) return; - /* 没 start 就 set:只记标签, 不该打印任何东西, 也不该崩 */ + /* 没 start 就 set: 只记标签, 不该打印任何东西, 也不该崩 */ spinner_init(1); spinner_set_label("思考中"); usleep(50 * 1000); - /* 没 start 就 stop:幂等 */ + /* 没 start 就 stop: 幂等 */ spinner_stop(); - /* start 两次:第二次忽略, 不该起第二个线程 */ + /* start 两次: 第二次忽略, 不该起第二个线程 */ spinner_start("等待响应"); spinner_start("思考中"); usleep(300 * 1000); @@ -113,7 +113,7 @@ static void test_idempotent(void) CHECK(strstr(buf, "思考中") == NULL, "已在转时再次 start 应被忽略, 标签不该被改掉"); - /* enabled = 0 时全部是空操作:连重定向都不需要 */ + /* enabled = 0 时全部是空操作: 连重定向都不需要 */ spinner_init(0); spinner_start("等待响应"); spinner_set_label("思考中"); @@ -156,7 +156,7 @@ static void test_stop_is_prompt(void) CHECK(worst < 0.10, "stop 应立即返回, 最慢一次 %.3fs (帧间隔是 0.25s)", worst); } -/* 帧率必须受 250ms 限制. 曾经踩过一次:pthread_cond_timedwait 默认按 +/* 帧率必须受 250ms 限制. 曾经踩过一次: pthread_cond_timedwait 默认按 * CLOCK_REALTIME 解释超时, 而传进去的是 CLOCK_MONOTONIC 的绝对时间 * (开机时长永远小于 epoch 时间), 于是超时永远"已经过了" —— worker * 变成疯狂重绘的忙循环, 把 pty 刷屏. */ @@ -186,7 +186,7 @@ static void test_frame_rate_is_bounded(void) /* spinner_notice 会先停、打印一条提示、再**接着转**. 恢复时它传的是 g_label * 自己 —— 而 spinner_start 曾经先执行 g_label[0] = '\0' 再读那个 label, 于是 - * 标签被自己清空、退化成默认的"思考中". 症状:每次 HTTP 重试退避之后, 屏幕上 + * 标签被自己清空、退化成默认的"思考中". 症状: 每次 HTTP 重试退避之后, 屏幕上 * 的"等待响应"都会变成"思考中", 而这两段等待的含义恰恰是它要区分的. */ static void test_notice_keeps_label(void) { @@ -213,7 +213,7 @@ static void test_notice_keeps_label(void) /* 提示之后剩下的帧里必须还是原来那个标签 */ if (noticed) CHECK(strstr(noticed, "自定义标签A") != NULL, - "notice 之后标签被改写了 (应保持\"自定义标签A\"):\n%.300s", + "notice 之后标签被改写了 (应保持\"自定义标签A\"): \n%.300s", noticed); } diff --git a/tests/test_tools.c b/tests/test_tools.c index d23addc..9789e6d 100644 --- a/tests/test_tools.c +++ b/tests/test_tools.c @@ -69,7 +69,7 @@ int main(void) CHECK(has_image, "schema 里应有 read_image"); } { - /* line 是集合型参数:schema 里必须是 array (元素 integer), + /* line 是集合型参数: schema 里必须是 array (元素 integer), * 否则模型根本不会想到可以给数组 —— 参数表的 "integer[]" 写法 * 只有 schema 展开对了才作数 */ JsonValue *props = NULL, *reqd = NULL; @@ -158,7 +158,7 @@ int main(void) CHECK(err == 1, "读不存在的文件应报错: %s", r); free(r); - /* ---- edit_file:替换由行号钉住 ---- */ + /* ---- edit_file: 替换由行号钉住 ---- */ puts("edit_file"); r = call("edit_file", "{\"path\":\"sub/dir/a.txt\",\"line\":2,\"old_string\":\"line2\"," @@ -170,9 +170,9 @@ int main(void) CHECK(strstr(r, "LINE-TWO") != NULL, "替换结果应落盘: %s", r); free(r); - /* 省略 line 不再是错误:那是"全文件替换"这条逃生门 (见下面那组用例) */ + /* 省略 line 不再是错误: 那是"全文件替换"这条逃生门 (见下面那组用例) */ - /* 行号对不上:必须报出它**真正在哪一行**, 否则模型只能反复猜 */ + /* 行号对不上: 必须报出它**真正在哪一行**, 否则模型只能反复猜 */ r = call("edit_file", "{\"path\":\"sub/dir/a.txt\",\"line\":3,\"old_string\":\"LINE-TWO\"," "\"new_string\":\"x\"}", &err); @@ -180,7 +180,7 @@ int main(void) CHECK(strstr(r, "第 2 行") != NULL, "应指出它真正的行号: %s", r); free(r); - /* 越界行号:要连文件总行数一起说清楚 */ + /* 越界行号: 要连文件总行数一起说清楚 */ r = call("edit_file", "{\"path\":\"sub/dir/a.txt\",\"line\":99,\"old_string\":\"x\"," "\"new_string\":\"y\"}", &err); @@ -188,7 +188,7 @@ int main(void) "越界行号应报错并给出总行数: %s", r); free(r); - /* 唯一性只要求**在这一行里**唯一:同一个词出现在别的行不该拦住替换 + /* 唯一性只要求**在这一行里**唯一: 同一个词出现在别的行不该拦住替换 * (这正是"全文唯一"最烦人的地方——文件一长, 随便一个短串都不唯一) */ r = call("edit_file", "{\"path\":\"sub/dir/a.txt\",\"line\":1,\"old_string\":\"line\"," @@ -223,16 +223,16 @@ int main(void) CHECK(err == 1, "replace_all 也必须锚定在真实的一行上: %s", r); free(r); - /* ---- edit_file:line 是集合型 ---- + /* ---- edit_file: line 是集合型 ---- * 集合是**搜索范围** (old_string 的起点落在其中任一行即可), 不是 - * "每行都改一次":真改动的仍是唯一那一处. */ - puts("edit_file:line 集合"); + * "每行都改一次": 真改动的仍是唯一那一处. */ + puts("edit_file: line 集合"); r = call("write_file", "{\"path\":\"set.txt\",\"content\":\"aaa\\nbbb\\nccc\\nddd\\n\"}", &err); CHECK(err == 0, "写入 set.txt: %s", r); free(r); - /* 数组写法:起点落在集合里的哪一行都算命中; 回执要带上整个集合 */ + /* 数组写法: 起点落在集合里的哪一行都算命中; 回执要带上整个集合 */ r = call("edit_file", "{\"path\":\"set.txt\",\"line\":[1,2],\"old_string\":\"aaa\"," "\"new_string\":\"AAA\"}", &err); @@ -264,7 +264,7 @@ int main(void) "集合里多处匹配应拒绝: %s", r); free(r); - /* 重复的行号要去重:不然同一处匹配会被数成两处 */ + /* 重复的行号要去重: 不然同一处匹配会被数成两处 */ r = call("write_file", "{\"path\":\"dup3.txt\",\"content\":\"foo\\n\"}", &err); CHECK(err == 0, "写入 dup3.txt: %s", r); free(r); @@ -282,7 +282,7 @@ int main(void) "集合里的越界行号应报错并给出总行数: %s", r); free(r); - /* 写法本身不对的三种:空数组、非整数元素、一次给太多 */ + /* 写法本身不对的三种: 空数组、非整数元素、一次给太多 */ r = call("edit_file", "{\"path\":\"set.txt\",\"line\":[],\"old_string\":\"x\"," "\"new_string\":\"y\"}", &err); @@ -298,7 +298,7 @@ int main(void) free(r); /* ---- 省略 line = 全文件替换所有匹配 ---- - * 这是 range 那样的集合参数该有的默认值:不给范围就是不设范围. */ + * 这是 range 那样的集合参数该有的默认值: 不给范围就是不设范围. */ r = call("write_file", "{\"path\":\"all.txt\",\"content\":\"k\\nx\\nk\\n\"}", &err); CHECK(err == 0, "写入 all.txt: %s", r); free(r); @@ -312,13 +312,13 @@ int main(void) CHECK(strstr(r, "K") != NULL && !strstr(r, "k"), "两处都该被换掉: %s", r); free(r); - /* 省略 line 时文件里没有 old_string:只说整个文件里没有 (没有"哪几行"可说) */ + /* 省略 line 时文件里没有 old_string: 只说整个文件里没有 (没有"哪几行"可说) */ r = call("edit_file", "{\"path\":\"all.txt\",\"old_string\":\"zzz\",\"new_string\":\"y\"}", &err); CHECK(err == 1 && strstr(r, "整个文件里没有") != NULL, "该报整个文件里没有: %s", r); free(r); - /* line 给错了类型 (不是"想全局"):仍然当场报错, 并告诉它可以省略 */ + /* line 给错了类型 (不是"想全局"): 仍然当场报错, 并告诉它可以省略 */ r = call("edit_file", "{\"path\":\"all.txt\",\"line\":\"1\",\"old_string\":\"K\"," "\"new_string\":\"z\"}", &err); @@ -327,7 +327,7 @@ int main(void) free(r); { - /* 65 个行号:超过一次能圈住的范围, 要明说 */ + /* 65 个行号: 超过一次能圈住的范围, 要明说 */ char big[600]; size_t o = (size_t)snprintf(big, sizeof big, "{\"path\":\"set.txt\",\"line\":["); for (int i = 1; i <= 65; i++) @@ -348,7 +348,7 @@ int main(void) CHECK(err == 1, "不存在的目录应报错: %s", r); free(r); - /* 这两个工具都已经删掉:搜索交给模型自己用 run_command 里的真 grep, + /* 这两个工具都已经删掉: 搜索交给模型自己用 run_command 里的真 grep, * 子代理 (spawn_subagent)整个功能也去掉了. 留着断言是为了钉住 * "不要再冒出一个半吊子的替代品". */ r = call("grep", "{\"pattern\":\"x\",\"path\":\".\"}", &err); @@ -362,7 +362,7 @@ int main(void) puts("超大文件防护"); { /* 稀疏文件, 创建几乎不耗时; 关键是客户端要在读之前就发现它太大. - * 开头 16 KB 写成 'x':这样它是"文本" (探测的前 8 KB 里没有 NUL), + * 开头 16 KB 写成 'x': 这样它是"文本" (探测的前 8 KB 里没有 NUL), * 否则连 read_file 都会先被二进制检测拦掉 —— 这个用例就测不到 * "分段读大文件"了. */ FILE *big = fopen("huge.txt", "wb"); @@ -389,7 +389,7 @@ int main(void) unlink("huge.txt"); } - /* ---- 二进制文件:所有处理文本的工具都必须拒绝 (而且要说明该用什么) ---- */ + /* ---- 二进制文件: 所有处理文本的工具都必须拒绝 (而且要说明该用什么) ---- */ puts("二进制拒绝"); { FILE *bf = fopen("bin.dat", "wb"); @@ -447,18 +447,18 @@ int main(void) CHECK(tool_mutating("没有这个工具") == 0, "未知工具按只读处理"); CHECK(tool_mutating(NULL) == 0, "空名字不应崩"); - /* ---- read_image:只收真的能内联进去的图 ---- + /* ---- read_image: 只收真的能内联进去的图 ---- * - * 两条拒绝路径是新的:格式不认识、体积超限. 它们都必须**不备份** —— + * 两条拒绝路径是新的: 格式不认识、体积超限. 它们都必须**不备份** —— * 一张交不出去的图, 留备份只会让 ligent.archive 变成垃圾场. */ - puts("read_image:本地就把不合格的图拦掉"); + puts("read_image: 本地就把不合格的图拦掉"); { char btmp[] = "/tmp/ligent_tools_img_XXXXXX"; char *bdir = mkdtemp(btmp); CHECK(bdir != NULL, "建备份临时目录失败"); if (bdir) { image_set_bindir(bdir); - image_pending_take(); /* 清干净, 免得上一段残留 */ + image_pending_take(); /* 清干净, 以免上一段残留 */ /* 1) 内容是文本、扩展名骗人 */ FILE *f = fopen("notanimage.png", "wb"); @@ -469,7 +469,7 @@ int main(void) CHECK(strstr(r, "按文件内容判断") != NULL, "应说明是看内容判断的"); free(r); - /* 2) 体积超限:base64 后超过 IMAGE_MAX_B64. + /* 2) 体积超限: base64 后超过 IMAGE_MAX_B64. * 4/3 * 原始大小 = 编码后大小, 所以原始要 > 3/4 * 上限. */ size_t raw_needed = (size_t)(IMAGE_MAX_B64 / 4 * 3) + 4096; f = fopen("huge.png", "wb"); @@ -491,7 +491,7 @@ int main(void) CHECK(strstr(r, "内联上限") != NULL, "应说清上限是多少"); free(r); - /* 3) 合法的小图:成功, 并且挂上待发附件 */ + /* 3) 合法的小图: 成功, 并且挂上待发附件 */ f = fopen("ok.png", "wb"); if (f) { fwrite("\x89PNG\r\n\x1a\n", 1, 8, f); fwrite("payload", 1, 7, f); fclose(f); } r = call("read_image", "{\"path\":\"ok.png\"}", &err); @@ -519,7 +519,7 @@ int main(void) } } - /* ---- 超长单行:read_file 报出的行号必须与 edit_file 的口径一致 ---- + /* ---- 超长单行: read_file 报出的行号必须与 edit_file 的口径一致 ---- * * read_file 以前用 fgets + 固定 64 KB 缓冲, 一行比缓冲长时会被切成"多行"、 * 每块各带一个行号. 于是模型按 read_file 看到的行号去 edit_file 改, 要么改错 @@ -537,7 +537,7 @@ int main(void) CHECK(err == 0, "读超长行文件失败: %s", r); CHECK(strstr(r, " 1\ta") != NULL, "第 1 行应以 a 开头:\n%.120s", r); CHECK(strstr(r, " 2\tsecond line") != NULL, - "超长行之后的第 2 行应是 second line (它不能被算成好几行):\n%.200s", r); + "超长行之后的第 2 行应是 second line (它不能被算成好几行): \n%.200s", r); CHECK(strstr(r, " 3\tthird line") != NULL, "第 3 行应是 third line"); CHECK(strstr(r, " 4\t") == NULL, "不应该出现第 4 行"); free(r); @@ -553,7 +553,7 @@ int main(void) CHECK(after && strstr(after, "THIRD LINE") != NULL, "改动应落到文件里"); free(after); - /* 单行本身就超过输出上限:必须说清"这一行只给了开头", 不能说成 + /* 单行本身就超过输出上限: 必须说清"这一行只给了开头", 不能说成 * "已截断, 从下一行继续" —— 那会让调用方以为拿到的是完整的行. */ FILE *hf = fopen("hugeline.txt", "wb"); for (int i = 0; i < 200000; i++) fputc('b', hf); @@ -562,14 +562,14 @@ int main(void) fclose(hf); r = call("read_file", "{\"path\":\"hugeline.txt\"}", &err); CHECK(strstr(r, "第 1 行本身就超过") != NULL, - "应说清是这一行超过上限:%.160s", r); + "应说清是这一行超过上限: %.160s", r); CHECK(strstr(r, " 2\t") == NULL, "不能把剩下的半个行伪装成第 2 行"); free(r); remove("hugeline.txt"); remove("longline.txt"); } - /* ---- 空目录:qsort 的第一个参数会拿到 NULL ---- */ + /* ---- 空目录: qsort 的第一个参数会拿到 NULL ---- */ puts("list_dir 空目录"); if (mkdir("emptydir", 0755) != 0) { /* 可能已存在 */ } r = call("list_dir", "{\"path\":\"emptydir\"}", &err); diff --git a/tools/build-static-deps.sh b/tools/build-static-deps.sh index 48ec300..21d9a22 100755 --- a/tools/build-static-deps.sh +++ b/tools/build-static-deps.sh @@ -1,7 +1,7 @@ #!/bin/sh # build-static-deps.sh - 造一份"musl 工具链编出来的静态 libcurl", 给 make static 用. # -# 为什么需要它:发行版的 libcurl.a 是针对 glibc 编的, 跟 musl 混link会得到一堆 +# 为什么需要它: 发行版的 libcurl.a 是针对 glibc 编的, 跟 musl 混link会得到一堆 # "undefined reference to __libc_start_main". 所以必须自己用 musl 工具链把 # libcurl 和它的依赖 (zlib、OpenSSL)编一遍. # @@ -46,7 +46,7 @@ need() { need make need "$CC" need perl # OpenSSL 的 Configure -# 下载靠它. 以前这里写的是 `need curl || true` —— 那是**假的容错**:need 失败时 +# 下载靠它. 以前这里写的是 `need curl || true` —— 那是**假的容错**: need 失败时 # 直接 exit, `|| true` 根本执行不到 (那句注释"下面会再确认"也不成立). 于是缺 # curl 时脚本会一路跑到 fetch, 最后报"所有候选地址都不可用 / 要么是没网, 要么 # 是版本号写错了", 把人引去查网络和版本号 —— 而真正的原因是工具没装. @@ -57,7 +57,7 @@ need tar # OpenSSL 的 Configure 要一个明确的 target 名, 它**不做**自动探测. 以前这里 # 写死 linux-x86_64, 于是在 aarch64/riscv64 上会去编 x86 的汇编, 直接失败 —— # 而这个脚本的产物恰恰是"拷到别的机器也能跑"的静态件. -# 认不出来时**不猜**:猜错的后果是编出一堆看着像成功、其实不对的东西. +# 认不出来时**不猜**: 猜错的后果是编出一堆看着像成功、其实不对的东西. OPENSSL_TARGET=${OPENSSL_TARGET:-} if [ -z "$OPENSSL_TARGET" ]; then case "$(uname -m)" in @@ -71,7 +71,7 @@ if [ -z "$OPENSSL_TARGET" ]; then loongarch64) OPENSSL_TARGET=linux64-loongarch64 ;; # 3.1+ 才有 *) echo "认不出这台机器的架构 ($(uname -m)), 而 OpenSSL 的 Configure" >&2 - echo "需要一个明确的 target 名. 请显式指定, 例如:" >&2 + echo "需要一个明确的 target 名. 请显式指定, 例如: " >&2 echo " OPENSSL_TARGET=linux-aarch64 $0" >&2 echo " (可用的 target 见 OpenSSL 源码里的 Configurations/10-main.conf)" >&2 exit 1 ;; @@ -80,27 +80,27 @@ fi # 刻意**不带** -fno-pie. 依赖库编成默认的 PIE 兼容代码就行 —— PIC 代码放进 # 非 PIE 可执行文件是合法的, 最后那一步由 ligent 自己用 -no-pie 决定. -# 反过来 (依赖 -fno-pie、链接却不带 -no-pie)会立刻炸:这些项目在 make 里 +# 反过来 (依赖 -fno-pie、链接却不带 -no-pie)会立刻炸: 这些项目在 make 里 # 还会顺手链几个测试程序, 而那些链接用的是默认的 PIE, 于是 # relocation R_X86_64_32 against ... can not be used when making a PIE object # 而且 zlib 的 configure 探测 vsnprintf 也会因此失败, 悄悄退化成不安全的 # vsprintf 版本 (实测踩过). # 体积优先. 静态产物是"拷过去就能跑"的发布件, 不是拿来调试的, 所以这里的 -# 取舍和普通构建相反 —— 目标是越小越好. 三组开关要一起上才有意义: +# 取舍和普通构建相反 —— 目标是越小越好. 三组开关要一起上才有意义: # -Os 别做以体积换速度的展开/内联 # -ffunction-sections 依赖库也按函数分段, 这样最后链接时 # -fdata-sections --gc-sections 才**丢得掉**没被引用的部分 # -fno-*-unwind-tables C 代码不需要 C++ 异常和栈回溯, .eh_frame 全省 -# 实测 (连同 Makefile 那侧的 strip 等一起):6.24 MB → 3.29 MB. -# 其中这一半的功劳很大:libcurl+OpenSSL 的 .text 从 3.45 MB 掉到 2.41 MB —— +# 实测 (连同 Makefile 那侧的 strip 等一起): 6.24 MB → 3.29 MB. +# 其中这一半的功劳很大: libcurl+OpenSSL 的 .text 从 3.45 MB 掉到 2.41 MB —— # 静态归档里本来就有一大堆我们永远走不到的算法和协议实现. -# 想回到调试友好 (-O2、保留 unwind 表):CFLAGS_COMMON="-O2 $0" +# 想回到调试友好 (-O2、保留 unwind 表): CFLAGS_COMMON="-O2 $0" CFLAGS_COMMON=${CFLAGS_COMMON:-"-Os -ffunction-sections -fdata-sections -fno-asynchronous-unwind-tables -fno-unwind-tables"} # musl-gcc 只搜 /usr/lib/musl/include, **不搜 /usr/include**, 于是内核的 UAPI -# 头 (linux/mman.h、asm/*.h 这些)找不到 —— OpenSSL 就死在这一步: +# 头 (linux/mman.h、asm/*.h 这些)找不到 —— OpenSSL 就死在这一步: # fatal error: linux/mman.h: No such file or directory -# 用 -idirafter:musl 自己的头优先, /usr/include 只在 musl 没有时兜底. +# 用 -idirafter: musl 自己的头优先, /usr/include 只在 musl 没有时兜底. # 直接写 -I 会让 glibc 的头盖过 musl 的, 那是另一个坑. UAPI_INC="-idirafter /usr/include" @@ -110,14 +110,14 @@ UAPI_INC="-idirafter /usr/include" # 记得把 BUILD_DEFS 加一**才能被发现; 忘了就继续用旧参数编出来的库, 症状是 # "我明明加了个 --disable-x, 怎么产物一点没变". -# zlib 只要 --static:我们要的是 libz.a +# zlib 只要 --static: 我们要的是 libz.a ZLIB_CONFIGURE_ARGS="--static" -# OpenSSL:no-shared 只出 .a; no-tests 少编一堆测试程序 (省几分钟); +# OpenSSL: no-shared 只出 .a; no-tests 少编一堆测试程序 (省几分钟); # no-legacy 去掉已淘汰的算法 (体积). OPENSSL_CONFIGURE_ARGS="no-shared no-tests no-legacy" -# curl:ligent 只用 chat/completions 一个端点, 所以只要能发 HTTPS 的那部分. +# curl: ligent 只用 chat/completions 一个端点, 所以只要能发 HTTPS 的那部分. # --disable-* FTP/邮件/字典/… 这些协议编进来纯属占地 # --without-* 静态链接下每个"可选增强"都会再拖一串依赖进来 (libpsl、idn2、 # brotli、zstd、nghttp2/3…), 而 ligent 一个都不用 @@ -138,21 +138,21 @@ mkdir -p "$SRC" "$PREFIX" # 编译选项变了就得把依赖整个重建. 否则下面每个守卫都会看到 libz.a 已存在而 # 直接跳过, 于是你一直用着旧选项编出来的库, 而且**看不出来** —— 症状是 # "我明明加了 -Os, 怎么产物一点没小". -# 这和 Makefile 里"对象目录按编译器/插桩分标签"是同一个坑的两处:make 和这个 +# 这和 Makefile 里"对象目录按编译器/插桩分标签"是同一个坑的两处: make 和这个 # 脚本都只看文件在不在, 看不见命令行变了. STAMP="$PREFIX/.build-flags" -# BUILD_DEFS:配方版本号, 留给"改了脚本逻辑、但 WANT 里的文本一个字都没变"的 +# BUILD_DEFS: 配方版本号, 留给"改了脚本逻辑、但 WANT 里的文本一个字都没变"的 # 改动 (比如换了一种绕坑的处理方式). 凡是会改变产物内容的改动都要加一. # -# 注意它现在**不再是唯一的触发源**:编译选项、三个版本号、OpenSSL target、 +# 注意它现在**不再是唯一的触发源**: 编译选项、三个版本号、OpenSSL target、 # 每个库的 configure 参数都直接进了下面的 WANT, 改它们会自动触发重建, 不需要 # 谁记得来这里加一 —— 以前 configure 参数不在 WANT 里, 全靠这个手写数字兜着, # 而人是会忘的 (忘了就是"一直用旧参数编出来的库, 看不出来"). -# 已有的历史值:1 = 加了体积优先的 CFLAGS; 2 = configure 期间藏起 .pc 文件; +# 已有的历史值: 1 = 加了体积优先的 CFLAGS; 2 = configure 期间藏起 .pc 文件; # 3 = 架构与 configure 参数进指纹 (本版). BUILD_DEFS=3 -# 多行参数折叠成一行:好塞进戳文件, 也好肉眼比对 +# 多行参数折叠成一行: 好塞进戳文件, 也好肉眼比对 flatten() { printf '%s' "$1" | tr -s '[:space:]' ' ' | sed 's/^ *//; s/ *$//' } @@ -164,7 +164,7 @@ WANT="defs=$BUILD_DEFS | CC=$CC | zlib=$ZLIB_VER openssl=$OPENSSL_VER curl=$CURL | curl:[$(flatten "$CURL_CONFIGURE_ARGS")]" if [ ! -f "$STAMP" ] && [ -f "$PREFIX/lib/libz.a" ]; then # 有库却没有选项记录 —— 那是**旧版脚本**编的, 不知道用的什么选项. - # 这一条不能省:没有它, 下面的判断会认为"stamp 不存在 = 不用重建", + # 这一条不能省: 没有它, 下面的判断会认为"stamp 不存在 = 不用重建", # 然后我们就给旧库写上一个声称是新选项的 stamp, 把它洗白成"新的". echo "== 这个前缀没有编译选项记录 (旧版脚本编的), 重建以确认选项一致" rm -rf "$PREFIX" @@ -208,8 +208,8 @@ fetch() { echo " 这个地址不行, 换下一个" rm -f "$out.part" done - echo " 下载 $(basename "$out") 失败:所有候选地址都不可用. " >&2 - echo " 要么是没网, 要么是版本号写错了. 可以指定版本重来, 例如:" >&2 + echo " 下载 $(basename "$out") 失败: 所有候选地址都不可用. " >&2 + echo " 要么是没网, 要么是版本号写错了. 可以指定版本重来, 例如: " >&2 echo " ZLIB=1.3.1 OPENSSL=3.5.0 CURL=8.11.1 $0" >&2 exit 1 } @@ -269,30 +269,30 @@ if [ ! -x "$PREFIX/bin/curl-config" ]; then # ---- configure 期间把 .pc 文件藏起来, 别让它走 pkg-config ---- # - # curl 的 configure 是这么挑 OpenSSL 的 (--with-openssl 给的是自定义前缀): + # curl 的 configure 是这么挑 OpenSSL 的 (--with-openssl 给的是自定义前缀): # OPENSSL_PCDIR="$OPT_OPENSSL/lib/pkgconfig" # if test -f "$OPENSSL_PCDIR/openssl.pc"; then PKGTEST="yes" # 用 pkg-config # ... # if test "$PKGTEST" != "yes"; then # SSL_LDFLAGS="-L$PREFIX_OPENSSL/lib" # 直接给目录, 路径原样 # - # 走 pkg-config 那条路会出事:configure 开头就 `LC_ALL=C; export LC_ALL` + # 走 pkg-config 那条路会出事: configure 开头就 `LC_ALL=C; export LC_ALL` # (autoconf 的标准开头, 脚本外面设 LC_ALL 压不住它), 而 pkg-config 在 # C locale 下用 isprint() 判断字节要不要转义 —— 每个 >= 0x80 的字节都被判成 - # "不可打印", 于是它给路径里每个非 ASCII 字节前面加个反斜杠: + # "不可打印", 于是它给路径里每个非 ASCII 字节前面加个反斜杠: # -L/…/开发/… -> -L/…/\开\发/… # \ 后面跟 E5 不是合法续字节, 所以那串是**非法 UTF-8**. curl 拿到就写进 # curl-config、libcurl.pc 和一堆 Makefile (实测 26 个文件). # - # 后果不只是难看:之后 `make static` 里 `curl-config --static-libs` 一打印, + # 后果不只是难看: 之后 `make static` 里 `curl-config --static-libs` 一打印, # 坏字节就顺着工具输出进了对话历史, 服务端会以 # invalid unicode code point # 拒掉**整个**请求, 而且坏数据已在历史里, 之后每次请求都失败. # # 本仓库路径带中文, 所以必中; 纯 ASCII 路径的人永远碰不到. # 让 .pc 暂时消失, curl 就退回上面那条"直接给目录"的路, 路径干干净净. - # 藏而不是删:这些 .pc 是 OpenSSL/zlib 正常安装的一部分, 别人可能要用. - # PCDIR/PCHIDE 和恢复用的 trap 在脚本开头 (restore_pc):这几步要几分钟, + # 藏而不是删: 这些 .pc 是 OpenSSL/zlib 正常安装的一部分, 别人可能要用. + # PCDIR/PCHIDE 和恢复用的 trap 在脚本开头 (restore_pc): 这几步要几分钟, # 中途 Ctrl-C 很常见, 不能让 .pc 在没人知道的情况下"消失"一段. restore_pc # 上一次被打断留下的隐藏目录, 先放回去 if [ -d "$PCDIR" ]; then mv "$PCDIR" "$PCHIDE"; fi @@ -318,7 +318,7 @@ fi # 光看文件在不在不够 —— 要真的**链一个用 curl 的静态程序**, 才能证明这份 # 前缀是可用的 (静态链接最常栽在依赖顺序和缺库上, 而那只在链接时暴露). echo -echo "== 自检:编一个最小程序并静态链接" +echo "== 自检: 编一个最小程序并静态链接" CHECK="$SRC/check.c" cat > "$CHECK" <<'EOF' #include @@ -336,7 +336,7 @@ LIBS=$("$PREFIX/bin/curl-config" --static-libs) if ldd "$SRC/check" 2>&1 | grep -q "not a dynamic executable\|statically linked\|不是动态可执行文件"; then echo " 静态链接成功, 且确实不依赖动态库" else - echo " 注意:ldd 没说它是静态的, 检查一下下面这行输出:" >&2 + echo " 注意: ldd 没说它是静态的, 检查一下下面这行输出: " >&2 ldd "$SRC/check" 2>&1 | head -3 >&2 fi @@ -345,19 +345,19 @@ fi # 于是会进工具输出、进对话历史、最后进请求体. 一旦含非法字节, 服务端会以 # "invalid unicode code point" 拒掉整个请求 —— 而且坏数据已经留在历史里, # 之后每次请求都会失败. 上面 curl 那段的长注释解释了坏字节是怎么来的. -# 用 iconv 做往返校验:截断的/被转义的序列都会被它拒掉. 没有 iconv 就跳过. +# 用 iconv 做往返校验: 截断的/被转义的序列都会被它拒掉. 没有 iconv 就跳过. if command -v iconv >/dev/null 2>&1; then if "$PREFIX/bin/curl-config" --static-libs 2>/dev/null \ | iconv -f UTF-8 -t UTF-8 > /dev/null 2>&1; then echo " curl-config --static-libs 输出是合法 UTF-8" else - echo " 错误:curl-config --static-libs 的输出含非法 UTF-8. " >&2 + echo " 错误: curl-config --static-libs 的输出含非法 UTF-8. " >&2 echo " 这通常意味着 configure 走了 pkg-config 那条路 (见上面 curl 那段). " >&2 - echo " 删掉这个前缀重跑本脚本:rm -rf \"$PREFIX\"" >&2 + echo " 删掉这个前缀重跑本脚本: rm -rf \"$PREFIX\"" >&2 exit 1 fi fi echo -echo "依赖就绪:$PREFIX" -echo "接下来: make static" +echo "依赖就绪: $PREFIX" +echo "接下来: make static" diff --git a/tools/check-colors.sh b/tools/check-colors.sh index 51193e6..02fc5fa 100755 --- a/tools/check-colors.sh +++ b/tools/check-colors.sh @@ -1,17 +1,17 @@ #!/bin/sh # check-colors.sh - 审计所有 ANSI 颜色码, 确保都在 16 色内 # -# 为什么要单独一个脚本:颜色越界这件事**看代码看不出来** —— \033[94m 在人眼里 +# 为什么要单独一个脚本: 颜色越界这件事**看代码看不出来** —— \033[94m 在人眼里 # 也是"一个蓝色", 只有拿尺子量才知道它属于 aixterm 的亮色段, 不在最窄的 # 16 色 ANSI 里. 而这个项目是硬编码 SGR、不查 terminfo 的, 既然不探测终端 # 能力, 就该待在所有终端都认的那一档. # -# 允许的 SGR 参数: +# 允许的 SGR 参数: # 0 重置 # 1/2/3/4 加粗/暗淡/斜体/下划线 (属性, 不是颜色) # 30-37 标准前景 8 色 # 39 默认前景色 -# 不允许:90-97 (亮色)、38;5/48;5 (256 色)、38;2/48;2 (真彩) +# 不允许: 90-97 (亮色)、38;5/48;5 (256 色)、38;2/48;2 (真彩) set -eu ROOT=$(cd "$(dirname "$0")/.." && pwd) @@ -29,10 +29,10 @@ if [ -n "$bad" ]; then echo "发现 16 色范围外的 SGR 参数:" >&2 printf ' %s\n' $bad >&2 echo "" >&2 - echo "允许的:0 1 2 3 4 30-37 39" >&2 - echo "越界的:90-97 (aixterm 亮色)、38;5/48;5 (256 色)、38;2/48;2 (真彩)" >&2 + echo "允许的: 0 1 2 3 4 30-37 39" >&2 + echo "越界的: 90-97 (aixterm 亮色)、38;5/48;5 (256 色)、38;2/48;2 (真彩)" >&2 exit 1 fi n=$(grep -rhoE '\\033\[[0-9;]+m' src/*.c src/*.h 2>/dev/null | sort -u | wc -l) -echo "颜色审计通过:$n 个不同的 SGR 序列, 全部在 16 色内. " +echo "颜色审计通过: $n 个不同的 SGR 序列, 全部在 16 色内. " diff --git a/tools/get-version.sh b/tools/get-version.sh index 743a7ab..6063b07 100755 --- a/tools/get-version.sh +++ b/tools/get-version.sh @@ -1,7 +1,7 @@ #!/bin/sh # get-version.sh - 算出版本号, 供 Makefile 用 -D 注入 # -# 输出六行 (顺序固定, Makefile 按行取): +# 输出六行 (顺序固定, Makefile 按行取): # 1. CalVer 主版本号 YYYY.MM.DD # 2. git 短哈希 abc1234 / 1a2b3c4.dirty / unknown # 3. 分支名 master / unknown (detached HEAD 或非 git) @@ -9,37 +9,37 @@ # 5. 构建时间 HH:MM:SS (UTC) # 6. 本脚本算出的版本串 (仅供人看/调试) # -# 版本方案:**CalVer (发布日的 YYYY.MM.DD)+ git 短哈希**. +# 版本方案: **CalVer (发布日的 YYYY.MM.DD)+ git 短哈希**. # # 2026.02.14 打在 tag 上的发布版 # 2026.02.14-1a2b3c4 tag 之后又提交了 # 2026.02.14-1a2b3c4.dirty 工作区还有没提交的改动 # -# 为什么 CalVer 而不是 SemVer:ligent 是个终端工具, 不是被别人链接的库, +# 为什么 CalVer 而不是 SemVer: ligent 是个终端工具, 不是被别人链接的库, # "第几个大版本"这种信息对它没什么用; "这是哪天的东西"才有用 —— 用户看到 # 2026.02.14 立刻知道新旧. 真需要表达破坏性变更时, git 历史和 README 比版本 # 号数字说得清楚. # -# 为什么还要带哈希:CalVer 的粒度是"天", 同一天可能发好几次; 而且用户手上 +# 为什么还要带哈希: CalVer 的粒度是"天", 同一天可能发好几次; 而且用户手上 # 的二进制未必来自 tag (可能自己改了源码). 带上哈希, 报 bug 时能精确定位. # -# 分隔符用 "-" 而不是 git describe 传统的 "+g":我们只用 git, 不存在"区分是 +# 分隔符用 "-" 而不是 git describe 传统的 "+g": 我们只用 git, 不存在"区分是 # 哪种 SCM"的需求 (那正是 g 前缀存在的理由), 而 2026.09.22-1a2b3c4 少两个 # 符号、念起来也顺. # -# 取版本号的优先级 (从高到低): +# 取版本号的优先级 (从高到低): # 1. 环境变量 LIGENT_VERSION_OVERRIDE —— 给 CI 用, tag 打得不对时能手动指定 # 2. git tag 里最新的 CalVer 形态的 tag (v2026.02.14 或 2026.02.14) # 3. 源码目录的 mtime (从 tarball 解出来时没有 .git, 用文件的日期当版本) # -# 任何一步失败都不算致命:退回一个明确的默认值, 绝不让构建挂掉 —— +# 任何一步失败都不算致命: 退回一个明确的默认值, 绝不让构建挂掉 —— # 版本号不对只是难看, 构建挂掉是真的不能用. set -eu ROOT=$(cd "$(dirname "$0")/.." && pwd) # ------------------------------------------------------- 构建日期与时间 -# SOURCE_DATE_EPOCH 是"可复现构建"的标准约定:设了它就用它当构建时间, +# SOURCE_DATE_EPOCH 是"可复现构建"的标准约定: 设了它就用它当构建时间, # 这样同一个源码在不同时间编出来的二进制是**逐字节相同**的. # 不认这个约定的话, CI 上每次构建的产物哈希都不一样, 没法校验. if [ -n "${SOURCE_DATE_EPOCH:-}" ]; then @@ -61,7 +61,7 @@ else CALVER= # 只在真的在 git 工作区里、且 git 可用时才问 git if command -v git >/dev/null 2>&1 && [ -d "$ROOT/.git" ]; then - # 找最近的、长得像 CalVer 的 tag. 用 grep 过滤而不是 --match:不同 + # 找最近的、长得像 CalVer 的 tag. 用 grep 过滤而不是 --match: 不同 # git 版本对 --match 的 glob 支持略有差异, 而这里只要"第一个符合 # YYYY.MM.DD 形态的"就够了, 过滤更直观也更可移植. CALVER=$(git -C "$ROOT" tag --list 2>/dev/null \ @@ -70,7 +70,7 @@ else | sort \ | tail -1 || true) fi - # 没有 tag:用源码目录的日期当版本. tarball 场景下这是"这份源码什么时候 + # 没有 tag: 用源码目录的日期当版本. tarball 场景下这是"这份源码什么时候 # 打的包", 虽然不如 tag 精确, 但比写死一个 0.0.0 有用. if [ -z "$CALVER" ]; then CALVER=$(date -u -r "$ROOT/src/main.c" +%Y.%m.%d 2>/dev/null || true) @@ -79,13 +79,13 @@ else fi # ---------------------------------------------------------------- git 哈希 -# LIGENT_HASH_OVERRIDE:给容器/CI 用. 容器里没有 .git (.dockerignore 排除了), +# LIGENT_HASH_OVERRIDE: 给容器/CI 用. 容器里没有 .git (.dockerignore 排除了), # 那里问不出哈希来, 只能由外面算好传进来. HASH=${LIGENT_HASH_OVERRIDE:-} if [ -z "$HASH" ] && command -v git >/dev/null 2>&1 && [ -d "$ROOT/.git" ]; then if h=$(git -C "$ROOT" rev-parse --short=7 HEAD 2>/dev/null); then HASH=$h - # 工作区脏了就标出来. **这一条很重要**:不带这个标记的话, 一个改过 + # 工作区脏了就标出来. **这一条很重要**: 不带这个标记的话, 一个改过 # 源码的构建和一个干净的构建会报出同样的版本号, 排查时会被误导. if ! git -C "$ROOT" diff --quiet HEAD -- 2>/dev/null; then HASH="$HASH.dirty" diff --git a/tools/md_demo.c b/tools/md_demo.c index 892124c..0cc1dc8 100644 --- a/tools/md_demo.c +++ b/tools/md_demo.c @@ -1,10 +1,10 @@ /* md_demo.c - Markdown 渲染效果演示 * - * 一个独立的小程序:把内置示例 (或你指定的文件 / stdin)喂进 ligent 的 + * 一个独立的小程序: 把内置示例 (或你指定的文件 / stdin)喂进 ligent 的 * 流式 Markdown 渲染器, 直观看到每种语法的渲染结果. * - * 编译: make md_demo - * 运行: ./build/md_demo + * 编译: make md_demo + * 运行: ./build/md_demo */ #include "markdown.h" #include "util.h" @@ -16,7 +16,7 @@ #include /* ------------------------------------------------------------------ */ -/* 内置示例:把支持的语法和边界情况都覆盖一遍 */ +/* 内置示例: 把支持的语法和边界情况都覆盖一遍 */ /* ------------------------------------------------------------------ */ static const char *SAMPLE = @@ -25,9 +25,9 @@ static const char *SAMPLE = "## 行内样式\n" "\n" "这是 **粗体**、*斜体*、_下划线斜体_ 和 `行内代码` 的混排效果. \n" -"标识符不会被误伤:foo_bar_baz、__init__、arr[i]、a_b_c 都保持原样. \n" -"链接只高亮方括号:[官方文档](https://example.com/doc) 后面还有普通文字. \n" -"图片保留原文:![架构图](./arch.png)\n" +"标识符不会被误伤: foo_bar_baz、__init__、arr[i]、a_b_c 都保持原样. \n" +"链接只高亮方括号: [官方文档](https://example.com/doc) 后面还有普通文字. \n" +"图片保留原文: ![架构图](./arch.png)\n" "\n" "## 列表与引用\n" "\n" @@ -52,7 +52,7 @@ static const char *SAMPLE = "}\n" "```\n" "\n" -"行内代码同理:`a ** b` 里的星号不会被当成粗体标记. \n" +"行内代码同理: `a ** b` 里的星号不会被当成粗体标记. \n" "\n" "## 表格\n" "\n" @@ -68,8 +68,8 @@ static const char *SAMPLE = "\n" "## 边界情况\n" "\n" -"乘法星号原样保留:2 * 3 * 4 = 24\n" -"管道命令不会被误判成表格:ls -la | grep foo\n" +"乘法星号原样保留: 2 * 3 * 4 = 24\n" +"管道命令不会被误判成表格: ls -la | grep foo\n" "未闭合的 **粗体标记 会在行尾被复位\n" "#井号后没有空格就不是标题\n" "####### 七个井号也不是标题\n" @@ -144,20 +144,20 @@ static void usage(FILE *out) fputs( "md_demo - 演示 ligent 的流式 Markdown 渲染效果\n" "\n" -"用法: md_demo [选项] [文件...]\n" +"用法: md_demo [选项] [文件...]\n" "\n" "不指定文件时使用内置示例; 文件名为 - 或 stdin 不是终端时从标准输入读取. \n" "\n" -"选项:\n" +"选项: \n" " -p, --plain 关闭渲染, 输出原始 Markdown (对照用)\n" " --color 强制开启渲染 (默认仅在输出到终端时开启)\n" " -c, --chunk N 按每 N 字节一片喂入 (默认 0 = 一次性喂完)\n" " -d, --delay MS 每片之间延时毫秒 (默认 0)\n" -" -s, --stream 流式预设:等价于 -c 5 -d 12\n" -" --compare 自检:把同一段文本按多种分片方式渲染, 比对结果是否一致\n" +" -s, --stream 流式预设: 等价于 -c 5 -d 12\n" +" --compare 自检: 把同一段文本按多种分片方式渲染, 比对结果是否一致\n" " -h, --help 显示本帮助\n" "\n" -"例子:\n" +"例子: \n" " md_demo 看渲染效果\n" " md_demo --plain 看原始 Markdown, 对照差异\n" " md_demo -c 5 -d 12 模拟 SSE 流式 (标记会被随机切断)\n" @@ -240,9 +240,9 @@ int main(int argc, char **argv) int enabled = plain ? 0 : (force_color ? 1 : (isatty(fileno(stdout)) ? 1 : 0)); if (compare) { - /* 分片不变性自检:不同分片大小必须得到完全相同的结果 */ + /* 分片不变性自检: 不同分片大小必须得到完全相同的结果 */ puts("分片不变性自检 (同一段文本, 不同分片方式, 输出必须逐字节一致)"); - printf("输入:%s (%zu 字节)\n\n", source_name, strlen(src)); + printf("输入: %s (%zu 字节)\n\n", source_name, strlen(src)); char *whole = render_to_mem(src, 1, 0); size_t sizes[] = { 1, 2, 3, 5, 7, 13, 64 }; @@ -279,14 +279,14 @@ int main(int argc, char **argv) free(buf); free(whole); - printf("\n结论:%s\n", all_ok ? "全部一致, 标记跨分片处理正确. " + printf("\n结论: %s\n", all_ok ? "全部一致, 标记跨分片处理正确. " : "存在不一致, 渲染器有 bug. "); free(src); return all_ok ? 0 : 1; } if (isatty(fileno(stdout))) { - fprintf(stderr, "── 输入:%s 渲染:%s%s ──\n\n", + fprintf(stderr, "── 输入: %s 渲染: %s%s ──\n\n", source_name, enabled ? "开" : "关", chunk ? " (分片模式)" : ""); }