diff --git a/.dockerignore b/.dockerignore index eaf6196..87320c6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,31 +1,31 @@ # .dockerignore - 别把没用的东西送进构建上下文 # -# 构建上下文越小,podman/docker 传得越快;更重要的是,**有些东西绝不能被 -# COPY 进去**。这里每一条都写清楚为什么。 +# 构建上下文越小, podman/docker 传得越快; 更重要的是, **有些东西绝不能被 +# COPY 进去**. 这里每一条都写清楚为什么. -# 构建产物:容器里会重新编,带进去只会让缓存失效、还可能串味 -# (比如本机 build/static-prefix 是给本机路径编的,进容器就废了) +# 构建产物:容器里会重新编, 带进去只会让缓存失效、还可能串味 +# (比如本机 build/static-prefix 是给本机路径编的, 进容器就废了) build/ ligent ligent-static *.o -# 版本控制:容器里用 --build-arg VERSION 传版本号,不需要 .git。 -# 而且 .git 可能有几百 MB,是构建上下文里最大的一块。 +# 版本控制:容器里用 --build-arg VERSION 传版本号, 不需要 .git. +# 而且 .git 可能有几百 MB, 是构建上下文里最大的一块. .git/ .gitignore -# 本地运行期状态:配置和密钥绝不能进镜像。 -# ligent.json 里可能写着 API key —— 这是最要紧的一条。 +# 本地运行期状态:配置和密钥绝不能进镜像. +# ligent.json 里可能写着 API key —— 这是最要紧的一条. ligent.json ligent.archive/ ligent.history ligent.history.tmp -# 测试:不需要它们来构建。带上会让"改一行测试"也触发镜像重建。 +# 测试:不需要它们来构建. 带上会让"改一行测试"也触发镜像重建. tests/ -# 文档在容器里用不上(scratch 镜像里没有 cat,放进去也读不了) +# 文档在容器里用不上 (scratch 镜像里没有 cat, 放进去也读不了) README.md LICENSE diff --git a/.gitignore b/.gitignore index 068f296..07cb1d7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ ligent ligent-static *.o -# 运行期本地状态(配置只放在二进制旁边,见 README §2) +# 运行期本地状态 (配置只放在二进制旁边, 见 README §2) ligent.json ligent.archive/ ligent.history diff --git a/Dockerfile b/Dockerfile index 6fa68b2..5836e33 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # Dockerfile - 构建完全静态的 ligent # -# 用法(podman 和 docker 都行,命令一样): +# 用法 (podman 和 docker 都行, 命令一样): # # podman build -t ligent-static . # podman run --rm ligent-static --version @@ -8,46 +8,46 @@ # # 或者直接跑 make 目标: # make docker-build # 构建镜像 -# make docker-extract # 把产物拷到当前目录(./ligent-static) +# make docker-extract # 把产物拷到当前目录 (./ligent-static) # -# 只在构建期需要网络(下载 zlib/OpenSSL/curl 源码);运行期不需要任何东西。 +# 只在构建期需要网络 (下载 zlib/OpenSSL/curl 源码); 运行期不需要任何东西. # # # 为什么用 Alpine 做构建环境 # -------------------------- -# ligent 的静态目标要一份**用 musl 编出来的** libcurl(发行版那份是给 glibc 的, -# 混着链会得到一堆 undefined reference to __libc_start_main)。 -# Alpine 本身就是 musl 的发行版,`apk add build-base` 得到的 gcc 直接就是 -# musl 目标,省掉了"装 musl 工具链 + 配 spec 文件"这一整段麻烦, -# 而且 musl-gcc 这个包装脚本在 Alpine 上根本不存在也不需要。 +# ligent 的静态目标要一份**用 musl 编出来的** libcurl (发行版那份是给 glibc 的, +# 混着链会得到一堆 undefined reference to __libc_start_main). +# Alpine 本身就是 musl 的发行版, `apk add build-base` 得到的 gcc 直接就是 +# 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 的。 +# 分支 (我们显式给它 CC=cc), 产出的 .a 天然就是 musl 的. # # # 为什么分多阶段 # -------------- -# 最终镜像里只有那个静态二进制。构建工具链(gcc/perl/make)加上 zlib/OpenSSL/curl -# 的源码和中间产物有好几百 MB,全都不该进最终镜像 —— 而 ligent 是静态链接的, -# 最终镜像连 libc 都不需要,scratch 就够。 +# 最终镜像里只有那个静态二进制. 构建工具链 (gcc/perl/make)加上 zlib/OpenSSL/curl +# 的源码和中间产物有好几百 MB, 全都不该进最终镜像 —— 而 ligent 是静态链接的, +# 最终镜像连 libc 都不需要, scratch 就够. # # # 关于构建缓存 # ------------ -# 依赖的编译(OpenSSL 那步最慢,几分钟)单独占一层,且只依赖脚本本身。 -# 源码 COPY 放在它后面:改一行 C 代码不会让"重编 OpenSSL"这件事再发生一遍。 -# 这是这个 Dockerfile 里唯一一处为了速度而做的结构调整,值得。 +# 依赖的编译 (OpenSSL 那步最慢, 几分钟)单独占一层, 且只依赖脚本本身. +# 源码 COPY 放在它后面:改一行 C 代码不会让"重编 OpenSSL"这件事再发生一遍. +# 这是这个 Dockerfile 里唯一一处为了速度而做的结构调整, 值得. # ------------------------------------------------------------------ 构建阶段 FROM docker.io/library/alpine:3.20 AS build -# build-base: gcc/make/binutils(Alpine 的 gcc 就是 musl 目标的) +# build-base: gcc/make/binutils (Alpine 的 gcc 就是 musl 目标的) # perl: OpenSSL 的 Configure 是 perl 写的 # curl: tools/build-static-deps.sh 用来下载源码包 -# linux-headers: 依赖要内核 UAPI 头(linux/mman.h 这些)。 -# 注意在本机上这一项是用 -idirafter /usr/include 解决的, -# 容器里显式装出来更干净。 +# linux-headers: 依赖要内核 UAPI 头 (linux/mman.h 这些). +# 注意在本机上这一项是用 -idirafter /usr/include 解决的, +# 容器里显式装出来更干净. # tar/xz: 解压源码包 RUN apk add --no-cache \ build-base \ @@ -59,77 +59,77 @@ RUN apk add --no-cache \ WORKDIR /src -# 先只拷贝依赖脚本,让"编依赖"这一步能独立于源码变化被缓存。 -# 版本号通过 build-arg 传进来(CI 里从 git tag 取),这样镜像内构建不依赖 .git —— -# .dockerignore 把 .git 排除了,容器里本来也看不到它。 +# 先只拷贝依赖脚本, 让"编依赖"这一步能独立于源码变化被缓存. +# 版本号通过 build-arg 传进来 (CI 里从 git tag 取), 这样镜像内构建不依赖 .git —— +# .dockerignore 把 .git 排除了, 容器里本来也看不到它. COPY tools/build-static-deps.sh /src/tools/build-static-deps.sh -# CC=cc:Alpine 的 cc 就是 musl 目标的 gcc,不需要 musl-gcc 包装脚本。 -# 这一步会联网下载并编译 zlib + OpenSSL + curl,约几分钟。 +# CC=cc:Alpine 的 cc 就是 musl 目标的 gcc, 不需要 musl-gcc 包装脚本. +# 这一步会联网下载并编译 zlib + OpenSSL + curl, 约几分钟. RUN CC=cc sh /src/tools/build-static-deps.sh -# 再拷源码。放在依赖之后,改 C 代码不会触发依赖重编。 +# 再拷源码. 放在依赖之后, 改 C 代码不会触发依赖重编. COPY src/ /src/src/ COPY Makefile /src/Makefile COPY tools/get-version.sh /src/tools/get-version.sh -# 版本号由外面注入。容器里没有 .git,get-version.sh 会回退到源码文件的 mtime -# —— 那是"这份源码的日期",不是"tag 的日期",所以 CI 应当显式传进来: +# 版本号由外面注入. 容器里没有 .git, get-version.sh 会回退到源码文件的 mtime +# —— 那是"这份源码的日期", 不是"tag 的日期", 所以 CI 应当显式传进来: # podman build --build-arg VERSION=$(git describe --tags --abbrev=0) . # podman build --build-arg VERSION=$(date -u +%Y.%m.%d) . ARG VERSION= ARG GIT_HASH=unknown ARG GIT_BRANCH=unknown -# 完整的 Unix 时间戳(秒)。不要传 "2026-09-22" 这种只有日期的串 —— -# date(1) 会把它解析成那天 00:00:00 UTC,产物的构建时间就永远是午夜。 +# 完整的 Unix 时间戳 (秒). 不要传 "2026-09-22" 这种只有日期的串 —— +# date(1) 会把它解析成那天 00:00:00 UTC, 产物的构建时间就永远是午夜. ARG BUILD_EPOCH= RUN set -eux; \ - # 传了就用传进来的,没传就让脚本自己算(会回退到源码日期) + # 传了就用传进来的, 没传就让脚本自己算 (会回退到源码日期) if [ -n "$VERSION" ]; then export LIGENT_VERSION_OVERRIDE="$VERSION"; fi; \ if [ -n "$GIT_HASH" ]; then export LIGENT_HASH_OVERRIDE="$GIT_HASH"; fi; \ 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 把符号表剥没了导致入口找不到)。 \ + # 自检:产物必须真的存在、真的是静态的、真的能跑. \ + # 不检查的话, "构建成功"可能只是"链接器没报错", 而产物是个空壳 \ + # (比如依赖没链全, 或者 -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; \ fi; \ V=$(/src/ligent-static --version); \ echo "容器内产物版本: $V"; \ - # 版本号还是回退默认值 => -D 没生效,构建是"看起来成功"。 - # 这里只在**没传 VERSION** 时警告:那种情况下回退是预期行为(比如有人 - # 直接 podman build 不带参数),传了却没用上才是真问题。 + # 版本号还是回退默认值 => -D 没生效, 构建是"看起来成功". + # 这里只在**没传 VERSION** 时警告:那种情况下回退是预期行为 (比如有人 + # 直接 podman build 不带参数), 传了却没用上才是真问题. if [ -n "$VERSION" ] && echo "$V" | grep -q '0.0.0-unknown'; then \ echo "错误:传了 VERSION=$VERSION 但产物仍报回退版本号" >&2; exit 1; \ fi # ------------------------------------------------------------------ 运行阶段 -# scratch 就够了:ligent-static 是完全静态的,连 libc 都不需要。 -# 没有任何攻击面可言 —— 镜像里只有一个二进制文件,没有 shell、没有包管理器。 +# scratch 就够了:ligent-static 是完全静态的, 连 libc 都不需要. +# 没有任何攻击面可言 —— 镜像里只有一个二进制文件, 没有 shell、没有包管理器. FROM scratch -# CA 证书是**唯一**需要的外部文件:TLS 握手要它。 +# CA 证书是**唯一**需要的外部文件:TLS 握手要它. # 注意 ligent 用的是编进 libcurl 的默认路径 /etc/ssl/certs/ca-certificates.crt -# (libcurl 库不读 CURL_CA_BUNDLE,那是 curl 命令行工具的行为), -# 所以证书必须放在那个位置才行,放别处不会被读到。 +# (libcurl 库不读 CURL_CA_BUNDLE, 那是 curl 命令行工具的行为), +# 所以证书必须放在那个位置才行, 放别处不会被读到. COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 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.description="纯 C 实现的 AI Agent (静态构建)" \ org.opencontainers.image.licenses="MIT" \ org.opencontainers.image.source="https://github.com/pluvium27/ligent" -# 默认进 REPL 需要 tty;不带参数跑会因为没有 API key 而给出提示, -# 所以这里不做 ENTRYPOINT 的花活,直接就是那个二进制。 +# 默认进 REPL 需要 tty; 不带参数跑会因为没有 API key 而给出提示, +# 所以这里不做 ENTRYPOINT 的花活, 直接就是那个二进制. ENTRYPOINT ["/ligent"] diff --git a/Makefile b/Makefile index 817e486..24cfb0f 100644 --- a/Makefile +++ b/Makefile @@ -1,34 +1,34 @@ CC ?= cc -# 警告就是纪律:这一串是"改完就回不去"的那类问题(影子变量、丢 const、 -# 没有原型的函数)。加进来之后新代码写错会当场报出来,而不是等哪天静态分析 -# 才捞到。项目在这套 flags 下是零警告的 —— 注意这句话是**连 make test 一起算的**: -# 测试程序用的是同一套 CFLAGS,以前它们里面藏着 5 条 const 丢弃警告,只有跑 -# test-unit 才会看到,光看 make 的输出是看不见的。 +# 警告就是纪律:这一串是"改完就回不去"的那类问题 (影子变量、丢 const、 +# 没有原型的函数). 加进来之后新代码写错会当场报出来, 而不是等哪天静态分析 +# 才捞到. 项目在这套 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:抓"把变量当格式串"。已经抓到一个 —— -# doctor 里把 HTTPS_PROXY 的值直接喂给 field(),而代理 URL 里带 % 是常事。 +# -Wformat=2 里值钱的是 -Wformat-security:抓"把变量当格式串". 已经抓到一个 —— +# doctor 里把 HTTPS_PROXY 的值直接喂给 field(), 而代理 URL 里带 % 是常事. # -Wno-format-nonliteral 关掉的是它的噪声面:我们自己那套包装内部转发 fmt 的 -# 地方(xvasprintf / err_result / …)天然就是非字面量。可查的部分已经由 -# LIGENT_PRINTF 属性在**调用点**查完了。 +# 地方 (xvasprintf / err_result / …)天然就是非字面量. 可查的部分已经由 +# LIGENT_PRINTF 属性在**调用点**查完了. CPPFLAGS += -Isrc -pthread # 版本号的 -D 也挂在 CPPFLAGS 上:这样主程序、静态目标、以及二十来条测试规则 -# 全都自动带上,不用逐个改。测试程序确实需要它 —— 它们会链 version.c。 +# 全都自动带上, 不用逐个改. 测试程序确实需要它 —— 它们会链 version.c. CPPFLAGS += $(VERSION_DEFS) -# -lm 是必须的:代码里调了 fabs,而 gcc 会把它折成内建函数,于是"不写 -lm -# 也能链接"是个假象 —— 换个编译器(tcc)或加 -fno-builtin 就会露出来。 -# 显式写上,不要靠编译器的好意。 +# -lm 是必须的:代码里调了 fabs, 而 gcc 会把它折成内建函数, 于是"不写 -lm +# 也能链接"是个假象 —— 换个编译器 (tcc)或加 -fno-builtin 就会露出来. +# 显式写上, 不要靠编译器的好意. LDLIBS += -lcurl -lm -pthread -# make SAN=1 —— 打开 AddressSanitizer / UndefinedBehaviorSanitizer。 -# 链接行也要带,否则只有目标文件被插桩、链接期运行时库没进来。 +# make SAN=1 —— 打开 AddressSanitizer / UndefinedBehaviorSanitizer. +# 链接行也要带, 否则只有目标文件被插桩、链接期运行时库没进来. ifeq ($(SAN),1) -# 注意 override:命令行上传的变量会屏蔽 makefile 里对它的一切赋值(连 += 也算), +# 注意 override:命令行上传的变量会屏蔽 makefile 里对它的一切赋值 (连 += 也算), # 所以 `make SAN=1 CFLAGS="-O1"` 不加 override 的话会**静默地不带 sanitizer** —— -# 结果是一份没插桩却看起来「干净」的构建。这个坑踩过一次,别再踩。 -# -fno-sanitize-recover=all 让报错直接中止,而不是报告完继续跑。 +# 结果是一份没插桩却看起来「干净」的构建. 这个坑踩过一次, 别再踩. +# -fno-sanitize-recover=all 让报错直接中止, 而不是报告完继续跑. override CFLAGS += -fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all override LDLIBS += -fsanitize=address,undefined endif @@ -37,32 +37,32 @@ BIN := ligent SRCDIR := src BUILDDIR := build -# 对象目录带"编译器 + 是否插桩"的标签,见文件末尾"为什么对象目录要带标签"。 +# 对象目录带"编译器 + 是否插桩"的标签, 见文件末尾"为什么对象目录要带标签". BUILD_TAG := $(basename $(notdir $(CC)))$(if $(filter 1,$(SAN)),-san,) OBJDIR := $(BUILDDIR)/$(BUILD_TAG) -# 头文件也是依赖。以前没有这一条,改了 .h 之后 make 会说"无需做任何事", -# 目标文件仍然是旧布局 —— 这正是"子代理读到的字段全是垃圾值"那次事故的成因。 -# 全量重编只要几秒,不值得为它引入 .d 文件那一套。 +# 头文件也是依赖. 以前没有这一条, 改了 .h 之后 make 会说"无需做任何事", +# 目标文件仍然是旧布局 —— 这正是"子代理读到的字段全是垃圾值"那次事故的成因. +# 全量重编只要几秒, 不值得为它引入 .d 文件那一套. HEADERS := $(wildcard $(SRCDIR)/*.h) # ------------------------------------------------------------------ */ -# 版本号:CalVer + git 哈希,构建期算出来注入 */ +# 版本号:CalVer + git 哈希, 构建期算出来注入 */ # ------------------------------------------------------------------ */ # -# 源码里**不写死**版本号,只留一个回退默认值(见 src/version.h 的说明)。 -# 这里取真值,用 -D 注入。算法和理由都在 tools/get-version.sh 里。 +# 源码里**不写死**版本号, 只留一个回退默认值 (见 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) LIGENT_GIT_BRANCH := $(shell sh $(VERSION_SCRIPT) 2>/dev/null | sed -n 3p) LIGENT_BUILD_DATE := $(shell sh $(VERSION_SCRIPT) 2>/dev/null | sed -n 4p) LIGENT_BUILD_TIME := $(shell sh $(VERSION_SCRIPT) 2>/dev/null | sed -n 5p) -# 脚本跑不起来(比如没有 sh)时给个明确的默认值,别让 -D 后面空着 —— -# 空的 LIGENT_VERSION 会让预处理器把它当成"没定义",于是回退到 version.h -# 里的值。那样其实也对,但显式写出来更不容易被误读。 +# 脚本跑不起来 (比如没有 sh)时给个明确的默认值, 别让 -D 后面空着 —— +# 空的 LIGENT_VERSION 会让预处理器把它当成"没定义", 于是回退到 version.h +# 里的值. 那样其实也对, 但显式写出来更不容易被误读. LIGENT_VERSION := $(if $(LIGENT_VERSION),$(LIGENT_VERSION),0.0.0-unknown) LIGENT_GIT_HASH := $(if $(LIGENT_GIT_HASH),$(LIGENT_GIT_HASH),unknown) LIGENT_GIT_BRANCH := $(if $(LIGENT_GIT_BRANCH),$(LIGENT_GIT_BRANCH),unknown) @@ -76,42 +76,42 @@ VERSION_DEFS = -DLIGENT_VERSION='"$(LIGENT_VERSION)"' \ -DLIGENT_BUILD_TIME='"$(LIGENT_BUILD_TIME)"' # ------------------------------------------------------------------ */ -# 版本号变了就自动清掉对象,免得 make 拿旧版本号蒙混过关 */ +# 版本号变了就自动清掉对象, 免得 make 拿旧版本号蒙混过关 */ # ------------------------------------------------------------------ */ # -# 这是"make 看不见命令行变量变了"这个坑的第三个入口(前两个:混用编译器 -# 导致链接错误、make SAN=1 不重新插桩 —— 那两个是用 build// 分目录解决的)。 +# 这是"make 看不见命令行变量变了"这个坑的第三个入口 (前两个:混用编译器 +# 导致链接错误、make SAN=1 不重新插桩 —— 那两个是用 build// 分目录解决的). # -# 版本号只走 -D,不进任何依赖关系,于是: +# 版本号只走 -D, 不进任何依赖关系, 于是: # $ make && ./ligent --version # 2027.01.01+... # $ git tag -d v2027.01.01 # 把 tag 删了 # $ make # "无需做任何事" # $ ./ligent --version # 还是 2027.01.01 —— **在说谎** -# 二进制看着完全正常,只是版本号是旧的。而这恰恰是 .dirty 标记想避免的那类误导。 +# 二进制看着完全正常, 只是版本号是旧的. 而这恰恰是 .dirty 标记想避免的那类误导. # -# 用一个戳文件记下上次构建用的版本串,对不上就把**我们自己的**对象目录清掉。 +# 用一个戳文件记下上次构建用的版本串, 对不上就把**我们自己的**对象目录清掉. # # 为什么不用 `make clean`:那个会连 build/static-prefix 和 build/static-src 一起删 -# (用户明确要的语义),而重建静态依赖要联网下载 + 编译好几分钟。为了一个版本号 -# 字符串付那个代价不合理。所以这里只删自己的产物,精确地清。 +# (用户明确要的语义), 而重建静态依赖要联网下载 + 编译好几分钟. 为了一个版本号 +# 字符串付那个代价不合理. 所以这里只删自己的产物, 精确地清. VERSION_STAMP := $(BUILDDIR)/.version-stamp -# 戳文件的内容:版本号 + 哈希 + 构建日期。任意一项变了都要重编 —— -# 比如只切了 tag(版本号变)或只改了工作区(.dirty 出现)。 -# 戳文件的内容:把**每一个**注入进二进制的字符串都算进去。任意一项变了都要 -# 重编 —— 比如只切了 tag(版本号变)、只改了工作区(.dirty 出现)、只换了 -# 分支(branch 变)。少算一项,那一项就会在二进制里过期而 make 毫无察觉, -# 正是这个机制要防的事。 +# 戳文件的内容:版本号 + 哈希 + 构建日期. 任意一项变了都要重编 —— +# 比如只切了 tag (版本号变)或只改了工作区 (.dirty 出现). +# 戳文件的内容:把**每一个**注入进二进制的字符串都算进去. 任意一项变了都要 +# 重编 —— 比如只切了 tag (版本号变)、只改了工作区 (.dirty 出现)、只换了 +# 分支 (branch 变). 少算一项, 那一项就会在二进制里过期而 make 毫无察觉, +# 正是这个机制要防的事. # -# 注意 BUILD_TIME 不在这里:它每秒都在变,算进去等于每次 make 都全量重编。 -# 代价是同一分钟内改了源码、二进制里的时间戳可能停在上一分钟 —— 那无所谓, -# 时间戳是"什么时候编的",不是"源码什么时候改的",而日期(BUILD_DATE)在, -# 粒度够用了。 +# 注意 BUILD_TIME 不在这里:它每秒都在变, 算进去等于每次 make 都全量重编. +# 代价是同一分钟内改了源码、二进制里的时间戳可能停在上一分钟 —— 那无所谓, +# 时间戳是"什么时候编的", 不是"源码什么时候改的", 而日期 (BUILD_DATE)在, +# 粒度够用了. VERSION_FINGERPRINT := $(LIGENT_VERSION)|$(LIGENT_GIT_HASH)|$(LIGENT_GIT_BRANCH)|$(LIGENT_BUILD_DATE) -# 用 $(shell) 在每个 make 里比对一次。 +# 用 $(shell) 在每个 make 里比对一次. # 注意 $(if $(filter-out X,Y),...) 在 Y 为空时**恒为假** —— filter-out 一个 -# 空列表得到空列表,什么都过滤不掉。所以这里必须显式处理"戳文件不存在"这一 -# 情况(第一次构建就是它),否则戳文件永远写不出来,机制整个失效。 +# 空列表得到空列表, 什么都过滤不掉. 所以这里必须显式处理"戳文件不存在"这一 +# 情况 (第一次构建就是它), 否则戳文件永远写不出来, 机制整个失效. CURRENT_STAMP := $(shell cat $(VERSION_STAMP) 2>/dev/null) ifeq ($(CURRENT_STAMP),) NEED_VERSION_CLEAN := 1 @@ -122,13 +122,13 @@ NEED_VERSION_CLEAN := endif ifeq ($(NEED_VERSION_CLEAN),1) -# 清掉再让 make 正常评估依赖 —— 这一步发生在**读 Makefile 的时候**, -# 所以后面的 $(OBJS) 会看到"对象不存在",自然全量重编。 -# 在 $(shell) 里写文件是安全的:它只在真的需要清的时候跑一次。 +# 清掉再让 make 正常评估依赖 —— 这一步发生在**读 Makefile 的时候**, +# 所以后面的 $(OBJS) 会看到"对象不存在", 自然全量重编. +# 在 $(shell) 里写文件是安全的:它只在真的需要清的时候跑一次. $(shell rm -rf $(OBJDIR) $(BIN) && mkdir -p $(BUILDDIR) && printf '%s' '$(VERSION_FINGERPRINT)' > $(VERSION_STAMP)) endif -# $^ 里既有 .c 也有 .h(后者是为了依赖关系才加上的),真正要编译的只有 .c +# $^ 里既有 .c 也有 .h (后者是为了依赖关系才加上的), 真正要编译的只有 .c SRCFILES = $(filter-out %.h,$^) SRCS := $(wildcard $(SRCDIR)/*.c) @@ -149,13 +149,13 @@ $(OBJDIR): # 清掉**一切**构建产物: # build/ 各 <编译器>[-san] 对象目录、build/static、测试可执行文件、 -# e2e 的临时文件,以及 tools/build-static-deps.sh 编出来的 -# static-prefix(静态库)和 static-src(源码 + 压缩包缓存) +# e2e 的临时文件, 以及 tools/build-static-deps.sh 编出来的 +# static-prefix (静态库)和 static-src (源码 + 压缩包缓存) # ligent / ligent-static # tests/__pycache__ 跑测试时 python 生成的字节码 # -# 特意**不动** ligent.history:那不是构建产物,是你的输入历史,删了找不回来。 -# (e2e 自己写的是 build/e2e.history,在 build/ 里,跟着一起清。) +# 特意**不动** ligent.history:那不是构建产物, 是你的输入历史, 删了找不回来. +# (e2e 自己写的是 build/e2e.history, 在 build/ 里, 跟着一起清. ) clean: rm -rf $(BUILDDIR) $(BIN) $(BIN)-static tests/__pycache__ @@ -164,56 +164,56 @@ clean: # ------------------------------------------------------------------ */ # # 目标是一个**完全静态**的可执行文件:不依赖宿主机的 libc、libcurl、 -# OpenSSL。拷到任何同架构的 Linux 上就能跑。 +# OpenSSL. 拷到任何同架构的 Linux 上就能跑. # -# 为什么不能直接拿发行版的 libcurl.a 链接:那是针对 glibc 编的,和 musl 混在 -# 一起会得到一堆 "undefined reference to __libc_start_main" 之类。必须有一份 -# **用 musl 工具链编出来的**静态 libcurl。造它的办法见 tools/build-static-deps.sh。 +# 为什么不能直接拿发行版的 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) +# STATIC_PREFIX 依赖所在的前缀 (默认 build/static-prefix) 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" || { \ echo "$(STATIC_PREFIX) 里没有 curl-config —— 那份 libcurl 不是在这个"; \ - echo "前缀下编的,或者没编完。重跑 ./tools/build-static-deps.sh"; \ + echo "前缀下编的, 或者没编完. 重跑 ./tools/build-static-deps.sh"; \ exit 1; } $(STATIC_OBJDIR): mkdir -p $(STATIC_OBJDIR) -# -I$(STATIC_PREFIX)/include:musl-gcc **不搜 /usr/include**,系统那份 curl 头 -# 也不是给 musl 用的 —— 必须用我们这份 prefix 里的头。 +# -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 自己报:它最清楚自己链了哪些库、什么顺序。 -# 手写一串 -lssl -lcrypto -lz 是会过期的,而且顺序错了链接就失败。 +# 依赖列表交给 curl 自己报:它最清楚自己链了哪些库、什么顺序. +# 手写一串 -lssl -lcrypto -lz 是会过期的, 而且顺序错了链接就失败. static: static-check $(STATIC_OBJS) @CURL_CONFIG="$(STATIC_PREFIX)/bin/curl-config"; \ LIBS="$$($$CURL_CONFIG --static-libs)" || exit 1; \ @@ -224,37 +224,37 @@ 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 -# (我们那份 libcurl.a 就是 -fno-pie 编的)会报 -# "relocation R_X86_64_32 against ... can not be used when making a PIE object"。 -# 两头都关掉,省得踩。 +# -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". +# 两头都关掉, 省得踩. STATIC_CFLAGS ?= $(CFLAGS) -fno-pie STATIC_LDFLAGS ?= -static -no-pie # ------------------------------------------------------------------ */ -# 静态产物优先体积(make static STATIC_RELEASE=0 关掉) */ +# 静态产物优先体积 (make static STATIC_RELEASE=0 关掉) */ # ------------------------------------------------------------------ */ # -# 普通构建的取舍是"能调试",静态产物的取舍是"拷过去就能跑、越小越好", -# 所以这里单独一套开关。实测 6.23 MB → 4.85 MB(-22%),逐项收益: +# 普通构建的取舍是"能调试", 静态产物的取舍是"拷过去就能跑、越小越好", +# 所以这里单独一套开关. 实测 6.23 MB → 4.85 MB (-22%), 逐项收益: # -# -s 4.92 MB 链接期 strip。**单项最大**:-g 带进来的调试信息 -# (551 KB) 加上符号表,一共 1.31 MB —— 占了原产物的 -# 五分之一,而静态发布件根本用不上。 -# -ffunction-sections 4.91 MB 只对我们自己的 .o 生效,所以几乎没用 -# -fdata-sections (收益来自后面 --gc-sections 能按函数/数据丢段; -# 依赖库的 .a 不是这么编的,见下面的"还能再小") -# -fno-*-unwind-tables 4.90 MB .eh_frame 本来 514 KB。C 代码不需要 -# C++ 异常和栈回溯。 +# -s 4.92 MB 链接期 strip. **单项最大**:-g 带进来的调试信息 +# (551 KB) 加上符号表, 一共 1.31 MB —— 占了原产物的 +# 五分之一, 而静态发布件根本用不上. +# -ffunction-sections 4.91 MB 只对我们自己的 .o 生效, 所以几乎没用 +# -fdata-sections (收益来自后面 --gc-sections 能按函数/数据丢段; +# 依赖库的 .a 不是这么编的, 见下面的"还能再小") +# -fno-*-unwind-tables 4.90 MB .eh_frame 本来 514 KB. C 代码不需要 +# C++ 异常和栈回溯. # -Os 4.86 MB 我们自己的代码优先体积 # -z noseparate-code 4.85 MB 不再为代码/数据段留页对齐空洞 # --build-id=none 少一个 note 段 -# -g0 不生成调试信息(否则每个 .o 都白白大一截, -# 编译也慢,反正最后要 strip) +# -g0 不生成调试信息 (否则每个 .o 都白白大一截, +# 编译也慢, 反正最后要 strip) # -# 还能再小的地方(不在本文件范围):3.49 MB 的 .text 几乎全是 libcurl + -# OpenSSL。要动它得在 tools/build-static-deps.sh 里给依赖也加 -Os 和 -# -ffunction-sections,让 --gc-sections 能真正丢进没被引用的 OpenSSL 代码。 +# 还能再小的地方 (不在本文件范围):3.49 MB 的 .text 几乎全是 libcurl + +# OpenSSL. 要动它得在 tools/build-static-deps.sh 里给依赖也加 -Os 和 +# -ffunction-sections, 让 --gc-sections 能真正丢进没被引用的 OpenSSL 代码. STATIC_RELEASE ?= 1 ifeq ($(STATIC_RELEASE),1) STATIC_CFLAGS += -Os -g0 -ffunction-sections -fdata-sections \ @@ -274,25 +274,25 @@ $(OBJDIR)/md_demo: tools/md_demo.c $(SRCDIR)/markdown.c $(SRCDIR)/util.c $(SRCDI demo: $(OBJDIR)/md_demo @./$(OBJDIR)/md_demo -# 只编译,不运行 +# 只编译, 不运行 md_demo: $(OBJDIR)/md_demo # ------------------------------------------------------------------ # 测试 # ------------------------------------------------------------------ # -# 测试与演示程序**复用主构建编出来的 .o**(只链它真正需要的那几个模块),而不是 -# 每个测试各自把源文件重新编译一遍。 +# 测试与演示程序**复用主构建编出来的 .o** (只链它真正需要的那几个模块), 而不是 +# 每个测试各自把源文件重新编译一遍. # -# 以前每条规则的依赖写的是源文件($(SRCDIR)/json.c 之类),recipe 是 -# `$(CC) … -o $@ $(SRCFILES)` —— 于是同一个源文件会被编很多遍:只改 util.c 一行, -# 它会按依赖被重编 16 次,实测 `make test-unit` 要 25 秒,而真正的工作量只有 -# "编一个 util.o + 重链 17 个二进制"。现在依赖写成 .o:编译一次、链接多次, -# 同样的场景降到几秒。而这些 .o 主构建本来就要用,等于没有额外成本。 +# 以前每条规则的依赖写的是源文件 ($(SRCDIR)/json.c 之类), recipe 是 +# `$(CC) … -o $@ $(SRCFILES)` —— 于是同一个源文件会被编很多遍:只改 util.c 一行, +# 它会按依赖被重编 16 次, 实测 `make test-unit` 要 25 秒, 而真正的工作量只有 +# "编一个 util.o + 重链 17 个二进制". 现在依赖写成 .o:编译一次、链接多次, +# 同样的场景降到几秒. 而这些 .o 主构建本来就要用, 等于没有额外成本. # -# 代价:测试的依赖里多了一层(.o 是否存在/是否过期由主构建的规则负责),排查 -# "为什么这个测试用的还是旧代码"要看 .o 的时间戳而不是源文件。$(SRCFILES) 仍然 -# 有意义 —— $^ 里混着 $(HEADERS),而头文件不能上链接行。 +# 代价:测试的依赖里多了一层 (.o 是否存在/是否过期由主构建的规则负责), 排查 +# "为什么这个测试用的还是旧代码"要看 .o 的时间戳而不是源文件. $(SRCFILES) 仍然 +# 有意义 —— $^ 里混着 $(HEADERS), 而头文件不能上链接行. $(OBJDIR)/test_json: tests/test_json.c $(OBJDIR)/json.o $(OBJDIR)/util.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR) $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS) @@ -404,17 +404,17 @@ test-unit: $(addprefix $(OBJDIR)/,$(UNIT_TESTS)) $(OBJDIR)/md_demo $(OBJDIR)/fuz test_i18n) label="界面文案" ;; \ *) label="$$t" ;; \ esac; \ - echo "==== $$label 测试($$t)===="; \ + echo "==== $$label 测试 ($$t)===="; \ ./$(OBJDIR)/$$t || exit 1; \ echo; \ done @echo "==== 演示程序分片自检 ====" @./$(OBJDIR)/md_demo --compare < /dev/null @echo - @echo "==== 颜色审计(只用 16 色内的 SGR) ====" + @echo "==== 颜色审计 (只用 16 色内的 SGR) ====" @./tools/check-colors.sh @echo - @echo "==== 模糊测试(每轮固定种子,可复现) ====" + @echo "==== 模糊测试 (每轮固定种子, 可复现) ====" @./$(OBJDIR)/fuzz 4000 test-e2e: $(BIN) @@ -424,12 +424,12 @@ test-e2e: $(BIN) # make check-docs —— README 里写的测试数量必须和现实一致 */ # ------------------------------------------------------------------ */ # -# README 里引用了"831 单元 + 314 e2e"这样的数字。数字会随着加测试而过期, -# 而过期的数字比不写更糟 —— 它是**错的信息**,读的人没有理由怀疑它。 -# (这件事就发生过:加完 JSON 的 UTF-8 测试后,README 还写着 797。) +# README 里引用了"831 单元 + 314 e2e"这样的数字. 数字会随着加测试而过期, +# 而过期的数字比不写更糟 —— 它是**错的信息**, 读的人没有理由怀疑它. +# (这件事就发生过:加完 JSON 的 UTF-8 测试后, README 还写着 797. ) # -# 所以把"文档说的"和"实际跑出来的"对一遍。不做成 make test 的一部分: -# 它要跑一遍单元测试来数数,比 test-unit 本身还慢。 +# 所以把"文档说的"和"实际跑出来的"对一遍. 不做成 make test 的一部分: +# 它要跑一遍单元测试来数数, 比 test-unit 本身还慢. check-docs: $(addprefix $(OBJDIR)/,$(UNIT_TESTS)) $(BIN) @unit=0; \ for t in $(UNIT_TESTS); do \ @@ -437,50 +437,50 @@ check-docs: $(addprefix $(OBJDIR)/,$(UNIT_TESTS)) $(BIN) unit=$$((unit + $${n:-0})); \ done; \ e2e=$$(bash tests/run_e2e.sh 2>/dev/null | grep -oE '通过 [0-9]+ 项' | grep -oE '[0-9]+' | tail -1); \ - unit_doc=$$(grep -oE '([0-9]+ 单元' README.md | grep -oE '[0-9]+' | head -1); \ + unit_doc=$$(grep -oE ' ([0-9]+ 单元' README.md | grep -oE '[0-9]+' | head -1); \ unit_docs=$$(grep -oE '[0-9]+ 个可执行、[0-9]+ 项' README.md | grep -oE '[0-9]+ 项' | grep -oE '[0-9]+' | head -1); \ 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; \ - else echo " ✓ 单元测试 $$unit 项,与 README 一致"; fi; \ + 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; \ - else echo " ✓ e2e $$e2e 项,与 README 一致"; fi; \ - if [ $$bad -eq 0 ]; then echo "文档里的数量与现实一致。"; \ - else echo "README 里的数字已过期,请更新(数字对不上就是错的信息)。"; exit 1; fi + 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 -# 拿同一套 e2e 打静态产物。以前"静态构建可用"实际上只等于"它能启动" —— -# 依赖脚本的自检只做 curl_easy_init + setopt,连一个请求都不发;现在这 300 -# 多项跑一遍,才真的说明它是可用的。 -# 单独一个目标、不进 make test:要先编依赖,慢。 +# 拿同一套 e2e 打静态产物. 以前"静态构建可用"实际上只等于"它能启动" —— +# 依赖脚本的自检只做 curl_easy_init + setopt, 连一个请求都不发; 现在这 300 +# 多项跑一遍, 才真的说明它是可用的. +# 单独一个目标、不进 make test:要先编依赖, 慢. static-test: static @BIN="$(abspath $(STATIC_BIN))" bash tests/run_e2e.sh # ------------------------------------------------------------------ */ -# 容器里构建静态产物(CI/CD 用) */ +# 容器里构建静态产物 (CI/CD 用) */ # ------------------------------------------------------------------ */ # -# podman 和 docker 命令行兼容,默认用 podman(这个项目在 rootless 环境下开发)。 -# 用 CONTAINER=... 换。 +# podman 和 docker 命令行兼容, 默认用 podman (这个项目在 rootless 环境下开发). +# 用 CONTAINER=... 换. CONTAINER ?= podman CONTAINER_IMAGE ?= ligent-static -# 版本号显式传给容器:容器里没有 .git,get-version.sh 只能回退到源码 mtime, -# 那得到的是"文件日期"而不是"tag 日期"。这里在本机算好传进去。 -# GIT_HASH / GIT_BRANCH 同理 —— 二进制里的分支名应当反映**源码来自哪个分支**, -# 而不是"在容器里看不到分支所以是 unknown"。$(VERSION_SCRIPT) 见上面。 +# 版本号显式传给容器:容器里没有 .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 的话就沿用它(那正是"可复现构建"要求的:同一份 -# 源码无论何时构建都得到同样的时间戳)。 +# 的 --version 里时间永远显示午夜. 传秒数就没有这个歧义, 而且仍然可复现. +# 设了 SOURCE_DATE_EPOCH 的话就沿用它 (那正是"可复现构建"要求的:同一份 +# 源码无论何时构建都得到同样的时间戳). CONTAINER_BUILD_EPOCH := $(if $(SOURCE_DATE_EPOCH),$(SOURCE_DATE_EPOCH),$(shell date -u +%s)) docker-build: @@ -491,9 +491,9 @@ docker-build: --build-arg BUILD_EPOCH='$(CONTAINER_BUILD_EPOCH)' \ -t $(CONTAINER_IMAGE) . -# 把产物从镜像里拷出来。用 create+cp+rm 而不是 build 到本地: -# 镜像是分发单位,本地文件是给人直接用的,两者都要,但不必让 Dockerfile -# 同时干这两件事(那需要挂载点或者 --output,podman/docker 的支持还不齐)。 +# 把产物从镜像里拷出来. 用 create+cp+rm 而不是 build 到本地: +# 镜像是分发单位, 本地文件是给人直接用的, 两者都要, 但不必让 Dockerfile +# 同时干这两件事 (那需要挂载点或者 --output, podman/docker 的支持还不齐). docker-extract: docker-build @$(CONTAINER) rm -f $(CONTAINER_IMAGE)-tmp >/dev/null 2>&1 || true @$(CONTAINER) create --name $(CONTAINER_IMAGE)-tmp $(CONTAINER_IMAGE) >/dev/null @@ -502,82 +502,82 @@ 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 万轮)====" + @echo "==== 模糊测试 (20 万轮)====" @./$(OBJDIR)/fuzz 200000 -# `make -j test` 会让单元测试与 e2e 并行,输出互相穿插、也互相抢资源 —— -# 这两个目标内部本来就是逐条跑的,没有并行的必要。 +# `make -j test` 会让单元测试与 e2e 并行, 输出互相穿插、也互相抢资源 —— +# 这两个目标内部本来就是逐条跑的, 没有并行的必要. .NOTPARALLEL: test test-unit test-e2e test: test-unit test-e2e @echo - @echo "全部测试通过。" + @echo "全部测试通过. " -# 依赖统一由 $(HEADERS) 管:每个 .o 和每个测试都以全部头文件为依赖。 -# 不精确,但一共只有十几个文件、全量重编几秒钟,换来的是「永远不会漏」。 -# 原先这里还有一段手写依赖表,既冗余又不完整(上次 agent.h 漏更新就是它 -# 没跟上),已删。 +# 依赖统一由 $(HEADERS) 管:每个 .o 和每个测试都以全部头文件为依赖. +# 不精确, 但一共只有十几个文件、全量重编几秒钟, 换来的是「永远不会漏」. +# 原先这里还有一段手写依赖表, 既冗余又不完整 (上次 agent.h 漏更新就是它 +# 没跟上), 已删. $(OBJDIR)/reduce: tests/reduce.c $(OBJDIR)/markdown.o $(OBJDIR)/util.o \ $(OBJDIR)/i18n.o | $(OBJDIR) $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS) # ------------------------------------------------------------------ */ -# make analyze —— gcc 静态分析器(-fanalyzer) */ +# make analyze —— gcc 静态分析器 (-fanalyzer) */ # ------------------------------------------------------------------ */ # -# 它抓的是单靠警告看不出来的那类问题:读未初始化的值、空指针、资源泄漏。 -# 第一次跑就捞出两个真 bug(attempt() 里 curl_easy_init 失败那条提前返回 -# 会把未初始化的 cerr 当字符串读;POST 分支对 body==NULL 不设防)。 +# 它抓的是单靠警告看不出来的那类问题:读未初始化的值、空指针、资源泄漏. +# 第一次跑就捞出两个真 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。 +# 报一条 "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; \ + echo "静态分析发现问题 (上面的 warning: 就是原因). "; exit 1; \ fi # ------------------------------------------------------------------ */ -# 为什么对象目录要带标签(build/gcc、build/clang、build/gcc-san …) */ +# 为什么对象目录要带标签 (build/gcc、build/clang、build/gcc-san …) */ # ------------------------------------------------------------------ */ # -# make 只按"源文件比目标文件新"决定要不要重编,它**看不见命令行变量变了**。 +# make 只按"源文件比目标文件新"决定要不要重编, 它**看不见命令行变量变了**. # 于是有两类事故: # # 1) make CC=tcc 之后再 make CC=clang —— 只有被 touch 过的那几个 .o 是 clang -# 编的,其余还是 tcc 编的。tcc 的目标文件要 tcc 自己的运行时辅助例程 -# (__floatundidf 之类),拿 clang 去链接就是一堆 undefined reference。 -# 我自己的构建就是这么炸的,而且第一次还因为只用 ls 看文件大小而误判成 -# "clang 编译通过"。 +# 编的, 其余还是 tcc 编的. tcc 的目标文件要 tcc 自己的运行时辅助例程 +# (__floatundidf 之类), 拿 clang 去链接就是一堆 undefined reference. +# 我自己的构建就是这么炸的, 而且第一次还因为只用 ls 看文件大小而误判成 +# "clang 编译通过". # -# 2) 更要命的一类:make 之后再 make SAN=1 —— 一个 .o 都不会重编。 -# 链接行带着 -fsanitize=...,ASan 运行时也进了二进制,于是它**看起来** -# 是插桩过的;实际 nm 里一个 __asan/__ubsan 符号都没有,什么都不会检测。 -# 这意味着"没先 make clean 的 ASan 结论全是假绿灯"。 +# 2) 更要命的一类:make 之后再 make SAN=1 —— 一个 .o 都不会重编. +# 链接行带着 -fsanitize=..., ASan 运行时也进了二进制, 于是它**看起来** +# 是插桩过的; 实际 nm 里一个 __asan/__ubsan 符号都没有, 什么都不会检测. +# 这意味着"没先 make clean 的 ASan 结论全是假绿灯". # -# 把标签写进路径之后这两种混用都不可能发生了:配置一变,对应的目录就是空的, -# 该编的自然会全编。全量重编只要几秒,值得。 +# 把标签写进路径之后这两种混用都不可能发生了:配置一变, 对应的目录就是空的, +# 该编的自然会全编. 全量重编只要几秒, 值得. # # 注意 STATIC_PREFIX / STATIC_OBJDIR 挂在 $(BUILDDIR) 上而不是 $(OBJDIR) 上: -# build/static-prefix 是 tools/build-static-deps.sh 写死的位置,不能被标签带跑。 +# build/static-prefix 是 tools/build-static-deps.sh 写死的位置, 不能被标签带跑. diff --git a/README.zh.md b/README.zh.md index db4d900..d8df9ed 100644 --- a/README.zh.md +++ b/README.zh.md @@ -1,17 +1,17 @@ # ligent - C99 实现的命令行 AI Agent 一个用 C99 从零写成的命令行 AI Agent:直连 **OpenAI 兼容**的 Chat Completions -接口,具备**原生 function calling**(工具调用)能力,能真正读写文件、执行命令、 -搜索代码,并在终端里流式输出。 +接口, 具备**原生 function calling** (工具调用)能力, 能真正读写文件、执行命令、 +搜索代码, 并在终端里流式输出. -对话、工具调用、图片走的都是通用协议(`image_url` + base64 内联,DeepSeek 和 -MiMo 都收);**只有思考模式的字段是方言**,不做跨厂商探测。代价见 §9。 +对话、工具调用、图片走的都是通用协议 (`image_url` + base64 内联, DeepSeek 和 +MiMo 都收); **只有思考模式的字段是方言**, 不做跨厂商探测. 代价见 §9. -除 `libcurl`(HTTPS 传输)外,**不依赖任何第三方库**: -JSON 解析器、SSE 流式协议解析、工具调用增量拼接、REPL 全部是本仓库自己实现的。 +除 `libcurl` (HTTPS 传输)外, **不依赖任何第三方库**: +JSON 解析器、SSE 流式协议解析、工具调用增量拼接、REPL 全部是本仓库自己实现的. ``` -$ ligent "把 src/util.c 里的 xmalloc 改成失败时打印调用栈,然后编译验证" +$ ligent "把 src/util.c 里的 xmalloc 改成失败时打印调用栈, 然后编译验证" {/} read_file "src/util.c" │ 1 /* util.c - 基础工具实现 */ │ 2 #include "util.h" @@ -23,14 +23,14 @@ $ ligent "把 src/util.c 里的 xmalloc 改成失败时打印调用栈,然后 {/} run_command make 2>&1 | tail -20 │ cc ... -o ligent ... │ [exit code: 0] -已按要求改造 xmalloc 的失败路径,并确认编译通过。 +已按要求改造 xmalloc 的失败路径, 并确认编译通过. ``` --- ## 1. 编译 -依赖:`gcc`(或 `clang`)、`make`、`libcurl` 开发包。 +依赖:`gcc` (或 `clang`)、`make`、`libcurl` 开发包. ```bash # Debian / Ubuntu @@ -48,31 +48,31 @@ make # 生成 ./ligent make test # 跑单元测试 + 端到端测试 ``` -没有 `make install`。这是个自包含的小工具,不是系统级程序 —— 可执行文件在哪, -配置就在哪(见下一节),所以直接拷走 `ligent` 和 `ligent.json` 就是"安装"。 +没有 `make install`. 这是个自包含的小工具, 不是系统级程序 —— 可执行文件在哪, +配置就在哪 (见下一节), 所以直接拷走 `ligent` 和 `ligent.json` 就是"安装". -依赖只有 **libcurl** 和 libc,外加一个 C99 编译器(gcc / clang / tcc 都编得过)。 -跑测试还需要 Python 3 —— 端到端测试用本地 mock 服务端,**不联网、不需要密钥**。 +依赖只有 **libcurl** 和 libc, 外加一个 C99 编译器 (gcc / clang / tcc 都编得过). +跑测试还需要 Python 3 —— 端到端测试用本地 mock 服务端, **不联网、不需要密钥**. 其它目标: | 目标 | 作用 | |---|---| -| `make test` | 单元测试(16 个可执行)+ 端到端测试(318 项) | -| `make test-unit` | 只跑单元测试,不需要网络 | -| `make test-fuzz` | 模糊测试 20 万轮(`test-unit` 里跑的是 4000 轮) | -| `make analyze` | `gcc -fanalyzer` 静态分析,无告警才算过 | -| `make static` | 完全静态的产物(先跑 `./tools/build-static-deps.sh`) | +| `make test` | 单元测试 (16 个可执行)+ 端到端测试 (318 项) | +| `make test-unit` | 只跑单元测试, 不需要网络 | +| `make test-fuzz` | 模糊测试 20 万轮 (`test-unit` 里跑的是 4000 轮) | +| `make analyze` | `gcc -fanalyzer` 静态分析, 无告警才算过 | +| `make static` | 完全静态的产物 (先跑 `./tools/build-static-deps.sh`) | | `make static-test` | 拿同一套 e2e 打静态产物 | -| `make docker-build` | 在容器里构建静态产物(podman/docker) | +| `make docker-build` | 在容器里构建静态产物 (podman/docker) | | `make docker-extract` | 把产物从镜像拷到 `./ligent-static` | | `make docker-test` | 拿同一套 e2e 打容器产物 | | `make check-docs` | 校验本文里引用的测试数量与现实一致 | -| `tools/check-colors.sh` | 审计 ANSI 颜色码是否都在 16 色内(`make test-unit` 里会跑) | +| `tools/check-colors.sh` | 审计 ANSI 颜色码是否都在 16 色内 (`make test-unit` 里会跑) | ### 构建目录按编译器和配置分开 -产物不在 `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`, -只有被改过的那几个 `.o` 是 clang 编的,其余还是 tcc 的,链接时就是一堆 -`__floatundidf` 之类的 undefined reference。更危险的是 `make` 之后再 `make SAN=1`: -一个 `.o` 都不会重编,而链接行带着 `-fsanitize=...`、ASan 运行时也确实进了二进制, -于是它**看起来**是插桩过的 —— 可 `nm` 里一个 `__asan`/`__ubsan` 符号都没有, -什么都不会检测。换句话说,"没先 `make clean` 的 ASan 结论"全是假绿灯。 +原因很实在:**make 看不见命令行变量变了**. `make CC=tcc` 之后再 `make CC=clang`, +只有被改过的那几个 `.o` 是 clang 编的, 其余还是 tcc 的, 链接时就是一堆 +`__floatundidf` 之类的 undefined reference. 更危险的是 `make` 之后再 `make SAN=1`: +一个 `.o` 都不会重编, 而链接行带着 `-fsanitize=...`、ASan 运行时也确实进了二进制, +于是它**看起来**是插桩过的 —— 可 `nm` 里一个 `__asan`/`__ubsan` 符号都没有, +什么都不会检测. 换句话说, "没先 `make clean` 的 ASan 结论"全是假绿灯. -把标签写进路径之后这两种混用都不可能发生了:配置一变,对应目录就是空的,该编的 -自然会全编。 +把标签写进路径之后这两种混用都不可能发生了:配置一变, 对应目录就是空的, 该编的 +自然会全编. `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` 应该只剩你自己的东西。 +测试可执行文件、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 @@ -105,167 +105,167 @@ make clean make static ``` -`make static` 自己**不会**去编依赖(那等于让一个构建目标偷偷联网下载),它会明确 -报错并告诉你该跑上面那条命令。 +`make static` 自己**不会**去编依赖 (那等于让一个构建目标偷偷联网下载), 它会明确 +报错并告诉你该跑上面那条命令. -特意**不动 `ligent.history`**:那不是构建产物,是你的输入历史,删了找不回来。 -(e2e 自己写的是 `build/e2e.history`,在 `build/` 里,跟着一起清。) +特意**不动 `ligent.history`**:那不是构建产物, 是你的输入历史, 删了找不回来. + (e2e 自己写的是 `build/e2e.history`, 在 `build/` 里, 跟着一起清. ) ### 让编译器检查我们自己的 printf 项目里有一层自己的 printf 风格包装:`xasprintf` / `sb_printf` / `err_result` / -`log_printf` / `spinner_notice` / `perr` / doctor 的 `field`·`marked`·`sub`,一共 -11 个函数、约 120 个调用点。它们的格式串**编译器一个都没在看** —— 因为全项目 -`__attribute__` 出现 0 次,编译器不知道这些函数有 printf 语义。 +`log_printf` / `spinner_notice` / `perr` / doctor 的 `field`·`marked`·`sub`, 一共 +11 个函数、约 120 个调用点. 它们的格式串**编译器一个都没在看** —— 因为全项目 +`__attribute__` 出现 0 次, 编译器不知道这些函数有 printf 语义. -直接写 `fprintf` 的地方是查的(`confirm.c` 那次 `%s` 对 `int` 就是这么抓到的), -走包装的就全漏过去了。现在用 `LIGENT_PRINTF(fmt_idx, arg_idx)` 宏(gcc/clang 展开成 -`__attribute__((format(printf, …)))`,tcc 没有这个机制、展开成空)把 11 个函数全标上, -再加 `-Wformat=2`。 +直接写 `fprintf` 的地方是查的 (`confirm.c` 那次 `%s` 对 `int` 就是这么抓到的), +走包装的就全漏过去了. 现在用 `LIGENT_PRINTF(fmt_idx, arg_idx)` 宏 (gcc/clang 展开成 +`__attribute__((format(printf, …)))`, tcc 没有这个机制、展开成空)把 11 个函数全标上, +再加 `-Wformat=2`. -结果:120 个调用点全部过了检查,并额外抓到 doctor 里把 `HTTPS_PROXY` 的值**直接当 -格式串**的写法 —— 代理 URL 里带 `%` 是常事(密码里的 `%40`、路径里的 `%2F`), -那会去读一个不存在的参数。改成 `field("代理", "%s", …)`。 +结果:120 个调用点全部过了检查, 并额外抓到 doctor 里把 `HTTPS_PROXY` 的值**直接当 +格式串**的写法 —— 代理 URL 里带 `%` 是常事 (密码里的 `%40`、路径里的 `%2F`), +那会去读一个不存在的参数. 改成 `field("代理", "%s", …)`. -`-Wformat=2` 附带 `-Wformat-nonliteral`,我们用 `-Wno-format-nonliteral` 关掉了: -包装函数内部转发 `fmt` 的地方(`xvasprintf` → `vsnprintf(fmt, ap)`)天然是非字面量, -而**调用点**那一侧已经由属性查完了,留着只是噪声。 -`-Wformat-security` 保留着 —— 它正是抓到上面那个代理问题的那条。 +`-Wformat=2` 附带 `-Wformat-nonliteral`, 我们用 `-Wno-format-nonliteral` 关掉了: +包装函数内部转发 `fmt` 的地方 (`xvasprintf` → `vsnprintf(fmt, ap)`)天然是非字面量, +而**调用点**那一侧已经由属性查完了, 留着只是噪声. +`-Wformat-security` 保留着 —— 它正是抓到上面那个代理问题的那条. -注意属性写在**函数定义**上时要放在声明符之前(`LIGENT_PRINTF(2, 3) static void field(...)`), -写在原型上则放末尾。gcc 对前者会直接报 -"attributes should be specified before the declarator in a function definition"。 +注意属性写在**函数定义**上时要放在声明符之前 (`LIGENT_PRINTF(2, 3) static void field(...)`), +写在原型上则放末尾. gcc 对前者会直接报 +"attributes should be specified before the declarator in a function definition". ### 静态分析 ```bash -make analyze # gcc -fanalyzer,独立目标,故意不进 make test(它比全套测试还慢) +make analyze # gcc -fanalyzer, 独立目标, 故意不进 make test (它比全套测试还慢) ``` -它抓的是稀疏但致命的那类问题,第一次跑就捞出两个真 bug:`attempt()` 里 -`curl_easy_init` 失败那条提前返回会把未初始化的 `cerr` 当字符串读;POST 分支对 -`body == NULL` 不设防(无条件 `strlen`)。两个都不在任何测试的覆盖里 —— 前者要 -`curl_easy_init` 真的失败,后者要一个当前调用方不会传的参数组合。 +它抓的是稀疏但致命的那类问题, 第一次跑就捞出两个真 bug:`attempt()` 里 +`curl_easy_init` 失败那条提前返回会把未初始化的 `cerr` 当字符串读; POST 分支对 +`body == NULL` 不设防 (无条件 `strlen`). 两个都不在任何测试的覆盖里 —— 前者要 +`curl_easy_init` 真的失败, 后者要一个当前调用方不会传的参数组合. -用 `-O0` 跑是有意的:开了优化之后 gcc 会内联,而 `-fanalyzer` 的跨函数状态遇到 -内联并不可靠(`image.c` 的 `rand_suffix` 在 `-O1/-O2/-Os` 下会报一条并不存在的 -FILE 泄漏,`fclose` 明明就写在 `fread` 下一行;`-O0` 下干净)。分析器自身就是跨 -函数分析的,不依赖 `-O` 级别,所以降级不会少查什么。目标里带 `-Werror`:不然 -它永远打印"通过"—— gcc 的分析器告警不影响退出码,这是我第一版踩的坑。 +用 `-O0` 跑是有意的:开了优化之后 gcc 会内联, 而 `-fanalyzer` 的跨函数状态遇到 +内联并不可靠 (`image.c` 的 `rand_suffix` 在 `-O1/-O2/-Os` 下会报一条并不存在的 +FILE 泄漏, `fclose` 明明就写在 `fread` 下一行; `-O0` 下干净). 分析器自身就是跨 +函数分析的, 不依赖 `-O` 级别, 所以降级不会少查什么. 目标里带 `-Werror`:不然 +它永远打印"通过"—— gcc 的分析器告警不影响退出码, 这是我第一版踩的坑. ### 换个编译器:tcc 也能编 ```bash -make CC=tcc # 全套测试(3692 单元 + 318 e2e)实测通过 +make CC=tcc # 全套测试 (3692 单元 + 318 e2e)实测通过 ``` -tcc 0.9.28 能把 ligent 完整编出来并链接成功,产物 1.0 MB。CPU 密集的基准 -(`fuzz 4000`)上 **218 ms vs gcc 的 171 ms,只慢约 1.3 倍** —— 对 ligent 这种 -瓶颈在网络的程序完全无所谓。 +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。 -- **没有那些严格警告**(`-Wshadow` / `-Wmissing-prototypes` 等)。tcc 的诊断 - 弱得多,所以"零警告"这条纪律只在 gcc/clang 下成立。 -- 没有优化器(上面那 1.3 倍就是它的全部代价)。 +- **没有 `-fsanitize`**:`make SAN=1` 仍然要用 gcc/clang. +- **没有那些严格警告** (`-Wshadow` / `-Wmissing-prototypes` 等). tcc 的诊断 + 弱得多, 所以"零警告"这条纪律只在 gcc/clang 下成立. +- 没有优化器 (上面那 1.3 倍就是它的全部代价). -顺带一提,tcc 一上来就帮我们抓出两个**一直存在的构建脆弱点**:代码调了 `fabs` -却没链 `-lm`(gcc 把它折成内建函数,所以看不出来),以及 6 条测试链接规则漏了 -`$(LDLIBS)`。两个都补上了 —— 这类"某个编译器恰好替你兜住"的洞,平时是看不见的。 +顺带一提, tcc 一上来就帮我们抓出两个**一直存在的构建脆弱点**:代码调了 `fabs` +却没链 `-lm` (gcc 把它折成内建函数, 所以看不出来), 以及 6 条测试链接规则漏了 +`$(LDLIBS)`. 两个都补上了 —— 这类"某个编译器恰好替你兜住"的洞, 平时是看不见的. ### 静态构建:musl libc + 静态 libcurl -想要一个**拷到哪都能跑**的可执行文件(不依赖宿主机的 glibc、libcurl、OpenSSL): +想要一个**拷到哪都能跑**的可执行文件 (不依赖宿主机的 glibc、libcurl、OpenSSL): ```bash -./tools/build-static-deps.sh # 造一份 musl 编的静态 libcurl(要联网、几分钟) +./tools/build-static-deps.sh # 造一份 musl 编的静态 libcurl (要联网、几分钟) make static # 产物:ligent-static file ligent-static # → ELF 64-bit ... statically linked ``` -**为什么不能直接拿发行版的 `libcurl.a` 链接。** 那是针对 glibc 编的,和 musl -混在一起会得到一堆 `undefined reference to __libc_start_main`。必须有一份用 +**为什么不能直接拿发行版的 `libcurl.a` 链接. ** 那是针对 glibc 编的, 和 musl +混在一起会得到一堆 `undefined reference to __libc_start_main`. 必须有一份用 musl 工具链编出来的静态 libcurl —— 这就是 `tools/build-static-deps.sh` 干的事: -用 musl 把 zlib、OpenSSL、curl 依次编到 `build/static-prefix/`,最后**真的链一个 -最小程序**自检(静态链接最常栽在依赖顺序和缺库上,而那只在链接时暴露)。 +用 musl 把 zlib、OpenSSL、curl 依次编到 `build/static-prefix/`, 最后**真的链一个 +最小程序**自检 (静态链接最常栽在依赖顺序和缺库上, 而那只在链接时暴露). -产物是 `ligent-static`,和常规构建**互不干扰**:静态目标用自己的对象目录 -(`build/static/`),因为那边的 `.o` 是 musl 编的,混在一起会互相覆盖。 +产物是 `ligent-static`, 和常规构建**互不干扰**:静态目标用自己的对象目录 + (`build/static/`), 因为那边的 `.o` 是 musl 编的, 混在一起会互相覆盖. | 变量 | 默认 | 说明 | |---|---|---| | `STATIC_CC` | 依次找 `musl-gcc` / `x86_64-linux-musl-gcc` | 没有就报错并告诉你装什么 | | `STATIC_PREFIX` | `build/static-prefix` | 静态依赖所在的前缀 | -| `STATIC_RELEASE` | `1` | 体积优先(`-Os` + strip + `--gc-sections`…)。设 0 回到能调试的版本 | +| `STATIC_RELEASE` | `1` | 体积优先 (`-Os` + strip + `--gc-sections`…). 设 0 回到能调试的版本 | ### 把静态产物压到最小 -静态产物是"拷过去就能跑"的发布件,不是拿来调试的,所以它的取舍和常规构建相反。 -**6.24 MB → 3.29 MB(-47%,几乎腰斩)**,分两半,缺一半效果就大打折扣: +静态产物是"拷过去就能跑"的发布件, 不是拿来调试的, 所以它的取舍和常规构建相反. +**6.24 MB → 3.29 MB (-47%, 几乎腰斩)**, 分两半, 缺一半效果就大打折扣: -**A. 链接我们自己的东西(Makefile,`STATIC_RELEASE=1`)** +**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 掉的调试信息 | +| `-fno-asynchronous-unwind-tables -fno-unwind-tables` | `.eh_frame` 本来 514 KB, C 代码用不上 | +| `-Os` / `-g0` | 优先体积; 不生成反正要 strip 掉的调试信息 | | `-z noseparate-code`、`--build-id=none` | 页对齐空洞和 note 段 | -**B. 链接依赖库(`tools/build-static-deps.sh`)** +**B. 链接依赖库 (`tools/build-static-deps.sh`)** -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** —— -静态归档里本来就有一大堆我们永远走不到的算法和协议实现。 +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 ``` -脚本会把用过的选项记在 `$PREFIX/.build-flags` 里,**选项变了会自动重建**。 -没有这一层的话,下面那些 `[ ! -f libz.a ]` 守卫会看到库已存在就跳过,你会一直用着 -旧选项编出来的依赖,症状是"我明明加了 `-Os`,产物怎么一点没小" —— 和 Makefile 里 -对象目录按配置分标签是同一个坑的两处。 +脚本会把用过的选项记在 `$PREFIX/.build-flags` 里, **选项变了会自动重建**. +没有这一层的话, 下面那些 `[ ! -f libz.a ]` 守卫会看到库已存在就跳过, 你会一直用着 +旧选项编出来的依赖, 症状是"我明明加了 `-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 打静态产物(而不是只看它能不能启动) +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`,从不发请求,验不到这一层)。 +都通了. 这也是静态二进制的真实 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`。 +`/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` - 会报 `relocation R_X86_64_32 ... can not be used when making a PIE object`。 -- **不用 nghttp2**:静态构建只留 ligent 真正要的东西,于是它说 HTTP/1.1 —— - 服务端都支持,我们也不需要多路复用。 -- **CA 路径编进去**(`/etc/ssl/certs`):静态二进制跑到别的机器上,得知道去哪 - 找根证书。 +- **依赖列表交给 `curl-config --static-libs`**, 不手写 `-lssl -lcrypto -lz`. + curl 最清楚自己链了哪些库、什么顺序; 手写的那串会过期, 顺序错了还链不上. +- **`-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 —— + 服务端都支持, 我们也不需要多路复用. +- **CA 路径编进去** (`/etc/ssl/certs`):静态二进制跑到别的机器上, 得知道去哪 + 找根证书. -### 在容器里构建(CI/CD) +### 在容器里构建 (CI/CD) ```bash make docker-build # podman build -t ligent-static . @@ -273,15 +273,15 @@ make docker-extract # 产物拷到 ./ligent-static make docker-test # 拿同一套 318 项 e2e 打容器产物 ``` -`make docker-*` 默认用 **podman**(本项目的开发环境是 rootless),换 docker -加 `CONTAINER=docker` 即可 —— 两者的 `build`/`run`/`cp` 参数在这里是一样的。 +`make docker-*` 默认用 **podman** (本项目的开发环境是 rootless), 换 docker +加 `CONTAINER=docker` 即可 —— 两者的 `build`/`run`/`cp` 参数在这里是一样的. -**为什么用 Alpine 做构建环境。** 静态目标要一份用 musl 编出来的 libcurl, +**为什么用 Alpine 做构建环境. ** 静态目标要一份用 musl 编出来的 libcurl, 而 Alpine 本身就是 musl 发行版:`apk add build-base` 得到的 gcc 直接就是 musl -目标(`--target=x86_64-alpine-linux-musl`),`tools/build-static-deps.sh` 里 -那套"找 musl-gcc、配 spec 文件"的麻烦在容器里根本不存在。 +目标 (`--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 @@ -289,13 +289,13 @@ etc/ssl/certs/ca-certificates.crt ligent ``` -用 `scratch` 做运行阶段 —— 静态链接的产物连 libc 都不需要,所以镜像里没有 -shell、没有包管理器、没有任何可被利用的东西。CA 证书放在 +用 `scratch` 做运行阶段 —— 静态链接的产物连 libc 都不需要, 所以镜像里没有 +shell、没有包管理器、没有任何可被利用的东西. CA 证书放在 `/etc/ssl/certs/ca-certificates.crt` 是**必须**的:libcurl 用的是编进去的这个 -路径(见上一条),放别处它读不到。 +路径 (见上一条), 放别处它读不到. -版本号、分支、哈希都由 build-arg 注入 —— 容器里没有 `.git`(`.dockerignore` -把它排除了),脚本在里面**问不出**这些,只能由外面算好传进去: +版本号、分支、哈希都由 build-arg 注入 —— 容器里没有 `.git` (`.dockerignore` +把它排除了), 脚本在里面**问不出**这些, 只能由外面算好传进去: ```bash podman build \ @@ -305,33 +305,33 @@ podman build \ --build-arg BUILD_EPOCH=$(date -u +%s) . ``` -`make docker-build` 会自动在本机算好传进去,不用手写。 +`make docker-build` 会自动在本机算好传进去, 不用手写. 两个容易踩的点: -- **`BUILD_EPOCH` 必须传完整的秒数,不能只传日期。** 它被当成 - `SOURCE_DATE_EPOCH`,而 `date(1)` 把 `2026-09-22` 解析成那天 **00:00:00 - UTC** —— 于是容器产物的构建时间永远显示午夜。传秒数没有这个歧义,而且仍然 - 可复现(设了 `SOURCE_DATE_EPOCH` 时优先沿用它)。 -- **分支也要传。** 不传的话二进制里会是 `unknown`,而那**不是事实** —— - 源码来自哪个分支是外面知道的,只是容器里看不到而已。 +- **`BUILD_EPOCH` 必须传完整的秒数, 不能只传日期. ** 它被当成 + `SOURCE_DATE_EPOCH`, 而 `date(1)` 把 `2026-09-22` 解析成那天 **00:00:00 + UTC** —— 于是容器产物的构建时间永远显示午夜. 传秒数没有这个歧义, 而且仍然 + 可复现 (设了 `SOURCE_DATE_EPOCH` 时优先沿用它). +- **分支也要传. ** 不传的话二进制里会是 `unknown`, 而那**不是事实** —— + 源码来自哪个分支是外面知道的, 只是容器里看不到而已. Dockerfile 里有自检:产物必须存在且可执行、必须真的是静态的、传了 VERSION 就 -绝不能报回退版本号,否则构建失败。这几条都是为了挡住"链接器没报错 ≠ 产物可用"。 +绝不能报回退版本号, 否则构建失败. 这几条都是为了挡住"链接器没报错 ≠ 产物可用". -**实测**(同一份源码,本机 + 容器): +**实测** (同一份源码, 本机 + 容器): ```console -$ ./ligent --version # 本机,Artix + gcc 16.2.1 +$ ./ligent --version # 本机, Artix + gcc 16.2.1 ligent 2026.09.22-209538e.dirty (master, 2026-09-22 04:39:59) [GCC 16.2.1 20260810] on linux/x86_64 -$ podman run --rm ligent-static --version # 容器,Alpine 3.20 + gcc 13.2.1 +$ 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`)。 +两个产物的行为可能因编译器版本不同而有细微差异, 排查时得知道是哪个编的. +容器产物同样过了**全套 318 项 e2e** (`make docker-test`). ### 版本号:CalVer + git 哈希 @@ -341,14 +341,14 @@ ligent 2026.09.22-209538e.dirty (master, 2026-09-22 04:44:33) [GCC 16.2.1 202608 ``` ``` │ │ │ │ │ │ - │ │ │ └ 构建时间(UTC) └ 编译器 └ 目标平台 - │ │ └ 分支(detached HEAD 时为 unknown) - │ └ git 短哈希,.dirty 表示工作区有未提交改动 + │ │ │ └ 构建时间 (UTC) └ 编译器 └ 目标平台 + │ │ └ 分支 (detached HEAD 时为 unknown) + │ └ git 短哈希, .dirty 表示工作区有未提交改动 └ CalVer:发布日 ``` -前两段(`2026.09.22-209538e.dirty`)是**版本号主体**,也是下面几节引用它时 -用的形式。后面几段是"这份二进制从哪来的",排查时要看的就是它们。 +前两段 (`2026.09.22-209538e.dirty`)是**版本号主体**, 也是下面几节引用它时 +用的形式. 后面几段是"这份二进制从哪来的", 排查时要看的就是它们. | 形态 | 什么时候出现 | |---|---| @@ -356,117 +356,117 @@ 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,不存在"区分是 -哪种 SCM"的需求(那正是 `g` 前缀存在的理由),而少两个符号念起来也顺。 +分隔符用 `-` 而不是 `git describe` 传统的 `+g`:我们只用 git, 不存在"区分是 +哪种 SCM"的需求 (那正是 `g` 前缀存在的理由), 而少两个符号念起来也顺. -**编译器与平台由编译期宏判断,不由构建脚本传。** 这是有意的:那份信息是 -"这份代码被什么编出来、给谁跑的",编译器自己最清楚。构建脚本去猜反而多一个 -出错点,而且**交叉编译时会猜错** —— `uname()` 说的是"现在跑在哪",`__linux__` -说的是"编给谁跑",静态产物会被拷到别的机器上跑,该记的是后者。 +**编译器与平台由编译期宏判断, 不由构建脚本传. ** 这是有意的:那份信息是 +"这份代码被什么编出来、给谁跑的", 编译器自己最清楚. 构建脚本去猜反而多一个 +出错点, 而且**交叉编译时会猜错** —— `uname()` 说的是"现在跑在哪", `__linux__` +说的是"编给谁跑", 静态产物会被拷到别的机器上跑, 该记的是后者. -三个编译器各有一条腿,因为它们的宏并不统一: +三个编译器各有一条腿, 因为它们的宏并不统一: | 编译器 | 取法 | |---|---| | gcc | `__VERSION__` → `16.2.1 20260810` | -| clang | `__clang_version__`,**要掐掉尾随空格**(它自带一个) | -| tcc | 没有 `__VERSION__`,只有整数 `__TINYC__`,按"去掉小数点当十进制读"还原 | +| clang | `__clang_version__`, **要掐掉尾随空格** (它自带一个) | +| tcc | 没有 `__VERSION__`, 只有整数 `__TINYC__`, 按"去掉小数点当十进制读"还原 | -判断顺序也有讲究:**clang 也定义 `__GNUC__`**(它假装自己是 gcc 4.2),所以 -clang 必须排在 gcc 前面,否则会被认成 GCC。 +判断顺序也有讲究:**clang 也定义 `__GNUC__`** (它假装自己是 gcc 4.2), 所以 +clang 必须排在 gcc 前面, 否则会被认成 GCC. -(这里踩过两个坑,都是实测三个编译器才发现的:tcc 的版本号按 `/100` 算会得到 -`tcc 9.2.8`,正确是 `/1000` → `0.9.28`;clang 的版本串带尾随空格,会输出成 -`Clang 22.1.8 ]`。单测 gcc 永远发现不了这两个。) + (这里踩过两个坑, 都是实测三个编译器才发现的:tcc 的版本号按 `/100` 算会得到 +`tcc 9.2.8`, 正确是 `/1000` → `0.9.28`; clang 的版本串带尾随空格, 会输出成 +`Clang 22.1.8 ]`. 单测 gcc 永远发现不了这两个. ) -**为什么 CalVer 而不是 SemVer。** ligent 是个终端工具,不是被别人链接的库 —— -"第几个大版本"这种信息对它没什么用,"这是哪天的东西"才有用。用户看到 -`2026.02.14` 立刻知道新旧,看到 `1.0.0` 还得去查。 +**为什么 CalVer 而不是 SemVer. ** ligent 是个终端工具, 不是被别人链接的库 —— +"第几个大版本"这种信息对它没什么用, "这是哪天的东西"才有用. 用户看到 +`2026.02.14` 立刻知道新旧, 看到 `1.0.0` 还得去查. -**为什么还要带哈希。** CalVer 的粒度是"天",同一天可能发好几次;而且用户手上 -的二进制未必来自 tag(可能自己改了源码)。带上哈希,报 bug 时能精确定位。 -`.dirty` 标记尤其重要:不带它的话,改过源码的构建和干净构建会报**同样的** -版本号,排查时会被完全误导。未跟踪的新文件也算脏 —— 它可能正是被编进去的那个 -源文件。 +**为什么还要带哈希. ** CalVer 的粒度是"天", 同一天可能发好几次; 而且用户手上 +的二进制未必来自 tag (可能自己改了源码). 带上哈希, 报 bug 时能精确定位. +`.dirty` 标记尤其重要:不带它的话, 改过源码的构建和干净构建会报**同样的** +版本号, 排查时会被完全误导. 未跟踪的新文件也算脏 —— 它可能正是被编进去的那个 +源文件. -**版本号不写死在源码里。** 构建期用 `tools/get-version.sh` 算出真值,Makefile -用 `-D` 注入;`src/version.h` 里只留一个回退默认值,给"没有构建系统"的场景 -(比如有人直接拿 gcc 编 `src` 下的所有 `.c`)。这样从 git 编和从 tarball 编 -不会出现版本号不一致,也不必为发版多做一个改源码的提交。这是 dunst / curl -这类项目的通行做法。 +**版本号不写死在源码里. ** 构建期用 `tools/get-version.sh` 算出真值, Makefile +用 `-D` 注入; `src/version.h` 里只留一个回退默认值, 给"没有构建系统"的场景 + (比如有人直接拿 gcc 编 `src` 下的所有 `.c`). 这样从 git 编和从 tarball 编 +不会出现版本号不一致, 也不必为发版多做一个改源码的提交. 这是 dunst / curl +这类项目的通行做法. -取值的优先级(`tools/get-version.sh` 里有完整注释): +取值的优先级 (`tools/get-version.sh` 里有完整注释): | 场景 | 结果 | |---|---| -| 环境变量 `LIGENT_VERSION_OVERRIDE` | 直接用它(CI 用,tag 打错时能救) | +| 环境变量 `LIGENT_VERSION_OVERRIDE` | 直接用它 (CI 用, tag 打错时能救) | | git 里有 `v2026.02.14` 形态的 tag | 取最新的那个 | -| 没有 tag(tarball) | 回退到源码文件的日期 | -| 没有 `sh` 或全失败 | `0.0.0-unknown`,**绝不让构建挂掉** | +| 没有 tag (tarball) | 回退到源码文件的日期 | +| 没有 `sh` 或全失败 | `0.0.0-unknown`, **绝不让构建挂掉** | -`SOURCE_DATE_EPOCH` 也认(可复现构建的标准约定):设了它,同一份源码在不同 -时间编出来的二进制是逐字节相同的,CI 上才能校验产物哈希。 +`SOURCE_DATE_EPOCH` 也认 (可复现构建的标准约定):设了它, 同一份源码在不同 +时间编出来的二进制是逐字节相同的, CI 上才能校验产物哈希. -版本号只在 `--version` 和 `ligent doctor` 里出现,不参与任何协议交互。 -`doctor` 把它放在最上面 —— 报 bug 时第一件要问的就是"你用的是哪个版本"。 +版本号只在 `--version` 和 `ligent doctor` 里出现, 不参与任何协议交互. +`doctor` 把它放在最上面 —— 报 bug 时第一件要问的就是"你用的是哪个版本". -**实测过了**(Artix,musl-gcc / gcc 16.2.1,zlib 1.3.1 + OpenSSL 3.0.15 + -curl 8.11.1): +**实测过了** (Artix, musl-gcc / gcc 16.2.1, zlib 1.3.1 + OpenSSL 3.0.15 + +curl 8.11.1): ``` ligent-static: ELF 64-bit LSB executable, x86-64, statically linked ldd ligent-static → 不是动态可执行文件 -体积 3.29 MB(压缩前 6.24 MB,见上一节);318 项 e2e 全过; -TLS 真握手验过(本地 s_server,见上一节) +体积 3.29 MB (压缩前 6.24 MB, 见上一节); 318 项 e2e 全过; +TLS 真握手验过 (本地 s_server, 见上一节) ``` -一路上真正会绊人的坑(脚本里都处理了,记下来免得重踩): +一路上真正会绊人的坑 (脚本里都处理了, 记下来免得重踩): -1. **`musl-gcc` 不搜 `/usr/include`。** 于是 OpenSSL 挂在内核 UAPI 头上: - `fatal error: linux/mman.h: No such file or directory`。 - 要用 `-idirafter /usr/include` —— musl 自己的头优先、系统头兜底。 - 写成 `-I` 就反了,glibc 的头会盖过 musl 的。 -2. **同一个原因,编译 ligent 自己时要 `-I$(STATIC_PREFIX)/include`。** - 系统那份 `curl/curl.h` 不是给 musl 用的。 -3. **依赖别加 `-fno-pie`。** 这些项目在 `make` 里会顺带链几个测试程序,而链接 - 用的是默认的 PIE,于是 - `relocation R_X86_64_32 against ... can not be used when making a PIE object`; - 更阴的是 zlib 的 configure 探测 `vsnprintf` 也会因此失败,**悄悄退化成不安全 - 的 vsprintf 版本**。PIE 与否由 ligent 最后那一步用 `-no-pie` 决定就够。 -4. **OpenSSL 3 在 x86_64 默认装 `lib64`**,和"守卫查 lib、curl 找 lib"对不上, - 表现为 curl 报 `--with-openssl was given but OpenSSL could not be detected`。 - 统一用 `--libdir=lib`。 -5. **下载地址会过期。** zlib 把旧版本挪到了 `/fossils/`。脚本对每个包试多个 - 地址(官网 / fossils / GitHub release),全失败才退出并告诉你怎么改版本。 -6. **路径里有非 ASCII 字符时,`pkg-config` 会吐出非法 UTF-8。** - 这是最阴的一个:只在"仓库路径带中文"这类情况下才出现,而症状离原因非常远。 +1. **`musl-gcc` 不搜 `/usr/include`. ** 于是 OpenSSL 挂在内核 UAPI 头上: + `fatal error: linux/mman.h: No such file or directory`. + 要用 `-idirafter /usr/include` —— musl 自己的头优先、系统头兜底. + 写成 `-I` 就反了, glibc 的头会盖过 musl 的. +2. **同一个原因, 编译 ligent 自己时要 `-I$(STATIC_PREFIX)/include`. ** + 系统那份 `curl/curl.h` 不是给 musl 用的. +3. **依赖别加 `-fno-pie`. ** 这些项目在 `make` 里会顺带链几个测试程序, 而链接 + 用的是默认的 PIE, 于是 + `relocation R_X86_64_32 against ... can not be used when making a PIE object`; + 更阴的是 zlib 的 configure 探测 `vsnprintf` 也会因此失败, **悄悄退化成不安全 + 的 vsprintf 版本**. PIE 与否由 ligent 最后那一步用 `-no-pie` 决定就够. +4. **OpenSSL 3 在 x86_64 默认装 `lib64`**, 和"守卫查 lib、curl 找 lib"对不上, + 表现为 curl 报 `--with-openssl was given but OpenSSL could not be detected`. + 统一用 `--libdir=lib`. +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 字节前面加一个反斜杠: - `-L/…/开发/…` → `-L/…/\开\发/…`。反斜杠后面跟 `E5` 不是合法续字节, - 所以那串是**非法 UTF-8**; - - curl 把它写进 `curl-config`、`libcurl.pc` 和一堆 Makefile(实测 26 个文件)。 + - 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 字节前面加一个反斜杠: + `-L/…/开发/…` → `-L/…/\开\发/…`. 反斜杠后面跟 `E5` 不是合法续字节, + 所以那串是**非法 UTF-8**; + - curl 把它写进 `curl-config`、`libcurl.pc` 和一堆 Makefile (实测 26 个文件). 后果不只是难看:`make static` 的 recipe 里那句 `echo " 依赖: $LIBS"` 会把 `curl-config --static-libs` 的输出原样打出来 → 进工具输出 → 进对话历史 → - 进请求体 → 服务端以 `invalid unicode code point` **拒掉整个请求**。而且坏 - 数据已经留在历史里,之后每一次请求都失败,只能丢掉整轮对话。 + 进请求体 → 服务端以 `invalid unicode code point` **拒掉整个请求**. 而且坏 + 数据已经留在历史里, 之后每一次请求都失败, 只能丢掉整轮对话. - 修法是**在 configure 期间把 `.pc` 藏起来**(藏不是删,编完放回去):curl - 看不见 `openssl.pc` 就退回"直接用 `-L$prefix/lib`"那条路,路径干干净净。 - 脚本末尾还有一道 `iconv` 往返自检,curl-config 输出不是合法 UTF-8 就直接退出。 + 修法是**在 configure 期间把 `.pc` 藏起来** (藏不是删, 编完放回去):curl + 看不见 `openssl.pc` 就退回"直接用 `-L$prefix/lib`"那条路, 路径干干净净. + 脚本末尾还有一道 `iconv` 往返自检, curl-config 输出不是合法 UTF-8 就直接退出. - (这条不需要你做什么,脚本会处理。但如果你在别处手写 curl/OpenSSL 的构建、 - 又碰巧路径带中文,记住别让它走 pkg-config。) + (这条不需要你做什么, 脚本会处理. 但如果你在别处手写 curl/OpenSSL 的构建、 + 又碰巧路径带中文, 记住别让它走 pkg-config. ) ## 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 @@ -475,11 +475,11 @@ ligent "你好" ### 配置文件:只有一个位置 —— 二进制旁边 -配置**只**从可执行文件所在目录读取,文件名固定 `ligent.json`: +配置**只**从可执行文件所在目录读取, 文件名固定 `ligent.json`: ``` /path/to/ligent ← 可执行文件 -/path/to/ligent.json ← 配置(不存在就用内置默认值) +/path/to/ligent.json ← 配置 (不存在就用内置默认值) ``` ```bash @@ -487,34 +487,34 @@ cp ligent.example.json ./ligent.json # 二进制就在当前目录时 $EDITOR ./ligent.json ``` -**不做用户级查找**(没有 `~/.config`、没有 `$XDG_CONFIG_HOME`、没有 `~/.ligent.json`), -**不做系统级查找**(没有 `/etc`),**也不看当前工作目录**。 +**不做用户级查找** (没有 `~/.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` -调用(`argv[0]` 里没有斜杠的情况)。 +那么配置目录是 `~/bin`, 而不是 `/opt/ligent` —— 你放在哪、就认哪. +四种调用方式结果一致:绝对路径、相对路径、链接链 (取第一层)、以及经 `$PATH` +调用 (`argv[0]` 里没有斜杠的情况). -实现上优先用 `argv[0]`(不解析最后一段软链接),只在拿不到时才退回 -`/proc/self/exe`。目录部分会被规范化(`.` / `..` / 目录自身的软链接都会解析), -但**最后一段不会** —— 那正是"软链接所在目录"的关键。另外会用 inode 比对确认 -找到的确实是 ligent 自己,避免 `$PATH` 里恰好有个同名文件把目录带偏。 +实现上优先用 `argv[0]` (不解析最后一段软链接), 只在拿不到时才退回 +`/proc/self/exe`. 目录部分会被规范化 (`.` / `..` / 目录自身的软链接都会解析), +但**最后一段不会** —— 那正是"软链接所在目录"的关键. 另外会用 inode 比对确认 +找到的确实是 ligent 自己, 避免 `$PATH` 里恰好有个同名文件把目录带偏. ```bash $ ./ligent --print-config | grep config_file -config_file : /home/you/ligent/ligent.json (不存在,使用内置默认值) +config_file : /home/you/ligent/ligent.json (不存在, 使用内置默认值) ``` -`--print-config` 会显示规范位置,所以不用猜它在哪找。 +`--print-config` 会显示规范位置, 所以不用猜它在哪找. -想临时换一份配置时有两个显式入口(都不是"默认查找位置"): +想临时换一份配置时有两个显式入口 (都不是"默认查找位置"): ```bash ligent -c /path/to/other.json "..." # 命令行显式指定 @@ -522,10 +522,10 @@ LIGENT_CONFIG=/path/to/other.json ligent "..." ``` 整体优先级:**内置默认值 → 配置文件 → 环境变量 → 命令行参数** -(`api_key` 按上面的规则细化)。 + (`api_key` 按上面的规则细化). -配置文件里可以用 `${VAR}` 引用环境变量,避免把密钥明文写进磁盘: +配置文件里可以用 `${VAR}` 引用环境变量, 避免把密钥明文写进磁盘: ```json { @@ -535,11 +535,11 @@ LIGENT_CONFIG=/path/to/other.json ligent "..." } ``` -想接别家服务,只改 `base_url` 和 `model` 即可,例如: +想接别家服务, 只改 `base_url` 和 `model` 即可, 例如: | 服务 | base_url | model 示例 | |---|---|---| -| DeepSeek(默认) | `https://api.deepseek.com/v1` | `deepseek-flash` / `deepseek-chat` / `deepseek-reasoner` | +| DeepSeek (默认) | `https://api.deepseek.com/v1` | `deepseek-flash` / `deepseek-chat` / `deepseek-reasoner` | | OpenAI | `https://api.openai.com/v1` | `gpt-4o` | | 本地 Ollama | `http://localhost:11434/v1` | `qwen2.5:14b` | | 本地 vLLM | `http://localhost:8000/v1` | 你部署的模型名 | @@ -547,12 +547,12 @@ LIGENT_CONFIG=/path/to/other.json ligent "..." 其他可选环境变量:`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`。 +`LIGENT_STREAM`、`LIGENT_TOOLS`. ### 思考模式与级别 -推理模型可以先"想"一段再回答。DeepSeek 的思考模式**默认就是开着的,默认级别 `high`**, -所以什么都不配也能用;想控制就加这两项: +推理模型可以先"想"一段再回答. DeepSeek 的思考模式**默认就是开着的, 默认级别 `high`**, +所以什么都不配也能用; 想控制就加这两项: ```json { @@ -561,15 +561,15 @@ LIGENT_CONFIG=/path/to/other.json ligent "..." } ``` -- `thinking`:`"on"` / `"off"` / `"auto"`,也接受 `true` / `false`。 - `auto`(默认)表示**请求里根本不带这个字段**,完全由服务端决定。 - 开启时下发 `{"thinking":{"type":"enabled"}}`,关闭则是 `disabled`。 -- `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"`, 也接受 `true` / `false`. + `auto` (默认)表示**请求里根本不带这个字段**, 完全由服务端决定. + 开启时下发 `{"thinking":{"type":"enabled"}}`, 关闭则是 `disabled`. +- `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`、 -`LIGENT_THINKING`、`LIGENT_REASONING_EFFORT`,以及 REPL 里的 +`LIGENT_THINKING`、`LIGENT_REASONING_EFFORT`, 以及 REPL 里的 ``` /think 查看当前设置 @@ -577,87 +577,87 @@ LIGENT_CONFIG=/path/to/other.json ligent "..." /think high 设置级别 ``` -**思考过程不会显示,也不会进上下文**(只累积、不回调)。但它必须跟着 assistant -消息一起回传:DeepSeek 要求**请求带 `tools` 时把历史里的 `reasoning_content` 全带上**, -漏了下一轮直接回 400。ligent 默认开着工具,所以这一条是硬要求 —— 流式和非流式 -两条解析路径都会保存它。 +**思考过程不会显示, 也不会进上下文** (只累积、不回调). 但它必须跟着 assistant +消息一起回传:DeepSeek 要求**请求带 `tools` 时把历史里的 `reasoning_content` 全带上**, +漏了下一轮直接回 400. ligent 默认开着工具, 所以这一条是硬要求 —— 流式和非流式 +两条解析路径都会保存它. -另外,思考模式下 `temperature` 是**不起作用**的(服务端会忽略),`top_p` 会被抬到 -≥ 0.95。`ligent doctor` 会在你同时设了 `thinking: on` 和 `temperature` 时提醒这一点。 +另外, 思考模式下 `temperature` 是**不起作用**的 (服务端会忽略), `top_p` 会被抬到 +≥ 0.95. `ligent doctor` 会在你同时设了 `thinking: on` 和 `temperature` 时提醒这一点. ### 取密钥的优先级 ``` -LIGENT_API_KEY ← 显式覆盖,任何时候都最高 +LIGENT_API_KEY ← 显式覆盖, 任何时候都最高 ligent.json 的 api_key ← 你写死在配置里的 -DEEPSEEK_API_KEY / OPENAI_API_KEY ← 兜底,且只在端点确实是那一家时才读 +DEEPSEEK_API_KEY / OPENAI_API_KEY ← 兜底, 且只在端点确实是那一家时才读 ``` -最后一条是刻意的:**厂商专属的环境变量只在"确实在用这家"时才生效**。 +最后一条是刻意的:**厂商专属的环境变量只在"确实在用这家"时才生效**. -否则会出这种事:配置文件里写的是 OpenRouter 的 key,而 shell 里残留的 -`DEEPSEEK_API_KEY` 把它悄悄顶掉 —— 结果 A 家的密钥被发给了 B 家, -对方回一个含糊的 401,你明明配对了却怎么都调不通,排查方向还会被带偏。 -(OpenRouter 对格式不对的 key 回的是 `Missing Authentication header`, -听起来像"请求里没带认证头",实际上带了,只是钥匙不是这家的。) +否则会出这种事:配置文件里写的是 OpenRouter 的 key, 而 shell 里残留的 +`DEEPSEEK_API_KEY` 把它悄悄顶掉 —— 结果 A 家的密钥被发给了 B 家, +对方回一个含糊的 401, 你明明配对了却怎么都调不通, 排查方向还会被带偏. + (OpenRouter 对格式不对的 key 回的是 `Missing Authentication header`, +听起来像"请求里没带认证头", 实际上带了, 只是钥匙不是这家的. ) -所以 `base_url` 指向 OpenRouter / 本地 Ollama / 自建网关时, -ligent **不会**去猜 `DEEPSEEK_API_KEY`,而是明确告诉你"未配置 API key"。 +所以 `base_url` 指向 OpenRouter / 本地 Ollama / 自建网关时, +ligent **不会**去猜 `DEEPSEEK_API_KEY`, 而是明确告诉你"未配置 API key". -`OPENAI_BASE_URL` 同理,只在 `base_url` 还是内置默认值时才兜底。 +`OPENAI_BASE_URL` 同理, 只在 `base_url` 还是内置默认值时才兜底. -想临时切换服务商,用 `LIGENT_API_KEY` 或 `-k`。 +想临时切换服务商, 用 `LIGENT_API_KEY` 或 `-k`. ### 界面语言 -界面文案(帮助、错误、诊断、确认提示、等待动画)有中英两份。**发给模型的 -那些不在这里**:系统提示词、工具描述、工具回执只有中文 —— 那是模型读的信息, -跟界面语言是两件事(见 §9)。 +界面文案 (帮助、错误、诊断、确认提示、等待动画)有中英两份. **发给模型的 +那些不在这里**:系统提示词、工具描述、工具回执只有中文 —— 那是模型读的信息, +跟界面语言是两件事 (见 §9). -优先级:`--lang` > `$LIGENT_LANG` > `LC_ALL` > `LC_MESSAGES` > `LANG`。 -都没设、或者认不出来(`fr_FR` 这类),按英文处理 —— `LANG=C` 也是英文, -这跟 POSIX 惯例一致。 +优先级:`--lang` > `$LIGENT_LANG` > `LC_ALL` > `LC_MESSAGES` > `LANG`. +都没设、或者认不出来 (`fr_FR` 这类), 按英文处理 —— `LANG=C` 也是英文, +这跟 POSIX 惯例一致. ```bash ligent --lang zh "……" # 这一次用中文 export LIGENT_LANG=en # 这个 shell 里都用英文 ``` -译文在 `src/i18n.c` 的一张表里,键是点分 ID(`cli.err.unknown_option` 这种), -两种语言各写一份。少一条、或者占位符对不上(`{path}` 写成 `{paths}`), -`tests/test_i18n.c` 都会逐条报出来 —— 漏翻这件事必须由机器发现, -不能靠人眼扫。`--print-config` 会打出当前生效的 `language`。 +译文在 `src/i18n.c` 的一张表里, 键是点分 ID (`cli.err.unknown_option` 这种), +两种语言各写一份. 少一条、或者占位符对不上 (`{path}` 写成 `{paths}`), +`tests/test_i18n.c` 都会逐条报出来 —— 漏翻这件事必须由机器发现, +不能靠人眼扫. `--print-config` 会打出当前生效的 `language`. -哪些**故意不译**(都不在这里,也不是漏翻): +哪些**故意不译** (都不在这里, 也不是漏翻): -- **给模型看的**:系统提示词、工具描述、工具回执,以及插进对话历史的说明 - (`/include` 的文本块头、图片降级说明、`/shell` 的操作记录)。切成英文之后 - 模型读到的仍然是中文 —— 想换就得改提示词本身。 +- **给模型看的**:系统提示词、工具描述、工具回执, 以及插进对话历史的说明 + (`/include` 的文本块头、图片降级说明、`/shell` 的操作记录). 切成英文之后 + 模型读到的仍然是中文 —— 想换就得改提示词本身. - **机器读的格式**:`/log` 导出里 `请求:` / `----- 响应头 -----` 这些标记 - (`tests/replay.py` 按它们切分往返)、`<已隐藏>` 这种脱敏占位符、 - `cap_name()` 这类只给日志和断言用的状态常量。 + (`tests/replay.py` 按它们切分往返)、`<已隐藏>` 这种脱敏占位符、 + `cap_name()` 这类只给日志和断言用的状态常量. -`/tools` 列出来的那张表**不是**这一类:工具名后面那行是纯 UI,文案在表里 -(`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()` —— 报错固定中文。解析**工具参数**用它:那条结论会被原样回填给 - 模型,属于"给模型看的信息",不随界面语言变(`agent.c` 只在这一处用)。 +- `json_parse_ui()` —— 报错跟随界面语言. 配置、存档、服务端响应这些给人看的 + 路径用它, 用户看到的是 `line 1, column 3: expected an object key (a string)`. +- `json_parse()` —— 报错固定中文. 解析**工具参数**用它:那条结论会被原样回填给 + 模型, 属于"给模型看的信息", 不随界面语言变 (`agent.c` 只在这一处用). -两种语气共用一份文案表(`json.err.*`),只是各取一列。这几条是 printf 模板而 -不是 `{name}`,所以 `tests/test_i18n.c` 另有一遍机械比对:两种语言的转换符 -数量与类型必须逐字一致 —— `%zu` 写成 `%d` 会让 `printf` 读错参数。 +两种语气共用一份文案表 (`json.err.*`), 只是各取一列. 这几条是 printf 模板而 +不是 `{name}`, 所以 `tests/test_i18n.c` 另有一遍机械比对:两种语言的转换符 +数量与类型必须逐字一致 —— `%zu` 写成 `%d` 会让 `printf` 读错参数. ## 3. 用法 ```bash -ligent "统计当前目录下 C 代码的总行数,并找出最大的三个文件" +ligent "统计当前目录下 C 代码的总行数, 并找出最大的三个文件" ligent # 进入交互式 REPL echo "解释一下 Makefile" | ligent # 从管道读取任务 -ligent --print-config # 查看最终生效的配置(key 已打码) +ligent --print-config # 查看最终生效的配置 (key 已打码) ``` ### 命令行选项 @@ -667,22 +667,22 @@ ligent --print-config # 查看最终生效的配置(key 已打码) -u, --base-url URL API 基地址 -k, --api-key KEY API key -c, --config PATH 指定配置文件 - --history FILE 指定输入历史文件(默认 <二进制目录>/ligent.history) + --history FILE 指定输入历史文件 (默认 <二进制目录>/ligent.history) --no-history 不读写输入历史 -s, --system TEXT 覆盖 system 提示词 -t, --temperature N 采样温度 --max-tokens N 单次回复最大 token 数 - --thinking MODE 思考模式:on / off / auto(默认 auto = 由服务端决定) + --thinking MODE 思考模式:on / off / auto (默认 auto = 由服务端决定) --effort LEVEL 思考级别:minimal/low/medium/high/xhigh/max/ultra - --max-steps N 单轮任务里模型最多往返几次(默认不限,0 = 不限) + --max-steps N 单轮任务里模型最多往返几次 (默认不限, 0 = 不限) --timeout N 请求超时秒数 --no-stream 关闭流式输出 - --no-tools 禁用工具,纯对话 - --no-probe 首个请求前不探测模型能力(不额外发请求) - --lang LANG 界面语言:auto / en / zh(默认 auto) + --no-tools 禁用工具, 纯对话 + --no-probe 首个请求前不探测模型能力 (不额外发请求) + --lang LANG 界面语言:auto / en / zh (默认 auto) -q, --quiet 不打印工具的输出内容 -v, --verbose 打印 HTTP 细节 - --color/--no-color 强制开关彩色输出(同时控制 Markdown 渲染) + --color/--no-color 强制开关彩色输出 (同时控制 Markdown 渲染) --print-config 打印生效配置后退出 -h, --help / -V, --version ``` @@ -693,44 +693,44 @@ ligent --print-config # 查看最终生效的配置(key 已打码) ligent doctor [--offline] 体检:配置、密钥、端点、网络逐环检查 ``` -`--offline` 跳过联网检查。 +`--offline` 跳过联网检查. ### REPL 命令 ``` -/help 帮助 /model [NAME] 切换模型;不带名字则在线选 +/help 帮助 /model [NAME] 切换模型; 不带名字则在线选 /exit, /quit 退出 /system TEXT 重设提示词并清空历史 -/reset 清空对话历史 /cls 清屏(不动对话历史) +/reset 清空对话历史 /cls 清屏 (不动对话历史) /meta 元编辑模式:让模型帮你改这段历史 -/think [开关|级别] 思考模式与级别 /tools 列出可用工具(已按能力隐藏的不列) -/probe 现在测一次模型能力(图片/工具);无视 --no-probe 与缓存 +/think [开关|级别] 思考模式与级别 /tools 列出可用工具 (已按能力隐藏的不列) +/probe 现在测一次模型能力 (图片/工具); 无视 --no-probe 与缓存 /config 显示当前配置 /cd PATH 切换工作目录 /history 打印完整历史 /usage token 用量 /log [FILE] 导出收发记录 /yolo 临时切换写操作确认 -/export 存档当前对话 /import [名] 载入存档(会问要不要打印历史) +/export 存档当前对话 /import [名] 载入存档 (会问要不要打印历史) /shell [命令] 进 shell / 直接执行命令 -/include [GLOB] 把文件附带给模型;不带参数则交互式挑选,clear 取消 -/back 删除最近一轮(破坏性) /unback 撤销 /back +/include [GLOB] 把文件附带给模型; 不带参数则交互式挑选, clear 取消 +/back 删除最近一轮 (破坏性) /unback 撤销 /back ``` -`/reset`(别名 `/clear`)和 `/cls` 是两回事,别敲混: +`/reset` (别名 `/clear`)和 `/cls` 是两回事, 别敲混: | | `/reset`、`/clear` | `/cls` | |---|---|---| -| 作用 | 清空对话历史,重新开始 | 清屏 | -| 影响模型看到的上下文 | **是**,历史没了 | 否,一字不动 | -| 可恢复 | `/unback` 不行,只能 `/import` 旧存档 | 本来就没删东西 | +| 作用 | 清空对话历史, 重新开始 | 清屏 | +| 影响模型看到的上下文 | **是**, 历史没了 | 否, 一字不动 | +| 可恢复 | `/unback` 不行, 只能 `/import` 旧存档 | 本来就没删东西 | -`/cls` 发的是 `\033[2J\033[3J\033[H`,和 `clear(1)` 一致:可见区和回滚缓冲 -一起清。所以清完之后往上滚也看不到旧内容了 —— 想找回来看 `/import` 那份存档。 +`/cls` 发的是 `\033[2J\033[3J\033[H`, 和 `clear(1)` 一致:可见区和回滚缓冲 +一起清. 所以清完之后往上滚也看不到旧内容了 —— 想找回来看 `/import` 那份存档. ### `/model`:不带参数就在线挑 -`/model NAME` 直接换。**不带参数**时会拉一次在线列表、打出来,然后问你: +`/model NAME` 直接换. **不带参数**时会拉一次在线列表、打出来, 然后问你: ``` ligent> /model -正在获取模型列表(https://api.deepseek.com/v1/models)... +正在获取模型列表 (https://api.deepseek.com/v1/models)... 服务端列出了 3 个模型: @@ -738,36 +738,36 @@ ligent> /model 2) deepseek-chat 3) deepseek-reasoner -选择序号,或直接输入模型名(回车取消)> 2 +选择序号, 或直接输入模型名 (回车取消)> 2 模型已切换为 deepseek-chat ``` -输入行里**既可以填序号,也可以直接写模型名**。后者是特意留的:列表里没有的 -东西恰恰很常见 —— 内部模型、灰度模型、服务端没列出来的别名,都得能填进去。 +输入行里**既可以填序号, 也可以直接写模型名**. 后者是特意留的:列表里没有的 +东西恰恰很常见 —— 内部模型、灰度模型、服务端没列出来的别名, 都得能填进去. -列表本身也是诚实的:顺序保持服务端给的(不去替它排序),重复项和缺 `id` 的 -坏条目会被跳过。序号越界会明确报出来(`序号 9 超出范围(1..3),已取消。`), -`Ctrl-C` 或直接回车取消。 +列表本身也是诚实的:顺序保持服务端给的 (不去替它排序), 重复项和缺 `id` 的 +坏条目会被跳过. 序号越界会明确报出来 (`序号 9 超出范围 (1..3), 已取消. `), +`Ctrl-C` 或直接回车取消. -拉列表走的是标准的 `GET {base_url}/models`。**拿不到就明说是什么情况**, +拉列表走的是标准的 `GET {base_url}/models`. **拿不到就明说是什么情况**, 而不是笼统地报一句失败: ``` -拿不到模型列表:服务端返回了空的模型列表(HTTP 200)—— 这个账号下可能没有可用的模型 -可以直接用 /model <名字> 指定,列表拿不到不影响这条。 +拿不到模型列表:服务端返回了空的模型列表 (HTTP 200)—— 这个账号下可能没有可用的模型 +可以直接用 /model <名字> 指定, 列表拿不到不影响这条. ``` -"响应不是 JSON"和"列表是空的"是分开报的 —— 前者多半是 base_url 指错了地方, -后者是账号/权限问题,下一步动作完全不同。 +"响应不是 JSON"和"列表是空的"是分开报的 —— 前者多半是 base_url 指错了地方, +后者是账号/权限问题, 下一步动作完全不同. -> **为什么不做成方向键菜单。** 试过一版,撤了。那种界面要靠 ANSI 反复重画, -> 会把 stdout 搅乱(重定向、日志、粘贴都跟着遭殃);更要命的是它**只让你在 -> 给定的选项里挑**,恰好堵死了"填一个列表里没有的模型名"这条最需要的路。 -> 所以这里是 `/import` 那一套:列表打在上面,输入行是普通的一行,随便写。 +> **为什么不做成方向键菜单. ** 试过一版, 撤了. 那种界面要靠 ANSI 反复重画, +> 会把 stdout 搅乱 (重定向、日志、粘贴都跟着遭殃); 更要命的是它**只让你在 +> 给定的选项里挑**, 恰好堵死了"填一个列表里没有的模型名"这条最需要的路. +> 所以这里是 `/import` 那一套:列表打在上面, 输入行是普通的一行, 随便写. ### `/include`:把文件附带给模型 -模型看不到你的文件,除非你给它。`/include` 把文件**暂存**起来,跟着下一条消息 +模型看不到你的文件, 除非你给它. `/include` 把文件**暂存**起来, 跟着下一条消息 一起发出去 —— 因为附带文件的意义几乎总是配合一个提问: ``` @@ -776,58 +776,58 @@ ligent> /include src/*.c src/agent.c src/json.c src/util.c -下一条消息会带上它们。用 /include clear 取消。 +下一条消息会带上它们. 用 /include clear 取消. ligent> 这三个文件里有没有重复的逻辑? ``` -不带参数则进**交互式挑选**,可以逐层进目录(这就是"递归"的走法 —— glob 本身 -是 shell 语义,`*` 不会跨目录): +不带参数则进**交互式挑选**, 可以逐层进目录 (这就是"递归"的走法 —— glob 本身 +是 shell 语义, `*` 不会跨目录): ``` ligent> /include -进入交互式选择。序号进目录 / 选中文件,也可以直接敲 glob 或路径,回车结束。 +进入交互式选择. 序号进目录 / 选中文件, 也可以直接敲 glob 或路径, 回车结束. 当前目录:/home/you/proj - 1) src/ (目录) + 1) src/ (目录) 2) README.md 12.3 KB 3) logo.png 4.5 KB 〔图片〕 .) .. 返回上级 -已选 0 个。选择 > 1 +已选 0 个. 选择 > 1 ``` -同样是 `/import` 那种普通输入行,不是方向键菜单:**序号、`..`、glob、路径都能 -敲**,空格分隔不支持的(一次一个模式)。`/include clear` 清空暂存,`/reset` -也会清。 +同样是 `/import` 那种普通输入行, 不是方向键菜单:**序号、`..`、glob、路径都能 +敲**, 空格分隔不支持的 (一次一个模式). `/include clear` 清空暂存, `/reset` +也会清. 几件事是有意为之的: -- **认内容,不认扩展名。** 叫 `.png` 的文本文件当文本带;反过来,内容真是图片 - 的才走图片那条路。 -- **图片不进历史。** 图片存成本机备份,历史里只放一个几十字节的引用,发请求时 - 才展开成 base64 —— 和 `read_image` 完全同一条路。 -- **图片要先确认模型能看图。** 遇到图片时才会去跑能力探测(纯文本的附带不该为 - 了"可能出现的图"先花掉两个探测请求),明确不支持就跳过并说明: +- **认内容, 不认扩展名. ** 叫 `.png` 的文本文件当文本带; 反过来, 内容真是图片 + 的才走图片那条路. +- **图片不进历史. ** 图片存成本机备份, 历史里只放一个几十字节的引用, 发请求时 + 才展开成 base64 —— 和 `read_image` 完全同一条路. +- **图片要先确认模型能看图. ** 遇到图片时才会去跑能力探测 (纯文本的附带不该为 + 了"可能出现的图"先花掉两个探测请求), 明确不支持就跳过并说明: ``` 跳过 src/logo.png:当前模型不支持图片输入 - 没有附带任何文件(1 个被跳过)。 + 没有附带任何文件 (1 个被跳过). ``` - 只有**明确不支持**才拦;探测关了或结论不确定时按支持放行 —— 和别处一个原则。 -- **体积有上限。** 单个文本文件 256 KB、一次累计 1 MB、最多 32 个文件。超了会 - 跳过并说明,让你改用 `read_file` 分段读。二进制文件(含 NUL 字节)直接跳过。 -- 暂存的文件名只用于在终端里给你看,**不会**塞进内容块 —— 那会跟着请求发给 - 服务端,而自造字段可能被严格的服务端判 400。 + 只有**明确不支持**才拦; 探测关了或结论不确定时按支持放行 —— 和别处一个原则. +- **体积有上限. ** 单个文本文件 256 KB、一次累计 1 MB、最多 32 个文件. 超了会 + 跳过并说明, 让你改用 `read_file` 分段读. 二进制文件 (含 NUL 字节)直接跳过. +- 暂存的文件名只用于在终端里给你看, **不会**塞进内容块 —— 那会跟着请求发给 + 服务端, 而自造字段可能被严格的服务端判 400. -输入行尾加 `\` 可以续行写多行任务。`Ctrl-C` 中断当前请求(不会退出程序), -`Ctrl-D` 退出。 +输入行尾加 `\` 可以续行写多行任务. `Ctrl-C` 中断当前请求 (不会退出程序), +`Ctrl-D` 退出. ### 输入历史:像 bash 一样跨进程 -你在 `ligent>` 敲过的东西会**实时**写进一个历史文件,新的 ligent 实例启动时读回来, -上下方向键就能翻,跟 bash 一样: +你在 `ligent>` 敲过的东西会**实时**写进一个历史文件, 新的 ligent 实例启动时读回来, +上下方向键就能翻, 跟 bash 一样: ``` ligent> ↑ → 上一条 @@ -835,87 +835,87 @@ ligent> ↑ ↑ → 再上一条 ligent> ↓ → 回来一条 ``` -- **落盘位置**:`<二进制目录>/ligent.history`(和 `ligent.json`、`ligent.archive/` - 同一个原则:只放二进制旁边)。 -- **实时**:每提交一条就 append 并 flush,所以另一个终端里的 ligent(或者另一个 - 标签页)马上就能翻到,不用等谁退出。 -- **权限 0600**:历史里可能顺手敲过密钥,不给同机其他用户读的机会。 -- **一条输入 = 一条历史**:`\` 续行写出来的多行任务算**一条**,上下键整条召回。 -- **上限 1000 条**:启动时只读最新的 1000 条,文件更长就就地截短,不会无限增长。 -- **连续重复不记**:和 bash 的 `ignoredups` 一个意思。 -- **只记 REPL 里敲的**:单次任务模式(`ligent "任务"`)的参数不算 —— 那不是 - "在提示符前敲的"。`/shell` 里执行的命令也不算,那是另一个 shell 的历史。 +- **落盘位置**:`<二进制目录>/ligent.history` (和 `ligent.json`、`ligent.archive/` + 同一个原则:只放二进制旁边). +- **实时**:每提交一条就 append 并 flush, 所以另一个终端里的 ligent (或者另一个 + 标签页)马上就能翻到, 不用等谁退出. +- **权限 0600**:历史里可能顺手敲过密钥, 不给同机其他用户读的机会. +- **一条输入 = 一条历史**:`\` 续行写出来的多行任务算**一条**, 上下键整条召回. +- **上限 1000 条**:启动时只读最新的 1000 条, 文件更长就就地截短, 不会无限增长. +- **连续重复不记**:和 bash 的 `ignoredups` 一个意思. +- **只记 REPL 里敲的**:单次任务模式 (`ligent "任务"`)的参数不算 —— 那不是 + "在提示符前敲的". `/shell` 里执行的命令也不算, 那是另一个 shell 的历史. 开关和覆盖: ```bash ligent --no-history # 本次不读也不写 ligent --history /tmp/my.history # 换个文件 -export LIGENT_HISTORY=/tmp/my.history # 同上,环境变量 +export LIGENT_HISTORY=/tmp/my.history # 同上, 环境变量 ``` -文件是一条历史一行。条目里的反斜杠和换行会被转义(`\` → `\\`,换行 → `\n`), -保证一条占一行且能原样读回;不含反斜杠的普通命令在文件里与输入完全一致。 +文件是一条历史一行. 条目里的反斜杠和换行会被转义 (`\` → `\\`, 换行 → `\n`), +保证一条占一行且能原样读回; 不含反斜杠的普通命令在文件里与输入完全一致. ### `/shell`:在子进程里干活 ``` -ligent> /shell 进入交互式 shell,退出后整段会话记入上下文 -ligent> /shell git log --oneline -5 带参数则直接执行,不进入 shell +ligent> /shell 进入交互式 shell, 退出后整段会话记入上下文 +ligent> /shell git log --oneline -5 带参数则直接执行, 不进入 shell ``` -交互式那条路用**伪终端**做双向搬运:子进程以为自己接在真终端上,所以行编辑、 -颜色、Tab 补全、Ctrl-C、作业控制都照常工作;我们在外面旁听并把输出记下来。 +交互式那条路用**伪终端**做双向搬运:子进程以为自己接在真终端上, 所以行编辑、 +颜色、Tab 补全、Ctrl-C、作业控制都照常工作; 我们在外面旁听并把输出记下来. ``` ligent> /shell -进入交互式 shell。输入 exit 或按 Ctrl-D 返回;整段会话会在退出后记入上下文。 +进入交互式 shell. 输入 exit 或按 Ctrl-D 返回; 整段会话会在退出后记入上下文. [you@host ligent]$ git status [you@host ligent]$ exit -已把 165 字节会话记录加入上下文(不自动提问)。 +已把 165 字节会话记录加入上下文 (不自动提问). ``` 录制进上下文的内容已经清理过 —— 去掉 ANSI 控制序列、把 CRLF 归一成 LF、 -丢掉退格,否则模型看到的是一堆 `^[[32m`。长度上限 100KB。 +丢掉退格, 否则模型看到的是一堆 `^[[32m`. 长度上限 100KB. -清理是**跨读取块**做的:转义序列被 `read()` 从中间切断也能接上,不会串味。 +清理是**跨读取块**做的:转义序列被 `read()` 从中间切断也能接上, 不会串味. -**全屏程序的内容完全不记进上下文。** `nano`、`vi`、`vim`、`nvim`、`less`、`top` -这类程序会切到终端的"备用屏幕",这段画面照常显示在你眼前,但记录里只剩你敲的 -命令行、下一行提示符,以及退出编辑器之后真实的输出 —— 和人眼在终端里看到的 -一模一样。中间留一对标记: +**全屏程序的内容完全不记进上下文. ** `nano`、`vi`、`vim`、`nvim`、`less`、`top` +这类程序会切到终端的"备用屏幕", 这段画面照常显示在你眼前, 但记录里只剩你敲的 +命令行、下一行提示符, 以及退出编辑器之后真实的输出 —— 和人眼在终端里看到的 +一模一样. 中间留一对标记: ``` -[全屏程序运行中,画面内容已省略] +[全屏程序运行中, 画面内容已省略] [全屏程序结束] ``` -理由是那一屏全是光标定位和重复重绘,记下来纯属噪音;而且 `vim` 打开的整个文件 -都会被灌进上下文。程序被强杀、来不及发退出序列时,收尾会补一个结束标记, -保证首尾配对。 +理由是那一屏全是光标定位和重复重绘, 记下来纯属噪音; 而且 `vim` 打开的整个文件 +都会被灌进上下文. 程序被强杀、来不及发退出序列时, 收尾会补一个结束标记, +保证首尾配对. -全屏程序只在 `TERM` 声称支持时才切备用屏幕,所以 `/shell` 会在 `TERM` 缺失或为 -`dumb` 时给子进程补一个 `xterm-256color`(用户显式设了别的值就原样传下去)。 +全屏程序只在 `TERM` 声称支持时才切备用屏幕, 所以 `/shell` 会在 `TERM` 缺失或为 +`dumb` 时给子进程补一个 `xterm-256color` (用户显式设了别的值就原样传下去). 依据是 ligent 自己的 REPL 就在用颜色、光标定位和括号粘贴 —— 这个终端至少是 -ANSI 兼容的,`TERM=dumb` 属于谎报。补上之后,即使是 `TERM=dumb`,`nano` 也会 -正常进入备用屏幕并被整段忽略。 +ANSI 兼容的, `TERM=dumb` 属于谎报. 补上之后, 即使是 `TERM=dumb`, `nano` 也会 +正常进入备用屏幕并被整段忽略. -对确实不用备用屏幕、靠成片空格清屏重绘的程序,退而求其次把连续空白压掉 -(短于 8 个空格的缩进原样保留)。 +对确实不用备用屏幕、靠成片空格清屏重绘的程序, 退而求其次把连续空白压掉 + (短于 8 个空格的缩进原样保留). -**记入上下文但不会自动提问**,你想问什么再问。这样你在 shell 里翻了一圈之后, -可以直接说"刚才那个报错怎么修"。 +**记入上下文但不会自动提问**, 你想问什么再问. 这样你在 shell 里翻了一圈之后, +可以直接说"刚才那个报错怎么修". -选 shell 的顺序是 `bash → ash → dash → sh`。刻意**不看 `$SHELL`**: -用户环境里的 `$SHELL` 可能是 xonsh 之类交互式外壳,在容器或受限环境里 -经常起不来,反而把 `/shell` 弄坏。 +选 shell 的顺序是 `bash → ash → dash → sh`. 刻意**不看 `$SHELL`**: +用户环境里的 `$SHELL` 可能是 xonsh 之类交互式外壳, 在容器或受限环境里 +经常起不来, 反而把 `/shell` 弄坏. ### `/usage` ``` ligent> /usage -token 用量(本次会话) +token 用量 (本次会话) API 调用 : 3 次 输入 token : 12345 输出 token : 678 @@ -924,31 +924,31 @@ token 用量(本次会话) 历史消息 : 15 条 ``` -服务端在每次响应里回报 `usage`,这里只做累加。有些服务端不返回该字段, -那样会明确说"还没回报过用量"而不是显示一堆 0。 +服务端在每次响应里回报 `usage`, 这里只做累加. 有些服务端不返回该字段, +那样会明确说"还没回报过用量"而不是显示一堆 0. ### `/back` 与 `/unback` ``` -ligent> /back 删除最近一轮(从最后一条 user 消息到末尾,整组删) -ligent> /unback 撤销,把删掉的装回去 +ligent> /back 删除最近一轮 (从最后一条 user 消息到末尾, 整组删) +ligent> /unback 撤销, 把删掉的装回去 ``` -是**按轮**删的:模型调用的工具与结果必须整组一起走,只删中间几条会让服务端 400。 +是**按轮**删的:模型调用的工具与结果必须整组一起走, 只删中间几条会让服务端 400. -`/unback` 只在 `/back` 之后没有新对话时可用 —— 一旦你又问了什么,就没有 -"撤销"这一说了,会明确拒绝而不是悄悄恢复到一半状态。 +`/unback` 只在 `/back` 之后没有新对话时可用 —— 一旦你又问了什么, 就没有 +"撤销"这一说了, 会明确拒绝而不是悄悄恢复到一半状态. ### `/yolo` ``` -ligent> /yolo 临时切换写操作确认(只影响本次会话,不进配置文件) +ligent> /yolo 临时切换写操作确认 (只影响本次会话, 不进配置文件) ``` -### 写操作确认(默认开启) +### 写操作确认 (默认开启) -任何**会改动磁盘或执行命令**的工具(`run_command` / `write_file` / `edit_file`) -在执行前都会先说明要做什么,然后问一句: +任何**会改动磁盘或执行命令**的工具 (`run_command` / `write_file` / `edit_file`) +在执行前都会先说明要做什么, 然后问一句: ``` 即将执行 run_command @@ -960,55 +960,55 @@ ligent> /yolo 临时切换写操作确认(只影响本次会话,不进 | 回答 | 含义 | |---|---| -| **回车** / `Y` | 允许这一次(默认) | +| **回车** / `Y` | 允许这一次 (默认) | | `N` | 拒绝这一次 | -| `A` / `Always` | 本轮剩下的改动**一律放行,不再询问** | +| `A` / `Always` | 本轮剩下的改动**一律放行, 不再询问** | | `V` / `neVer` | 本轮剩下的改动**一律拒绝** | -`Always` / `neVer` 的作用域是**这一轮** —— 一条用户消息引发的那串工具调用。 -下一条消息进来时自动复位,所以它不会像 `/yolo` 那样"一直生效到你关掉", -也不会写进配置。选了哪个都会当场提示你: +`Always` / `neVer` 的作用域是**这一轮** —— 一条用户消息引发的那串工具调用. +下一条消息进来时自动复位, 所以它不会像 `/yolo` 那样"一直生效到你关掉", +也不会写进配置. 选了哪个都会当场提示你: ``` 继续?(Y/N/Always/neVer) A - 本轮后续的改动都不再询问。 + 本轮后续的改动都不再询问. ``` -拒绝不等于失败:上层会把"用户拒绝了"作为工具结果回填给模型,让它换个做法, -而不是让整轮对话就此中断。选了 `neVer` 的话,回填的话会更硬("不要再尝试改动, -直接说明你的结论"),免得模型一个接一个地试。 +拒绝不等于失败:上层会把"用户拒绝了"作为工具结果回填给模型, 让它换个做法, +而不是让整轮对话就此中断. 选了 `neVer` 的话, 回填的话会更硬 ("不要再尝试改动, +直接说明你的结论"), 免得模型一个接一个地试. -只读工具(`read_file` / `list_dir`)不打扰。 +只读工具 (`read_file` / `list_dir`)不打扰. -> **非交互输入时按默认值放行。** stdin 不是终端(管道、重定向)时没有人能回答, -> 此时直接按 Y 处理、不显示提示。所以 `echo 任务 | ligent` 里的确认实际上是 -> 不生效的 —— 那条路径要靠 `--no-confirm` 的对面,也就是别指望它兜底。 +> **非交互输入时按默认值放行. ** stdin 不是终端 (管道、重定向)时没有人能回答, +> 此时直接按 Y 处理、不显示提示. 所以 `echo 任务 | ligent` 里的确认实际上是 +> 不生效的 —— 那条路径要靠 `--no-confirm` 的对面, 也就是别指望它兜底. -关掉:`--no-confirm` / `LIGENT_CONFIRM=0` / 配置项 `confirm: false`。 +关掉:`--no-confirm` / `LIGENT_CONFIRM=0` / 配置项 `confirm: false`. -### 自动存档(默认开启) +### 自动存档 (默认开启) 在三个时间点自动把当前对话存进 `ligent.archive/`: -- **退出**时(`/exit`、`Ctrl-D`、单次任务结束) +- **退出**时 (`/exit`、`Ctrl-D`、单次任务结束) - **`/reset` 前** —— 免得清空历史时把刚才那段丢掉 - **`/import` 前** —— 免得载入别的存档时覆盖掉当前的 -只在"有内容"且"自上次存档后又变了"时才写,所以不会产生一堆重复文件; -只有 system 一条的空对话不会留下存档。关掉用 `--no-auto-save`。 +只在"有内容"且"自上次存档后又变了"时才写, 所以不会产生一堆重复文件; +只有 system 一条的空对话不会留下存档. 关掉用 `--no-auto-save`. ### `/meta`:让模型帮你改对话历史 -`/cls` 清屏、`/reset` 清空、`/back` 退一轮 —— 这些都是粗粒度的。真正难的是 -"把第 3 轮里我那句'用 Python'删掉"、"第 5 轮助手那个结论是错的,改掉"。 +`/cls` 清屏、`/reset` 清空、`/back` 退一轮 —— 这些都是粗粒度的. 真正难的是 +"把第 3 轮里我那句'用 Python'删掉"、"第 5 轮助手那个结论是错的, 改掉". -`/meta` 就是干这个的:进入 `meta>` 子提示符,那边是**另一个 AI**,它的工作 -对象不是继续对话,而是**编辑这段历史**。 +`/meta` 就是干这个的:进入 `meta>` 子提示符, 那边是**另一个 AI**, 它的工作 +对象不是继续对话, 而是**编辑这段历史**. ``` ligent> /meta -已进入 meta 模式 —— 改的是对话历史本身,不是继续对话。 -用自然语言说要改什么;/help 看命令,/exit 返回。 +已进入 meta 模式 —— 改的是对话历史本身, 不是继续对话. +用自然语言说要改什么; /help 看命令, /exit 返回. meta> 第 3 轮里我说的"用 Python"删掉 {/} list_rounds @@ -1017,120 +1017,120 @@ meta> 第 3 轮里我说的"用 Python"删掉 即将执行 replace_message #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 模型的指令。 +meta 里的命令只有四个:`/exit` `/diff` (看当前历史, 只读)`/undo` `/help`. +其余输入都是给 meta 模型的指令. #### 和主模型完全隔离 -meta 会话有自己的 system 提示词、自己的工具表,分发走 `Agent` 上的两个函数 -指针(`exec_tool` / `is_mutating`)。主模型看不到也调不到这些工具,反过来也一样 —— -它就算瞎编一个 `delete_rounds`,也只会拿到"未知工具"。 +meta 会话有自己的 system 提示词、自己的工具表, 分发走 `Agent` 上的两个函数 +指针 (`exec_tool` / `is_mutating`). 主模型看不到也调不到这些工具, 反过来也一样 —— +它就算瞎编一个 `delete_rounds`, 也只会拿到"未知工具". | 工具 | 作用 | 参数里带整段正文 | |---|---|---| | `list_rounds [from,to]` | 轮目录:轮号、消息编号范围、条数、字节数、预览 | 否 | -| `read_messages from,to` | 读指定消息的全文(含工具调用参数) | 否 | +| `read_messages from,to` | 读指定消息的全文 (含工具调用参数) | 否 | | `replace_message index,content` | 改写某条消息的正文 | 是 | -| `delete_messages indices` | 删若干条(只限可安全单删的) | 否 | -| `delete_rounds from,to` | 删连续整轮(含轮内所有工具往返) | 否 | -| `insert_message after,role,content` | 插一条(补一句迟到的约束) | 是 | +| `delete_messages indices` | 删若干条 (只限可安全单删的) | 否 | +| `delete_rounds from,to` | 删连续整轮 (含轮内所有工具往返) | 否 | +| `insert_message after,role,content` | 插一条 (补一句迟到的约束) | 是 | | `undo_last_edit` | 撤销上一次改动 | 否 | #### 结构安全是硬约束 -对话历史不是一袋独立消息:`assistant` 带 `tool_calls` 时,后面必须紧跟 -`tool_call_id` 一一对应的 `tool` 消息,否则下一轮请求直接 400。所以: +对话历史不是一袋独立消息:`assistant` 带 `tool_calls` 时, 后面必须紧跟 +`tool_call_id` 一一对应的 `tool` 消息, 否则下一轮请求直接 400. 所以: -- `delete_messages` 只接受 **user 和不带 tool_calls 的 assistant**;碰结构消息 - 一律拒绝,并让模型改用 `delete_rounds` -- 一批删除**先整体校验再动手** —— 宁可一条不改,也不要改一半 +- `delete_messages` 只接受 **user 和不带 tool_calls 的 assistant**; 碰结构消息 + 一律拒绝, 并让模型改用 `delete_rounds` +- 一批删除**先整体校验再动手** —— 宁可一条不改, 也不要改一半 - 动作粒度以"轮"为单位:user → 中间所有工具往返 → 最终回答 -- 删掉某轮的提问时,工具会提醒"那轮剩下的回答现在没有对应的提问了" +- 删掉某轮的提问时, 工具会提醒"那轮剩下的回答现在没有对应的提问了" #### 四道安全网 -1. 进 `/meta` 前自动存一份 `tmp_` 保底存档(文件名会打印出来) -2. 每个破坏性操作走 `(Y/n)` 确认(`--no-confirm` / `/yolo` 下自动放行) -3. `/undo` 或工具 `undo_last_edit`,最多回退 10 步 -4. 每次改动都打印增减字节,不静默 +1. 进 `/meta` 前自动存一份 `tmp_` 保底存档 (文件名会打印出来) +2. 每个破坏性操作走 `(Y/n)` 确认 (`--no-confirm` / `/yolo` 下自动放行) +3. `/undo` 或工具 `undo_last_edit`, 最多回退 10 步 +4. 每次改动都打印增减字节, 不静默 #### 改过之后另起一根 -只要成功改过,下次 `/export`(或退出时的自动存档)就是**新根**: -名字 `<原名>_m<3位>`,`parent` 记空。 +只要成功改过, 下次 `/export` (或退出时的自动存档)就是**新根**: +名字 `<原名>_m<3位>`, `parent` 记空. ``` a1b2c3d4 原版 -a1b2c3d4_mq3z meta 改过的(新根) +a1b2c3d4_mq3z meta 改过的 (新根) a1b2c3d4_mq3z_p8w 从 meta 版再分支 ``` -`_m` 后面是 3 位随机字符,所以下划线后面一共 **4** 个字符;普通分支固定 3 个。 -按长度就能分开,不会歧义。原版仍在 `tmp_` 存档里,丢不了。 +`_m` 后面是 3 位随机字符, 所以下划线后面一共 **4** 个字符; 普通分支固定 3 个. +按长度就能分开, 不会歧义. 原版仍在 `tmp_` 存档里, 丢不了. -meta 自身的 token 消耗单独汇报,不混进主对话的 `/usage`。 +meta 自身的 token 消耗单独汇报, 不混进主对话的 `/usage`. #### 它不是压缩工具 -meta 模型的提示词里写着:**用户没要求压缩就不要压缩 —— 压缩会丢证据**。 +meta 模型的提示词里写着:**用户没要求压缩就不要压缩 —— 压缩会丢证据**. 它的价值在精修:删掉说错的前提、去掉被推翻的方案、改掉助手那次自信但错误的 -结论,让这段历史不再诱导助手重蹈覆辙。 +结论, 让这段历史不再诱导助手重蹈覆辙. -> ⚠️ 让 AI 改自己的历史,等于允许它改写"发生过什么"。排查问题时如果关键证据 -> 被"压掉"就麻烦了 —— 所以默认不压缩、每步要确认、改动可撤销、原版有存档。 +> ⚠️ 让 AI 改自己的历史, 等于允许它改写"发生过什么". 排查问题时如果关键证据 +> 被"压掉"就麻烦了 —— 所以默认不压缩、每步要确认、改动可撤销、原版有存档. ### 等待时的动画:两段标签 -模型不输出思考过程是有意的,但等待期间屏幕完全静止,和"卡死了"看不出区别。 -所以等待时会在 **stderr** 上转一个灰色的动画,并且**按等待的性质分两段**: +模型不输出思考过程是有意的, 但等待期间屏幕完全静止, 和"卡死了"看不出区别. +所以等待时会在 **stderr** 上转一个灰色的动画, 并且**按等待的性质分两段**: ``` -等待响应.... 请求发出去了,但一个字节都还没回来(网络、排队、服务端没醒) -思考中...... 思维链开始流回来了,模型确实在推理 +等待响应.... 请求发出去了, 但一个字节都还没回来 (网络、排队、服务端没醒) +思考中...... 思维链开始流回来了, 模型确实在推理 ``` -这个区分是有用的:同样是等了 30 秒,"一直卡在 `等待响应`"和"一直在 `思考中`" -是两种完全不同的处境 —— 前者多半该去查网络或端点,后者只是模型想得久。 +这个区分是有用的:同样是等了 30 秒, "一直卡在 `等待响应`"和"一直在 `思考中`" +是两种完全不同的处境 —— 前者多半该去查网络或端点, 后者只是模型想得久. -切换的时机是**收到第一片 `reasoning_content`**,不是在发出请求时猜的。所以: +切换的时机是**收到第一片 `reasoning_content`**, 不是在发出请求时猜的. 所以: -- 关掉思考(`thinking: off`)或者用不支持思考的模型 → 只会看到 `等待响应`。 -- 非流式(`--no-stream`)拿不到任何增量 → 也只会看到 `等待响应`。 -- 一收到**正文**动画立刻停并擦掉 —— 那时已经有东西可看了。 +- 关掉思考 (`thinking: off`)或者用不支持思考的模型 → 只会看到 `等待响应`. +- 非流式 (`--no-stream`)拿不到任何增量 → 也只会看到 `等待响应`. +- 一收到**正文**动画立刻停并擦掉 —— 那时已经有东西可看了. -计时是按阶段算的:标签一变就从 0 重新计。超过 5 秒会把秒数亮出来 -(`等待响应.... (12s)`),进一步和卡顿区分。只在 stderr 是终端时才转, -所以重定向或接管道时不会污染输出。 +计时是按阶段算的:标签一变就从 0 重新计. 超过 5 秒会把秒数亮出来 + (`等待响应.... (12s)`), 进一步和卡顿区分. 只在 stderr 是终端时才转, +所以重定向或接管道时不会污染输出. ### 对话存档:文件名就是谱系 ``` ligent> /export 把当前对话存下来 -ligent> /import 列出全部存档,选一份载入 -ligent> /import a1b2c3d4_a9x 按名字直接载入(支持唯一前缀) +ligent> /import 列出全部存档, 选一份载入 +ligent> /import a1b2c3d4_a9x 按名字直接载入 (支持唯一前缀) ``` 存档放在**二进制所在目录**下的 `ligent.archive/` —— 和配置同一个原则: -整个程序连同它的数据都待在一个目录里,拷走即走。 +整个程序连同它的数据都待在一个目录里, 拷走即走. -**命名不做时间戳,而是把分支关系写进文件名**: +**命名不做时间戳, 而是把分支关系写进文件名**: ``` a1b2c3d4 从零开始的一段对话 a1b2c3d4_a9x 从 a1b2c3d4 分出来的分支 a1b2c3d4_a9x_k2m 再往下分支 -a1b2c3d4_q4k 从 a1b2c3d4 分出的另一支(兄弟) +a1b2c3d4_q4k 从 a1b2c3d4 分出的另一支 (兄弟) ``` -新会话第一次存档时随机取 8 位小写字母数字作为根;之后每存一次, -就在**当前所在的节点**后面接 `_` 加 3 位随机字符。 +新会话第一次存档时随机取 8 位小写字母数字作为根; 之后每存一次, +就在**当前所在的节点**后面接 `_` 加 3 位随机字符. `/import` 之前那次保底存档会统一加上 `tmp_` 前缀: @@ -1140,11 +1140,11 @@ znmjjhzl 你主动存下来的 znmjjhzl_ocq 从 znmjjhzl 分出的分支 ``` -临时存档**不会成为以后分支的祖先**(它只是保底,不属于分支树), -所以上面那支仍然署名为 `znmjjhzl_ocq` 而不是 `tmp_mimgkjex_xxx`。 +临时存档**不会成为以后分支的祖先** (它只是保底, 不属于分支树), +所以上面那支仍然署名为 `znmjjhzl_ocq` 而不是 `tmp_mimgkjex_xxx`. -好处是不需要任何索引文件 —— 光 `ls` 一下就能看出谁从谁分出来, -也不会出现索引与实际文件不同步的问题。 +好处是不需要任何索引文件 —— 光 `ls` 一下就能看出谁从谁分出来, +也不会出现索引与实际文件不同步的问题. ``` ligent.archive/ @@ -1154,12 +1154,12 @@ ligent.archive/ mvd2bl1h_q4k 另一支 ``` -**长度上限**:完整文件名(含 `.json`)不超过 240 字符。接一级需要 4 个字符, -所以大约能到 58 层。再往下接就**丢掉谱系、重新起一个 8 位根** -(名字撑爆文件系统总不是个办法)。 +**长度上限**:完整文件名 (含 `.json`)不超过 240 字符. 接一级需要 4 个字符, +所以大约能到 58 层. 再往下接就**丢掉谱系、重新起一个 8 位根** + (名字撑爆文件系统总不是个办法). -存档内容仍是自描述的 JSON,并把 `name` 与 `parent` 也写进去 —— -这样即使文件被改名、或者谱系因超长而重置,也能从内容里看出它从哪来: +存档内容仍是自描述的 JSON, 并把 `name` 与 `parent` 也写进去 —— +这样即使文件被改名、或者谱系因超长而重置, 也能从内容里看出它从哪来: ```json { @@ -1176,16 +1176,16 @@ ligent.archive/ `/import` 载入后会问一句要不要把这段历史打出来: ``` -已载入 mvd2bl1h_cpc(7 条消息)。之后 /export 会存成 mvd2bl1h_cpc_xxx +已载入 mvd2bl1h_cpc (7 条消息). 之后 /export 会存成 mvd2bl1h_cpc_xxx 是否输出这段历史的内容?(Y/n) ``` -回车(默认 Y)就**完整**逐条打出来,带角色标签和颜色: +回车 (默认 Y)就**完整**逐条打出来, 带角色标签和颜色: ``` -- 1/7 系统 -- 你是用户的 AI 助手 -...(system 提示词全文) +... (system 提示词全文) -- 2/7 你 -- 帮我把 build 目录清一下 @@ -1201,23 +1201,23 @@ ligent.archive/ ``` "完整"是字面意思:**正文不截断、工具参数不省略、总量不封顶、`system` -提示词也全文展开**。都回答了 Y,就该看到全部 —— 截一半反而会让人以为历史里 -本来就这么点东西。`tool_call_id` 是特意标出来的,否则一堆工具输出看不出 -谁在回应谁。 +提示词也全文展开**. 都回答了 Y, 就该看到全部 —— 截一半反而会让人以为历史里 +本来就这么点东西. `tool_call_id` 是特意标出来的, 否则一堆工具输出看不出 +谁在回应谁. -回 `n` 就只装进上下文、屏幕保持干净。之后想随时再看一遍,敲 `/history` —— -它打的就是同一份东西(当前这段对话,一条不落)。 +回 `n` 就只装进上下文、屏幕保持干净. 之后想随时再看一遍, 敲 `/history` —— +它打的就是同一份东西 (当前这段对话, 一条不落). -两点行为约定:存档里的 `system` 消息不会被带进来(否则载入旧存档会把你现在 -的提示词换掉),当前生效的那条保留;载入后对话开始时间重置。空对话不会产生 -存档。`/reset` 会断开谱系,下一次存档另起一个根。 +两点行为约定:存档里的 `system` 消息不会被带进来 (否则载入旧存档会把你现在 +的提示词换掉), 当前生效的那条保留; 载入后对话开始时间重置. 空对话不会产生 +存档. `/reset` 会断开谱系, 下一次存档另起一个根. -(输入历史**不会**跟着清 —— 它是一份持久的、跨会话的用户资源,记的是 -"我在 `ligent>` 敲过什么",和当前装的是哪段对话无关。) + (输入历史**不会**跟着清 —— 它是一份持久的、跨会话的用户资源, 记的是 +"我在 `ligent>` 敲过什么", 和当前装的是哪段对话无关. ) ### 先跑一次体检 -配置调不通时,**先别猜**: +配置调不通时, **先别猜**: ```bash ligent doctor @@ -1225,107 +1225,107 @@ ligent doctor 它会把整条链路逐环打出来 —— 二进制与配置目录怎么定的、配置文件在不在、 密钥从哪来、格式与端点是否匹配、环境变量有没有互相顶掉、参数是否离谱、 -网络通不通、端点有没有跳转。 +网络通不通、端点有没有跳转. ``` 【API key】 来源 配置文件 - 值 sk-or-v1...(73 字符) + 值 sk-or-v1... (73 字符) 格式推断 OpenRouter - 与端点匹配 [v] 端点属于 OpenRouter,密钥格式吻合 + 与端点匹配 [v] 端点属于 OpenRouter, 密钥格式吻合 【网络】 - 连通性 [v] 可连接,TLS 校验通过 + 连通性 [v] 可连接, TLS 校验通过 HTTP 状态 307 重定向 [x] 端点跳转到 https://example.invalid/v1/chat/completions - ligent 不自动跟随重定向:跨源跳转会丢掉 Authorization 头, - 服务端只会回 401 "Missing Authentication header",看起来像是密钥没带, - 实际是 URL 跳了。把 base_url 直接改成上面那个地址即可。 + ligent 不自动跟随重定向:跨源跳转会丢掉 Authorization 头, + 服务端只会回 401 "Missing Authentication header", 看起来像是密钥没带, + 实际是 URL 跳了. 把 base_url 直接改成上面那个地址即可. ``` 它专门盯着几类"报错信息会把人带偏"的问题:配置被残留环境变量顶掉、 -密钥与服务商不匹配、端点重定向导致伪 401、配置文件语法错误。 -退出码:有问题为 1,否则 0。 +密钥与服务商不匹配、端点重定向导致伪 401、配置文件语法错误. +退出码:有问题为 1, 否则 0. -> doctor 在**配置本身加载失败时也能跑** —— 那正是最需要它的时刻。 +> doctor 在**配置本身加载失败时也能跑** —— 那正是最需要它的时刻. ### 行编辑 -REPL 自带行编辑器(`src/lineedit.c`),不依赖 readline,也不用终端的行规程: +REPL 自带行编辑器 (`src/lineedit.c`), 不依赖 readline, 也不用终端的行规程: | 按键 | 作用 | |---|---| -| `←` `→` | 光标左右移动(按**字符**而不是字节,中文不会移半个字) | +| `←` `→` | 光标左右移动 (按**字符**而不是字节, 中文不会移半个字) | | `↑` `↓` | 翻历史记录 | -| `Home` / `End` / `Ctrl-A` / `Ctrl-E` | **当前这一行**的行首 / 行尾(粘进来多行时不会跳到整块两端) | +| `Home` / `End` / `Ctrl-A` / `Ctrl-E` | **当前这一行**的行首 / 行尾 (粘进来多行时不会跳到整块两端) | | `Backspace` / `Delete` | 删除光标前 / 光标处的字符 | | `Ctrl-K` / `Ctrl-U` / `Ctrl-W` | 删到行尾 / 删到行首 / 往回删一个词 | -| `Ctrl-C` | 丢弃当前输入行并开新行(请求执行中则是中断请求,不退出程序);**在写操作确认提示上等于"拒绝"** | -| `Ctrl-D` | 空行时退出;行非空时删一个字符 | -| **粘贴多行** | 整段插入,**不会被逐行执行** | +| `Ctrl-C` | 丢弃当前输入行并开新行 (请求执行中则是中断请求, 不退出程序); **在写操作确认提示上等于"拒绝"** | +| `Ctrl-D` | 空行时退出; 行非空时删一个字符 | +| **粘贴多行** | 整段插入, **不会被逐行执行** | -粘贴用 bracketed paste(`\033[?2004h`)实现:终端会把粘贴内容和真实按键区分开, -收到粘贴块就整段放进缓冲区,里面的换行不再触发提交。所以可以直接把一段 -多行需求、一个代码片段、一段日志粘进来,编辑好再回车,它会作为**一条**消息发出。 -`Ctrl+Shift+V`、中键、菜单里的粘贴走的都是这条路。 +粘贴用 bracketed paste (`\033[?2004h`)实现:终端会把粘贴内容和真实按键区分开, +收到粘贴块就整段放进缓冲区, 里面的换行不再触发提交. 所以可以直接把一段 +多行需求、一个代码片段、一段日志粘进来, 编辑好再回车, 它会作为**一条**消息发出. +`Ctrl+Shift+V`、中键、菜单里的粘贴走的都是这条路. -**粘贴进来的内容会被清洗一遍。** 因为 `read_paste()` 收的是终端给的原始字节、 -不经按键分类,里面夹着控制字符就会让"编辑器的算盘"和"终端实际显示"对不上: +**粘贴进来的内容会被清洗一遍. ** 因为 `read_paste()` 收的是终端给的原始字节、 +不经按键分类, 里面夹着控制字符就会让"编辑器的算盘"和"终端实际显示"对不上: | 字符 | 不清洗会怎样 | 清洗后 | |---|---|---| -| `\t` | 终端按制表位(8 的倍数)展开,编辑器只当 1 列 —— 粘一段带缩进的代码,**光标从此偏掉,之后每敲一个字都在错位置上** | 就地展开成空格,缓冲区里不留制表符 | +| `\t` | 终端按制表位 (8 的倍数)展开, 编辑器只当 1 列 —— 粘一段带缩进的代码, **光标从此偏掉, 之后每敲一个字都在错位置上** | 就地展开成空格, 缓冲区里不留制表符 | | `ESC` | 被终端当成转义序列**真的执行掉**:粘贴内容能改颜色、甚至清屏 | 连同后面整条序列一起丢弃 | -| `\r` | 把终端光标拉回行首,屏幕当场错位 | 丢弃(CRLF 归一成 LF) | +| `\r` | 把终端光标拉回行首, 屏幕当场错位 | 丢弃 (CRLF 归一成 LF) | | `NUL` | 会把 C 字符串截断 | 丢弃 | -制表符展开是按**插入点当时的列号**算的,不是从 0 开始 —— 在行中间粘贴时缩进才对。 +制表符展开是按**插入点当时的列号**算的, 不是从 0 开始 —— 在行中间粘贴时缩进才对. -为此缓冲区支持多行,渲染也相应升级为多行 + 折行感知。 +为此缓冲区支持多行, 渲染也相应升级为多行 + 折行感知. -实现方式是进 raw 模式自己接管按键。最早用 `fgets` 直接读时终端停在规范模式, -方向键发出的转义序列会被当成普通字节读进来、显示成 `^[[D`,所以必须自己处理。 +实现方式是进 raw 模式自己接管按键. 最早用 `fgets` 直接读时终端停在规范模式, +方向键发出的转义序列会被当成普通字节读进来、显示成 `^[[D`, 所以必须自己处理. -超出终端宽度时自动折行,光标位置按行列重算;宽度计算复用与表格渲染同一套 -CJK 规则(中文按 2 列)。行尾 `\` 续行仍然可用,续行的每一段都能独立编辑。 +超出终端宽度时自动折行, 光标位置按行列重算; 宽度计算复用与表格渲染同一套 +CJK 规则 (中文按 2 列). 行尾 `\` 续行仍然可用, 续行的每一段都能独立编辑. -**你敲的那一行整个是绿色的**(提示符 + 输入的文字),翻历史或滚屏时一眼能 +**你敲的那一行整个是绿色的** (提示符 + 输入的文字), 翻历史或滚屏时一眼能 看出哪些是自己的输入、哪些是模型输出: ``` -ligent> 你好 ← 绿色,整行都是输入 -你好!有什么可以帮你的吗? ← 普通色,是模型正文 +ligent> 你好 ← 绿色, 整行都是输入 +你好! 有什么可以帮你的吗? ← 普通色, 是模型正文 ``` -颜色是行编辑器统一加在整行两头的,所以提示符字符串本身必须保持纯文本 —— -在提示符里自带一个 reset,后面的输入就会掉回默认色。 +颜色是行编辑器统一加在整行两头的, 所以提示符字符串本身必须保持纯文本 —— +在提示符里自带一个 reset, 后面的输入就会掉回默认色. -配色跟着正文那个开关走(`--color` / `--no-color` / 非终端自动关闭)。 +配色跟着正文那个开关走 (`--color` / `--no-color` / 非终端自动关闭). 绿色区间那两串转义序列在算光标位置时会被跳过 —— 否则 `\033[32m` 会被当成 -5 个可见字符,光标每次重绘都会偏 5 列。 +5 个可见字符, 光标每次重绘都会偏 5 列. ## 4. 终端渲染 助手回复会做一层**流式 Markdown 渲染**:模型吐出的 `#`、`**粗体**` 等标记 -在终端里直接变成样式,而不是原样显示。工具输出(文件内容、命令结果)**不做** -任何渲染,保持原样,避免破坏代码。 +在终端里直接变成样式, 而不是原样显示. 工具输出 (文件内容、命令结果)**不做** +任何渲染, 保持原样, 避免破坏代码. ### 只用 16 色 -全部配色都落在最窄的 **16 色 ANSI** 里:前景用 30-37,加粗/暗淡/斜体/下划线 -用 SGR 属性 1/2/3/4。不用 90-97 那一段(aixterm 的"亮色"),也不用 256 色或 -真彩。 +全部配色都落在最窄的 **16 色 ANSI** 里:前景用 30-37, 加粗/暗淡/斜体/下划线 +用 SGR 属性 1/2/3/4. 不用 90-97 那一段 (aixterm 的"亮色"), 也不用 256 色或 +真彩. -理由是这个项目**硬编码 SGR、不查 terminfo**(也不查 `COLORTERM`)—— 既然不去 -探测终端能力,就该待在**所有终端都认**的那一档里。90-97 虽然到处都是,但严格 -讲不属于 16 色,在精简终端、把 `TERM` 设成不认识的值、或者老式串口终端上, -它可能被当成两个参数解析或者直接忽略。 +理由是这个项目**硬编码 SGR、不查 terminfo** (也不查 `COLORTERM`)—— 既然不去 +探测终端能力, 就该待在**所有终端都认**的那一档里. 90-97 虽然到处都是, 但严格 +讲不属于 16 色, 在精简终端、把 `TERM` 设成不认识的值、或者老式串口终端上, +它可能被当成两个参数解析或者直接忽略. -这条约束由 `tools/check-colors.sh` 守着,`make test-unit` 里会跑。**它值得单独 -一个脚本,是因为颜色越界看代码看不出来** —— 一个越界的码在人眼里也只是"一个 -蓝色",只有拿尺子量才知道它在哪一段。(这个检查抓到过一处:链接原本用的 -`\033[94m` 就是越界的亮蓝。) +这条约束由 `tools/check-colors.sh` 守着, `make test-unit` 里会跑. **它值得单独 +一个脚本, 是因为颜色越界看代码看不出来** —— 一个越界的码在人眼里也只是"一个 +蓝色", 只有拿尺子量才知道它在哪一段. (这个检查抓到过一处:链接原本用的 +`\033[94m` 就是越界的亮蓝. ) 配色语义在各模块之间是一致的: @@ -1334,35 +1334,35 @@ ligent> 你好 ← 绿色,整行都是输入 | 成功 / 工具执行成功 / 输入行 | 32 绿 | agent、doctor、main、lineedit | | 失败 / 错误 | 31 红 | agent、doctor | | 警告 / 需要注意 | 33 黄 | doctor、confirm、main | -| 次要信息(提示、路径、引用) | 2 暗淡 | 各模块 | -| 强调(工具名、标题) | 1 粗体 | 各模块 | +| 次要信息 (提示、路径、引用) | 2 暗淡 | 各模块 | +| 强调 (工具名、标题) | 1 粗体 | 各模块 | | 链接 | 4;36 下划线+青 | markdown | -链接用**下划线**而不是只靠颜色:下划线是 SGR 属性,在单色终端、色盲用户、 -以及把颜色重映射过的主题里都还在 —— 颜色会丢,属性不会。 +链接用**下划线**而不是只靠颜色:下划线是 SGR 属性, 在单色终端、色盲用户、 +以及把颜色重映射过的主题里都还在 —— 颜色会丢, 属性不会. | 语法 | 效果 | |---|---| -| `#` ~ `######` | 标题,按层级着色(H1 粗体青 / H2 粗体品红 / H3 粗体蓝 / H4+ 粗体) | -| `**粗体**` | 粗体,标记被吃掉 | +| `#` ~ `######` | 标题, 按层级着色 (H1 粗体青 / H2 粗体品红 / H3 粗体蓝 / H4+ 粗体) | +| `**粗体**` | 粗体, 标记被吃掉 | | `*斜体*` / `_斜体_` | 斜体 | -| `` `代码` `` | 青色的行内代码,**反引号保留**(方便复制到 shell),内容不做任何解析 | -| `-` `*` `+` 列表 | **保留原始标记字符**并着青色(不替换成 `•`) | +| `` `代码` `` | 青色的行内代码, **反引号保留** (方便复制到 shell), 内容不做任何解析 | +| `-` `*` `+` 列表 | **保留原始标记字符**并着青色 (不替换成 `•`) | | `> 引用` | 暗色 `│ ` 前缀 | | `---` | 暗色分隔线 | -| `\| a \| b \|` 表格 | 带边框对齐的表格,表头加粗,支持 `:---` / `:---:` / `---:` 对齐 | -| `[链接文字](url)` | `[链接文字]` 加下划线并着青色,`(url)` 原样保留 | +| `\| a \| b \|` 表格 | 带边框对齐的表格, 表头加粗, 支持 `:---` / `:---:` / `---:` 对齐 | +| `[链接文字](url)` | `[链接文字]` 加下划线并着青色, `(url)` 原样保留 | **完全不做渲染、逐字节原样保留**的四种情况: | 语法 | 处理 | |---|---| -| ` ``` ` 围栏代码块 | 整块(含 ` ``` ` 标记本身)原样输出,零 ANSI 转义 | +| ` ``` ` 围栏代码块 | 整块 (含 ` ``` ` 标记本身)原样输出, 零 ANSI 转义 | | `![图片](src)` | 保留原文 | -| 链接的 `(url)` 部分 | 保留原文(不隐藏、不做超链接跳转) | +| 链接的 `(url)` 部分 | 保留原文 (不隐藏、不做超链接跳转) | | 行内代码的内容 | 不做任何解析 | -表格渲染示例(列宽按**显示宽度**计算,中文按 2 列,所以边框能对齐): +表格渲染示例 (列宽按**显示宽度**计算, 中文按 2 列, 所以边框能对齐): ``` ┌──────┬──────┬──────┐ @@ -1375,46 +1375,46 @@ ligent> 你好 ← 绿色,整行都是输入 ### 两个刻意的取舍 -**`_斜体_` 会渲染,但下划线标识符不会被误伤。** 按 CommonMark 规则禁用"词内强调": -`foo_bar_baz`、`a_b_c` 原样保留;与另一个下划线相邻时也一律当字面量, -所以 Python 的 `__init__`、`__main__` 不会被拆成斜体。中文不算词字符, -因此 `中文_斜体_` 能正常生效。 +**`_斜体_` 会渲染, 但下划线标识符不会被误伤. ** 按 CommonMark 规则禁用"词内强调": +`foo_bar_baz`、`a_b_c` 原样保留; 与另一个下划线相邻时也一律当字面量, +所以 Python 的 `__init__`、`__main__` 不会被拆成斜体. 中文不算词字符, +因此 `中文_斜体_` 能正常生效. -**表格必须等整块收完才能渲染。** 列宽取决于所有行,所以表格在流式过程中会先缓存在 -缓冲区,遇到非表格行(或消息结束)才一次性输出。行数超过 200 行会先渲染已有部分, -避免超大表格一直不显示。 +**表格必须等整块收完才能渲染. ** 列宽取决于所有行, 所以表格在流式过程中会先缓存在 +缓冲区, 遇到非表格行 (或消息结束)才一次性输出. 行数超过 200 行会先渲染已有部分, +避免超大表格一直不显示. -**表格必须顶格写 `|`。** 不带首竖线的写法(`a | b` / `--- | ---`)按普通文本处理。 +**表格必须顶格写 `|`. ** 不带首竖线的写法 (`a | b` / `--- | ---`)按普通文本处理. -这条限制不是偷懒,而是为了保证**输出与分片方式无关**:判断"这是不是表格"所需的信息 -必须在行的第 0 个字节就能拿到。如果允许"整行任意位置含竖线",那么当 SSE 的分片边界 -恰好落在竖线之前时,行的前半段已经被当成普通文本输出、无法收回;而同一行若整块到达 -则会被识别为表格——**同样的内容,渲染结果取决于网络怎么切的包**。这个问题是模糊测试 -找出来的(最小复现只有 4 字节:`s|\n-`)。 +这条限制不是偷懒, 而是为了保证**输出与分片方式无关**:判断"这是不是表格"所需的信息 +必须在行的第 0 个字节就能拿到. 如果允许"整行任意位置含竖线", 那么当 SSE 的分片边界 +恰好落在竖线之前时, 行的前半段已经被当成普通文本输出、无法收回; 而同一行若整块到达 +则会被识别为表格——**同样的内容, 渲染结果取决于网络怎么切的包**. 这个问题是模糊测试 +找出来的 (最小复现只有 4 字节:`s|\n-`). ### 流式是这里的难点 -SSE 的分片位置是随机的,`**bold**` 完全可能被切成 `**bo` / `ld**` 两片, -甚至两个 `*` 分别落在两片里。所以渲染器是一个**带状态、可挂起**的状态机 -(`src/markdown.c`): +SSE 的分片位置是随机的, `**bold**` 完全可能被切成 `**bo` / `ld**` 两片, +甚至两个 `*` 分别落在两片里. 所以渲染器是一个**带状态、可挂起**的状态机 + (`src/markdown.c`): -- 遇到可能是标记前缀的尾部(结尾的 `*`、行首的 `#`/`>`/`-`、`` ` ``、`[`), - 它**不立即输出**,而是把这段字节留在缓冲区里等下一个分片,凑齐后再判定。 -- 绝大多数情况下挂起只有几个字节,延迟不可感知;唯一的例外是**表格** - (列宽依赖所有行,必须整块收完才能渲染)。 -- 缓冲区有 64KB 硬上限兜底,表格另有 200 行 / 单行 4KB 的上限, - 防止异常输入把内存吃满或让输出长时间卡住。 -- 流式与非流式走同一个渲染器,因此两种模式的输出**逐字节一致**。 +- 遇到可能是标记前缀的尾部 (结尾的 `*`、行首的 `#`/`>`/`-`、`` ` ``、`[`), + 它**不立即输出**, 而是把这段字节留在缓冲区里等下一个分片, 凑齐后再判定. +- 绝大多数情况下挂起只有几个字节, 延迟不可感知; 唯一的例外是**表格** + (列宽依赖所有行, 必须整块收完才能渲染). +- 缓冲区有 64KB 硬上限兜底, 表格另有 200 行 / 单行 4KB 的上限, + 防止异常输入把内存吃满或让输出长时间卡住. +- 流式与非流式走同一个渲染器, 因此两种模式的输出**逐字节一致**. `tests/test_markdown.c` 用一条不变量来锁住这个行为:把同一段文本分别按 -**一次性 / 3 字节 / 逐字节** 三种方式喂进去,输出必须完全相同。 +**一次性 / 3 字节 / 逐字节** 三种方式喂进去, 输出必须完全相同. ### 想看效果? -仓库里带了一个独立的小演示程序,不需要 API key: +仓库里带了一个独立的小演示程序, 不需要 API key: ```bash -make demo # 编译并运行,用内置示例展示所有语法 +make demo # 编译并运行, 用内置示例展示所有语法 ./build/md_demo --plain # 对照:输出原始 Markdown ./build/md_demo -s # 流式模式:每 5 字节一片、间隔 12ms ./build/md_demo --compare # 自检:不同分片方式输出是否一致 @@ -1422,176 +1422,176 @@ make demo # 编译并运行,用内置示例展示所有语法 echo '# hi **there**' | ./build/md_demo ``` -`-s` 看起来和普通输出几乎一样,这正是重点:分片边界落在 `**`、行首 `#`、 -表格中间都察觉不到——因为状态机会把可能被切断的标记暂存到下一片再判定。 -`--compare` 会把同一段文本按 1/2/3/5/7/13/64 字节以及**随机**分片各渲染一遍, -逐字节比对结果,这也是 `make test` 会跑的一项。 +`-s` 看起来和普通输出几乎一样, 这正是重点:分片边界落在 `**`、行首 `#`、 +表格中间都察觉不到——因为状态机会把可能被切断的标记暂存到下一片再判定. +`--compare` 会把同一段文本按 1/2/3/5/7/13/64 字节以及**随机**分片各渲染一遍, +逐字节比对结果, 这也是 `make test` 会跑的一项. ### 什么时候不渲染 -只有输出到终端时才渲染。以下情况会退化为**逐字节原样输出**: +只有输出到终端时才渲染. 以下情况会退化为**逐字节原样输出**: -- 输出被重定向或接管道(`ligent "..." > out.md`) +- 输出被重定向或接管道 (`ligent "..." > out.md`) - 显式 `--no-color` -这样 `ligent "生成一份 README" > README.md` 拿到的仍然是干净的 Markdown。 -判断依据是 `isatty(stdout)`,可用 `--color` / `--no-color` 强制覆盖。 +这样 `ligent "生成一份 README" > README.md` 拿到的仍然是干净的 Markdown. +判断依据是 `isatty(stdout)`, 可用 `--color` / `--no-color` 强制覆盖. ## 5. 内置工具 | 工具 | 作用 | 关键参数 | |---|---|---| -| `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` | -| `list_dir` | 列出目录,标注类型与大小 | `path` | -| `read_image` | 读入本地图片交给模型看:本机留备份,请求时以 base64 内联发出 | `path` | +| `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` | +| `list_dir` | 列出目录, 标注类型与大小 | `path` | +| `read_image` | 读入本地图片交给模型看:本机留备份, 请求时以 base64 内联发出 | `path` | -单次工具输出上限 100 KB,超出会截断并提示,避免一次 `cat` 把上下文撑爆。 +单次工具输出上限 100 KB, 超出会截断并提示, 避免一次 `cat` 把上下文撑爆. ### `edit_file`:行号集合钉住的替换 -`line` 是**集合型**参数,而且**可以整个省略**:省略 = 不设行范围 = **全文件替换 -所有匹配**(就是编辑器里那个"全局替换")。给了它就是集合型 —— 单个整数 -(`"line": 41`)或整数数组(`"line": [41, 42]`)都收,行号就是 `read_file` 打出来 -的那些(从 1 开始)。给了行号之后,替换只发生在这些行里,`old_string` 也只需 -**在这些行里**唯一。 +`line` 是**集合型**参数, 而且**可以整个省略**:省略 = 不设行范围 = **全文件替换 +所有匹配** (就是编辑器里那个"全局替换"). 给了它就是集合型 —— 单个整数 + (`"line": 41`)或整数数组 (`"line": [41, 42]`)都收, 行号就是 `read_file` 打出来 +的那些 (从 1 开始). 给了行号之后, 替换只发生在这些行里, `old_string` 也只需 +**在这些行里**唯一. -判定命中看的是 `old_string` 的**起点**:起点落在集合里的任意一行上就算数。 -所以 `old_string` 跨好几行时,照 `read_file` 的显示把覆盖到的那几行都填进数组就行, -不必先赌起点到底是第 41 行还是第 42 行。集合是**搜索范围**,不是"每行都改一遍": -真改动的仍然只有唯一那一处(`replace_all=true` 或省略 `line` 时才动全文件)。 +判定命中看的是 `old_string` 的**起点**:起点落在集合里的任意一行上就算数. +所以 `old_string` 跨好几行时, 照 `read_file` 的显示把覆盖到的那几行都填进数组就行, +不必先赌起点到底是第 41 行还是第 42 行. 集合是**搜索范围**, 不是"每行都改一遍": +真改动的仍然只有唯一那一处 (`replace_all=true` 或省略 `line` 时才动全文件). -**省略 `line`** 时没有范围约束:整个文件都是搜索范围,而且**命中几处就换几处** -(不再要求唯一),回执里会写明"全文件替换 N 处"。这是故意留着的逃生门 —— 有些 -改动本来就该全文件生效(比如统一改个名字);但它不是默认路径:不给行号就没法 -声称"我知道改的是哪一处",要改一处仍得给行号。 +**省略 `line`** 时没有范围约束:整个文件都是搜索范围, 而且**命中几处就换几处** + (不再要求唯一), 回执里会写明"全文件替换 N 处". 这是故意留着的逃生门 —— 有些 +改动本来就该全文件生效 (比如统一改个名字); 但它不是默认路径:不给行号就没法 +声称"我知道改的是哪一处", 要改一处仍得给行号. -为什么不再只靠"全文唯一":行号是模型唯一的空间坐标,而"全文唯一"要求整个文件里 -恰好只有一处 —— 文件一长这个条件经常不成立(同一个 `return 0;` 到处都是),模型 -只能不断加长 `old_string` 去凑唯一性;凑不成要么放弃,要么赌一把改错地方。把行号 -钉住之后,"改哪一处"由坐标决定,不再是个概率问题,`old_string` 也不必为了唯一而写长。 +为什么不再只靠"全文唯一":行号是模型唯一的空间坐标, 而"全文唯一"要求整个文件里 +恰好只有一处 —— 文件一长这个条件经常不成立 (同一个 `return 0;` 到处都是), 模型 +只能不断加长 `old_string` 去凑唯一性; 凑不成要么放弃, 要么赌一把改错地方. 把行号 +钉住之后, "改哪一处"由坐标决定, 不再是个概率问题, `old_string` 也不必为了唯一而写长. -负面路径都给出**可照做的下一步**,而不是干说一句"不行": +负面路径都给出**可照做的下一步**, 而不是干说一句"不行": | 情况 | 反馈 | |---|---| -| `line` 写成了字符串之类 | 报错说明它要的是整数或整数数组,并提示"要全文件替换就整个省略" | -| 给了空数组 | 报错说明至少要给一个行号,或整个省略 `line` | -| 集合里有越界行号 | 报出文件总行数("只有 3 行,line 里的 99 越界") | -| 集合里的元素不是 >=1 的整数 | 报错并指出是第几个元素(空数组、非整数都在这里拦掉) | -| 集合里的行号太多了(>64) | 报错说明上限,并提示先 `read_file` 看清再分几次改 | -| 集合圈住的行里没有 `old_string` | **报出它真正在哪几行**("第 3 行没有,它在第 7 行" / "在第 2、9、14 行等共 6 处"),照着改 `line` 即可 | -| 集合圈住的行里有多处 | 要求写长 `old_string`,或设 `replace_all=true` | -| 省略了 `line`,而文件里没有 `old_string` | 报"整个文件里没有",并提示要给行范围就填 `line` | -| 文件是二进制 | 拒绝并说明理由(见下节) | +| `line` 写成了字符串之类 | 报错说明它要的是整数或整数数组, 并提示"要全文件替换就整个省略" | +| 给了空数组 | 报错说明至少要给一个行号, 或整个省略 `line` | +| 集合里有越界行号 | 报出文件总行数 ("只有 3 行, line 里的 99 越界") | +| 集合里的元素不是 >=1 的整数 | 报错并指出是第几个元素 (空数组、非整数都在这里拦掉) | +| 集合里的行号太多了 (>64) | 报错说明上限, 并提示先 `read_file` 看清再分几次改 | +| 集合圈住的行里没有 `old_string` | **报出它真正在哪几行** ("第 3 行没有, 它在第 7 行" / "在第 2、9、14 行等共 6 处"), 照着改 `line` 即可 | +| 集合圈住的行里有多处 | 要求写长 `old_string`, 或设 `replace_all=true` | +| 省略了 `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` 的判据一致 —— 两处必须一样, -否则会出现"同一个文件 `/include` 说能带、`read_file` 说不能读"这种自相矛盾)。 +`read_file` / `write_file` / `edit_file` 遇到二进制文件一律**拒绝**, 而不是硬读或 +硬写. 判据只有一条:**出现 NUL 字节** (和 `/include` 的判据一致 —— 两处必须一样, +否则会出现"同一个文件 `/include` 说能带、`read_file` 说不能读"这种自相矛盾). -拒绝的理由是实的,不是洁癖: +拒绝的理由是实的, 不是洁癖: -- `read_file` 会把二进制读成乱码,还会在第一个 NUL 处被截断 —— 模型看到的是**残缺** - 的内容,却没有任何提示说它是残缺的; -- `edit_file` 更糟:它在内存里按 C 字符串处理(`strstr`/`strlen`),文件里任何一个 - NUL 都会让后面的内容被当成结束,**改下去就是把原文件毁掉**。所以它探的是全文件, - 不像读类工具只看前 8 KB; -- `write_file` 只写文本,用文本整体覆盖一个二进制文件等于把原内容毁掉。 +- `read_file` 会把二进制读成乱码, 还会在第一个 NUL 处被截断 —— 模型看到的是**残缺** + 的内容, 却没有任何提示说它是残缺的; +- `edit_file` 更糟:它在内存里按 C 字符串处理 (`strstr`/`strlen`), 文件里任何一个 + NUL 都会让后面的内容被当成结束, **改下去就是把原文件毁掉**. 所以它探的是全文件, + 不像读类工具只看前 8 KB; +- `write_file` 只写文本, 用文本整体覆盖一个二进制文件等于把原内容毁掉. -拒绝时会说清"第几字节有 NUL"以及**该改用什么**:看图片用 `read_image`,看其它 -二进制用 `run_command`(`file` / `xxd` / `strings` / `od`),要改二进制用 -`printf` / `dd` / `perl -pi` 这类。 +拒绝时会说清"第几字节有 NUL"以及**该改用什么**:看图片用 `read_image`, 看其它 +二进制用 `run_command` (`file` / `xxd` / `strings` / `od`), 要改二进制用 +`printf` / `dd` / `perl -pi` 这类. **搜索内容不在这套工具里**:模型要搜就用 `run_command` 里的真 `grep` -(`grep -rn 模式 目录`)。以前有个 `search_files`(后来改名 `grep`)的包装, -删掉的理由见下节。 + (`grep -rn 模式 目录`). 以前有个 `search_files` (后来改名 `grep`)的包装, +删掉的理由见下节. ### 工具命名约定 -工具名是协议的一部分:它每个请求都要重发,还要被人、被模型、被代码里好几处引用。 -所以它有一套明写的规矩(`src/tools.c` 顶部有同一份,两处必须同步): +工具名是协议的一部分:它每个请求都要重发, 还要被人、被模型、被代码里好几处引用. +所以它有一套明写的规矩 (`src/tools.c` 顶部有同一份, 两处必须同步): -1. **形式**:全小写 snake_case,动宾结构「动词_对象」。动词必须来自白名单, - 而白名单**按域划分** —— 要用新动词,先把它加进对应域: +1. **形式**:全小写 snake_case, 动宾结构「动词_对象」. 动词必须来自白名单, + 而白名单**按域划分** —— 要用新动词, 先把它加进对应域: - 文件与执行域:`read` / `write` / `edit` / `list` / `run` / `view` - - 历史编辑域(`/meta`):`list` / `read` / `replace` / `delete` / `insert` / `undo` + - 历史编辑域 (`/meta`):`list` / `read` / `replace` / `delete` / `insert` / `undo` - 两域动词不混用。禁用 `do` / `handle` / `process` / `get` / `exec` 这类不说明对象的空动词。 -2. **名字要能推出副作用**:会改盘的名字里不会出现 `read` / `list`。 - 模型靠名字判断该不该先征求同意 —— 含糊的名字等于让它猜。 -3. 不带 `ligent` / `ai` / `tool` 这类冗余词:工具表本身就在工具命名空间里。 -4. **不与 REPL 命令同名**(`/shell`、`/cd`、`/model`、`/log`… 已被占用), - 否则人和模型两边会串。这也是执行命令的工具叫 `run_command` 而不叫 `shell` 的原因之一。 -5. 不与别的工具只在词序、单复数或大小写上不同(`read_file` / `file_read` 这种)。 -6. **长度 ≤ 24 字符**:工具表每个请求都要重发,名字长度是重复成本。 -7. **参数名与工具名同源**:路径一律 `path`,内容 `content`。同一个概念在两个工具里 - 必须同名。 -8. **能不包装就不包装**(这条是人定的,不是名字的规矩):模型手上有一个能跑任意 - 命令的 `run_command`,凡是"一条标准命令就能干、而且干得更好"的事,就别再做一个 + 两域动词不混用. 禁用 `do` / `handle` / `process` / `get` / `exec` 这类不说明对象的空动词. +2. **名字要能推出副作用**:会改盘的名字里不会出现 `read` / `list`. + 模型靠名字判断该不该先征求同意 —— 含糊的名字等于让它猜. +3. 不带 `ligent` / `ai` / `tool` 这类冗余词:工具表本身就在工具命名空间里. +4. **不与 REPL 命令同名** (`/shell`、`/cd`、`/model`、`/log`… 已被占用), + 否则人和模型两边会串. 这也是执行命令的工具叫 `run_command` 而不叫 `shell` 的原因之一. +5. 不与别的工具只在词序、单复数或大小写上不同 (`read_file` / `file_read` 这种). +6. **长度 ≤ 24 字符**:工具表每个请求都要重发, 名字长度是重复成本. +7. **参数名与工具名同源**:路径一律 `path`, 内容 `content`. 同一个概念在两个工具里 + 必须同名. +8. **能不包装就不包装** (这条是人定的, 不是名字的规矩):模型手上有一个能跑任意 + 命令的 `run_command`, 凡是"一条标准命令就能干、而且干得更好"的事, 就别再做一个 自己的版本 —— 多一个工具 = 多一份 schema、多一个要维护的边界、多一次被误用的 - 机会,换来的只是省一次 `run_command`。 + 机会, 换来的只是省一次 `run_command`. ### 为什么没有搜索工具 -曾经有 `search_files`(后来按规矩改名 `grep`),现在删了 —— 它是第 8 条规矩的 -反例:真 `grep` 支持正则、文件名过滤、上下文行(`-A/-B/-C`)、大小写、多模式、 -二进制提示,我们那版每一样都要自己写一遍,还得跟着需求慢慢长(第一版就叫错了 -名字:明明是搜内容,却叫 `search_files`,模型自然会去传 `pattern="*.c"`)。 -删掉之后工具表从 8 个变成 7 个。 +曾经有 `search_files` (后来按规矩改名 `grep`), 现在删了 —— 它是第 8 条规矩的 +反例:真 `grep` 支持正则、文件名过滤、上下文行 (`-A/-B/-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` 的输出 -全从那张表生成。所以**改名只需改 `.name` 一列** —— 这三样以前各有一份 `strcmp` -链,改名必定漏一处,而且漏了不报错(只是静默地少认一个工具)。 +名字只定义一次 (`src/tools.h` 的 `TOOL_*` 宏), 工具表也只有一处 + (`src/tools.c` 的 `TOOLS[]`):schema、执行分发、要不要走确认、`/tools` 的输出 +全从那张表生成. 所以**改名只需改 `.name` 一列** —— 这三样以前各有一份 `strcmp` +链, 改名必定漏一处, 而且漏了不报错 (只是静默地少认一个工具). -**不保留旧名别名**,这是刻意的:schema 里只发新名,dispatch 再认旧名只会掩盖 -"调用方没跟着改"(旧存档、用户自写的提示词、测试夹具都一样)。认不出来就明确报 -`未知工具 "xxx"`,比静默兼容更容易发现漏改。 +**不保留旧名别名**, 这是刻意的:schema 里只发新名, dispatch 再认旧名只会掩盖 +"调用方没跟着改" (旧存档、用户自写的提示词、测试夹具都一样). 认不出来就明确报 +`未知工具 "xxx"`, 比静默兼容更容易发现漏改. ### `read_image`:图片怎么进对话 -模型自己看不到磁盘。给它一个路径,它得先把图**读进来**——这一步就是 +模型自己看不到磁盘. 给它一个路径, 它得先把图**读进来**——这一步就是 `read_image`: ```bash ligent "看看 build/screenshot.png 里的报错是什么" ``` -内部做了四件事,每件都有它的理由: +内部做了四件事, 每件都有它的理由: -**1. 按内容认格式,不认扩展名。** 读文件头判断 PNG / JPEG / GIF / WebP。 -一个叫 `.png` 的文本文件会被明确拒绝,而不是发出去等一个莫名其妙的 400。 +**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)。**这是通用形状**: -DeepSeek 和 MiMo 都收,ligent 不去网上抓图,所以只产出 base64 这一种。 +这是协议里图片仅有的两种走法之一 (另一种是外部 URL). **这是通用形状**: +DeepSeek 和 MiMo 都收, ligent 不去网上抓图, 所以只产出 base64 这一种. -图片只能出现在 `user` 消息里,而工具结果只能是字符串。所以工具跑完之后, -ligent 会**补一条合成的 `user` 消息**承载图片。它算附件、不算提问——`/back` -按轮删除、上下文回收时都按这个规则处理,不会把它当成新的一轮。 +图片只能出现在 `user` 消息里, 而工具结果只能是字符串. 所以工具跑完之后, +ligent 会**补一条合成的 `user` 消息**承载图片. 它算附件、不算提问——`/back` +按轮删除、上下文回收时都按这个规则处理, 不会把它当成新的一轮. -**3. 但图片字节不进历史。** base64 内联有个直接后果:字节会躺在会话里, -每轮请求都重发。归档、`/history`、`/log` 全都跟着膨胀。所以**存储形态和发送 +**3. 但图片字节不进历史. ** base64 内联有个直接后果:字节会躺在会话里, +每轮请求都重发. 归档、`/history`、`/log` 全都跟着膨胀. 所以**存储形态和发送 形态是分开的**: ``` @@ -1600,223 +1600,223 @@ ligent 会**补一条合成的 `user` 消息**承载图片。它算附件、不 ``` `read_image` 先把图存到 -`<二进制目录>/ligent.archive/images/<时间戳>_<4位随机>.`,历史里只挂一个 -对备份的引用;**备份文件才是字节的真身**,真正构造请求时才读出来编码。 -`/export` 出来的存档因此还是几十 KB,而不是几十 MB。 +`<二进制目录>/ligent.archive/images/<时间戳>_<4位随机>.`, 历史里只挂一个 +对备份的引用; **备份文件才是字节的真身**, 真正构造请求时才读出来编码. +`/export` 出来的存档因此还是几十 KB, 而不是几十 MB. -两个内部块类型都带 `ligent_` 前缀,就是为了万一哪天漏了展开、直接发出去, +两个内部块类型都带 `ligent_` 前缀, 就是为了万一哪天漏了展开、直接发出去, 服务端报的错能一眼看出是我们的问题: | 内部块 | 含义 | |---|---| | `ligent_image` | 引用一张本地备份 | -| `ligent_note` | 展开失败后的文字说明(备份读不到了、体积超了) | +| `ligent_note` | 展开失败后的文字说明 (备份读不到了、体积超了) | -**4. 体积在本地就判掉。** 图片会留在历史里,所以一次超限不是一次失败, -而是从此**每一轮**都超限。与其发出去等一个 400,不如当场告诉模型"太大了"—— -而且**不备份**:一张永远发不出去的图,留着备份没有意义。 +**4. 体积在本地就判掉. ** 图片会留在历史里, 所以一次超限不是一次失败, +而是从此**每一轮**都超限. 与其发出去等一个 400, 不如当场告诉模型"太大了"—— +而且**不备份**:一张永远发不出去的图, 留着备份没有意义. -上限取各家文档里最严的一档(DeepSeek 单图 32 MiB、请求体 48 MiB;MiMo -base64 字符串 50 MB): +上限取各家文档里最严的一档 (DeepSeek 单图 32 MiB、请求体 48 MiB; MiMo +base64 字符串 50 MB): | 限制 | 值 | |---|---| -| 单张图(base64 之后的长度) | 32 MiB | -| 一次请求里图片的 base64 总量 | 44 MiB(给提示词/历史/工具描述留 4 MiB) | +| 单张图 (base64 之后的长度) | 32 MiB | +| 一次请求里图片的 base64 总量 | 44 MiB (给提示词/历史/工具描述留 4 MiB) | -注意判的是**编码后**的长度:base64 会膨胀 4/3,所以一张 25 MiB 的原图就已经 -超了。被拒时工具直接把话带给模型,让它请用户先缩小或压缩。 +注意判的是**编码后**的长度:base64 会膨胀 4/3, 所以一张 25 MiB 的原图就已经 +超了. 被拒时工具直接把话带给模型, 让它请用户先缩小或压缩. -**5. 服务端要是还是拒了呢?** 本地拦的是格式和体积,但服务端还可能因为别的 -原因不收(图片损坏、这个变体不支持……)。那条引用躺在历史里,不修的话之后 -每一轮都会以同样的 4xx 失败、历史却原样保留——用户只剩 `/reset` 一条路。 -所以 ligent 撞到 4xx 且请求里有图片时,会把图片**从历史里就地摘掉**、换成 -一句说明再重试一次。 +**5. 服务端要是还是拒了呢?** 本地拦的是格式和体积, 但服务端还可能因为别的 +原因不收 (图片损坏、这个变体不支持……). 那条引用躺在历史里, 不修的话之后 +每一轮都会以同样的 4xx 失败、历史却原样保留——用户只剩 `/reset` 一条路. +所以 ligent 撞到 4xx 且请求里有图片时, 会把图片**从历史里就地摘掉**、换成 +一句说明再重试一次. 给**模型**看的是能直接照做的一句话: ``` -[图片 1700000000_a1b2.png 不符合 API 所需的格式标准,无法上传,请规范化图片后重试] +[图片 1700000000_a1b2.png 不符合 API 所需的格式标准, 无法上传, 请规范化图片后重试] ``` -给**你**看的(stderr)是移除处数和服务端的原话 —— 原始报错通常是给人看的英文 -错误码,塞进上下文只会让模型分心,但完全不显示又会让你没法排查: +给**你**看的 (stderr)是移除处数和服务端的原话 —— 原始报错通常是给人看的英文 +错误码, 塞进上下文只会让模型分心, 但完全不显示又会让你没法排查: ``` -服务端拒绝了请求里的图片,已把 1 处图片从对话里移除后重试... +服务端拒绝了请求里的图片, 已把 1 处图片从对话里移除后重试... 服务端原话:HTTP 400 —— image rejected: unsupported image variant ``` -"就地"是要紧的:光在请求副本里摘掉,历史里那条坏引用下一轮还会再犯。 +"就地"是要紧的:光在请求副本里摘掉, 历史里那条坏引用下一轮还会再犯. -`read_image` 不写文件、不执行命令,所以**不触发写操作确认**。备份是副作用, -不是"改动你的东西"。 +`read_image` 不写文件、不执行命令, 所以**不触发写操作确认**. 备份是副作用, +不是"改动你的东西". -它会不会出现在工具表里,取决于模型收不收图片 —— 见下一节。 +它会不会出现在工具表里, 取决于模型收不收图片 —— 见下一节. ### 模型能力探测:怎么知道它收不收图片 -`read_image` 只对**能看图的模型**有意义。可是**没有任何接口能问出来** —— -DeepSeek 和 MiMo 的 `GET /v1/models` 都只返回 `id` / `object` / `owned_by`, -没有 capability 或 modality 字段(OpenAI 自己的也是如此)。这是 OpenAI 兼容层 -的通病。 +`read_image` 只对**能看图的模型**有意义. 可是**没有任何接口能问出来** —— +DeepSeek 和 MiMo 的 `GET /v1/models` 都只返回 `id` / `object` / `owned_by`, +没有 capability 或 modality 字段 (OpenAI 自己的也是如此). 这是 OpenAI 兼容层 +的通病. -所以只能**发试探请求**问。ligent 在**首次请求之前**跑一个独立的"能力测试序列", -它不碰对话历史,也不进历史: +所以只能**发试探请求**问. ligent 在**首次请求之前**跑一个独立的"能力测试序列", +它不碰对话历史, 也不进历史: | 步 | 发什么 | 结论 | |---|---|---| -| 1 | 一句纯文本 + `max_tokens: 1` | 通了才说明模型是活的,后面的否定结论才作数 | -| 2 | 同一句 + 一张 1×1 的图 + `tools` | 200 → 两样都支持,到此为止 | -| 3 | 拆开单独发(图 / tools) | 第 2 步被拒时才知道是哪一个不行 | +| 1 | 一句纯文本 + `max_tokens: 1` | 通了才说明模型是活的, 后面的否定结论才作数 | +| 2 | 同一句 + 一张 1×1 的图 + `tools` | 200 → 两样都支持, 到此为止 | +| 3 | 拆开单独发 (图 / tools) | 第 2 步被拒时才知道是哪一个不行 | -关键在于**否定结论只在基线通过时才敢下**:单个试探 400 而基线 200,差别就在 -那一样东西上。这样不需要猜服务端的错误文案("不支持图片"、"invalid content"、 -"multimodal not supported"……千家千样),也就不会因为文案变了而误判。 +关键在于**否定结论只在基线通过时才敢下**:单个试探 400 而基线 200, 差别就在 +那一样东西上. 这样不需要猜服务端的错误文案 ("不支持图片"、"invalid content"、 +"multimodal not supported"……千家千样), 也就不会因为文案变了而误判. -**猜不准时一律按"支持"处理**:多暴露一个工具,代价是模型可能白调一次(还有 -4xx 降级兜底);而错误地藏掉工具,你会以为模型看不见图。 +**猜不准时一律按"支持"处理**:多暴露一个工具, 代价是模型可能白调一次 (还有 +4xx 降级兜底); 而错误地藏掉工具, 你会以为模型看不见图. -结果按 `base_url + model` 缓存,所以**换模型会自动重测**,切回来命中缓存。 -探测完总会打一条灰字结论 —— 支持什么、不支持什么、以及我们因此**做了什么**, -都在同一行里,不用去猜: +结果按 `base_url + model` 缓存, 所以**换模型会自动重测**, 切回来命中缓存. +探测完总会打一条灰字结论 —— 支持什么、不支持什么、以及我们因此**做了什么**, +都在同一行里, 不用去猜: ``` -模型能力:多模态(图片输入)支持 * 工具调用 支持 -模型能力:多模态(图片输入)不支持(已隐藏 read_image) * 工具调用 支持 -模型能力:多模态(图片输入)未测 * 工具调用 未测 —— 没测出来的一律按「支持」处理(HTTP 401) +模型能力:多模态 (图片输入)支持 * 工具调用 支持 +模型能力:多模态 (图片输入)不支持 (已隐藏 read_image) * 工具调用 支持 +模型能力:多模态 (图片输入)未测 * 工具调用 未测 —— 没测出来的一律按「支持」处理 (HTTP 401) ``` -探测出"不支持"时把动作也写在同行,是因为只报"不支持"而不说"我已经把工具藏了", -用户会以为工具还在。 +探测出"不支持"时把动作也写在同行, 是因为只报"不支持"而不说"我已经把工具藏了", +用户会以为工具还在. -代价要说清楚:**开启探测时,每个进程的第一次请求之前会多出 2~4 个小请求** -(常见情况是 2 个,模型受限时才拆开)。不想要这个开销就用 `--no-probe` -(或配置项 `"probe": false`)—— 那时不探测、什么结论都不下、 -`read_image` 照常暴露,也就是探测功能出现之前的行为。 +代价要说清楚:**开启探测时, 每个进程的第一次请求之前会多出 2~4 个小请求** + (常见情况是 2 个, 模型受限时才拆开). 不想要这个开销就用 `--no-probe` + (或配置项 `"probe": false`)—— 那时不探测、什么结论都不下、 +`read_image` 照常暴露, 也就是探测功能出现之前的行为. -想临时测一次(比如用了 `--no-probe` 又想确认模型收不收图片),在 REPL 里敲 -**`/probe`**:它绕开 `--no-probe` 与缓存,重新跑一遍上面的序列并打出结论。 +想临时测一次 (比如用了 `--no-probe` 又想确认模型收不收图片), 在 REPL 里敲 +**`/probe`**:它绕开 `--no-probe` 与缓存, 重新跑一遍上面的序列并打出结论. #### 为什么最后没走 Files API -早先这一路走的是 Files API:上传换一个 `file_id`,请求里只带引用。它确实能 -让历史更小,但**它不是一个标准**。只有最外面那层(`POST /v1/files` + -multipart + `purpose`)因为抄 OpenAI 而看着像标准;真正要紧的"在对话请求里 -怎么引用"各家完全不同——OpenAI 只在 Responses API 里收 `input_file`, -**Chat Completions 根本不收 `file_id`**;Google 用 `file_data.file_uri`; -Anthropic 的图像走 `source`;MiMo 干脆明确写了不支持上传本地文件。 +早先这一路走的是 Files API:上传换一个 `file_id`, 请求里只带引用. 它确实能 +让历史更小, 但**它不是一个标准**. 只有最外面那层 (`POST /v1/files` + +multipart + `purpose`)因为抄 OpenAI 而看着像标准; 真正要紧的"在对话请求里 +怎么引用"各家完全不同——OpenAI 只在 Responses API 里收 `input_file`, +**Chat Completions 根本不收 `file_id`**; Google 用 `file_data.file_uri`; +Anthropic 的图像走 `source`; MiMo 干脆明确写了不支持上传本地文件. -也就是说 `{"type": "file", "file_id": …}` 这条路**只有 DeepSeek 一家认**。 -而 `image_url` + base64 内联是 DeepSeek 和 MiMo 都认的通用形状(OpenAI 兼容层 -普遍也认)。既然"图片不进历史"这件事可以靠**本机备份 + 请求前展开**自己做到, -就没有理由再为它绑定一个厂商私有的引用协议。 +也就是说 `{"type": "file", "file_id": …}` 这条路**只有 DeepSeek 一家认**. +而 `image_url` + base64 内联是 DeepSeek 和 MiMo 都认的通用形状 (OpenAI 兼容层 +普遍也认). 既然"图片不进历史"这件事可以靠**本机备份 + 请求前展开**自己做到, +就没有理由再为它绑定一个厂商私有的引用协议. -代价写在 §9 里,不藏着。思考模式则仍然是方言(见 §9),那一条没有通用替代。 +代价写在 §9 里, 不藏着. 思考模式则仍然是方言 (见 §9), 那一条没有通用替代. > ⚠️ **安全提醒**:按需求本工具**不做沙箱限制**——模型可以执行任意 shell 命令、 -> 读写任意路径。建议在容器、虚拟机或独立用户下运行,不要在存有重要数据的 -> 生产环境里直接把 `--no-tools` 之外的限制交给模型自觉。 +> 读写任意路径. 建议在容器、虚拟机或独立用户下运行, 不要在存有重要数据的 +> 生产环境里直接把 `--no-tools` 之外的限制交给模型自觉. ## 6. 源码结构 ``` src/ util.c/h xmalloc、动态字符串缓冲、文件读取、mkdir -p、${VAR} 展开 - json.c/h 递归下降 JSON 解析器;UTF-8/代理对解码;构造器;紧凑/美化序列化 - http.c/h libcurl 薄封装:同步 POST、流式 POST(header 回调取状态码、 - 写回调分流错误体)、Ctrl-C 中断 - llm.c/h OpenAI 兼容客户端:请求体构造(含 thinking / reasoning_effort)、 + json.c/h 递归下降 JSON 解析器; UTF-8/代理对解码; 构造器; 紧凑/美化序列化 + http.c/h libcurl 薄封装:同步 POST、流式 POST (header 回调取状态码、 + 写回调分流错误体)、Ctrl-C 中断 + llm.c/h OpenAI 兼容客户端:请求体构造 (含 thinking / reasoning_effort)、 SSE 逐行解析、tool_calls 按 index 增量拼接、 reasoning_content 累积与回传、非流式回退、错误体可读化 - 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 感知, - 以及输入历史的持久化(实时追加 + 启动加载 + 上限截短) + 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 感知, + 以及输入历史的持久化 (实时追加 + 启动加载 + 上限截短) doctor.c/h 环境体检:逐环检查并给出可操作的结论 - archive.c/h 对话存档:导出/列举/载入(文件名即谱系) + archive.c/h 对话存档:导出/列举/载入 (文件名即谱系) image.c/h 图片:按文件头认格式、本机备份、请求前把备份展开成 base64 内联 shell.c/h /shell:伪终端双向搬运 + 录制 - meta.c/h /meta:元编辑对话历史(独立工具集 + 快照栈 + 结构安全校验) - spinner.c/h 等待动画:两段标签(等待响应 → 思考中),后台线程, + meta.c/h /meta:元编辑对话历史 (独立工具集 + 快照栈 + 结构安全校验) + spinner.c/h 等待动画:两段标签 (等待响应 → 思考中), 后台线程, 只在 stderr 是终端时启用 - i18n.c/h 界面文案表(中英各一份)、语言探测、{name} 占位符替换 - markdown.c/h 流式 Markdown 渲染状态机(标记跨分片挂起、表格列宽对齐、 - 近似 wcwidth 的 CJK 宽度计算) + i18n.c/h 界面文案表 (中英各一份)、语言探测、{name} 占位符替换 + markdown.c/h 流式 Markdown 渲染状态机 (标记跨分片挂起、表格列宽对齐、 + 近似 wcwidth 的 CJK 宽度计算) agent.c/h 主循环:消息历史管理、多步工具调用、流式打印 config.c/h 默认值 → 配置文件 → 环境变量 main.c 参数解析、单次任务模式、REPL tools/ - md_demo.c 渲染效果演示程序(独立可执行,不依赖网络) + md_demo.c 渲染效果演示程序 (独立可执行, 不依赖网络) tests/ test_json.c JSON 解析/序列化/转义/非法输入/嵌套深度 - test_tools.c 工具的端到端行为(真实执行命令、真实读写文件) - test_agent.c 上下文不变量(system 不重复、reset 语义) + test_tools.c 工具的端到端行为 (真实执行命令、真实读写文件) + test_agent.c 上下文不变量 (system 不重复、reset 语义) test_config.c 默认值、环境变量优先级、配置文件覆盖与 ${VAR} 展开 - test_markdown.c Markdown 渲染 + 分片不变性(一次性/3字节/逐字节 输出一致) + test_markdown.c Markdown 渲染 + 分片不变性 (一次性/3字节/逐字节 输出一致) fuzz.c 对抗性模糊测试:随机/变异输入砸 JSON 与 Markdown 解析器 reduce.c delta-debugging 最小化器:把失败输入缩到最短复现 - test_http.c HTTP 层纯逻辑(重试判定、上限取值) - test_lineedit.c 行编辑器缓冲区与历史(含 Home/End 在逻辑行内的语义、 - 历史文件转义往返 / 上限截短 / 文件不存在) - test_spinner.c 等待动画的两段标签与幂等性(把 stderr 接到临时文件后读回断言) + test_http.c HTTP 层纯逻辑 (重试判定、上限取值) + test_lineedit.c 行编辑器缓冲区与历史 (含 Home/End 在逻辑行内的语义、 + 历史文件转义往返 / 上限截短 / 文件不存在) + test_spinner.c 等待动画的两段标签与幂等性 (把 stderr 接到临时文件后读回断言) test_meta.c 元编辑:轮次切分、结构安全、七个工具、撤销、重新存根标记 test_image.c 图片:格式嗅探、备份与索引、附件队列、损坏索引降级 - test_capability.c 能力探测的纯逻辑(名字映射、关掉探测时的语义、缓存查询) - test_models.c /models 响应体解析(标准形状、裸数组、去重、坏条目、空列表) + test_capability.c 能力探测的纯逻辑 (名字映射、关掉探测时的语义、缓存查询) + test_models.c /models 响应体解析 (标准形状、裸数组、去重、坏条目、空列表) test_attach.c /include:glob 展开、二进制/超限跳过、图片引用、回调只在遇图时问 - repl_edit_check.py 带极简终端模拟器,验证方向键/Home/End/退格/历史/多行粘贴 - 真的生效(只对比字节流证明不了光标移动) - repl_drive.py 用 pty 驱动 REPL 跑多轮对话(管道模式无法测多轮行为) - mock_server.py 最小 OpenAI 兼容 mock,含 SSE 与分片 tool_calls + repl_edit_check.py 带极简终端模拟器, 验证方向键/Home/End/退格/历史/多行粘贴 + 真的生效 (只对比字节流证明不了光标移动) + repl_drive.py 用 pty 驱动 REPL 跑多轮对话 (管道模式无法测多轮行为) + mock_server.py 最小 OpenAI 兼容 mock, 含 SSE 与分片 tool_calls run_e2e.sh 端到端:流式、非流式、多步工具、错误处理、缺 key、管道、渲染 ``` ### 几个实现细节 -- **tool_calls 增量拼接**:流式响应里 `function.arguments` 会被服务端切成任意碎片, - 客户端按 `index` 累积成完整 JSON 字符串后再整体解析,而不是每片单独解析。 -- **错误响应可读化**:非 2xx 时解析 `{"error": {"message": "..."}}` 并解码 `\uXXXX`, - 而不是把原始 JSON 甩给用户。 -- **不依赖流式**:如果服务端忽略 `stream` 参数直接返回整包 JSON,客户端会自动回退解析。 -- **行号集合钉住的替换**:`edit_file` 的 `line`(`read_file` 给的行号,整数或整数 - 数组都收)把替换钉在集合圈住的行里,且只要行内唯一 —— 从机制上避免模型改错地方, - 也不逼它为了"全文唯一"把 `old_string` 写成一整段。`line` 可以省略,省略就是 - **全文件替换所有匹配**:逃生门留着(有些改动本来就该全文件生效),但它不是默认 - 路径 —— 要改一处仍得给行号。 +- **tool_calls 增量拼接**:流式响应里 `function.arguments` 会被服务端切成任意碎片, + 客户端按 `index` 累积成完整 JSON 字符串后再整体解析, 而不是每片单独解析. +- **错误响应可读化**:非 2xx 时解析 `{"error": {"message": "..."}}` 并解码 `\uXXXX`, + 而不是把原始 JSON 甩给用户. +- **不依赖流式**:如果服务端忽略 `stream` 参数直接返回整包 JSON, 客户端会自动回退解析. +- **行号集合钉住的替换**:`edit_file` 的 `line` (`read_file` 给的行号, 整数或整数 + 数组都收)把替换钉在集合圈住的行里, 且只要行内唯一 —— 从机制上避免模型改错地方, + 也不逼它为了"全文唯一"把 `old_string` 写成一整段. `line` 可以省略, 省略就是 + **全文件替换所有匹配**:逃生门留着 (有些改动本来就该全文件生效), 但它不是默认 + 路径 —— 要改一处仍得给行号. ## 7. 测试 ```bash make test # 全部 -make test-unit # 纯单元测试,不需要网络(含演示程序自检 + 模糊测试) -make test-e2e # 用本地 mock 服务端验证完整链路,不需要真实 API key -make test-fuzz # 只跑模糊测试,迭代更多轮次 +make test-unit # 纯单元测试, 不需要网络 (含演示程序自检 + 模糊测试) +make test-e2e # 用本地 mock 服务端验证完整链路, 不需要真实 API key +make test-fuzz # 只跑模糊测试, 迭代更多轮次 make demo # 编译并运行渲染效果演示 ``` ### 模糊测试 -`tests/fuzz.c` 用随机与变异输入去砸两个"解析不可信数据"的模块(JSON 解析器、 -Markdown 渲染器),检查四条不变量: +`tests/fuzz.c` 用随机与变异输入去砸两个"解析不可信数据"的模块 (JSON 解析器、 +Markdown 渲染器), 检查四条不变量: | 模块 | 不变量 | |---|---| | JSON | 任何输入都不崩溃、不泄漏 | -| JSON | 解析成功 ⇒ dump → parse → dump 两次结果一致(幂等) | +| JSON | 解析成功 ⇒ dump → parse → dump 两次结果一致 (幂等) | | Markdown | 任何输入都不崩溃、不泄漏 | | Markdown | **一次性喂 与 任意分片喂 输出逐字节一致** | | Markdown | 不含元字符的输入必须原样输出 | -种子固定,失败可复现。失败时会把输入写到 `build/md_fail.bin`, -用 `build/reduce build/md_fail.bin` 可以自动最小化成最短复现样例。 +种子固定, 失败可复现. 失败时会把输入写到 `build/md_fail.bin`, +用 `build/reduce build/md_fail.bin` 可以自动最小化成最短复现样例. `tests/reduce.c` 是个 delta-debugging 最小化器 —— 上面那条"表格必须顶格" -的规则,就是靠它把 376 字节的失败输入缩到 4 字节才定位到根因的。 +的规则, 就是靠它把 376 字节的失败输入缩到 4 字节才定位到根因的. ```bash make build/fuzz build/reduce @@ -1825,14 +1825,14 @@ make build/fuzz build/reduce ``` 端到端测试不需要联网也不需要密钥:`tests/mock_server.py` 会在本地起一个 -OpenAI 兼容服务端,按请求内容返回确定性的流式响应,从而验证「模型请求工具 → -本地真实执行 → 结果回填 → 模型给出最终回答」的完整闭环。 +OpenAI 兼容服务端, 按请求内容返回确定性的流式响应, 从而验证「模型请求工具 → +本地真实执行 → 结果回填 → 模型给出最终回答」的完整闭环. -> 存档目录(`<二进制目录>/ligent.archive`)是运行期状态而不是构建产物,e2e 却 -> 需要它干净,所以脚本开机时会检查:**非空就先整体挪到 `/tmp/ligent.archive.saved.*`** -> 并打印路径,而不是直接删掉。移不动就直接中止(宁可测试不跑,也不误删)。 +> 存档目录 (`<二进制目录>/ligent.archive`)是运行期状态而不是构建产物, e2e 却 +> 需要它干净, 所以脚本开机时会检查:**非空就先整体挪到 `/tmp/ligent.archive.saved.*`** +> 并打印路径, 而不是直接删掉. 移不动就直接中止 (宁可测试不跑, 也不误删). > 挪走之后 e2e 才会在自己的那份临时目录里折腾 —— 跑两次也不会把你上一轮的 -> 存档叠进去。 +> 存档叠进去. 用 AddressSanitizer / UBSan 跑一遍: @@ -1842,39 +1842,39 @@ make SAN=1 test-unit ``` `SAN=1` 在默认 `CFLAGS` 之上追加 -`-fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all`, -链接行也带上(只插桩目标文件、不进运行时库的话检测器根本不生效)。 -`-fno-sanitize-recover=all` 让报错直接中止而不是"报告后继续跑",免得刷屏。 -整套单元测试(16 个可执行、3692 项)在这个配置下是干净的。 +`-fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all`, +链接行也带上 (只插桩目标文件、不进运行时库的话检测器根本不生效). +`-fno-sanitize-recover=all` 让报错直接中止而不是"报告后继续跑", 免得刷屏. +整套单元测试 (16 个可执行、3692 项)在这个配置下是干净的. ## 8. 稳定性设计 -这一节记录为了让它在真实网络和真实模型下不崩、不卡死所做的处理。 +这一节记录为了让它在真实网络和真实模型下不崩、不卡死所做的处理. ### 网络层 | 机制 | 说明 | |---|---| -| 瞬态失败重试 | 408/409/429/5xx 与连接级失败(DNS/连接/收发/半包)自动重试,指数退避 + 抖动,并遵循服务端 `Retry-After` | -| 不重试请求超时 | 超时意味着服务端**可能已经处理并计费**了,重发会重复扣费,所以宁可失败 | -| 已输出则不重试 | 流式一旦向终端吐过内容就不再重试,否则用户会看到重复输出 | -| 响应体上限 | 声明或实际超过 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 都会把结果截断) | +| 瞬态失败重试 | 408/409/429/5xx 与连接级失败 (DNS/连接/收发/半包)自动重试, 指数退避 + 抖动, 并遵循服务端 `Retry-After` | +| 不重试请求超时 | 超时意味着服务端**可能已经处理并计费**了, 重发会重复扣费, 所以宁可失败 | +| 已输出则不重试 | 流式一旦向终端吐过内容就不再重试, 否则用户会看到重复输出 | +| 响应体上限 | 声明或实际超过 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 都会把结果截断) | | 错误体上限 | 只保留 256KB 用于诊断 | -重试次数可配置:`--retries N`(默认 2,0 = 不重试)/ `LIGENT_RETRIES` / 配置项 `retries`。 +重试次数可配置:`--retries N` (默认 2, 0 = 不重试)/ `LIGENT_RETRIES` / 配置项 `retries`. ### 传输中断 -流式请求中途断连时,**已经收到的部分内容会保留**成一条消息并提示"传输中断", -而不是整段丢弃 —— 否则屏幕上有这段文字、上下文里却没有,后续对话会对不上。 +流式请求中途断连时, **已经收到的部分内容会保留**成一条消息并提示"传输中断", +而不是整段丢弃 —— 否则屏幕上有这段文字、上下文里却没有, 后续对话会对不上. -### 上下文超限:如实报错,不替你做决定 +### 上下文超限:如实报错, 不替你做决定 -历史超过模型窗口时,服务端回一个 4xx。ligent **不会自动删掉任何一条消息** —— -它把服务端的原始错误原样报出来,历史一个字节都不动: +历史超过模型窗口时, 服务端回一个 4xx. ligent **不会自动删掉任何一条消息** —— +它把服务端的原始错误原样报出来, 历史一个字节都不动: ``` ligent> 第二轮 @@ -1882,25 +1882,25 @@ 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`。它被**整个删掉**了,理由: +这里曾经有一整套"丢弃最老的一整轮再重试 (最多 5 次)"的机制, 配套 +`--no-auto-trim` / `auto_trim` / `LIGENT_AUTO_TRIM`. 它被**整个删掉**了, 理由: -- 触发它的判据只是错误文案里的关键词(`too long`、`上下文`、`超出`……), - 任何 4xx 蹭到这些词都会触发裁剪 —— 代价却是**不可逆地**删掉几轮对话。 - 用一句猜出来的判据去毁掉对话记录,代价不对等。 -- 它把"历史太大了"这件本该由用户决定的事(`/back` 摘掉哪一轮、`/reset` 重来、 - 还是换一个窗口更大的模型)自动化掉了;用户看到的是对话莫名其妙少了几轮。 +- 触发它的判据只是错误文案里的关键词 (`too long`、`上下文`、`超出`……), + 任何 4xx 蹭到这些词都会触发裁剪 —— 代价却是**不可逆地**删掉几轮对话. + 用一句猜出来的判据去毁掉对话记录, 代价不对等. +- 它把"历史太大了"这件本该由用户决定的事 (`/back` 摘掉哪一轮、`/reset` 重来、 + 还是换一个窗口更大的模型)自动化掉了; 用户看到的是对话莫名其妙少了几轮. -现在怎么处理由你决定:`/back` 摘掉最近一轮(可 `/unback` 撤销)、`/reset` 清空 -重来、`/export` 存档后另起一段,或者直接换个模型。`/usage` 里的"最近一次输入 -token"≈ 当前上下文大小,可以据此判断还剩多少余量。 +现在怎么处理由你决定:`/back` 摘掉最近一轮 (可 `/unback` 撤销)、`/reset` 清空 +重来、`/export` 存档后另起一段, 或者直接换个模型. `/usage` 里的"最近一次输入 +token"≈ 当前上下文大小, 可以据此判断还剩多少余量. -(`/meta` 里让模型帮你做这件事仍然是可行的 —— 它删的是你看得见、也点过头的那些轮。) + (`/meta` 里让模型帮你做这件事仍然是可行的 —— 它删的是你看得见、也点过头的那些轮. ) ### 对话结构不变量:tool_calls 必须连着 tool 服务端对消息结构的要求很硬:**带 `tool_calls` 的 assistant 后面必须紧跟**与每个 -`tool_call_id` 一一对应的 `tool` 消息,中间不能夹别的东西,也不能少。违反了就是: +`tool_call_id` 一一对应的 `tool` 消息, 中间不能夹别的东西, 也不能少. 违反了就是: ``` HTTP 400 — An assistant message with 'tool_calls' must be followed by tool @@ -1908,47 +1908,47 @@ messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message) ``` -麻烦在于这条历史**留在上下文里**——一旦写坏,之后每一轮都会以同样的 400 失败, -用户只能 `/reset`。所以 ligent 两头都做了: +麻烦在于这条历史**留在上下文里**——一旦写坏, 之后每一轮都会以同样的 400 失败, +用户只能 `/reset`. 所以 ligent 两头都做了: -**发之前先体检。** 每次构造请求前扫一遍历史:缺的 `tool` 消息补一条占位 -("这次调用没有拿到结果,不要假设它执行过"),孤儿 `tool` 消息(找不到对应 -调用的)丢掉,并如实打一行说明。没问题时它只是一次只读扫描,不分配任何东西。 +**发之前先体检. ** 每次构造请求前扫一遍历史:缺的 `tool` 消息补一条占位 + ("这次调用没有拿到结果, 不要假设它执行过"), 孤儿 `tool` 消息 (找不到对应 +调用的)丢掉, 并如实打一行说明. 没问题时它只是一次只读扫描, 不分配任何东西. ``` -对话结构有问题(assistant 的 tool_calls 与 tool 消息对不上),已自动修复 1 处 -—— 不修的话之后每一轮都会被服务端拒绝。 +对话结构有问题 (assistant 的 tool_calls 与 tool 消息对不上), 已自动修复 1 处 +—— 不修的话之后每一轮都会被服务端拒绝. ``` -**产生的时候别写坏。** 体检是兜底,正常路径不该走到它。两个真实踩过的坑: +**产生的时候别写坏. ** 体检是兜底, 正常路径不该走到它. 两个真实踩过的坑: -- **图片附件的位置。** 图片只能出现在 `user` 消息里,而 `tool` 消息的内容只能是 - 字符串,所以 `read_image` 之后要补一条 `user` 消息。曾经是"每推完一条 `tool` - 就补一次",于是一条 assistant 带两个调用、第一个是读图时,历史成了 - `assistant / tool / user(附件) / tool` —— 中间那条 `user` 把 `tool` 段截断了。 - 现在附件**攒到所有 `tool` 消息都推完之后**再补。 -- **中断。** 一条 assistant 带多个工具调用时按 `Ctrl-C`,以前直接 `return`, - 剩下的调用没人回应 —— 同一个 400。现在给没跑成的调用补一条"用户中断了本轮, - 本次调用没有执行"的 `tool` 消息再返回。 +- **图片附件的位置. ** 图片只能出现在 `user` 消息里, 而 `tool` 消息的内容只能是 + 字符串, 所以 `read_image` 之后要补一条 `user` 消息. 曾经是"每推完一条 `tool` + 就补一次", 于是一条 assistant 带两个调用、第一个是读图时, 历史成了 + `assistant / tool / user(附件) / tool` —— 中间那条 `user` 把 `tool` 段截断了. + 现在附件**攒到所有 `tool` 消息都推完之后**再补. +- **中断. ** 一条 assistant 带多个工具调用时按 `Ctrl-C`, 以前直接 `return`, + 剩下的调用没人回应 —— 同一个 400. 现在给没跑成的调用补一条"用户中断了本轮, + 本次调用没有执行"的 `tool` 消息再返回. -回归用例是第 53 段,而且 `tests/mock_server.py` **和真服务端一样校验这个结构** -(`pairing_error`):以后任何写坏历史的回归都会在 e2e 里当场 400,而不是等到 -用户真拿它去连服务端才发现。 +回归用例是第 53 段, 而且 `tests/mock_server.py` **和真服务端一样校验这个结构** + (`pairing_error`):以后任何写坏历史的回归都会在 e2e 里当场 400, 而不是等到 +用户真拿它去连服务端才发现. ### 图片降级只在错误确实冲着图片来时动手 -服务端 4xx 时会检查请求里有没有图片引用,有就把图片从历史里摘掉再重试一次 -(见 §5)。这一条**加了关键词判断**:只有错误里出现 `image` / `vision` / -`multimodal` / `content type` / `图片` 这类词才动手。 +服务端 4xx 时会检查请求里有没有图片引用, 有就把图片从历史里摘掉再重试一次 + (见 §5). 这一条**加了关键词判断**:只有错误里出现 `image` / `vision` / +`multimodal` / `content type` / `图片` 这类词才动手. 理由是它会把图片**永久删掉**:一条"tool_calls 后面缺 tool 消息"的 400 也被当成 -图片问题处理的话,图白丢,而且真正的错误被我们的提示盖住了 —— 排障方向直接被 -带偏。**不确定就不动手**,宁可让错误如实报出来。这终究是启发式;主要防线还是 -发送前就把格式和体积查掉。 +图片问题处理的话, 图白丢, 而且真正的错误被我们的提示盖住了 —— 排障方向直接被 +带偏. **不确定就不动手**, 宁可让错误如实报出来. 这终究是启发式; 主要防线还是 +发送前就把格式和体积查掉. -### 提权:模型请求,你输密码 +### 提权:模型请求, 你输密码 -`run_command` 有一个 `elevated` 参数。模型撞到权限墙时带上它,那一条命令就以 +`run_command` 有一个 `elevated` 参数. 模型撞到权限墙时带上它, 那一条命令就以 root 执行: ``` @@ -1962,40 +1962,40 @@ run_command {"command": "apt install -y nginx", "elevated": true} [sudo] password for pluv: ← 真正的授权在这里 ``` -**先说清楚为什么没有 `/su`、也没有"授权提示"。** +**先说清楚为什么没有 `/su`、也没有"授权提示". ** -- **`su` 能变 root,不是因为 PAM 验证了密码**,而是因为 `/usr/bin/su` 本身是 - setuid root:内核先把 root 给它,它再验密码、验不过就降权。普通进程调 - `pam_authenticate()` 只能得到「密码对不对」,uid 一位都不变。要让 ligent - 内部认证后真的变 root,唯一的路是把它编译成 setuid root —— 而这个程序的功能 - 是「让模型执行任意命令」,setuid root 的它等于把 root 远程执行做成一等功能。 - **那条路没走。** -- **也不需要 ligent 再发明一道 y/N 授权**。密码本身就是不可伪造的授权:模型敲 - 不出来,你也不可能手滑敲出来。前面再加一道"允许吗"只会是多余的摩擦,还会给出 - 一个虚假的安全模型 —— 好像把关的是 ligent 的提示,其实把关的是 sudo。 -- 所以没有常驻提权状态,也就没有"忘了降权"这回事。**一次命令一次身份**; - 确认提示负责让你看清命令,sudo 负责授权,各司其职。 +- **`su` 能变 root, 不是因为 PAM 验证了密码**, 而是因为 `/usr/bin/su` 本身是 + setuid root:内核先把 root 给它, 它再验密码、验不过就降权. 普通进程调 + `pam_authenticate()` 只能得到「密码对不对」, uid 一位都不变. 要让 ligent + 内部认证后真的变 root, 唯一的路是把它编译成 setuid root —— 而这个程序的功能 + 是「让模型执行任意命令」, setuid root 的它等于把 root 远程执行做成一等功能. + **那条路没走. ** +- **也不需要 ligent 再发明一道 y/N 授权**. 密码本身就是不可伪造的授权:模型敲 + 不出来, 你也不可能手滑敲出来. 前面再加一道"允许吗"只会是多余的摩擦, 还会给出 + 一个虚假的安全模型 —— 好像把关的是 ligent 的提示, 其实把关的是 sudo. +- 所以没有常驻提权状态, 也就没有"忘了降权"这回事. **一次命令一次身份**; + 确认提示负责让你看清命令, sudo 负责授权, 各司其职. | 环节 | 做法 | 为什么 | |---|---|---| -| 传参 | `sudo -u root -- ` | argv 形式,**不经 shell** —— 命令里的引号、换行、`;` 注入不进 root 那条命令 | -| 认证 | **不带 `-n`** | `-n` 是"不许提问",带上它需要密码时就直接失败;而这里要的正是让 sudo 能问 | -| 终端 | 提权路径**不 `setsid`** | sudo 是打开 `/dev/tty` 问密码的,摘掉控制终端它就永远问不出来 | -| 输出 | 仍然接管道 | 命令输出照常捕获回填给模型,和普通 `run_command` 一样 | -| 没有终端 | **直接拒绝,不去试** | 否则就是一个在后台等一个没人能看见的密码提示的进程 | +| 传参 | `sudo -u root -- ` | argv 形式, **不经 shell** —— 命令里的引号、换行、`;` 注入不进 root 那条命令 | +| 认证 | **不带 `-n`** | `-n` 是"不许提问", 带上它需要密码时就直接失败; 而这里要的正是让 sudo 能问 | +| 终端 | 提权路径**不 `setsid`** | sudo 是打开 `/dev/tty` 问密码的, 摘掉控制终端它就永远问不出来 | +| 输出 | 仍然接管道 | 命令输出照常捕获回填给模型, 和普通 `run_command` 一样 | +| 没有终端 | **直接拒绝, 不去试** | 否则就是一个在后台等一个没人能看见的密码提示的进程 | -想立刻收回授权:在 `/shell` 里敲 `sudo -k`,或者等 sudo 自己的超时。 +想立刻收回授权:在 `/shell` 里敲 `sudo -k`, 或者等 sudo 自己的超时. -**踩过的坑,值得单独记**:第一版把认证也放进通用的 `run_capture()`,而它 -`setsid()` + 全接管道 —— 真机上认证 **100% 失败**。更糟的是当时的 e2e 替身 -sudo 无条件成功,给了个**假绿**。现在替身必须像真 sudo 一样要求终端,并且有一条 -断言专门盯"认证时 sudo 真的拿到了终端";`tests/test_priv.c` 还钉住了"包装里不许 -出现 `-n`"和"没终端时能识别出来"这两条。 +**踩过的坑, 值得单独记**:第一版把认证也放进通用的 `run_capture()`, 而它 +`setsid()` + 全接管道 —— 真机上认证 **100% 失败**. 更糟的是当时的 e2e 替身 +sudo 无条件成功, 给了个**假绿**. 现在替身必须像真 sudo 一样要求终端, 并且有一条 +断言专门盯"认证时 sudo 真的拿到了终端"; `tests/test_priv.c` 还钉住了"包装里不许 +出现 `-n`"和"没终端时能识别出来"这两条. ### 排障:/log 导出收发记录 -进程会把每一次请求的**请求体**、响应头、以及响应原文(流式就是原始 SSE 字节) -滚动缓存在内存里。不给这个问题事先准备什么,出事了再捞: +进程会把每一次请求的**请求体**、响应头、以及响应原文 (流式就是原始 SSE 字节) +滚动缓存在内存里. 不给这个问题事先准备什么, 出事了再捞: ``` ligent> /log # 看看现在缓存了多少 @@ -2003,16 +2003,16 @@ ligent> /log /tmp/session.log # 把已经积累的全部追加写入文件 ligent> /log clear # 清空缓存 ``` -记录里**不含请求头**,所以不会把 API key 写进去(有测试断言)。 +记录里**不含请求头**, 所以不会把 API key 写进去 (有测试断言). -它的真正用途是:**录一次真实会话,之后离线回放**。ligent 的协议适配一直只在 -自建的 mock 服务端上验证过,而 mock 是我们自己写的 —— 它接受什么,完全取决于 -我们**以为**服务端会接受什么。有了这个文件,真实服务端的响应就能当成测试夹具 -反复使用,不必每次都烧 token。 +它的真正用途是:**录一次真实会话, 之后离线回放**. ligent 的协议适配一直只在 +自建的 mock 服务端上验证过, 而 mock 是我们自己写的 —— 它接受什么, 完全取决于 +我们**以为**服务端会接受什么. 有了这个文件, 真实服务端的响应就能当成测试夹具 +反复使用, 不必每次都烧 token. -`tests/replay.py` 就是这个回放器。它不重写一个"模拟客户端",而是当**离线 -服务端**:把录下来的响应原样发给一个真的 ligent,于是 `http.c` 的分块读取、 -`llm.c` 的 SSE 行切分与 `tool_calls` 增量重组,全都在真实字节上跑一遍。 +`tests/replay.py` 就是这个回放器. 它不重写一个"模拟客户端", 而是当**离线 +服务端**:把录下来的响应原样发给一个真的 ligent, 于是 `http.c` 的分块读取、 +`llm.c` 的 SSE 行切分与 `tool_calls` 增量重组, 全都在真实字节上跑一遍. ```bash python3 tests/replay.py session.log --summary # 这份录制里有什么 @@ -2022,121 +2022,121 @@ python3 tests/replay.py --serve 8899 session.log --strict # 当离线服务端 ``` `--strict` 是它最有用的一半:它把每个进来的请求体与录制时那一份做**结构比对** -(顶层键、消息角色序列、工具名集合),不一致就回 400 并在 stderr 打印差异。 + (顶层键、消息角色序列、工具名集合), 不一致就回 400 并在 stderr 打印差异. 于是"今天的客户端还在发同样的报文形状"变成了一条**会失败的断言**——改坏了 -报文而没发现,从此不可能。 +报文而没发现, 从此不可能. -回放时要给 ligent 加 `--no-probe`:能力探测会插进额外的请求,而那几轮不在 -录制里(录制时也建议 `LIGENT_PROBE=0`,两边一致)。录制用完之后回放器会明确 -报"没有更多响应",而不是静静回错东西。 +回放时要给 ligent 加 `--no-probe`:能力探测会插进额外的请求, 而那几轮不在 +录制里 (录制时也建议 `LIGENT_PROBE=0`, 两边一致). 录制用完之后回放器会明确 +报"没有更多响应", 而不是静静回错东西. -仓库里冻了两份夹具(`tests/fixtures/`),都是对着 mock 录的 —— mock 故意把 -SSE 切成 1 字符、把 `tool_calls` 也切碎,所以它们冻结的正是最值得冻结的形状: +仓库里冻了两份夹具 (`tests/fixtures/`), 都是对着 mock 录的 —— mock 故意把 +SSE 切成 1 字符、把 `tool_calls` 也切碎, 所以它们冻结的正是最值得冻结的形状: | 夹具 | 冻住的是 | |---|---| | `session.log` | 流式 SSE + 被切碎的 `tool_calls` 重组 + 工具往返 + 最终回答 | -| `error.log` | 一个 400 错误体,以及错误信息的可读化 | +| `error.log` | 一个 400 错误体, 以及错误信息的可读化 | -**录一份真实会话**(你自己的 key,跑一次真问题): +**录一份真实会话** (你自己的 key, 跑一次真问题): ```bash -ligent # 正常聊,直到复现出你要冻住的那个行为 +ligent # 正常聊, 直到复现出你要冻住的那个行为 ligent> /log session.log # 导出 python3 tests/replay.py session.log --summary ``` -之后把它放进 `tests/fixtures/` 并加段断言,那次真实故障就永久变成了测试。 +之后把它放进 `tests/fixtures/` 并加段断言, 那次真实故障就永久变成了测试. -内存上限 16MB,超出后丢弃较早的部分(排障时通常更关心刚刚发生了什么), -导出时会在文件开头注明发生了截断。 +内存上限 16MB, 超出后丢弃较早的部分 (排障时通常更关心刚刚发生了什么), +导出时会在文件开头注明发生了截断. -> 注意:`/log` 是 REPL 命令。单次任务模式(`ligent "任务"`)下记录同样会累积, -> 但没有地方把它导出来。 +> 注意:`/log` 是 REPL 命令. 单次任务模式 (`ligent "任务"`)下记录同样会累积, +> 但没有地方把它导出来. ### 对抗性测试 -`tests/fuzz.c` 用随机与变异输入砸两个"解析不可信数据"的模块(JSON 解析器、 -Markdown 渲染器);`tests/reduce.c` 把失败输入最小化成最短复现。 -详见下一节。 +`tests/fuzz.c` 用随机与变异输入砸两个"解析不可信数据"的模块 (JSON 解析器、 +Markdown 渲染器); `tests/reduce.c` 把失败输入最小化成最短复现. +详见下一节. ## 9. 已知限制 -- 界面文案只有中英两份,而且只管"给人看"的部分:**工具描述、工具回执、 - 系统提示词不随界面语言变**(那些是给模型的信息)。切成英文之后模型收到的 - 仍然是中文回执 —— 想让模型侧也换语言,得改提示词本身。 -- 只在 Linux 上验证过(用到 `fork/setsid/pipe/poll`、`fnmatch`、POSIX 正则)。 -- `--timeout` 只对非流式请求生效;流式请求靠 120 秒无数据即断开的低速检测。 -- 历史超过模型窗口时**只报错,不自动回收**(见§8):要不要丢几轮、丢哪几轮由你 - 用 `/back`、`/reset`、`/meta` 决定。单轮内工具输出过大(超过上限被截断)时, - 只能靠模型自己用 `offset/limit` 分段读。 -- 每轮都会重发完整历史,token 成本随轮数平方增长。 -- 终端交互是行式 REPL,不是全屏 TUI:有行内编辑与历史,但**没有 Tab 补全**。 -- 对话历史本身只存在进程内,退出即丢 —— 要留就 `/export`。 - (输入历史是持久化的,见下一节。) -- Markdown 渲染不处理嵌套列表缩进。 -- 提权只覆盖 `run_command`(含 `/shell`)。文件类工具还没走提权通道,要动受 - 保护的文件得在命令里自己用 sudo。 -- 提权依赖系统的 `sudo`;不支持 `su`(它需要终端且不缓存凭据)。 -- 提权命令不独立成进程组(要保住控制终端给 sudo 问密码),所以超时只能杀直接 - 子进程,不能整组杀。 -- `/include` 的 glob 是 shell 语义(`glob(3)`),`*` 不跨目录;要递归就进 - 交互式界面逐层进,或者自己写多级模式。 -- `/include` 一次最多 32 个文件、单文件 256 KB、累计 1 MB。 -- `/models` 的列表只影响"你能看到什么",不影响"你能填什么":输入行里直接写 - 名字可以指定任何模型,包括服务端没列出来的内部模型。 +- 界面文案只有中英两份, 而且只管"给人看"的部分:**工具描述、工具回执、 + 系统提示词不随界面语言变** (那些是给模型的信息). 切成英文之后模型收到的 + 仍然是中文回执 —— 想让模型侧也换语言, 得改提示词本身. +- 只在 Linux 上验证过 (用到 `fork/setsid/pipe/poll`、`fnmatch`、POSIX 正则). +- `--timeout` 只对非流式请求生效; 流式请求靠 120 秒无数据即断开的低速检测. +- 历史超过模型窗口时**只报错, 不自动回收** (见§8):要不要丢几轮、丢哪几轮由你 + 用 `/back`、`/reset`、`/meta` 决定. 单轮内工具输出过大 (超过上限被截断)时, + 只能靠模型自己用 `offset/limit` 分段读. +- 每轮都会重发完整历史, token 成本随轮数平方增长. +- 终端交互是行式 REPL, 不是全屏 TUI:有行内编辑与历史, 但**没有 Tab 补全**. +- 对话历史本身只存在进程内, 退出即丢 —— 要留就 `/export`. + (输入历史是持久化的, 见下一节. ) +- Markdown 渲染不处理嵌套列表缩进. +- 提权只覆盖 `run_command` (含 `/shell`). 文件类工具还没走提权通道, 要动受 + 保护的文件得在命令里自己用 sudo. +- 提权依赖系统的 `sudo`; 不支持 `su` (它需要终端且不缓存凭据). +- 提权命令不独立成进程组 (要保住控制终端给 sudo 问密码), 所以超时只能杀直接 + 子进程, 不能整组杀. +- `/include` 的 glob 是 shell 语义 (`glob(3)`), `*` 不跨目录; 要递归就进 + 交互式界面逐层进, 或者自己写多级模式. +- `/include` 一次最多 32 个文件、单文件 256 KB、累计 1 MB. +- `/models` 的列表只影响"你能看到什么", 不影响"你能填什么":输入行里直接写 + 名字可以指定任何模型, 包括服务端没列出来的内部模型. - `/history`、`/back`、`/unback`、`/export`、`/import` 报的"历史条数"**不含 - system 提示词** —— 它是配置不是对话,删掉唯一一轮之后应当显示 0 条。 - 只有 `/diff`(meta)报的是数组真实条数,因为它要跟 `#0 #1 ...` 的下标对上。 -- 图片只进不出:`read_image` 能让模型看图,但模型没有画图能力。 -- 能力探测靠"发请求试",因为**根本不存在**查询能力的接口。代价是开启探测时 - 每个进程的第一次请求前会多出 2~4 个小请求(换模型会重测,切回来命中缓存)。 - `--no-probe` 可以彻底关掉,回到"全都暴露"的老行为。探测结论不确定时按支持 - 处理 —— 宁可让模型白调一次,也不错误地藏掉工具。 -- 图片以 base64 内联,所以**每轮请求都会重发一遍图片字节**(推理侧的图片 token - 有上限、不随体积涨,但上行带宽和请求体大小是实打实要付的)。历史/归档本身 - 不受影响——那里只有引用。 -- 历史里的图片是对 `ligent.archive/images/` 的引用,**备份才是字节的真身**。 - 把 `ligent.archive` 删掉或移走,图片就展开不出来,会被降级成文字说明 - (不会让对话卡死,但图也就没了)。`/export` 的存档同理,不是自包含的。 -- 图片只支持内联,**不支持直接给一个外部 URL**:ligent 不去网上抓图,`read_image` - 只收本地文件。(协议上的另一种走法是外部 URL,需要服务端自己去下载。) -- 单张图 base64 后超过 32 MiB、或一次请求图片总量超过 44 MiB 会被本地拒绝, - 模型会被告知"图片太大",需要先缩小或压缩。 -- 工具结果按字节截断,不按 token 计算,极大文件需要配合 `offset/limit` 分段读。 -- 二进制检测只看"有没有 NUL 字节":`read_file` 只探前 8 KB,所以一个 - **前 8 KB 干净、之后才有 NUL** 的文件仍会被当文本读(内容会在第一个 NUL 处被 - 悄悄截断)。`edit_file` 不受影响 —— 它看全文件。UTF-16、压缩包、可执行文件这类 - 在开头就有 NUL,都能识别出来。 -- 对话落盘要手动 `/export`,不会自动保存(输入历史则是自动的,见下)。 -- 思考过程只是"存下来回传",不做展示也不做摘要;它会一直留在上下文里, - 所以长对话下 token 花销比不开思考更大(这是服务端的要求,不是可以省的)。 -- 思考模式的具体字段(顶层 `thinking` 对象 / `reasoning_effort` / 回传 - `reasoning_content`)按 DeepSeek 的 OpenAI 兼容格式发送 —— 这一套 MiMo 也一样, - 两边能共用。别家如果用的是嵌套的 `reasoning` 对象(如 OpenRouter)或 - `thinking.budget_tokens`(如 Anthropic),需要服务端自己有兼容层; - ligent 不做方言探测。顺带一提,`reasoning_content` ligent 读的时候两种 - 名字都认,但回传时**只写 `reasoning_content`** —— 对着用 `reasoning` 的 - 服务端可能会丢失思考上下文。 + system 提示词** —— 它是配置不是对话, 删掉唯一一轮之后应当显示 0 条. + 只有 `/diff` (meta)报的是数组真实条数, 因为它要跟 `#0 #1 ...` 的下标对上. +- 图片只进不出:`read_image` 能让模型看图, 但模型没有画图能力. +- 能力探测靠"发请求试", 因为**根本不存在**查询能力的接口. 代价是开启探测时 + 每个进程的第一次请求前会多出 2~4 个小请求 (换模型会重测, 切回来命中缓存). + `--no-probe` 可以彻底关掉, 回到"全都暴露"的老行为. 探测结论不确定时按支持 + 处理 —— 宁可让模型白调一次, 也不错误地藏掉工具. +- 图片以 base64 内联, 所以**每轮请求都会重发一遍图片字节** (推理侧的图片 token + 有上限、不随体积涨, 但上行带宽和请求体大小是实打实要付的). 历史/归档本身 + 不受影响——那里只有引用. +- 历史里的图片是对 `ligent.archive/images/` 的引用, **备份才是字节的真身**. + 把 `ligent.archive` 删掉或移走, 图片就展开不出来, 会被降级成文字说明 + (不会让对话卡死, 但图也就没了). `/export` 的存档同理, 不是自包含的. +- 图片只支持内联, **不支持直接给一个外部 URL**:ligent 不去网上抓图, `read_image` + 只收本地文件. (协议上的另一种走法是外部 URL, 需要服务端自己去下载. ) +- 单张图 base64 后超过 32 MiB、或一次请求图片总量超过 44 MiB 会被本地拒绝, + 模型会被告知"图片太大", 需要先缩小或压缩. +- 工具结果按字节截断, 不按 token 计算, 极大文件需要配合 `offset/limit` 分段读. +- 二进制检测只看"有没有 NUL 字节":`read_file` 只探前 8 KB, 所以一个 + **前 8 KB 干净、之后才有 NUL** 的文件仍会被当文本读 (内容会在第一个 NUL 处被 + 悄悄截断). `edit_file` 不受影响 —— 它看全文件. UTF-16、压缩包、可执行文件这类 + 在开头就有 NUL, 都能识别出来. +- 对话落盘要手动 `/export`, 不会自动保存 (输入历史则是自动的, 见下). +- 思考过程只是"存下来回传", 不做展示也不做摘要; 它会一直留在上下文里, + 所以长对话下 token 花销比不开思考更大 (这是服务端的要求, 不是可以省的). +- 思考模式的具体字段 (顶层 `thinking` 对象 / `reasoning_effort` / 回传 + `reasoning_content`)按 DeepSeek 的 OpenAI 兼容格式发送 —— 这一套 MiMo 也一样, + 两边能共用. 别家如果用的是嵌套的 `reasoning` 对象 (如 OpenRouter)或 + `thinking.budget_tokens` (如 Anthropic), 需要服务端自己有兼容层; + ligent 不做方言探测. 顺带一提, `reasoning_content` ligent 读的时候两种 + 名字都认, 但回传时**只写 `reasoning_content`** —— 对着用 `reasoning` 的 + 服务端可能会丢失思考上下文. - `/shell` 的记录对全屏程序是"整段忽略画面"而不是"还原画面":`nano`/`vim` 这类 - 用备用屏幕的程序,记录里只剩命令行和退出后的输出。极少数既不用备用屏幕、 - 又逐行重绘的程序(`TERM` 被强行设成不认识的值时)只能靠压缩空白降噪; - 真正逐屏还原需要一整套终端模拟器,不在当前范围内。 + 用备用屏幕的程序, 记录里只剩命令行和退出后的输出. 极少数既不用备用屏幕、 + 又逐行重绘的程序 (`TERM` 被强行设成不认识的值时)只能靠压缩空白降噪; + 真正逐屏还原需要一整套终端模拟器, 不在当前范围内. -**验证上的缺口**(这些地方是"我没验过",不是"我验过没问题"): +**验证上的缺口** (这些地方是"我没验过", 不是"我验过没问题"): -- **提权的真实路径没端到端跑过。** e2e 里用的是桩 `sudo`(它要求有 tty 并把 - argv 记下来,专门守着 `setsid` 那个回归),真实 sudo 的密码提示路径没有测过 - —— 那需要碰使用者的密码。而这个模块当初正是因为控制终端的问题认证失败过, - 所以这里确实是风险点,不是理论上的担心。 -- **静态二进制只在本机验过 HTTPS。** TLS 栈是验过的(本地 `openssl s_server` + - 自签证书,能走到"证书不被信任"说明整条 OpenSSL 路径在跑),但它依赖编进去的 - `/etc/ssl/certs/ca-certificates.crt`。目标机器上没有这个文件的话,TLS 直接 - 不可用;而且 **libcurl 库不读 `CURL_CA_BUNDLE`**(那是 `curl` 命令行工具的行 - 为),所以使用者也没法用环境变量指一份别的 CA —— 要支持得改成显式 - `CURLOPT_CAINFO`。 -- **`clang --analyze` 还剩 3 条告警**,我判断是误报(`fgets` 前已置 `errno=0`; - `fread` 的 EOF 循环惯用法),但没有能力证明。`gcc -fanalyzer` 在 `image.c` 报 +- **提权的真实路径没端到端跑过. ** e2e 里用的是桩 `sudo` (它要求有 tty 并把 + argv 记下来, 专门守着 `setsid` 那个回归), 真实 sudo 的密码提示路径没有测过 + —— 那需要碰使用者的密码. 而这个模块当初正是因为控制终端的问题认证失败过, + 所以这里确实是风险点, 不是理论上的担心. +- **静态二进制只在本机验过 HTTPS. ** TLS 栈是验过的 (本地 `openssl s_server` + + 自签证书, 能走到"证书不被信任"说明整条 OpenSSL 路径在跑), 但它依赖编进去的 + `/etc/ssl/certs/ca-certificates.crt`. 目标机器上没有这个文件的话, TLS 直接 + 不可用; 而且 **libcurl 库不读 `CURL_CA_BUNDLE`** (那是 `curl` 命令行工具的行 + 为), 所以使用者也没法用环境变量指一份别的 CA —— 要支持得改成显式 + `CURLOPT_CAINFO`. +- **`clang --analyze` 还剩 3 条告警**, 我判断是误报 (`fgets` 前已置 `errno=0`; + `fread` 的 EOF 循环惯用法), 但没有能力证明. `gcc -fanalyzer` 在 `image.c` 报 的那条 FILE 泄漏同理:`-O0` 下干净、`-O1` 起才报、把函数原样抽出来单独编也 - 不报,我判断是内联导致的误报 —— 换个 gcc 版本结论可能变。 -- 打断(Ctrl-C)路径只在几处有覆盖,不全面。 + 不报, 我判断是内联导致的误报 —— 换个 gcc 版本结论可能变. +- 打断 (Ctrl-C)路径只在几处有覆盖, 不全面. diff --git a/README2.md b/README2.md index 4634d35..4f574b6 100644 --- a/README2.md +++ b/README2.md @@ -1,17 +1,17 @@ # ligent: Self-contained yet powerful command-line AI Agent, implemented in C99 C99 从零写成的命令行 AI Agent:连接 **OpenAI 兼容**的 Chat Completions -接口, 具备**原生 function calling**(工具调用)能力,能读写文件、执行命令、 -搜索代码,并在终端里流式输出。 +接口, 具备**原生 function calling** (工具调用)能力, 能读写文件、执行命令、 +搜索代码, 并在终端里流式输出. -对话、工具调用、图片走的都是通用协议(`image_url` + base64 内联,DeepSeek 和 -MiMo 都收);**只有思考模式的字段是方言**,不做跨厂商探测。代价见 §9。 +对话、工具调用、图片走的都是通用协议 (`image_url` + base64 内联, DeepSeek 和 +MiMo 都收); **只有思考模式的字段是方言**, 不做跨厂商探测. 代价见 §9. -除 `libcurl`(HTTPS 传输)外,**不依赖任何第三方库**: -JSON 解析器、SSE 流式协议解析、工具调用增量拼接、REPL 全部是本仓库自己实现的。 +除 `libcurl` (HTTPS 传输)外, **不依赖任何第三方库**: +JSON 解析器、SSE 流式协议解析、工具调用增量拼接、REPL 全部是本仓库自己实现的. ``` -$ ligent "把 src/util.c 里的 xmalloc 改成失败时打印调用栈,然后编译验证" +$ ligent "把 src/util.c 里的 xmalloc 改成失败时打印调用栈, 然后编译验证" {/} read_file "src/util.c" │ 1 /* util.c - 基础工具实现 */ │ 2 #include "util.h" @@ -23,14 +23,14 @@ $ ligent "把 src/util.c 里的 xmalloc 改成失败时打印调用栈,然后 {/} run_command make 2>&1 | tail -20 │ cc ... -o ligent ... │ [exit code: 0] -已按要求改造 xmalloc 的失败路径,并确认编译通过。 +已按要求改造 xmalloc 的失败路径, 并确认编译通过. ``` --- ## 1. 编译 -依赖:`gcc`(或 `clang`)、`make`、`libcurl` 开发包。 +依赖:`gcc` (或 `clang`)、`make`、`libcurl` 开发包. ```bash # Debian / Ubuntu @@ -48,31 +48,31 @@ make # 生成 ./ligent make test # 跑单元测试 + 端到端测试 ``` -没有 `make install`。这是个自包含的小工具,不是系统级程序 —— 可执行文件在哪, -配置就在哪(见下一节),所以直接拷走 `ligent` 和 `ligent.json` 就是"安装"。 +没有 `make install`. 这是个自包含的小工具, 不是系统级程序 —— 可执行文件在哪, +配置就在哪 (见下一节), 所以直接拷走 `ligent` 和 `ligent.json` 就是"安装". -依赖只有 **libcurl** 和 libc,外加一个 C99 编译器(gcc / clang / tcc 都编得过)。 -跑测试还需要 Python 3 —— 端到端测试用本地 mock 服务端,**不联网、不需要密钥**。 +依赖只有 **libcurl** 和 libc, 外加一个 C99 编译器 (gcc / clang / tcc 都编得过). +跑测试还需要 Python 3 —— 端到端测试用本地 mock 服务端, **不联网、不需要密钥**. 其它目标: | 目标 | 作用 | |---|---| -| `make test` | 单元测试(16 个可执行)+ 端到端测试(318 项) | -| `make test-unit` | 只跑单元测试,不需要网络 | -| `make test-fuzz` | 模糊测试 20 万轮(`test-unit` 里跑的是 4000 轮) | -| `make analyze` | `gcc -fanalyzer` 静态分析,无告警才算过 | -| `make static` | 完全静态的产物(先跑 `./tools/build-static-deps.sh`) | +| `make test` | 单元测试 (16 个可执行)+ 端到端测试 (318 项) | +| `make test-unit` | 只跑单元测试, 不需要网络 | +| `make test-fuzz` | 模糊测试 20 万轮 (`test-unit` 里跑的是 4000 轮) | +| `make analyze` | `gcc -fanalyzer` 静态分析, 无告警才算过 | +| `make static` | 完全静态的产物 (先跑 `./tools/build-static-deps.sh`) | | `make static-test` | 拿同一套 e2e 打静态产物 | -| `make docker-build` | 在容器里构建静态产物(podman/docker) | +| `make docker-build` | 在容器里构建静态产物 (podman/docker) | | `make docker-extract` | 把产物从镜像拷到 `./ligent-static` | | `make docker-test` | 拿同一套 e2e 打容器产物 | | `make check-docs` | 校验本文里引用的测试数量与现实一致 | -| `tools/check-colors.sh` | 审计 ANSI 颜色码是否都在 16 色内(`make test-unit` 里会跑) | +| `tools/check-colors.sh` | 审计 ANSI 颜色码是否都在 16 色内 (`make test-unit` 里会跑) | ### 构建目录按编译器和配置分开 -产物不在 `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`, -只有被改过的那几个 `.o` 是 clang 编的,其余还是 tcc 的,链接时就是一堆 -`__floatundidf` 之类的 undefined reference。更危险的是 `make` 之后再 `make SAN=1`: -一个 `.o` 都不会重编,而链接行带着 `-fsanitize=...`、ASan 运行时也确实进了二进制, -于是它**看起来**是插桩过的 —— 可 `nm` 里一个 `__asan`/`__ubsan` 符号都没有, -什么都不会检测。换句话说,"没先 `make clean` 的 ASan 结论"全是假绿灯。 +原因很实在:**make 看不见命令行变量变了**. `make CC=tcc` 之后再 `make CC=clang`, +只有被改过的那几个 `.o` 是 clang 编的, 其余还是 tcc 的, 链接时就是一堆 +`__floatundidf` 之类的 undefined reference. 更危险的是 `make` 之后再 `make SAN=1`: +一个 `.o` 都不会重编, 而链接行带着 `-fsanitize=...`、ASan 运行时也确实进了二进制, +于是它**看起来**是插桩过的 —— 可 `nm` 里一个 `__asan`/`__ubsan` 符号都没有, +什么都不会检测. 换句话说, "没先 `make clean` 的 ASan 结论"全是假绿灯. -把标签写进路径之后这两种混用都不可能发生了:配置一变,对应目录就是空的,该编的 -自然会全编。 +把标签写进路径之后这两种混用都不可能发生了:配置一变, 对应目录就是空的, 该编的 +自然会全编. `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` 应该只剩你自己的东西。 +测试可执行文件、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 @@ -105,167 +105,167 @@ make clean make static ``` -`make static` 自己**不会**去编依赖(那等于让一个构建目标偷偷联网下载),它会明确 -报错并告诉你该跑上面那条命令。 +`make static` 自己**不会**去编依赖 (那等于让一个构建目标偷偷联网下载), 它会明确 +报错并告诉你该跑上面那条命令. -特意**不动 `ligent.history`**:那不是构建产物,是你的输入历史,删了找不回来。 -(e2e 自己写的是 `build/e2e.history`,在 `build/` 里,跟着一起清。) +特意**不动 `ligent.history`**:那不是构建产物, 是你的输入历史, 删了找不回来. + (e2e 自己写的是 `build/e2e.history`, 在 `build/` 里, 跟着一起清. ) ### 让编译器检查我们自己的 printf 项目里有一层自己的 printf 风格包装:`xasprintf` / `sb_printf` / `err_result` / -`log_printf` / `spinner_notice` / `perr` / doctor 的 `field`·`marked`·`sub`,一共 -11 个函数、约 120 个调用点。它们的格式串**编译器一个都没在看** —— 因为全项目 -`__attribute__` 出现 0 次,编译器不知道这些函数有 printf 语义。 +`log_printf` / `spinner_notice` / `perr` / doctor 的 `field`·`marked`·`sub`, 一共 +11 个函数、约 120 个调用点. 它们的格式串**编译器一个都没在看** —— 因为全项目 +`__attribute__` 出现 0 次, 编译器不知道这些函数有 printf 语义. -直接写 `fprintf` 的地方是查的(`confirm.c` 那次 `%s` 对 `int` 就是这么抓到的), -走包装的就全漏过去了。现在用 `LIGENT_PRINTF(fmt_idx, arg_idx)` 宏(gcc/clang 展开成 -`__attribute__((format(printf, …)))`,tcc 没有这个机制、展开成空)把 11 个函数全标上, -再加 `-Wformat=2`。 +直接写 `fprintf` 的地方是查的 (`confirm.c` 那次 `%s` 对 `int` 就是这么抓到的), +走包装的就全漏过去了. 现在用 `LIGENT_PRINTF(fmt_idx, arg_idx)` 宏 (gcc/clang 展开成 +`__attribute__((format(printf, …)))`, tcc 没有这个机制、展开成空)把 11 个函数全标上, +再加 `-Wformat=2`. -结果:120 个调用点全部过了检查,并额外抓到 doctor 里把 `HTTPS_PROXY` 的值**直接当 -格式串**的写法 —— 代理 URL 里带 `%` 是常事(密码里的 `%40`、路径里的 `%2F`), -那会去读一个不存在的参数。改成 `field("代理", "%s", …)`。 +结果:120 个调用点全部过了检查, 并额外抓到 doctor 里把 `HTTPS_PROXY` 的值**直接当 +格式串**的写法 —— 代理 URL 里带 `%` 是常事 (密码里的 `%40`、路径里的 `%2F`), +那会去读一个不存在的参数. 改成 `field("代理", "%s", …)`. -`-Wformat=2` 附带 `-Wformat-nonliteral`,我们用 `-Wno-format-nonliteral` 关掉了: -包装函数内部转发 `fmt` 的地方(`xvasprintf` → `vsnprintf(fmt, ap)`)天然是非字面量, -而**调用点**那一侧已经由属性查完了,留着只是噪声。 -`-Wformat-security` 保留着 —— 它正是抓到上面那个代理问题的那条。 +`-Wformat=2` 附带 `-Wformat-nonliteral`, 我们用 `-Wno-format-nonliteral` 关掉了: +包装函数内部转发 `fmt` 的地方 (`xvasprintf` → `vsnprintf(fmt, ap)`)天然是非字面量, +而**调用点**那一侧已经由属性查完了, 留着只是噪声. +`-Wformat-security` 保留着 —— 它正是抓到上面那个代理问题的那条. -注意属性写在**函数定义**上时要放在声明符之前(`LIGENT_PRINTF(2, 3) static void field(...)`), -写在原型上则放末尾。gcc 对前者会直接报 -"attributes should be specified before the declarator in a function definition"。 +注意属性写在**函数定义**上时要放在声明符之前 (`LIGENT_PRINTF(2, 3) static void field(...)`), +写在原型上则放末尾. gcc 对前者会直接报 +"attributes should be specified before the declarator in a function definition". ### 静态分析 ```bash -make analyze # gcc -fanalyzer,独立目标,故意不进 make test(它比全套测试还慢) +make analyze # gcc -fanalyzer, 独立目标, 故意不进 make test (它比全套测试还慢) ``` -它抓的是稀疏但致命的那类问题,第一次跑就捞出两个真 bug:`attempt()` 里 -`curl_easy_init` 失败那条提前返回会把未初始化的 `cerr` 当字符串读;POST 分支对 -`body == NULL` 不设防(无条件 `strlen`)。两个都不在任何测试的覆盖里 —— 前者要 -`curl_easy_init` 真的失败,后者要一个当前调用方不会传的参数组合。 +它抓的是稀疏但致命的那类问题, 第一次跑就捞出两个真 bug:`attempt()` 里 +`curl_easy_init` 失败那条提前返回会把未初始化的 `cerr` 当字符串读; POST 分支对 +`body == NULL` 不设防 (无条件 `strlen`). 两个都不在任何测试的覆盖里 —— 前者要 +`curl_easy_init` 真的失败, 后者要一个当前调用方不会传的参数组合. -用 `-O0` 跑是有意的:开了优化之后 gcc 会内联,而 `-fanalyzer` 的跨函数状态遇到 -内联并不可靠(`image.c` 的 `rand_suffix` 在 `-O1/-O2/-Os` 下会报一条并不存在的 -FILE 泄漏,`fclose` 明明就写在 `fread` 下一行;`-O0` 下干净)。分析器自身就是跨 -函数分析的,不依赖 `-O` 级别,所以降级不会少查什么。目标里带 `-Werror`:不然 -它永远打印"通过"—— gcc 的分析器告警不影响退出码,这是我第一版踩的坑。 +用 `-O0` 跑是有意的:开了优化之后 gcc 会内联, 而 `-fanalyzer` 的跨函数状态遇到 +内联并不可靠 (`image.c` 的 `rand_suffix` 在 `-O1/-O2/-Os` 下会报一条并不存在的 +FILE 泄漏, `fclose` 明明就写在 `fread` 下一行; `-O0` 下干净). 分析器自身就是跨 +函数分析的, 不依赖 `-O` 级别, 所以降级不会少查什么. 目标里带 `-Werror`:不然 +它永远打印"通过"—— gcc 的分析器告警不影响退出码, 这是我第一版踩的坑. ### 换个编译器:tcc 也能编 ```bash -make CC=tcc # 全套测试(3692 单元 + 318 e2e)实测通过 +make CC=tcc # 全套测试 (3692 单元 + 318 e2e)实测通过 ``` -tcc 0.9.28 能把 ligent 完整编出来并链接成功,产物 1.0 MB。CPU 密集的基准 -(`fuzz 4000`)上 **218 ms vs gcc 的 171 ms,只慢约 1.3 倍** —— 对 ligent 这种 -瓶颈在网络的程序完全无所谓。 +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。 -- **没有那些严格警告**(`-Wshadow` / `-Wmissing-prototypes` 等)。tcc 的诊断 - 弱得多,所以"零警告"这条纪律只在 gcc/clang 下成立。 -- 没有优化器(上面那 1.3 倍就是它的全部代价)。 +- **没有 `-fsanitize`**:`make SAN=1` 仍然要用 gcc/clang. +- **没有那些严格警告** (`-Wshadow` / `-Wmissing-prototypes` 等). tcc 的诊断 + 弱得多, 所以"零警告"这条纪律只在 gcc/clang 下成立. +- 没有优化器 (上面那 1.3 倍就是它的全部代价). -顺带一提,tcc 一上来就帮我们抓出两个**一直存在的构建脆弱点**:代码调了 `fabs` -却没链 `-lm`(gcc 把它折成内建函数,所以看不出来),以及 6 条测试链接规则漏了 -`$(LDLIBS)`。两个都补上了 —— 这类"某个编译器恰好替你兜住"的洞,平时是看不见的。 +顺带一提, tcc 一上来就帮我们抓出两个**一直存在的构建脆弱点**:代码调了 `fabs` +却没链 `-lm` (gcc 把它折成内建函数, 所以看不出来), 以及 6 条测试链接规则漏了 +`$(LDLIBS)`. 两个都补上了 —— 这类"某个编译器恰好替你兜住"的洞, 平时是看不见的. ### 静态构建:musl libc + 静态 libcurl -想要一个**拷到哪都能跑**的可执行文件(不依赖宿主机的 glibc、libcurl、OpenSSL): +想要一个**拷到哪都能跑**的可执行文件 (不依赖宿主机的 glibc、libcurl、OpenSSL): ```bash -./tools/build-static-deps.sh # 造一份 musl 编的静态 libcurl(要联网、几分钟) +./tools/build-static-deps.sh # 造一份 musl 编的静态 libcurl (要联网、几分钟) make static # 产物:ligent-static file ligent-static # → ELF 64-bit ... statically linked ``` -**为什么不能直接拿发行版的 `libcurl.a` 链接。** 那是针对 glibc 编的,和 musl -混在一起会得到一堆 `undefined reference to __libc_start_main`。必须有一份用 +**为什么不能直接拿发行版的 `libcurl.a` 链接. ** 那是针对 glibc 编的, 和 musl +混在一起会得到一堆 `undefined reference to __libc_start_main`. 必须有一份用 musl 工具链编出来的静态 libcurl —— 这就是 `tools/build-static-deps.sh` 干的事: -用 musl 把 zlib、OpenSSL、curl 依次编到 `build/static-prefix/`,最后**真的链一个 -最小程序**自检(静态链接最常栽在依赖顺序和缺库上,而那只在链接时暴露)。 +用 musl 把 zlib、OpenSSL、curl 依次编到 `build/static-prefix/`, 最后**真的链一个 +最小程序**自检 (静态链接最常栽在依赖顺序和缺库上, 而那只在链接时暴露). -产物是 `ligent-static`,和常规构建**互不干扰**:静态目标用自己的对象目录 -(`build/static/`),因为那边的 `.o` 是 musl 编的,混在一起会互相覆盖。 +产物是 `ligent-static`, 和常规构建**互不干扰**:静态目标用自己的对象目录 + (`build/static/`), 因为那边的 `.o` 是 musl 编的, 混在一起会互相覆盖. | 变量 | 默认 | 说明 | |---|---|---| | `STATIC_CC` | 依次找 `musl-gcc` / `x86_64-linux-musl-gcc` | 没有就报错并告诉你装什么 | | `STATIC_PREFIX` | `build/static-prefix` | 静态依赖所在的前缀 | -| `STATIC_RELEASE` | `1` | 体积优先(`-Os` + strip + `--gc-sections`…)。设 0 回到能调试的版本 | +| `STATIC_RELEASE` | `1` | 体积优先 (`-Os` + strip + `--gc-sections`…). 设 0 回到能调试的版本 | ### 把静态产物压到最小 -静态产物是"拷过去就能跑"的发布件,不是拿来调试的,所以它的取舍和常规构建相反。 -**6.24 MB → 3.29 MB(-47%,几乎腰斩)**,分两半,缺一半效果就大打折扣: +静态产物是"拷过去就能跑"的发布件, 不是拿来调试的, 所以它的取舍和常规构建相反. +**6.24 MB → 3.29 MB (-47%, 几乎腰斩)**, 分两半, 缺一半效果就大打折扣: -**A. 链接我们自己的东西(Makefile,`STATIC_RELEASE=1`)** +**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 掉的调试信息 | +| `-fno-asynchronous-unwind-tables -fno-unwind-tables` | `.eh_frame` 本来 514 KB, C 代码用不上 | +| `-Os` / `-g0` | 优先体积; 不生成反正要 strip 掉的调试信息 | | `-z noseparate-code`、`--build-id=none` | 页对齐空洞和 note 段 | -**B. 链接依赖库(`tools/build-static-deps.sh`)** +**B. 链接依赖库 (`tools/build-static-deps.sh`)** -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** —— -静态归档里本来就有一大堆我们永远走不到的算法和协议实现。 +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 ``` -脚本会把用过的选项记在 `$PREFIX/.build-flags` 里,**选项变了会自动重建**。 -没有这一层的话,下面那些 `[ ! -f libz.a ]` 守卫会看到库已存在就跳过,你会一直用着 -旧选项编出来的依赖,症状是"我明明加了 `-Os`,产物怎么一点没小" —— 和 Makefile 里 -对象目录按配置分标签是同一个坑的两处。 +脚本会把用过的选项记在 `$PREFIX/.build-flags` 里, **选项变了会自动重建**. +没有这一层的话, 下面那些 `[ ! -f libz.a ]` 守卫会看到库已存在就跳过, 你会一直用着 +旧选项编出来的依赖, 症状是"我明明加了 `-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 打静态产物(而不是只看它能不能启动) +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`,从不发请求,验不到这一层)。 +都通了. 这也是静态二进制的真实 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`。 +`/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` - 会报 `relocation R_X86_64_32 ... can not be used when making a PIE object`。 -- **不用 nghttp2**:静态构建只留 ligent 真正要的东西,于是它说 HTTP/1.1 —— - 服务端都支持,我们也不需要多路复用。 -- **CA 路径编进去**(`/etc/ssl/certs`):静态二进制跑到别的机器上,得知道去哪 - 找根证书。 +- **依赖列表交给 `curl-config --static-libs`**, 不手写 `-lssl -lcrypto -lz`. + curl 最清楚自己链了哪些库、什么顺序; 手写的那串会过期, 顺序错了还链不上. +- **`-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 —— + 服务端都支持, 我们也不需要多路复用. +- **CA 路径编进去** (`/etc/ssl/certs`):静态二进制跑到别的机器上, 得知道去哪 + 找根证书. -### 在容器里构建(CI/CD) +### 在容器里构建 (CI/CD) ```bash make docker-build # podman build -t ligent-static . @@ -273,15 +273,15 @@ make docker-extract # 产物拷到 ./ligent-static make docker-test # 拿同一套 318 项 e2e 打容器产物 ``` -`make docker-*` 默认用 **podman**(本项目的开发环境是 rootless),换 docker -加 `CONTAINER=docker` 即可 —— 两者的 `build`/`run`/`cp` 参数在这里是一样的。 +`make docker-*` 默认用 **podman** (本项目的开发环境是 rootless), 换 docker +加 `CONTAINER=docker` 即可 —— 两者的 `build`/`run`/`cp` 参数在这里是一样的. -**为什么用 Alpine 做构建环境。** 静态目标要一份用 musl 编出来的 libcurl, +**为什么用 Alpine 做构建环境. ** 静态目标要一份用 musl 编出来的 libcurl, 而 Alpine 本身就是 musl 发行版:`apk add build-base` 得到的 gcc 直接就是 musl -目标(`--target=x86_64-alpine-linux-musl`),`tools/build-static-deps.sh` 里 -那套"找 musl-gcc、配 spec 文件"的麻烦在容器里根本不存在。 +目标 (`--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 @@ -289,13 +289,13 @@ etc/ssl/certs/ca-certificates.crt ligent ``` -用 `scratch` 做运行阶段 —— 静态链接的产物连 libc 都不需要,所以镜像里没有 -shell、没有包管理器、没有任何可被利用的东西。CA 证书放在 +用 `scratch` 做运行阶段 —— 静态链接的产物连 libc 都不需要, 所以镜像里没有 +shell、没有包管理器、没有任何可被利用的东西. CA 证书放在 `/etc/ssl/certs/ca-certificates.crt` 是**必须**的:libcurl 用的是编进去的这个 -路径(见上一条),放别处它读不到。 +路径 (见上一条), 放别处它读不到. -版本号、分支、哈希都由 build-arg 注入 —— 容器里没有 `.git`(`.dockerignore` -把它排除了),脚本在里面**问不出**这些,只能由外面算好传进去: +版本号、分支、哈希都由 build-arg 注入 —— 容器里没有 `.git` (`.dockerignore` +把它排除了), 脚本在里面**问不出**这些, 只能由外面算好传进去: ```bash podman build \ @@ -305,33 +305,33 @@ podman build \ --build-arg BUILD_EPOCH=$(date -u +%s) . ``` -`make docker-build` 会自动在本机算好传进去,不用手写。 +`make docker-build` 会自动在本机算好传进去, 不用手写. 两个容易踩的点: -- **`BUILD_EPOCH` 必须传完整的秒数,不能只传日期。** 它被当成 - `SOURCE_DATE_EPOCH`,而 `date(1)` 把 `2026-09-22` 解析成那天 **00:00:00 - UTC** —— 于是容器产物的构建时间永远显示午夜。传秒数没有这个歧义,而且仍然 - 可复现(设了 `SOURCE_DATE_EPOCH` 时优先沿用它)。 -- **分支也要传。** 不传的话二进制里会是 `unknown`,而那**不是事实** —— - 源码来自哪个分支是外面知道的,只是容器里看不到而已。 +- **`BUILD_EPOCH` 必须传完整的秒数, 不能只传日期. ** 它被当成 + `SOURCE_DATE_EPOCH`, 而 `date(1)` 把 `2026-09-22` 解析成那天 **00:00:00 + UTC** —— 于是容器产物的构建时间永远显示午夜. 传秒数没有这个歧义, 而且仍然 + 可复现 (设了 `SOURCE_DATE_EPOCH` 时优先沿用它). +- **分支也要传. ** 不传的话二进制里会是 `unknown`, 而那**不是事实** —— + 源码来自哪个分支是外面知道的, 只是容器里看不到而已. Dockerfile 里有自检:产物必须存在且可执行、必须真的是静态的、传了 VERSION 就 -绝不能报回退版本号,否则构建失败。这几条都是为了挡住"链接器没报错 ≠ 产物可用"。 +绝不能报回退版本号, 否则构建失败. 这几条都是为了挡住"链接器没报错 ≠ 产物可用". -**实测**(同一份源码,本机 + 容器): +**实测** (同一份源码, 本机 + 容器): ```console -$ ./ligent --version # 本机,Artix + gcc 16.2.1 +$ ./ligent --version # 本机, Artix + gcc 16.2.1 ligent 2026.09.22-209538e.dirty (master, 2026-09-22 04:39:59) [GCC 16.2.1 20260810] on linux/x86_64 -$ podman run --rm ligent-static --version # 容器,Alpine 3.20 + gcc 13.2.1 +$ 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`)。 +两个产物的行为可能因编译器版本不同而有细微差异, 排查时得知道是哪个编的. +容器产物同样过了**全套 318 项 e2e** (`make docker-test`). ### 版本号:CalVer + git 哈希 @@ -341,14 +341,14 @@ ligent 2026.09.22-209538e.dirty (master, 2026-09-22 04:44:33) [GCC 16.2.1 202608 ``` ``` │ │ │ │ │ │ - │ │ │ └ 构建时间(UTC) └ 编译器 └ 目标平台 - │ │ └ 分支(detached HEAD 时为 unknown) - │ └ git 短哈希,.dirty 表示工作区有未提交改动 + │ │ │ └ 构建时间 (UTC) └ 编译器 └ 目标平台 + │ │ └ 分支 (detached HEAD 时为 unknown) + │ └ git 短哈希, .dirty 表示工作区有未提交改动 └ CalVer:发布日 ``` -前两段(`2026.09.22-209538e.dirty`)是**版本号主体**,也是下面几节引用它时 -用的形式。后面几段是"这份二进制从哪来的",排查时要看的就是它们。 +前两段 (`2026.09.22-209538e.dirty`)是**版本号主体**, 也是下面几节引用它时 +用的形式. 后面几段是"这份二进制从哪来的", 排查时要看的就是它们. | 形态 | 什么时候出现 | |---|---| @@ -356,117 +356,117 @@ 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,不存在"区分是 -哪种 SCM"的需求(那正是 `g` 前缀存在的理由),而少两个符号念起来也顺。 +分隔符用 `-` 而不是 `git describe` 传统的 `+g`:我们只用 git, 不存在"区分是 +哪种 SCM"的需求 (那正是 `g` 前缀存在的理由), 而少两个符号念起来也顺. -**编译器与平台由编译期宏判断,不由构建脚本传。** 这是有意的:那份信息是 -"这份代码被什么编出来、给谁跑的",编译器自己最清楚。构建脚本去猜反而多一个 -出错点,而且**交叉编译时会猜错** —— `uname()` 说的是"现在跑在哪",`__linux__` -说的是"编给谁跑",静态产物会被拷到别的机器上跑,该记的是后者。 +**编译器与平台由编译期宏判断, 不由构建脚本传. ** 这是有意的:那份信息是 +"这份代码被什么编出来、给谁跑的", 编译器自己最清楚. 构建脚本去猜反而多一个 +出错点, 而且**交叉编译时会猜错** —— `uname()` 说的是"现在跑在哪", `__linux__` +说的是"编给谁跑", 静态产物会被拷到别的机器上跑, 该记的是后者. -三个编译器各有一条腿,因为它们的宏并不统一: +三个编译器各有一条腿, 因为它们的宏并不统一: | 编译器 | 取法 | |---|---| | gcc | `__VERSION__` → `16.2.1 20260810` | -| clang | `__clang_version__`,**要掐掉尾随空格**(它自带一个) | -| tcc | 没有 `__VERSION__`,只有整数 `__TINYC__`,按"去掉小数点当十进制读"还原 | +| clang | `__clang_version__`, **要掐掉尾随空格** (它自带一个) | +| tcc | 没有 `__VERSION__`, 只有整数 `__TINYC__`, 按"去掉小数点当十进制读"还原 | -判断顺序也有讲究:**clang 也定义 `__GNUC__`**(它假装自己是 gcc 4.2),所以 -clang 必须排在 gcc 前面,否则会被认成 GCC。 +判断顺序也有讲究:**clang 也定义 `__GNUC__`** (它假装自己是 gcc 4.2), 所以 +clang 必须排在 gcc 前面, 否则会被认成 GCC. -(这里踩过两个坑,都是实测三个编译器才发现的:tcc 的版本号按 `/100` 算会得到 -`tcc 9.2.8`,正确是 `/1000` → `0.9.28`;clang 的版本串带尾随空格,会输出成 -`Clang 22.1.8 ]`。单测 gcc 永远发现不了这两个。) + (这里踩过两个坑, 都是实测三个编译器才发现的:tcc 的版本号按 `/100` 算会得到 +`tcc 9.2.8`, 正确是 `/1000` → `0.9.28`; clang 的版本串带尾随空格, 会输出成 +`Clang 22.1.8 ]`. 单测 gcc 永远发现不了这两个. ) -**为什么 CalVer 而不是 SemVer。** ligent 是个终端工具,不是被别人链接的库 —— -"第几个大版本"这种信息对它没什么用,"这是哪天的东西"才有用。用户看到 -`2026.02.14` 立刻知道新旧,看到 `1.0.0` 还得去查。 +**为什么 CalVer 而不是 SemVer. ** ligent 是个终端工具, 不是被别人链接的库 —— +"第几个大版本"这种信息对它没什么用, "这是哪天的东西"才有用. 用户看到 +`2026.02.14` 立刻知道新旧, 看到 `1.0.0` 还得去查. -**为什么还要带哈希。** CalVer 的粒度是"天",同一天可能发好几次;而且用户手上 -的二进制未必来自 tag(可能自己改了源码)。带上哈希,报 bug 时能精确定位。 -`.dirty` 标记尤其重要:不带它的话,改过源码的构建和干净构建会报**同样的** -版本号,排查时会被完全误导。未跟踪的新文件也算脏 —— 它可能正是被编进去的那个 -源文件。 +**为什么还要带哈希. ** CalVer 的粒度是"天", 同一天可能发好几次; 而且用户手上 +的二进制未必来自 tag (可能自己改了源码). 带上哈希, 报 bug 时能精确定位. +`.dirty` 标记尤其重要:不带它的话, 改过源码的构建和干净构建会报**同样的** +版本号, 排查时会被完全误导. 未跟踪的新文件也算脏 —— 它可能正是被编进去的那个 +源文件. -**版本号不写死在源码里。** 构建期用 `tools/get-version.sh` 算出真值,Makefile -用 `-D` 注入;`src/version.h` 里只留一个回退默认值,给"没有构建系统"的场景 -(比如有人直接拿 gcc 编 `src` 下的所有 `.c`)。这样从 git 编和从 tarball 编 -不会出现版本号不一致,也不必为发版多做一个改源码的提交。这是 dunst / curl -这类项目的通行做法。 +**版本号不写死在源码里. ** 构建期用 `tools/get-version.sh` 算出真值, Makefile +用 `-D` 注入; `src/version.h` 里只留一个回退默认值, 给"没有构建系统"的场景 + (比如有人直接拿 gcc 编 `src` 下的所有 `.c`). 这样从 git 编和从 tarball 编 +不会出现版本号不一致, 也不必为发版多做一个改源码的提交. 这是 dunst / curl +这类项目的通行做法. -取值的优先级(`tools/get-version.sh` 里有完整注释): +取值的优先级 (`tools/get-version.sh` 里有完整注释): | 场景 | 结果 | |---|---| -| 环境变量 `LIGENT_VERSION_OVERRIDE` | 直接用它(CI 用,tag 打错时能救) | +| 环境变量 `LIGENT_VERSION_OVERRIDE` | 直接用它 (CI 用, tag 打错时能救) | | git 里有 `v2026.02.14` 形态的 tag | 取最新的那个 | -| 没有 tag(tarball) | 回退到源码文件的日期 | -| 没有 `sh` 或全失败 | `0.0.0-unknown`,**绝不让构建挂掉** | +| 没有 tag (tarball) | 回退到源码文件的日期 | +| 没有 `sh` 或全失败 | `0.0.0-unknown`, **绝不让构建挂掉** | -`SOURCE_DATE_EPOCH` 也认(可复现构建的标准约定):设了它,同一份源码在不同 -时间编出来的二进制是逐字节相同的,CI 上才能校验产物哈希。 +`SOURCE_DATE_EPOCH` 也认 (可复现构建的标准约定):设了它, 同一份源码在不同 +时间编出来的二进制是逐字节相同的, CI 上才能校验产物哈希. -版本号只在 `--version` 和 `ligent doctor` 里出现,不参与任何协议交互。 -`doctor` 把它放在最上面 —— 报 bug 时第一件要问的就是"你用的是哪个版本"。 +版本号只在 `--version` 和 `ligent doctor` 里出现, 不参与任何协议交互. +`doctor` 把它放在最上面 —— 报 bug 时第一件要问的就是"你用的是哪个版本". -**实测过了**(Artix,musl-gcc / gcc 16.2.1,zlib 1.3.1 + OpenSSL 3.0.15 + -curl 8.11.1): +**实测过了** (Artix, musl-gcc / gcc 16.2.1, zlib 1.3.1 + OpenSSL 3.0.15 + +curl 8.11.1): ``` ligent-static: ELF 64-bit LSB executable, x86-64, statically linked ldd ligent-static → 不是动态可执行文件 -体积 3.29 MB(压缩前 6.24 MB,见上一节);318 项 e2e 全过; -TLS 真握手验过(本地 s_server,见上一节) +体积 3.29 MB (压缩前 6.24 MB, 见上一节); 318 项 e2e 全过; +TLS 真握手验过 (本地 s_server, 见上一节) ``` -一路上真正会绊人的坑(脚本里都处理了,记下来免得重踩): +一路上真正会绊人的坑 (脚本里都处理了, 记下来免得重踩): -1. **`musl-gcc` 不搜 `/usr/include`。** 于是 OpenSSL 挂在内核 UAPI 头上: - `fatal error: linux/mman.h: No such file or directory`。 - 要用 `-idirafter /usr/include` —— musl 自己的头优先、系统头兜底。 - 写成 `-I` 就反了,glibc 的头会盖过 musl 的。 -2. **同一个原因,编译 ligent 自己时要 `-I$(STATIC_PREFIX)/include`。** - 系统那份 `curl/curl.h` 不是给 musl 用的。 -3. **依赖别加 `-fno-pie`。** 这些项目在 `make` 里会顺带链几个测试程序,而链接 - 用的是默认的 PIE,于是 - `relocation R_X86_64_32 against ... can not be used when making a PIE object`; - 更阴的是 zlib 的 configure 探测 `vsnprintf` 也会因此失败,**悄悄退化成不安全 - 的 vsprintf 版本**。PIE 与否由 ligent 最后那一步用 `-no-pie` 决定就够。 -4. **OpenSSL 3 在 x86_64 默认装 `lib64`**,和"守卫查 lib、curl 找 lib"对不上, - 表现为 curl 报 `--with-openssl was given but OpenSSL could not be detected`。 - 统一用 `--libdir=lib`。 -5. **下载地址会过期。** zlib 把旧版本挪到了 `/fossils/`。脚本对每个包试多个 - 地址(官网 / fossils / GitHub release),全失败才退出并告诉你怎么改版本。 -6. **路径里有非 ASCII 字符时,`pkg-config` 会吐出非法 UTF-8。** - 这是最阴的一个:只在"仓库路径带中文"这类情况下才出现,而症状离原因非常远。 +1. **`musl-gcc` 不搜 `/usr/include`. ** 于是 OpenSSL 挂在内核 UAPI 头上: + `fatal error: linux/mman.h: No such file or directory`. + 要用 `-idirafter /usr/include` —— musl 自己的头优先、系统头兜底. + 写成 `-I` 就反了, glibc 的头会盖过 musl 的. +2. **同一个原因, 编译 ligent 自己时要 `-I$(STATIC_PREFIX)/include`. ** + 系统那份 `curl/curl.h` 不是给 musl 用的. +3. **依赖别加 `-fno-pie`. ** 这些项目在 `make` 里会顺带链几个测试程序, 而链接 + 用的是默认的 PIE, 于是 + `relocation R_X86_64_32 against ... can not be used when making a PIE object`; + 更阴的是 zlib 的 configure 探测 `vsnprintf` 也会因此失败, **悄悄退化成不安全 + 的 vsprintf 版本**. PIE 与否由 ligent 最后那一步用 `-no-pie` 决定就够. +4. **OpenSSL 3 在 x86_64 默认装 `lib64`**, 和"守卫查 lib、curl 找 lib"对不上, + 表现为 curl 报 `--with-openssl was given but OpenSSL could not be detected`. + 统一用 `--libdir=lib`. +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 字节前面加一个反斜杠: - `-L/…/开发/…` → `-L/…/\开\发/…`。反斜杠后面跟 `E5` 不是合法续字节, - 所以那串是**非法 UTF-8**; - - curl 把它写进 `curl-config`、`libcurl.pc` 和一堆 Makefile(实测 26 个文件)。 + - 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 字节前面加一个反斜杠: + `-L/…/开发/…` → `-L/…/\开\发/…`. 反斜杠后面跟 `E5` 不是合法续字节, + 所以那串是**非法 UTF-8**; + - curl 把它写进 `curl-config`、`libcurl.pc` 和一堆 Makefile (实测 26 个文件). 后果不只是难看:`make static` 的 recipe 里那句 `echo " 依赖: $LIBS"` 会把 `curl-config --static-libs` 的输出原样打出来 → 进工具输出 → 进对话历史 → - 进请求体 → 服务端以 `invalid unicode code point` **拒掉整个请求**。而且坏 - 数据已经留在历史里,之后每一次请求都失败,只能丢掉整轮对话。 + 进请求体 → 服务端以 `invalid unicode code point` **拒掉整个请求**. 而且坏 + 数据已经留在历史里, 之后每一次请求都失败, 只能丢掉整轮对话. - 修法是**在 configure 期间把 `.pc` 藏起来**(藏不是删,编完放回去):curl - 看不见 `openssl.pc` 就退回"直接用 `-L$prefix/lib`"那条路,路径干干净净。 - 脚本末尾还有一道 `iconv` 往返自检,curl-config 输出不是合法 UTF-8 就直接退出。 + 修法是**在 configure 期间把 `.pc` 藏起来** (藏不是删, 编完放回去):curl + 看不见 `openssl.pc` 就退回"直接用 `-L$prefix/lib`"那条路, 路径干干净净. + 脚本末尾还有一道 `iconv` 往返自检, curl-config 输出不是合法 UTF-8 就直接退出. - (这条不需要你做什么,脚本会处理。但如果你在别处手写 curl/OpenSSL 的构建、 - 又碰巧路径带中文,记住别让它走 pkg-config。) + (这条不需要你做什么, 脚本会处理. 但如果你在别处手写 curl/OpenSSL 的构建、 + 又碰巧路径带中文, 记住别让它走 pkg-config. ) ## 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 @@ -475,11 +475,11 @@ ligent "你好" ### 配置文件:只有一个位置 —— 二进制旁边 -配置**只**从可执行文件所在目录读取,文件名固定 `ligent.json`: +配置**只**从可执行文件所在目录读取, 文件名固定 `ligent.json`: ``` /path/to/ligent ← 可执行文件 -/path/to/ligent.json ← 配置(不存在就用内置默认值) +/path/to/ligent.json ← 配置 (不存在就用内置默认值) ``` ```bash @@ -487,34 +487,34 @@ cp ligent.example.json ./ligent.json # 二进制就在当前目录时 $EDITOR ./ligent.json ``` -**不做用户级查找**(没有 `~/.config`、没有 `$XDG_CONFIG_HOME`、没有 `~/.ligent.json`), -**不做系统级查找**(没有 `/etc`),**也不看当前工作目录**。 +**不做用户级查找** (没有 `~/.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` -调用(`argv[0]` 里没有斜杠的情况)。 +那么配置目录是 `~/bin`, 而不是 `/opt/ligent` —— 你放在哪、就认哪. +四种调用方式结果一致:绝对路径、相对路径、链接链 (取第一层)、以及经 `$PATH` +调用 (`argv[0]` 里没有斜杠的情况). -实现上优先用 `argv[0]`(不解析最后一段软链接),只在拿不到时才退回 -`/proc/self/exe`。目录部分会被规范化(`.` / `..` / 目录自身的软链接都会解析), -但**最后一段不会** —— 那正是"软链接所在目录"的关键。另外会用 inode 比对确认 -找到的确实是 ligent 自己,避免 `$PATH` 里恰好有个同名文件把目录带偏。 +实现上优先用 `argv[0]` (不解析最后一段软链接), 只在拿不到时才退回 +`/proc/self/exe`. 目录部分会被规范化 (`.` / `..` / 目录自身的软链接都会解析), +但**最后一段不会** —— 那正是"软链接所在目录"的关键. 另外会用 inode 比对确认 +找到的确实是 ligent 自己, 避免 `$PATH` 里恰好有个同名文件把目录带偏. ```bash $ ./ligent --print-config | grep config_file -config_file : /home/you/ligent/ligent.json (不存在,使用内置默认值) +config_file : /home/you/ligent/ligent.json (不存在, 使用内置默认值) ``` -`--print-config` 会显示规范位置,所以不用猜它在哪找。 +`--print-config` 会显示规范位置, 所以不用猜它在哪找. -想临时换一份配置时有两个显式入口(都不是"默认查找位置"): +想临时换一份配置时有两个显式入口 (都不是"默认查找位置"): ```bash ligent -c /path/to/other.json "..." # 命令行显式指定 @@ -522,10 +522,10 @@ LIGENT_CONFIG=/path/to/other.json ligent "..." ``` 整体优先级:**内置默认值 → 配置文件 → 环境变量 → 命令行参数** -(`api_key` 按上面的规则细化)。 + (`api_key` 按上面的规则细化). -配置文件里可以用 `${VAR}` 引用环境变量,避免把密钥明文写进磁盘: +配置文件里可以用 `${VAR}` 引用环境变量, 避免把密钥明文写进磁盘: ```json { @@ -535,11 +535,11 @@ LIGENT_CONFIG=/path/to/other.json ligent "..." } ``` -想接别家服务,只改 `base_url` 和 `model` 即可,例如: +想接别家服务, 只改 `base_url` 和 `model` 即可, 例如: | 服务 | base_url | model 示例 | |---|---|---| -| DeepSeek(默认) | `https://api.deepseek.com/v1` | `deepseek-flash` / `deepseek-chat` / `deepseek-reasoner` | +| DeepSeek (默认) | `https://api.deepseek.com/v1` | `deepseek-flash` / `deepseek-chat` / `deepseek-reasoner` | | OpenAI | `https://api.openai.com/v1` | `gpt-4o` | | 本地 Ollama | `http://localhost:11434/v1` | `qwen2.5:14b` | | 本地 vLLM | `http://localhost:8000/v1` | 你部署的模型名 | @@ -547,12 +547,12 @@ LIGENT_CONFIG=/path/to/other.json ligent "..." 其他可选环境变量:`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`。 +`LIGENT_STREAM`、`LIGENT_TOOLS`. ### 思考模式与级别 -推理模型可以先"想"一段再回答。DeepSeek 的思考模式**默认就是开着的,默认级别 `high`**, -所以什么都不配也能用;想控制就加这两项: +推理模型可以先"想"一段再回答. DeepSeek 的思考模式**默认就是开着的, 默认级别 `high`**, +所以什么都不配也能用; 想控制就加这两项: ```json { @@ -561,15 +561,15 @@ LIGENT_CONFIG=/path/to/other.json ligent "..." } ``` -- `thinking`:`"on"` / `"off"` / `"auto"`,也接受 `true` / `false`。 - `auto`(默认)表示**请求里根本不带这个字段**,完全由服务端决定。 - 开启时下发 `{"thinking":{"type":"enabled"}}`,关闭则是 `disabled`。 -- `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"`, 也接受 `true` / `false`. + `auto` (默认)表示**请求里根本不带这个字段**, 完全由服务端决定. + 开启时下发 `{"thinking":{"type":"enabled"}}`, 关闭则是 `disabled`. +- `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`、 -`LIGENT_THINKING`、`LIGENT_REASONING_EFFORT`,以及 REPL 里的 +`LIGENT_THINKING`、`LIGENT_REASONING_EFFORT`, 以及 REPL 里的 ``` /think 查看当前设置 @@ -577,87 +577,87 @@ LIGENT_CONFIG=/path/to/other.json ligent "..." /think high 设置级别 ``` -**思考过程不会显示,也不会进上下文**(只累积、不回调)。但它必须跟着 assistant -消息一起回传:DeepSeek 要求**请求带 `tools` 时把历史里的 `reasoning_content` 全带上**, -漏了下一轮直接回 400。ligent 默认开着工具,所以这一条是硬要求 —— 流式和非流式 -两条解析路径都会保存它。 +**思考过程不会显示, 也不会进上下文** (只累积、不回调). 但它必须跟着 assistant +消息一起回传:DeepSeek 要求**请求带 `tools` 时把历史里的 `reasoning_content` 全带上**, +漏了下一轮直接回 400. ligent 默认开着工具, 所以这一条是硬要求 —— 流式和非流式 +两条解析路径都会保存它. -另外,思考模式下 `temperature` 是**不起作用**的(服务端会忽略),`top_p` 会被抬到 -≥ 0.95。`ligent doctor` 会在你同时设了 `thinking: on` 和 `temperature` 时提醒这一点。 +另外, 思考模式下 `temperature` 是**不起作用**的 (服务端会忽略), `top_p` 会被抬到 +≥ 0.95. `ligent doctor` 会在你同时设了 `thinking: on` 和 `temperature` 时提醒这一点. ### 取密钥的优先级 ``` -LIGENT_API_KEY ← 显式覆盖,任何时候都最高 +LIGENT_API_KEY ← 显式覆盖, 任何时候都最高 ligent.json 的 api_key ← 你写死在配置里的 -DEEPSEEK_API_KEY / OPENAI_API_KEY ← 兜底,且只在端点确实是那一家时才读 +DEEPSEEK_API_KEY / OPENAI_API_KEY ← 兜底, 且只在端点确实是那一家时才读 ``` -最后一条是刻意的:**厂商专属的环境变量只在"确实在用这家"时才生效**。 +最后一条是刻意的:**厂商专属的环境变量只在"确实在用这家"时才生效**. -否则会出这种事:配置文件里写的是 OpenRouter 的 key,而 shell 里残留的 -`DEEPSEEK_API_KEY` 把它悄悄顶掉 —— 结果 A 家的密钥被发给了 B 家, -对方回一个含糊的 401,你明明配对了却怎么都调不通,排查方向还会被带偏。 -(OpenRouter 对格式不对的 key 回的是 `Missing Authentication header`, -听起来像"请求里没带认证头",实际上带了,只是钥匙不是这家的。) +否则会出这种事:配置文件里写的是 OpenRouter 的 key, 而 shell 里残留的 +`DEEPSEEK_API_KEY` 把它悄悄顶掉 —— 结果 A 家的密钥被发给了 B 家, +对方回一个含糊的 401, 你明明配对了却怎么都调不通, 排查方向还会被带偏. + (OpenRouter 对格式不对的 key 回的是 `Missing Authentication header`, +听起来像"请求里没带认证头", 实际上带了, 只是钥匙不是这家的. ) -所以 `base_url` 指向 OpenRouter / 本地 Ollama / 自建网关时, -ligent **不会**去猜 `DEEPSEEK_API_KEY`,而是明确告诉你"未配置 API key"。 +所以 `base_url` 指向 OpenRouter / 本地 Ollama / 自建网关时, +ligent **不会**去猜 `DEEPSEEK_API_KEY`, 而是明确告诉你"未配置 API key". -`OPENAI_BASE_URL` 同理,只在 `base_url` 还是内置默认值时才兜底。 +`OPENAI_BASE_URL` 同理, 只在 `base_url` 还是内置默认值时才兜底. -想临时切换服务商,用 `LIGENT_API_KEY` 或 `-k`。 +想临时切换服务商, 用 `LIGENT_API_KEY` 或 `-k`. ### 界面语言 -界面文案(帮助、错误、诊断、确认提示、等待动画)有中英两份。**发给模型的 -那些不在这里**:系统提示词、工具描述、工具回执只有中文 —— 那是模型读的信息, -跟界面语言是两件事(见 §9)。 +界面文案 (帮助、错误、诊断、确认提示、等待动画)有中英两份. **发给模型的 +那些不在这里**:系统提示词、工具描述、工具回执只有中文 —— 那是模型读的信息, +跟界面语言是两件事 (见 §9). -优先级:`--lang` > `$LIGENT_LANG` > `LC_ALL` > `LC_MESSAGES` > `LANG`。 -都没设、或者认不出来(`fr_FR` 这类),按英文处理 —— `LANG=C` 也是英文, -这跟 POSIX 惯例一致。 +优先级:`--lang` > `$LIGENT_LANG` > `LC_ALL` > `LC_MESSAGES` > `LANG`. +都没设、或者认不出来 (`fr_FR` 这类), 按英文处理 —— `LANG=C` 也是英文, +这跟 POSIX 惯例一致. ```bash ligent --lang zh "……" # 这一次用中文 export LIGENT_LANG=en # 这个 shell 里都用英文 ``` -译文在 `src/i18n.c` 的一张表里,键是点分 ID(`cli.err.unknown_option` 这种), -两种语言各写一份。少一条、或者占位符对不上(`{path}` 写成 `{paths}`), -`tests/test_i18n.c` 都会逐条报出来 —— 漏翻这件事必须由机器发现, -不能靠人眼扫。`--print-config` 会打出当前生效的 `language`。 +译文在 `src/i18n.c` 的一张表里, 键是点分 ID (`cli.err.unknown_option` 这种), +两种语言各写一份. 少一条、或者占位符对不上 (`{path}` 写成 `{paths}`), +`tests/test_i18n.c` 都会逐条报出来 —— 漏翻这件事必须由机器发现, +不能靠人眼扫. `--print-config` 会打出当前生效的 `language`. -哪些**故意不译**(都不在这里,也不是漏翻): +哪些**故意不译** (都不在这里, 也不是漏翻): -- **给模型看的**:系统提示词、工具描述、工具回执,以及插进对话历史的说明 - (`/include` 的文本块头、图片降级说明、`/shell` 的操作记录)。切成英文之后 - 模型读到的仍然是中文 —— 想换就得改提示词本身。 +- **给模型看的**:系统提示词、工具描述、工具回执, 以及插进对话历史的说明 + (`/include` 的文本块头、图片降级说明、`/shell` 的操作记录). 切成英文之后 + 模型读到的仍然是中文 —— 想换就得改提示词本身. - **机器读的格式**:`/log` 导出里 `请求:` / `----- 响应头 -----` 这些标记 - (`tests/replay.py` 按它们切分往返)、`<已隐藏>` 这种脱敏占位符、 - `cap_name()` 这类只给日志和断言用的状态常量。 + (`tests/replay.py` 按它们切分往返)、`<已隐藏>` 这种脱敏占位符、 + `cap_name()` 这类只给日志和断言用的状态常量. -`/tools` 列出来的那张表**不是**这一类:工具名后面那行是纯 UI,文案在表里 -(`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()` —— 报错固定中文。解析**工具参数**用它:那条结论会被原样回填给 - 模型,属于"给模型看的信息",不随界面语言变(`agent.c` 只在这一处用)。 +- `json_parse_ui()` —— 报错跟随界面语言. 配置、存档、服务端响应这些给人看的 + 路径用它, 用户看到的是 `line 1, column 3: expected an object key (a string)`. +- `json_parse()` —— 报错固定中文. 解析**工具参数**用它:那条结论会被原样回填给 + 模型, 属于"给模型看的信息", 不随界面语言变 (`agent.c` 只在这一处用). -两种语气共用一份文案表(`json.err.*`),只是各取一列。这几条是 printf 模板而 -不是 `{name}`,所以 `tests/test_i18n.c` 另有一遍机械比对:两种语言的转换符 -数量与类型必须逐字一致 —— `%zu` 写成 `%d` 会让 `printf` 读错参数。 +两种语气共用一份文案表 (`json.err.*`), 只是各取一列. 这几条是 printf 模板而 +不是 `{name}`, 所以 `tests/test_i18n.c` 另有一遍机械比对:两种语言的转换符 +数量与类型必须逐字一致 —— `%zu` 写成 `%d` 会让 `printf` 读错参数. ## 3. 用法 ```bash -ligent "统计当前目录下 C 代码的总行数,并找出最大的三个文件" +ligent "统计当前目录下 C 代码的总行数, 并找出最大的三个文件" ligent # 进入交互式 REPL echo "解释一下 Makefile" | ligent # 从管道读取任务 -ligent --print-config # 查看最终生效的配置(key 已打码) +ligent --print-config # 查看最终生效的配置 (key 已打码) ``` ### 命令行选项 @@ -667,22 +667,22 @@ ligent --print-config # 查看最终生效的配置(key 已打码) -u, --base-url URL API 基地址 -k, --api-key KEY API key -c, --config PATH 指定配置文件 - --history FILE 指定输入历史文件(默认 <二进制目录>/ligent.history) + --history FILE 指定输入历史文件 (默认 <二进制目录>/ligent.history) --no-history 不读写输入历史 -s, --system TEXT 覆盖 system 提示词 -t, --temperature N 采样温度 --max-tokens N 单次回复最大 token 数 - --thinking MODE 思考模式:on / off / auto(默认 auto = 由服务端决定) + --thinking MODE 思考模式:on / off / auto (默认 auto = 由服务端决定) --effort LEVEL 思考级别:minimal/low/medium/high/xhigh/max/ultra - --max-steps N 单轮任务里模型最多往返几次(默认不限,0 = 不限) + --max-steps N 单轮任务里模型最多往返几次 (默认不限, 0 = 不限) --timeout N 请求超时秒数 --no-stream 关闭流式输出 - --no-tools 禁用工具,纯对话 - --no-probe 首个请求前不探测模型能力(不额外发请求) - --lang LANG 界面语言:auto / en / zh(默认 auto) + --no-tools 禁用工具, 纯对话 + --no-probe 首个请求前不探测模型能力 (不额外发请求) + --lang LANG 界面语言:auto / en / zh (默认 auto) -q, --quiet 不打印工具的输出内容 -v, --verbose 打印 HTTP 细节 - --color/--no-color 强制开关彩色输出(同时控制 Markdown 渲染) + --color/--no-color 强制开关彩色输出 (同时控制 Markdown 渲染) --print-config 打印生效配置后退出 -h, --help / -V, --version ``` @@ -693,44 +693,44 @@ ligent --print-config # 查看最终生效的配置(key 已打码) ligent doctor [--offline] 体检:配置、密钥、端点、网络逐环检查 ``` -`--offline` 跳过联网检查。 +`--offline` 跳过联网检查. ### REPL 命令 ``` -/help 帮助 /model [NAME] 切换模型;不带名字则在线选 +/help 帮助 /model [NAME] 切换模型; 不带名字则在线选 /exit, /quit 退出 /system TEXT 重设提示词并清空历史 -/reset 清空对话历史 /cls 清屏(不动对话历史) +/reset 清空对话历史 /cls 清屏 (不动对话历史) /meta 元编辑模式:让模型帮你改这段历史 -/think [开关|级别] 思考模式与级别 /tools 列出可用工具(已按能力隐藏的不列) -/probe 现在测一次模型能力(图片/工具);无视 --no-probe 与缓存 +/think [开关|级别] 思考模式与级别 /tools 列出可用工具 (已按能力隐藏的不列) +/probe 现在测一次模型能力 (图片/工具); 无视 --no-probe 与缓存 /config 显示当前配置 /cd PATH 切换工作目录 /history 打印完整历史 /usage token 用量 /log [FILE] 导出收发记录 /yolo 临时切换写操作确认 -/export 存档当前对话 /import [名] 载入存档(会问要不要打印历史) +/export 存档当前对话 /import [名] 载入存档 (会问要不要打印历史) /shell [命令] 进 shell / 直接执行命令 -/include [GLOB] 把文件附带给模型;不带参数则交互式挑选,clear 取消 -/back 删除最近一轮(破坏性) /unback 撤销 /back +/include [GLOB] 把文件附带给模型; 不带参数则交互式挑选, clear 取消 +/back 删除最近一轮 (破坏性) /unback 撤销 /back ``` -`/reset`(别名 `/clear`)和 `/cls` 是两回事,别敲混: +`/reset` (别名 `/clear`)和 `/cls` 是两回事, 别敲混: | | `/reset`、`/clear` | `/cls` | |---|---|---| -| 作用 | 清空对话历史,重新开始 | 清屏 | -| 影响模型看到的上下文 | **是**,历史没了 | 否,一字不动 | -| 可恢复 | `/unback` 不行,只能 `/import` 旧存档 | 本来就没删东西 | +| 作用 | 清空对话历史, 重新开始 | 清屏 | +| 影响模型看到的上下文 | **是**, 历史没了 | 否, 一字不动 | +| 可恢复 | `/unback` 不行, 只能 `/import` 旧存档 | 本来就没删东西 | -`/cls` 发的是 `\033[2J\033[3J\033[H`,和 `clear(1)` 一致:可见区和回滚缓冲 -一起清。所以清完之后往上滚也看不到旧内容了 —— 想找回来看 `/import` 那份存档。 +`/cls` 发的是 `\033[2J\033[3J\033[H`, 和 `clear(1)` 一致:可见区和回滚缓冲 +一起清. 所以清完之后往上滚也看不到旧内容了 —— 想找回来看 `/import` 那份存档. ### `/model`:不带参数就在线挑 -`/model NAME` 直接换。**不带参数**时会拉一次在线列表、打出来,然后问你: +`/model NAME` 直接换. **不带参数**时会拉一次在线列表、打出来, 然后问你: ``` ligent> /model -正在获取模型列表(https://api.deepseek.com/v1/models)... +正在获取模型列表 (https://api.deepseek.com/v1/models)... 服务端列出了 3 个模型: @@ -738,36 +738,36 @@ ligent> /model 2) deepseek-chat 3) deepseek-reasoner -选择序号,或直接输入模型名(回车取消)> 2 +选择序号, 或直接输入模型名 (回车取消)> 2 模型已切换为 deepseek-chat ``` -输入行里**既可以填序号,也可以直接写模型名**。后者是特意留的:列表里没有的 -东西恰恰很常见 —— 内部模型、灰度模型、服务端没列出来的别名,都得能填进去。 +输入行里**既可以填序号, 也可以直接写模型名**. 后者是特意留的:列表里没有的 +东西恰恰很常见 —— 内部模型、灰度模型、服务端没列出来的别名, 都得能填进去. -列表本身也是诚实的:顺序保持服务端给的(不去替它排序),重复项和缺 `id` 的 -坏条目会被跳过。序号越界会明确报出来(`序号 9 超出范围(1..3),已取消。`), -`Ctrl-C` 或直接回车取消。 +列表本身也是诚实的:顺序保持服务端给的 (不去替它排序), 重复项和缺 `id` 的 +坏条目会被跳过. 序号越界会明确报出来 (`序号 9 超出范围 (1..3), 已取消. `), +`Ctrl-C` 或直接回车取消. -拉列表走的是标准的 `GET {base_url}/models`。**拿不到就明说是什么情况**, +拉列表走的是标准的 `GET {base_url}/models`. **拿不到就明说是什么情况**, 而不是笼统地报一句失败: ``` -拿不到模型列表:服务端返回了空的模型列表(HTTP 200)—— 这个账号下可能没有可用的模型 -可以直接用 /model <名字> 指定,列表拿不到不影响这条。 +拿不到模型列表:服务端返回了空的模型列表 (HTTP 200)—— 这个账号下可能没有可用的模型 +可以直接用 /model <名字> 指定, 列表拿不到不影响这条. ``` -"响应不是 JSON"和"列表是空的"是分开报的 —— 前者多半是 base_url 指错了地方, -后者是账号/权限问题,下一步动作完全不同。 +"响应不是 JSON"和"列表是空的"是分开报的 —— 前者多半是 base_url 指错了地方, +后者是账号/权限问题, 下一步动作完全不同. -> **为什么不做成方向键菜单。** 试过一版,撤了。那种界面要靠 ANSI 反复重画, -> 会把 stdout 搅乱(重定向、日志、粘贴都跟着遭殃);更要命的是它**只让你在 -> 给定的选项里挑**,恰好堵死了"填一个列表里没有的模型名"这条最需要的路。 -> 所以这里是 `/import` 那一套:列表打在上面,输入行是普通的一行,随便写。 +> **为什么不做成方向键菜单. ** 试过一版, 撤了. 那种界面要靠 ANSI 反复重画, +> 会把 stdout 搅乱 (重定向、日志、粘贴都跟着遭殃); 更要命的是它**只让你在 +> 给定的选项里挑**, 恰好堵死了"填一个列表里没有的模型名"这条最需要的路. +> 所以这里是 `/import` 那一套:列表打在上面, 输入行是普通的一行, 随便写. ### `/include`:把文件附带给模型 -模型看不到你的文件,除非你给它。`/include` 把文件**暂存**起来,跟着下一条消息 +模型看不到你的文件, 除非你给它. `/include` 把文件**暂存**起来, 跟着下一条消息 一起发出去 —— 因为附带文件的意义几乎总是配合一个提问: ``` @@ -776,58 +776,58 @@ ligent> /include src/*.c src/agent.c src/json.c src/util.c -下一条消息会带上它们。用 /include clear 取消。 +下一条消息会带上它们. 用 /include clear 取消. ligent> 这三个文件里有没有重复的逻辑? ``` -不带参数则进**交互式挑选**,可以逐层进目录(这就是"递归"的走法 —— glob 本身 -是 shell 语义,`*` 不会跨目录): +不带参数则进**交互式挑选**, 可以逐层进目录 (这就是"递归"的走法 —— glob 本身 +是 shell 语义, `*` 不会跨目录): ``` ligent> /include -进入交互式选择。序号进目录 / 选中文件,也可以直接敲 glob 或路径,回车结束。 +进入交互式选择. 序号进目录 / 选中文件, 也可以直接敲 glob 或路径, 回车结束. 当前目录:/home/you/proj - 1) src/ (目录) + 1) src/ (目录) 2) README.md 12.3 KB 3) logo.png 4.5 KB 〔图片〕 .) .. 返回上级 -已选 0 个。选择 > 1 +已选 0 个. 选择 > 1 ``` -同样是 `/import` 那种普通输入行,不是方向键菜单:**序号、`..`、glob、路径都能 -敲**,空格分隔不支持的(一次一个模式)。`/include clear` 清空暂存,`/reset` -也会清。 +同样是 `/import` 那种普通输入行, 不是方向键菜单:**序号、`..`、glob、路径都能 +敲**, 空格分隔不支持的 (一次一个模式). `/include clear` 清空暂存, `/reset` +也会清. 几件事是有意为之的: -- **认内容,不认扩展名。** 叫 `.png` 的文本文件当文本带;反过来,内容真是图片 - 的才走图片那条路。 -- **图片不进历史。** 图片存成本机备份,历史里只放一个几十字节的引用,发请求时 - 才展开成 base64 —— 和 `read_image` 完全同一条路。 -- **图片要先确认模型能看图。** 遇到图片时才会去跑能力探测(纯文本的附带不该为 - 了"可能出现的图"先花掉两个探测请求),明确不支持就跳过并说明: +- **认内容, 不认扩展名. ** 叫 `.png` 的文本文件当文本带; 反过来, 内容真是图片 + 的才走图片那条路. +- **图片不进历史. ** 图片存成本机备份, 历史里只放一个几十字节的引用, 发请求时 + 才展开成 base64 —— 和 `read_image` 完全同一条路. +- **图片要先确认模型能看图. ** 遇到图片时才会去跑能力探测 (纯文本的附带不该为 + 了"可能出现的图"先花掉两个探测请求), 明确不支持就跳过并说明: ``` 跳过 src/logo.png:当前模型不支持图片输入 - 没有附带任何文件(1 个被跳过)。 + 没有附带任何文件 (1 个被跳过). ``` - 只有**明确不支持**才拦;探测关了或结论不确定时按支持放行 —— 和别处一个原则。 -- **体积有上限。** 单个文本文件 256 KB、一次累计 1 MB、最多 32 个文件。超了会 - 跳过并说明,让你改用 `read_file` 分段读。二进制文件(含 NUL 字节)直接跳过。 -- 暂存的文件名只用于在终端里给你看,**不会**塞进内容块 —— 那会跟着请求发给 - 服务端,而自造字段可能被严格的服务端判 400。 + 只有**明确不支持**才拦; 探测关了或结论不确定时按支持放行 —— 和别处一个原则. +- **体积有上限. ** 单个文本文件 256 KB、一次累计 1 MB、最多 32 个文件. 超了会 + 跳过并说明, 让你改用 `read_file` 分段读. 二进制文件 (含 NUL 字节)直接跳过. +- 暂存的文件名只用于在终端里给你看, **不会**塞进内容块 —— 那会跟着请求发给 + 服务端, 而自造字段可能被严格的服务端判 400. -输入行尾加 `\` 可以续行写多行任务。`Ctrl-C` 中断当前请求(不会退出程序), -`Ctrl-D` 退出。 +输入行尾加 `\` 可以续行写多行任务. `Ctrl-C` 中断当前请求 (不会退出程序), +`Ctrl-D` 退出. ### 输入历史:像 bash 一样跨进程 -你在 `ligent>` 敲过的东西会**实时**写进一个历史文件,新的 ligent 实例启动时读回来, -上下方向键就能翻,跟 bash 一样: +你在 `ligent>` 敲过的东西会**实时**写进一个历史文件, 新的 ligent 实例启动时读回来, +上下方向键就能翻, 跟 bash 一样: ``` ligent> ↑ → 上一条 @@ -835,87 +835,87 @@ ligent> ↑ ↑ → 再上一条 ligent> ↓ → 回来一条 ``` -- **落盘位置**:`<二进制目录>/ligent.history`(和 `ligent.json`、`ligent.archive/` - 同一个原则:只放二进制旁边)。 -- **实时**:每提交一条就 append 并 flush,所以另一个终端里的 ligent(或者另一个 - 标签页)马上就能翻到,不用等谁退出。 -- **权限 0600**:历史里可能顺手敲过密钥,不给同机其他用户读的机会。 -- **一条输入 = 一条历史**:`\` 续行写出来的多行任务算**一条**,上下键整条召回。 -- **上限 1000 条**:启动时只读最新的 1000 条,文件更长就就地截短,不会无限增长。 -- **连续重复不记**:和 bash 的 `ignoredups` 一个意思。 -- **只记 REPL 里敲的**:单次任务模式(`ligent "任务"`)的参数不算 —— 那不是 - "在提示符前敲的"。`/shell` 里执行的命令也不算,那是另一个 shell 的历史。 +- **落盘位置**:`<二进制目录>/ligent.history` (和 `ligent.json`、`ligent.archive/` + 同一个原则:只放二进制旁边). +- **实时**:每提交一条就 append 并 flush, 所以另一个终端里的 ligent (或者另一个 + 标签页)马上就能翻到, 不用等谁退出. +- **权限 0600**:历史里可能顺手敲过密钥, 不给同机其他用户读的机会. +- **一条输入 = 一条历史**:`\` 续行写出来的多行任务算**一条**, 上下键整条召回. +- **上限 1000 条**:启动时只读最新的 1000 条, 文件更长就就地截短, 不会无限增长. +- **连续重复不记**:和 bash 的 `ignoredups` 一个意思. +- **只记 REPL 里敲的**:单次任务模式 (`ligent "任务"`)的参数不算 —— 那不是 + "在提示符前敲的". `/shell` 里执行的命令也不算, 那是另一个 shell 的历史. 开关和覆盖: ```bash ligent --no-history # 本次不读也不写 ligent --history /tmp/my.history # 换个文件 -export LIGENT_HISTORY=/tmp/my.history # 同上,环境变量 +export LIGENT_HISTORY=/tmp/my.history # 同上, 环境变量 ``` -文件是一条历史一行。条目里的反斜杠和换行会被转义(`\` → `\\`,换行 → `\n`), -保证一条占一行且能原样读回;不含反斜杠的普通命令在文件里与输入完全一致。 +文件是一条历史一行. 条目里的反斜杠和换行会被转义 (`\` → `\\`, 换行 → `\n`), +保证一条占一行且能原样读回; 不含反斜杠的普通命令在文件里与输入完全一致. ### `/shell`:在子进程里干活 ``` -ligent> /shell 进入交互式 shell,退出后整段会话记入上下文 -ligent> /shell git log --oneline -5 带参数则直接执行,不进入 shell +ligent> /shell 进入交互式 shell, 退出后整段会话记入上下文 +ligent> /shell git log --oneline -5 带参数则直接执行, 不进入 shell ``` -交互式那条路用**伪终端**做双向搬运:子进程以为自己接在真终端上,所以行编辑、 -颜色、Tab 补全、Ctrl-C、作业控制都照常工作;我们在外面旁听并把输出记下来。 +交互式那条路用**伪终端**做双向搬运:子进程以为自己接在真终端上, 所以行编辑、 +颜色、Tab 补全、Ctrl-C、作业控制都照常工作; 我们在外面旁听并把输出记下来. ``` ligent> /shell -进入交互式 shell。输入 exit 或按 Ctrl-D 返回;整段会话会在退出后记入上下文。 +进入交互式 shell. 输入 exit 或按 Ctrl-D 返回; 整段会话会在退出后记入上下文. [you@host ligent]$ git status [you@host ligent]$ exit -已把 165 字节会话记录加入上下文(不自动提问)。 +已把 165 字节会话记录加入上下文 (不自动提问). ``` 录制进上下文的内容已经清理过 —— 去掉 ANSI 控制序列、把 CRLF 归一成 LF、 -丢掉退格,否则模型看到的是一堆 `^[[32m`。长度上限 100KB。 +丢掉退格, 否则模型看到的是一堆 `^[[32m`. 长度上限 100KB. -清理是**跨读取块**做的:转义序列被 `read()` 从中间切断也能接上,不会串味。 +清理是**跨读取块**做的:转义序列被 `read()` 从中间切断也能接上, 不会串味. -**全屏程序的内容完全不记进上下文。** `nano`、`vi`、`vim`、`nvim`、`less`、`top` -这类程序会切到终端的"备用屏幕",这段画面照常显示在你眼前,但记录里只剩你敲的 -命令行、下一行提示符,以及退出编辑器之后真实的输出 —— 和人眼在终端里看到的 -一模一样。中间留一对标记: +**全屏程序的内容完全不记进上下文. ** `nano`、`vi`、`vim`、`nvim`、`less`、`top` +这类程序会切到终端的"备用屏幕", 这段画面照常显示在你眼前, 但记录里只剩你敲的 +命令行、下一行提示符, 以及退出编辑器之后真实的输出 —— 和人眼在终端里看到的 +一模一样. 中间留一对标记: ``` -[全屏程序运行中,画面内容已省略] +[全屏程序运行中, 画面内容已省略] [全屏程序结束] ``` -理由是那一屏全是光标定位和重复重绘,记下来纯属噪音;而且 `vim` 打开的整个文件 -都会被灌进上下文。程序被强杀、来不及发退出序列时,收尾会补一个结束标记, -保证首尾配对。 +理由是那一屏全是光标定位和重复重绘, 记下来纯属噪音; 而且 `vim` 打开的整个文件 +都会被灌进上下文. 程序被强杀、来不及发退出序列时, 收尾会补一个结束标记, +保证首尾配对. -全屏程序只在 `TERM` 声称支持时才切备用屏幕,所以 `/shell` 会在 `TERM` 缺失或为 -`dumb` 时给子进程补一个 `xterm-256color`(用户显式设了别的值就原样传下去)。 +全屏程序只在 `TERM` 声称支持时才切备用屏幕, 所以 `/shell` 会在 `TERM` 缺失或为 +`dumb` 时给子进程补一个 `xterm-256color` (用户显式设了别的值就原样传下去). 依据是 ligent 自己的 REPL 就在用颜色、光标定位和括号粘贴 —— 这个终端至少是 -ANSI 兼容的,`TERM=dumb` 属于谎报。补上之后,即使是 `TERM=dumb`,`nano` 也会 -正常进入备用屏幕并被整段忽略。 +ANSI 兼容的, `TERM=dumb` 属于谎报. 补上之后, 即使是 `TERM=dumb`, `nano` 也会 +正常进入备用屏幕并被整段忽略. -对确实不用备用屏幕、靠成片空格清屏重绘的程序,退而求其次把连续空白压掉 -(短于 8 个空格的缩进原样保留)。 +对确实不用备用屏幕、靠成片空格清屏重绘的程序, 退而求其次把连续空白压掉 + (短于 8 个空格的缩进原样保留). -**记入上下文但不会自动提问**,你想问什么再问。这样你在 shell 里翻了一圈之后, -可以直接说"刚才那个报错怎么修"。 +**记入上下文但不会自动提问**, 你想问什么再问. 这样你在 shell 里翻了一圈之后, +可以直接说"刚才那个报错怎么修". -选 shell 的顺序是 `bash → ash → dash → sh`。刻意**不看 `$SHELL`**: -用户环境里的 `$SHELL` 可能是 xonsh 之类交互式外壳,在容器或受限环境里 -经常起不来,反而把 `/shell` 弄坏。 +选 shell 的顺序是 `bash → ash → dash → sh`. 刻意**不看 `$SHELL`**: +用户环境里的 `$SHELL` 可能是 xonsh 之类交互式外壳, 在容器或受限环境里 +经常起不来, 反而把 `/shell` 弄坏. ### `/usage` ``` ligent> /usage -token 用量(本次会话) +token 用量 (本次会话) API 调用 : 3 次 输入 token : 12345 输出 token : 678 @@ -924,31 +924,31 @@ token 用量(本次会话) 历史消息 : 15 条 ``` -服务端在每次响应里回报 `usage`,这里只做累加。有些服务端不返回该字段, -那样会明确说"还没回报过用量"而不是显示一堆 0。 +服务端在每次响应里回报 `usage`, 这里只做累加. 有些服务端不返回该字段, +那样会明确说"还没回报过用量"而不是显示一堆 0. ### `/back` 与 `/unback` ``` -ligent> /back 删除最近一轮(从最后一条 user 消息到末尾,整组删) -ligent> /unback 撤销,把删掉的装回去 +ligent> /back 删除最近一轮 (从最后一条 user 消息到末尾, 整组删) +ligent> /unback 撤销, 把删掉的装回去 ``` -是**按轮**删的:模型调用的工具与结果必须整组一起走,只删中间几条会让服务端 400。 +是**按轮**删的:模型调用的工具与结果必须整组一起走, 只删中间几条会让服务端 400. -`/unback` 只在 `/back` 之后没有新对话时可用 —— 一旦你又问了什么,就没有 -"撤销"这一说了,会明确拒绝而不是悄悄恢复到一半状态。 +`/unback` 只在 `/back` 之后没有新对话时可用 —— 一旦你又问了什么, 就没有 +"撤销"这一说了, 会明确拒绝而不是悄悄恢复到一半状态. ### `/yolo` ``` -ligent> /yolo 临时切换写操作确认(只影响本次会话,不进配置文件) +ligent> /yolo 临时切换写操作确认 (只影响本次会话, 不进配置文件) ``` -### 写操作确认(默认开启) +### 写操作确认 (默认开启) -任何**会改动磁盘或执行命令**的工具(`run_command` / `write_file` / `edit_file`) -在执行前都会先说明要做什么,然后问一句: +任何**会改动磁盘或执行命令**的工具 (`run_command` / `write_file` / `edit_file`) +在执行前都会先说明要做什么, 然后问一句: ``` 即将执行 run_command @@ -960,55 +960,55 @@ ligent> /yolo 临时切换写操作确认(只影响本次会话,不进 | 回答 | 含义 | |---|---| -| **回车** / `Y` | 允许这一次(默认) | +| **回车** / `Y` | 允许这一次 (默认) | | `N` | 拒绝这一次 | -| `A` / `Always` | 本轮剩下的改动**一律放行,不再询问** | +| `A` / `Always` | 本轮剩下的改动**一律放行, 不再询问** | | `V` / `neVer` | 本轮剩下的改动**一律拒绝** | -`Always` / `neVer` 的作用域是**这一轮** —— 一条用户消息引发的那串工具调用。 -下一条消息进来时自动复位,所以它不会像 `/yolo` 那样"一直生效到你关掉", -也不会写进配置。选了哪个都会当场提示你: +`Always` / `neVer` 的作用域是**这一轮** —— 一条用户消息引发的那串工具调用. +下一条消息进来时自动复位, 所以它不会像 `/yolo` 那样"一直生效到你关掉", +也不会写进配置. 选了哪个都会当场提示你: ``` 继续?(Y/N/Always/neVer) A - 本轮后续的改动都不再询问。 + 本轮后续的改动都不再询问. ``` -拒绝不等于失败:上层会把"用户拒绝了"作为工具结果回填给模型,让它换个做法, -而不是让整轮对话就此中断。选了 `neVer` 的话,回填的话会更硬("不要再尝试改动, -直接说明你的结论"),免得模型一个接一个地试。 +拒绝不等于失败:上层会把"用户拒绝了"作为工具结果回填给模型, 让它换个做法, +而不是让整轮对话就此中断. 选了 `neVer` 的话, 回填的话会更硬 ("不要再尝试改动, +直接说明你的结论"), 免得模型一个接一个地试. -只读工具(`read_file` / `list_dir`)不打扰。 +只读工具 (`read_file` / `list_dir`)不打扰. -> **非交互输入时按默认值放行。** stdin 不是终端(管道、重定向)时没有人能回答, -> 此时直接按 Y 处理、不显示提示。所以 `echo 任务 | ligent` 里的确认实际上是 -> 不生效的 —— 那条路径要靠 `--no-confirm` 的对面,也就是别指望它兜底。 +> **非交互输入时按默认值放行. ** stdin 不是终端 (管道、重定向)时没有人能回答, +> 此时直接按 Y 处理、不显示提示. 所以 `echo 任务 | ligent` 里的确认实际上是 +> 不生效的 —— 那条路径要靠 `--no-confirm` 的对面, 也就是别指望它兜底. -关掉:`--no-confirm` / `LIGENT_CONFIRM=0` / 配置项 `confirm: false`。 +关掉:`--no-confirm` / `LIGENT_CONFIRM=0` / 配置项 `confirm: false`. -### 自动存档(默认开启) +### 自动存档 (默认开启) 在三个时间点自动把当前对话存进 `ligent.archive/`: -- **退出**时(`/exit`、`Ctrl-D`、单次任务结束) +- **退出**时 (`/exit`、`Ctrl-D`、单次任务结束) - **`/reset` 前** —— 免得清空历史时把刚才那段丢掉 - **`/import` 前** —— 免得载入别的存档时覆盖掉当前的 -只在"有内容"且"自上次存档后又变了"时才写,所以不会产生一堆重复文件; -只有 system 一条的空对话不会留下存档。关掉用 `--no-auto-save`。 +只在"有内容"且"自上次存档后又变了"时才写, 所以不会产生一堆重复文件; +只有 system 一条的空对话不会留下存档. 关掉用 `--no-auto-save`. ### `/meta`:让模型帮你改对话历史 -`/cls` 清屏、`/reset` 清空、`/back` 退一轮 —— 这些都是粗粒度的。真正难的是 -"把第 3 轮里我那句'用 Python'删掉"、"第 5 轮助手那个结论是错的,改掉"。 +`/cls` 清屏、`/reset` 清空、`/back` 退一轮 —— 这些都是粗粒度的. 真正难的是 +"把第 3 轮里我那句'用 Python'删掉"、"第 5 轮助手那个结论是错的, 改掉". -`/meta` 就是干这个的:进入 `meta>` 子提示符,那边是**另一个 AI**,它的工作 -对象不是继续对话,而是**编辑这段历史**。 +`/meta` 就是干这个的:进入 `meta>` 子提示符, 那边是**另一个 AI**, 它的工作 +对象不是继续对话, 而是**编辑这段历史**. ``` ligent> /meta -已进入 meta 模式 —— 改的是对话历史本身,不是继续对话。 -用自然语言说要改什么;/help 看命令,/exit 返回。 +已进入 meta 模式 —— 改的是对话历史本身, 不是继续对话. +用自然语言说要改什么; /help 看命令, /exit 返回. meta> 第 3 轮里我说的"用 Python"删掉 {/} list_rounds @@ -1017,120 +1017,120 @@ meta> 第 3 轮里我说的"用 Python"删掉 即将执行 replace_message #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 模型的指令。 +meta 里的命令只有四个:`/exit` `/diff` (看当前历史, 只读)`/undo` `/help`. +其余输入都是给 meta 模型的指令. #### 和主模型完全隔离 -meta 会话有自己的 system 提示词、自己的工具表,分发走 `Agent` 上的两个函数 -指针(`exec_tool` / `is_mutating`)。主模型看不到也调不到这些工具,反过来也一样 —— -它就算瞎编一个 `delete_rounds`,也只会拿到"未知工具"。 +meta 会话有自己的 system 提示词、自己的工具表, 分发走 `Agent` 上的两个函数 +指针 (`exec_tool` / `is_mutating`). 主模型看不到也调不到这些工具, 反过来也一样 —— +它就算瞎编一个 `delete_rounds`, 也只会拿到"未知工具". | 工具 | 作用 | 参数里带整段正文 | |---|---|---| | `list_rounds [from,to]` | 轮目录:轮号、消息编号范围、条数、字节数、预览 | 否 | -| `read_messages from,to` | 读指定消息的全文(含工具调用参数) | 否 | +| `read_messages from,to` | 读指定消息的全文 (含工具调用参数) | 否 | | `replace_message index,content` | 改写某条消息的正文 | 是 | -| `delete_messages indices` | 删若干条(只限可安全单删的) | 否 | -| `delete_rounds from,to` | 删连续整轮(含轮内所有工具往返) | 否 | -| `insert_message after,role,content` | 插一条(补一句迟到的约束) | 是 | +| `delete_messages indices` | 删若干条 (只限可安全单删的) | 否 | +| `delete_rounds from,to` | 删连续整轮 (含轮内所有工具往返) | 否 | +| `insert_message after,role,content` | 插一条 (补一句迟到的约束) | 是 | | `undo_last_edit` | 撤销上一次改动 | 否 | #### 结构安全是硬约束 -对话历史不是一袋独立消息:`assistant` 带 `tool_calls` 时,后面必须紧跟 -`tool_call_id` 一一对应的 `tool` 消息,否则下一轮请求直接 400。所以: +对话历史不是一袋独立消息:`assistant` 带 `tool_calls` 时, 后面必须紧跟 +`tool_call_id` 一一对应的 `tool` 消息, 否则下一轮请求直接 400. 所以: -- `delete_messages` 只接受 **user 和不带 tool_calls 的 assistant**;碰结构消息 - 一律拒绝,并让模型改用 `delete_rounds` -- 一批删除**先整体校验再动手** —— 宁可一条不改,也不要改一半 +- `delete_messages` 只接受 **user 和不带 tool_calls 的 assistant**; 碰结构消息 + 一律拒绝, 并让模型改用 `delete_rounds` +- 一批删除**先整体校验再动手** —— 宁可一条不改, 也不要改一半 - 动作粒度以"轮"为单位:user → 中间所有工具往返 → 最终回答 -- 删掉某轮的提问时,工具会提醒"那轮剩下的回答现在没有对应的提问了" +- 删掉某轮的提问时, 工具会提醒"那轮剩下的回答现在没有对应的提问了" #### 四道安全网 -1. 进 `/meta` 前自动存一份 `tmp_` 保底存档(文件名会打印出来) -2. 每个破坏性操作走 `(Y/n)` 确认(`--no-confirm` / `/yolo` 下自动放行) -3. `/undo` 或工具 `undo_last_edit`,最多回退 10 步 -4. 每次改动都打印增减字节,不静默 +1. 进 `/meta` 前自动存一份 `tmp_` 保底存档 (文件名会打印出来) +2. 每个破坏性操作走 `(Y/n)` 确认 (`--no-confirm` / `/yolo` 下自动放行) +3. `/undo` 或工具 `undo_last_edit`, 最多回退 10 步 +4. 每次改动都打印增减字节, 不静默 #### 改过之后另起一根 -只要成功改过,下次 `/export`(或退出时的自动存档)就是**新根**: -名字 `<原名>_m<3位>`,`parent` 记空。 +只要成功改过, 下次 `/export` (或退出时的自动存档)就是**新根**: +名字 `<原名>_m<3位>`, `parent` 记空. ``` a1b2c3d4 原版 -a1b2c3d4_mq3z meta 改过的(新根) +a1b2c3d4_mq3z meta 改过的 (新根) a1b2c3d4_mq3z_p8w 从 meta 版再分支 ``` -`_m` 后面是 3 位随机字符,所以下划线后面一共 **4** 个字符;普通分支固定 3 个。 -按长度就能分开,不会歧义。原版仍在 `tmp_` 存档里,丢不了。 +`_m` 后面是 3 位随机字符, 所以下划线后面一共 **4** 个字符; 普通分支固定 3 个. +按长度就能分开, 不会歧义. 原版仍在 `tmp_` 存档里, 丢不了. -meta 自身的 token 消耗单独汇报,不混进主对话的 `/usage`。 +meta 自身的 token 消耗单独汇报, 不混进主对话的 `/usage`. #### 它不是压缩工具 -meta 模型的提示词里写着:**用户没要求压缩就不要压缩 —— 压缩会丢证据**。 +meta 模型的提示词里写着:**用户没要求压缩就不要压缩 —— 压缩会丢证据**. 它的价值在精修:删掉说错的前提、去掉被推翻的方案、改掉助手那次自信但错误的 -结论,让这段历史不再诱导助手重蹈覆辙。 +结论, 让这段历史不再诱导助手重蹈覆辙. -> ⚠️ 让 AI 改自己的历史,等于允许它改写"发生过什么"。排查问题时如果关键证据 -> 被"压掉"就麻烦了 —— 所以默认不压缩、每步要确认、改动可撤销、原版有存档。 +> ⚠️ 让 AI 改自己的历史, 等于允许它改写"发生过什么". 排查问题时如果关键证据 +> 被"压掉"就麻烦了 —— 所以默认不压缩、每步要确认、改动可撤销、原版有存档. ### 等待时的动画:两段标签 -模型不输出思考过程是有意的,但等待期间屏幕完全静止,和"卡死了"看不出区别。 -所以等待时会在 **stderr** 上转一个灰色的动画,并且**按等待的性质分两段**: +模型不输出思考过程是有意的, 但等待期间屏幕完全静止, 和"卡死了"看不出区别. +所以等待时会在 **stderr** 上转一个灰色的动画, 并且**按等待的性质分两段**: ``` -等待响应.... 请求发出去了,但一个字节都还没回来(网络、排队、服务端没醒) -思考中...... 思维链开始流回来了,模型确实在推理 +等待响应.... 请求发出去了, 但一个字节都还没回来 (网络、排队、服务端没醒) +思考中...... 思维链开始流回来了, 模型确实在推理 ``` -这个区分是有用的:同样是等了 30 秒,"一直卡在 `等待响应`"和"一直在 `思考中`" -是两种完全不同的处境 —— 前者多半该去查网络或端点,后者只是模型想得久。 +这个区分是有用的:同样是等了 30 秒, "一直卡在 `等待响应`"和"一直在 `思考中`" +是两种完全不同的处境 —— 前者多半该去查网络或端点, 后者只是模型想得久. -切换的时机是**收到第一片 `reasoning_content`**,不是在发出请求时猜的。所以: +切换的时机是**收到第一片 `reasoning_content`**, 不是在发出请求时猜的. 所以: -- 关掉思考(`thinking: off`)或者用不支持思考的模型 → 只会看到 `等待响应`。 -- 非流式(`--no-stream`)拿不到任何增量 → 也只会看到 `等待响应`。 -- 一收到**正文**动画立刻停并擦掉 —— 那时已经有东西可看了。 +- 关掉思考 (`thinking: off`)或者用不支持思考的模型 → 只会看到 `等待响应`. +- 非流式 (`--no-stream`)拿不到任何增量 → 也只会看到 `等待响应`. +- 一收到**正文**动画立刻停并擦掉 —— 那时已经有东西可看了. -计时是按阶段算的:标签一变就从 0 重新计。超过 5 秒会把秒数亮出来 -(`等待响应.... (12s)`),进一步和卡顿区分。只在 stderr 是终端时才转, -所以重定向或接管道时不会污染输出。 +计时是按阶段算的:标签一变就从 0 重新计. 超过 5 秒会把秒数亮出来 + (`等待响应.... (12s)`), 进一步和卡顿区分. 只在 stderr 是终端时才转, +所以重定向或接管道时不会污染输出. ### 对话存档:文件名就是谱系 ``` ligent> /export 把当前对话存下来 -ligent> /import 列出全部存档,选一份载入 -ligent> /import a1b2c3d4_a9x 按名字直接载入(支持唯一前缀) +ligent> /import 列出全部存档, 选一份载入 +ligent> /import a1b2c3d4_a9x 按名字直接载入 (支持唯一前缀) ``` 存档放在**二进制所在目录**下的 `ligent.archive/` —— 和配置同一个原则: -整个程序连同它的数据都待在一个目录里,拷走即走。 +整个程序连同它的数据都待在一个目录里, 拷走即走. -**命名不做时间戳,而是把分支关系写进文件名**: +**命名不做时间戳, 而是把分支关系写进文件名**: ``` a1b2c3d4 从零开始的一段对话 a1b2c3d4_a9x 从 a1b2c3d4 分出来的分支 a1b2c3d4_a9x_k2m 再往下分支 -a1b2c3d4_q4k 从 a1b2c3d4 分出的另一支(兄弟) +a1b2c3d4_q4k 从 a1b2c3d4 分出的另一支 (兄弟) ``` -新会话第一次存档时随机取 8 位小写字母数字作为根;之后每存一次, -就在**当前所在的节点**后面接 `_` 加 3 位随机字符。 +新会话第一次存档时随机取 8 位小写字母数字作为根; 之后每存一次, +就在**当前所在的节点**后面接 `_` 加 3 位随机字符. `/import` 之前那次保底存档会统一加上 `tmp_` 前缀: @@ -1140,11 +1140,11 @@ znmjjhzl 你主动存下来的 znmjjhzl_ocq 从 znmjjhzl 分出的分支 ``` -临时存档**不会成为以后分支的祖先**(它只是保底,不属于分支树), -所以上面那支仍然署名为 `znmjjhzl_ocq` 而不是 `tmp_mimgkjex_xxx`。 +临时存档**不会成为以后分支的祖先** (它只是保底, 不属于分支树), +所以上面那支仍然署名为 `znmjjhzl_ocq` 而不是 `tmp_mimgkjex_xxx`. -好处是不需要任何索引文件 —— 光 `ls` 一下就能看出谁从谁分出来, -也不会出现索引与实际文件不同步的问题。 +好处是不需要任何索引文件 —— 光 `ls` 一下就能看出谁从谁分出来, +也不会出现索引与实际文件不同步的问题. ``` ligent.archive/ @@ -1154,12 +1154,12 @@ ligent.archive/ mvd2bl1h_q4k 另一支 ``` -**长度上限**:完整文件名(含 `.json`)不超过 240 字符。接一级需要 4 个字符, -所以大约能到 58 层。再往下接就**丢掉谱系、重新起一个 8 位根** -(名字撑爆文件系统总不是个办法)。 +**长度上限**:完整文件名 (含 `.json`)不超过 240 字符. 接一级需要 4 个字符, +所以大约能到 58 层. 再往下接就**丢掉谱系、重新起一个 8 位根** + (名字撑爆文件系统总不是个办法). -存档内容仍是自描述的 JSON,并把 `name` 与 `parent` 也写进去 —— -这样即使文件被改名、或者谱系因超长而重置,也能从内容里看出它从哪来: +存档内容仍是自描述的 JSON, 并把 `name` 与 `parent` 也写进去 —— +这样即使文件被改名、或者谱系因超长而重置, 也能从内容里看出它从哪来: ```json { @@ -1176,16 +1176,16 @@ ligent.archive/ `/import` 载入后会问一句要不要把这段历史打出来: ``` -已载入 mvd2bl1h_cpc(7 条消息)。之后 /export 会存成 mvd2bl1h_cpc_xxx +已载入 mvd2bl1h_cpc (7 条消息). 之后 /export 会存成 mvd2bl1h_cpc_xxx 是否输出这段历史的内容?(Y/n) ``` -回车(默认 Y)就**完整**逐条打出来,带角色标签和颜色: +回车 (默认 Y)就**完整**逐条打出来, 带角色标签和颜色: ``` -- 1/7 系统 -- 你是用户的 AI 助手 -...(system 提示词全文) +... (system 提示词全文) -- 2/7 你 -- 帮我把 build 目录清一下 @@ -1201,23 +1201,23 @@ ligent.archive/ ``` "完整"是字面意思:**正文不截断、工具参数不省略、总量不封顶、`system` -提示词也全文展开**。都回答了 Y,就该看到全部 —— 截一半反而会让人以为历史里 -本来就这么点东西。`tool_call_id` 是特意标出来的,否则一堆工具输出看不出 -谁在回应谁。 +提示词也全文展开**. 都回答了 Y, 就该看到全部 —— 截一半反而会让人以为历史里 +本来就这么点东西. `tool_call_id` 是特意标出来的, 否则一堆工具输出看不出 +谁在回应谁. -回 `n` 就只装进上下文、屏幕保持干净。之后想随时再看一遍,敲 `/history` —— -它打的就是同一份东西(当前这段对话,一条不落)。 +回 `n` 就只装进上下文、屏幕保持干净. 之后想随时再看一遍, 敲 `/history` —— +它打的就是同一份东西 (当前这段对话, 一条不落). -两点行为约定:存档里的 `system` 消息不会被带进来(否则载入旧存档会把你现在 -的提示词换掉),当前生效的那条保留;载入后对话开始时间重置。空对话不会产生 -存档。`/reset` 会断开谱系,下一次存档另起一个根。 +两点行为约定:存档里的 `system` 消息不会被带进来 (否则载入旧存档会把你现在 +的提示词换掉), 当前生效的那条保留; 载入后对话开始时间重置. 空对话不会产生 +存档. `/reset` 会断开谱系, 下一次存档另起一个根. -(输入历史**不会**跟着清 —— 它是一份持久的、跨会话的用户资源,记的是 -"我在 `ligent>` 敲过什么",和当前装的是哪段对话无关。) + (输入历史**不会**跟着清 —— 它是一份持久的、跨会话的用户资源, 记的是 +"我在 `ligent>` 敲过什么", 和当前装的是哪段对话无关. ) ### 先跑一次体检 -配置调不通时,**先别猜**: +配置调不通时, **先别猜**: ```bash ligent doctor @@ -1225,107 +1225,107 @@ ligent doctor 它会把整条链路逐环打出来 —— 二进制与配置目录怎么定的、配置文件在不在、 密钥从哪来、格式与端点是否匹配、环境变量有没有互相顶掉、参数是否离谱、 -网络通不通、端点有没有跳转。 +网络通不通、端点有没有跳转. ``` 【API key】 来源 配置文件 - 值 sk-or-v1...(73 字符) + 值 sk-or-v1... (73 字符) 格式推断 OpenRouter - 与端点匹配 [v] 端点属于 OpenRouter,密钥格式吻合 + 与端点匹配 [v] 端点属于 OpenRouter, 密钥格式吻合 【网络】 - 连通性 [v] 可连接,TLS 校验通过 + 连通性 [v] 可连接, TLS 校验通过 HTTP 状态 307 重定向 [x] 端点跳转到 https://example.invalid/v1/chat/completions - ligent 不自动跟随重定向:跨源跳转会丢掉 Authorization 头, - 服务端只会回 401 "Missing Authentication header",看起来像是密钥没带, - 实际是 URL 跳了。把 base_url 直接改成上面那个地址即可。 + ligent 不自动跟随重定向:跨源跳转会丢掉 Authorization 头, + 服务端只会回 401 "Missing Authentication header", 看起来像是密钥没带, + 实际是 URL 跳了. 把 base_url 直接改成上面那个地址即可. ``` 它专门盯着几类"报错信息会把人带偏"的问题:配置被残留环境变量顶掉、 -密钥与服务商不匹配、端点重定向导致伪 401、配置文件语法错误。 -退出码:有问题为 1,否则 0。 +密钥与服务商不匹配、端点重定向导致伪 401、配置文件语法错误. +退出码:有问题为 1, 否则 0. -> doctor 在**配置本身加载失败时也能跑** —— 那正是最需要它的时刻。 +> doctor 在**配置本身加载失败时也能跑** —— 那正是最需要它的时刻. ### 行编辑 -REPL 自带行编辑器(`src/lineedit.c`),不依赖 readline,也不用终端的行规程: +REPL 自带行编辑器 (`src/lineedit.c`), 不依赖 readline, 也不用终端的行规程: | 按键 | 作用 | |---|---| -| `←` `→` | 光标左右移动(按**字符**而不是字节,中文不会移半个字) | +| `←` `→` | 光标左右移动 (按**字符**而不是字节, 中文不会移半个字) | | `↑` `↓` | 翻历史记录 | -| `Home` / `End` / `Ctrl-A` / `Ctrl-E` | **当前这一行**的行首 / 行尾(粘进来多行时不会跳到整块两端) | +| `Home` / `End` / `Ctrl-A` / `Ctrl-E` | **当前这一行**的行首 / 行尾 (粘进来多行时不会跳到整块两端) | | `Backspace` / `Delete` | 删除光标前 / 光标处的字符 | | `Ctrl-K` / `Ctrl-U` / `Ctrl-W` | 删到行尾 / 删到行首 / 往回删一个词 | -| `Ctrl-C` | 丢弃当前输入行并开新行(请求执行中则是中断请求,不退出程序);**在写操作确认提示上等于"拒绝"** | -| `Ctrl-D` | 空行时退出;行非空时删一个字符 | -| **粘贴多行** | 整段插入,**不会被逐行执行** | +| `Ctrl-C` | 丢弃当前输入行并开新行 (请求执行中则是中断请求, 不退出程序); **在写操作确认提示上等于"拒绝"** | +| `Ctrl-D` | 空行时退出; 行非空时删一个字符 | +| **粘贴多行** | 整段插入, **不会被逐行执行** | -粘贴用 bracketed paste(`\033[?2004h`)实现:终端会把粘贴内容和真实按键区分开, -收到粘贴块就整段放进缓冲区,里面的换行不再触发提交。所以可以直接把一段 -多行需求、一个代码片段、一段日志粘进来,编辑好再回车,它会作为**一条**消息发出。 -`Ctrl+Shift+V`、中键、菜单里的粘贴走的都是这条路。 +粘贴用 bracketed paste (`\033[?2004h`)实现:终端会把粘贴内容和真实按键区分开, +收到粘贴块就整段放进缓冲区, 里面的换行不再触发提交. 所以可以直接把一段 +多行需求、一个代码片段、一段日志粘进来, 编辑好再回车, 它会作为**一条**消息发出. +`Ctrl+Shift+V`、中键、菜单里的粘贴走的都是这条路. -**粘贴进来的内容会被清洗一遍。** 因为 `read_paste()` 收的是终端给的原始字节、 -不经按键分类,里面夹着控制字符就会让"编辑器的算盘"和"终端实际显示"对不上: +**粘贴进来的内容会被清洗一遍. ** 因为 `read_paste()` 收的是终端给的原始字节、 +不经按键分类, 里面夹着控制字符就会让"编辑器的算盘"和"终端实际显示"对不上: | 字符 | 不清洗会怎样 | 清洗后 | |---|---|---| -| `\t` | 终端按制表位(8 的倍数)展开,编辑器只当 1 列 —— 粘一段带缩进的代码,**光标从此偏掉,之后每敲一个字都在错位置上** | 就地展开成空格,缓冲区里不留制表符 | +| `\t` | 终端按制表位 (8 的倍数)展开, 编辑器只当 1 列 —— 粘一段带缩进的代码, **光标从此偏掉, 之后每敲一个字都在错位置上** | 就地展开成空格, 缓冲区里不留制表符 | | `ESC` | 被终端当成转义序列**真的执行掉**:粘贴内容能改颜色、甚至清屏 | 连同后面整条序列一起丢弃 | -| `\r` | 把终端光标拉回行首,屏幕当场错位 | 丢弃(CRLF 归一成 LF) | +| `\r` | 把终端光标拉回行首, 屏幕当场错位 | 丢弃 (CRLF 归一成 LF) | | `NUL` | 会把 C 字符串截断 | 丢弃 | -制表符展开是按**插入点当时的列号**算的,不是从 0 开始 —— 在行中间粘贴时缩进才对。 +制表符展开是按**插入点当时的列号**算的, 不是从 0 开始 —— 在行中间粘贴时缩进才对. -为此缓冲区支持多行,渲染也相应升级为多行 + 折行感知。 +为此缓冲区支持多行, 渲染也相应升级为多行 + 折行感知. -实现方式是进 raw 模式自己接管按键。最早用 `fgets` 直接读时终端停在规范模式, -方向键发出的转义序列会被当成普通字节读进来、显示成 `^[[D`,所以必须自己处理。 +实现方式是进 raw 模式自己接管按键. 最早用 `fgets` 直接读时终端停在规范模式, +方向键发出的转义序列会被当成普通字节读进来、显示成 `^[[D`, 所以必须自己处理. -超出终端宽度时自动折行,光标位置按行列重算;宽度计算复用与表格渲染同一套 -CJK 规则(中文按 2 列)。行尾 `\` 续行仍然可用,续行的每一段都能独立编辑。 +超出终端宽度时自动折行, 光标位置按行列重算; 宽度计算复用与表格渲染同一套 +CJK 规则 (中文按 2 列). 行尾 `\` 续行仍然可用, 续行的每一段都能独立编辑. -**你敲的那一行整个是绿色的**(提示符 + 输入的文字),翻历史或滚屏时一眼能 +**你敲的那一行整个是绿色的** (提示符 + 输入的文字), 翻历史或滚屏时一眼能 看出哪些是自己的输入、哪些是模型输出: ``` -ligent> 你好 ← 绿色,整行都是输入 -你好!有什么可以帮你的吗? ← 普通色,是模型正文 +ligent> 你好 ← 绿色, 整行都是输入 +你好! 有什么可以帮你的吗? ← 普通色, 是模型正文 ``` -颜色是行编辑器统一加在整行两头的,所以提示符字符串本身必须保持纯文本 —— -在提示符里自带一个 reset,后面的输入就会掉回默认色。 +颜色是行编辑器统一加在整行两头的, 所以提示符字符串本身必须保持纯文本 —— +在提示符里自带一个 reset, 后面的输入就会掉回默认色. -配色跟着正文那个开关走(`--color` / `--no-color` / 非终端自动关闭)。 +配色跟着正文那个开关走 (`--color` / `--no-color` / 非终端自动关闭). 绿色区间那两串转义序列在算光标位置时会被跳过 —— 否则 `\033[32m` 会被当成 -5 个可见字符,光标每次重绘都会偏 5 列。 +5 个可见字符, 光标每次重绘都会偏 5 列. ## 4. 终端渲染 助手回复会做一层**流式 Markdown 渲染**:模型吐出的 `#`、`**粗体**` 等标记 -在终端里直接变成样式,而不是原样显示。工具输出(文件内容、命令结果)**不做** -任何渲染,保持原样,避免破坏代码。 +在终端里直接变成样式, 而不是原样显示. 工具输出 (文件内容、命令结果)**不做** +任何渲染, 保持原样, 避免破坏代码. ### 只用 16 色 -全部配色都落在最窄的 **16 色 ANSI** 里:前景用 30-37,加粗/暗淡/斜体/下划线 -用 SGR 属性 1/2/3/4。不用 90-97 那一段(aixterm 的"亮色"),也不用 256 色或 -真彩。 +全部配色都落在最窄的 **16 色 ANSI** 里:前景用 30-37, 加粗/暗淡/斜体/下划线 +用 SGR 属性 1/2/3/4. 不用 90-97 那一段 (aixterm 的"亮色"), 也不用 256 色或 +真彩. -理由是这个项目**硬编码 SGR、不查 terminfo**(也不查 `COLORTERM`)—— 既然不去 -探测终端能力,就该待在**所有终端都认**的那一档里。90-97 虽然到处都是,但严格 -讲不属于 16 色,在精简终端、把 `TERM` 设成不认识的值、或者老式串口终端上, -它可能被当成两个参数解析或者直接忽略。 +理由是这个项目**硬编码 SGR、不查 terminfo** (也不查 `COLORTERM`)—— 既然不去 +探测终端能力, 就该待在**所有终端都认**的那一档里. 90-97 虽然到处都是, 但严格 +讲不属于 16 色, 在精简终端、把 `TERM` 设成不认识的值、或者老式串口终端上, +它可能被当成两个参数解析或者直接忽略. -这条约束由 `tools/check-colors.sh` 守着,`make test-unit` 里会跑。**它值得单独 -一个脚本,是因为颜色越界看代码看不出来** —— 一个越界的码在人眼里也只是"一个 -蓝色",只有拿尺子量才知道它在哪一段。(这个检查抓到过一处:链接原本用的 -`\033[94m` 就是越界的亮蓝。) +这条约束由 `tools/check-colors.sh` 守着, `make test-unit` 里会跑. **它值得单独 +一个脚本, 是因为颜色越界看代码看不出来** —— 一个越界的码在人眼里也只是"一个 +蓝色", 只有拿尺子量才知道它在哪一段. (这个检查抓到过一处:链接原本用的 +`\033[94m` 就是越界的亮蓝. ) 配色语义在各模块之间是一致的: @@ -1334,35 +1334,35 @@ ligent> 你好 ← 绿色,整行都是输入 | 成功 / 工具执行成功 / 输入行 | 32 绿 | agent、doctor、main、lineedit | | 失败 / 错误 | 31 红 | agent、doctor | | 警告 / 需要注意 | 33 黄 | doctor、confirm、main | -| 次要信息(提示、路径、引用) | 2 暗淡 | 各模块 | -| 强调(工具名、标题) | 1 粗体 | 各模块 | +| 次要信息 (提示、路径、引用) | 2 暗淡 | 各模块 | +| 强调 (工具名、标题) | 1 粗体 | 各模块 | | 链接 | 4;36 下划线+青 | markdown | -链接用**下划线**而不是只靠颜色:下划线是 SGR 属性,在单色终端、色盲用户、 -以及把颜色重映射过的主题里都还在 —— 颜色会丢,属性不会。 +链接用**下划线**而不是只靠颜色:下划线是 SGR 属性, 在单色终端、色盲用户、 +以及把颜色重映射过的主题里都还在 —— 颜色会丢, 属性不会. | 语法 | 效果 | |---|---| -| `#` ~ `######` | 标题,按层级着色(H1 粗体青 / H2 粗体品红 / H3 粗体蓝 / H4+ 粗体) | -| `**粗体**` | 粗体,标记被吃掉 | +| `#` ~ `######` | 标题, 按层级着色 (H1 粗体青 / H2 粗体品红 / H3 粗体蓝 / H4+ 粗体) | +| `**粗体**` | 粗体, 标记被吃掉 | | `*斜体*` / `_斜体_` | 斜体 | -| `` `代码` `` | 青色的行内代码,**反引号保留**(方便复制到 shell),内容不做任何解析 | -| `-` `*` `+` 列表 | **保留原始标记字符**并着青色(不替换成 `•`) | +| `` `代码` `` | 青色的行内代码, **反引号保留** (方便复制到 shell), 内容不做任何解析 | +| `-` `*` `+` 列表 | **保留原始标记字符**并着青色 (不替换成 `•`) | | `> 引用` | 暗色 `│ ` 前缀 | | `---` | 暗色分隔线 | -| `\| a \| b \|` 表格 | 带边框对齐的表格,表头加粗,支持 `:---` / `:---:` / `---:` 对齐 | -| `[链接文字](url)` | `[链接文字]` 加下划线并着青色,`(url)` 原样保留 | +| `\| a \| b \|` 表格 | 带边框对齐的表格, 表头加粗, 支持 `:---` / `:---:` / `---:` 对齐 | +| `[链接文字](url)` | `[链接文字]` 加下划线并着青色, `(url)` 原样保留 | **完全不做渲染、逐字节原样保留**的四种情况: | 语法 | 处理 | |---|---| -| ` ``` ` 围栏代码块 | 整块(含 ` ``` ` 标记本身)原样输出,零 ANSI 转义 | +| ` ``` ` 围栏代码块 | 整块 (含 ` ``` ` 标记本身)原样输出, 零 ANSI 转义 | | `![图片](src)` | 保留原文 | -| 链接的 `(url)` 部分 | 保留原文(不隐藏、不做超链接跳转) | +| 链接的 `(url)` 部分 | 保留原文 (不隐藏、不做超链接跳转) | | 行内代码的内容 | 不做任何解析 | -表格渲染示例(列宽按**显示宽度**计算,中文按 2 列,所以边框能对齐): +表格渲染示例 (列宽按**显示宽度**计算, 中文按 2 列, 所以边框能对齐): ``` ┌──────┬──────┬──────┐ @@ -1375,46 +1375,46 @@ ligent> 你好 ← 绿色,整行都是输入 ### 两个刻意的取舍 -**`_斜体_` 会渲染,但下划线标识符不会被误伤。** 按 CommonMark 规则禁用"词内强调": -`foo_bar_baz`、`a_b_c` 原样保留;与另一个下划线相邻时也一律当字面量, -所以 Python 的 `__init__`、`__main__` 不会被拆成斜体。中文不算词字符, -因此 `中文_斜体_` 能正常生效。 +**`_斜体_` 会渲染, 但下划线标识符不会被误伤. ** 按 CommonMark 规则禁用"词内强调": +`foo_bar_baz`、`a_b_c` 原样保留; 与另一个下划线相邻时也一律当字面量, +所以 Python 的 `__init__`、`__main__` 不会被拆成斜体. 中文不算词字符, +因此 `中文_斜体_` 能正常生效. -**表格必须等整块收完才能渲染。** 列宽取决于所有行,所以表格在流式过程中会先缓存在 -缓冲区,遇到非表格行(或消息结束)才一次性输出。行数超过 200 行会先渲染已有部分, -避免超大表格一直不显示。 +**表格必须等整块收完才能渲染. ** 列宽取决于所有行, 所以表格在流式过程中会先缓存在 +缓冲区, 遇到非表格行 (或消息结束)才一次性输出. 行数超过 200 行会先渲染已有部分, +避免超大表格一直不显示. -**表格必须顶格写 `|`。** 不带首竖线的写法(`a | b` / `--- | ---`)按普通文本处理。 +**表格必须顶格写 `|`. ** 不带首竖线的写法 (`a | b` / `--- | ---`)按普通文本处理. -这条限制不是偷懒,而是为了保证**输出与分片方式无关**:判断"这是不是表格"所需的信息 -必须在行的第 0 个字节就能拿到。如果允许"整行任意位置含竖线",那么当 SSE 的分片边界 -恰好落在竖线之前时,行的前半段已经被当成普通文本输出、无法收回;而同一行若整块到达 -则会被识别为表格——**同样的内容,渲染结果取决于网络怎么切的包**。这个问题是模糊测试 -找出来的(最小复现只有 4 字节:`s|\n-`)。 +这条限制不是偷懒, 而是为了保证**输出与分片方式无关**:判断"这是不是表格"所需的信息 +必须在行的第 0 个字节就能拿到. 如果允许"整行任意位置含竖线", 那么当 SSE 的分片边界 +恰好落在竖线之前时, 行的前半段已经被当成普通文本输出、无法收回; 而同一行若整块到达 +则会被识别为表格——**同样的内容, 渲染结果取决于网络怎么切的包**. 这个问题是模糊测试 +找出来的 (最小复现只有 4 字节:`s|\n-`). ### 流式是这里的难点 -SSE 的分片位置是随机的,`**bold**` 完全可能被切成 `**bo` / `ld**` 两片, -甚至两个 `*` 分别落在两片里。所以渲染器是一个**带状态、可挂起**的状态机 -(`src/markdown.c`): +SSE 的分片位置是随机的, `**bold**` 完全可能被切成 `**bo` / `ld**` 两片, +甚至两个 `*` 分别落在两片里. 所以渲染器是一个**带状态、可挂起**的状态机 + (`src/markdown.c`): -- 遇到可能是标记前缀的尾部(结尾的 `*`、行首的 `#`/`>`/`-`、`` ` ``、`[`), - 它**不立即输出**,而是把这段字节留在缓冲区里等下一个分片,凑齐后再判定。 -- 绝大多数情况下挂起只有几个字节,延迟不可感知;唯一的例外是**表格** - (列宽依赖所有行,必须整块收完才能渲染)。 -- 缓冲区有 64KB 硬上限兜底,表格另有 200 行 / 单行 4KB 的上限, - 防止异常输入把内存吃满或让输出长时间卡住。 -- 流式与非流式走同一个渲染器,因此两种模式的输出**逐字节一致**。 +- 遇到可能是标记前缀的尾部 (结尾的 `*`、行首的 `#`/`>`/`-`、`` ` ``、`[`), + 它**不立即输出**, 而是把这段字节留在缓冲区里等下一个分片, 凑齐后再判定. +- 绝大多数情况下挂起只有几个字节, 延迟不可感知; 唯一的例外是**表格** + (列宽依赖所有行, 必须整块收完才能渲染). +- 缓冲区有 64KB 硬上限兜底, 表格另有 200 行 / 单行 4KB 的上限, + 防止异常输入把内存吃满或让输出长时间卡住. +- 流式与非流式走同一个渲染器, 因此两种模式的输出**逐字节一致**. `tests/test_markdown.c` 用一条不变量来锁住这个行为:把同一段文本分别按 -**一次性 / 3 字节 / 逐字节** 三种方式喂进去,输出必须完全相同。 +**一次性 / 3 字节 / 逐字节** 三种方式喂进去, 输出必须完全相同. ### 想看效果? -仓库里带了一个独立的小演示程序,不需要 API key: +仓库里带了一个独立的小演示程序, 不需要 API key: ```bash -make demo # 编译并运行,用内置示例展示所有语法 +make demo # 编译并运行, 用内置示例展示所有语法 ./build/md_demo --plain # 对照:输出原始 Markdown ./build/md_demo -s # 流式模式:每 5 字节一片、间隔 12ms ./build/md_demo --compare # 自检:不同分片方式输出是否一致 @@ -1422,176 +1422,176 @@ make demo # 编译并运行,用内置示例展示所有语法 echo '# hi **there**' | ./build/md_demo ``` -`-s` 看起来和普通输出几乎一样,这正是重点:分片边界落在 `**`、行首 `#`、 -表格中间都察觉不到——因为状态机会把可能被切断的标记暂存到下一片再判定。 -`--compare` 会把同一段文本按 1/2/3/5/7/13/64 字节以及**随机**分片各渲染一遍, -逐字节比对结果,这也是 `make test` 会跑的一项。 +`-s` 看起来和普通输出几乎一样, 这正是重点:分片边界落在 `**`、行首 `#`、 +表格中间都察觉不到——因为状态机会把可能被切断的标记暂存到下一片再判定. +`--compare` 会把同一段文本按 1/2/3/5/7/13/64 字节以及**随机**分片各渲染一遍, +逐字节比对结果, 这也是 `make test` 会跑的一项. ### 什么时候不渲染 -只有输出到终端时才渲染。以下情况会退化为**逐字节原样输出**: +只有输出到终端时才渲染. 以下情况会退化为**逐字节原样输出**: -- 输出被重定向或接管道(`ligent "..." > out.md`) +- 输出被重定向或接管道 (`ligent "..." > out.md`) - 显式 `--no-color` -这样 `ligent "生成一份 README" > README.md` 拿到的仍然是干净的 Markdown。 -判断依据是 `isatty(stdout)`,可用 `--color` / `--no-color` 强制覆盖。 +这样 `ligent "生成一份 README" > README.md` 拿到的仍然是干净的 Markdown. +判断依据是 `isatty(stdout)`, 可用 `--color` / `--no-color` 强制覆盖. ## 5. 内置工具 | 工具 | 作用 | 关键参数 | |---|---|---| -| `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` | -| `list_dir` | 列出目录,标注类型与大小 | `path` | -| `read_image` | 读入本地图片交给模型看:本机留备份,请求时以 base64 内联发出 | `path` | +| `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` | +| `list_dir` | 列出目录, 标注类型与大小 | `path` | +| `read_image` | 读入本地图片交给模型看:本机留备份, 请求时以 base64 内联发出 | `path` | -单次工具输出上限 100 KB,超出会截断并提示,避免一次 `cat` 把上下文撑爆。 +单次工具输出上限 100 KB, 超出会截断并提示, 避免一次 `cat` 把上下文撑爆. ### `edit_file`:行号集合钉住的替换 -`line` 是**集合型**参数,而且**可以整个省略**:省略 = 不设行范围 = **全文件替换 -所有匹配**(就是编辑器里那个"全局替换")。给了它就是集合型 —— 单个整数 -(`"line": 41`)或整数数组(`"line": [41, 42]`)都收,行号就是 `read_file` 打出来 -的那些(从 1 开始)。给了行号之后,替换只发生在这些行里,`old_string` 也只需 -**在这些行里**唯一。 +`line` 是**集合型**参数, 而且**可以整个省略**:省略 = 不设行范围 = **全文件替换 +所有匹配** (就是编辑器里那个"全局替换"). 给了它就是集合型 —— 单个整数 + (`"line": 41`)或整数数组 (`"line": [41, 42]`)都收, 行号就是 `read_file` 打出来 +的那些 (从 1 开始). 给了行号之后, 替换只发生在这些行里, `old_string` 也只需 +**在这些行里**唯一. -判定命中看的是 `old_string` 的**起点**:起点落在集合里的任意一行上就算数。 -所以 `old_string` 跨好几行时,照 `read_file` 的显示把覆盖到的那几行都填进数组就行, -不必先赌起点到底是第 41 行还是第 42 行。集合是**搜索范围**,不是"每行都改一遍": -真改动的仍然只有唯一那一处(`replace_all=true` 或省略 `line` 时才动全文件)。 +判定命中看的是 `old_string` 的**起点**:起点落在集合里的任意一行上就算数. +所以 `old_string` 跨好几行时, 照 `read_file` 的显示把覆盖到的那几行都填进数组就行, +不必先赌起点到底是第 41 行还是第 42 行. 集合是**搜索范围**, 不是"每行都改一遍": +真改动的仍然只有唯一那一处 (`replace_all=true` 或省略 `line` 时才动全文件). -**省略 `line`** 时没有范围约束:整个文件都是搜索范围,而且**命中几处就换几处** -(不再要求唯一),回执里会写明"全文件替换 N 处"。这是故意留着的逃生门 —— 有些 -改动本来就该全文件生效(比如统一改个名字);但它不是默认路径:不给行号就没法 -声称"我知道改的是哪一处",要改一处仍得给行号。 +**省略 `line`** 时没有范围约束:整个文件都是搜索范围, 而且**命中几处就换几处** + (不再要求唯一), 回执里会写明"全文件替换 N 处". 这是故意留着的逃生门 —— 有些 +改动本来就该全文件生效 (比如统一改个名字); 但它不是默认路径:不给行号就没法 +声称"我知道改的是哪一处", 要改一处仍得给行号. -为什么不再只靠"全文唯一":行号是模型唯一的空间坐标,而"全文唯一"要求整个文件里 -恰好只有一处 —— 文件一长这个条件经常不成立(同一个 `return 0;` 到处都是),模型 -只能不断加长 `old_string` 去凑唯一性;凑不成要么放弃,要么赌一把改错地方。把行号 -钉住之后,"改哪一处"由坐标决定,不再是个概率问题,`old_string` 也不必为了唯一而写长。 +为什么不再只靠"全文唯一":行号是模型唯一的空间坐标, 而"全文唯一"要求整个文件里 +恰好只有一处 —— 文件一长这个条件经常不成立 (同一个 `return 0;` 到处都是), 模型 +只能不断加长 `old_string` 去凑唯一性; 凑不成要么放弃, 要么赌一把改错地方. 把行号 +钉住之后, "改哪一处"由坐标决定, 不再是个概率问题, `old_string` 也不必为了唯一而写长. -负面路径都给出**可照做的下一步**,而不是干说一句"不行": +负面路径都给出**可照做的下一步**, 而不是干说一句"不行": | 情况 | 反馈 | |---|---| -| `line` 写成了字符串之类 | 报错说明它要的是整数或整数数组,并提示"要全文件替换就整个省略" | -| 给了空数组 | 报错说明至少要给一个行号,或整个省略 `line` | -| 集合里有越界行号 | 报出文件总行数("只有 3 行,line 里的 99 越界") | -| 集合里的元素不是 >=1 的整数 | 报错并指出是第几个元素(空数组、非整数都在这里拦掉) | -| 集合里的行号太多了(>64) | 报错说明上限,并提示先 `read_file` 看清再分几次改 | -| 集合圈住的行里没有 `old_string` | **报出它真正在哪几行**("第 3 行没有,它在第 7 行" / "在第 2、9、14 行等共 6 处"),照着改 `line` 即可 | -| 集合圈住的行里有多处 | 要求写长 `old_string`,或设 `replace_all=true` | -| 省略了 `line`,而文件里没有 `old_string` | 报"整个文件里没有",并提示要给行范围就填 `line` | -| 文件是二进制 | 拒绝并说明理由(见下节) | +| `line` 写成了字符串之类 | 报错说明它要的是整数或整数数组, 并提示"要全文件替换就整个省略" | +| 给了空数组 | 报错说明至少要给一个行号, 或整个省略 `line` | +| 集合里有越界行号 | 报出文件总行数 ("只有 3 行, line 里的 99 越界") | +| 集合里的元素不是 >=1 的整数 | 报错并指出是第几个元素 (空数组、非整数都在这里拦掉) | +| 集合里的行号太多了 (>64) | 报错说明上限, 并提示先 `read_file` 看清再分几次改 | +| 集合圈住的行里没有 `old_string` | **报出它真正在哪几行** ("第 3 行没有, 它在第 7 行" / "在第 2、9、14 行等共 6 处"), 照着改 `line` 即可 | +| 集合圈住的行里有多处 | 要求写长 `old_string`, 或设 `replace_all=true` | +| 省略了 `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` 的判据一致 —— 两处必须一样, -否则会出现"同一个文件 `/include` 说能带、`read_file` 说不能读"这种自相矛盾)。 +`read_file` / `write_file` / `edit_file` 遇到二进制文件一律**拒绝**, 而不是硬读或 +硬写. 判据只有一条:**出现 NUL 字节** (和 `/include` 的判据一致 —— 两处必须一样, +否则会出现"同一个文件 `/include` 说能带、`read_file` 说不能读"这种自相矛盾). -拒绝的理由是实的,不是洁癖: +拒绝的理由是实的, 不是洁癖: -- `read_file` 会把二进制读成乱码,还会在第一个 NUL 处被截断 —— 模型看到的是**残缺** - 的内容,却没有任何提示说它是残缺的; -- `edit_file` 更糟:它在内存里按 C 字符串处理(`strstr`/`strlen`),文件里任何一个 - NUL 都会让后面的内容被当成结束,**改下去就是把原文件毁掉**。所以它探的是全文件, - 不像读类工具只看前 8 KB; -- `write_file` 只写文本,用文本整体覆盖一个二进制文件等于把原内容毁掉。 +- `read_file` 会把二进制读成乱码, 还会在第一个 NUL 处被截断 —— 模型看到的是**残缺** + 的内容, 却没有任何提示说它是残缺的; +- `edit_file` 更糟:它在内存里按 C 字符串处理 (`strstr`/`strlen`), 文件里任何一个 + NUL 都会让后面的内容被当成结束, **改下去就是把原文件毁掉**. 所以它探的是全文件, + 不像读类工具只看前 8 KB; +- `write_file` 只写文本, 用文本整体覆盖一个二进制文件等于把原内容毁掉. -拒绝时会说清"第几字节有 NUL"以及**该改用什么**:看图片用 `read_image`,看其它 -二进制用 `run_command`(`file` / `xxd` / `strings` / `od`),要改二进制用 -`printf` / `dd` / `perl -pi` 这类。 +拒绝时会说清"第几字节有 NUL"以及**该改用什么**:看图片用 `read_image`, 看其它 +二进制用 `run_command` (`file` / `xxd` / `strings` / `od`), 要改二进制用 +`printf` / `dd` / `perl -pi` 这类. **搜索内容不在这套工具里**:模型要搜就用 `run_command` 里的真 `grep` -(`grep -rn 模式 目录`)。以前有个 `search_files`(后来改名 `grep`)的包装, -删掉的理由见下节。 + (`grep -rn 模式 目录`). 以前有个 `search_files` (后来改名 `grep`)的包装, +删掉的理由见下节. ### 工具命名约定 -工具名是协议的一部分:它每个请求都要重发,还要被人、被模型、被代码里好几处引用。 -所以它有一套明写的规矩(`src/tools.c` 顶部有同一份,两处必须同步): +工具名是协议的一部分:它每个请求都要重发, 还要被人、被模型、被代码里好几处引用. +所以它有一套明写的规矩 (`src/tools.c` 顶部有同一份, 两处必须同步): -1. **形式**:全小写 snake_case,动宾结构「动词_对象」。动词必须来自白名单, - 而白名单**按域划分** —— 要用新动词,先把它加进对应域: +1. **形式**:全小写 snake_case, 动宾结构「动词_对象」. 动词必须来自白名单, + 而白名单**按域划分** —— 要用新动词, 先把它加进对应域: - 文件与执行域:`read` / `write` / `edit` / `list` / `run` / `view` - - 历史编辑域(`/meta`):`list` / `read` / `replace` / `delete` / `insert` / `undo` + - 历史编辑域 (`/meta`):`list` / `read` / `replace` / `delete` / `insert` / `undo` - 两域动词不混用。禁用 `do` / `handle` / `process` / `get` / `exec` 这类不说明对象的空动词。 -2. **名字要能推出副作用**:会改盘的名字里不会出现 `read` / `list`。 - 模型靠名字判断该不该先征求同意 —— 含糊的名字等于让它猜。 -3. 不带 `ligent` / `ai` / `tool` 这类冗余词:工具表本身就在工具命名空间里。 -4. **不与 REPL 命令同名**(`/shell`、`/cd`、`/model`、`/log`… 已被占用), - 否则人和模型两边会串。这也是执行命令的工具叫 `run_command` 而不叫 `shell` 的原因之一。 -5. 不与别的工具只在词序、单复数或大小写上不同(`read_file` / `file_read` 这种)。 -6. **长度 ≤ 24 字符**:工具表每个请求都要重发,名字长度是重复成本。 -7. **参数名与工具名同源**:路径一律 `path`,内容 `content`。同一个概念在两个工具里 - 必须同名。 -8. **能不包装就不包装**(这条是人定的,不是名字的规矩):模型手上有一个能跑任意 - 命令的 `run_command`,凡是"一条标准命令就能干、而且干得更好"的事,就别再做一个 + 两域动词不混用. 禁用 `do` / `handle` / `process` / `get` / `exec` 这类不说明对象的空动词. +2. **名字要能推出副作用**:会改盘的名字里不会出现 `read` / `list`. + 模型靠名字判断该不该先征求同意 —— 含糊的名字等于让它猜. +3. 不带 `ligent` / `ai` / `tool` 这类冗余词:工具表本身就在工具命名空间里. +4. **不与 REPL 命令同名** (`/shell`、`/cd`、`/model`、`/log`… 已被占用), + 否则人和模型两边会串. 这也是执行命令的工具叫 `run_command` 而不叫 `shell` 的原因之一. +5. 不与别的工具只在词序、单复数或大小写上不同 (`read_file` / `file_read` 这种). +6. **长度 ≤ 24 字符**:工具表每个请求都要重发, 名字长度是重复成本. +7. **参数名与工具名同源**:路径一律 `path`, 内容 `content`. 同一个概念在两个工具里 + 必须同名. +8. **能不包装就不包装** (这条是人定的, 不是名字的规矩):模型手上有一个能跑任意 + 命令的 `run_command`, 凡是"一条标准命令就能干、而且干得更好"的事, 就别再做一个 自己的版本 —— 多一个工具 = 多一份 schema、多一个要维护的边界、多一次被误用的 - 机会,换来的只是省一次 `run_command`。 + 机会, 换来的只是省一次 `run_command`. ### 为什么没有搜索工具 -曾经有 `search_files`(后来按规矩改名 `grep`),现在删了 —— 它是第 8 条规矩的 -反例:真 `grep` 支持正则、文件名过滤、上下文行(`-A/-B/-C`)、大小写、多模式、 -二进制提示,我们那版每一样都要自己写一遍,还得跟着需求慢慢长(第一版就叫错了 -名字:明明是搜内容,却叫 `search_files`,模型自然会去传 `pattern="*.c"`)。 -删掉之后工具表从 8 个变成 7 个。 +曾经有 `search_files` (后来按规矩改名 `grep`), 现在删了 —— 它是第 8 条规矩的 +反例:真 `grep` 支持正则、文件名过滤、上下文行 (`-A/-B/-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` 的输出 -全从那张表生成。所以**改名只需改 `.name` 一列** —— 这三样以前各有一份 `strcmp` -链,改名必定漏一处,而且漏了不报错(只是静默地少认一个工具)。 +名字只定义一次 (`src/tools.h` 的 `TOOL_*` 宏), 工具表也只有一处 + (`src/tools.c` 的 `TOOLS[]`):schema、执行分发、要不要走确认、`/tools` 的输出 +全从那张表生成. 所以**改名只需改 `.name` 一列** —— 这三样以前各有一份 `strcmp` +链, 改名必定漏一处, 而且漏了不报错 (只是静默地少认一个工具). -**不保留旧名别名**,这是刻意的:schema 里只发新名,dispatch 再认旧名只会掩盖 -"调用方没跟着改"(旧存档、用户自写的提示词、测试夹具都一样)。认不出来就明确报 -`未知工具 "xxx"`,比静默兼容更容易发现漏改。 +**不保留旧名别名**, 这是刻意的:schema 里只发新名, dispatch 再认旧名只会掩盖 +"调用方没跟着改" (旧存档、用户自写的提示词、测试夹具都一样). 认不出来就明确报 +`未知工具 "xxx"`, 比静默兼容更容易发现漏改. ### `read_image`:图片怎么进对话 -模型自己看不到磁盘。给它一个路径,它得先把图**读进来**——这一步就是 +模型自己看不到磁盘. 给它一个路径, 它得先把图**读进来**——这一步就是 `read_image`: ```bash ligent "看看 build/screenshot.png 里的报错是什么" ``` -内部做了四件事,每件都有它的理由: +内部做了四件事, 每件都有它的理由: -**1. 按内容认格式,不认扩展名。** 读文件头判断 PNG / JPEG / GIF / WebP。 -一个叫 `.png` 的文本文件会被明确拒绝,而不是发出去等一个莫名其妙的 400。 +**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)。**这是通用形状**: -DeepSeek 和 MiMo 都收,ligent 不去网上抓图,所以只产出 base64 这一种。 +这是协议里图片仅有的两种走法之一 (另一种是外部 URL). **这是通用形状**: +DeepSeek 和 MiMo 都收, ligent 不去网上抓图, 所以只产出 base64 这一种. -图片只能出现在 `user` 消息里,而工具结果只能是字符串。所以工具跑完之后, -ligent 会**补一条合成的 `user` 消息**承载图片。它算附件、不算提问——`/back` -按轮删除、上下文回收时都按这个规则处理,不会把它当成新的一轮。 +图片只能出现在 `user` 消息里, 而工具结果只能是字符串. 所以工具跑完之后, +ligent 会**补一条合成的 `user` 消息**承载图片. 它算附件、不算提问——`/back` +按轮删除、上下文回收时都按这个规则处理, 不会把它当成新的一轮. -**3. 但图片字节不进历史。** base64 内联有个直接后果:字节会躺在会话里, -每轮请求都重发。归档、`/history`、`/log` 全都跟着膨胀。所以**存储形态和发送 +**3. 但图片字节不进历史. ** base64 内联有个直接后果:字节会躺在会话里, +每轮请求都重发. 归档、`/history`、`/log` 全都跟着膨胀. 所以**存储形态和发送 形态是分开的**: ``` @@ -1600,223 +1600,223 @@ ligent 会**补一条合成的 `user` 消息**承载图片。它算附件、不 ``` `read_image` 先把图存到 -`<二进制目录>/ligent.archive/images/<时间戳>_<4位随机>.`,历史里只挂一个 -对备份的引用;**备份文件才是字节的真身**,真正构造请求时才读出来编码。 -`/export` 出来的存档因此还是几十 KB,而不是几十 MB。 +`<二进制目录>/ligent.archive/images/<时间戳>_<4位随机>.`, 历史里只挂一个 +对备份的引用; **备份文件才是字节的真身**, 真正构造请求时才读出来编码. +`/export` 出来的存档因此还是几十 KB, 而不是几十 MB. -两个内部块类型都带 `ligent_` 前缀,就是为了万一哪天漏了展开、直接发出去, +两个内部块类型都带 `ligent_` 前缀, 就是为了万一哪天漏了展开、直接发出去, 服务端报的错能一眼看出是我们的问题: | 内部块 | 含义 | |---|---| | `ligent_image` | 引用一张本地备份 | -| `ligent_note` | 展开失败后的文字说明(备份读不到了、体积超了) | +| `ligent_note` | 展开失败后的文字说明 (备份读不到了、体积超了) | -**4. 体积在本地就判掉。** 图片会留在历史里,所以一次超限不是一次失败, -而是从此**每一轮**都超限。与其发出去等一个 400,不如当场告诉模型"太大了"—— -而且**不备份**:一张永远发不出去的图,留着备份没有意义。 +**4. 体积在本地就判掉. ** 图片会留在历史里, 所以一次超限不是一次失败, +而是从此**每一轮**都超限. 与其发出去等一个 400, 不如当场告诉模型"太大了"—— +而且**不备份**:一张永远发不出去的图, 留着备份没有意义. -上限取各家文档里最严的一档(DeepSeek 单图 32 MiB、请求体 48 MiB;MiMo -base64 字符串 50 MB): +上限取各家文档里最严的一档 (DeepSeek 单图 32 MiB、请求体 48 MiB; MiMo +base64 字符串 50 MB): | 限制 | 值 | |---|---| -| 单张图(base64 之后的长度) | 32 MiB | -| 一次请求里图片的 base64 总量 | 44 MiB(给提示词/历史/工具描述留 4 MiB) | +| 单张图 (base64 之后的长度) | 32 MiB | +| 一次请求里图片的 base64 总量 | 44 MiB (给提示词/历史/工具描述留 4 MiB) | -注意判的是**编码后**的长度:base64 会膨胀 4/3,所以一张 25 MiB 的原图就已经 -超了。被拒时工具直接把话带给模型,让它请用户先缩小或压缩。 +注意判的是**编码后**的长度:base64 会膨胀 4/3, 所以一张 25 MiB 的原图就已经 +超了. 被拒时工具直接把话带给模型, 让它请用户先缩小或压缩. -**5. 服务端要是还是拒了呢?** 本地拦的是格式和体积,但服务端还可能因为别的 -原因不收(图片损坏、这个变体不支持……)。那条引用躺在历史里,不修的话之后 -每一轮都会以同样的 4xx 失败、历史却原样保留——用户只剩 `/reset` 一条路。 -所以 ligent 撞到 4xx 且请求里有图片时,会把图片**从历史里就地摘掉**、换成 -一句说明再重试一次。 +**5. 服务端要是还是拒了呢?** 本地拦的是格式和体积, 但服务端还可能因为别的 +原因不收 (图片损坏、这个变体不支持……). 那条引用躺在历史里, 不修的话之后 +每一轮都会以同样的 4xx 失败、历史却原样保留——用户只剩 `/reset` 一条路. +所以 ligent 撞到 4xx 且请求里有图片时, 会把图片**从历史里就地摘掉**、换成 +一句说明再重试一次. 给**模型**看的是能直接照做的一句话: ``` -[图片 1700000000_a1b2.png 不符合 API 所需的格式标准,无法上传,请规范化图片后重试] +[图片 1700000000_a1b2.png 不符合 API 所需的格式标准, 无法上传, 请规范化图片后重试] ``` -给**你**看的(stderr)是移除处数和服务端的原话 —— 原始报错通常是给人看的英文 -错误码,塞进上下文只会让模型分心,但完全不显示又会让你没法排查: +给**你**看的 (stderr)是移除处数和服务端的原话 —— 原始报错通常是给人看的英文 +错误码, 塞进上下文只会让模型分心, 但完全不显示又会让你没法排查: ``` -服务端拒绝了请求里的图片,已把 1 处图片从对话里移除后重试... +服务端拒绝了请求里的图片, 已把 1 处图片从对话里移除后重试... 服务端原话:HTTP 400 —— image rejected: unsupported image variant ``` -"就地"是要紧的:光在请求副本里摘掉,历史里那条坏引用下一轮还会再犯。 +"就地"是要紧的:光在请求副本里摘掉, 历史里那条坏引用下一轮还会再犯. -`read_image` 不写文件、不执行命令,所以**不触发写操作确认**。备份是副作用, -不是"改动你的东西"。 +`read_image` 不写文件、不执行命令, 所以**不触发写操作确认**. 备份是副作用, +不是"改动你的东西". -它会不会出现在工具表里,取决于模型收不收图片 —— 见下一节。 +它会不会出现在工具表里, 取决于模型收不收图片 —— 见下一节. ### 模型能力探测:怎么知道它收不收图片 -`read_image` 只对**能看图的模型**有意义。可是**没有任何接口能问出来** —— -DeepSeek 和 MiMo 的 `GET /v1/models` 都只返回 `id` / `object` / `owned_by`, -没有 capability 或 modality 字段(OpenAI 自己的也是如此)。这是 OpenAI 兼容层 -的通病。 +`read_image` 只对**能看图的模型**有意义. 可是**没有任何接口能问出来** —— +DeepSeek 和 MiMo 的 `GET /v1/models` 都只返回 `id` / `object` / `owned_by`, +没有 capability 或 modality 字段 (OpenAI 自己的也是如此). 这是 OpenAI 兼容层 +的通病. -所以只能**发试探请求**问。ligent 在**首次请求之前**跑一个独立的"能力测试序列", -它不碰对话历史,也不进历史: +所以只能**发试探请求**问. ligent 在**首次请求之前**跑一个独立的"能力测试序列", +它不碰对话历史, 也不进历史: | 步 | 发什么 | 结论 | |---|---|---| -| 1 | 一句纯文本 + `max_tokens: 1` | 通了才说明模型是活的,后面的否定结论才作数 | -| 2 | 同一句 + 一张 1×1 的图 + `tools` | 200 → 两样都支持,到此为止 | -| 3 | 拆开单独发(图 / tools) | 第 2 步被拒时才知道是哪一个不行 | +| 1 | 一句纯文本 + `max_tokens: 1` | 通了才说明模型是活的, 后面的否定结论才作数 | +| 2 | 同一句 + 一张 1×1 的图 + `tools` | 200 → 两样都支持, 到此为止 | +| 3 | 拆开单独发 (图 / tools) | 第 2 步被拒时才知道是哪一个不行 | -关键在于**否定结论只在基线通过时才敢下**:单个试探 400 而基线 200,差别就在 -那一样东西上。这样不需要猜服务端的错误文案("不支持图片"、"invalid content"、 -"multimodal not supported"……千家千样),也就不会因为文案变了而误判。 +关键在于**否定结论只在基线通过时才敢下**:单个试探 400 而基线 200, 差别就在 +那一样东西上. 这样不需要猜服务端的错误文案 ("不支持图片"、"invalid content"、 +"multimodal not supported"……千家千样), 也就不会因为文案变了而误判. -**猜不准时一律按"支持"处理**:多暴露一个工具,代价是模型可能白调一次(还有 -4xx 降级兜底);而错误地藏掉工具,你会以为模型看不见图。 +**猜不准时一律按"支持"处理**:多暴露一个工具, 代价是模型可能白调一次 (还有 +4xx 降级兜底); 而错误地藏掉工具, 你会以为模型看不见图. -结果按 `base_url + model` 缓存,所以**换模型会自动重测**,切回来命中缓存。 -探测完总会打一条灰字结论 —— 支持什么、不支持什么、以及我们因此**做了什么**, -都在同一行里,不用去猜: +结果按 `base_url + model` 缓存, 所以**换模型会自动重测**, 切回来命中缓存. +探测完总会打一条灰字结论 —— 支持什么、不支持什么、以及我们因此**做了什么**, +都在同一行里, 不用去猜: ``` -模型能力:多模态(图片输入)支持 * 工具调用 支持 -模型能力:多模态(图片输入)不支持(已隐藏 read_image) * 工具调用 支持 -模型能力:多模态(图片输入)未测 * 工具调用 未测 —— 没测出来的一律按「支持」处理(HTTP 401) +模型能力:多模态 (图片输入)支持 * 工具调用 支持 +模型能力:多模态 (图片输入)不支持 (已隐藏 read_image) * 工具调用 支持 +模型能力:多模态 (图片输入)未测 * 工具调用 未测 —— 没测出来的一律按「支持」处理 (HTTP 401) ``` -探测出"不支持"时把动作也写在同行,是因为只报"不支持"而不说"我已经把工具藏了", -用户会以为工具还在。 +探测出"不支持"时把动作也写在同行, 是因为只报"不支持"而不说"我已经把工具藏了", +用户会以为工具还在. -代价要说清楚:**开启探测时,每个进程的第一次请求之前会多出 2~4 个小请求** -(常见情况是 2 个,模型受限时才拆开)。不想要这个开销就用 `--no-probe` -(或配置项 `"probe": false`)—— 那时不探测、什么结论都不下、 -`read_image` 照常暴露,也就是探测功能出现之前的行为。 +代价要说清楚:**开启探测时, 每个进程的第一次请求之前会多出 2~4 个小请求** + (常见情况是 2 个, 模型受限时才拆开). 不想要这个开销就用 `--no-probe` + (或配置项 `"probe": false`)—— 那时不探测、什么结论都不下、 +`read_image` 照常暴露, 也就是探测功能出现之前的行为. -想临时测一次(比如用了 `--no-probe` 又想确认模型收不收图片),在 REPL 里敲 -**`/probe`**:它绕开 `--no-probe` 与缓存,重新跑一遍上面的序列并打出结论。 +想临时测一次 (比如用了 `--no-probe` 又想确认模型收不收图片), 在 REPL 里敲 +**`/probe`**:它绕开 `--no-probe` 与缓存, 重新跑一遍上面的序列并打出结论. #### 为什么最后没走 Files API -早先这一路走的是 Files API:上传换一个 `file_id`,请求里只带引用。它确实能 -让历史更小,但**它不是一个标准**。只有最外面那层(`POST /v1/files` + -multipart + `purpose`)因为抄 OpenAI 而看着像标准;真正要紧的"在对话请求里 -怎么引用"各家完全不同——OpenAI 只在 Responses API 里收 `input_file`, -**Chat Completions 根本不收 `file_id`**;Google 用 `file_data.file_uri`; -Anthropic 的图像走 `source`;MiMo 干脆明确写了不支持上传本地文件。 +早先这一路走的是 Files API:上传换一个 `file_id`, 请求里只带引用. 它确实能 +让历史更小, 但**它不是一个标准**. 只有最外面那层 (`POST /v1/files` + +multipart + `purpose`)因为抄 OpenAI 而看着像标准; 真正要紧的"在对话请求里 +怎么引用"各家完全不同——OpenAI 只在 Responses API 里收 `input_file`, +**Chat Completions 根本不收 `file_id`**; Google 用 `file_data.file_uri`; +Anthropic 的图像走 `source`; MiMo 干脆明确写了不支持上传本地文件. -也就是说 `{"type": "file", "file_id": …}` 这条路**只有 DeepSeek 一家认**。 -而 `image_url` + base64 内联是 DeepSeek 和 MiMo 都认的通用形状(OpenAI 兼容层 -普遍也认)。既然"图片不进历史"这件事可以靠**本机备份 + 请求前展开**自己做到, -就没有理由再为它绑定一个厂商私有的引用协议。 +也就是说 `{"type": "file", "file_id": …}` 这条路**只有 DeepSeek 一家认**. +而 `image_url` + base64 内联是 DeepSeek 和 MiMo 都认的通用形状 (OpenAI 兼容层 +普遍也认). 既然"图片不进历史"这件事可以靠**本机备份 + 请求前展开**自己做到, +就没有理由再为它绑定一个厂商私有的引用协议. -代价写在 §9 里,不藏着。思考模式则仍然是方言(见 §9),那一条没有通用替代。 +代价写在 §9 里, 不藏着. 思考模式则仍然是方言 (见 §9), 那一条没有通用替代. > ⚠️ **安全提醒**:按需求本工具**不做沙箱限制**——模型可以执行任意 shell 命令、 -> 读写任意路径。建议在容器、虚拟机或独立用户下运行,不要在存有重要数据的 -> 生产环境里直接把 `--no-tools` 之外的限制交给模型自觉。 +> 读写任意路径. 建议在容器、虚拟机或独立用户下运行, 不要在存有重要数据的 +> 生产环境里直接把 `--no-tools` 之外的限制交给模型自觉. ## 6. 源码结构 ``` src/ util.c/h xmalloc、动态字符串缓冲、文件读取、mkdir -p、${VAR} 展开 - json.c/h 递归下降 JSON 解析器;UTF-8/代理对解码;构造器;紧凑/美化序列化 - http.c/h libcurl 薄封装:同步 POST、流式 POST(header 回调取状态码、 - 写回调分流错误体)、Ctrl-C 中断 - llm.c/h OpenAI 兼容客户端:请求体构造(含 thinking / reasoning_effort)、 + json.c/h 递归下降 JSON 解析器; UTF-8/代理对解码; 构造器; 紧凑/美化序列化 + http.c/h libcurl 薄封装:同步 POST、流式 POST (header 回调取状态码、 + 写回调分流错误体)、Ctrl-C 中断 + llm.c/h OpenAI 兼容客户端:请求体构造 (含 thinking / reasoning_effort)、 SSE 逐行解析、tool_calls 按 index 增量拼接、 reasoning_content 累积与回传、非流式回退、错误体可读化 - 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 感知, - 以及输入历史的持久化(实时追加 + 启动加载 + 上限截短) + 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 感知, + 以及输入历史的持久化 (实时追加 + 启动加载 + 上限截短) doctor.c/h 环境体检:逐环检查并给出可操作的结论 - archive.c/h 对话存档:导出/列举/载入(文件名即谱系) + archive.c/h 对话存档:导出/列举/载入 (文件名即谱系) image.c/h 图片:按文件头认格式、本机备份、请求前把备份展开成 base64 内联 shell.c/h /shell:伪终端双向搬运 + 录制 - meta.c/h /meta:元编辑对话历史(独立工具集 + 快照栈 + 结构安全校验) - spinner.c/h 等待动画:两段标签(等待响应 → 思考中),后台线程, + meta.c/h /meta:元编辑对话历史 (独立工具集 + 快照栈 + 结构安全校验) + spinner.c/h 等待动画:两段标签 (等待响应 → 思考中), 后台线程, 只在 stderr 是终端时启用 - i18n.c/h 界面文案表(中英各一份)、语言探测、{name} 占位符替换 - markdown.c/h 流式 Markdown 渲染状态机(标记跨分片挂起、表格列宽对齐、 - 近似 wcwidth 的 CJK 宽度计算) + i18n.c/h 界面文案表 (中英各一份)、语言探测、{name} 占位符替换 + markdown.c/h 流式 Markdown 渲染状态机 (标记跨分片挂起、表格列宽对齐、 + 近似 wcwidth 的 CJK 宽度计算) agent.c/h 主循环:消息历史管理、多步工具调用、流式打印 config.c/h 默认值 → 配置文件 → 环境变量 main.c 参数解析、单次任务模式、REPL tools/ - md_demo.c 渲染效果演示程序(独立可执行,不依赖网络) + md_demo.c 渲染效果演示程序 (独立可执行, 不依赖网络) tests/ test_json.c JSON 解析/序列化/转义/非法输入/嵌套深度 - test_tools.c 工具的端到端行为(真实执行命令、真实读写文件) - test_agent.c 上下文不变量(system 不重复、reset 语义) + test_tools.c 工具的端到端行为 (真实执行命令、真实读写文件) + test_agent.c 上下文不变量 (system 不重复、reset 语义) test_config.c 默认值、环境变量优先级、配置文件覆盖与 ${VAR} 展开 - test_markdown.c Markdown 渲染 + 分片不变性(一次性/3字节/逐字节 输出一致) + test_markdown.c Markdown 渲染 + 分片不变性 (一次性/3字节/逐字节 输出一致) fuzz.c 对抗性模糊测试:随机/变异输入砸 JSON 与 Markdown 解析器 reduce.c delta-debugging 最小化器:把失败输入缩到最短复现 - test_http.c HTTP 层纯逻辑(重试判定、上限取值) - test_lineedit.c 行编辑器缓冲区与历史(含 Home/End 在逻辑行内的语义、 - 历史文件转义往返 / 上限截短 / 文件不存在) - test_spinner.c 等待动画的两段标签与幂等性(把 stderr 接到临时文件后读回断言) + test_http.c HTTP 层纯逻辑 (重试判定、上限取值) + test_lineedit.c 行编辑器缓冲区与历史 (含 Home/End 在逻辑行内的语义、 + 历史文件转义往返 / 上限截短 / 文件不存在) + test_spinner.c 等待动画的两段标签与幂等性 (把 stderr 接到临时文件后读回断言) test_meta.c 元编辑:轮次切分、结构安全、七个工具、撤销、重新存根标记 test_image.c 图片:格式嗅探、备份与索引、附件队列、损坏索引降级 - test_capability.c 能力探测的纯逻辑(名字映射、关掉探测时的语义、缓存查询) - test_models.c /models 响应体解析(标准形状、裸数组、去重、坏条目、空列表) + test_capability.c 能力探测的纯逻辑 (名字映射、关掉探测时的语义、缓存查询) + test_models.c /models 响应体解析 (标准形状、裸数组、去重、坏条目、空列表) test_attach.c /include:glob 展开、二进制/超限跳过、图片引用、回调只在遇图时问 - repl_edit_check.py 带极简终端模拟器,验证方向键/Home/End/退格/历史/多行粘贴 - 真的生效(只对比字节流证明不了光标移动) - repl_drive.py 用 pty 驱动 REPL 跑多轮对话(管道模式无法测多轮行为) - mock_server.py 最小 OpenAI 兼容 mock,含 SSE 与分片 tool_calls + repl_edit_check.py 带极简终端模拟器, 验证方向键/Home/End/退格/历史/多行粘贴 + 真的生效 (只对比字节流证明不了光标移动) + repl_drive.py 用 pty 驱动 REPL 跑多轮对话 (管道模式无法测多轮行为) + mock_server.py 最小 OpenAI 兼容 mock, 含 SSE 与分片 tool_calls run_e2e.sh 端到端:流式、非流式、多步工具、错误处理、缺 key、管道、渲染 ``` ### 几个实现细节 -- **tool_calls 增量拼接**:流式响应里 `function.arguments` 会被服务端切成任意碎片, - 客户端按 `index` 累积成完整 JSON 字符串后再整体解析,而不是每片单独解析。 -- **错误响应可读化**:非 2xx 时解析 `{"error": {"message": "..."}}` 并解码 `\uXXXX`, - 而不是把原始 JSON 甩给用户。 -- **不依赖流式**:如果服务端忽略 `stream` 参数直接返回整包 JSON,客户端会自动回退解析。 -- **行号集合钉住的替换**:`edit_file` 的 `line`(`read_file` 给的行号,整数或整数 - 数组都收)把替换钉在集合圈住的行里,且只要行内唯一 —— 从机制上避免模型改错地方, - 也不逼它为了"全文唯一"把 `old_string` 写成一整段。`line` 可以省略,省略就是 - **全文件替换所有匹配**:逃生门留着(有些改动本来就该全文件生效),但它不是默认 - 路径 —— 要改一处仍得给行号。 +- **tool_calls 增量拼接**:流式响应里 `function.arguments` 会被服务端切成任意碎片, + 客户端按 `index` 累积成完整 JSON 字符串后再整体解析, 而不是每片单独解析. +- **错误响应可读化**:非 2xx 时解析 `{"error": {"message": "..."}}` 并解码 `\uXXXX`, + 而不是把原始 JSON 甩给用户. +- **不依赖流式**:如果服务端忽略 `stream` 参数直接返回整包 JSON, 客户端会自动回退解析. +- **行号集合钉住的替换**:`edit_file` 的 `line` (`read_file` 给的行号, 整数或整数 + 数组都收)把替换钉在集合圈住的行里, 且只要行内唯一 —— 从机制上避免模型改错地方, + 也不逼它为了"全文唯一"把 `old_string` 写成一整段. `line` 可以省略, 省略就是 + **全文件替换所有匹配**:逃生门留着 (有些改动本来就该全文件生效), 但它不是默认 + 路径 —— 要改一处仍得给行号. ## 7. 测试 ```bash make test # 全部 -make test-unit # 纯单元测试,不需要网络(含演示程序自检 + 模糊测试) -make test-e2e # 用本地 mock 服务端验证完整链路,不需要真实 API key -make test-fuzz # 只跑模糊测试,迭代更多轮次 +make test-unit # 纯单元测试, 不需要网络 (含演示程序自检 + 模糊测试) +make test-e2e # 用本地 mock 服务端验证完整链路, 不需要真实 API key +make test-fuzz # 只跑模糊测试, 迭代更多轮次 make demo # 编译并运行渲染效果演示 ``` ### 模糊测试 -`tests/fuzz.c` 用随机与变异输入去砸两个"解析不可信数据"的模块(JSON 解析器、 -Markdown 渲染器),检查四条不变量: +`tests/fuzz.c` 用随机与变异输入去砸两个"解析不可信数据"的模块 (JSON 解析器、 +Markdown 渲染器), 检查四条不变量: | 模块 | 不变量 | |---|---| | JSON | 任何输入都不崩溃、不泄漏 | -| JSON | 解析成功 ⇒ dump → parse → dump 两次结果一致(幂等) | +| JSON | 解析成功 ⇒ dump → parse → dump 两次结果一致 (幂等) | | Markdown | 任何输入都不崩溃、不泄漏 | | Markdown | **一次性喂 与 任意分片喂 输出逐字节一致** | | Markdown | 不含元字符的输入必须原样输出 | -种子固定,失败可复现。失败时会把输入写到 `build/md_fail.bin`, -用 `build/reduce build/md_fail.bin` 可以自动最小化成最短复现样例。 +种子固定, 失败可复现. 失败时会把输入写到 `build/md_fail.bin`, +用 `build/reduce build/md_fail.bin` 可以自动最小化成最短复现样例. `tests/reduce.c` 是个 delta-debugging 最小化器 —— 上面那条"表格必须顶格" -的规则,就是靠它把 376 字节的失败输入缩到 4 字节才定位到根因的。 +的规则, 就是靠它把 376 字节的失败输入缩到 4 字节才定位到根因的. ```bash make build/fuzz build/reduce @@ -1825,14 +1825,14 @@ make build/fuzz build/reduce ``` 端到端测试不需要联网也不需要密钥:`tests/mock_server.py` 会在本地起一个 -OpenAI 兼容服务端,按请求内容返回确定性的流式响应,从而验证「模型请求工具 → -本地真实执行 → 结果回填 → 模型给出最终回答」的完整闭环。 +OpenAI 兼容服务端, 按请求内容返回确定性的流式响应, 从而验证「模型请求工具 → +本地真实执行 → 结果回填 → 模型给出最终回答」的完整闭环. -> 存档目录(`<二进制目录>/ligent.archive`)是运行期状态而不是构建产物,e2e 却 -> 需要它干净,所以脚本开机时会检查:**非空就先整体挪到 `/tmp/ligent.archive.saved.*`** -> 并打印路径,而不是直接删掉。移不动就直接中止(宁可测试不跑,也不误删)。 +> 存档目录 (`<二进制目录>/ligent.archive`)是运行期状态而不是构建产物, e2e 却 +> 需要它干净, 所以脚本开机时会检查:**非空就先整体挪到 `/tmp/ligent.archive.saved.*`** +> 并打印路径, 而不是直接删掉. 移不动就直接中止 (宁可测试不跑, 也不误删). > 挪走之后 e2e 才会在自己的那份临时目录里折腾 —— 跑两次也不会把你上一轮的 -> 存档叠进去。 +> 存档叠进去. 用 AddressSanitizer / UBSan 跑一遍: @@ -1842,39 +1842,39 @@ make SAN=1 test-unit ``` `SAN=1` 在默认 `CFLAGS` 之上追加 -`-fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all`, -链接行也带上(只插桩目标文件、不进运行时库的话检测器根本不生效)。 -`-fno-sanitize-recover=all` 让报错直接中止而不是"报告后继续跑",免得刷屏。 -整套单元测试(16 个可执行、3692 项)在这个配置下是干净的。 +`-fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all`, +链接行也带上 (只插桩目标文件、不进运行时库的话检测器根本不生效). +`-fno-sanitize-recover=all` 让报错直接中止而不是"报告后继续跑", 免得刷屏. +整套单元测试 (16 个可执行、3692 项)在这个配置下是干净的. ## 8. 稳定性设计 -这一节记录为了让它在真实网络和真实模型下不崩、不卡死所做的处理。 +这一节记录为了让它在真实网络和真实模型下不崩、不卡死所做的处理. ### 网络层 | 机制 | 说明 | |---|---| -| 瞬态失败重试 | 408/409/429/5xx 与连接级失败(DNS/连接/收发/半包)自动重试,指数退避 + 抖动,并遵循服务端 `Retry-After` | -| 不重试请求超时 | 超时意味着服务端**可能已经处理并计费**了,重发会重复扣费,所以宁可失败 | -| 已输出则不重试 | 流式一旦向终端吐过内容就不再重试,否则用户会看到重复输出 | -| 响应体上限 | 声明或实际超过 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 都会把结果截断) | +| 瞬态失败重试 | 408/409/429/5xx 与连接级失败 (DNS/连接/收发/半包)自动重试, 指数退避 + 抖动, 并遵循服务端 `Retry-After` | +| 不重试请求超时 | 超时意味着服务端**可能已经处理并计费**了, 重发会重复扣费, 所以宁可失败 | +| 已输出则不重试 | 流式一旦向终端吐过内容就不再重试, 否则用户会看到重复输出 | +| 响应体上限 | 声明或实际超过 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 都会把结果截断) | | 错误体上限 | 只保留 256KB 用于诊断 | -重试次数可配置:`--retries N`(默认 2,0 = 不重试)/ `LIGENT_RETRIES` / 配置项 `retries`。 +重试次数可配置:`--retries N` (默认 2, 0 = 不重试)/ `LIGENT_RETRIES` / 配置项 `retries`. ### 传输中断 -流式请求中途断连时,**已经收到的部分内容会保留**成一条消息并提示"传输中断", -而不是整段丢弃 —— 否则屏幕上有这段文字、上下文里却没有,后续对话会对不上。 +流式请求中途断连时, **已经收到的部分内容会保留**成一条消息并提示"传输中断", +而不是整段丢弃 —— 否则屏幕上有这段文字、上下文里却没有, 后续对话会对不上. -### 上下文超限:如实报错,不替你做决定 +### 上下文超限:如实报错, 不替你做决定 -历史超过模型窗口时,服务端回一个 4xx。ligent **不会自动删掉任何一条消息** —— -它把服务端的原始错误原样报出来,历史一个字节都不动: +历史超过模型窗口时, 服务端回一个 4xx. ligent **不会自动删掉任何一条消息** —— +它把服务端的原始错误原样报出来, 历史一个字节都不动: ``` ligent> 第二轮 @@ -1882,25 +1882,25 @@ 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`。它被**整个删掉**了,理由: +这里曾经有一整套"丢弃最老的一整轮再重试 (最多 5 次)"的机制, 配套 +`--no-auto-trim` / `auto_trim` / `LIGENT_AUTO_TRIM`. 它被**整个删掉**了, 理由: -- 触发它的判据只是错误文案里的关键词(`too long`、`上下文`、`超出`……), - 任何 4xx 蹭到这些词都会触发裁剪 —— 代价却是**不可逆地**删掉几轮对话。 - 用一句猜出来的判据去毁掉对话记录,代价不对等。 -- 它把"历史太大了"这件本该由用户决定的事(`/back` 摘掉哪一轮、`/reset` 重来、 - 还是换一个窗口更大的模型)自动化掉了;用户看到的是对话莫名其妙少了几轮。 +- 触发它的判据只是错误文案里的关键词 (`too long`、`上下文`、`超出`……), + 任何 4xx 蹭到这些词都会触发裁剪 —— 代价却是**不可逆地**删掉几轮对话. + 用一句猜出来的判据去毁掉对话记录, 代价不对等. +- 它把"历史太大了"这件本该由用户决定的事 (`/back` 摘掉哪一轮、`/reset` 重来、 + 还是换一个窗口更大的模型)自动化掉了; 用户看到的是对话莫名其妙少了几轮. -现在怎么处理由你决定:`/back` 摘掉最近一轮(可 `/unback` 撤销)、`/reset` 清空 -重来、`/export` 存档后另起一段,或者直接换个模型。`/usage` 里的"最近一次输入 -token"≈ 当前上下文大小,可以据此判断还剩多少余量。 +现在怎么处理由你决定:`/back` 摘掉最近一轮 (可 `/unback` 撤销)、`/reset` 清空 +重来、`/export` 存档后另起一段, 或者直接换个模型. `/usage` 里的"最近一次输入 +token"≈ 当前上下文大小, 可以据此判断还剩多少余量. -(`/meta` 里让模型帮你做这件事仍然是可行的 —— 它删的是你看得见、也点过头的那些轮。) + (`/meta` 里让模型帮你做这件事仍然是可行的 —— 它删的是你看得见、也点过头的那些轮. ) ### 对话结构不变量:tool_calls 必须连着 tool 服务端对消息结构的要求很硬:**带 `tool_calls` 的 assistant 后面必须紧跟**与每个 -`tool_call_id` 一一对应的 `tool` 消息,中间不能夹别的东西,也不能少。违反了就是: +`tool_call_id` 一一对应的 `tool` 消息, 中间不能夹别的东西, 也不能少. 违反了就是: ``` HTTP 400 — An assistant message with 'tool_calls' must be followed by tool @@ -1908,47 +1908,47 @@ messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message) ``` -麻烦在于这条历史**留在上下文里**——一旦写坏,之后每一轮都会以同样的 400 失败, -用户只能 `/reset`。所以 ligent 两头都做了: +麻烦在于这条历史**留在上下文里**——一旦写坏, 之后每一轮都会以同样的 400 失败, +用户只能 `/reset`. 所以 ligent 两头都做了: -**发之前先体检。** 每次构造请求前扫一遍历史:缺的 `tool` 消息补一条占位 -("这次调用没有拿到结果,不要假设它执行过"),孤儿 `tool` 消息(找不到对应 -调用的)丢掉,并如实打一行说明。没问题时它只是一次只读扫描,不分配任何东西。 +**发之前先体检. ** 每次构造请求前扫一遍历史:缺的 `tool` 消息补一条占位 + ("这次调用没有拿到结果, 不要假设它执行过"), 孤儿 `tool` 消息 (找不到对应 +调用的)丢掉, 并如实打一行说明. 没问题时它只是一次只读扫描, 不分配任何东西. ``` -对话结构有问题(assistant 的 tool_calls 与 tool 消息对不上),已自动修复 1 处 -—— 不修的话之后每一轮都会被服务端拒绝。 +对话结构有问题 (assistant 的 tool_calls 与 tool 消息对不上), 已自动修复 1 处 +—— 不修的话之后每一轮都会被服务端拒绝. ``` -**产生的时候别写坏。** 体检是兜底,正常路径不该走到它。两个真实踩过的坑: +**产生的时候别写坏. ** 体检是兜底, 正常路径不该走到它. 两个真实踩过的坑: -- **图片附件的位置。** 图片只能出现在 `user` 消息里,而 `tool` 消息的内容只能是 - 字符串,所以 `read_image` 之后要补一条 `user` 消息。曾经是"每推完一条 `tool` - 就补一次",于是一条 assistant 带两个调用、第一个是读图时,历史成了 - `assistant / tool / user(附件) / tool` —— 中间那条 `user` 把 `tool` 段截断了。 - 现在附件**攒到所有 `tool` 消息都推完之后**再补。 -- **中断。** 一条 assistant 带多个工具调用时按 `Ctrl-C`,以前直接 `return`, - 剩下的调用没人回应 —— 同一个 400。现在给没跑成的调用补一条"用户中断了本轮, - 本次调用没有执行"的 `tool` 消息再返回。 +- **图片附件的位置. ** 图片只能出现在 `user` 消息里, 而 `tool` 消息的内容只能是 + 字符串, 所以 `read_image` 之后要补一条 `user` 消息. 曾经是"每推完一条 `tool` + 就补一次", 于是一条 assistant 带两个调用、第一个是读图时, 历史成了 + `assistant / tool / user(附件) / tool` —— 中间那条 `user` 把 `tool` 段截断了. + 现在附件**攒到所有 `tool` 消息都推完之后**再补. +- **中断. ** 一条 assistant 带多个工具调用时按 `Ctrl-C`, 以前直接 `return`, + 剩下的调用没人回应 —— 同一个 400. 现在给没跑成的调用补一条"用户中断了本轮, + 本次调用没有执行"的 `tool` 消息再返回. -回归用例是第 53 段,而且 `tests/mock_server.py` **和真服务端一样校验这个结构** -(`pairing_error`):以后任何写坏历史的回归都会在 e2e 里当场 400,而不是等到 -用户真拿它去连服务端才发现。 +回归用例是第 53 段, 而且 `tests/mock_server.py` **和真服务端一样校验这个结构** + (`pairing_error`):以后任何写坏历史的回归都会在 e2e 里当场 400, 而不是等到 +用户真拿它去连服务端才发现. ### 图片降级只在错误确实冲着图片来时动手 -服务端 4xx 时会检查请求里有没有图片引用,有就把图片从历史里摘掉再重试一次 -(见 §5)。这一条**加了关键词判断**:只有错误里出现 `image` / `vision` / -`multimodal` / `content type` / `图片` 这类词才动手。 +服务端 4xx 时会检查请求里有没有图片引用, 有就把图片从历史里摘掉再重试一次 + (见 §5). 这一条**加了关键词判断**:只有错误里出现 `image` / `vision` / +`multimodal` / `content type` / `图片` 这类词才动手. 理由是它会把图片**永久删掉**:一条"tool_calls 后面缺 tool 消息"的 400 也被当成 -图片问题处理的话,图白丢,而且真正的错误被我们的提示盖住了 —— 排障方向直接被 -带偏。**不确定就不动手**,宁可让错误如实报出来。这终究是启发式;主要防线还是 -发送前就把格式和体积查掉。 +图片问题处理的话, 图白丢, 而且真正的错误被我们的提示盖住了 —— 排障方向直接被 +带偏. **不确定就不动手**, 宁可让错误如实报出来. 这终究是启发式; 主要防线还是 +发送前就把格式和体积查掉. -### 提权:模型请求,你输密码 +### 提权:模型请求, 你输密码 -`run_command` 有一个 `elevated` 参数。模型撞到权限墙时带上它,那一条命令就以 +`run_command` 有一个 `elevated` 参数. 模型撞到权限墙时带上它, 那一条命令就以 root 执行: ``` @@ -1962,40 +1962,40 @@ run_command {"command": "apt install -y nginx", "elevated": true} [sudo] password for pluv: ← 真正的授权在这里 ``` -**先说清楚为什么没有 `/su`、也没有"授权提示"。** +**先说清楚为什么没有 `/su`、也没有"授权提示". ** -- **`su` 能变 root,不是因为 PAM 验证了密码**,而是因为 `/usr/bin/su` 本身是 - setuid root:内核先把 root 给它,它再验密码、验不过就降权。普通进程调 - `pam_authenticate()` 只能得到「密码对不对」,uid 一位都不变。要让 ligent - 内部认证后真的变 root,唯一的路是把它编译成 setuid root —— 而这个程序的功能 - 是「让模型执行任意命令」,setuid root 的它等于把 root 远程执行做成一等功能。 - **那条路没走。** -- **也不需要 ligent 再发明一道 y/N 授权**。密码本身就是不可伪造的授权:模型敲 - 不出来,你也不可能手滑敲出来。前面再加一道"允许吗"只会是多余的摩擦,还会给出 - 一个虚假的安全模型 —— 好像把关的是 ligent 的提示,其实把关的是 sudo。 -- 所以没有常驻提权状态,也就没有"忘了降权"这回事。**一次命令一次身份**; - 确认提示负责让你看清命令,sudo 负责授权,各司其职。 +- **`su` 能变 root, 不是因为 PAM 验证了密码**, 而是因为 `/usr/bin/su` 本身是 + setuid root:内核先把 root 给它, 它再验密码、验不过就降权. 普通进程调 + `pam_authenticate()` 只能得到「密码对不对」, uid 一位都不变. 要让 ligent + 内部认证后真的变 root, 唯一的路是把它编译成 setuid root —— 而这个程序的功能 + 是「让模型执行任意命令」, setuid root 的它等于把 root 远程执行做成一等功能. + **那条路没走. ** +- **也不需要 ligent 再发明一道 y/N 授权**. 密码本身就是不可伪造的授权:模型敲 + 不出来, 你也不可能手滑敲出来. 前面再加一道"允许吗"只会是多余的摩擦, 还会给出 + 一个虚假的安全模型 —— 好像把关的是 ligent 的提示, 其实把关的是 sudo. +- 所以没有常驻提权状态, 也就没有"忘了降权"这回事. **一次命令一次身份**; + 确认提示负责让你看清命令, sudo 负责授权, 各司其职. | 环节 | 做法 | 为什么 | |---|---|---| -| 传参 | `sudo -u root -- ` | argv 形式,**不经 shell** —— 命令里的引号、换行、`;` 注入不进 root 那条命令 | -| 认证 | **不带 `-n`** | `-n` 是"不许提问",带上它需要密码时就直接失败;而这里要的正是让 sudo 能问 | -| 终端 | 提权路径**不 `setsid`** | sudo 是打开 `/dev/tty` 问密码的,摘掉控制终端它就永远问不出来 | -| 输出 | 仍然接管道 | 命令输出照常捕获回填给模型,和普通 `run_command` 一样 | -| 没有终端 | **直接拒绝,不去试** | 否则就是一个在后台等一个没人能看见的密码提示的进程 | +| 传参 | `sudo -u root -- ` | argv 形式, **不经 shell** —— 命令里的引号、换行、`;` 注入不进 root 那条命令 | +| 认证 | **不带 `-n`** | `-n` 是"不许提问", 带上它需要密码时就直接失败; 而这里要的正是让 sudo 能问 | +| 终端 | 提权路径**不 `setsid`** | sudo 是打开 `/dev/tty` 问密码的, 摘掉控制终端它就永远问不出来 | +| 输出 | 仍然接管道 | 命令输出照常捕获回填给模型, 和普通 `run_command` 一样 | +| 没有终端 | **直接拒绝, 不去试** | 否则就是一个在后台等一个没人能看见的密码提示的进程 | -想立刻收回授权:在 `/shell` 里敲 `sudo -k`,或者等 sudo 自己的超时。 +想立刻收回授权:在 `/shell` 里敲 `sudo -k`, 或者等 sudo 自己的超时. -**踩过的坑,值得单独记**:第一版把认证也放进通用的 `run_capture()`,而它 -`setsid()` + 全接管道 —— 真机上认证 **100% 失败**。更糟的是当时的 e2e 替身 -sudo 无条件成功,给了个**假绿**。现在替身必须像真 sudo 一样要求终端,并且有一条 -断言专门盯"认证时 sudo 真的拿到了终端";`tests/test_priv.c` 还钉住了"包装里不许 -出现 `-n`"和"没终端时能识别出来"这两条。 +**踩过的坑, 值得单独记**:第一版把认证也放进通用的 `run_capture()`, 而它 +`setsid()` + 全接管道 —— 真机上认证 **100% 失败**. 更糟的是当时的 e2e 替身 +sudo 无条件成功, 给了个**假绿**. 现在替身必须像真 sudo 一样要求终端, 并且有一条 +断言专门盯"认证时 sudo 真的拿到了终端"; `tests/test_priv.c` 还钉住了"包装里不许 +出现 `-n`"和"没终端时能识别出来"这两条. ### 排障:/log 导出收发记录 -进程会把每一次请求的**请求体**、响应头、以及响应原文(流式就是原始 SSE 字节) -滚动缓存在内存里。不给这个问题事先准备什么,出事了再捞: +进程会把每一次请求的**请求体**、响应头、以及响应原文 (流式就是原始 SSE 字节) +滚动缓存在内存里. 不给这个问题事先准备什么, 出事了再捞: ``` ligent> /log # 看看现在缓存了多少 @@ -2003,16 +2003,16 @@ ligent> /log /tmp/session.log # 把已经积累的全部追加写入文件 ligent> /log clear # 清空缓存 ``` -记录里**不含请求头**,所以不会把 API key 写进去(有测试断言)。 +记录里**不含请求头**, 所以不会把 API key 写进去 (有测试断言). -它的真正用途是:**录一次真实会话,之后离线回放**。ligent 的协议适配一直只在 -自建的 mock 服务端上验证过,而 mock 是我们自己写的 —— 它接受什么,完全取决于 -我们**以为**服务端会接受什么。有了这个文件,真实服务端的响应就能当成测试夹具 -反复使用,不必每次都烧 token。 +它的真正用途是:**录一次真实会话, 之后离线回放**. ligent 的协议适配一直只在 +自建的 mock 服务端上验证过, 而 mock 是我们自己写的 —— 它接受什么, 完全取决于 +我们**以为**服务端会接受什么. 有了这个文件, 真实服务端的响应就能当成测试夹具 +反复使用, 不必每次都烧 token. -`tests/replay.py` 就是这个回放器。它不重写一个"模拟客户端",而是当**离线 -服务端**:把录下来的响应原样发给一个真的 ligent,于是 `http.c` 的分块读取、 -`llm.c` 的 SSE 行切分与 `tool_calls` 增量重组,全都在真实字节上跑一遍。 +`tests/replay.py` 就是这个回放器. 它不重写一个"模拟客户端", 而是当**离线 +服务端**:把录下来的响应原样发给一个真的 ligent, 于是 `http.c` 的分块读取、 +`llm.c` 的 SSE 行切分与 `tool_calls` 增量重组, 全都在真实字节上跑一遍. ```bash python3 tests/replay.py session.log --summary # 这份录制里有什么 @@ -2022,121 +2022,121 @@ python3 tests/replay.py --serve 8899 session.log --strict # 当离线服务端 ``` `--strict` 是它最有用的一半:它把每个进来的请求体与录制时那一份做**结构比对** -(顶层键、消息角色序列、工具名集合),不一致就回 400 并在 stderr 打印差异。 + (顶层键、消息角色序列、工具名集合), 不一致就回 400 并在 stderr 打印差异. 于是"今天的客户端还在发同样的报文形状"变成了一条**会失败的断言**——改坏了 -报文而没发现,从此不可能。 +报文而没发现, 从此不可能. -回放时要给 ligent 加 `--no-probe`:能力探测会插进额外的请求,而那几轮不在 -录制里(录制时也建议 `LIGENT_PROBE=0`,两边一致)。录制用完之后回放器会明确 -报"没有更多响应",而不是静静回错东西。 +回放时要给 ligent 加 `--no-probe`:能力探测会插进额外的请求, 而那几轮不在 +录制里 (录制时也建议 `LIGENT_PROBE=0`, 两边一致). 录制用完之后回放器会明确 +报"没有更多响应", 而不是静静回错东西. -仓库里冻了两份夹具(`tests/fixtures/`),都是对着 mock 录的 —— mock 故意把 -SSE 切成 1 字符、把 `tool_calls` 也切碎,所以它们冻结的正是最值得冻结的形状: +仓库里冻了两份夹具 (`tests/fixtures/`), 都是对着 mock 录的 —— mock 故意把 +SSE 切成 1 字符、把 `tool_calls` 也切碎, 所以它们冻结的正是最值得冻结的形状: | 夹具 | 冻住的是 | |---|---| | `session.log` | 流式 SSE + 被切碎的 `tool_calls` 重组 + 工具往返 + 最终回答 | -| `error.log` | 一个 400 错误体,以及错误信息的可读化 | +| `error.log` | 一个 400 错误体, 以及错误信息的可读化 | -**录一份真实会话**(你自己的 key,跑一次真问题): +**录一份真实会话** (你自己的 key, 跑一次真问题): ```bash -ligent # 正常聊,直到复现出你要冻住的那个行为 +ligent # 正常聊, 直到复现出你要冻住的那个行为 ligent> /log session.log # 导出 python3 tests/replay.py session.log --summary ``` -之后把它放进 `tests/fixtures/` 并加段断言,那次真实故障就永久变成了测试。 +之后把它放进 `tests/fixtures/` 并加段断言, 那次真实故障就永久变成了测试. -内存上限 16MB,超出后丢弃较早的部分(排障时通常更关心刚刚发生了什么), -导出时会在文件开头注明发生了截断。 +内存上限 16MB, 超出后丢弃较早的部分 (排障时通常更关心刚刚发生了什么), +导出时会在文件开头注明发生了截断. -> 注意:`/log` 是 REPL 命令。单次任务模式(`ligent "任务"`)下记录同样会累积, -> 但没有地方把它导出来。 +> 注意:`/log` 是 REPL 命令. 单次任务模式 (`ligent "任务"`)下记录同样会累积, +> 但没有地方把它导出来. ### 对抗性测试 -`tests/fuzz.c` 用随机与变异输入砸两个"解析不可信数据"的模块(JSON 解析器、 -Markdown 渲染器);`tests/reduce.c` 把失败输入最小化成最短复现。 -详见下一节。 +`tests/fuzz.c` 用随机与变异输入砸两个"解析不可信数据"的模块 (JSON 解析器、 +Markdown 渲染器); `tests/reduce.c` 把失败输入最小化成最短复现. +详见下一节. ## 9. 已知限制 -- 界面文案只有中英两份,而且只管"给人看"的部分:**工具描述、工具回执、 - 系统提示词不随界面语言变**(那些是给模型的信息)。切成英文之后模型收到的 - 仍然是中文回执 —— 想让模型侧也换语言,得改提示词本身。 -- 只在 Linux 上验证过(用到 `fork/setsid/pipe/poll`、`fnmatch`、POSIX 正则)。 -- `--timeout` 只对非流式请求生效;流式请求靠 120 秒无数据即断开的低速检测。 -- 历史超过模型窗口时**只报错,不自动回收**(见§8):要不要丢几轮、丢哪几轮由你 - 用 `/back`、`/reset`、`/meta` 决定。单轮内工具输出过大(超过上限被截断)时, - 只能靠模型自己用 `offset/limit` 分段读。 -- 每轮都会重发完整历史,token 成本随轮数平方增长。 -- 终端交互是行式 REPL,不是全屏 TUI:有行内编辑与历史,但**没有 Tab 补全**。 -- 对话历史本身只存在进程内,退出即丢 —— 要留就 `/export`。 - (输入历史是持久化的,见下一节。) -- Markdown 渲染不处理嵌套列表缩进。 -- 提权只覆盖 `run_command`(含 `/shell`)。文件类工具还没走提权通道,要动受 - 保护的文件得在命令里自己用 sudo。 -- 提权依赖系统的 `sudo`;不支持 `su`(它需要终端且不缓存凭据)。 -- 提权命令不独立成进程组(要保住控制终端给 sudo 问密码),所以超时只能杀直接 - 子进程,不能整组杀。 -- `/include` 的 glob 是 shell 语义(`glob(3)`),`*` 不跨目录;要递归就进 - 交互式界面逐层进,或者自己写多级模式。 -- `/include` 一次最多 32 个文件、单文件 256 KB、累计 1 MB。 -- `/models` 的列表只影响"你能看到什么",不影响"你能填什么":输入行里直接写 - 名字可以指定任何模型,包括服务端没列出来的内部模型。 +- 界面文案只有中英两份, 而且只管"给人看"的部分:**工具描述、工具回执、 + 系统提示词不随界面语言变** (那些是给模型的信息). 切成英文之后模型收到的 + 仍然是中文回执 —— 想让模型侧也换语言, 得改提示词本身. +- 只在 Linux 上验证过 (用到 `fork/setsid/pipe/poll`、`fnmatch`、POSIX 正则). +- `--timeout` 只对非流式请求生效; 流式请求靠 120 秒无数据即断开的低速检测. +- 历史超过模型窗口时**只报错, 不自动回收** (见§8):要不要丢几轮、丢哪几轮由你 + 用 `/back`、`/reset`、`/meta` 决定. 单轮内工具输出过大 (超过上限被截断)时, + 只能靠模型自己用 `offset/limit` 分段读. +- 每轮都会重发完整历史, token 成本随轮数平方增长. +- 终端交互是行式 REPL, 不是全屏 TUI:有行内编辑与历史, 但**没有 Tab 补全**. +- 对话历史本身只存在进程内, 退出即丢 —— 要留就 `/export`. + (输入历史是持久化的, 见下一节. ) +- Markdown 渲染不处理嵌套列表缩进. +- 提权只覆盖 `run_command` (含 `/shell`). 文件类工具还没走提权通道, 要动受 + 保护的文件得在命令里自己用 sudo. +- 提权依赖系统的 `sudo`; 不支持 `su` (它需要终端且不缓存凭据). +- 提权命令不独立成进程组 (要保住控制终端给 sudo 问密码), 所以超时只能杀直接 + 子进程, 不能整组杀. +- `/include` 的 glob 是 shell 语义 (`glob(3)`), `*` 不跨目录; 要递归就进 + 交互式界面逐层进, 或者自己写多级模式. +- `/include` 一次最多 32 个文件、单文件 256 KB、累计 1 MB. +- `/models` 的列表只影响"你能看到什么", 不影响"你能填什么":输入行里直接写 + 名字可以指定任何模型, 包括服务端没列出来的内部模型. - `/history`、`/back`、`/unback`、`/export`、`/import` 报的"历史条数"**不含 - system 提示词** —— 它是配置不是对话,删掉唯一一轮之后应当显示 0 条。 - 只有 `/diff`(meta)报的是数组真实条数,因为它要跟 `#0 #1 ...` 的下标对上。 -- 图片只进不出:`read_image` 能让模型看图,但模型没有画图能力。 -- 能力探测靠"发请求试",因为**根本不存在**查询能力的接口。代价是开启探测时 - 每个进程的第一次请求前会多出 2~4 个小请求(换模型会重测,切回来命中缓存)。 - `--no-probe` 可以彻底关掉,回到"全都暴露"的老行为。探测结论不确定时按支持 - 处理 —— 宁可让模型白调一次,也不错误地藏掉工具。 -- 图片以 base64 内联,所以**每轮请求都会重发一遍图片字节**(推理侧的图片 token - 有上限、不随体积涨,但上行带宽和请求体大小是实打实要付的)。历史/归档本身 - 不受影响——那里只有引用。 -- 历史里的图片是对 `ligent.archive/images/` 的引用,**备份才是字节的真身**。 - 把 `ligent.archive` 删掉或移走,图片就展开不出来,会被降级成文字说明 - (不会让对话卡死,但图也就没了)。`/export` 的存档同理,不是自包含的。 -- 图片只支持内联,**不支持直接给一个外部 URL**:ligent 不去网上抓图,`read_image` - 只收本地文件。(协议上的另一种走法是外部 URL,需要服务端自己去下载。) -- 单张图 base64 后超过 32 MiB、或一次请求图片总量超过 44 MiB 会被本地拒绝, - 模型会被告知"图片太大",需要先缩小或压缩。 -- 工具结果按字节截断,不按 token 计算,极大文件需要配合 `offset/limit` 分段读。 -- 二进制检测只看"有没有 NUL 字节":`read_file` 只探前 8 KB,所以一个 - **前 8 KB 干净、之后才有 NUL** 的文件仍会被当文本读(内容会在第一个 NUL 处被 - 悄悄截断)。`edit_file` 不受影响 —— 它看全文件。UTF-16、压缩包、可执行文件这类 - 在开头就有 NUL,都能识别出来。 -- 对话落盘要手动 `/export`,不会自动保存(输入历史则是自动的,见下)。 -- 思考过程只是"存下来回传",不做展示也不做摘要;它会一直留在上下文里, - 所以长对话下 token 花销比不开思考更大(这是服务端的要求,不是可以省的)。 -- 思考模式的具体字段(顶层 `thinking` 对象 / `reasoning_effort` / 回传 - `reasoning_content`)按 DeepSeek 的 OpenAI 兼容格式发送 —— 这一套 MiMo 也一样, - 两边能共用。别家如果用的是嵌套的 `reasoning` 对象(如 OpenRouter)或 - `thinking.budget_tokens`(如 Anthropic),需要服务端自己有兼容层; - ligent 不做方言探测。顺带一提,`reasoning_content` ligent 读的时候两种 - 名字都认,但回传时**只写 `reasoning_content`** —— 对着用 `reasoning` 的 - 服务端可能会丢失思考上下文。 + system 提示词** —— 它是配置不是对话, 删掉唯一一轮之后应当显示 0 条. + 只有 `/diff` (meta)报的是数组真实条数, 因为它要跟 `#0 #1 ...` 的下标对上. +- 图片只进不出:`read_image` 能让模型看图, 但模型没有画图能力. +- 能力探测靠"发请求试", 因为**根本不存在**查询能力的接口. 代价是开启探测时 + 每个进程的第一次请求前会多出 2~4 个小请求 (换模型会重测, 切回来命中缓存). + `--no-probe` 可以彻底关掉, 回到"全都暴露"的老行为. 探测结论不确定时按支持 + 处理 —— 宁可让模型白调一次, 也不错误地藏掉工具. +- 图片以 base64 内联, 所以**每轮请求都会重发一遍图片字节** (推理侧的图片 token + 有上限、不随体积涨, 但上行带宽和请求体大小是实打实要付的). 历史/归档本身 + 不受影响——那里只有引用. +- 历史里的图片是对 `ligent.archive/images/` 的引用, **备份才是字节的真身**. + 把 `ligent.archive` 删掉或移走, 图片就展开不出来, 会被降级成文字说明 + (不会让对话卡死, 但图也就没了). `/export` 的存档同理, 不是自包含的. +- 图片只支持内联, **不支持直接给一个外部 URL**:ligent 不去网上抓图, `read_image` + 只收本地文件. (协议上的另一种走法是外部 URL, 需要服务端自己去下载. ) +- 单张图 base64 后超过 32 MiB、或一次请求图片总量超过 44 MiB 会被本地拒绝, + 模型会被告知"图片太大", 需要先缩小或压缩. +- 工具结果按字节截断, 不按 token 计算, 极大文件需要配合 `offset/limit` 分段读. +- 二进制检测只看"有没有 NUL 字节":`read_file` 只探前 8 KB, 所以一个 + **前 8 KB 干净、之后才有 NUL** 的文件仍会被当文本读 (内容会在第一个 NUL 处被 + 悄悄截断). `edit_file` 不受影响 —— 它看全文件. UTF-16、压缩包、可执行文件这类 + 在开头就有 NUL, 都能识别出来. +- 对话落盘要手动 `/export`, 不会自动保存 (输入历史则是自动的, 见下). +- 思考过程只是"存下来回传", 不做展示也不做摘要; 它会一直留在上下文里, + 所以长对话下 token 花销比不开思考更大 (这是服务端的要求, 不是可以省的). +- 思考模式的具体字段 (顶层 `thinking` 对象 / `reasoning_effort` / 回传 + `reasoning_content`)按 DeepSeek 的 OpenAI 兼容格式发送 —— 这一套 MiMo 也一样, + 两边能共用. 别家如果用的是嵌套的 `reasoning` 对象 (如 OpenRouter)或 + `thinking.budget_tokens` (如 Anthropic), 需要服务端自己有兼容层; + ligent 不做方言探测. 顺带一提, `reasoning_content` ligent 读的时候两种 + 名字都认, 但回传时**只写 `reasoning_content`** —— 对着用 `reasoning` 的 + 服务端可能会丢失思考上下文. - `/shell` 的记录对全屏程序是"整段忽略画面"而不是"还原画面":`nano`/`vim` 这类 - 用备用屏幕的程序,记录里只剩命令行和退出后的输出。极少数既不用备用屏幕、 - 又逐行重绘的程序(`TERM` 被强行设成不认识的值时)只能靠压缩空白降噪; - 真正逐屏还原需要一整套终端模拟器,不在当前范围内。 + 用备用屏幕的程序, 记录里只剩命令行和退出后的输出. 极少数既不用备用屏幕、 + 又逐行重绘的程序 (`TERM` 被强行设成不认识的值时)只能靠压缩空白降噪; + 真正逐屏还原需要一整套终端模拟器, 不在当前范围内. -**验证上的缺口**(这些地方是"我没验过",不是"我验过没问题"): +**验证上的缺口** (这些地方是"我没验过", 不是"我验过没问题"): -- **提权的真实路径没端到端跑过。** e2e 里用的是桩 `sudo`(它要求有 tty 并把 - argv 记下来,专门守着 `setsid` 那个回归),真实 sudo 的密码提示路径没有测过 - —— 那需要碰使用者的密码。而这个模块当初正是因为控制终端的问题认证失败过, - 所以这里确实是风险点,不是理论上的担心。 -- **静态二进制只在本机验过 HTTPS。** TLS 栈是验过的(本地 `openssl s_server` + - 自签证书,能走到"证书不被信任"说明整条 OpenSSL 路径在跑),但它依赖编进去的 - `/etc/ssl/certs/ca-certificates.crt`。目标机器上没有这个文件的话,TLS 直接 - 不可用;而且 **libcurl 库不读 `CURL_CA_BUNDLE`**(那是 `curl` 命令行工具的行 - 为),所以使用者也没法用环境变量指一份别的 CA —— 要支持得改成显式 - `CURLOPT_CAINFO`。 -- **`clang --analyze` 还剩 3 条告警**,我判断是误报(`fgets` 前已置 `errno=0`; - `fread` 的 EOF 循环惯用法),但没有能力证明。`gcc -fanalyzer` 在 `image.c` 报 +- **提权的真实路径没端到端跑过. ** e2e 里用的是桩 `sudo` (它要求有 tty 并把 + argv 记下来, 专门守着 `setsid` 那个回归), 真实 sudo 的密码提示路径没有测过 + —— 那需要碰使用者的密码. 而这个模块当初正是因为控制终端的问题认证失败过, + 所以这里确实是风险点, 不是理论上的担心. +- **静态二进制只在本机验过 HTTPS. ** TLS 栈是验过的 (本地 `openssl s_server` + + 自签证书, 能走到"证书不被信任"说明整条 OpenSSL 路径在跑), 但它依赖编进去的 + `/etc/ssl/certs/ca-certificates.crt`. 目标机器上没有这个文件的话, TLS 直接 + 不可用; 而且 **libcurl 库不读 `CURL_CA_BUNDLE`** (那是 `curl` 命令行工具的行 + 为), 所以使用者也没法用环境变量指一份别的 CA —— 要支持得改成显式 + `CURLOPT_CAINFO`. +- **`clang --analyze` 还剩 3 条告警**, 我判断是误报 (`fgets` 前已置 `errno=0`; + `fread` 的 EOF 循环惯用法), 但没有能力证明. `gcc -fanalyzer` 在 `image.c` 报 的那条 FILE 泄漏同理:`-O0` 下干净、`-O1` 起才报、把函数原样抽出来单独编也 - 不报,我判断是内联导致的误报 —— 换个 gcc 版本结论可能变。 -- 打断(Ctrl-C)路径只在几处有覆盖,不全面。 + 不报, 我判断是内联导致的误报 —— 换个 gcc 版本结论可能变. +- 打断 (Ctrl-C)路径只在几处有覆盖, 不全面. diff --git a/ligent.example.json b/ligent.example.json index e3c43dc..d2f7bb2 100644 --- a/ligent.example.json +++ b/ligent.example.json @@ -1,5 +1,5 @@ { - "_注释": "把本文件复制为「二进制所在目录」下的 ligent.json 即生效(例如 ./ligent.json);支持 ${VAR} 环境变量展开", + "_注释": "把本文件复制为「二进制所在目录」下的 ligent.json 即生效 (例如 ./ligent.json); 支持 ${VAR} 环境变量展开", "base_url": "https://api.deepseek.com/v1", "api_key": "${DEEPSEEK_API_KEY}", @@ -13,11 +13,11 @@ "reasoning_effort": "high", "stream": true, - "_max_steps注释": "单轮最多几次模型往返;0 = 不限(默认也是 0,即不限制)", + "_max_steps注释": "单轮最多几次模型往返; 0 = 不限 (默认也是 0, 即不限制)", "max_steps": 0, "tools": true, - "_probe注释": "首个请求前先试探模型收不收图片/tools,据此决定暴露哪些工具;关掉就不额外发请求", + "_probe注释": "首个请求前先试探模型收不收图片/tools, 据此决定暴露哪些工具; 关掉就不额外发请求", "probe": true, "system": "可选:完全覆盖内置的 system 提示词" diff --git a/src/agent.c b/src/agent.c index a9c4831..e550069 100644 --- a/src/agent.c +++ b/src/agent.c @@ -1,5 +1,6 @@ /* agent.c - agent 主循环 */ #include "agent.h" +#include "sgr.h" #include "tools.h" #include "i18n.h" #include "util.h" @@ -18,22 +19,15 @@ /* 颜色与打印 */ /* ------------------------------------------------------------------ */ -/* 前置声明:定义在文件靠后的上下文管理一节 */ +/* 前置声明: 定义在文件靠后的上下文管理一节 */ static int role_is(const JsonValue *m, const char *want); -#define C_DIM "\033[2m" -#define C_CYAN "\033[36m" -#define C_GREEN "\033[32m" -#define C_RED "\033[31m" -#define C_BOLD "\033[1m" -#define C_OFF "\033[0m" - static const char *col(const Agent *a, const char *code) { return a->color ? code : ""; } -/* 把多行文本缩进显示,最多 max_lines 行 */ +/* 把多行文本缩进显示, 最多 max_lines 行 */ static void print_indented(const char *text, const char *prefix, int max_lines, const char *color_code, int use_color) { @@ -71,16 +65,16 @@ typedef struct { static void on_delta(void *ud, const char *text) { DeltaCtx *c = ud; - /* 一出正文就把等待动画收掉,否则动画会和正文抢同一行 */ + /* 一出正文就把等待动画收掉, 否则动画会和正文抢同一行 */ spinner_stop(); - /* 走 Markdown 渲染器:它会处理被 SSE 切断的标记 */ + /* 走 Markdown 渲染器: 它会处理被 SSE 切断的标记 */ md_feed(c->md, text); fflush(stdout); c->printed = 1; } -/* 收到思维链:说明请求早就发出去了,模型确实在推理,而不是卡在网络。 - * 只换标签,不打印内容 —— 思维链不显示。 */ +/* 收到思维链: 说明请求早就发出去了, 模型确实在推理, 而不是卡在网络. + * 只换标签, 不打印内容 —— 思维链不显示. */ static void on_think(void *ud, const char *text) { (void)ud; @@ -97,14 +91,14 @@ char *agent_default_system_prompt(void) return xstrdup( "你是用户的 AI 助手\n" "\n" - "你可以调用工具来读写文件和执行命令。\n" - "工作原则:\n" - "1. 先观察再动手:不确定文件内容或目录结构时,先用 list_dir / read_file 查看,不要凭猜测写代码。搜内容用 run_command(如 grep -rn 模式 目录)。\n" - "2. 修改已有文件优先用 edit_file:改一处就带上 read_file 显示的行号 line(集合型:给一个整数,或给一个整数数组把 old_string 覆盖到的那几行都填进来),替换只发生在这些行里。省略 line 等于全文件替换所有匹配,只在你确实要改全文件时才省。只有创建新文件或需要整体重写时才用 write_file。\n" - "3. 工具报错时,先读错误信息定位原因,再决定换一种方式;不要重复执行同一个失败的命令。\n" - "4. 回答用中文,简洁直接,不要把大段代码重复贴回来。完成任务后用一两句话说明改了什么、验证结果如何。\n" - "5. 不要执行破坏性的操作(例如删除用户数据、强制推送),除非用户明确要求。\n" - "6. read_file / write_file / edit_file 只处理文本:二进制文件会被拒绝。看图片用 read_image,看其它二进制用 run_command(file / xxd / strings)。\n" + "你可以调用工具来读写文件和执行命令. \n" + "工作原则: \n" + "1. 先观察再动手: 不确定文件内容或目录结构时, 先用 list_dir / read_file 查看, 不要凭猜测写代码. 搜内容用 run_command (如 grep -rn 模式 目录). \n" + "2. 修改已有文件优先使用 edit_file: 改一处就带上 read_file 显示的行号 line (集合型: 给一个整数, 或给一个整数数组把 old_string 覆盖到的那几行都填进来), 替换只发生在这些行里. 省略 line 等于全文件替换所有匹配, 只在你确实要改全文件时才省. 只有创建新文件或需要整体重写时才用 write_file. \n" + "3. 工具报错时, 先读错误信息定位原因, 再决定换一种方式; 不要重复执行同一个失败的命令. \n" + "4. 回答简洁直接, 不要把大段代码重复贴回来. 完成任务后用一两句话说明改了什么、验证结果如何. \n" + "5. 不要执行破坏性的操作 (例如删除用户数据、强制推送), 除非用户明确要求. \n" + "6. read_file / write_file / edit_file 只处理文本: 二进制文件会被拒绝. 看图片用 read_image, 看其它二进制用 run_command (file / xxd / strings). \n" "\n" "除非必要, 否则不要执行命令.\n" "除非用户说明, 不得直接或间接(如使用curl)获取任何在线资源.\n"); @@ -114,7 +108,7 @@ void agent_init(Agent *a) { memset(a, 0, sizeof *a); a->messages = json_new_array(); - a->max_steps = 0; /* 0 = 不限往返次数(默认,由配置覆盖) */ + a->max_steps = 0; /* 0 = 不限往返次数 (默认, 由配置覆盖) */ a->use_tools = 1; a->color = 0; a->show_tool_output = 1; @@ -124,9 +118,9 @@ void agent_init(Agent *a) a->exec_tool = tool_execute; /* 默认就是普通工具集 */ a->is_mutating = tool_mutating; a->probe_caps = 0; - /* 必须显式初始化成"未测"。CAP_NO 正好是 0,而 memset 出来的零值就是它 —— - * 忘了这一步的话,"不探测"会被当成"探测结论是不支持",于是一声不响地 - * 把所有工具都摘掉(这个 bug 真的被 e2e 抓过一次)。 */ + /* 必须显式初始化成"未测". CAP_NO 正好是 0, 而 memset 出来的零值就是它 —— + * 忘了这一步的话, "不探测"会被当成"探测结论是不支持", 于是一声不响地 + * 把所有工具都摘掉 (这个 bug 真的被 e2e 抓过一次). */ a->caps.vision = CAP_UNKNOWN; a->caps.tools = CAP_UNKNOWN; } @@ -155,7 +149,7 @@ void agent_add_system(Agent *a, const char *prompt) json_object_set_string(m, "role", "system"); json_object_set_string(m, "content", prompt); - /* system 永远是第一条;历史里已有的旧 system 会被替换而不是叠加 */ + /* system 永远是第一条; 历史里已有的旧 system 会被替换而不是叠加 */ JsonValue *tmp = json_new_array(); json_array_push(tmp, m); for (size_t i = 0; i < json_array_size(a->messages); i++) { @@ -168,11 +162,11 @@ void agent_add_system(Agent *a, const char *prompt) a->messages = tmp; } -/* 加一条 user 消息:文本 + 若干内容块(parts 的所有权转移给这条消息, - * 可以为 NULL)。块排在文本之后。 +/* 加一条 user 消息: 文本 + 若干内容块 (parts 的所有权转移给这条消息, + * 可以为 NULL). 块排在文本之后. * - * 为什么需要它:/include 要把文件、图片跟着用户这一句话一起发出去,而 - * agent_add_user 只能放纯字符串。 */ + * 为什么需要它: /include 要把文件、图片跟着用户这一句话一起发出去, 而 + * agent_add_user 只能放纯字符串. */ void agent_add_user_parts(Agent *a, const char *text, JsonValue *parts) { JsonValue *m = json_new_object(); @@ -224,14 +218,14 @@ size_t agent_history_len(const Agent *a) return json_array_size(a->messages); } -/* 面向用户的"历史消息数":**不含 system 提示词**。 +/* 面向用户的"历史消息数": **不含 system 提示词**. * - * system 是配置不是对话。删掉唯一一轮之后,用户看到的应该是"历史剩 0 条", - * 而不是"1 条"(那 1 条是提示词)。/history、/back、/unback、/export、/import - * 统一用这个口径,数字之间才对得上。 + * system 是配置不是对话. 删掉唯一一轮之后, 用户看到的应该是"历史剩 0 条", + * 而不是"1 条" (那 1 条是提示词). /history、/back、/unback、/export、/import + * 统一用这个口径, 数字之间才对得上. * - * 内部判断"历史变了没有"仍然用 agent_history_len(数组真实长度)—— 那是在比 - * 大小,不是在报数。 */ + * 内部判断"历史变了没有"仍然用 agent_history_len (数组真实长度)—— 那是在比 + * 大小, 不是在报数. */ size_t agent_conversation_len(const Agent *a) { size_t n = json_array_size(a->messages); @@ -288,8 +282,8 @@ void agent_replace_history(Agent *a, JsonValue *messages) /* 错误分类 */ /* ------------------------------------------------------------------ */ -/* 是不是 4xx。图片引用失效没有稳定的措辞可认(各家不同),所以只认状态码, - * 再配合"历史里确实有图片引用"来决定要不要把图摘掉重试。 */ +/* 是不是 4xx. 图片引用失效没有稳定的措辞可认 (各家不同), 所以只认状态码, + * 再配合"历史里确实有图片引用"来决定要不要把图摘掉重试. */ int agent_is_http_4xx(const char *err) { return err && strstr(err, "HTTP 4") != NULL; @@ -301,14 +295,14 @@ static int role_is(const JsonValue *m, const char *want) return r && strcmp(r, want) == 0; } -/* 打开新一轮的 user 消息(agent.h 里有声明 —— meta.c 切轮时用的是同一个判据, - * 两边对"轮"的理解必须一致,否则 /meta 报的轮号和主循环的口径对不上)。 +/* 打开新一轮的 user 消息 (agent.h 里有声明 —— meta.c 切轮时用的是同一个判据, + * 两边对"轮"的理解必须一致, 否则 /meta 报的轮号和主循环的口径对不上). * - * read_image 之后会补一条"只有图片附件、没有文字"的 user 消息(协议规定图片 - * 只能出现在 user 消息里)。它不算新的一轮 —— 按它切轮会把紧邻的 - * assistant(tool_calls) + tool 那对切散,下一轮直接 400。 + * read_image 之后会补一条"只有图片附件、没有文字"的 user 消息 (协议规定图片 + * 只能出现在 user 消息里). 它不算新的一轮 —— 按它切轮会把紧邻的 + * assistant(tool_calls) + tool 那对切散, 下一轮直接 400. * - * 判据很直白:content 是数组、且里面一个 text 块都没有。 */ + * 判据很直白: content 是数组、且里面一个 text 块都没有. */ int agent_opens_round(const JsonValue *m) { if (!role_is(m, "user")) return 0; @@ -325,17 +319,17 @@ int agent_opens_round(const JsonValue *m) /* 工具调用展示与执行 */ /* ------------------------------------------------------------------ */ -/* 调用摘要里,兜底那些工具(参数照原样看一眼)最多打多少字符 */ +/* 调用摘要里, 兜底那些工具 (参数照原样看一眼)最多打多少字符 */ #define CALL_JSON_MAX 120 -/* 工具调用的一行摘要:**人话**,不是原始 JSON。 +/* 工具调用的一行摘要: **人话**, 不是原始 JSON. * - * 以前这里把整个参数 JSON 原样打出来(截到 256 字节)。对 edit_file 来说那是 - * 一整段 old_string / new_string:屏幕上是一坨转义过的引号,既刷屏,又完全看 - * 不出"要改哪里" —— 而人想知道的恰恰是"哪个文件的第几行、改成什么"。 + * 以前这里把整个参数 JSON 原样打出来 (截到 256 字节). 对 edit_file 来说那是 + * 一整段 old_string / new_string: 屏幕上是一坨转义过的引号, 既刷屏, 又完全看 + * 不出"要改哪里" —— 而人想知道的恰恰是"哪个文件的第几行、改成什么". * - * 现在这里只摆最要紧的那一两项(改动的具体内容交给 show_edit_diff 去对照), - * 参数解析不出来(模型给了非法 JSON)时就只有工具名 —— 原因在结果里说。 */ + * 现在这里只摆最要紧的那一两项 (改动的具体内容交给 show_edit_diff 去对照), + * 参数解析不出来 (模型给了非法 JSON)时就只有工具名 —— 原因在结果里说. */ static void show_tool_call(const Agent *a, const char *name, const JsonValue *args) { char shown[400] = ""; @@ -344,14 +338,14 @@ static void show_tool_call(const Agent *a, const char *name, const JsonValue *ar if (args && strcmp(name, TOOL_EDIT_FILE) == 0) { char lines[128]; tool_lines_label(args, lines, sizeof lines); - /* diff 只显示"改成什么",看不出命中几处 —— 所以"全文件替换"和 - * "所有匹配都换掉"这两种范围必须在摘要里说清楚。 */ + /* diff 只显示"改成什么", 看不出命中几处 —— 所以"全文件替换"和 + * "所有匹配都换掉"这两种范围必须在摘要里说清楚. */ const char *scope = lines[0] ? lines : t("tool.call.all_lines"); if (json_get_bool(args, "replace_all", 0)) scope = tf("tool.call.scope_all", "scope", scope, NULL); - /* 路径带引号、行号用方括号:`{/} edit_file "./Makefile" [280, 284]`。 - * 引号让路径从描述里"跳出来"(路径本身可能带空格),方括号里的就是 - * read_file 打出来的那些行号。 */ + /* 路径带引号、行号用方括号: `{/} edit_file "./Makefile" [280, 284]`. + * 引号让路径从描述里"跳出来" (路径本身可能带空格), 方括号里的就是 + * read_file 打出来的那些行号. */ snprintf(shown, sizeof shown, "\"%s\" [%s]", path ? path : "?", scope); } else if (args && strcmp(name, TOOL_WRITE_FILE) == 0) { const char *content = json_get_string(args, "content"); @@ -359,7 +353,7 @@ static void show_tool_call(const Agent *a, const char *name, const JsonValue *ar tf("agent.call.write", "path", path ? path : "?", "n", i18n_num((long long)(content ? strlen(content) : 0)), NULL)); } else if (args && strcmp(name, TOOL_RUN_COMMAND) == 0) { - /* 提权必须一眼看见:这条命令会以 root 跑 */ + /* 提权必须一眼看见: 这条命令会以 root 跑 */ const char *cmd = json_get_string(args, "command"); if (json_get_bool(args, "elevated", 0)) snprintf(shown, sizeof shown, "%s", @@ -367,12 +361,12 @@ static void show_tool_call(const Agent *a, const char *name, const JsonValue *ar else snprintf(shown, sizeof shown, "%s", cmd ? cmd : ""); } else if (path) { - /* 路径一律带引号:它可能含空格,引号能把"这是一个路径"框出来 */ + /* 路径一律带引号: 它可能含空格, 引号能把"这是一个路径"框出来 */ snprintf(shown, sizeof shown, "\"%s\"", path); } else if (args) { - /* 其它工具(包括 meta 自己那一套:list_rounds / read_messages / …): - * 参数照原样看一眼,但压到一行、截到 CALL_JSON_MAX 字符。它们的参数里 - * 没有上面那几种"整段正文",短 JSON 反而是最省事也最准的摘要。 */ + /* 其它工具 (包括 meta 自己那一套: list_rounds / read_messages / …): + * 参数照原样看一眼, 但压到一行、截到 CALL_JSON_MAX 字符. 它们的参数里 + * 没有上面那几种"整段正文", 短 JSON 反而是最省事也最准的摘要. */ char *j = json_dump(args); size_t n = strlen(j); snprintf(shown, sizeof shown, "%.*s%s", CALL_JSON_MAX, j, @@ -400,12 +394,12 @@ static void show_tool_result(const Agent *a, const char *name, const JsonValue * fflush(stdout); return; } - /* edit_file 改成功了:屏幕上摆新旧对照(带行号),比那行"已在 X 第 N 行 - * 替换 1 处"有用得多(那条回执仍会原样进历史 —— 它是给模型看的)。 - * --no-color 下也一样显示:- / + 前缀不依赖颜色。 + /* edit_file 改成功了: 屏幕上摆新旧对照 (带行号), 比那行"已在 X 第 N 行 + * 替换 1 处"有用得多 (那条回执仍会原样进历史 —— 它是给模型看的). + * --no-color 下也一样显示: - / + 前缀不依赖颜色. * - * 但**确认提示里已经摆过一次**(默认就开着确认),就不再重复第二遍:同一个 - * 东西在一屏里出现两次只会让人怀疑"是不是要改两次"。 */ + * 但**确认提示里已经摆过一次** (默认就开着确认), 就不再重复第二遍: 同一个 + * 东西在一屏里出现两次只会让人怀疑"是不是要改两次". */ if (!is_error && !diff_shown && name && strcmp(name, TOOL_EDIT_FILE) == 0 && args && tool_render_diff(stdout, args, " │ ", a->color)) { fflush(stdout); @@ -416,7 +410,7 @@ static void show_tool_result(const Agent *a, const char *name, const JsonValue * fflush(stdout); } -/* 执行一批 tool_calls,把结果作为 tool 消息追加到历史 */ +/* 执行一批 tool_calls, 把结果作为 tool 消息追加到历史 */ static int execute_tool_calls(Agent *a, JsonValue *tool_calls) { size_t count = json_array_size(tool_calls); @@ -434,36 +428,36 @@ static int execute_tool_calls(Agent *a, JsonValue *tool_calls) char jerr[256]; JsonValue *args = json_parse(args_raw, jerr, sizeof jerr); - /* 摘要打在**执行之前**:命令可能跑很久,用户得先看到要做什么。 - * 参数解析不出来时摘要只有工具名,原因会在结果里说。 */ + /* 摘要打在**执行之前**: 命令可能跑很久, 用户得先看到要做什么. + * 参数解析不出来时摘要只有工具名, 原因会在结果里说. */ show_tool_call(a, name ? name : t("agent.tool.name_unknown"), args); int is_error = 0; int diff_shown = 0; /* 确认提示里是不是已经摆过新旧对照 */ char *result; if (!args) { - result = xasprintf("错误:工具参数不是合法 JSON(%s)", jerr); + result = xasprintf("错误: 工具参数不是合法 JSON (%s)", jerr); is_error = 1; } else if (a->confirm && a->is_mutating(name) && !confirm_tool(name, args)) { - /* 拒绝不等于失败:把结果回填给模型,让它换个做法, + /* 拒绝不等于失败: 把结果回填给模型, 让它换个做法, * 而不是让整轮对话就此中断 */ if (confirm_turn_mode() == CONFIRM_NEVER) - /* 用户在确认提示上选了 neVer = 本轮别再改了。 - * 话说死一点,否则模型会一个接一个地试下去。 */ - result = xstrdup("用户已表示这一轮不再执行任何改动。" - "不要再尝试改动,直接说明你的结论或需要用户做什么。"); + /* 用户在确认提示上选了 neVer = 本轮别再改了. + * 话说死一点, 否则模型会一个接一个地试下去. */ + result = xstrdup("用户已表示这一轮不再执行任何改动. " + "不要再尝试改动, 直接说明你的结论或需要用户做什么. "); else - result = xstrdup("用户拒绝了这次操作。请换一种不改动磁盘的做法," - "或先向用户说明你打算做什么并征得同意。"); + result = xstrdup("用户拒绝了这次操作. 请换一种不改动磁盘的做法, " + "或先向用户说明你打算做什么并征得同意. "); is_error = 1; } else { - /* 确认提示里已经把带行号的新旧对照摆出来了,用户是看着它点头的 —— - * 执行之后再摆一遍只会让人怀疑"是不是要改两次"。 */ + /* 确认提示里已经把带行号的新旧对照摆出来了, 用户是看着它点头的 —— + * 执行之后再摆一遍只会让人怀疑"是不是要改两次". */ diff_shown = a->confirm && a->is_mutating(name); result = a->exec_tool(name, args, &is_error); } - /* args 要活到 show_tool_result 之后:edit_file 的新旧对照就是从这里取的 */ + /* args 要活到 show_tool_result 之后: edit_file 的新旧对照就是从这里取的 */ show_tool_result(a, name, args, result, is_error, diff_shown); json_free(args); @@ -475,13 +469,13 @@ static int execute_tool_calls(Agent *a, JsonValue *tool_calls) free(result); } - /* ---- 被中断:剩下的 tool_calls 也得补齐 ---- + /* ---- 被中断: 剩下的 tool_calls 也得补齐 ---- * - * 这里以前直接 return 1,于是 assistant(tool_calls) 后面少了几条 tool - * 消息 —— 和插错附件是同一个 400,而且同样是"之后每轮都失败、历史却留着"。 - * 宁可给没跑成的调用补一句"没执行",也不能把一段坏历史留在上下文里。 + * 这里以前直接 return 1, 于是 assistant(tool_calls) 后面少了几条 tool + * 消息 —— 和插错附件是同一个 400, 而且同样是"之后每轮都失败、历史却留着". + * 宁可给没跑成的调用补一句"没执行", 也不能把一段坏历史留在上下文里. * - * 顺序要紧:占位 tool 消息必须在附件之前,否则配对又断了。 */ + * 顺序要紧: 占位 tool 消息必须在附件之前, 否则配对又断了. */ if (g_ligent_interrupt) { for (size_t i = interrupted_at; i < count; i++) { JsonValue *tc = json_array_get(tool_calls, i); @@ -490,28 +484,28 @@ static int execute_tool_calls(Agent *a, JsonValue *tool_calls) json_object_set_string(tm, "role", "tool"); json_object_set_string(tm, "tool_call_id", id ? id : ""); json_object_set_string(tm, "content", - "错误:用户中断了这一轮,本次调用没有执行。"); + "错误: 用户中断了这一轮, 本次调用没有执行. "); json_array_push(a->messages, tm); } } - /* ---- 附件:必须等**所有** tool 消息都推完再推 ---- + /* ---- 附件: 必须等**所有** tool 消息都推完再推 ---- * - * read_image 把图片挂在待发附件里,而图片只能出现在 user 消息中(tool - * 消息的内容只能是字符串),所以要补一条 user 消息。 + * read_image 把图片挂在待发附件里, 而图片只能出现在 user 消息中 (tool + * 消息的内容只能是字符串), 所以要补一条 user 消息. * - * 关键是补在哪儿:服务端要求 assistant(tool_calls) 后面**紧跟**这一组 - * tool 消息。以前是在循环里每推完一条 tool 就补一次附件,于是一条 - * assistant 带两个工具调用、第一个是 read_image 时,历史就成了 + * 关键是补在哪儿: 服务端要求 assistant(tool_calls) 后面**紧跟**这一组 + * tool 消息. 以前是在循环里每推完一条 tool 就补一次附件, 于是一条 + * assistant 带两个工具调用、第一个是 read_image 时, 历史就成了 * * assistant(tool_calls: [读图, 另一个]) * tool(读图) * user(图片附件) ← 插在中间 * tool(另一个) ← 不再是"紧跟" * - * 服务端回 400:An assistant message with 'tool_calls' must be followed by - * tool messages responding to each 'tool_call_id'。而且这段历史留在上下文 - * 里,之后**每一轮**都会 400。攒到现在一次性推出去就没有这个问题。 */ + * 服务端回 400: An assistant message with 'tool_calls' must be followed by + * tool messages responding to each 'tool_call_id'. 而且这段历史留在上下文 + * 里, 之后**每一轮**都会 400. 攒到现在一次性推出去就没有这个问题. */ JsonValue *attach = image_pending_take(); if (attach && json_array_size(attach) > 0) { JsonValue *um = json_new_object(); @@ -529,17 +523,17 @@ static int execute_tool_calls(Agent *a, JsonValue *tool_calls) /* 主循环 */ /* ------------------------------------------------------------------ */ -/* 构造"这一轮请求"用的消息副本:把历史里的图片引用展开成 image_url + base64。 +/* 构造"这一轮请求"用的消息副本: 把历史里的图片引用展开成 image_url + base64. * - * 历史里存的是对本地备份的引用(几十字节),图片字节在备份文件里;展开只发生 - * 在请求这一层,所以归档 / /history / /log 都不会被 base64 撑大。 + * 历史里存的是对本地备份的引用 (几十字节), 图片字节在备份文件里; 展开只发生 + * 在请求这一层, 所以归档 / /history / /log 都不会被 base64 撑大. * - * 展开不出来的引用会被就地降级成文字说明(image.c 里做的),这里只负责提示 —— - * 坏引用留在历史里的话,每一轮都会重新失败一次。 */ -/* 按探测到的能力收敛工具表。 - * 模型明确不收图片时把 read_image 摘掉 —— 留着它只会让模型白调一次, - * 然后请求被 400(虽然我们还有降级兜底,但那是补救,不如不给)。 - * 模型明确不收 tools 时整张表都不发:否则每一轮都会以同样的 400 失败。 */ + * 展开不出来的引用会被就地降级成文字说明 (image.c 里做的), 这里只负责提示 —— + * 坏引用留在历史里的话, 每一轮都会重新失败一次. */ +/* 按探测到的能力收敛工具表. + * 模型明确不收图片时把 read_image 摘掉 —— 留着它只会让模型白调一次, + * 然后请求被 400 (虽然我们还有降级兜底, 但那是补救, 不如不给). + * 模型明确不收 tools 时整张表都不发: 否则每一轮都会以同样的 400 失败. */ static JsonValue *tools_for_request(const Agent *a) { if (!a->tools || json_array_size(a->tools) == 0) return NULL; @@ -549,8 +543,8 @@ static JsonValue *tools_for_request(const Agent *a) return t; } -/* 一次请求要用的一切:消息副本(图片引用已展开)+ 收敛过的工具表。 - * 两者都是新分配的,用完一起释放 —— 三处 llm_chat 调用点各来一份。 */ +/* 一次请求要用的一切: 消息副本 (图片引用已展开)+ 收敛过的工具表. + * 两者都是新分配的, 用完一起释放 —— 三处 llm_chat 调用点各来一份. */ typedef struct { JsonValue *messages; JsonValue *tools; @@ -560,13 +554,13 @@ static Req req_prepare(Agent *a) { Req r; - /* 每次构造请求之前体检一遍结构:assistant(tool_calls) 后面必须紧跟对应的 - * tool 消息,中间不能夹别的东西,也不能少。 + /* 每次构造请求之前体检一遍结构: assistant(tool_calls) 后面必须紧跟对应的 + * tool 消息, 中间不能夹别的东西, 也不能少. * - * 放在这里而不是 agent_run 开头一次 —— 历史在本轮之内也会变(推 tool - * 结果、补图片附件、中断),而坏历史一旦发出去就是 400,并且会一直留在 - * 上下文里,之后每轮都失败。放在这里才是"发出之前最后一道关"。 - * 没问题时它只是一次只读扫描,不分配任何东西。 */ + * 放在这里而不是 agent_run 开头一次 —— 历史在本轮之内也会变 (推 tool + * 结果、补图片附件、中断), 而坏历史一旦发出去就是 400, 并且会一直留在 + * 上下文里, 之后每轮都失败. 放在这里才是"发出之前最后一道关". + * 没问题时它只是一次只读扫描, 不分配任何东西. */ { int fixed = 0; JsonValue *repaired = agent_repair_tool_pairing(a->messages, &fixed); @@ -598,18 +592,18 @@ static void req_free(Req *r) } /* ------------------------------------------------------------------ */ -/* 结构体检:assistant(tool_calls) 后面必须紧跟对应的 tool 消息 */ +/* 结构体检: assistant(tool_calls) 后面必须紧跟对应的 tool 消息 */ /* ------------------------------------------------------------------ */ -/* 服务端要求得很死:带 tool_calls 的 assistant 后面必须**紧跟**与每个 - * tool_call_id 一一对应的 tool 消息,中间不能夹别的东西,也不能少。 +/* 服务端要求得很死: 带 tool_calls 的 assistant 后面必须**紧跟**与每个 + * tool_call_id 一一对应的 tool 消息, 中间不能夹别的东西, 也不能少. * - * 我们的代码现在会维持这条不变量,但历史还可能从别处被改坏:/meta 的编辑、 - * 手工改过的存档、以及早先版本的 bug(附件插错位置、中断时丢下没回应的调用)。 - * 一旦坏了,症状是之后**每一轮**都 400 而历史原样保留 —— 用户只能 /reset。 - * 所以发请求之前统一体检一次:缺的补占位、孤儿 tool 消息丢掉,并如实说明。 + * 我们的代码现在会维持这条不变量, 但历史还可能从别处被改坏: /meta 的编辑、 + * 手工改过的存档、以及早先版本的 bug (附件插错位置、中断时丢下没回应的调用). + * 一旦坏了, 症状是之后**每一轮**都 400 而历史原样保留 —— 用户只能 /reset. + * 所以发请求之前统一体检一次: 缺的补占位、孤儿 tool 消息丢掉, 并如实说明. * - * 没问题返回 NULL;修过则返回重建好的数组(调用方替换旧的)。 */ + * 没问题返回 NULL; 修过则返回重建好的数组 (调用方替换旧的). */ JsonValue *agent_repair_tool_pairing(const JsonValue *messages, int *out_fixed) { if (out_fixed) *out_fixed = 0; @@ -630,8 +624,8 @@ JsonValue *agent_repair_tool_pairing(const JsonValue *messages, int *out_fixed) json_object_set_string(tm_, "role", "tool"); \ json_object_set_string(tm_, "tool_call_id", pend[k_].id); \ json_object_set_string(tm_, "content", \ - "错误:这条工具调用没有得到结果(历史结构被改坏了," \ - "这里补一条占位)。不要假设它执行过。"); \ + "错误: 这条工具调用没有得到结果 (历史结构被改坏了, " \ + "这里补一条占位). 不要假设它执行过. "); \ json_array_push(out, tm_); \ fixed++; \ } \ @@ -643,7 +637,7 @@ JsonValue *agent_repair_tool_pairing(const JsonValue *messages, int *out_fixed) JsonValue *m = json_array_get(messages, i); const char *role = json_get_string(m, "role"); - /* tool 消息:只留下"确实在等它"的那些,孤儿丢掉 */ + /* tool 消息: 只留下"确实在等它"的那些, 孤儿丢掉 */ if (role && strcmp(role, "tool") == 0) { const char *id = json_get_string(m, "tool_call_id"); size_t hit = (size_t)-1; @@ -655,7 +649,7 @@ JsonValue *agent_repair_tool_pairing(const JsonValue *messages, int *out_fixed) continue; } - /* 别的消息:先把上一组没回应的补齐,再推自己 */ + /* 别的消息: 先把上一组没回应的补齐, 再推自己 */ FLUSH_PENDING(); json_array_push(out, json_clone(m)); @@ -682,14 +676,14 @@ JsonValue *agent_repair_tool_pairing(const JsonValue *messages, int *out_fixed) /* 这个 4xx 看起来是不是冲着图片来的? * - * 降级会把历史里的图片**永久拿掉**,所以不能见到 4xx 就动手:一条 - * "tool_calls 后面缺少 tool 消息"的 400 也被当成图片问题处理的话,图片白丢, - * 而且真正的错误被我们的提示盖住了 —— 排障方向直接被带偏。 + * 降级会把历史里的图片**永久拿掉**, 所以不能见到 4xx 就动手: 一条 + * "tool_calls 后面缺少 tool 消息"的 400 也被当成图片问题处理的话, 图片白丢, + * 而且真正的错误被我们的提示盖住了 —— 排障方向直接被带偏. * - * 这里是关键词启发式,不是精确判断,所以: - * 1. 它是**兜底**,主要防线是发送前就把格式和体积查掉; - * 2. 关键词只收那些几乎不可能出现在别处的词; - * 3. 不确定时**不动手** —— 宁可让错误如实报出来,也不误删用户的图。 */ + * 这里是关键词启发式, 不是精确判断, 所以: + * 1. 它是**兜底**, 主要防线是发送前就把格式和体积查掉; + * 2. 关键词只收那些几乎不可能出现在别处的词; + * 3. 不确定时**不动手** —— 宁可让错误如实报出来, 也不误删用户的图. */ static int err_mentions_image(const char *err) { static const char *kw[] = { @@ -702,29 +696,29 @@ static int err_mentions_image(const char *err) return 0; } -/* 能力探测序列。独立于对话:那几次试探请求不带任何历史,也不进历史。 - * 结果按 base_url+model 缓存,所以只有本进程第一次(或换了模型)才真发请求。 +/* 能力探测序列. 独立于对话: 那几次试探请求不带任何历史, 也不进历史. + * 结果按 base_url+model 缓存, 所以只有本进程第一次 (或换了模型)才真发请求. * - * 除了 agent_run 每轮开头,/include 要附带图片时也会调它 —— "确保模型支持" - * 这件事不能靠猜。探测被关掉时它什么都不做(结论保持"未测",一切照旧放行)。 + * 除了 agent_run 每轮开头, /include 要附带图片时也会调它 —— "确保模型支持" + * 这件事不能靠猜. 探测被关掉时它什么都不做 (结论保持"未测", 一切照旧放行). * - * force=1 是给 /probe 用的手动触发:它绕开上面这两条(开关与缓存)。 - * 两条理由不同:开关说的是"启动时别自动探测",不是"禁止探测";而缓存是 - * "这次进程里已经测过" —— 用户显式敲了 /probe,就该真的重测一遍,否则 - * 敲了等于没敲。 */ + * force=1 是给 /probe 用的手动触发: 它绕开上面这两条 (开关与缓存). + * 两条理由不同: 开关说的是"启动时别自动探测", 不是"禁止探测"; 而缓存是 + * "这次进程里已经测过" —— 用户显式敲了 /probe, 就该真的重测一遍, 否则 + * 敲了等于没敲. */ static void ensure_caps(Agent *a, int force) { - if (!force && !a->probe_caps) return; /* 关掉探测:不下任何结论,一切照旧放行 */ + if (!force && !a->probe_caps) return; /* 关掉探测: 不下任何结论, 一切照旧放行 */ if (force) cap_cache_clear(); /* 手动触发不吃缓存 */ char cerr[256] = ""; spinner_start(t("spinner.probe")); int probed = cap_get(&a->llm, 1, &a->caps, a->llm.verbose, cerr, sizeof cerr); spinner_stop(); - if (!probed) return; /* 命中缓存,不用再啰嗦一遍 */ + if (!probed) return; /* 命中缓存, 不用再啰嗦一遍 */ - /* 一行灰字把结论说清楚。探测到"不支持"时把**因此做了什么**也写在同一行里, - * 否则用户只看到"不支持",不知道我们顺手把工具藏了。 */ + /* 一行灰字把结论说清楚. 探测到"不支持"时把**因此做了什么**也写在同一行里, + * 否则用户只看到"不支持", 不知道我们顺手把工具藏了. */ const char *vis = a->caps.vision == CAP_YES ? t("agent.caps.yes") : a->caps.vision == CAP_NO ? t("agent.caps.no.vision") @@ -752,16 +746,16 @@ int agent_run(Agent *a) { char err[1024]; - /* "这一轮"的定义就在这儿:一条用户消息引发的一整串工具调用。 - * 确认提示上的 Always/neVer 只活到这里,下一条消息进来时复位。 */ + /* "这一轮"的定义就在这儿: 一条用户消息引发的一整串工具调用. + * 确认提示上的 Always/neVer 只活到这里, 下一条消息进来时复位. */ confirm_reset_turn(); - int img_dropped = 0; /* 图片被服务端拒了只降级一次,免得来回打转 */ + int img_dropped = 0; /* 图片被服务端拒了只降级一次, 免得来回打转 */ agent_ensure_caps(a); - /* max_steps <= 0 时不限往返次数(默认)。计数用 long long:不限次数的会话 - * 真的可能跑很多步,而 int 溢出是 UB。 */ + /* max_steps <= 0 时不限往返次数 (默认). 计数用 long long: 不限次数的会话 + * 真的可能跑很多步, 而 int 溢出是 UB. */ for (long long step = 0; ; step++) { DeltaCtx dc; dc.printed = 0; @@ -776,26 +770,26 @@ int agent_run(Agent *a) req_free(&req); spinner_stop(); - /* 上下文超限**不再自动裁剪历史**。 + /* 上下文超限**不再自动裁剪历史**. * - * 这里曾经有一整套"丢最老一轮 → 重试,最多丢 5 轮"的机制(配套 - * --no-auto-trim / auto_trim / LIGENT_AUTO_TRIM)。它被删掉的理由: + * 这里曾经有一整套"丢最老一轮 → 重试, 最多丢 5 轮"的机制 (配套 + * --no-auto-trim / auto_trim / LIGENT_AUTO_TRIM). 它被删掉的理由: * - * 1. 判断依据只是错误文案里的关键词("too long"、"上下文"、"超出"…), + * 1. 判断依据只是错误文案里的关键词 ("too long"、"上下文"、"超出"…), * 任何 4xx 蹭到这些词都会触发裁剪 —— 而裁剪是把最老的几轮从历史里 - * **真的删掉**,不可逆。用一句猜出来的判据去毁掉对话记录,代价不对等。 - * 2. 它把"历史太大"这个本该由用户决定的事(/back 摘掉哪一轮、/reset - * 重来、还是换个窗口更大的模型)自动化掉了,用户看到的是对话莫名其妙 - * 少了几轮。 + * **真的删掉**, 不可逆. 用一句猜出来的判据去毁掉对话记录, 代价不对等. + * 2. 它把"历史太大"这个本该由用户决定的事 (/back 摘掉哪一轮、/reset + * 重来、还是换个窗口更大的模型)自动化掉了, 用户看到的是对话莫名其妙 + * 少了几轮. * - * 现在超限就是如实报错,历史一个字节都不动,怎么处理由用户决定。 */ + * 现在超限就是如实报错, 历史一个字节都不动, 怎么处理由用户决定. */ - /* ---- 图片被服务端拒绝:把图片从历史里摘掉再试一次 ---- + /* ---- 图片被服务端拒绝: 把图片从历史里摘掉再试一次 ---- * - * 格式和体积本地已经拦了,但服务端还可能因为别的原因不收(图片损坏、 - * 这个变体不支持……)。那条引用躺在历史里,不修的话之后每一轮都会以 - * 同样的 4xx 失败、历史却原样保留 —— 用户只剩 /reset 一条路。 - * 这里宁可丢掉这些图,也不能让整段对话死在这儿。 */ + * 格式和体积本地已经拦了, 但服务端还可能因为别的原因不收 (图片损坏、 + * 这个变体不支持……). 那条引用躺在历史里, 不修的话之后每一轮都会以 + * 同样的 4xx 失败、历史却原样保留 —— 用户只剩 /reset 一条路. + * 这里宁可丢掉这些图, 也不能让整段对话死在这儿. */ if (!resp && !dc.printed && !img_dropped && agent_is_http_4xx(err) && image_has_refs(a->messages) && err_mentions_image(err)) { @@ -805,7 +799,7 @@ int agent_run(Agent *a) fprintf(stderr, "%s%s%s\n", col(a, C_DIM), 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), @@ -835,18 +829,18 @@ int agent_run(Agent *a) if (dc.printed) fputc('\n', stdout); printf("%s%s%s\n", col(a, C_DIM), t("agent.interrupted"), col(a, C_OFF)); - /* 把已经生成的部分内容留在历史里,保持上下文连贯 */ + /* 把已经生成的部分内容留在历史里, 保持上下文连贯 */ json_array_push(a->messages, json_clone(resp->message)); llm_response_free(resp); return 1; } - /* 助手回复走 Markdown 渲染;工具输出保持原样,避免破坏代码/命令结果。 + /* 助手回复走 Markdown 渲染; 工具输出保持原样, 避免破坏代码/命令结果. * - * 判据是"流式回调有没有真的打印过",而不是"配置是不是流式模式": - * 服务端可能忽略 stream 参数直接返回整包 JSON(llm.c 会回退解析), - * 这种情况下不会有任何 delta 回调,必须在这里补打印, - * 否则内容被解析出来却永远不显示。 */ + * 判据是"流式回调有没有真的打印过", 而不是"配置是不是流式模式": + * 服务端可能忽略 stream 参数直接返回整包 JSON (llm.c 会回退解析), + * 这种情况下不会有任何 delta 回调, 必须在这里补打印, + * 否则内容被解析出来却永远不显示. */ int wrote_text = dc.printed; if (!wrote_text) { const char *content = json_get_string(resp->message, "content"); @@ -886,7 +880,7 @@ int agent_run(Agent *a) if (!has_tools) { if (step == 0 && a->llm.stream && !dc.printed) { - /* 既没输出也没调工具,提示一下,避免看起来像卡死 */ + /* 既没输出也没调工具, 提示一下, 避免看起来像卡死 */ const char *c = json_get_string( json_array_get(a->messages, json_array_size(a->messages) - 1), "content"); @@ -901,9 +895,9 @@ int agent_run(Agent *a) json_free(tool_calls); if (rc != 0) return 1; - /* 只有设了正数上限才可能"到顶"。不限时这一轮一直跑到模型自己收尾 - * (或用户 Ctrl-C)—— 每一次工具调用都会回到这个循环顶端,中断检查 - * 分别在 llm_chat 和 execute_tool_calls 里。 */ + /* 只有设了正数上限才可能"到顶". 不限时这一轮一直跑到模型自己收尾 + * (或用户 Ctrl-C)—— 每一次工具调用都会回到这个循环顶端, 中断检查 + * 分别在 llm_chat 和 execute_tool_calls 里. */ if (a->max_steps > 0 && step + 1 >= a->max_steps) { fprintf(stderr, "%s%s%s\n", col(a, C_DIM), tf("agent.max_steps", "n", i18n_num(a->max_steps), NULL), diff --git a/src/agent.h b/src/agent.h index 31a6844..3494800 100644 --- a/src/agent.h +++ b/src/agent.h @@ -8,14 +8,14 @@ #include "markdown.h" #include -/* Agent 用具名 struct(而不是匿名 typedef),这样别的头文件可以只写 - * `struct Agent;` 做前置声明,不必把整个定义 include 进去。 - * 对使用者没有任何区别,`Agent x;` 照样写。 */ +/* Agent 用具名 struct (而不是匿名 typedef), 这样别的头文件可以只写 + * `struct Agent;` 做前置声明, 不必把整个定义 include 进去. + * 对使用者没有任何区别, `Agent x;` 照样写. */ struct Agent { LlmConfig llm; - JsonValue *messages; /* 消息数组,跨轮保留 */ - JsonValue *tools; /* tools schema,use_tools=0 时为 NULL */ - int max_steps; /* 单轮最多几次模型往返;<= 0 = 不限 */ + JsonValue *messages; /* 消息数组, 跨轮保留 */ + JsonValue *tools; /* tools schema, use_tools=0 时为 NULL */ + int max_steps; /* 单轮最多几次模型往返; <= 0 = 不限 */ int use_tools; int color; /* 是否输出 ANSI 颜色 */ int show_tool_output; @@ -23,20 +23,20 @@ struct Agent { int probe_caps; /* 首个请求前跑一次能力探测序列 */ Capabilities caps; /* 探测结果:决定要不要暴露 read_image / 工具表 */ - /* 工具分发。默认指向 tools.c 里那套;meta 会话换成自己的, - * 于是两边的工具集天然隔离 —— 主模型看不到也调不到 meta 的工具, - * 反过来也一样。agent_run 只认这两个指针。 */ + /* 工具分发. 默认指向 tools.c 里那套; meta 会话换成自己的, + * 于是两边的工具集天然隔离 —— 主模型看不到也调不到 meta 的工具, + * 反过来也一样. agent_run 只认这两个指针. */ char *(*exec_tool)(const char *name, const JsonValue *args, int *is_error); int (*is_mutating)(const char *name); - /* 本次会话的 token 用量(服务端在响应里回报,这里只做累加) */ + /* 本次会话的 token 用量 (服务端在响应里回报, 这里只做累加) */ long long tok_prompt; long long tok_completion; long long tok_total; long long tok_last_prompt; /* 最近一次的输入 token ≈ 当前上下文大小 */ int api_calls; int usage_reported; /* 服务端是否报过 usage */ - time_t started_at; /* 本轮对话开始时间,存档文件名要用 */ + time_t started_at; /* 本轮对话开始时间, 存档文件名要用 */ MdRenderer *md; /* 助手回复的 Markdown 渲染器 */ }; typedef struct Agent Agent; @@ -44,64 +44,64 @@ typedef struct Agent Agent; void agent_init(Agent *a); void agent_free(Agent *a); -/* 内置默认 system 提示词,返回值需 free */ +/* 内置默认 system 提示词, 返回值需 free */ char *agent_default_system_prompt(void); -/* 设置/覆盖 system 提示词(必须在 agent_add_system 之前调用才生效) */ +/* 设置/覆盖 system 提示词 (必须在 agent_add_system 之前调用才生效) */ void agent_add_system(Agent *a, const char *prompt); void agent_add_user(Agent *a, const char *text); -/* 加一条 user 消息:文本 + 若干内容块(parts 所有权转移,可为 NULL)。 - * /include 用它把文件和图片跟用户这一句话一起发出去。 */ +/* 加一条 user 消息:文本 + 若干内容块 (parts 所有权转移, 可为 NULL). + * /include 用它把文件和图片跟用户这一句话一起发出去. */ void agent_add_user_parts(Agent *a, const char *text, JsonValue *parts); -/* 确保模型能力已探测(按 base_url+model 缓存,只真跑一次)。 - * 探测被关掉时什么都不做。 */ +/* 确保模型能力已探测 (按 base_url+model 缓存, 只真跑一次). + * 探测被关掉时什么都不做. */ void agent_ensure_caps(Agent *a); -/* 手动探测一次:**无视** --no-probe 与缓存,重新测(REPL 的 /probe 用它)。 - * 与自动探测的唯一区别就是这两点 —— 打出来的结论是同一份。 */ +/* 手动探测一次:**无视** --no-probe 与缓存, 重新测 (REPL 的 /probe 用它). + * 与自动探测的唯一区别就是这两点 —— 打出来的结论是同一份. */ void agent_probe_now(Agent *a); -/* 开关彩色 + Markdown 渲染。 - * 关闭时助手回复逐字节原样输出,保证管道 / 重定向拿到干净文本。 */ +/* 开关彩色 + Markdown 渲染. + * 关闭时助手回复逐字节原样输出, 保证管道 / 重定向拿到干净文本. */ void agent_set_color(Agent *a, int enabled); -/* 清空对话历史(保留 system 提示词) */ +/* 清空对话历史 (保留 system 提示词) */ void agent_reset(Agent *a); -/* 从最后一条 user 消息开始跑,直到模型不再请求工具。 - * 返回 0 正常结束,-1 出错,1 被用户中断。 */ +/* 从最后一条 user 消息开始跑, 直到模型不再请求工具. + * 返回 0 正常结束, -1 出错, 1 被用户中断. */ int agent_run(Agent *a); -/* 结构体检:保证 assistant(tool_calls) 后面紧跟对应的 tool 消息。 - * 没问题返回 NULL;修过则返回重建好的数组(调用方负责替换旧的)。 - * *out_fixed 回填补/删的处数。 */ +/* 结构体检:保证 assistant(tool_calls) 后面紧跟对应的 tool 消息. + * 没问题返回 NULL; 修过则返回重建好的数组 (调用方负责替换旧的). + * *out_fixed 回填补/删的处数. */ JsonValue *agent_repair_tool_pairing(const JsonValue *messages, int *out_fixed); -/* 当前历史的消息条数(含 system 提示词,内部比对用) */ +/* 当前历史的消息条数 (含 system 提示词, 内部比对用) */ size_t agent_history_len(const Agent *a); /* 面向用户的"历史消息数":不含 system 提示词 */ size_t agent_conversation_len(const Agent *a); -/* 用 given 整体替换历史(取得所有权)。存档里的 system 消息会被丢掉, - * 保留当前生效的那条 —— 否则载入旧存档会把用户现在的提示词换掉。 - * 同时把对话开始时间重置为 now。 */ +/* 用 given 整体替换历史 (取得所有权). 存档里的 system 消息会被丢掉, + * 保留当前生效的那条 —— 否则载入旧存档会把用户现在的提示词换掉. + * 同时把对话开始时间重置为 now. */ void agent_replace_history(Agent *a, JsonValue *messages); -/* 摘掉最近一整轮(最后一条 user 消息及其之后的一切),返回被摘掉的消息数组, - * 没有可摘的则返回 NULL。调用者负责释放。 - * 用于 /back:模型调用的工具与结果必须整组一起走,不能只删中间几条。 */ +/* 摘掉最近一整轮 (最后一条 user 消息及其之后的一切), 返回被摘掉的消息数组, + * 没有可摘的则返回 NULL. 调用者负责释放. + * 用于 /back:模型调用的工具与结果必须整组一起走, 不能只删中间几条. */ JsonValue *agent_take_last_round(Agent *a); -/* 服务端回了 4xx(图片引用失效的重传就靠它触发) */ +/* 服务端回了 4xx (图片引用失效的重传就靠它触发) */ int agent_is_http_4xx(const char *err); -/* 这条 user 消息是否开启新的一轮。判据见 agent.c:**只有图片附件、没有文字的** - * user 消息不算新的一轮 —— 它是 read_image 补上去的,按它切轮会把紧邻的 - * assistant(tool_calls) 与 tool 结果拆散(下一轮请求直接 400)。 - * meta.c 切轮时必须用同一个判据,否则它报的"轮号"和主循环的口径对不上。 */ +/* 这条 user 消息是否开启新的一轮. 判据见 agent.c:**只有图片附件、没有文字的** + * user 消息不算新的一轮 —— 它是 read_image 补上去的, 按它切轮会把紧邻的 + * assistant(tool_calls) 与 tool 结果拆散 (下一轮请求直接 400). + * meta.c 切轮时必须用同一个判据, 否则它报的"轮号"和主循环的口径对不上. */ int agent_opens_round(const JsonValue *m); #endif /* LIGENT_AGENT_H */ diff --git a/src/archive.c b/src/archive.c index 3fe21a1..9b50218 100644 --- a/src/archive.c +++ b/src/archive.c @@ -1,4 +1,4 @@ -/* archive.c - 对话存档实现(文件名即谱系) */ +/* archive.c - 对话存档实现 (文件名即谱系) */ #include "archive.h" #include "i18n.h" #include "util.h" @@ -31,11 +31,11 @@ static void stamp(time_t t, const char *fmt, char *out, size_t n) /* 名字生成 */ /* ------------------------------------------------------------------ */ -/* 随机字母数字串由 util.c 的 random_alnum 提供([a-z0-9],与用户给的例子一致)。 - * 图片备份名用的也是同一个函数 —— 以前两处各写了一份,连退化种子都各写一遍。 */ +/* 随机字母数字串由 util.c 的 random_alnum 提供 ([a-z0-9], 与用户给的例子一致). + * 图片备份名用的也是同一个函数 —— 以前两处各写了一份, 连退化种子都各写一遍. */ -/* 接一级分支之后是否还塞得下(前缀也算在内)。 - * meta 根多一个 'm',所以要按 meta_len 算。 */ +/* 接一级分支之后是否还塞得下 (前缀也算在内). + * meta 根多一个 'm', 所以要按 meta_len 算. */ static int branch_fits(const char *lineage, size_t prefixlen, size_t meta_len) { size_t need = prefixlen + strlen(lineage) + 1 + meta_len @@ -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); @@ -86,7 +86,7 @@ int archive_next_name(const char *bindir, const char *lineage, free(name); } - /* 极不可能走到这里(64 次都撞名),把话说清楚而不是静默失败 */ + /* 极不可能走到这里 (64 次都撞名), 把话说清楚而不是静默失败 */ snprintf(errbuf, errlen, "%s", t("archive.err.name_exhausted")); free(dir); return -1; @@ -131,9 +131,9 @@ int archive_export(const JsonValue *messages, const char *bindir, json_object_set_string(root, "format", ARCHIVE_FORMAT); json_object_set_number(root, "version", 2); json_object_set_string(root, "name", name); - /* 记下来源,这样即使文件名被改名或谱系因超长而重置, + /* 记下来源, 这样即使文件名被改名或谱系因超长而重置, * 也能从内容里看出它从哪来的 */ - /* meta 版是另起的新根,所以 parent 记空 —— 出处已经写在文件名里了 */ + /* meta 版是另起的新根, 所以 parent 记空 —— 出处已经写在文件名里了 */ json_object_set_string(root, "parent", (!meta && lineage && *lineage) ? lineage : ""); json_object_set_string(root, "created", ctime_s); @@ -178,7 +178,7 @@ int archive_export(const JsonValue *messages, const char *bindir, static int cmp_entry(const void *a, const void *b) { const ArchiveEntry *x = a, *y = b; - /* 新的在前;时间相同时按名字,保证顺序稳定 */ + /* 新的在前; 时间相同时按名字, 保证顺序稳定 */ int c = strcmp(y->created ? y->created : "", x->created ? x->created : ""); if (c) return c; return strcmp(x->name ? x->name : "", y->name ? y->name : ""); @@ -214,11 +214,11 @@ size_t archive_list(const char *bindir, ArchiveEntry **out) memset(e, 0, sizeof *e); e->path = path; e->name = stem; - e->created = created; /* 时间只从内容里读,不看 mtime */ + e->created = created; /* 时间只从内容里读, 不看 mtime */ e->saved = saved; /* 上次写出这份存档的时间 */ e->model = model; e->count = msgs ? (long)json_array_size(msgs) : -1; - /* parent 只用于展示,这里不单独留存 */ + /* parent 只用于展示, 这里不单独留存 */ free(name); free(parent); json_free(msgs); diff --git a/src/archive.h b/src/archive.h index e5d5b70..89edee7 100644 --- a/src/archive.h +++ b/src/archive.h @@ -6,12 +6,12 @@ * a1b2c3d4_a9x 从 a1b2c3d4 分出来的分支 * a1b2c3d4_a9x_k2m 再往下分支 * - * 这样不需要任何索引文件,光看目录就能看出分支关系,也不会因为索引与 - * 实际文件不同步而出错。代价是名字会越来越长,所以超过长度上限时 - * 重新起一个 8 位随机根(谱系就此断开,但至少不会撑爆文件名)。 + * 这样不需要任何索引文件, 光看目录就能看出分支关系, 也不会因为索引与 + * 实际文件不同步而出错. 代价是名字会越来越长, 所以超过长度上限时 + * 重新起一个 8 位随机根 (谱系就此断开, 但至少不会撑爆文件名). * * 存档放在「二进制所在目录」下的 ligent.archive/ 里 —— 和配置同一个原则: - * 整个程序连同它的数据都待在一个目录里,拷走即走。 + * 整个程序连同它的数据都待在一个目录里, 拷走即走. */ #ifndef LIGENT_ARCHIVE_H #define LIGENT_ARCHIVE_H @@ -19,48 +19,48 @@ #include "json.h" #include -/* 完整文件名(含 .json)的长度上限 */ +/* 完整文件名 (含 .json)的长度上限 */ #define ARCHIVE_NAME_MAX 240 /* 根名与每级分支后缀的长度 */ #define ARCHIVE_ROOT_LEN 8 #define ARCHIVE_BRANCH_LEN 3 -/* meta 改过的对话另起一个根,但名字里留住出处:<原名>_m<3位>。 - * 标记是 'm',所以"下划线后有 4 个字符"只可能是 meta 根, - * 而普通分支固定是 3 个 —— 按长度就能分开,不会歧义。 */ +/* meta 改过的对话另起一个根, 但名字里留住出处:<原名>_m<3位>. + * 标记是 'm', 所以"下划线后有 4 个字符"只可能是 meta 根, + * 而普通分支固定是 3 个 —— 按长度就能分开, 不会歧义. */ #define ARCHIVE_META_MARK 'm' #define ARCHIVE_META_LEN (1 + ARCHIVE_BRANCH_LEN) typedef struct { char *path; /* 完整路径 */ - char *name; /* 文件名,不含 .json */ - char *parent; /* 来源存档名;是自己起的根则为空串 */ + char *name; /* 文件名, 不含 .json */ + char *parent; /* 来源存档名; 是自己起的根则为空串 */ char *created; /* "YYYY-MM-DD HH:MM:SS" */ char *saved; char *model; - long count; /* 消息条数;-1 表示读取失败 */ + long count; /* 消息条数; -1 表示读取失败 */ } ArchiveEntry; -/* 存档目录:/ligent.archive(malloc) */ +/* 存档目录:/ligent.archive (malloc) */ char *archive_dir(const char *bindir); -/* 临时存档的前缀。/import 前那次保底存档用它,以便和用户主动存下来的分支 - * 区分开:它只是"换掉当前对话之前先留一手",不属于分支树的一部分。 */ +/* 临时存档的前缀. /import 前那次保底存档用它, 以便和用户主动存下来的分支 + * 区分开:它只是"换掉当前对话之前先留一手", 不属于分支树的一部分. */ #define ARCHIVE_TMP_PREFIX "tmp_" -/* 生成下一个可用的文件名(不含 .json),保证在目录里不冲突。 - * lineage 为空则起一个新根,否则在其后接 _<3位随机>; - * 接上去会超出长度上限时也退回新根。 - * prefix 加在名字最前面(例如临时存档的 "tmp_"),长度上限把它也算进去。 - * meta=1 时接的是 _m<3位>,语义是"这份是 meta 改过的",调用方应把 - * parent 记成空(新根)。 - * 成功返回 0 并把名字写进 out(malloc),失败返回 -1 并写 errbuf。 */ +/* 生成下一个可用的文件名 (不含 .json), 保证在目录里不冲突. + * lineage 为空则起一个新根, 否则在其后接 _<3位随机>; + * 接上去会超出长度上限时也退回新根. + * prefix 加在名字最前面 (例如临时存档的 "tmp_"), 长度上限把它也算进去. + * meta=1 时接的是 _m<3位>, 语义是"这份是 meta 改过的", 调用方应把 + * parent 记成空 (新根). + * 成功返回 0 并把名字写进 out (malloc), 失败返回 -1 并写 errbuf. */ int archive_next_name(const char *bindir, const char *lineage, const char *prefix, int meta, char **out, char *errbuf, size_t errlen); -/* 导出。lineage 为来源存档名(NULL/空 = 新根),prefix 见上。 - * meta=1 表示这份是 meta 编辑过的:名字用 _m<3位>,且 parent 记空。 - * 成功返回 0,out_path 与 out_name 均为 malloc(可为 NULL)。 */ +/* 导出. lineage 为来源存档名 (NULL/空 = 新根), prefix 见上. + * 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, time_t created, time_t saved, @@ -68,12 +68,12 @@ int archive_export(const JsonValue *messages, const char *bindir, char **out_path, char **out_name, char *errbuf, size_t errlen); -/* 列出全部存档,按开始时间从新到旧排序。返回条数,*out 为 malloc 的数组 */ +/* 列出全部存档, 按开始时间从新到旧排序. 返回条数, *out 为 malloc 的数组 */ size_t archive_list(const char *bindir, ArchiveEntry **out); void archive_list_free(ArchiveEntry *list, size_t n); -/* 读取存档里的 messages 数组(调用者释放)。 - * out_created / out_saved / out_model / out_name / out_parent 可选回填(malloc)。 */ +/* 读取存档里的 messages 数组 (调用者释放). + * out_created / out_saved / out_model / out_name / out_parent 可选回填 (malloc). */ JsonValue *archive_load(const char *path, char **out_created, char **out_saved, char **out_model, char **out_name, char **out_parent, char *errbuf, size_t errlen); diff --git a/src/attach.c b/src/attach.c index 95591cc..23a7bfd 100644 --- a/src/attach.c +++ b/src/attach.c @@ -15,14 +15,14 @@ #include #include -/* 暂存区。每一项都已经是一个可以直接塞进 content 数组的块 */ +/* 暂存区. 每一项都已经是一个可以直接塞进 content 数组的块 */ static JsonValue *g_parts; /* 懒创建 */ -static size_t g_text_bytes; /* 文本累计字节数,用来卡总量 */ +static size_t g_text_bytes; /* 文本累计字节数, 用来卡总量 */ -/* 每个块对应的来源路径,只用于给用户看。 +/* 每个块对应的来源路径, 只用于给用户看. * - * 刻意**不**把它塞进块里:那会跟着请求一起发给服务端,而 content 块里多一个 - * 自造字段是可以被严格的服务端判 400 的。显示用的东西就该留在本地。 */ + * 刻意**不**把它塞进块里:那会跟着请求一起发给服务端, 而 content 块里多一个 + * 自造字段是可以被严格的服务端判 400 的. 显示用的东西就该留在本地. */ static char **g_names; static size_t g_names_n, g_names_cap; @@ -42,7 +42,7 @@ void attach_clear(void) names_clear(); } -/* 把来源路径记下来(和 g_parts 同步增长) */ +/* 把来源路径记下来 (和 g_parts 同步增长) */ static void remember(const char *path) { if (g_names_n == g_names_cap) { @@ -100,12 +100,12 @@ void attach_print(void) /* 单个文件的处理 */ /* ------------------------------------------------------------------ */ -/* 二进制判定(判据与 8 KB 窗口都在 util.h,和 read_file 共用一份): - * 以前这里扫的是**整个文件**而 read_file 只看前 8 KB,于是同一个文件会出现 - * "/include 说它是二进制、read_file 说它能读"这种自相矛盾的说法。 */ +/* 二进制判定 (判据与 8 KB 窗口都在 util.h, 和 read_file 共用一份): + * 以前这里扫的是**整个文件**而 read_file 只看前 8 KB, 于是同一个文件会出现 + * "/include 说它是二进制、read_file 说它能读"这种自相矛盾的说法. */ -/* 把一个文件变成 content 块推入暂存区。 - * 返回 1 = 已附带,0 = 跳过(原因打到 skipped_why),-1 = 出错了(同 0 处理) */ +/* 把一个文件变成 content 块推入暂存区. + * 返回 1 = 已附带, 0 = 跳过 (原因打到 skipped_why), -1 = 出错了 (同 0 处理) */ static int add_one(const char *path, AttachVisionFn can_image, void *ud, const char **why) { @@ -113,7 +113,7 @@ static int add_one(const char *path, AttachVisionFn can_image, void *ud, if (stat(path, &st) != 0) { *why = t("attach.err.unreadable"); return 0; } if (!S_ISREG(st.st_mode)) { *why = t("attach.err.not_regular"); return 0; } - /* 先按内容试试是不是图片 —— 扩展名不算数(和 read_image 一个规矩) */ + /* 先按内容试试是不是图片 —— 扩展名不算数 (和 read_image 一个规矩) */ { size_t len = 0; char *raw = read_entire_file(path, &len); @@ -170,11 +170,11 @@ static int add_one(const char *path, AttachVisionFn can_image, void *ud, return 0; } - /* 头 + 正文。头用 ===== 包起来,模型一眼能看出文件从哪开始。 - * 文件不以换行结尾时补一个,免得下一个文件头黏在最后一行后面。 */ + /* 头 + 正文. 头用 ===== 包起来, 模型一眼能看出文件从哪开始. + * 文件不以换行结尾时补一个, 免得下一个文件头黏在最后一行后面. */ StrBuf sb; sb_init(&sb); - sb_printf(&sb, "===== %s(%zu 字节)=====\n", path, len); + sb_printf(&sb, "===== %s (%zu 字节)=====\n", path, len); sb_append(&sb, raw, len); free(raw); if (len > 0 && sb.data[sb.len - 1] != '\n') sb_putc(&sb, '\n'); @@ -206,11 +206,11 @@ int attach_glob(const char *pattern, AttachVisionFn can_image, void *ud, glob_t g; memset(&g, 0, sizeof g); - /* 认 ~/。**用 #ifdef 包起来**:GLOB_TILDE 是 GNU 扩展,glibc 一定有, - * 而 musl 那边不敢打包票(它挂在编译期 —— 没有这个宏就是编译失败, - * 静态 musl 构建会直接挂在这儿)。有就带,没有就退化成普通路径展开。 - * 不开 GLOB_BRACE:{a,b} 是 shell 特有写法,开了会让用户以为别的 shell - * 语法也能用。 */ + /* 认 ~/. **用 #ifdef 包起来**:GLOB_TILDE 是 GNU 扩展, glibc 一定有, + * 而 musl 那边不敢打包票 (它挂在编译期 —— 没有这个宏就是编译失败, + * 静态 musl 构建会直接挂在这儿). 有就带, 没有就退化成普通路径展开. + * 不开 GLOB_BRACE:{a,b} 是 shell 特有写法, 开了会让用户以为别的 shell + * 语法也能用. */ int gflags = 0; #ifdef GLOB_TILDE gflags |= GLOB_TILDE; @@ -240,7 +240,7 @@ int attach_glob(const char *pattern, AttachVisionFn can_image, void *ud, added++; } else { skipped_n++; - /* 这是用户自己敲的命令的回执,走 stdout(stderr 留给诊断) */ + /* 这是用户自己敲的命令的回执, 走 stdout (stderr 留给诊断) */ if (why) printf("%s\n", tf("attach.glob.skipped", "file", g.gl_pathv[i], "why", why, NULL)); @@ -262,7 +262,7 @@ static int cmp_str(const void *a, const void *b) return strcmp(*(const char *const *)a, *(const char *const *)b); } -/* 列一个目录。返回 malloc 的完整路径数组(目录带尾斜杠),*n 回填个数。 */ +/* 列一个目录. 返回 malloc 的完整路径数组 (目录带尾斜杠), *n 回填个数. */ static char **list_dir_sorted(const char *dir, size_t *n) { char **out = NULL; @@ -293,16 +293,16 @@ static char **list_dir_sorted(const char *dir, size_t *n) static void free_list(char **l, size_t n) { - /* 空表就是 NULL(list_dir_sorted 在没列出东西时返回 NULL 并把 n 置 0, - * 两者是绑在一起的,但那个不变量是隐式的)。既然 free(NULL) 本来就合法, - * 这里直接挡掉,省得依赖调用方永远把 n 和指针配平 —— sb_free 也是这么写的。 */ + /* 空表就是 NULL (list_dir_sorted 在没列出东西时返回 NULL 并把 n 置 0, + * 两者是绑在一起的, 但那个不变量是隐式的). 既然 free(NULL) 本来就合法, + * 这里直接挡掉, 省得依赖调用方永远把 n 和指针配平 —— sb_free 也是这么写的. */ if (!l) return; for (size_t i = 0; i < n; i++) free(l[i]); free(l); } -/* 相对 dir 解析用户输入:可能是序号、目录名、路径或 glob。 - * 返回选中的路径列表(malloc 数组),*n 回填个数。 */ +/* 相对 dir 解析用户输入:可能是序号、目录名、路径或 glob. + * 返回选中的路径列表 (malloc 数组), *n 回填个数. */ static char **resolve_input(const char *dir, const char *input, size_t *n) { *n = 0; diff --git a/src/attach.h b/src/attach.h index 6afc75b..b1259ea 100644 --- a/src/attach.h +++ b/src/attach.h @@ -1,16 +1,16 @@ /* attach.h - /include:把文件附带进下一条消息 * * 设计成"暂存"而不是"立刻发出去":附带文件的意义在于配合一个提问 - * ("这几个文件有什么问题"),所以 /include 先把文件攒起来,下一条消息 - * 带上它们一起发。暂存是进程内状态,发完就清空。 + * ("这几个文件有什么问题"), 所以 /include 先把文件攒起来, 下一条消息 + * 带上它们一起发. 暂存是进程内状态, 发完就清空. * * 附带的文件在会话里有两种形态: - * 文本 -> 直接作为 text 块(就是内容本身) - * 图片 -> 存成本机备份,历史里只放 ligent_image 引用 —— 和 read_image - * 完全同一条路,图片字节不进 JSON,构造请求时才展开成 base64。 + * 文本 -> 直接作为 text 块 (就是内容本身) + * 图片 -> 存成本机备份, 历史里只放 ligent_image 引用 —— 和 read_image + * 完全同一条路, 图片字节不进 JSON, 构造请求时才展开成 base64. * - * 图片有额外前提:**模型得能看图**。所以附带图片前会先用能力探测的结论 - * 确认一次,不支持就跳过并如实说明,而不是把图塞进去让服务端 400。 + * 图片有额外前提:**模型得能看图**. 所以附带图片前会先用能力探测的结论 + * 确认一次, 不支持就跳过并如实说明, 而不是把图塞进去让服务端 400. */ #ifndef LIGENT_ATTACH_H #define LIGENT_ATTACH_H @@ -19,10 +19,10 @@ #include -/* 单个文本文件的体积上限。比 read_file 的工具输出上限(100 KB)宽一些, - * 因为这是用户点名要带的,不是模型顺手的输出。 */ +/* 单个文本文件的体积上限. 比 read_file 的工具输出上限 (100 KB)宽一些, + * 因为这是用户点名要带的, 不是模型顺手的输出. */ #define ATTACH_MAX_FILE (256u * 1024) -/* 一次暂存的文本总量。再多就该用 read_file 的分段读,或者先挑一挑。 */ +/* 一次暂存的文本总量. 再多就该用 read_file 的分段读, 或者先挑一挑. */ #define ATTACH_MAX_TOTAL (1024u * 1024) /* 一次最多附带几个文件 */ #define ATTACH_MAX_FILES 32 @@ -36,29 +36,29 @@ size_t attach_count(void); /* 暂存里有图片吗 */ int attach_has_image(void); -/* 遇到图片时回调一次,问"当前模型能收图吗",返回 0 就跳过这张图。 +/* 遇到图片时回调一次, 问"当前模型能收图吗", 返回 0 就跳过这张图. * - * 做成回调而不是一个布尔入参:判断"能不能收图"要跑能力探测,而探测是要发 - * 请求的。纯文本的 /include 不该为了一张图都没有的一次附带去探测模型。 */ + * 做成回调而不是一个布尔入参:判断"能不能收图"要跑能力探测, 而探测是要发 + * 请求的. 纯文本的 /include 不该为了一张图都没有的一次附带去探测模型. */ typedef int (*AttachVisionFn)(void *ud); -/* 按 glob 展开并暂存(shell 语义,由 glob(3) 展开;目录要进去看请用 - * 交互式模式)。can_image 可为 NULL(一律当能收)。 - * 返回新增的文件数;*skipped 回填被跳过的(模型不收图 / 二进制 / - * 超限 / 读不到),出错返回 -1 并把原因写进 err。 */ +/* 按 glob 展开并暂存 (shell 语义, 由 glob(3) 展开; 目录要进去看请用 + * 交互式模式). can_image 可为 NULL (一律当能收). + * 返回新增的文件数; *skipped 回填被跳过的 (模型不收图 / 二进制 / + * 超限 / 读不到), 出错返回 -1 并把原因写进 err. */ int attach_glob(const char *pattern, AttachVisionFn can_image, void *ud, size_t *skipped, char *err, size_t errlen); -/* 交互式挑选:打印目录列表,用普通输入行逐层进入(可递归), - * 也能直接敲 glob 或路径。从 dir 开始(NULL = 当前目录)。 - * 返回新增的文件数;用户取消返回 0。 */ +/* 交互式挑选:打印目录列表, 用普通输入行逐层进入 (可递归), + * 也能直接敲 glob 或路径. 从 dir 开始 (NULL = 当前目录). + * 返回新增的文件数; 用户取消返回 0. */ int attach_pick(const char *dir, AttachVisionFn can_image, void *ud, size_t *skipped); -/* 取出暂存的附件(content 块数组)并清空。空则返回 NULL。 */ +/* 取出暂存的附件 (content 块数组)并清空. 空则返回 NULL. */ JsonValue *attach_take_parts(void); -/* 暂存了什么,给用户看一眼(一行一个,带缩进)。 */ +/* 暂存了什么, 给用户看一眼 (一行一个, 带缩进). */ void attach_print(void); #endif /* LIGENT_ATTACH_H */ diff --git a/src/capability.c b/src/capability.c index 41e24f6..55efc20 100644 --- a/src/capability.c +++ b/src/capability.c @@ -9,14 +9,14 @@ #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 typedef struct { @@ -47,8 +47,8 @@ const char *cap_name(int v) } } -/* 给人看的标签(--verbose 的探测记录)。cap_name() 是内部/测试用的中文常量, - * 不随界面语言变,所以这里另走一遍界面文案表。 */ +/* 给人看的标签 (--verbose 的探测记录). cap_name() 是内部/测试用的中文常量, + * 不随界面语言变, 所以这里另走一遍界面文案表. */ static const char *cap_label(int v, int is_tools) { if (v == CAP_YES) return t("agent.caps.yes"); @@ -57,7 +57,7 @@ static const char *cap_label(int v, int is_tools) return t("agent.caps.unknown"); } -/* https://host/v1 + /chat/completions(base_url 末尾可能带斜杠) */ +/* https://host/v1 + /chat/completions (base_url 末尾可能带斜杠) */ static char *chat_url(const char *base_url) { const char *bu = base_url ? base_url : ""; @@ -67,10 +67,10 @@ static char *chat_url(const char *base_url) } /* 一个试探请求的结论 */ -/* 注意别叫 R_OK —— 里那个是 access() 的权限位,会撞名 */ +/* 注意别叫 R_OK —— 里那个是 access() 的权限位, 会撞名 */ typedef enum { PROBE_OK = 0, /* 2xx:接受 */ - PROBE_REJECT = 1, /* 400:明确拒绝(只有在基线通过时才有意义) */ + PROBE_REJECT = 1, /* 400:明确拒绝 (只有在基线通过时才有意义) */ PROBE_UNKNOWN = 2 /* 网络、401/403/404/429/5xx……:什么也说明不了 */ } ProbeResult; @@ -82,8 +82,8 @@ 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:探测要的是"收不收",不是回复内容。 - * 不设这个的话,默认开着思考的模型会为了回答"1"生成一大段推理。 */ + /* 只让它回一个 token:探测要的是"收不收", 不是回复内容. + * 不设这个的话, 默认开着思考的模型会为了回答"1"生成一大段推理. */ json_object_set_number(body, "max_tokens", 1); json_object_set_bool(body, "stream", 0); if (with_tools) { @@ -92,7 +92,7 @@ static ProbeResult probe(const LlmConfig *cfg, const char *url, json_object_set_string(t, "type", "function"); JsonValue *fn = json_new_object(); json_object_set_string(fn, "name", "ligent_probe"); - json_object_set_string(fn, "description", "能力探测用,永远不该被调用"); + json_object_set_string(fn, "description", "能力探测用, 永远不该被调用"); JsonValue *params = json_new_object(); json_object_set_string(params, "type", "object"); json_object_set(fn, "parameters", params); @@ -108,8 +108,8 @@ 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, &no_retry, &resp_body, &status, NULL, @@ -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(); @@ -175,7 +175,7 @@ static void run_sequence(const LlmConfig *cfg, Capabilities *out, json_free(plain); if (base != PROBE_OK) { - /* 连基线都不通,后面的否定结论一概不作数 */ + /* 连基线都不通, 后面的否定结论一概不作数 */ if (verbose) fprintf(stderr, "%s", tf("capability.note.baseline_failed", "status", i18n_num(status), @@ -191,7 +191,7 @@ static void run_sequence(const LlmConfig *cfg, Capabilities *out, 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); @@ -210,7 +210,7 @@ static void run_sequence(const LlmConfig *cfg, Capabilities *out, out->tools = (tr == PROBE_OK) ? CAP_YES : (tr == PROBE_REJECT ? CAP_NO : CAP_UNKNOWN); if (verbose) - /* 用界面语言的标签,不用 cap_name() —— 后者是给日志和断言用的中文常量 */ + /* 用界面语言的标签, 不用 cap_name() —— 后者是给日志和断言用的中文常量 */ fprintf(stderr, "%s", tf("capability.note.split", "vision", cap_label(out->vision, 0), "tools", cap_label(out->tools, 1), NULL)); @@ -236,7 +236,7 @@ int cap_get(const LlmConfig *cfg, int enable, Capabilities *out, { if (!out) return 0; if (errbuf && errlen) errbuf[0] = '\0'; - if (!cfg) { /* 没有配置就什么都测不了,按"未测"处理 */ + if (!cfg) { /* 没有配置就什么都测不了, 按"未测"处理 */ out->vision = out->tools = CAP_UNKNOWN; out->probed = 0; out->conclusive = 0; @@ -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; @@ -264,7 +264,7 @@ int cap_get(const LlmConfig *cfg, int enable, Capabilities *out, run_sequence(cfg, out, verbose, errbuf, errlen); - /* 记进缓存;满了就丢掉最老的一条 */ + /* 记进缓存; 满了就丢掉最老的一条 */ if (g_cache_n == CAP_CACHE_MAX) { free(g_cache[0].base_url); free(g_cache[0].model); diff --git a/src/capability.h b/src/capability.h index 1f9670a..c476487 100644 --- a/src/capability.h +++ b/src/capability.h @@ -1,28 +1,28 @@ -/* capability.h - 模型能力探测(vision / tools) +/* capability.h - 模型能力探测 (vision / tools) * - * 为什么需要它:OpenAI 兼容层**没有任何查询能力的接口**。DeepSeek 和 MiMo 的 - * GET /v1/models 都只返回 id / object / owned_by,没有 capability / modality - * 字段。所以"这个模型收不收图片"只能靠**发一个试探请求**问出来。 + * 为什么需要它:OpenAI 兼容层**没有任何查询能力的接口**. DeepSeek 和 MiMo 的 + * GET /v1/models 都只返回 id / object / owned_by, 没有 capability / modality + * 字段. 所以"这个模型收不收图片"只能靠**发一个试探请求**问出来. * - * 这里叫"序列"是因为它不是一次请求,而是有先后的几步: + * 这里叫"序列"是因为它不是一次请求, 而是有先后的几步: * * 1. 基线:一句纯文本 + max_tokens=1 - * └ 失败 → 什么都别下结论(可能是 key、网络、模型名的问题) - * └ 成功 → 说明模型是活的,后面的否定结论才有意义 + * └ 失败 → 什么都别下结论 (可能是 key、网络、模型名的问题) + * └ 成功 → 说明模型是活的, 后面的否定结论才有意义 * 2. 合并试探:图片 + tools 一起发 - * └ 200 → 两样都支持(常见情况,到此为止,只花了 2 个请求) - * └ 非 200 → 拆开单独试,才知道是哪一个不行 + * └ 200 → 两样都支持 (常见情况, 到此为止, 只花了 2 个请求) + * └ 非 200 → 拆开单独试, 才知道是哪一个不行 * 3. 单独试探:图片、tools 各发一次 * - * 关键在于**否定结论只在基线成功时才敢下**:单个试探 400 而基线 200,说明 - * 差别就在那一样东西上。这样不需要去猜服务端的错误文案("不支持图片"、 - * "invalid content"、"multimodal not supported"…… 各家写法都不一样), - * 也就不会因为文案变化而误判。 + * 关键在于**否定结论只在基线成功时才敢下**:单个试探 400 而基线 200, 说明 + * 差别就在那一样东西上. 这样不需要去猜服务端的错误文案 ("不支持图片"、 + * "invalid content"、"multimodal not supported"…… 各家写法都不一样), + * 也就不会因为文案变化而误判. * - * 猜不准的时候一律按"支持"处理:多暴露一个工具,代价是模型可能白调一次 - * (我们还有 4xx 降级兜底);而错误地藏掉工具,用户会以为模型看不见图。 + * 猜不准的时候一律按"支持"处理:多暴露一个工具, 代价是模型可能白调一次 + * (我们还有 4xx 降级兜底); 而错误地藏掉工具, 用户会以为模型看不见图. * - * 结果按 base_url + model 缓存,所以**换模型会自动重新测**,切回来则命中缓存。 + * 结果按 base_url + model 缓存, 所以**换模型会自动重新测**, 切回来则命中缓存. */ #ifndef LIGENT_CAPABILITY_H #define LIGENT_CAPABILITY_H @@ -38,27 +38,27 @@ typedef struct { int vision; /* 能不能收图片输入 */ int tools; /* 能不能收 tools 参数 */ - int probed; /* 真的测过吗(0 = 没测或被禁用,结论一律按"支持") */ - int conclusive; /* 基线成功了吗 —— 只有它成功,否定结论才作数 */ + int probed; /* 真的测过吗 (0 = 没测或被禁用, 结论一律按"支持") */ + int conclusive; /* 基线成功了吗 —— 只有它成功, 否定结论才作数 */ } Capabilities; -/* 探测(或命中缓存)。enable=0 时直接给"没测过"的全支持结果。 - * verbose 会打印每个试探请求的细节。errbuf 收探测过程中的失败原因, - * 仅供提示,不影响结论。 - * 返回 1 = 这次真的发了请求(调用方可以据此提示用户一次); - * 返回 0 = 命中缓存或被禁用(什么都没发生,别重复提示)。 */ +/* 探测 (或命中缓存). enable=0 时直接给"没测过"的全支持结果. + * verbose 会打印每个试探请求的细节. errbuf 收探测过程中的失败原因, + * 仅供提示, 不影响结论. + * 返回 1 = 这次真的发了请求 (调用方可以据此提示用户一次); + * 返回 0 = 命中缓存或被禁用 (什么都没发生, 别重复提示). */ int cap_get(const LlmConfig *cfg, int enable, Capabilities *out, int verbose, char *errbuf, size_t errlen); -/* 只查缓存,不发任何请求。命中返回 1 并回填,没测过返回 0。 - * 给 /tools 这种"想知道但绝不该为此发请求"的地方用。 */ +/* 只查缓存, 不发任何请求. 命中返回 1 并回填, 没测过返回 0. + * 给 /tools 这种"想知道但绝不该为此发请求"的地方用. */ int cap_cached(const LlmConfig *cfg, Capabilities *out); -/* 把已缓存的结果清掉(下次调用重新探测)。 - * 正常换模型不需要它 —— 缓存按 base_url+model 索引;这个给测试用。 */ +/* 把已缓存的结果清掉 (下次调用重新探测). + * 正常换模型不需要它 —— 缓存按 base_url+model 索引; 这个给测试用. */ void cap_cache_clear(void); -/* 给人和日志看的中文说明,如"支持"/"不支持"/"未测"。字面量,不用释放 */ +/* 给人和日志看的中文说明, 如"支持"/"不支持"/"未测". 字面量, 不用释放 */ const char *cap_name(int v); #endif /* LIGENT_CAPABILITY_H */ diff --git a/src/config.c b/src/config.c index c292760..d698e2a 100644 --- a/src/config.c +++ b/src/config.c @@ -21,18 +21,18 @@ void config_defaults(LigentConfig *c) memset(c, 0, sizeof *c); c->base_url = xstrdup(DEFAULT_BASE_URL); c->api_key = xstrdup(""); - c->api_key_env = NULL; /* 密钥来源,供诊断展示(见 config.h) */ + c->api_key_env = NULL; /* 密钥来源, 供诊断展示 (见 config.h) */ c->model = xstrdup(DEFAULT_MODEL); c->system = NULL; - c->temperature = -1; /* 不发送,用服务端默认 */ + c->temperature = -1; /* 不发送, 用服务端默认 */ c->max_tokens = 0; /* 不发送 */ - c->thinking = THINKING_AUTO; /* 不发送 thinking,由服务端决定 */ + c->thinking = THINKING_AUTO; /* 不发送 thinking, 由服务端决定 */ c->reasoning_effort = NULL; /* 不发送 */ c->timeout = 300; c->stream = 1; - /* max_steps <= 0 = 不限制单轮往返次数(默认)。这是**默认**值:模型陷进 - * "调工具 → 看结果 → 再调" 的循环时不会再被我们自己掐断,由用户 Ctrl-C - * (或模型自己收尾)来结束。要恢复上限就设个正数。 */ + /* max_steps <= 0 = 不限制单轮往返次数 (默认). 这是**默认**值:模型陷进 + * "调工具 → 看结果 → 再调" 的循环时不会再被我们自己掐断, 由用户 Ctrl-C + * (或模型自己收尾)来结束. 要恢复上限就设个正数. */ c->max_steps = 0; c->use_tools = 1; c->retries = 2; @@ -48,10 +48,10 @@ static void set_str(char **dst, const char *val) *dst = val ? xstrdup(val) : NULL; } -/* 服务端认识的思考级别。DeepSeek 会把它们映射到 low/high/max 三档 - * (minimal/low→low,medium/high/xhigh→high,max/ultra→max), - * OpenAI 那边则用 minimal/low/medium/high。这里只做白名单校验, - * 不替用户改写 —— 原样发过去,由服务端决定怎么映射。 */ +/* 服务端认识的思考级别. DeepSeek 会把它们映射到 low/high/max 三档 + * (minimal/low→low, medium/high/xhigh→high, max/ultra→max), + * OpenAI 那边则用 minimal/low/medium/high. 这里只做白名单校验, + * 不替用户改写 —— 原样发过去, 由服务端决定怎么映射. */ static const char *const EFFORTS[] = { "minimal", "low", "medium", "high", "xhigh", "max", "ultra", NULL }; @@ -95,7 +95,7 @@ int config_parse_thinking(const char *s, int *out) } /* 配置文件里 thinking 既可以是布尔也可以是字符串: - * `"thinking": true` 比 `"thinking": "on"` 自然,两种都收。 */ + * `"thinking": true` 比 `"thinking": "on"` 自然, 两种都收. */ static int load_thinking(LigentConfig *c, const JsonValue *v, const char *path, char *errbuf, size_t errlen) { @@ -115,25 +115,25 @@ static int load_thinking(LigentConfig *c, const JsonValue *v, /* ---- 定位二进制所在目录 ---- * - * 关键点:路径要"跟着用户看到的那个文件走"。如果 ligent 是个软链接, - * 用户是把它当 ./bin/ligent 用的,配置就该在 ./bin/ 旁边,而不是被 - * 悄悄挪到源文件所在的目录去。 + * 关键点:路径要"跟着用户看到的那个文件走". 如果 ligent 是个软链接, + * 用户是把它当 ./bin/ligent 用的, 配置就该在 ./bin/ 旁边, 而不是被 + * 悄悄挪到源文件所在的目录去. * - * 所以这里优先用 argv[0](不解析最后一段的软链接),只在拿不到时 - * 才退回 /proc/self/exe —— 那个会解析软链接。 */ + * 所以这里优先用 argv[0] (不解析最后一段的软链接), 只在拿不到时 + * 才退回 /proc/self/exe —— 那个会解析软链接. */ -/* 确认 cand 确实指向我们自己的可执行文件。 - * 用 inode 比对,避免 $PATH 里恰好有个同名的别的东西把目录带偏。 - * 读不到 /proc/self/exe 时不做检查(非 Linux 平台)。 */ +/* 确认 cand 确实指向我们自己的可执行文件. + * 用 inode 比对, 避免 $PATH 里恰好有个同名的别的东西把目录带偏. + * 读不到 /proc/self/exe 时不做检查 (非 Linux 平台). */ static int is_self(const char *cand) { struct stat a, b; if (!cand || stat(cand, &a) != 0) return 0; /* stat 会跟随软链接 */ - if (stat("/proc/self/exe", &b) != 0) return 1; /* 无法比对,放行 */ + if (stat("/proc/self/exe", &b) != 0) return 1; /* 无法比对, 放行 */ return a.st_dev == b.st_dev && a.st_ino == b.st_ino; } -/* 在 $PATH 里查找可执行且确实是我们自己的文件,返回完整路径(malloc)或 NULL */ +/* 在 $PATH 里查找可执行且确实是我们自己的文件, 返回完整路径 (malloc)或 NULL */ static char *search_path(const char *name) { const char *path = getenv("PATH"); @@ -153,7 +153,7 @@ static char *search_path(const char *name) return found; } -/* 取路径中最后一段之前的部分;没有 '/' 时返回 NULL */ +/* 取路径中最后一段之前的部分; 没有 '/' 时返回 NULL */ static char *dir_of(const char *path) { const char *slash = path ? strrchr(path, '/') : NULL; @@ -162,7 +162,7 @@ static char *dir_of(const char *path) return xstrndup(path, (size_t)(slash - path)); } -/* 取所在目录并规范化(解析目录部分里的软链接、. 和 ..), +/* 取所在目录并规范化 (解析目录部分里的软链接、. 和 ..), * 但刻意不解析最后一段 —— 那正是"软链接所在目录"的关键 */ static char *dir_of_canonical(const char *path) { @@ -172,7 +172,7 @@ static char *dir_of_canonical(const char *path) char *canon = realpath(dir, NULL); /* 成功时由 malloc 分配 */ if (canon) { free(dir); return canon; } - /* 目录不存在等情况下退回未规范化的结果,但要保证是绝对路径 */ + /* 目录不存在等情况下退回未规范化的结果, 但要保证是绝对路径 */ if (dir[0] == '/') return dir; char cwd[PATH_MAX]; if (getcwd(cwd, sizeof cwd)) { @@ -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); @@ -213,7 +213,7 @@ char *config_binary_dir(const char *argv0) if (d) return d; } - /* 都失败就认当前目录,至少不会崩 */ + /* 都失败就认当前目录, 至少不会崩 */ return xstrdup("."); } @@ -227,12 +227,12 @@ char *config_default_path(const char *argv0) /* ---- 查找要加载的配置文件 ---- */ -/* 只认两个来源:命令行的显式指定,以及二进制目录下的 ligent.json。 - * 不做用户级 / 系统级 / 当前工作目录的查找。 */ +/* 只认两个来源:命令行的显式指定, 以及二进制目录下的 ligent.json. + * 不做用户级 / 系统级 / 当前工作目录的查找. */ static char *find_config_file(const char *explicit_path, const char *argv0) { if (explicit_path && *explicit_path) { - /* 显式指定的路径不做存在性判断,读不到会明确报错 */ + /* 显式指定的路径不做存在性判断, 读不到会明确报错 */ return xstrdup(explicit_path); } @@ -279,9 +279,9 @@ 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 消息 - * 既浪费又没有意义。 */ + /* 空字符串视为"没写":内置提示词里带着工具使用规范, + * 静默丢掉会让 agent 行为明显变差; 而且下发一条空的 system 消息 + * 既浪费又没有意义. */ if ((s = json_get_string(root, "system"))) { if (*s) set_str(&c->system, s); } @@ -349,18 +349,18 @@ void config_apply_env(LigentConfig *c) if (v && *v) set_str(&c->system, v); } - /* 厂商专属的环境变量只在"确实在用这家"时才读。 + /* 厂商专属的环境变量只在"确实在用这家"时才读. * - * 否则会出这种事:配置文件里写的是 OpenRouter 的 key, - * 而 shell 里残留的 DEEPSEEK_API_KEY 把它悄悄顶掉, - * 结果 A 家的密钥被发给了 B 家 —— 对方只会回一个含糊的 401。 - * 用户明明配对了却怎么都调不通,排查方向还会被带偏。 */ + * 否则会出这种事:配置文件里写的是 OpenRouter 的 key, + * 而 shell 里残留的 DEEPSEEK_API_KEY 把它悄悄顶掉, + * 结果 A 家的密钥被发给了 B 家 —— 对方只会回一个含糊的 401. + * 用户明明配对了却怎么都调不通, 排查方向还会被带偏. */ const char *url = c->base_url ? c->base_url : ""; int use_deepseek = strstr(url, "deepseek") != NULL; int use_openai = strstr(url, "api.openai.com") != NULL; /* api_key 优先级: - * LIGENT_API_KEY(显式覆盖) > 配置文件 > 对应厂商的环境变量兜底 */ + * LIGENT_API_KEY (显式覆盖) > 配置文件 > 对应厂商的环境变量兜底 */ const char *key = getenv("LIGENT_API_KEY"); if (key && *key) { set_str(&c->api_key, key); @@ -373,7 +373,7 @@ void config_apply_env(LigentConfig *c) if (k && *k) { set_str(&c->api_key, k); c->api_key_env = src; } } - /* OPENAI_BASE_URL 同理,只在 base_url 还是内置默认值(用户没显式配)时兜底 */ + /* OPENAI_BASE_URL 同理, 只在 base_url 还是内置默认值 (用户没显式配)时兜底 */ if (use_openai || strcmp(url, DEFAULT_BASE_URL) == 0) { const char *bu = getenv("OPENAI_BASE_URL"); if (bu && *bu) set_str(&c->base_url, bu); @@ -395,8 +395,8 @@ 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; if (config_parse_thinking(v, &t) == 0) c->thinking = t; @@ -420,7 +420,7 @@ int config_init(LigentConfig *c, const char *explicit_path, const char *argv0, if (path) { if (config_load_file(c, path, errbuf, errlen) != 0) { if (explicit_path && *explicit_path) { free(path); return -1; } - /* 约定位置的配置坏了,也不该直接拦住用户 */ + /* 约定位置的配置坏了, 也不该直接拦住用户 */ if (errbuf && errlen) fprintf(stderr, "%s\n", tf("cfg.err.parse_ignored", "error", errbuf, NULL)); @@ -461,8 +461,8 @@ static void mask_key(const char *key, char *out, size_t outlen) tf("cfg.val.chars", "n", num, NULL)); } -/* 字段名按显示宽度补齐。中文标签一个字占 2 列,用 %-13s 会整体顶歪, - * 而英文标签没有这个问题 —— 所以宽度得算,不能写死空格(同 doctor 的 label)。 */ +/* 字段名按显示宽度补齐. 中文标签一个字占 2 列, 用 %-13s 会整体顶歪, + * 而英文标签没有这个问题 —— 所以宽度得算, 不能写死空格 (同 doctor 的 label). */ static void dump_field(const char *lbl, const char *fmt, ...) { size_t w = display_width(lbl, strlen(lbl)); @@ -543,8 +543,8 @@ void config_dump(const LigentConfig *c) } } -/* 密钥来源的可读标签(诊断用)。诊断代码不该去 strstr() 中文文案 - * 判断来源 —— 界面语言一变那个判断就悄悄失效了。 */ +/* 密钥来源的可读标签 (诊断用). 诊断代码不该去 strstr() 中文文案 + * 判断来源 —— 界面语言一变那个判断就悄悄失效了. */ const char *config_api_key_source(const LigentConfig *c) { if (c->api_key_env) return tf("cfg.src.env", "name", c->api_key_env, NULL); diff --git a/src/config.h b/src/config.h index 0dfcf73..884c166 100644 --- a/src/config.h +++ b/src/config.h @@ -3,90 +3,90 @@ * 唯一的配置文件位置是**二进制所在目录**下的 ligent.json: * * /path/to/ligent ← 可执行文件 - * /path/to/ligent.json ← 配置(不存在则用内置默认值) + * /path/to/ligent.json ← 配置 (不存在则用内置默认值) * - * 不做用户级(~/.config、$XDG_CONFIG_HOME)也不做系统级(/etc)查找, - * 以便整个程序和它的数据可以自包含在一个目录里(U 盘 / 容器 / 拷贝即走)。 + * 不做用户级 (~/.config、$XDG_CONFIG_HOME)也不做系统级 (/etc)查找, + * 以便整个程序和它的数据可以自包含在一个目录里 (U 盘 / 容器 / 拷贝即走). */ #ifndef LIGENT_CONFIG_H #define LIGENT_CONFIG_H #include -/* 思考模式开关。AUTO 表示请求里不带 thinking 字段,由服务端决定 - * (DeepSeek 的默认是"开启")。 */ +/* 思考模式开关. AUTO 表示请求里不带 thinking 字段, 由服务端决定 + * (DeepSeek 的默认是"开启"). */ enum { THINKING_AUTO = -1, THINKING_OFF = 0, THINKING_ON = 1 }; typedef struct { char *base_url; char *api_key; - /* 密钥来源,仅供诊断展示(不拥有内存)。 - * 以前这里放的是现成的中文文案,诊断代码靠 strstr(..., "环境变量") 判断 - * 来源 —— 界面语言一变那个判断就悄悄失效了,所以改成结构化字段。 */ - const char *api_key_env; /* 来自哪个环境变量;NULL 表示不是环境变量 */ + /* 密钥来源, 仅供诊断展示 (不拥有内存). + * 以前这里放的是现成的中文文案, 诊断代码靠 strstr(..., "环境变量") 判断 + * 来源 —— 界面语言一变那个判断就悄悄失效了, 所以改成结构化字段. */ + const char *api_key_env; /* 来自哪个环境变量; NULL 表示不是环境变量 */ int api_key_from_file; int api_key_from_cli; char *model; char *system; /* NULL = 使用内置提示词 */ double temperature; /* < 0 = 不发送 */ long max_tokens; /* <= 0 = 不发送 */ - int thinking; /* 思考模式:THINKING_AUTO / _OFF / _ON,见下 */ - char *reasoning_effort; /* 思考级别;NULL/空 = 不发送 */ + int thinking; /* 思考模式:THINKING_AUTO / _OFF / _ON, 见下 */ + char *reasoning_effort; /* 思考级别; NULL/空 = 不发送 */ long timeout; /* 秒 */ int stream; - int max_steps; /* 单轮最多几次模型往返;<= 0 = 不限 */ + int max_steps; /* 单轮最多几次模型往返; <= 0 = 不限 */ int use_tools; int retries; /* 瞬态失败的额外重试次数 */ int confirm; /* 写操作与命令执行前询问 */ int auto_save; /* 退出/重置/导入前自动存档 */ - int probe; /* 启动/换模型时探测模型能力(vision / tools) */ - char *config_dir; /* 二进制所在目录(配置与数据的唯一存放处) */ - char *config_path; /* 实际加载到的配置文件;没加载则为 NULL */ + int probe; /* 启动/换模型时探测模型能力 (vision / tools) */ + char *config_dir; /* 二进制所在目录 (配置与数据的唯一存放处) */ + char *config_path; /* 实际加载到的配置文件; 没加载则为 NULL */ } LigentConfig; void config_defaults(LigentConfig *c); -/* 加载配置:先读配置文件,再用环境变量覆盖。返回 0 成功。 +/* 加载配置:先读配置文件, 再用环境变量覆盖. 返回 0 成功. * - * explicit_path 非 NULL 时只读该文件(命令行 -c 的显式指定); - * 为 NULL 时读 <二进制目录>/ligent.json。 - * argv0 仅用于 /proc/self/exe 不可用时回退定位。 */ + * explicit_path 非 NULL 时只读该文件 (命令行 -c 的显式指定); + * 为 NULL 时读 <二进制目录>/ligent.json. + * argv0 仅用于 /proc/self/exe 不可用时回退定位. */ int config_init(LigentConfig *c, const char *explicit_path, const char *argv0, char *errbuf, size_t errlen); -/* 从指定文件读取并合并(供 config_init 内部与测试使用) */ +/* 从指定文件读取并合并 (供 config_init 内部与测试使用) */ int config_load_file(LigentConfig *c, const char *path, char *errbuf, size_t errlen); void config_apply_env(LigentConfig *c); void config_free(LigentConfig *c); -/* ---- 思考模式的小工具(配置文件 / 环境变量 / 命令行 / /think 共用)---- */ +/* ---- 思考模式的小工具 (配置文件 / 环境变量 / 命令行 / /think 共用)---- */ -/* 把 "auto"/"on"/"off"/"true"/"false"/"enabled"/"disabled"/"" 解析成 THINKING_*。 - * 成功返回 0,无法识别返回 -1。 */ +/* 把 "auto"/"on"/"off"/"true"/"false"/"enabled"/"disabled"/"" 解析成 THINKING_*. + * 成功返回 0, 无法识别返回 -1. */ int config_parse_thinking(const char *s, int *out); -/* 思考级别是否在服务端认识的白名单里(minimal/low/medium/high/xhigh/max/ultra) */ +/* 思考级别是否在服务端认识的白名单里 (minimal/low/medium/high/xhigh/max/ultra) */ int config_effort_valid(const char *s); -/* 白名单拼成的展示串,形如 "minimal/low/.../ultra"(静态缓冲,不用释放) */ +/* 白名单拼成的展示串, 形如 "minimal/low/.../ultra" (静态缓冲, 不用释放) */ const char *config_effort_list(void); -/* THINKING_* 的中文说明(静态字面量) */ +/* THINKING_* 的中文说明 (静态字面量) */ const char *config_thinking_name(int thinking); -/* 二进制所在目录(malloc);失败返回 NULL。 - * 优先 /proc/self/exe,其次 argv0 里的路径,最后在 $PATH 里查 */ +/* 二进制所在目录 (malloc); 失败返回 NULL. + * 优先 /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 打码) */ +/* 打印当前生效配置 (api_key 打码) */ void config_dump(const LigentConfig *c); -/* 密钥来源的可读标签(诊断用;返回静态缓冲,别长期持有) */ +/* 密钥来源的可读标签 (诊断用; 返回静态缓冲, 别长期持有) */ const char *config_api_key_source(const LigentConfig *c); #endif /* LIGENT_CONFIG_H */ diff --git a/src/confirm.c b/src/confirm.c index 59624b3..4fab869 100644 --- a/src/confirm.c +++ b/src/confirm.c @@ -19,11 +19,11 @@ static int g_enabled = 1; static ConfirmDescriber g_describer; static int g_color; -static int g_turn = CONFIRM_ASK; /* 本轮的提问策略,每轮复位 */ +static int g_turn = CONFIRM_ASK; /* 本轮的提问策略, 每轮复位 */ static const char *c(const char *code) { return g_color ? code : ""; } -/* 截断并压掉换行,保持在一行内 */ +/* 截断并压掉换行, 保持在一行内 */ static void preview(const char *s, size_t max, char *out, size_t outlen) { if (!s) { snprintf(out, outlen, "%s", t("confirm.val.empty")); return; } @@ -43,10 +43,10 @@ static void preview(const char *s, size_t max, char *out, size_t outlen) } } -/* line 集合的渲染在 tools.c 的 tool_lines_label —— 调用摘要(agent.c)用的是 - * 同一份,两处对"改的是哪几行"的说法必须一致。 */ +/* line 集合的渲染在 tools.c 的 tool_lines_label —— 调用摘要 (agent.c)用的是 + * 同一份, 两处对"改的是哪几行"的说法必须一致. */ -/* 把这次调用做什么讲清楚 —— 只说工具名等于没说,用户没法判断 */ +/* 把这次调用做什么讲清楚 —— 只说工具名等于没说, 用户没法判断 */ static void describe(const char *name, const JsonValue *args) { if (g_describer) { @@ -69,16 +69,16 @@ 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 就是全文件替换(工具不拒绝它),摘要里直说"全文件"。 */ + /* 行号必须打出来:用户要确认的第一件事就是"改的是哪几行". + * 没给 line 就是全文件替换 (工具不拒绝它), 摘要里直说"全文件". */ const char *scope = lines[0] ? lines : t("tool.call.all_lines"); if (json_get_bool(args, "replace_all", 0)) scope = tf("tool.call.scope_all", "scope", scope, NULL); fprintf(stderr, " %s %s\"%s\" [%s]%s\n", t("confirm.lbl.edit"), c(BOLD), buf, scope, c(OFF)); - /* 然后摆出**带行号**的新旧对照,再问 Y/n —— 用户就是照着它决定放不放行。 - * 旧版这里打的是"替换 <旧> -> <新>":两边各截 60 字符、压成一行,看不出 - * 到底动了几行、动的是哪几行,长一点的改动更是只剩两个省略号。 */ + /* 然后摆出**带行号**的新旧对照, 再问 Y/n —— 用户就是照着它决定放不放行. + * 旧版这里打的是"替换 <旧> -> <新>":两边各截 60 字符、压成一行, 看不出 + * 到底动了几行、动的是哪几行, 长一点的改动更是只剩两个省略号. */ tool_render_diff(stderr, args, " ", g_color); } else if (path) { preview(path, 160, buf, sizeof buf); @@ -107,20 +107,20 @@ int confirm_tool(const char *name, const JsonValue *args) { if (!g_enabled) return 1; - /* 本轮已经表过态就不再打扰。Always/neVer 只作用于这一轮: - * 下一条用户消息进来时 agent_run 会把策略复位成 ASK。 */ + /* 本轮已经表过态就不再打扰. Always/neVer 只作用于这一轮: + * 下一条用户消息进来时 agent_run 会把策略复位成 ASK. */ if (g_turn == CONFIRM_ALWAYS) return 1; if (g_turn == CONFIRM_NEVER) return 0; - /* 名字都没有就没什么可描述的,也没有什么"改动"值得拦 —— 交给工具层 - * 去回一句"未知工具"。strcmp(NULL, ...) 会直接崩。 */ + /* 名字都没有就没什么可描述的, 也没有什么"改动"值得拦 —— 交给工具层 + * 去回一句"未知工具". strcmp(NULL, ...) 会直接崩. */ if (!name) return 1; - /* 非交互输入:没有人能回答,按默认值(Y)放行。 - * 这也是"默认 Y"的自然推论 —— 问不到就等于不反对。 */ + /* 非交互输入:没有人能回答, 按默认值 (Y)放行. + * 这也是"默认 Y"的自然推论 —— 问不到就等于不反对. */ if (!isatty(STDIN_FILENO)) return 1; - /* 提权命令会在参数里明说 elevated,describe() 已经把它打出来了 —— - * 这里不需要额外的身份标注:没有常驻提权状态,一次命令一次身份。 */ + /* 提权命令会在参数里明说 elevated, describe() 已经把它打出来了 —— + * 这里不需要额外的身份标注:没有常驻提权状态, 一次命令一次身份. */ fprintf(stderr, "\n%s %s %s%s\n", c(YEL), t("confirm.lbl.about"), name ? name : "?", c(OFF)); describe(name, args); @@ -135,20 +135,20 @@ int confirm_tool(const char *name, const JsonValue *args) fputc('\n', stderr); /* 这里必须区分两种"读不到": * - * EINTR 用户按了 Ctrl-C。他是在说"别问了" —— 必须拒绝。 - * 真 EOF stdin 关了,没人能回答 —— 才按默认值放行。 + * EINTR 用户按了 Ctrl-C. 他是在说"别问了" —— 必须拒绝. + * 真 EOF stdin 关了, 没人能回答 —— 才按默认值放行. * - * 原来两者走同一个分支、都返回 1(允许),于是"在确认提示上按 - * Ctrl-C"变成了"批准这次操作"。run_command 侥幸自己查了中断 - * 标志才没执行,write_file / edit_file 没查,文件照写。 + * 原来两者走同一个分支、都返回 1 (允许), 于是"在确认提示上按 + * Ctrl-C"变成了"批准这次操作". run_command 侥幸自己查了中断 + * 标志才没执行, write_file / edit_file 没查, 文件照写. * - * 返回 0 之后 g_ligent_interrupt 仍是 1,agent_run 会把这一轮 - * 收掉 —— 和 Ctrl-C 在别处的语义(中断当前请求)保持一致。 */ + * 返回 0 之后 g_ligent_interrupt 仍是 1, agent_run 会把这一轮 + * 收掉 —— 和 Ctrl-C 在别处的语义 (中断当前请求)保持一致. */ if (errno == EINTR) return 0; return 1; } char *ans = str_trim(line); - /* 回车 = Y;A/V 大写小写都收,也收全词 */ + /* 回车 = Y; A/V 大写小写都收, 也收全词 */ if (*ans == '\0' || strcmp(ans, "y") == 0 || strcmp(ans, "Y") == 0) return 1; if (strcmp(ans, "n") == 0 || strcmp(ans, "N") == 0) return 0; if (strcmp(ans, "a") == 0 || strcmp(ans, "A") == 0 || diff --git a/src/confirm.h b/src/confirm.h index 56ad8c6..5d02cbe 100644 --- a/src/confirm.h +++ b/src/confirm.h @@ -1,9 +1,9 @@ /* confirm.h - 写操作与命令执行的确认 * - * 默认开启。ligent 最初是"全权执行"的设计,但模型一旦看错文件或理解偏了 - * 需求,后果是直接落到磁盘上的;加一道确认是目前唯一有效的止损点。 + * 默认开启. ligent 最初是"全权执行"的设计, 但模型一旦看错文件或理解偏了 + * 需求, 后果是直接落到磁盘上的; 加一道确认是目前唯一有效的止损点. * - * 只读工具(read_file / list_dir)不打扰。 + * 只读工具 (read_file / list_dir)不打扰. */ #ifndef LIGENT_CONFIRM_H #define LIGENT_CONFIRM_H @@ -14,34 +14,34 @@ void confirm_init(int enabled); void confirm_set_enabled(int on); int confirm_enabled(void); -/* 询问是否允许执行某个会改动东西的工具:显示将要做什么,然后问 (Y/n)。 - * 返回 1 允许,0 拒绝。回车即默认允许。 +/* 询问是否允许执行某个会改动东西的工具:显示将要做什么, 然后问 (Y/n). + * 返回 1 允许, 0 拒绝. 回车即默认允许. * * 拒绝时返回 0 而不是让整轮失败 —— 上层会把"用户拒绝了"作为工具结果 - * 回填给模型,让它换一种做法。 + * 回填给模型, 让它换一种做法. * - * stdin 不是终端时(管道/重定向)没有人能回答,按默认值放行。 */ -/* 本轮的提问策略。作用域是"一轮"——一条用户消息引发的那串工具调用, - * 下一轮自动复位。会话级的开关是 /yolo,配置级的是 confirm。 */ + * stdin 不是终端时 (管道/重定向)没有人能回答, 按默认值放行. */ +/* 本轮的提问策略. 作用域是"一轮"——一条用户消息引发的那串工具调用, + * 下一轮自动复位. 会话级的开关是 /yolo, 配置级的是 confirm. */ enum { - CONFIRM_ASK = 0, /* 每次都要问(默认) */ + CONFIRM_ASK = 0, /* 每次都要问 (默认) */ CONFIRM_ALWAYS, /* 本轮剩下的改动一律放行 */ CONFIRM_NEVER /* 本轮剩下的改动一律拒绝 */ }; -/* 复位成 CONFIRM_ASK。agent_run 每轮开头调一次 —— "这一轮"的定义就在那儿。 */ +/* 复位成 CONFIRM_ASK. agent_run 每轮开头调一次 —— "这一轮"的定义就在那儿. */ void confirm_reset_turn(void); -/* 当前轮次策略,供上层展示/测试 */ +/* 当前轮次策略, 供上层展示/测试 */ int confirm_turn_mode(void); int confirm_tool(const char *name, const JsonValue *args); -/* 可选:自定义"这个工具要做什么"的说明。 - * 返回 malloc 的文本(每行自带缩进),返回 NULL 表示回退到内置描述。 +/* 可选:自定义"这个工具要做什么"的说明. + * 返回 malloc 的文本 (每行自带缩进), 返回 NULL 表示回退到内置描述. * - * meta 的工具需要它:那些参数里带整段消息内容,内置描述既打不出来 - * 也不该打出来 —— 得总结成人话("压缩 2..6 轮,6 条 -> 1 条")。 */ + * 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 5aba1cb..6d23c7f 100644 --- a/src/doctor.c +++ b/src/doctor.c @@ -1,11 +1,11 @@ /* doctor.c - 环境体检 * * 这是把前几轮踩过的坑固化成检查项: - * 1. 配置文件解析失败却只给一句"语法错误"(现在带行列号) + * 1. 配置文件解析失败却只给一句"语法错误" (现在带行列号) * 2. shell 里残留的厂商环境变量顶掉配置文件里的密钥 - * 3. base_url 重定向导致 Authorization 头被丢掉,服务端回 401 - * 4. 密钥与服务商不匹配(把 DeepSeek 的 key 发给 OpenRouter) - * 这些问题的共同点是:报错信息指向的方向是错的,靠猜要花很久。 + * 3. base_url 重定向导致 Authorization 头被丢掉, 服务端回 401 + * 4. 密钥与服务商不匹配 (把 DeepSeek 的 key 发给 OpenRouter) + * 这些问题的共同点是:报错信息指向的方向是错的, 靠猜要花很久. */ #include "doctor.h" #include "version.h" @@ -44,7 +44,7 @@ static void section(const char *name) 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) { @@ -65,7 +65,7 @@ LIGENT_PRINTF(2, 3) static void field(const char *lbl, const char *fmt, ...) putchar('\n'); } -/* level: 0 = 正常,1 = 警告,2 = 问题,3 = 仅陈列(灰点) */ +/* level: 0 = 正常, 1 = 警告, 2 = 问题, 3 = 仅陈列 (灰点) */ LIGENT_PRINTF(3, 4) static void marked(int level, const char *lbl, const char *fmt, ...) { const char *tag = level == 2 ? "[x]" : level == 1 ? "[!]" : @@ -121,7 +121,7 @@ static void mask_key(const char *key, char *out, size_t n) tf("doctor.val.chars", "n", num, NULL)); } -/* 从密钥前缀推断它属于哪家。返回 NULL 表示认不出来 */ +/* 从密钥前缀推断它属于哪家. 返回 NULL 表示认不出来 */ static const char *key_owner(const char *key) { if (!key || !*key) return NULL; @@ -155,8 +155,8 @@ static void check_binary(const char *argv0) { section(t("doctor.sec.binary")); - /* 版本放最前面:报 bug 时第一件要问的就是"你用的是哪个版本"。 - * 带 git hash 才能在对方改过源码的情况下知道到底是哪份代码。 */ + /* 版本放最前面:报 bug 时第一件要问的就是"你用的是哪个版本". + * 带 git hash 才能在对方改过源码的情况下知道到底是哪份代码. */ field(t("doctor.lbl.version"), "%s", ligent_version_full()); char exe[PATH_MAX]; @@ -227,9 +227,9 @@ static void check_config_file(const LigentConfig *cfg, const char *argv0, if (text) v = json_parse(text, NULL, 0); if (v) { /* 这份清单必须与 config.c 的 config_load_file 认识的键一一对应: - * 它是"配置文件里到底设了哪些键"的唯一依据,多写一个不存在的键、 - * 或者漏掉一个真键,这一行说的就是假话。(以前这里写的是 "log", - * 而配置文件根本没有这个键,同时真有的 "probe" 没有列出来。) */ + * 它是"配置文件里到底设了哪些键"的唯一依据, 多写一个不存在的键、 + * 或者漏掉一个真键, 这一行说的就是假话. (以前这里写的是 "log", + * 而配置文件根本没有这个键, 同时真有的 "probe" 没有列出来. ) */ static const char *keys[] = { "base_url", "api_key", "model", "system", "temperature", "max_tokens", "thinking", "reasoning_effort", @@ -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; @@ -412,7 +412,7 @@ static void check_params(const LigentConfig *cfg) snprintf(num, sizeof num, "%d", cfg->retries); field("retries", "%s", tf("doctor.val.retries", "n", num, NULL)); - /* 往返次数看不到就会让人以为它还卡在某个默认值上(默认 0 = 不限) */ + /* 往返次数看不到就会让人以为它还卡在某个默认值上 (默认 0 = 不限) */ if (cfg->max_steps > 0) { snprintf(num, sizeof num, "%d", cfg->max_steps); field("max_steps", "%s", tf("doctor.val.steps", "n", num, NULL)); @@ -435,8 +435,8 @@ 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")); @@ -470,8 +470,8 @@ static void check_network(const LigentConfig *cfg, int verbose) sub("%s", t("doctor.sub.redirect_2")); sub("%s", t("doctor.sub.redirect_3")); } else if (status == 401 || status == 403) { - /* 这两种 401 含义完全不同,绝不能混着说 —— 把"你没配密钥"说成 - * "你的密钥被拒绝",会让人去换一把本来没问题的钥匙。 */ + /* 这两种 401 含义完全不同, 绝不能混着说 —— 把"你没配密钥"说成 + * "你的密钥被拒绝", 会让人去换一把本来没问题的钥匙. */ snprintf(num, sizeof num, "%ld", status); if (have_key) { marked(2, t("doctor.lbl.auth"), "%s", @@ -483,15 +483,15 @@ static void check_network(const LigentConfig *cfg, int verbose) sub("%s", t("doctor.sub.auth_not_network")); } } else if (status == 404) { - /* 404 也不能当成"密钥没问题":请求多半在匹配到路由之前就被回绝了, - * 这次探测根本没走到认证那一步。而且 404 本身通常就是 base_url 写错了。 */ + /* 404 也不能当成"密钥没问题":请求多半在匹配到路由之前就被回绝了, + * 这次探测根本没走到认证那一步. 而且 404 本身通常就是 base_url 写错了. */ marked(2, t("doctor.lbl.endpoint"), "%s", tf("doctor.val.endpoint_404", "url", url, NULL)); sub("%s", t("doctor.sub.404_base_url")); sub("%s", t("doctor.sub.404_no_key_info")); } else { - /* 400 / 405 / 200 这类都说明请求已经进到了路由里 —— 认证这一关过了。 - * (405 就是我们用 GET 打一个只收 POST 的端点,正常现象。) */ + /* 400 / 405 / 200 这类都说明请求已经进到了路由里 —— 认证这一关过了. + * (405 就是我们用 GET 打一个只收 POST 的端点, 正常现象. ) */ marked(0, t("doctor.lbl.auth"), "%s", t(have_key ? "doctor.val.auth_ok" : "doctor.val.no_auth")); marked(0, t("doctor.lbl.redirect"), "%s", t("doctor.val.no_redirect")); diff --git a/src/doctor.h b/src/doctor.h index 6403845..81d073e 100644 --- a/src/doctor.h +++ b/src/doctor.h @@ -5,12 +5,12 @@ #include "config.h" /* 把整条链路逐环检查一遍:二进制定位、配置文件、密钥来源与格式、 - * 端点、环境变量干扰、参数合理性、网络连通性与重定向。 + * 端点、环境变量干扰、参数合理性、网络连通性与重定向. * - * do_network = 0 时跳过联网检查。 - * 返回"问题"([x])的个数,main 用它决定退出码。 */ -/* config_error 非 NULL 表示配置文件本身加载失败 —— 这种情况下更要跑 doctor, - * 因为那正是用户最需要诊断的时刻。 */ + * do_network = 0 时跳过联网检查. + * 返回"问题" ([x])的个数, main 用它决定退出码. */ +/* config_error 非 NULL 表示配置文件本身加载失败 —— 这种情况下更要跑 doctor, + * 因为那正是用户最需要诊断的时刻. */ int doctor_run(const LigentConfig *cfg, const char *argv0, int do_network, const char *config_error); diff --git a/src/http.c b/src/http.c index 3440252..1cb3933 100644 --- a/src/http.c +++ b/src/http.c @@ -22,8 +22,8 @@ static char errsink[1]; /* 收发记录:进程内滚动缓存 */ /* ------------------------------------------------------------------ */ -/* 上限。一场长会话可能产生很多流量,不能无限涨; - * 超出后丢掉较早的部分,保留最近的(排障时通常更关心刚刚发生了什么) */ +/* 上限. 一场长会话可能产生很多流量, 不能无限涨; + * 超出后丢掉较早的部分, 保留最近的 (排障时通常更关心刚刚发生了什么) */ #define LOG_MAX (16u * 1024 * 1024) static StrBuf g_log; @@ -51,8 +51,8 @@ static void log_append(const void *data, size_t n) sb_append(&g_log, p, n); } -/* 追加一个 C 字符串。不要手写长度 —— 上面那行中文标签就是因为 - * 手数成 20 字节(实际 22)而被吃掉了两个字符。 */ +/* 追加一个 C 字符串. 不要手写长度 —— 上面那行中文标签就是因为 + * 手数成 20 字节 (实际 22)而被吃掉了两个字符. */ static void log_line(const char *s) { if (s) log_append(s, strlen(s)); @@ -116,7 +116,7 @@ int http_log_save(const char *path, size_t *out_bytes, fprintf(f, "%s", tf("http.log.header", "time", when, "n", i18n_num((long long)g_log.len), NULL)); - /* 这里以前是 fputs —— 不处理格式,于是文件里真的会出现字面量 "%u" */ + /* 这里以前是 fputs —— 不处理格式, 于是文件里真的会出现字面量 "%u" */ if (g_log_truncated) fprintf(f, "%s", tf("http.log.truncated", "limit", i18n_num((long long)LOG_MAX), NULL)); @@ -144,7 +144,7 @@ int http_status_retryable(long status) { switch (status) { case 408: /* Request Timeout */ - case 409: /* Conflict(部分网关用它表示临时冲突) */ + case 409: /* Conflict (部分网关用它表示临时冲突) */ case 429: /* Too Many Requests */ case 500: case 502: @@ -157,13 +157,13 @@ int http_status_retryable(long status) } /* 注意这里刻意不含 CURLE_OPERATION_TIMEDOUT: - * 请求超时说明服务端很可能已经处理完了,重发会重复扣费。 */ + * 请求超时说明服务端很可能已经处理完了, 重发会重复扣费. */ static int curl_code_retryable(CURLcode rc) { switch (rc) { case CURLE_COULDNT_RESOLVE_PROXY: case CURLE_COULDNT_RESOLVE_HOST: - case CURLE_COULDNT_CONNECT: /* 连接根本没建立,肯定没被处理 */ + case CURLE_COULDNT_CONNECT: /* 连接根本没建立, 肯定没被处理 */ case CURLE_SEND_ERROR: case CURLE_RECV_ERROR: case CURLE_GOT_NOTHING: @@ -187,7 +187,7 @@ static void sleep_ms_interruptible(long ms) } } -/* 第 attempt 次失败后的退避时长(attempt 从 1 开始) */ +/* 第 attempt 次失败后的退避时长 (attempt 从 1 开始) */ static long backoff_ms(const HttpRetry *r, int attempt, long retry_after_s) { if (retry_after_s > 0) { @@ -196,7 +196,7 @@ static long backoff_ms(const HttpRetry *r, int attempt, long retry_after_s) } long ms = r->base_delay_ms; for (int i = 1; i < attempt && ms < r->max_delay_ms; i++) ms *= 2; - /* 加一点抖动,避免多个客户端同时重试打雷 */ + /* 加一点抖动, 避免多个客户端同时重试打雷 */ ms += ms / 4 > 0 ? (long)(rand() % (1 + ms / 4)) : 0; return ms > r->max_delay_ms ? r->max_delay_ms : ms; } @@ -208,16 +208,16 @@ static long backoff_ms(const HttpRetry *r, int attempt, long retry_after_s) typedef struct { StrBuf body; /* 非流式成功响应体 */ StrBuf errbody; /* 错误响应体 */ - HttpSink sink; /* 流式回调;NULL 表示非流式 */ + HttpSink sink; /* 流式回调; NULL 表示非流式 */ void *ud; long status; - long retry_after; /* 服务端给的 Retry-After(秒) */ + long retry_after; /* 服务端给的 Retry-After (秒) */ size_t delivered; /* 已交给 sink 的字节数 */ int overflow; /* 响应体超过上限 */ - curl_off_t content_length; /* 服务端声明的长度,<=0 表示未知 */ + curl_off_t content_length; /* 服务端声明的长度, <=0 表示未知 */ char *location; /* 3xx 重定向的目标 */ int log_hdr_started; /* 响应头只写一次分隔标记 */ - int probe; /* 1 = GET 探测,不发请求体 */ + int probe; /* 1 = GET 探测, 不发请求体 */ } HttpCtx; static void ctx_init(HttpCtx *c, HttpSink sink, void *ud) @@ -237,10 +237,10 @@ static void ctx_free(HttpCtx *c) c->location = NULL; } -/* 必须按 curl 的契约返回 size_t —— 返回值不等于 n 会被当成传输失败。 - * 以前这里写的 int:(int)n 在 x86-64 上碰巧是对的(写 eax 会清零 rax 高位), - * 所以 gcc 不吭声、测试全绿,只有 clang 借 libcurl 的类型检查把它指了出来。 - * 换个 ABI,高 32 位的垃圾值就会被当成「处理了多少字节」。 */ +/* 必须按 curl 的契约返回 size_t —— 返回值不等于 n 会被当成传输失败. + * 以前这里写的 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) { HttpCtx *c = ud; @@ -253,7 +253,7 @@ static size_t header_cb(char *buffer, size_t size, size_t nitems, void *ud) if (n >= 5 && strncmp(buffer, "HTTP/", 5) == 0) { const char *sp = memchr(buffer, ' ', n); if (sp) c->status = strtol(sp + 1, NULL, 10); - c->retry_after = 0; /* 新响应开始,重置 */ + c->retry_after = 0; /* 新响应开始, 重置 */ } else if (n > 12 && strncasecmp(buffer, "Retry-After:", 12) == 0) { c->retry_after = strtol(buffer + 12, NULL, 10); } else if (n > 15 && strncasecmp(buffer, "Content-Length:", 15) == 0) { @@ -273,18 +273,18 @@ static size_t write_cb(char *ptr, size_t size, size_t nmemb, void *ud) { HttpCtx *c = ud; size_t n = size * nmemb; - log_append(ptr, n); /* 原样记下,便于之后回放 */ + log_append(ptr, n); /* 原样记下, 便于之后回放 */ if (g_ligent_interrupt) return 0; - /* 一眼就能看出太大时,连读都不读,直接中止。 - * 这是流式路径唯一的整体大小防线 —— 流式没有"最终 body"可以事后检查。 */ + /* 一眼就能看出太大时, 连读都不读, 直接中止. + * 这是流式路径唯一的整体大小防线 —— 流式没有"最终 body"可以事后检查. */ if (c->content_length > (curl_off_t)HTTP_MAX_BODY) { c->overflow = 1; return 0; } if (c->status >= 400) { - /* 错误体只留够诊断的部分,多余的照样"消费"掉,避免半路断连 */ + /* 错误体只留够诊断的部分, 多余的照样"消费"掉, 避免半路断连 */ if (c->errbody.len < HTTP_MAX_ERRBODY) { size_t take = HTTP_MAX_ERRBODY - c->errbody.len; sb_append(&c->errbody, ptr, n < take ? n : take); @@ -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; @@ -326,12 +326,12 @@ void http_write_redacted(FILE *out, const char *data, size_t size, } } -/* 自己接管 libcurl 的调试输出,只为把密钥隐去。 - * 装了 DEBUGFUNCTION 就得自己打印,所以这里复刻 libcurl 的默认格式。 +/* 自己接管 libcurl 的调试输出, 只为把密钥隐去. + * 装了 DEBUGFUNCTION 就得自己打印, 所以这里复刻 libcurl 的默认格式. * - * 早期版本只处理 HEADER_OUT 那一段,结果 HTTP/2 的 + * 早期版本只处理 HEADER_OUT 那一段, 结果 HTTP/2 的 * "== Info: [HTTP/2] [1] [authorization: Bearer sk-...]" - * 仍然把完整密钥打了出来。现在按密钥值做替换,所有通道一起覆盖。 */ + * 仍然把完整密钥打了出来. 现在按密钥值做替换, 所有通道一起覆盖. */ static int debug_cb(CURL *handle, curl_infotype type, char *data, size_t size, void *ud) { @@ -376,16 +376,16 @@ static struct curl_slist *make_headers(const char *api_key) /* 单次尝试 */ /* ------------------------------------------------------------------ */ -/* 返回 CURLcode;ctx 中带回 status / body / errbody / delivered */ +/* 返回 CURLcode; ctx 中带回 status / body / errbody / delivered */ static CURLcode attempt(const char *url, const char *api_key, const char *body, long timeout_s, int verbose, HttpCtx *ctx, char *cerr, size_t cerrlen) { - /* 必须**在最前面**清空。下面 curl_easy_init 失败会直接 return,而调用方 + /* 必须**在最前面**清空. 下面 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'; CURL *curl = curl_easy_init(); @@ -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); @@ -401,24 +401,24 @@ static CURLcode attempt(const char *url, const char *api_key, const char *body, if (ctx->probe) { curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L); } else { - /* body 为 NULL 时按空请求体处理 —— curl 的 POSTFIELDS 不收 NULL 指针。 - * 目前的调用方都不会这么调,但函数签名允许,而崩溃点离得只有一行。 */ + /* body 为 NULL 时按空请求体处理 —— curl 的 POSTFIELDS 不收 NULL 指针. + * 目前的调用方都不会这么调, 但函数签名允许, 而崩溃点离得只有一行. */ curl_easy_setopt(curl, CURLOPT_POST, 1L); curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body ? 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 跳转了; - * 2) 跟随 POST 重定向意味着把对话内容转发到另一个主机。 - * 收到 3xx 时直接把目标地址报给用户,让他自己改 base_url。 */ + * 1) libcurl 跨源重定向时会主动丢掉 Authorization 头 (安全设计), + * 结果是 401 "Missing Authentication header" —— 错误指向完全错的方向, + * 用户会去查自己的 key, 而真正的问题是 URL 跳转了; + * 2) 跟随 POST 重定向意味着把对话内容转发到另一个主机. + * 收到 3xx 时直接把目标地址报给用户, 让他自己改 base_url. */ curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L); curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, ""); curl_easy_setopt(curl, CURLOPT_USERAGENT, "ligent/1.0"); curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 30L); - /* 流式不限总时长(响应可能长时间持续输出),靠低速检测兜底 */ + /* 流式不限总时长 (响应可能长时间持续输出), 靠低速检测兜底 */ curl_easy_setopt(curl, CURLOPT_TIMEOUT, ctx->sink ? 0L : (timeout_s > 0 ? timeout_s : 300L)); curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1L); @@ -473,8 +473,8 @@ int http_probe(const char *url, const char *api_key, long timeout_s, int verbose ctx.probe = 1; char cerr[CURL_ERROR_SIZE]; - /* 把密钥带上:探测的目的之一就是验证"这把密钥能不能过", - * 不带密钥去问,401 只说明端点要认证,说明不了密钥好不好。 */ + /* 把密钥带上:探测的目的之一就是验证"这把密钥能不能过", + * 不带密钥去问, 401 只说明端点要认证, 说明不了密钥好不好. */ CURLcode rc = attempt(url, api_key, NULL, timeout_s, verbose, &ctx, cerr, sizeof cerr); @@ -492,10 +492,10 @@ int http_probe(const char *url, const char *api_key, long timeout_s, int verbose } /* ------------------------------------------------------------------ */ -/* 重试循环(两者共用) */ +/* 重试循环 (两者共用) */ /* ------------------------------------------------------------------ */ -/* 返回 0 表示可以重试;否则把最终错误写进 errbuf 并返回 -1 */ +/* 返回 0 表示可以重试; 否则把最终错误写进 errbuf 并返回 -1 */ static int should_retry(const HttpRetry *rt, int attempt, CURLcode rc, const HttpCtx *ctx, const char *cerr, char *errbuf, size_t errlen) @@ -564,11 +564,11 @@ static void normalize_retry(const HttpRetry *in, HttpRetry *out) } /* ------------------------------------------------------------------ */ -/* 同步请求(POST / GET 共用同一套重试与错误处理) */ +/* 同步请求 (POST / GET 共用同一套重试与错误处理) */ /* ------------------------------------------------------------------ */ -/* POST 与 GET 只差一个标志位,重试、退避、错误体处理完全一样 —— - * 合成一个实现,免得两条路各修一遍 bug。 */ +/* POST 与 GET 只差一个标志位, 重试、退避、错误体处理完全一样 —— + * 合成一个实现, 免得两条路各修一遍 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, @@ -606,7 +606,7 @@ static int http_request(int is_get, const char *url, const char *api_key, } if (should_retry(&rt, attempt_no, rc, &ctx, cerr, errbuf, errlen) != 0) { - /* 失败时把错误体留给调用方,便于展示服务端的具体说明 */ + /* 失败时把错误体留给调用方, 便于展示服务端的具体说明 */ if (out_body) *out_body = sb_detach(&ctx.errbody); if (out_attempts) *out_attempts = attempt_no; ctx_free(&ctx); @@ -631,8 +631,8 @@ int http_post(const char *url, const char *api_key, const char *body, out_body, out_status, out_attempts, errbuf, errlen); } -/* GET,把响应体读回来。给 /model 拉模型列表用。 - * 语义与 http_post 完全一致,只是方法是 GET、没有请求体。 */ +/* GET, 把响应体读回来. 给 /model 拉模型列表用. + * 语义与 http_post 完全一致, 只是方法是 GET、没有请求体. */ int http_get(const char *url, const char *api_key, long timeout_s, int verbose, const HttpRetry *retry, char **out_body, long *out_status, int *out_attempts, diff --git a/src/http.h b/src/http.h index da75cd4..0c2946f 100644 --- a/src/http.h +++ b/src/http.h @@ -6,18 +6,18 @@ #include #include -/* 全局中断标志(Ctrl-C 置位,由 main 安装信号处理器) */ +/* 全局中断标志 (Ctrl-C 置位, 由 main 安装信号处理器) */ extern volatile sig_atomic_t g_ligent_interrupt; -/* libcurl 流式写回调,语义同 libcurl CURLOPT_WRITEFUNCTION: - * 返回已处理的字节数;返回不等于 nmemb*size 的值即中止传输 */ +/* libcurl 流式写回调, 语义同 libcurl CURLOPT_WRITEFUNCTION: + * 返回已处理的字节数; 返回不等于 nmemb*size 的值即中止传输 */ typedef size_t (*HttpSink)(char *data, size_t size, size_t nmemb, void *userdata); -/* 重试策略。只重试"服务端明确报错"或"连接根本没建立"的情况: - * 超时(OPERATION_TIMEDOUT)不重试 —— 服务端可能已经处理完并计费了, - * 重发会造成重复扣费。 */ +/* 重试策略. 只重试"服务端明确报错"或"连接根本没建立"的情况: + * 超时 (OPERATION_TIMEDOUT)不重试 —— 服务端可能已经处理完并计费了, + * 重发会造成重复扣费. */ typedef struct { - int max_retries; /* 额外重试次数;0 = 只尝试一次 */ + int max_retries; /* 额外重试次数; 0 = 只尝试一次 */ int base_delay_ms; /* 退避基数 */ int max_delay_ms; /* 单次退避上限 */ } HttpRetry; @@ -25,9 +25,9 @@ typedef struct { #define HTTP_RETRY_DEFAULT { 2, 500, 8000 } #define HTTP_RETRY_NONE { 0, 0, 0 } -/* 非流式响应体上限。防止服务端异常时把内存吃干 */ +/* 非流式响应体上限. 防止服务端异常时把内存吃干 */ #define HTTP_MAX_BODY (32u * 1024 * 1024) -/* 错误响应体最多收集这么多,够诊断就行 */ +/* 错误响应体最多收集这么多, 够诊断就行 */ #define HTTP_MAX_ERRBODY (256u * 1024) int http_global_init(void); @@ -35,59 +35,59 @@ void http_global_cleanup(void); /* ---- 收发记录 ---- * - * 进程内滚动缓存每次请求的收发原文(请求体、响应头、响应原文), - * 由 REPL 的 /log 命令导出到文件。这样不必事先指定文件, - * 出问题时可以"回放式"地把已经发生过的事情捞出来。 + * 进程内滚动缓存每次请求的收发原文 (请求体、响应头、响应原文), + * 由 REPL 的 /log 命令导出到文件. 这样不必事先指定文件, + * 出问题时可以"回放式"地把已经发生过的事情捞出来. * - * 只记请求体与响应,不记请求头,所以不会写入密钥。 - * 内存上限 LOG_MAX,超出后丢弃较早的部分。 */ + * 只记请求体与响应, 不记请求头, 所以不会写入密钥. + * 内存上限 LOG_MAX, 超出后丢弃较早的部分. */ size_t http_log_size(void); int http_log_truncated(void); -/* 追加写入 path;成功返回 0,失败返回 -1 并写 errbuf */ +/* 追加写入 path; 成功返回 0, 失败返回 -1 并写 errbuf */ int http_log_save(const char *path, size_t *out_bytes, char *errbuf, size_t errlen); void http_log_clear(void); void http_log_free(void); -/* 该 HTTP 状态码是否值得重试(408/409/429/5xx) */ +/* 该 HTTP 状态码是否值得重试 (408/409/429/5xx) */ int http_status_retryable(long status); -/* 把 data 里出现的 secret 全部替换成 <已隐藏> 后写入 out。 - * 用"按密钥值替换"而不是"匹配 Authorization 头",是因为 libcurl 有多条 - * 输出通道都会带出密钥:除了 HEADER_OUT 那段,HTTP/2 还会在 TEXT 消息里 - * 打一行 "[HTTP/2] [1] [authorization: Bearer sk-...]"。 */ +/* 把 data 里出现的 secret 全部替换成 <已隐藏> 后写入 out. + * 用"按密钥值替换"而不是"匹配 Authorization 头", 是因为 libcurl 有多条 + * 输出通道都会带出密钥:除了 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); -/* 非流式 POST。 - * 成功(HTTP 2xx)返回 0;否则返回 -1 并写 errbuf。 - * out_* 均可为 NULL,out_attempts 报告实际尝试次数。 - * 响应体超过 HTTP_MAX_BODY 时明确报错,而不是把内存吃干。 */ +/* 非流式 POST. + * 成功 (HTTP 2xx)返回 0; 否则返回 -1 并写 errbuf. + * out_* 均可为 NULL, out_attempts 报告实际尝试次数. + * 响应体超过 HTTP_MAX_BODY 时明确报错, 而不是把内存吃干. */ int http_post(const char *url, const char *api_key, const char *body, long timeout_s, int verbose, const HttpRetry *retry, char **out_body, long *out_status, int *out_attempts, char *errbuf, size_t errlen); -/* GET,并把响应体读回来(语义与 http_post 一致,只是没有请求体、方法是 GET)。 - * 给 /model 拉取模型列表用。 */ +/* GET, 并把响应体读回来 (语义与 http_post 一致, 只是没有请求体、方法是 GET). + * 给 /model 拉取模型列表用. */ int http_get(const char *url, const char *api_key, long timeout_s, int verbose, const HttpRetry *retry, char **out_body, long *out_status, int *out_attempts, char *errbuf, size_t errlen); -/* 探测:发一个 GET(不跟随重定向、不带请求体),用于 doctor 检查 - * 连通性、TLS 与是否发生重定向。 - * out_location 为 3xx 时的 Location(malloc,可为 NULL)。 */ -/* api_key 为 NULL 表示不带认证发出去。doctor 要拿它区分"端点要求认证但你没配 - * 密钥"和"密钥被拒绝了" —— 不带密钥去探测却把 401 说成"密钥被拒",会把用户 - * 引到一个根本不存在的问题上。 */ +/* 探测:发一个 GET (不跟随重定向、不带请求体), 用于 doctor 检查 + * 连通性、TLS 与是否发生重定向. + * out_location 为 3xx 时的 Location (malloc, 可为 NULL). */ +/* api_key 为 NULL 表示不带认证发出去. doctor 要拿它区分"端点要求认证但你没配 + * 密钥"和"密钥被拒绝了" —— 不带密钥去探测却把 401 说成"密钥被拒", 会把用户 + * 引到一个根本不存在的问题上. */ int http_probe(const char *url, const char *api_key, long timeout_s, int verbose, long *out_status, char **out_location, char *errbuf, size_t errlen); -/* 流式 POST。2xx 时响应体逐块交给 sink;非 2xx 时响应体被收集到 out_errbody。 - * 注意:一旦有数据交给了 sink(见 out_delivered),就不再重试 —— - * 否则会把已经显示过的内容重复输出一遍。 */ +/* 流式 POST. 2xx 时响应体逐块交给 sink; 非 2xx 时响应体被收集到 out_errbody. + * 注意:一旦有数据交给了 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, HttpSink sink, void *userdata, diff --git a/src/i18n.c b/src/i18n.c index d89ada4..043fb23 100644 --- a/src/i18n.c +++ b/src/i18n.c @@ -1,7 +1,7 @@ /* i18n.c - 界面文案表与查找 * - * 表里每条两种语言各一份。条目按 key 排序(不需要,但好查)。 - * 测试会遍历这张表:两种语言都不许空,占位符集合必须一致。 + * 表里每条两种语言各一份. 条目按 key 排序 (不需要, 但好查). + * 测试会遍历这张表:两种语言都不许空, 占位符集合必须一致. */ #include "i18n.h" @@ -72,7 +72,7 @@ M("cli.help", "Subcommands:\n" " ligent doctor [--offline] check config, key, endpoint and network in order\n", -"ligent - 纯 C 实现的 AI Agent(OpenAI 兼容接口)\n" +"ligent - 纯 C 实现的 AI Agent (OpenAI 兼容接口)\n" "\n" "用法:\n" " ligent [选项] [任务描述...] 带任务描述时一次性执行后退出\n" @@ -80,26 +80,26 @@ M("cli.help", " echo \"任务\" | ligent 从标准输入读取任务\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" +" -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" " -c, --config PATH 指定配置文件\n" -" --history FILE 指定输入历史文件(默认 <二进制目录>/ligent.history)\n" +" --history FILE 指定输入历史文件 (默认 <二进制目录>/ligent.history)\n" " --no-history 不读写输入历史\n" " -s, --system TEXT 覆盖 system 提示词\n" " -t, --temperature N 采样温度\n" " --max-tokens N 单次回复的最大 token 数\n" -" --thinking MODE 思考模式:on / off / auto(默认 auto = 由服务端决定)\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" +" --max-steps N 单轮任务里模型最多往返几次 (默认不限, 0 = 不限)\n" +" --timeout N 单次请求超时秒数 (默认 300)\n" +" --retries N 瞬态失败 (429/5xx/连接失败)的重试次数 (默认 2, 0 = 不重试)\n" " --no-stream 关闭流式输出\n" -" --no-tools 禁用工具,只做纯对话\n" -" --no-confirm 不再询问写操作与命令执行(默认会问)\n" -" --no-auto-save 退出/重置/导入前不自动存档(默认会存)\n" -" --no-probe 首个请求前不探测模型能力(不额外发请求)\n" -" --lang LANG 界面语言:auto / en / zh(默认 auto)\n" +" --no-tools 禁用工具, 只做纯对话\n" +" --no-confirm 不再询问写操作与命令执行 (默认会问)\n" +" --no-auto-save 退出/重置/导入前不自动存档 (默认会存)\n" +" --no-probe 首个请求前不探测模型能力 (不额外发请求)\n" +" --lang LANG 界面语言:auto / en / zh (默认 auto)\n" " -q, --quiet 不打印工具的输出内容\n" " -v, --verbose 打印 HTTP 细节与调试信息\n" " --color / --no-color 强制开关彩色输出\n" @@ -107,11 +107,11 @@ M("cli.help", " -h, --help 显示本帮助\n" " -V, --version 显示版本\n" "\n" -"配置文件(JSON):\n" -" 只读「二进制所在目录」下的 ligent.json,例如 /usr/local/bin/ligent 对应\n" -" /usr/local/bin/ligent.json。不存在则使用内置默认值。\n" -" 不做用户级(~/.config)和系统级(/etc)查找,也不看当前工作目录。\n" -" 临时换一份可用 -c PATH 或 $LIGENT_CONFIG。\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" " thinking, reasoning_effort,\n" @@ -128,13 +128,13 @@ M("cli.help", M("cli.err.bad_effort", "ligent: --effort accepts only {list} (got \"{value}\")\n", - "ligent: --effort 只接受 {list}(收到 \"{value}\")\n"), + "ligent: --effort 只接受 {list} (收到 \"{value}\")\n"), M("cli.err.bad_lang", "ligent: --lang accepts only auto / en / zh (got \"{value}\")\n", - "ligent: --lang 只接受 auto / en / zh(收到 \"{value}\")\n"), + "ligent: --lang 只接受 auto / en / zh (收到 \"{value}\")\n"), M("cli.err.bad_thinking", "ligent: --thinking accepts only on / off / auto (got \"{value}\")\n", - "ligent: --thinking 只接受 on / off / auto(收到 \"{value}\")\n"), + "ligent: --thinking 只接受 on / off / auto (收到 \"{value}\")\n"), M("cli.err.config", "ligent: {error}\n", "ligent: {error}\n"), M("cli.err.curl_init", "ligent: libcurl init failed\n", "ligent: libcurl 初始化失败\n"), M("cli.err.missing_value", "ligent: option {option} needs a value\n", @@ -145,35 +145,35 @@ M("cli.err.no_api_key", " 2) put a ligent.json next to the binary (see the path in --print-config)\n" " with something like {\"api_key\": \"${DEEPSEEK_API_KEY}\", \"model\": \"deepseek-flash\"}\n" " 3) ligent --api-key sk-xxx \"hi\"\n", - "ligent: 未配置 API key。\n" + "ligent: 未配置 API key. \n" " 方式一:export DEEPSEEK_API_KEY=sk-xxx\n" - " 方式二:在二进制所在目录建一个 ligent.json(见 --print-config 显示的路径),\n" + " 方式二:在二进制所在目录建一个 ligent.json (见 --print-config 显示的路径), \n" " 内容形如 {\"api_key\": \"${DEEPSEEK_API_KEY}\", \"model\": \"deepseek-flash\"}\n" " 方式三:ligent --api-key sk-xxx \"你好\"\n"), M("cli.err.unknown_option", "ligent: unknown option {option} (try --help)\n", - "ligent: 未知选项 {option}(--help 查看用法)\n"), + "ligent: 未知选项 {option} (--help 查看用法)\n"), -/* ---- 配置展示(--print-config)---- */ +/* ---- 配置展示 (--print-config)---- */ M("cfg.dump.config_file_missing", "(not found, using built-in defaults)", - "(不存在,使用内置默认值)"), + "(不存在, 使用内置默认值)"), M("cfg.dump.config_file_none", "(no config file in use)", "(未使用配置文件)"), M("cfg.dump.label.effort", "effort", "思考级别"), M("cfg.dump.label.log", "wire log", "收发记录"), M("cfg.val.auto_save_on", "on (auto-archive before exit/reset/import)", - "启用(退出/重置/导入前自动存档)"), + "启用 (退出/重置/导入前自动存档)"), M("cfg.val.builtin_system", "(built-in default prompt)", "(内置默认提示词)"), -M("cfg.val.chars", "({n} chars)", "({n} 字符)"), +M("cfg.val.chars", "({n} chars)", " ({n} 字符)"), M("cfg.val.confirm_on", "on (ask before writes and commands)", - "启用(写操作与命令执行前询问)"), + "启用 (写操作与命令执行前询问)"), M("cfg.val.disabled", "disabled", "禁用"), M("cfg.val.empty_key", "****", "****"), M("cfg.val.enabled", "enabled", "启用"), M("cfg.val.log", "{n} bytes in memory (export with /log FILE in the REPL)", - "内存缓存 {n} 字节(REPL 里 /log FILE 导出)"), + "内存缓存 {n} 字节 (REPL 里 /log FILE 导出)"), M("cfg.val.no", "no", "否"), M("cfg.val.probe_on", "on (probe vision/tools support before the first request)", - "启用(首个请求前探测模型是否支持图片/工具)"), + "启用 (首个请求前探测模型是否支持图片/工具)"), M("cfg.val.seconds", "{n}s", "{n} 秒"), M("cfg.val.server_default", "(server default)", "(服务端默认)"), M("cfg.val.times", "{n}", "{n} 次"), @@ -184,59 +184,59 @@ M("cfg.val.yes", "yes", "是"), M("cfg.src.cli", "command line --api-key", "命令行 --api-key"), M("cfg.src.env", "environment variable {name}", "环境变量 {name}"), M("cfg.src.file", "config file", "配置文件"), -M("cfg.thinking.auto", "not sent (server decides)", "不发送(由服务端决定)"), +M("cfg.thinking.auto", "not sent (server decides)", "不发送 (由服务端决定)"), M("cfg.thinking.off", "off (sends thinking.type=disabled)", - "关闭(发 thinking.type=disabled)"), + "关闭 (发 thinking.type=disabled)"), M("cfg.thinking.on", "on (sends thinking.type=enabled)", - "开启(发 thinking.type=enabled)"), + "开启 (发 thinking.type=enabled)"), 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}", - "ligent: 配置文件解析失败,已忽略改用内置默认值\n" + "ligent: 配置文件解析失败, 已忽略改用内置默认值\n" " {error}"), 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.", - "本轮后续的改动都不再询问。"), + "本轮后续的改动都不再询问. "), M("confirm.bad_input", "Enter Y / N / A (allow all this turn) / V (deny all this turn); Enter = Y", - "请输入 Y / N / A(本轮全允许) / V(本轮全拒绝),回车 = Y"), + "请输入 Y / N / A (本轮全允许) / V (本轮全拒绝), 回车 = Y"), M("confirm.lbl.about", "about to run", "即将执行"), M("confirm.lbl.cmd", "command:", "命令:"), M("confirm.lbl.edit", "edit:", "修改:"), M("confirm.lbl.path", "path:", "路径:"), M("confirm.lbl.write", "write:", "写入:"), M("confirm.never", "everything else in this turn is denied.", - "本轮后续的改动一律拒绝。"), + "本轮后续的改动一律拒绝. "), M("confirm.note.overwrite", "({bytes} bytes, overwrites the whole file)", - "({bytes} 字节,将整体覆盖)"), + " ({bytes} 字节, 将整体覆盖)"), M("confirm.prompt", " Continue? (Y/N/Always/neVer) ", " 继续?(Y/N/Always/neVer) "), M("confirm.val.empty", "(empty)", "(空)"), /* ---- 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] 的项。"), + "先处理标 [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", - "显式覆盖,优先级最高"), + "显式覆盖, 优先级最高"), M("doctor.env.note.base_url", "overrides base_url", "覆盖 base_url"), M("doctor.env.note.config", "path to the config file", "指定配置文件路径"), M("doctor.env.note.deepseek", "used only when the endpoint really is DeepSeek", @@ -282,70 +282,70 @@ M("doctor.sec.endpoint", "endpoint", "端点"), M("doctor.sec.env", "environment", "环境变量"), M("doctor.sec.key", "API key", "API key"), M("doctor.sec.net", "network", "网络"), -M("doctor.sec.net_skipped", "[network] skipped (--offline)", "【网络】已跳过(--offline)"), +M("doctor.sec.net_skipped", "[network] skipped (--offline)", "【网络】已跳过 (--offline)"), M("doctor.sec.params", "parameters", "参数"), M("doctor.sub.404_base_url", "usually a wrong base_url (a missing /v1, say); check it the way the redirect section describes", - "多半是 base_url 写错了(比如少了 /v1);先照下面「重定向」那节的方法核对"), + "多半是 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 是不是被跳转过:详见下面「重定向」"), M("doctor.sub.max_tokens_huge", " far above the usual output cap (mostly <= 65536); may be rejected or truncated", - " 远超常见模型的输出上限(多数 <= 65536),可能被拒或被截断"), + " 远超常见模型的输出上限 (多数 <= 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\",看起来像是密钥没带,"), + "服务端只会回 401 \"Missing Authentication header\", 看起来像是密钥没带, "), M("doctor.sub.redirect_3", "when in fact the URL moved. Point base_url straight at the address above.", - "实际是 URL 跳了。把 base_url 直接改成上面那个地址即可。"), + "实际是 URL 跳了. 把 base_url 直接改成上面那个地址即可. "), M("doctor.sub.temp_too_high", "above 2.0, most providers reject this", - "超过 2.0,多数服务商会拒绝"), + "超过 2.0, 多数服务商会拒绝"), M("doctor.sub.thinking_temp", "temperature has no effect in thinking mode; the server ignores it", - "思考模式下 temperature 不起作用,服务端会忽略它"), + "思考模式下 temperature 不起作用, 服务端会忽略它"), M("doctor.title", "ligent doctor — walking the whole chain link by link", "ligent doctor —— 把整条链路逐环检查一遍"), M("doctor.val.auth_missing", "HTTP {status} — the endpoint requires auth but no API key is configured", - "HTTP {status} —— 端点要求认证,但当前没有配置 API key"), + "HTTP {status} —— 端点要求认证, 但当前没有配置 API key"), M("doctor.val.auth_ok", "the endpoint accepted this key", "端点接受了这把密钥"), M("doctor.val.auth_rejected", "HTTP {status} — the server rejected this key; check its format and source " "against the API key section above", - "HTTP {status} —— 服务端拒绝了这把密钥,先用上面「API key」一节核对格式与来源"), + "HTTP {status} —— 服务端拒绝了这把密钥, 先用上面「API key」一节核对格式与来源"), 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.chars", "({n} chars)", " ({n} 字符)"), M("doctor.val.connect_failed", "failed: {error}", "失败:{error}"), M("doctor.val.connect_ok", "reachable, TLS verified (for HTTPS)", - "可连接,TLS 校验通过(HTTPS 时)"), + "可连接, TLS 校验通过 (HTTPS 时)"), M("doctor.val.empty", "(empty)", "(空)"), M("doctor.val.endpoint_404", "HTTP 404 — no such address: {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"), + "{var} 已设置, 但本次没用上 (密钥来自「{source}」). " + "如果这不是你想要的, unset 它或改用 LIGENT_API_KEY"), 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 对象)"), + "存在但加载失败 (顶层必须是 JSON 对象)"), M("doctor.val.exists_unparsable", "present but unparsable: {error}", "存在但无法解析:{error}"), M("doctor.val.how_to_set_key", @@ -357,32 +357,32 @@ M("doctor.val.location_missing", "(the server sent no Location)", "(服务端未提供 Location)"), M("doctor.val.loaded", "loaded", "已加载"), M("doctor.val.log", "{n} bytes in memory (export with /log FILE in the REPL)", - "内存缓存 {n} 字节(REPL 里 /log FILE 导出)"), + "内存缓存 {n} 字节 (REPL 里 /log FILE 导出)"), M("doctor.val.match_bad", "endpoint is {owner} but the key prefix looks like {guess}. " "Keys are not interchangeable, so the server will only return a vague 401", - "端点属于 {owner},但密钥前缀像是 {guess} 的。" - "各家密钥不通用,服务端只会回一个含糊的 401"), + "端点属于 {owner}, 但密钥前缀像是 {guess} 的. " + "各家密钥不通用, 服务端只会回一个含糊的 401"), M("doctor.val.match_ok", "endpoint is {owner} and the key format matches", - "端点属于 {owner},密钥格式吻合"), + "端点属于 {owner}, 密钥格式吻合"), M("doctor.val.match_skipped", "endpoint is not a known provider, skipping", - "端点不是已知服务商,跳过检查"), + "端点不是已知服务商, 跳过检查"), M("doctor.val.match_unknown", "endpoint is {owner} but the prefix is unrecognized", - "端点属于 {owner},但密钥前缀认不出来"), + "端点属于 {owner}, 但密钥前缀认不出来"), M("doctor.val.missing", "not found, using built-in defaults", - "不存在,使用内置默认值"), + "不存在, 使用内置默认值"), M("doctor.val.net_tip", "check the network, the proxy settings and that base_url is right", - "检查网络、代理设置,以及 base_url 是否写对"), + "检查网络、代理设置, 以及 base_url 是否写对"), M("doctor.val.no_auth", "the endpoint requires no auth", "端点不要求认证"), M("doctor.val.no_redirect", "none (the endpoint did not redirect)", - "无(端点没有跳转)"), + "无 (端点没有跳转)"), M("doctor.val.none", "none", "无"), M("doctor.val.none_set", "(none)", "(无)"), M("doctor.val.owner.sk", "OpenAI or DeepSeek", "OpenAI 或 DeepSeek"), M("doctor.val.plain_local", "plain HTTP, but the target is localhost", - "明文 HTTP,但目标是本机"), + "明文 HTTP, 但目标是本机"), M("doctor.val.plain_remote", "plain HTTP, the key will travel in the clear", - "明文 HTTP,密钥会以明文上网"), + "明文 HTTP, 密钥会以明文上网"), M("doctor.val.prefix_unclear", "(unrecognized)", "(认不出前缀)"), M("doctor.val.proxy_unset", "(not set)", "(未设置)"), M("doctor.val.redirect_to", "endpoint redirects to {location}", @@ -392,31 +392,31 @@ M("doctor.val.steps", "{n} round trips per turn", "每轮最多 {n} 次往返"), M("doctor.val.server_default", "(server default)", "(服务端默认)"), M("doctor.val.symlink_diff", "argv[0] points at a different file than the real one", "argv[0] 指向的文件与真实文件不同"), -M("doctor.val.timeout", "{n}s (non-streaming only)", "{n} 秒(只对非流式生效)"), +M("doctor.val.timeout", "{n}s (non-streaming only)", "{n} 秒 (只对非流式生效)"), M("doctor.val.trailing_slash_yes", "base_url ends with /, which tends to add a second slash", - "base_url 以 / 结尾,容易多出一个斜杠"), + "base_url 以 / 结尾, 容易多出一个斜杠"), M("doctor.val.unreadable_exe", "(cannot read /proc/self/exe)", "(无法读取 /proc/self/exe)"), 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"), M("repl.banner.hint", "Type a task to start; /help lists commands, Ctrl-D exits.", - "输入任务开始对话,/help 查看命令,Ctrl-D 退出。"), -M("repl.bye", "Bye.", "再见。"), + "输入任务开始对话, /help 查看命令, Ctrl-D 退出. "), +M("repl.bye", "Bye.", "再见. "), M("spinner.probe", "probing capabilities", "检测模型能力"), M("spinner.thinking", "thinking", "思考中"), M("spinner.waiting", "waiting for response", "等待响应"), M("repl.archive_empty", "(this archive is empty)", - "(这段存档是空的)"), + " (这段存档是空的)"), M("repl.cancelled", "cancelled.", - "已取消。"), + "已取消. "), M("repl.colon", ":", ":"), @@ -425,52 +425,52 @@ M("repl.export.done", "已导出 {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"), M("repl.export.name_limit", "(name hit the length cap; the lineage starts a new root here)", - "(名字已达长度上限,谱系在这里重新起头)"), + " (名字已达长度上限, 谱系在这里重新起头)"), M("repl.include.added", "{n} file(s) attached{skipped}", "已附带 {n} 个文件{skipped}"), M("repl.include.cleared", "cleared the pending attachments.", - "已清空待附带的文件。"), + "已清空待附带的文件. "), M("repl.include.failed", "attaching failed: {error}\n", "附带失败:{error}\n"), M("repl.include.next_hint", "they go out with your next message. /include clear cancels.", - "下一条消息会带上它们。用 /include clear 取消。"), + "下一条消息会带上它们. 用 /include clear 取消. "), M("repl.include.none", "nothing attached ({n} skipped).\n", - "没有附带任何文件({n} 个被跳过)。\n"), + "没有附带任何文件 ({n} 个被跳过). \n"), M("repl.include.skipped_more", " ({n} more skipped)", - "(另有 {n} 个被跳过)"), + " (另有 {n} 个被跳过)"), M("repl.index_out_of_range", "index {n} is out of range (1..{max}), cancelled.\n", - "序号 {n} 超出范围(1..{max}),已取消。\n"), + "序号 {n} 超出范围 (1..{max}), 已取消. \n"), M("repl.model.current", " <- current", " <- 当前"), M("repl.model.fetching", "fetching the model list ({url}/models)...\n", - "正在获取模型列表({url}/models)...\n"), + "正在获取模型列表 ({url}/models)...\n"), M("repl.model.list_failed", "could not fetch the model list: {error}\n", "拿不到模型列表:{error}\n"), M("repl.model.list_failed_hint", "you can still name one directly: /model NAME (the list is not required).", - "可以直接用 /model <名字> 指定,列表拿不到不影响这条。"), + "可以直接用 /model <名字> 指定, 列表拿不到不影响这条. "), M("repl.model.list_header", "\nthe server lists {n} models:\n\n", "\n服务端列出了 {n} 个模型:\n\n"), M("repl.model.pick_prompt", "\nselect a number, or type a model name (Enter cancels) > ", - "\n选择序号,或直接输入模型名(回车取消)> "), + "\n选择序号, 或直接输入模型名 (回车取消)> "), M("repl.model.row", " %2zu) %s%s\n", " %2zu) %s%s\n"), @@ -482,7 +482,7 @@ M("repl.model.switched", "模型已切换为 {model}\n"), M("repl.no_content", "(no content)", - "(没有内容)"), + " (没有内容)"), M("repl.role.assistant", "assistant", "助手"), @@ -503,13 +503,13 @@ M("repl.unknown_error", "未知错误"), M("repl.yes_no.hint", "please answer y or n; Enter means y.", - "请输入 y 或 n,直接回车表示 y。"), + "请输入 y 或 n, 直接回车表示 y. "), M("repl.back.done", "removed the last round ({n} messages); {left} left. Undo with /unback.\n", - "已删除最近一轮({n} 条消息),历史剩 {left} 条。可以用 /unback 撤销。\n"), + "已删除最近一轮 ({n} 条消息), 历史剩 {left} 条. 可以用 /unback 撤销. \n"), M("repl.back.none", "no rounds to roll back.", - "没有可以回退的对话轮次。"), + "没有可以回退的对话轮次. "), M("repl.cd.current", "current directory: {dir}\n", "当前目录:{dir}\n"), @@ -518,10 +518,10 @@ M("repl.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"), + "已载入 {name} ({n} 条消息). 之后 /export 会存成 {name}_xxx\n"), M("repl.import.list_header", "\n{dir} holds {n} archives:\n\n", "\n{dir} 里有 {n} 份存档:\n\n"), @@ -530,16 +530,16 @@ M("repl.import.load_failed", "载入失败:{error}\n"), M("repl.import.none", "no archives yet ({dir} is empty). Save one with /export first.\n", - "还没有任何存档({dir} 是空的)。先用 /export 存一份。\n"), + "还没有任何存档 ({dir} 是空的). 先用 /export 存一份. \n"), M("repl.import.not_found", "no archive named {name}. Run /import with no argument to see them all.\n", - "找不到名为 {name} 的存档。用 /import 不带参数可以看到全部。\n"), + "找不到名为 {name} 的存档. 用 /import 不带参数可以看到全部. \n"), M("repl.import.not_found_cancelled", "no archive named {name}, cancelled.\n", - "找不到名为 {name} 的存档,已取消。\n"), + "找不到名为 {name} 的存档, 已取消. \n"), M("repl.import.pick_prompt", "\nselect a number or a name (Enter cancels) > ", - "\n选择序号或名字(直接回车取消)> "), + "\n选择序号或名字 (直接回车取消)> "), M("repl.import.print_ask", "print the contents of this history? (Y/n) ", "是否输出这段历史的内容?(Y/n) "), @@ -551,10 +551,10 @@ M("repl.import.row_failed", " %2zu) %-40s 读取失败\n"), M("repl.index_out_of_range_plain", "index {n} is out of range, cancelled.\n", - "序号 {n} 超出范围,已取消。\n"), + "序号 {n} 超出范围, 已取消. \n"), M("repl.reset.done", "conversation history cleared.", - "已清空对话历史。"), + "已清空对话历史. "), M("repl.system.builtin", "(built-in default)", "(内置默认)"), @@ -563,31 +563,31 @@ M("repl.system.current", "当前 system 提示词:"), M("repl.system.updated", "system prompt updated and history cleared.", - "system 提示词已更新,历史已清空。"), + "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"), M("repl.think.effort_set", "reasoning effort set to {level} (this session only).\n", - "思考级别已设为 {level}(只影响本次会话)。\n"), + "思考级别已设为 {level} (只影响本次会话). \n"), M("repl.think.mode", "thinking mode: %s\n", "思考模式:%s\n"), M("repl.tools.disabled", "tools are disabled (--no-tools).", - "工具已禁用(--no-tools)。"), + "工具已禁用 (--no-tools). "), M("repl.unback.done", "restored {n} messages; history is back to {total}.\n", - "已恢复 {n} 条消息,历史回到 {total} 条。\n"), + "已恢复 {n} 条消息, 历史回到 {total} 条. \n"), M("repl.unback.new_chat", "a new conversation started after /back, cannot undo.", - "/back 之后已经开始了新对话,无法撤销。"), + "/back 之后已经开始了新对话, 无法撤销. "), M("repl.unback.none", "nothing to undo.", - "没有可撤销的操作。"), + "没有可撤销的操作. "), M("repl.usage.calls", " API calls : %d\n", " API 调用 : %d 次\n"), @@ -596,16 +596,16 @@ M("repl.usage.completion", " 输出 token : %lld\n"), M("repl.usage.header", "token usage (this session)\n", - "token 用量(本次会话)\n"), + "token 用量 (本次会话)\n"), M("repl.usage.history", " history : %zu messages (excluding system)\n", - " 历史消息 : %zu 条(不含 system)\n"), + " 历史消息 : %zu 条 (不含 system)\n"), M("repl.usage.last", " last prompt : %lld <- roughly the current context size\n", " 最近一次输入 : %lld <- 大致等于当前上下文大小\n"), M("repl.usage.none", "the server has not reported usage yet (some do not send the usage field).", - "服务端还没有回报过用量(有些服务端不返回 usage 字段)。"), + "服务端还没有回报过用量 (有些服务端不返回 usage 字段). "), M("repl.usage.prompt", " prompt : %lld\n", " 输入 token : %lld\n"), @@ -626,25 +626,25 @@ M("repl.why.exit", "退出"), M("repl.yolo.off", "off — no more prompts; run /yolo again to restore", - "关闭 —— 之后不再询问,想恢复再敲一次 /yolo"), + "关闭 —— 之后不再询问, 想恢复再敲一次 /yolo"), M("repl.yolo.on", "on", "开启"), M("repl.yolo.set", "write confirmation is now {state} (this session only).\n", - "写操作确认已{state}(只影响本次会话)。\n"), + "写操作确认已{state} (只影响本次会话). \n"), M("repl.history.count", "{n} messages in history (excluding the system prompt).\n", - "历史消息 {n} 条(不含 system 提示词)。\n"), + "历史消息 {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.", - "收发记录已清空。"), + "收发记录已清空. "), M("repl.log.empty", "no wire log yet, nothing written to {file}.\n", - "还没有任何收发记录,{file} 未写入内容。\n"), + "还没有任何收发记录, {file} 未写入内容. \n"), M("repl.log.save_failed", "export failed: {error}\n", "导出失败:{error}\n"), @@ -653,48 +653,48 @@ M("repl.log.saved", "已把 {n} 字节收发记录追加写入 {file}\n"), M("repl.log.status", "wire log: {n} bytes cached{trunc}\n /log FILE append to FILE (exporting does not clear it; can be run again)\n /log clear clear the cache\n", - "收发记录已缓存 {n} 字节{trunc}。\n /log FILE 追加写入 FILE(导出后不清空,可反复导出)\n /log clear 清空缓存\n"), + "收发记录已缓存 {n} 字节{trunc}. \n /log FILE 追加写入 FILE (导出后不清空, 可反复导出)\n /log clear 清空缓存\n"), M("repl.log.truncated", " (older part dropped at the size cap)", - "(较早部分已因超上限被丢弃)"), + " (较早部分已因超上限被丢弃)"), M("repl.shell.added_hint", "(added to the context, no automatic follow-up question)", - "(已加入上下文,不自动提问)"), + " (已加入上下文, 不自动提问)"), M("repl.shell.enter", "entering an interactive shell. Type exit or press Ctrl-D to return; the whole session is added to the context when it ends.", - "进入交互式 shell。输入 exit 或按 Ctrl-D 返回;整段会话会在退出后记入上下文。"), + "进入交互式 shell. 输入 exit 或按 Ctrl-D 返回; 整段会话会在退出后记入上下文. "), M("repl.shell.nothing", "nothing was run this time, nothing added to the context.", - "这次没执行任何东西,未记入上下文。"), + "这次没执行任何东西, 未记入上下文. "), M("repl.shell.recorded", "{n} bytes of session log added to the context (no automatic question).\n", - "已把 {n} 字节会话记录加入上下文(不自动提问)。\n"), + "已把 {n} 字节会话记录加入上下文 (不自动提问). \n"), M("repl.shell.start_failed", "could not start the 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"), + "本轮失败, 历史保留, 可继续提问或 /reset 清空. \n"), M("repl.unknown_command", "unknown command: {cmd} (/help lists them)\n", - "未知命令:{cmd}(/help 查看帮助)\n"), + "未知命令:{cmd} (/help 查看帮助)\n"), M("agent.call.elevated", "as root: {cmd}", "以 root:{cmd}"), -M("agent.call.write", "\"{path}\" ({n} bytes)", "\"{path}\"({n} 字节)"), +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)", - "不支持(不再发送工具表)"), + "不支持 (不再发送工具表)"), M("agent.caps.no.vision", "no (read_image hidden)", - "不支持(已隐藏 read_image)"), + "不支持 (已隐藏 read_image)"), M("agent.caps.note", " — anything untested is treated as supported ({why})", - " —— 没测出来的一律按「支持」处理({why})"), + " —— 没测出来的一律按「支持」处理 ({why})"), M("agent.caps.unknown", "untested", "未测"), @@ -709,10 +709,10 @@ M("agent.error", "错误:{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} 处图片引用展开不出来(本机备份缺失或体积超限),已就地降级成文字说明,不再重试。"), + "有 {n} 处图片引用展开不出来 (本机备份缺失或体积超限), 已就地降级成文字说明, 不再重试. "), M("agent.img.rejected", "the server rejected the images in this request; removed {n} image block(s) from the conversation and retrying...\n", - "服务端拒绝了请求里的图片,已把 {n} 处图片从对话里移除后重试...\n"), + "服务端拒绝了请求里的图片, 已把 {n} 处图片从对话里移除后重试...\n"), M("agent.img.server_said", "the server said: {error}", "服务端原话:{error}"), @@ -721,7 +721,7 @@ M("agent.interrupted", "[已中断]"), M("agent.max_steps", "reached the step limit ({n}); stopping. Set --max-steps 0 for no limit.\n", - "已达到最大步数 {n},停止。要不设上限就写 --max-steps 0。\n"), + "已达到最大步数 {n}, 停止. 要不设上限就写 --max-steps 0. \n"), M("agent.output.elided", "... [rest of output elided]", "... [省略剩余输出]"), @@ -730,10 +730,10 @@ 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} 处 —— 不修的话之后每一轮都会被服务端拒绝。"), + "对话结构有问题 (assistant 的 tool_calls 与 tool 消息对不上), 已自动修复 {n} 处 —— 不修的话之后每一轮都会被服务端拒绝. "), M("agent.tool.done", "done", "完成"), @@ -766,10 +766,10 @@ M("attach.err.read_failed", "读不了"), M("attach.err.too_big", "file too large (256 KB per file; use read_file with offsets instead)", - "文件太大(单个上限 256 KB,请改用 read_file 分段读)"), + "文件太大 (单个上限 256 KB, 请改用 read_file 分段读)"), M("attach.err.total_big", "the attached text exceeds the total limit (1 MB)", - "本次附带的文本总量超过上限(1 MB)"), + "本次附带的文本总量超过上限 (1 MB)"), M("attach.err.unreadable", "cannot read", "读不到"), @@ -781,22 +781,22 @@ M("attach.image_mark", " 〔图片〕"), M("attach.pick.cancelled", "selection cancelled.", - "已取消选择。"), + "已取消选择. "), M("attach.pick.dir", "\ncurrent directory: {dir}\n\n", "\n当前目录:{dir}\n\n"), M("attach.pick.empty_dir", "(empty directory, or no permission to read it)", - "(空目录,或者没有权限读)"), + " (空目录, 或者没有权限读)"), M("attach.pick.index_range", "index {n} is out of range (1..{max}).\n", - "序号 {n} 超出范围(1..{max})。\n"), + "序号 {n} 超出范围 (1..{max}). \n"), M("attach.pick.intro", "interactive picker: a number enters a directory / selects a file; you can also type a glob or a path. Enter finishes.", - "进入交互式选择。序号进目录 / 选中文件,也可以直接敲 glob 或路径,回车结束。"), + "进入交互式选择. 序号进目录 / 选中文件, 也可以直接敲 glob 或路径, 回车结束. "), M("attach.pick.is_dir", " (dir)", - " (目录)"), + " (目录)"), M("attach.pick.more", " ...{n} more not listed\n", " ...还有 {n} 项没列出来\n"), @@ -808,7 +808,7 @@ M("attach.pick.parent", " .) .. 返回上级"), M("attach.pick.prompt", "\nselected {n}. pick > ", - "\n已选 {n} 个。选择 > "), + "\n已选 {n} 个. 选择 > "), M("attach.pick.selected", "selected {file}\n", "已选中 {file}\n"), @@ -816,7 +816,7 @@ M("attach.pick.skipped", "skipped {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}"), @@ -828,7 +828,7 @@ M("archive.err.no_messages", "{path} 里没有 messages 数组"), M("archive.err.not_archive", "{path} is not a ligent archive (the format marker is missing)", - "{path} 不是 ligent 存档(缺少 format 标记)"), + "{path} 不是 ligent 存档 (缺少 format 标记)"), M("archive.err.not_json", "{path} is not valid JSON: {error}", "{path} 不是合法 JSON:{error}"), @@ -842,14 +842,14 @@ M("archive.err.write_open", "cannot write {path}: {error}", "无法写入 {path}:{error}"), -/* ---- 模型列表(models.c)---- */ +/* ---- 模型列表 (models.c)---- */ M("models.err.empty_body", "the server returned an empty response (HTTP {status})", - "服务端返回了空响应(HTTP {status})"), + "服务端返回了空响应 (HTTP {status})"), M("models.err.empty_list", "the server returned an empty model list (HTTP {status}) — " "this account may have no models available", - "服务端返回了空的模型列表(HTTP {status})—— " + "服务端返回了空的模型列表 (HTTP {status})—— " "这个账号下可能没有可用的模型"), M("models.err.no_base_url", "no base_url configured", @@ -859,12 +859,12 @@ M("models.err.request_failed", "请求失败"), M("models.err.unparsable", "the server did not return a usable model list (HTTP {status}, response {body})", - "服务端没有返回可用的模型列表(HTTP {status},响应 {body})"), + "服务端没有返回可用的模型列表 (HTTP {status}, 响应 {body})"), -/* ---- 请求与网络(http.c / llm.c)---- */ +/* ---- 请求与网络 (http.c / llm.c)---- */ M("http.err.body_too_big", "the response body exceeded the {limit}-byte cap, aborted", - "响应体超过 {limit} 字节上限,已中止"), + "响应体超过 {limit} 字节上限, 已中止"), M("http.err.interrupted", "interrupted", "已中断"), @@ -879,33 +879,33 @@ M("http.err.network", "网络错误:{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 头," - "服务端只会返回 401)"), + "HTTP {status} 重定向到 {location} —— 请直接把 base_url 改成该地址. " + " (ligent 不自动跟随重定向:跨源跳转会丢掉 Authorization 头, " + "服务端只会返回 401)"), M("http.err.status_partial", "HTTP {status} (some content was already shown, not retrying)", - "HTTP {status}(已输出部分内容,不重试)"), + "HTTP {status} (已输出部分内容, 不重试)"), M("http.err.status_retried", "HTTP {status} (after {n} retries)", - "HTTP {status}(已重试 {n} 次)"), + "HTTP {status} (已重试 {n} 次)"), M("http.log.header", "# ligent request/response log — exported {time}, {n} bytes\n", - "# ligent 收发记录 —— 导出 {time},共 {n} 字节\n"), + "# ligent 收发记录 —— 导出 {time}, 共 {n} 字节\n"), 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} 次)"), + "ligent: 请求失败, {delay} 毫秒后重试 (第 {n} 次)"), M("http.val.no_location", "(the server sent no Location)", "(服务端未提供 Location)"), @@ -914,34 +914,34 @@ M("llm.err.auth_hint", "providers — OpenRouter's start with sk-or-, DeepSeek's with sk-. Make sure " "this one belongs to {url}, and check which of -k / $DEEPSEEK_API_KEY / " "ligent.json is feeding it in.", - "\n 本次发送的密钥是 {masked}。不同服务商的密钥互不通用——" - "OpenRouter 的以 sk-or- 开头,DeepSeek 的以 sk- 开头。" - "请确认它属于 {url},并检查 -k / $DEEPSEEK_API_KEY / ligent.json 里配的是哪一个。"), + "\n 本次发送的密钥是 {masked}. 不同服务商的密钥互不通用——" + "OpenRouter 的以 sk-or- 开头, DeepSeek 的以 sk- 开头. " + "请确认它属于 {url}, 并检查 -k / $DEEPSEEK_API_KEY / ligent.json 里配的是哪一个. "), M("llm.err.bad_json", "the response is not valid 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 环境变量或写入配置文件)"), + "缺少 API key (设置 $DEEPSEEK_API_KEY 环境变量或写入配置文件)"), M("llm.err.no_choices", "the response has no choices[0].message", "响应缺少 choices[0].message"), M("llm.err.overflow", "the response exceeded the cap ({content} bytes of content / {args} bytes of " "tool arguments), aborted", - "响应内容超过上限(正文 {content} 字节 / 工具参数 {args} 字节),已中止"), + "响应内容超过上限 (正文 {content} 字节 / 工具参数 {args} 字节), 已中止"), M("llm.err.server", "server error: {error}", "服务端错误:{error}"), M("llm.err.unparsable_response", "cannot parse the server response (neither streaming nor JSON)", - "无法解析服务端响应(非流式且非 JSON)"), + "无法解析服务端响应 (非流式且非 JSON)"), M("llm.mask.empty", "(empty)", "(空)"), M("llm.mask.partial", "{head}... ({n} chars)", - "{head}...({n} 字符)"), + "{head}... ({n} 字符)"), M("llm.val.no_base_url", "(base_url not set)", "(未设置 base_url)"), @@ -949,7 +949,7 @@ M("llm.val.unknown", "(unknown)", "未知"), -/* ---- /shell(shell.c)---- */ +/* ---- /shell (shell.c)---- */ M("shell.err.fork", "fork failed: {error}", "fork 失败:{error}"), @@ -963,35 +963,35 @@ M("shell.err.pty_init", "pty initialization failed: {error}", "伪终端初始化失败:{error}"), -/* ---- 能力探测(capability.c)---- */ +/* ---- 能力探测 (capability.c)---- */ M("capability.note.baseline_failed", "[capability probe] the baseline request failed (HTTP {status}); " "drawing no conclusion, everything is treated as supported\n", - "[能力探测] 基线请求未通过(HTTP {status})," - "不做任何结论,按「支持」处理\n"), + "[能力探测] 基线请求未通过 (HTTP {status}), " + "不做任何结论, 按「支持」处理\n"), M("capability.note.both_ok", "[capability probe] images + tools are both accepted\n", "[能力探测] 图片 + tools 都被接受\n"), 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_ui() 的报错跟随界面语言(配置、存档、 - * 服务端响应这些给人看的路径)。文案只有这一份,两种走法各取一列。 + * 这一组是"两条腿":json_parse() 的报错固定中文 (它会被原样回填给模型, + * 属于给模型看的信息); json_parse_ui() 的报错跟随界面语言 (配置、存档、 + * 服务端响应这些给人看的路径). 文案只有这一份, 两种走法各取一列. * - * 它们是 printf 模板(%zu/%c/%s…)而不是 {name},所以上面那套占位符检查 - * 管不到 —— 转换符写岔了 snprintf 会读错参数类型,test_i18n 里另有一遍 - * 机械比对(见 printf_specs)。 */ + * 它们是 printf 模板 (%zu/%c/%s…)而不是 {name}, 所以上面那套占位符检查 + * 管不到 —— 转换符写岔了 snprintf 会读错参数类型, test_i18n 里另有一遍 + * 机械比对 (见 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", "输入为空"), @@ -1005,7 +1005,7 @@ M("json.err.string_escape_truncated", M("json.err.string_unterminated", "string is not closed", "字符串未闭合"), M("json.err.too_deep", "JSON nested too deeply (over %d levels)", - "JSON 嵌套过深(超过 %d 层)"), + "JSON 嵌套过深 (超过 %d 层)"), M("json.err.trailing", "extra content after the JSON: '%.20s'", "JSON 末尾存在多余内容:'%.20s'"), @@ -1021,65 +1021,65 @@ M("json.err.want_colon", "对象中键 '%s' 之后期望 ':'"), M("json.err.want_comma_in_array", "expected ',' or ']' in an array, got '%c'", - "数组中期望 ',' 或 ']',实际是 '%c'"), + "数组中期望 ',' 或 ']', 实际是 '%c'"), M("json.err.want_comma_in_object", "expected ',' or '}' in an object, got '%c'", - "对象中期望 ',' 或 '}',实际是 '%c'"), + "对象中期望 ',' 或 '}', 实际是 '%c'"), M("json.err.want_false", "invalid literal (expected false)", - "非法字面量(期望 false)"), + "非法字面量 (期望 false)"), M("json.err.want_key", "expected an object key (a string)", - "期望对象的键(字符串)"), + "期望对象的键 (字符串)"), M("json.err.want_null", "invalid literal (expected null)", - "非法字面量(期望 null)"), + "非法字面量 (期望 null)"), M("json.err.want_true", "invalid literal (expected true)", - "非法字面量(期望 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} 行)"), +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 = 全文件替换)"), + "按行号集合精确替换 (省略 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", "列出目录"), M("tools.ui.read_file", "read a text file (with line numbers; binary files are refused)", - "读取文本文件(带行号;二进制文件被拒)"), + "读取文本文件 (带行号; 二进制文件被拒)"), M("tools.ui.read_image", "read a local image for the model (kept as a local backup, inlined as base64 " "per request)", - "读一张本地图片交给模型看(本机留备份,请求时内联 base64)"), + "读一张本地图片交给模型看 (本机留备份, 请求时内联 base64)"), M("tools.ui.run_command", "run a shell command", "执行 shell 命令"), M("tools.ui.write_file", "create/overwrite a text file (never overwrites a binary)", - "创建/覆盖文本文件(不覆盖二进制)"), + "创建/覆盖文本文件 (不覆盖二进制)"), -/* ---- 杂项(util.c)---- */ +/* ---- 杂项 (util.c)---- */ 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 模式里了。"), + "已经在 meta 模式里了. "), M("meta.desc.delete_rounds", " rounds {from}..{to} deleted whole\n", " 轮 {from}..{to} 整轮删除\n"), @@ -1094,7 +1094,7 @@ M("meta.desc.undo", " 撤销上一次改动\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)", "(无正文)"), @@ -1104,26 +1104,26 @@ M("meta.diff.structural", M("meta.enter", "entered meta mode — you are now editing the conversation history itself, " "not continuing it.", - "已进入 meta 模式 —— 改的是对话历史本身,不是继续对话。"), + "已进入 meta 模式 —— 改的是对话历史本身, 不是继续对话. "), M("meta.enter.hint", "say what to change in plain words; /help lists the commands, /exit goes back.", - "用自然语言说要改什么;/help 看命令,/exit 返回。"), + "用自然语言说要改什么; /help 看命令, /exit 返回. "), M("meta.exit", "\nleft meta mode. ", - "\n退出 meta 模式。"), + "\n退出 meta 模式. "), M("meta.exit.edits", "made {n} edit(s) this time, ", - "本次共改动 {n} 次,"), + "本次共改动 {n} 次, "), 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" @@ -1135,38 +1135,38 @@ M("meta.help", " the assistant's conclusion in round 5 is wrong, fix it\n" " rounds 2 to 4 go in circles, squeeze them into one paragraph\n", - "/exit, /quit 退出 meta 模式,回到主对话\n" - "/diff 看看现在的对话历史(不会改任何东西)\n" + "/exit, /quit 退出 meta 模式, 回到主对话\n" + "/diff 看看现在的对话历史 (不会改任何东西)\n" "/undo 撤销上一次改动\n" "/help 显示本帮助\n" "\n" - "其余输入都是给 meta 模型的指令,例如:\n" - " 把第 3 轮里我说的“用 Python”删掉\n" - " 第 5 轮助手那个结论是错的,改掉\n" - " 第 2 到 4 轮在绕圈子,压成一段\n"), + "其余输入都是给 meta 模型的指令, 例如:\n" + " 把第 3 轮里我说的 "用 Python" 删掉\n" + " 第 5 轮助手那个结论是错的, 改掉\n" + " 第 2 到 4 轮在绕圈子, 压成一段\n"), M("meta.turn_failed", "this turn failed; the history is untouched, so you can say it again.\n", - "本轮失败,历史未受影响,可以重说一遍。\n"), + "本轮失败, 历史未受影响, 可以重说一遍. \n"), M("meta.undo.done", "undid the last edit.", - "已撤销上一次改动。"), + "已撤销上一次改动. "), M("meta.undo.none", "there is nothing left to undo.", - "没有可撤销的改动了。"), + "没有可撤销的改动了. "), M("meta.unit.bytes", "bytes", "字节"), M("meta.unknown_cmd", "meta mode has no {cmd} command (/help lists the ones it has).\n", - "meta 模式里没有 {cmd} 这个命令(/help 看可用的)。\n"), + "meta 模式里没有 {cmd} 这个命令 (/help 看可用的). \n"), }; #undef M #define NMSGS (sizeof MSGS / sizeof MSGS[0]) -/* tf() 用一圈静态缓冲轮流返回字符串(这样调用点不必自己 malloc)。 +/* tf() 用一圈静态缓冲轮流返回字符串 (这样调用点不必自己 malloc). * 轮转的层数就是**嵌套调用**的上限:`tf(a, "x", tf(b, ...), NULL)` 里的内层 - * 返回值必须在外层拼完之前一直有效,嵌套超过 TF_BUFS 层就会踩坏自己。 */ + * 返回值必须在外层拼完之前一直有效, 嵌套超过 TF_BUFS 层就会踩坏自己. */ #define TF_BUFS 8 #define TF_BUFLEN 4096 @@ -1225,8 +1225,8 @@ 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; } return LIGENT_LANG_EN; @@ -1260,7 +1260,7 @@ const char *i18n_num(long long v) const char *t(const char *key) { const LigentMsg *m = find(key); - if (!m) { /* 漏掉的键要看得见,别静默成空白 */ + if (!m) { /* 漏掉的键要看得见, 别静默成空白 */ char *b = next_buf(); snprintf(b, TF_BUFLEN, "[?%s?]", key ? key : ""); return b; @@ -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 8f85b55..0948156 100644 --- a/src/i18n.h +++ b/src/i18n.h @@ -1,11 +1,11 @@ /* i18n.h - 界面文案的多语言表 * - * 只覆盖**给人看**的文案:帮助、错误、诊断、确认提示、spinner 标签。 - * 送给模型的那些(系统提示词、工具描述、工具回执)**不在这里**, - * 它们跟界面语言是两件事,见 README「界面语言」一节。 + * 只覆盖**给人看**的文案:帮助、错误、诊断、确认提示、spinner 标签. + * 送给模型的那些 (系统提示词、工具描述、工具回执)**不在这里**, + * 它们跟界面语言是两件事, 见 README「界面语言」一节. * - * 键用点分 ID(`err.unknown_option`)而不是中文原文:这样两种语言的 - * 条目能机械对齐 —— 少一条、占位符对不上,测试都会报出来。 + * 键用点分 ID (`err.unknown_option`)而不是中文原文:这样两种语言的 + * 条目能机械对齐 —— 少一条、占位符对不上, 测试都会报出来. */ #ifndef LIGENT_I18N_H #define LIGENT_I18N_H @@ -24,39 +24,39 @@ typedef struct { void i18n_set_lang(int lang); int i18n_lang(void); -/* 解析语言名:"zh"、"zh_CN"、"zh-Hans.UTF-8" → ZH; - * "en"、"C"、"POSIX" → EN; - * "auto"、空、认不出来的 → -1(调用方自行决定回退)。 */ +/* 解析语言名:"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 时按环境探测。 */ +/* 初始化. lang 为 -1 时按环境探测. */ void i18n_init(int lang); -/* "en" / "zh"(静态字面量) */ +/* "en" / "zh" (静态字面量) */ const char *i18n_lang_name(void); -/* 查表取当前语言的文案。未知键返回 "[?key?]" —— 显眼, - * 免得漏掉的条目静默变成空白。 */ +/* 查表取当前语言的文案. 未知键返回 "[?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]。 */ + * 省得每个调用点都开一个 char buf[32]. */ const char *i18n_num(long long v); -/* 表本身,供测试遍历(检查两种语言是否齐全、占位符是否一致) */ +/* 表本身, 供测试遍历 (检查两种语言是否齐全、占位符是否一致) */ const LigentMsg *i18n_table(size_t *count); -/* 指定语言取原文(测试用)。找不到返回 NULL。 */ +/* 指定语言取原文 (测试用). 找不到返回 NULL. */ const char *i18n_lookup(int lang, const char *key); #endif /* LIGENT_I18N_H */ diff --git a/src/image.c b/src/image.c index feb9f11..1bcee4e 100644 --- a/src/image.c +++ b/src/image.c @@ -11,9 +11,9 @@ #include #include -/* 识别四种服务端支持的格式。都靠文件头那几个字节 —— 服务端也明说了 - * "格式由文件实际内容判断,而非文件名或声明的 MIME 类型",我们得跟它一致, - * 否则会把一个改名成 .png 的 JPEG 当成 PNG 报出去。 */ +/* 识别四种服务端支持的格式. 都靠文件头那几个字节 —— 服务端也明说了 + * "格式由文件实际内容判断, 而非文件名或声明的 MIME 类型", 我们得跟它一致, + * 否则会把一个改名成 .png 的 JPEG 当成 PNG 报出去. */ int image_sniff(const unsigned char *d, size_t n, const char **ext, const char **mime) { @@ -41,7 +41,7 @@ int image_sniff(const unsigned char *d, size_t n, *ext = "webp"; *mime = "image/webp"; return 0; } - return -1; /* 不认识的格式,交给上层把话说清楚 */ + return -1; /* 不认识的格式, 交给上层把话说清楚 */ } char *image_backup_dir(const char *bindir) @@ -58,7 +58,7 @@ char *image_backup_path(const char *bindir, const char *name) return p; } -/* 名字里的随机后缀由 util.c 的 random_alnum 提供(和存档名共用一份实现) */ +/* 名字里的随机后缀由 util.c 的 random_alnum 提供 (和存档名共用一份实现) */ char *image_backup_save(const char *bindir, const unsigned char *data, size_t n, const char *ext) @@ -66,8 +66,8 @@ char *image_backup_save(const char *bindir, const unsigned char *data, size_t n, char *dir = image_backup_dir(bindir); if (mkdir_p(dir) != 0) { free(dir); return NULL; } - /* <上传 UNIX 时间戳>_<4 位随机>.。名字里带时间戳是为了能一眼看出 - * 这张图是什么时候传的,跟 file_id 的有效期对得上。 */ + /* <上传 UNIX 时间戳>_<4 位随机>.. 名字里带时间戳是为了能一眼看出 + * 这张图是什么时候传的, 跟 file_id 的有效期对得上. */ for (int attempt = 0; attempt < 64; attempt++) { char sfx[5]; random_alnum(sfx, 4); @@ -95,8 +95,8 @@ char *image_backup_save(const char *bindir, const unsigned char *data, size_t n, /* 请求前展开:ligent_image -> image_url + base64 */ /* ------------------------------------------------------------------ */ -/* 内部块类型。带 ligent_ 前缀是为了万一漏了展开、直接发出去时, - * 服务端报的错能看出是我们的问题。 */ +/* 内部块类型. 带 ligent_ 前缀是为了万一漏了展开、直接发出去时, + * 服务端报的错能看出是我们的问题. */ #define PART_IMAGE "ligent_image" #define PART_NOTE "ligent_note" @@ -107,9 +107,9 @@ static int part_is(const JsonValue *part, const char *type) return t && strcmp(t, type) == 0; } -/* 造一个降级说明块。text 会被同时用在存储形态和发送形态里 —— - * 存储用 ligent_note(opens_round 不认它,附件消息不会因此变成"一轮"), - * 发送时再换成协议认的 text。 */ +/* 造一个降级说明块. text 会被同时用在存储形态和发送形态里 —— + * 存储用 ligent_note (opens_round 不认它, 附件消息不会因此变成"一轮"), + * 发送时再换成协议认的 text. */ static JsonValue *make_note(const char *text) { JsonValue *n = json_new_object(); @@ -118,7 +118,7 @@ static JsonValue *make_note(const char *text) return n; } -/* 存储形态 -> 发送形态。只处理内部块,别的原样克隆。 */ +/* 存储形态 -> 发送形态. 只处理内部块, 别的原样克隆. */ static JsonValue *to_wire_part(const JsonValue *part) { if (part_is(part, PART_NOTE)) { @@ -127,11 +127,11 @@ static JsonValue *to_wire_part(const JsonValue *part) json_object_set_string(w, "text", json_get_string(part, "text")); return w; } - /* 已经是协议形态(比如用户自己塞进来的 text / image_url)就照抄 */ + /* 已经是协议形态 (比如用户自己塞进来的 text / image_url)就照抄 */ return json_clone(part); } -/* 读一份备份并编码成 data URL。失败返回 NULL,*why 给出给人看的理由。 */ +/* 读一份备份并编码成 data URL. 失败返回 NULL, *why 给出给人看的理由. */ static char *backup_to_data_url(const char *name, size_t *budget, const char **why) { const char *bindir = image_bindir(); @@ -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,8 +220,8 @@ JsonValue *image_expand_for_request(JsonValue *messages, int *degraded) continue; } - /* 展开不出来:把历史里这条坏引用就地换成文字说明, - * 否则它每轮都会再失败一次。 */ + /* 展开不出来:把历史里这条坏引用就地换成文字说明, + * 否则它每轮都会再失败一次. */ char *text = xasprintf("[图片 %s 无法交给模型:%s]", name && *name ? name : "?", why); JsonValue *note = make_note(text); @@ -259,20 +259,20 @@ 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" - "(按文件内容判断,不看扩展名)", path); + "不认识的图片格式:%s. 支持 PNG / JPEG / GIF / WebP" + " (按文件内容判断, 不看扩展名)", path); return NULL; } - /* 先判尺寸,再决定要不要备份 —— 超限的图不备份 */ + /* 先判尺寸, 再决定要不要备份 —— 超限的图不备份 */ size_t b64len = base64_encoded_len(len); if (b64len > IMAGE_MAX_B64) { free(raw); if (out_msg) *out_msg = xasprintf( - "图片太大,没法交给模型看:%s 有 %.1f MiB,base64 内联后 %.1f MiB," - "超过内联上限 %.0f MiB。" - "(图片会留在对话历史里,所以这里当场拒掉,而不是发出去等报错。" - "如果要看大图,请先缩小尺寸或压缩后再读。)", + "图片太大, 没法交给模型看:%s 有 %.1f MiB, base64 内联后 %.1f MiB, " + "超过内联上限 %.0f MiB. " + " (图片会留在对话历史里, 所以这里当场拒掉, 而不是发出去等报错. " + "如果要看大图, 请先缩小尺寸或压缩后再读. )", path, (double)len / 1048576.0, (double)b64len / 1048576.0, (double)IMAGE_MAX_B64 / 1048576.0); return NULL; @@ -319,12 +319,12 @@ int image_degrade_all(JsonValue *messages) JsonValue *part = json_array_get(c, k); if (!part_is(part, PART_IMAGE)) continue; - /* 给模型看的话要能直接照做,所以不把服务端的原始报错抄进来 —— - * 那通常是给人看的英文错误码。原始原因由 agent.c 打到 stderr, - * 想查的人看得到,上下文里不脏。 */ + /* 给模型看的话要能直接照做, 所以不把服务端的原始报错抄进来 —— + * 那通常是给人看的英文错误码. 原始原因由 agent.c 打到 stderr, + * 想查的人看得到, 上下文里不脏. */ const char *name = json_get_string(part, "backup"); char *text = xasprintf( - "[图片 %s 不符合 API 所需的格式标准,无法上传,请规范化图片后重试]", + "[图片 %s 不符合 API 所需的格式标准, 无法上传, 请规范化图片后重试]", name && *name ? name : "?"); JsonValue *note = make_note(text); free(text); @@ -370,7 +370,7 @@ JsonValue *image_pending_take(void) { if (g_pending_n == 0) return NULL; - /* 存的是对备份的引用,不是图片字节 —— 字节等构造请求时再去读 */ + /* 存的是对备份的引用, 不是图片字节 —— 字节等构造请求时再去读 */ JsonValue *arr = json_new_array(); for (size_t i = 0; i < g_pending_n; i++) { JsonValue *part = json_new_object(); diff --git a/src/image.h b/src/image.h index 575acfa..002db88 100644 --- a/src/image.h +++ b/src/image.h @@ -1,22 +1,22 @@ /* image.h - 图片:格式嗅探、本地备份、请求前展开 * - * 协议层面图片只有两条路能走:给一个外部 URL,或者把 base64 内联进请求。 - * ligent 不替用户去网上抓图,所以 read_image 只收本地文件,产出 base64 内联。 + * 协议层面图片只有两条路能走:给一个外部 URL, 或者把 base64 内联进请求. + * ligent 不替用户去网上抓图, 所以 read_image 只收本地文件, 产出 base64 内联. * - * 但 base64 内联有个直接后果:图片字节会躺在会话历史里,每轮请求都重发一遍。 - * 归档、/history、/log 全都会跟着膨胀。所以历史的**存储形态**和**发送形态** + * 但 base64 内联有个直接后果:图片字节会躺在会话历史里, 每轮请求都重发一遍. + * 归档、/history、/log 全都会跟着膨胀. 所以历史的**存储形态**和**发送形态** * 是分开的: * * 存进历史 {"type":"ligent_image","backup":"1700000000_a1b2.png"} * 发给服务端 {"type":"image_url","image_url":{"url":"data:image/png;base64,...."}} * - * 也就是说历史里只有对本地备份的引用(几十字节),备份文件才是字节的真身, - * 真正构造请求时才读出来编码。备份目录是 /ligent.archive/images/。 + * 也就是说历史里只有对本地备份的引用 (几十字节), 备份文件才是字节的真身, + * 真正构造请求时才读出来编码. 备份目录是 /ligent.archive/images/. * - * 两个内部块类型都带 ligent_ 前缀,就是为了万一哪天漏了展开、直接把它发出去, - * 服务端报的错能一眼看出是我们的问题,而不是一个莫名其妙的 400: + * 两个内部块类型都带 ligent_ 前缀, 就是为了万一哪天漏了展开、直接把它发出去, + * 服务端报的错能一眼看出是我们的问题, 而不是一个莫名其妙的 400: * ligent_image 引用一张本地备份 - * ligent_note 展开失败后的文字说明(引用读不到了、或体积超了) + * ligent_note 展开失败后的文字说明 (引用读不到了、或体积超了) */ #ifndef LIGENT_IMAGE_H #define LIGENT_IMAGE_H @@ -27,82 +27,82 @@ /* ---- 内联图片的上限 ---- * - * 取各家文档里最严的一档(ligent 同时对着 DeepSeek 和 MiMo 跑,两边都发了 - * 文档里的数字): - * DeepSeek:请求体 48 MiB;单张(base64 / 外部 URL)32 MiB + * 取各家文档里最严的一档 (ligent 同时对着 DeepSeek 和 MiMo 跑, 两边都发了 + * 文档里的数字): + * DeepSeek:请求体 48 MiB; 单张 (base64 / 外部 URL)32 MiB * MiMo :base64 字符串 50 MB - * 于是单张按 32 MiB 的 **base64 字符串** 算 —— 注意是编码后的长度,一张 - * 24 MiB 的原图编码后就已经超了。宁可在这里拒掉,也别发出去等一个 400: - * 图片是躺在历史里的,一次超限会变成每轮都超限。 + * 于是单张按 32 MiB 的 **base64 字符串** 算 —— 注意是编码后的长度, 一张 + * 24 MiB 的原图编码后就已经超了. 宁可在这里拒掉, 也别发出去等一个 400: + * 图片是躺在历史里的, 一次超限会变成每轮都超限. */ #define IMAGE_MAX_B64 (32u * 1024 * 1024) -/* 一个请求里所有内联图片的 base64 总量。48 MiB 的请求体还要装提示词、历史、 - * 工具描述,给它们留 4 MiB。这条是兜底:read_image 一次最多挂 8 张, - * 每张都卡在 32 MiB 的话,加起来早就冲爆请求体了。 */ +/* 一个请求里所有内联图片的 base64 总量. 48 MiB 的请求体还要装提示词、历史、 + * 工具描述, 给它们留 4 MiB. 这条是兜底:read_image 一次最多挂 8 张, + * 每张都卡在 32 MiB 的话, 加起来早就冲爆请求体了. */ #define IMAGE_MAX_TOTAL_B64 (44u * 1024 * 1024) -/* 支持的四种格式。认出来返回 0,并回填扩展名与 MIME(都是字面量,不用释放)。 - * 按文件实际内容判断,不看文件名 —— 服务端也是这么判的。 */ +/* 支持的四种格式. 认出来返回 0, 并回填扩展名与 MIME (都是字面量, 不用释放). + * 按文件实际内容判断, 不看文件名 —— 服务端也是这么判的. */ 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 返回的名字 */ +/* 备份文件所在目录下的完整路径 (malloc). name 是 image_backup_save 返回的名字 */ char *image_backup_path(const char *bindir, const char *name); -/* 把一段图片字节存成备份,返回文件名(malloc,形如 "1700000000_a1b2.png")。 - * 失败返回 NULL。dir 不存在会自动建。 */ +/* 把一段图片字节存成备份, 返回文件名 (malloc, 形如 "1700000000_a1b2.png"). + * 失败返回 NULL. dir 不存在会自动建. */ char *image_backup_save(const char *bindir, const unsigned char *data, size_t n, const char *ext); /* ---- 备份根目录 ---- - * 工具层拿不到 main 里的 g_bindir,启动时设一次即可。 */ + * 工具层拿不到 main 里的 g_bindir, 启动时设一次即可. */ 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)。 + * 成功返回备份文件名 (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); /* ---- 待发附件 ---- - * 协议规定图片只能出现在 user 消息里(system/assistant 带图直接 400), - * 而 read_image 是工具、它的结果只能是字符串。所以工具把备份名挂在这里, - * agent_run 推完 tool 消息之后取走,补一条 user 消息带上它。 + * 协议规定图片只能出现在 user 消息里 (system/assistant 带图直接 400), + * 而 read_image 是工具、它的结果只能是字符串. 所以工具把备份名挂在这里, + * agent_run 推完 tool 消息之后取走, 补一条 user 消息带上它. * - * 取走即清空。返回 content 块数组(调用者释放),没有则返回 NULL。 */ + * 取走即清空. 返回 content 块数组 (调用者释放), 没有则返回 NULL. */ void image_pending_add(const char *backup); JsonValue *image_pending_take(void); /* ---- 请求前展开 ---- * - * 把历史里的 ligent_image 块换成 image_url + base64,返回一份**请求副本** - * (调用者 json_free)。原 messages 不变,除非有引用展开不出来 —— 那种情况 - * 会**就地**把它降级成 ligent_note,把坏引用从历史里清掉。 + * 把历史里的 ligent_image 块换成 image_url + base64, 返回一份**请求副本** + * (调用者 json_free). 原 messages 不变, 除非有引用展开不出来 —— 那种情况 + * 会**就地**把它降级成 ligent_note, 把坏引用从历史里清掉. * - * 为什么就地降级:那条引用躺在历史里,不修的话每一轮都会重新失败一次, - * 用户除了 /reset 没有别的路。这和上下文超限要丢老轮次是同一个道理。 + * 为什么就地降级:那条引用躺在历史里, 不修的话每一轮都会重新失败一次, + * 用户除了 /reset 没有别的路. 这和上下文超限要丢老轮次是同一个道理. * - * *degraded(可传 NULL)回填就地降级的处数,调用方据此提示用户。 - * 返回 NULL 表示分配失败或 messages 不是数组。 */ + * *degraded (可传 NULL)回填就地降级的处数, 调用方据此提示用户. + * 返回 NULL 表示分配失败或 messages 不是数组. */ JsonValue *image_expand_for_request(JsonValue *messages, int *degraded); -/* 历史里还有没有未展开的图片引用。给"服务端 4xx 了,是不是图片惹的"这种 - * 判断用 —— 没有图片就别瞎猜。 */ +/* 历史里还有没有未展开的图片引用. 给"服务端 4xx 了, 是不是图片惹的"这种 + * 判断用 —— 没有图片就别瞎猜. */ int image_has_refs(const JsonValue *messages); -/* 把历史里所有图片引用就地降级成文字说明("不符合 API 所需的格式标准, - * 无法上传,请规范化图片后重试"),返回处理处数。 - * 不给理由参数:给模型的话要能直接照做,服务端的原始英文报错由调用方打到 - * stderr,不往上下文里塞。 */ +/* 把历史里所有图片引用就地降级成文字说明 ("不符合 API 所需的格式标准, + * 无法上传, 请规范化图片后重试"), 返回处理处数. + * 不给理由参数:给模型的话要能直接照做, 服务端的原始英文报错由调用方打到 + * stderr, 不往上下文里塞. */ int image_degrade_all(JsonValue *messages); #endif /* LIGENT_IMAGE_H */ diff --git a/src/json.c b/src/json.c index ed5221e..6a62271 100644 --- a/src/json.c +++ b/src/json.c @@ -260,22 +260,22 @@ int json_get_bool(const JsonValue *obj, const char *key, int def) /* ------------------------------------------------------------------ */ typedef struct { - const char *start; /* 原文起点,用来算出行列号 */ + const char *start; /* 原文起点, 用来算出行列号 */ const char *p; const char *end; char *err; size_t errlen; int depth; - int ui; /* 1 = 报错跟随界面语言(见 jfmt) */ + int ui; /* 1 = 报错跟随界面语言 (见 jfmt) */ } Parser; -/* 取一条诊断的模板。ui 为 0 时固定用中文那份 —— 解析器的"母语"是中文, - * 而它同时出现在**给模型看的**工具参数报错里(agent.c 把 json_parse 的 - * 结论原样回填给模型),那些不该随界面语言变,见 README「界面语言」。 +/* 取一条诊断的模板. ui 为 0 时固定用中文那份 —— 解析器的"母语"是中文, + * 而它同时出现在**给模型看的**工具参数报错里 (agent.c 把 json_parse 的 + * 结论原样回填给模型), 那些不该随界面语言变, 见 README「界面语言」. * - * 表里的 json.* 用的是 printf 转换(%s/%c/%zu…)而不是 {name} —— 这里是 - * 格式化转发,不是占位符替换。两种语言的转换符序列必须逐字一致, - * tests/test_i18n.c 会机械地比一遍。 */ + * 表里的 json.* 用的是 printf 转换 (%s/%c/%zu…)而不是 {name} —— 这里是 + * 格式化转发, 不是占位符替换. 两种语言的转换符序列必须逐字一致, + * tests/test_i18n.c 会机械地比一遍. */ static const char *jfmt(int ui, const char *key) { if (ui) return t(key); @@ -283,8 +283,8 @@ static const char *jfmt(int ui, const char *key) return zh ? zh : key; } -/* 只记录第一个错误;带上行列号和出错处的原文片段, - * 否则用户只知道"语法错了"却不知道错在哪 —— 手写 JSON 时这几乎是必然的。 */ +/* 只记录第一个错误; 带上行列号和出错处的原文片段, + * 否则用户只知道"语法错了"却不知道错在哪 —— 手写 JSON 时这几乎是必然的. */ LIGENT_PRINTF(2, 3) static void perr(Parser *ps, const char *fmt, ...) { if (!ps->err || ps->errlen == 0 || ps->err[0] != '\0') return; @@ -301,7 +301,7 @@ LIGENT_PRINTF(2, 3) static void perr(Parser *ps, const char *fmt, ...) vsnprintf(detail, sizeof detail, fmt, ap); va_end(ap); - /* 出错位置开始的一小段原文(控制字符替换掉,避免把终端搞乱) */ + /* 出错位置开始的一小段原文 (控制字符替换掉, 避免把终端搞乱) */ char excerpt[40]; size_t n = 0; for (const char *q = ps->p; q < ps->end && n < 24; q++) { @@ -311,7 +311,7 @@ LIGENT_PRINTF(2, 3) static void perr(Parser *ps, const char *fmt, ...) } excerpt[n] = '\0'; - /* 模板里是同序的 %zu/%zu/%s(+%s)—— 两种语言必须完全一致 */ + /* 模板里是同序的 %zu/%zu/%s (+%s)—— 两种语言必须完全一致 */ if (n > 0) snprintf(ps->err, ps->errlen, jfmt(ps->ui, "json.err.at_got"), line, col, detail, excerpt); @@ -319,9 +319,9 @@ 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'" 这种格式化会直接把控制字符带进消息本体, - * 原样打到终端上会把用户的屏幕搞乱。 */ + /* 最后统一净化:错误信息里不允许出现原始控制字符. + * 像 "意外的字符 '%c'" 这种格式化会直接把控制字符带进消息本体, + * 原样打到终端上会把用户的屏幕搞乱. */ for (char *q = ps->err; *q; q++) { unsigned char c = (unsigned char)*q; if (c < 0x20 || c == 0x7f) *q = '?'; @@ -339,7 +339,7 @@ static void skip_ws(Parser *ps) static JsonValue *parse_value(Parser *ps); -/* 解析一个 JSON 字符串内容(ps->p 指向起始双引号之后),写入 sb */ +/* 解析一个 JSON 字符串内容 (ps->p 指向起始双引号之后), 写入 sb */ static int parse_string_raw(Parser *ps, StrBuf *sb, char *errb, size_t errlen) { (void)errb; (void)errlen; @@ -437,11 +437,11 @@ static JsonValue *parse_string(Parser *ps) /* 严格按 RFC 8259 扫描一个数字: * -?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)? * - * 以前这里是"能吃多少吃多少",于是 01、5.、1e、1e+ 全都被当成合法数字 —— - * 而 strtod("1e") 会老老实实返回 1,那个残缺的指数就被静默吞掉了。 - * 配置文件和存档都是手写的,把写错的数字当成另一个数继续用下去,用户只会在 - * 很久之后从一个完全不相干的现象里发现它。所以语法必须自己严查一遍: - * strtod 只负责最后那步文本 -> double 的转换。 */ + * 以前这里是"能吃多少吃多少", 于是 01、5.、1e、1e+ 全都被当成合法数字 —— + * 而 strtod("1e") 会老老实实返回 1, 那个残缺的指数就被静默吞掉了. + * 配置文件和存档都是手写的, 把写错的数字当成另一个数继续用下去, 用户只会在 + * 很久之后从一个完全不相干的现象里发现它. 所以语法必须自己严查一遍: + * strtod 只负责最后那步文本 -> double 的转换. */ static JsonValue *parse_number(Parser *ps) { const char *start = ps->p; @@ -449,7 +449,7 @@ static JsonValue *parse_number(Parser *ps) if (ps->p < ps->end && *ps->p == '-') ps->p++; if (ps->p >= ps->end || !isdigit((unsigned char)*ps->p)) goto bad; - if (*ps->p == '0') ps->p++; /* '0' 后面不能再跟数字(01 非法) */ + if (*ps->p == '0') ps->p++; /* '0' 后面不能再跟数字 (01 非法) */ else while (ps->p < ps->end && isdigit((unsigned char)*ps->p)) ps->p++; if (ps->p < ps->end && *ps->p == '.') { @@ -475,8 +475,8 @@ static JsonValue *parse_number(Parser *ps) } bad: - /* 回退到数字的起点再报错:这样行列号指向"这个数字从哪开始", - * 而不是指向我们自己刚消费掉的那个字节。 */ + /* 回退到数字的起点再报错:这样行列号指向"这个数字从哪开始", + * 而不是指向我们自己刚消费掉的那个字节. */ { char *tmp = xstrndup(start, (size_t)(ps->p - start)); ps->p = start; @@ -601,8 +601,8 @@ done: return result; } -/* ui != 0:报错跟随界面语言;= 0:固定中文(给模型看的那份)。 - * 见上面 jfmt 的说明与 README「界面语言」。 */ +/* ui != 0:报错跟随界面语言; = 0:固定中文 (给模型看的那份). + * 见上面 jfmt 的说明与 README「界面语言」. */ static JsonValue *parse_impl(const char *text, char *errbuf, size_t errlen, int ui) { if (errbuf && errlen) errbuf[0] = '\0'; @@ -666,26 +666,26 @@ 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 字符串。 - * 以前这里是无条件 sb_putc,于是工具输出里一个坏字节(例如某条命令在 - * 非 UTF-8 locale 下打出来的路径,或者被截断的多字节字符)就会让**整个 - * 请求体**不是合法 UTF-8,服务端直接回 + /* 非 ASCII 字节:只有构成**合法** UTF-8 序列时才能原样进 JSON 字符串. + * 以前这里是无条件 sb_putc, 于是工具输出里一个坏字节 (例如某条命令在 + * 非 UTF-8 locale 下打出来的路径, 或者被截断的多字节字符)就会让**整个 + * 请求体**不是合法 UTF-8, 服务端直接回 * 400 ... invalid unicode code point - * 更糟的是那段坏数据已经进了对话历史,之后每次请求都会失败 —— - * 除非 /reset 把整轮对话丢掉。 + * 更糟的是那段坏数据已经进了对话历史, 之后每次请求都会失败 —— + * 除非 /reset 把整轮对话丢掉. * - * 兜在出口而不是只在入口清理,是因为出口才是能保证"发出去的永远合法" + * 兜在出口而不是只在入口清理, 是因为出口才是能保证"发出去的永远合法" * 的那一层:对话内容还可能来自存档、粘贴、/include 进来的文件…… - * 这里统一把非法字节换成 U+FFFD,既让请求成立,也让模型看到"这里有个 - * 无法解码的字符",而不是收到一段乱码。 */ + * 这里统一把非法字节换成 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, - * 解析回来变成真的 U+FFFD,第二次就原样吐三个字节。 + /* U+FFFD 一律写成 \ufffd 转义. 它是上面替换坏字节时用的字符, 如果这里 + * 让 EF BF BD 原样输出, dump→parse→dump 就不稳定了:第一次是 \ufffd, + * 解析回来变成真的 U+FFFD, 第二次就原样吐三个字节. * fuzz 的"dump 幂等"这条不变量就是被这个当场抓住的 —— 幂等是有价值的 - * 性质,它保证存档反复读写在字节层面也是收敛的。给这一个字符定死一个 - * 规范形式,代价是真正合法的 U+FFFD 会多占 3 个字节,可以忽略。 */ + * 性质, 它保证存档反复读写在字节层面也是收敛的. 给这一个字符定死一个 + * 规范形式, 代价是真正合法的 U+FFFD 会多占 3 个字节, 可以忽略. */ if (n == 3 && (unsigned char)s[i] == 0xEF && (unsigned char)s[i + 1] == 0xBF && (unsigned char)s[i + 2] == 0xBD) { sb_puts(sb, "\\ufffd"); @@ -702,10 +702,10 @@ 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 - * 就会踩到(clang 的 UBSan 报 float-cast-overflow;gcc 不插桩这条转换, - * 所以 make SAN=1 抓不到)。 */ + /* 先判范围, 再转换. double -> long long 在值超出范围时是 UB, 而 && 的 + * 求值顺序保不住这个转换:原来它写在条件最左边, 服务端回一个 1e300 + * 就会踩到 (clang 的 UBSan 报 float-cast-overflow; gcc 不插桩这条转换, + * 所以 make SAN=1 抓不到). */ if (fabs(n) < 1e15 && n == (double)(long long)n) snprintf(buf, sizeof buf, "%lld", (long long)n); else diff --git a/src/json.h b/src/json.h index a2e1754..6539d83 100644 --- a/src/json.h +++ b/src/json.h @@ -42,9 +42,9 @@ struct JsonValue { /* ---- 解析:失败返回 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); /* ---- 释放 ---- */ @@ -62,7 +62,7 @@ const char *json_get_string(const JsonValue *obj, const char *key); double json_get_number(const JsonValue *obj, const char *key, double def); int json_get_bool(const JsonValue *obj, const char *key, int def); -/* ---- 构造(取得所有权) ---- */ +/* ---- 构造 (取得所有权) ---- */ JsonValue *json_new_null(void); JsonValue *json_new_bool(int b); JsonValue *json_new_number(double n); @@ -71,12 +71,12 @@ JsonValue *json_new_array(void); JsonValue *json_new_object(void); void json_array_push(JsonValue *arr, JsonValue *item); -/* 在 i 处插入(i 可等于 count,即追加);取得所有权 */ +/* 在 i 处插入 (i 可等于 count, 即追加); 取得所有权 */ void json_array_insert(JsonValue *arr, size_t i, JsonValue *item); -/* 删掉第 i 条并释放它。越界不动 */ +/* 删掉第 i 条并释放它. 越界不动 */ void json_array_remove(JsonValue *arr, size_t i); -/* 把 src 的所有元素搬到 dst 末尾(偷所有权),src 变空。 - * 用于"我攒了一批块,现在要把它们并进另一个数组"这种场景。 */ +/* 把 src 的所有元素搬到 dst 末尾 (偷所有权), src 变空. + * 用于"我攒了一批块, 现在要把它们并进另一个数组"这种场景. */ void json_array_steal(JsonValue *dst, JsonValue *src); void json_object_set(JsonValue *obj, const char *key, JsonValue *value); void json_object_set_string(JsonValue *obj, const char *key, const char *value); @@ -88,7 +88,7 @@ void json_object_remove(JsonValue *obj, const char *key); /* ---- 序列化:返回 malloc 的字符串 ---- */ char *json_dump(const JsonValue *v); /* 紧凑格式 */ char *json_dump_pretty(const JsonValue *v); /* 2 空格缩进 */ -/* 把任意 C 字符串转义成 JSON 字符串字面量(含首尾引号) */ +/* 把任意 C 字符串转义成 JSON 字符串字面量 (含首尾引号) */ char *json_quote_string(const char *s); /* 深拷贝 */ diff --git a/src/lineedit.c b/src/lineedit.c index 238dee4..5d3be99 100644 --- a/src/lineedit.c +++ b/src/lineedit.c @@ -15,7 +15,7 @@ #include /* ------------------------------------------------------------------ */ -/* 缓冲区操作(纯逻辑,可单测) */ +/* 缓冲区操作 (纯逻辑, 可单测) */ /* ------------------------------------------------------------------ */ void linebuf_init(LineBuf *lb) @@ -97,9 +97,9 @@ void linebuf_right(LineBuf *lb) lb->pos = utf8_next(lb->buf, lb->len, lb->pos); } -/* Home / End 走的是"逻辑行"(两个换行之间),不是整个缓冲区。 - * 粘贴进来一段多行文本后,按 Home 应该回到当前那一行的行首, - * 而不是跳到整块的最开头 —— 后者是加多行支持时留下的毛病。 */ +/* Home / End 走的是"逻辑行" (两个换行之间), 不是整个缓冲区. + * 粘贴进来一段多行文本后, 按 Home 应该回到当前那一行的行首, + * 而不是跳到整块的最开头 —— 后者是加多行支持时留下的毛病. */ void linebuf_home(LineBuf *lb) { size_t i = lb->pos; @@ -149,7 +149,7 @@ void linebuf_kill_word(LineBuf *lb) static char **g_hist; static size_t g_hist_len, g_hist_cap; -/* 直接接管一条已分配好的字符串(加载文件时用,不做去重) */ +/* 直接接管一条已分配好的字符串 (加载文件时用, 不做去重) */ static void hist_push_owned(char *line) { if (g_hist_len == g_hist_cap) { @@ -187,8 +187,8 @@ const char *line_history_get(size_t i) /* 输入历史持久化 */ /* ------------------------------------------------------------------ */ -/* 一条历史在文件里占一行。用户输入可能含换行(行尾反斜杠续行), - * 也可能含反斜杠,所以这两个字符必须转义,否则读回来就变了样。 */ +/* 一条历史在文件里占一行. 用户输入可能含换行 (行尾反斜杠续行), + * 也可能含反斜杠, 所以这两个字符必须转义, 否则读回来就变了样. */ static void hist_encode(const char *s, StrBuf *out) { for (const unsigned char *p = (const unsigned char *)s; *p; p++) { @@ -198,8 +198,8 @@ static void hist_encode(const char *s, StrBuf *out) } } -/* 转义的反向。遇到不认识的组合就原样保留,宁可展示得怪一点, - * 也不要把用户的东西吃掉。 */ +/* 转义的反向. 遇到不认识的组合就原样保留, 宁可展示得怪一点, + * 也不要把用户的东西吃掉. */ static char *hist_decode(const char *s) { StrBuf out; @@ -215,8 +215,8 @@ static char *hist_decode(const char *s) return sb_detach(&out); } -/* 把内存里最新的 n 条重写回文件(先写临时文件再 rename, - * 免得写到一半被杀掉就把历史弄没了)。 */ +/* 把内存里最新的 n 条重写回文件 (先写临时文件再 rename, + * 免得写到一半被杀掉就把历史弄没了). */ static void hist_rewrite(const char *path, size_t n) { if (g_hist_len < n) n = g_hist_len; @@ -237,7 +237,7 @@ static void hist_rewrite(const char *path, size_t n) } if (fclose(f) != 0) ok = 0; if (ok) { - chmod(tmp, 0600); /* 历史里可能有密钥,别给同机其他用户看 */ + chmod(tmp, 0600); /* 历史里可能有密钥, 别给同机其他用户看 */ if (rename(tmp, path) != 0) unlink(tmp); } else { unlink(tmp); @@ -252,8 +252,8 @@ int line_history_load(const char *path, size_t keep) if (!f) return errno == ENOENT ? 0 : -1; if (keep == 0) keep = 1; - /* 只留最新的 keep 条,所以用环形缓冲边读边丢 —— 再大的历史文件 - * 也只占 keep 条的内存。 */ + /* 只留最新的 keep 条, 所以用环形缓冲边读边丢 —— 再大的历史文件 + * 也只占 keep 条的内存. */ char **ring = xcalloc(keep, sizeof(char *)); size_t total = 0; char *line = NULL; @@ -282,7 +282,7 @@ int line_history_load(const char *path, size_t keep) for (size_t i = 0; i < keep; i++) free(ring[i]); free(ring); - /* 文件比上限长就地截短。不这么做的话它会一直长下去。 */ + /* 文件比上限长就地截短. 不这么做的话它会一直长下去. */ if (rc == 0 && total > keep) hist_rewrite(path, keep); return rc; } @@ -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"); @@ -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; @@ -369,13 +369,13 @@ static int classify(unsigned char c) case 0x15: return K_KILL_BOL; case 0x17: return K_KILL_WORD; case 0x7f: case 0x08: return K_BACKSPACE; - default: return K_IGNORE; /* 其它控制键忽略,避免污染缓冲区 */ + default: return K_IGNORE; /* 其它控制键忽略, 避免污染缓冲区 */ } } -/* 读一个按键。 - * 普通可打印字节通过 *out_ch 返回并返回 K_CHAR; - * 其余返回功能键码。转义序列若在 40ms 内没有后续字节,按"单独按 ESC"处理。 */ +/* 读一个按键. + * 普通可打印字节通过 *out_ch 返回并返回 K_CHAR; + * 其余返回功能键码. 转义序列若在 40ms 内没有后续字节, 按"单独按 ESC"处理. */ static int read_key(unsigned char *out_ch) { unsigned char c; @@ -439,12 +439,12 @@ static int read_key(unsigned char *out_ch) typedef struct { const char *prompt; LineBuf *lb; - int drawn_row; /* 光标当前位于输入区的第几行,用于回到起点 */ + int drawn_row; /* 光标当前位于输入区的第几行, 用于回到起点 */ } Editor; -/* 跳过一段 ANSI 转义序列,返回跨过的字节数(调用时保证 s[0] == ESC)。 - * 提示符带颜色时这些字节一个格子都不占,必须跳过,否则光标位置会整体偏移。 - * 提示符是我们自己拼的、很短,所以这里只认 CSI / OSC 两种最常见的形态。 */ +/* 跳过一段 ANSI 转义序列, 返回跨过的字节数 (调用时保证 s[0] == ESC). + * 提示符带颜色时这些字节一个格子都不占, 必须跳过, 否则光标位置会整体偏移. + * 提示符是我们自己拼的、很短, 所以这里只认 CSI / OSC 两种最常见的形态. */ static size_t ansi_skip(const char *s, size_t n) { size_t i = 1; @@ -466,8 +466,8 @@ static size_t ansi_skip(const char *s, size_t n) return i; /* 光秃秃的 ESC 按零宽处理 */ } -/* 从给定起点"走"过 s[0..n),推进 (row, col)。 - * 显式换行与自动折行都算。col 允许到达 cols,表示"下一个字符会换行"。 */ +/* 从给定起点"走"过 s[0..n), 推进 (row, col). + * 显式换行与自动折行都算. col 允许到达 cols, 表示"下一个字符会换行". */ static void advance_pos(const char *s, size_t n, int cols, int *row, int *col) { for (size_t i = 0; i < n; ) { @@ -485,8 +485,8 @@ 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) { @@ -498,7 +498,7 @@ static void end_pos(const char *prompt, const char *buf, size_t len, if (*col < 0) *col = 0; } -/* 光标应当停在哪里(下一个字符会被写到的位置) */ +/* 光标应当停在哪里 (下一个字符会被写到的位置) */ static void want_pos(const char *prompt, const char *buf, size_t pos, int cols, int *row, int *col) { @@ -509,9 +509,9 @@ static void want_pos(const char *prompt, const char *buf, size_t pos, if (*col >= cols) { (*row)++; *col = 0; } } -/* 输入行整行着色:提示符和你敲进去的字都是绿的,和模型的输出区分开。 - * 颜色由这里统一加,调用方传进来的 prompt 保持纯文本 —— 否则提示符自己 - * 带一次 reset,后面输入的正文就掉回默认色了。 */ +/* 输入行整行着色:提示符和你敲进去的字都是绿的, 和模型的输出区分开. + * 颜色由这里统一加, 调用方传进来的 prompt 保持纯文本 —— 否则提示符自己 + * 带一次 reset, 后面输入的正文就掉回默认色了. */ #define C_INPUT "\033[32m" #define C_INPUT_OFF "\033[0m" @@ -528,8 +528,8 @@ void line_edit_set_ink(const char *ansi) g_input_ink = ansi ? ansi : C_INPUT; } -/* 重绘整个输入区并把光标放回正确位置。 - * 缓冲区可能包含换行(粘贴多行),也可能超出终端宽度自动折行。 */ +/* 重绘整个输入区并把光标放回正确位置. + * 缓冲区可能包含换行 (粘贴多行), 也可能超出终端宽度自动折行. */ static void refresh(Editor *e) { int cols = term_cols(); @@ -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); @@ -558,20 +558,20 @@ static void refresh(Editor *e) e->drawn_row = trow; } -/* 把粘贴进来的内容清洗成"编辑器能正确渲染"的文本。 +/* 把粘贴进来的内容清洗成"编辑器能正确渲染"的文本. * - * 为什么必须清洗:read_paste 收下的是终端给的原始字节,而缓冲区会被 - * 逐字节回显到终端上。里面只要有控制字符,两边的算盘就对不上: - * - \t 终端按制表位展开(下一个 8 的倍数),编辑器却只当 1 列, - * 粘一段带缩进的代码,光标就从此偏掉,后面每敲一个字都在错位置上; - * - ESC 会被终端当成转义序列真的执行掉 —— 粘贴内容能改颜色、清屏; - * - \r 把终端光标拉回行首,屏幕当场错位; - * - NUL 会把 C 字符串截断。 - * 处理办法:\n 保留(多行粘贴整段提交靠它),\t 就地展开成空格(这样 - * 缓冲区里不留制表符,宽度怎么算都对),ESC 连同后面整条序列一起丢掉, - * 其余控制字符丢弃。 + * 为什么必须清洗:read_paste 收下的是终端给的原始字节, 而缓冲区会被 + * 逐字节回显到终端上. 里面只要有控制字符, 两边的算盘就对不上: + * - \t 终端按制表位展开 (下一个 8 的倍数), 编辑器却只当 1 列, + * 粘一段带缩进的代码, 光标就从此偏掉, 后面每敲一个字都在错位置上; + * - ESC 会被终端当成转义序列真的执行掉 —— 粘贴内容能改颜色、清屏; + * - \r 把终端光标拉回行首, 屏幕当场错位; + * - NUL 会把 C 字符串截断. + * 处理办法:\n 保留 (多行粘贴整段提交靠它), \t 就地展开成空格 (这样 + * 缓冲区里不留制表符, 宽度怎么算都对), ESC 连同后面整条序列一起丢掉, + * 其余控制字符丢弃. * - * start_col 是插入点在屏幕上的列号 —— 制表位要从它开始算,而不是从 0。 */ + * start_col 是插入点在屏幕上的列号 —— 制表位要从它开始算, 而不是从 0. */ static char *sanitize_paste(const char *s, int start_col, int cols) { StrBuf out; @@ -603,8 +603,8 @@ static char *sanitize_paste(const char *s, int start_col, int cols) return sb_detach(&out); } -/* 读取一段 bracketed paste 内容,直到结束标记 \x1b[201~。 - * 返回的文本可能含换行 —— 这正是粘贴多行不再被逐行执行的关键。 */ +/* 读取一段 bracketed paste 内容, 直到结束标记 \x1b[201~. + * 返回的文本可能含换行 —— 这正是粘贴多行不再被逐行执行的关键. */ static char *read_paste(void) { static const char END[] = "\x1b[201~"; @@ -622,10 +622,10 @@ static char *read_paste(void) break; /* 中断或读到 EOF */ } if (c == (unsigned char)END[matched]) { - if (++matched == endlen) break; /* 正常结束,标记本身不入缓冲区 */ + if (++matched == endlen) break; /* 正常结束, 标记本身不入缓冲区 */ continue; } - if (matched > 0) { /* 之前只是部分匹配,吐回去 */ + if (matched > 0) { /* 之前只是部分匹配, 吐回去 */ sb_append(&sb, END, matched); matched = 0; } @@ -636,8 +636,8 @@ static char *read_paste(void) return sb_detach(&sb); } -/* 进入/退出终端状态。bracketed paste 必须成对开关, - * 否则会把用户的终端留在开启状态。 */ +/* 进入/退出终端状态. bracketed paste 必须成对开关, + * 否则会把用户的终端留在开启状态. */ static void tty_enter(const struct termios *saved) { (void)saved; @@ -656,7 +656,7 @@ char *line_edit(const char *prompt, int *interrupted) { if (interrupted) *interrupted = 0; - /* 非终端(管道 / 重定向)退化为普通的按行读取 */ + /* 非终端 (管道 / 重定向)退化为普通的按行读取 */ if (!isatty(STDIN_FILENO) || !isatty(STDOUT_FILENO)) { if (g_input_color) fputs(g_input_ink, stdout); fputs(prompt, stdout); @@ -705,8 +705,8 @@ char *line_edit(const char *prompt, int *interrupted) switch (key) { case K_CHAR: linebuf_insert(&lb, (const char *)&ch, 1); break; case K_PASTE_START: { - /* 整段插入,缓冲区里允许出现换行 —— 提交时作为一个任务发出去。 - * 插之前先算出插入点在第几列,制表符展开要用它。 */ + /* 整段插入, 缓冲区里允许出现换行 —— 提交时作为一个任务发出去. + * 插之前先算出插入点在第几列, 制表符展开要用它. */ char *pasted = read_paste(); int pcols = term_cols(); if (pcols < 2) pcols = 80; @@ -718,7 +718,7 @@ char *line_edit(const char *prompt, int *interrupted) free(pasted); break; } - case K_PASTE_END: break; /* 单独出现的结束标记,忽略 */ + case K_PASTE_END: break; /* 单独出现的结束标记, 忽略 */ case K_ENTER: stop = 1; break; case K_EOF: if (lb.len == 0) { at_eof = 1; stop = 1; } diff --git a/src/lineedit.h b/src/lineedit.h index a9dd8e9..cbd3c13 100644 --- a/src/lineedit.h +++ b/src/lineedit.h @@ -1,17 +1,17 @@ /* lineedit.h - REPL 的行编辑器 * - * 为什么需要它:原来的 REPL 用 fgets 直接读,终端停在规范模式, - * 方向键发出的转义序列(\x1b[D 之类)会被当成普通字节读进来, - * 显示成 ^[[D 而不是移动光标。要有行内编辑就必须自己进 raw 模式接管按键。 + * 为什么需要它:原来的 REPL 用 fgets 直接读, 终端停在规范模式, + * 方向键发出的转义序列 (\x1b[D 之类)会被当成普通字节读进来, + * 显示成 ^[[D 而不是移动光标. 要有行内编辑就必须自己进 raw 模式接管按键. * - * 缓冲区与编辑操作是纯逻辑,可以脱离终端单测;只有渲染与按键读取需要 tty。 + * 缓冲区与编辑操作是纯逻辑, 可以脱离终端单测; 只有渲染与按键读取需要 tty. */ #ifndef LIGENT_LINEEDIT_H #define LIGENT_LINEEDIT_H #include -/* 可编辑缓冲区。pos 是字节偏移,且始终落在字符边界上 */ +/* 可编辑缓冲区. pos 是字节偏移, 且始终落在字符边界上 */ typedef struct { char *buf; size_t len; @@ -22,9 +22,9 @@ typedef struct { void linebuf_init(LineBuf *lb); void linebuf_free(LineBuf *lb); -/* 用 text 整体替换内容,光标移到末尾 */ +/* 用 text 整体替换内容, 光标移到末尾 */ void linebuf_set(LineBuf *lb, const char *text); -/* 在光标处插入 n 字节,光标随之后移 */ +/* 在光标处插入 n 字节, 光标随之后移 */ void linebuf_insert(LineBuf *lb, const char *text, size_t n); void linebuf_backspace(LineBuf *lb); /* 删除光标前一个字符 */ @@ -37,24 +37,24 @@ void linebuf_kill_to_end(LineBuf *lb); /* 删到行尾 */ void linebuf_kill_to_start(LineBuf *lb); /* 删到行首 */ void linebuf_kill_word(LineBuf *lb); /* 往回删一个词 */ -/* 输入行是否着色(整行:提示符 + 你敲进去的字)。 - * 传进来的 prompt 应当是纯文本 —— 颜色由编辑器统一加在两头。 */ +/* 输入行是否着色 (整行:提示符 + 你敲进去的字). + * 传进来的 prompt 应当是纯文本 —— 颜色由编辑器统一加在两头. */ void line_edit_set_color(int enabled); -/* 换输入行的墨色(ANSI 起手式,例如 "\033[35m")。NULL 恢复默认的绿色。 - * meta 模式靠它和主提示符区分开。 */ +/* 换输入行的墨色 (ANSI 起手式, 例如 "\033[35m"). NULL 恢复默认的绿色. + * meta 模式靠它和主提示符区分开. */ void line_edit_set_ink(const char *ansi); -/* 读入一行(不含换行符,malloc)。 - * Ctrl-C -> 返回 NULL,*interrupted = 1 - * Ctrl-D 空行 -> 返回 NULL,*interrupted = 0(终止输入) +/* 读入一行 (不含换行符, malloc). + * Ctrl-C -> 返回 NULL, *interrupted = 1 + * Ctrl-D 空行 -> 返回 NULL, *interrupted = 0 (终止输入) * stdin 非终端 -> 退化为按行读取 - * 返回的字符串可能为空串(用户直接回车),调用方负责 free。 */ + * 返回的字符串可能为空串 (用户直接回车), 调用方负责 free. */ char *line_edit(const char *prompt, int *interrupted); -/* 进程内的历史记录(上下方向键用) */ +/* 进程内的历史记录 (上下方向键用) */ -/* 追加一条。返回 1 表示真的记下了,0 表示被忽略(空行,或与上一条重复)。 */ +/* 追加一条. 返回 1 表示真的记下了, 0 表示被忽略 (空行, 或与上一条重复). */ int line_history_add(const char *line); void line_history_clear(void); size_t line_history_len(void); @@ -62,21 +62,21 @@ const char *line_history_get(size_t i); /* ---- 输入历史的持久化 ---- * - * 文件位置由调用方决定(ligent 用 <二进制目录>/ligent.history)。 - * 一条历史在文件里占一行,所以条目里的反斜杠和换行会被转义 - * (\ -> \\,换行 -> \n),保证读回来和写出去一模一样。不含反斜杠的 - * 普通命令在文件里与输入完全一致,肉眼可读。 + * 文件位置由调用方决定 (ligent 用 <二进制目录>/ligent.history). + * 一条历史在文件里占一行, 所以条目里的反斜杠和换行会被转义 + * (\ -> \\, 换行 -> \n), 保证读回来和写出去一模一样. 不含反斜杠的 + * 普通命令在文件里与输入完全一致, 肉眼可读. * - * 时间戳之类的元数据刻意不记:目的只是"上下键能翻到",不是审计。 */ + * 时间戳之类的元数据刻意不记:目的只是"上下键能翻到", 不是审计. */ -/* 从 path 读取历史,追加到当前历史之后。最多保留最新的 keep 条; - * 文件本身更长时会被就地截短(否则会无限增长)。 - * 文件不存在返回 0,读出错返回 -1。 */ +/* 从 path 读取历史, 追加到当前历史之后. 最多保留最新的 keep 条; + * 文件本身更长时会被就地截短 (否则会无限增长). + * 文件不存在返回 0, 读出错返回 -1. */ int line_history_load(const char *path, size_t keep); -/* 把一条输入实时追加到 path —— 每提交一条就写一次并立刻 flush, - * 因此另一个 ligent 实例马上就能翻到。line 为空时什么都不做。 - * 成功返回 0,失败返回 -1(失败不打断 REPL)。 */ +/* 把一条输入实时追加到 path —— 每提交一条就写一次并立刻 flush, + * 因此另一个 ligent 实例马上就能翻到. line 为空时什么都不做. + * 成功返回 0, 失败返回 -1 (失败不打断 REPL). */ int line_history_append(const char *path, const char *line); #endif /* LIGENT_LINEEDIT_H */ diff --git a/src/llm.c b/src/llm.c index 580f157..fe4eddd 100644 --- a/src/llm.c +++ b/src/llm.c @@ -34,9 +34,9 @@ JsonValue *llm_build_body(const LlmConfig *cfg, const JsonValue *messages, json_object_set_number(body, "temperature", cfg->temperature); if (cfg->max_tokens > 0) json_object_set_number(body, "max_tokens", (double)cfg->max_tokens); - /* 思考模式开关。AUTO 时整个字段都不发,让服务端用自己的默认值 - * (DeepSeek 默认是开启)。注意这些是请求体的顶层字段,不是 - * OpenAI SDK 里的 extra_body 包装。 */ + /* 思考模式开关. AUTO 时整个字段都不发, 让服务端用自己的默认值 + * (DeepSeek 默认是开启). 注意这些是请求体的顶层字段, 不是 + * OpenAI SDK 里的 extra_body 包装. */ if (cfg->thinking != THINKING_AUTO) { JsonValue *t = json_new_object(); json_object_set_string(t, "type", @@ -53,22 +53,22 @@ JsonValue *llm_build_body(const LlmConfig *cfg, const JsonValue *messages, return body; } -/* 单行 SSE 上限。正常的 data: 行只有几十字节; - * 出现超长"行"说明对端根本不是 SSE(例如忽略 stream 直接返回整包 JSON, - * 而 JSON 里没有换行),必须丢弃,否则内存会被撑爆。 */ +/* 单行 SSE 上限. 正常的 data: 行只有几十字节; + * 出现超长"行"说明对端根本不是 SSE (例如忽略 stream 直接返回整包 JSON, + * 而 JSON 里没有换行), 必须丢弃, 否则内存会被撑爆. */ #define SSE_MAX_LINE (256u * 1024) /* 单条回复累积内容上限 */ #define SSE_MAX_CONTENT (32u * 1024 * 1024) -/* 单个工具调用参数上限。正常人写不出 8MB 的参数, - * 超过基本可以断定是对端异常或恶意,必须截住 */ +/* 单个工具调用参数上限. 正常人写不出 8MB 的参数, + * 超过基本可以断定是对端异常或恶意, 必须截住 */ #define SSE_MAX_TOOL_ARGS (8u * 1024 * 1024) /* ------------------------------------------------------------------ */ /* 错误响应的可读化 */ /* ------------------------------------------------------------------ */ -/* 很多服务端把 message 写成 \uXXXX 转义,直接打印很难看。 - * 这里尝试解析出 error.message,失败则回退到原始文本。 */ +/* 很多服务端把 message 写成 \uXXXX 转义, 直接打印很难看. + * 这里尝试解析出 error.message, 失败则回退到原始文本. */ static char *extract_error_message(const char *body) { if (!body || !*body) return NULL; @@ -90,9 +90,9 @@ static char *extract_error_message(const char *body) return msg; } -/* 401/403 时补一句提示。服务端的原文经常把人引向错误的方向: - * OpenRouter 对"格式不对的 key"回的是 "Missing Authentication header", - * 听起来像是请求里没带认证头,实际上带了,只是 key 不属于这家。 */ +/* 401/403 时补一句提示. 服务端的原文经常把人引向错误的方向: + * OpenRouter 对"格式不对的 key"回的是 "Missing Authentication header", + * 听起来像是请求里没带认证头, 实际上带了, 只是 key 不属于这家. */ static void append_auth_hint(char *errbuf, size_t errlen, const LlmConfig *cfg, long status) { @@ -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", @@ -149,9 +149,9 @@ typedef struct { } ToolAcc; typedef struct { - StrBuf raw; /* 原始字节,用于“服务端不支持流式”时的回退 */ + StrBuf raw; /* 原始字节, 用于 "服务端不支持流式" 时的回退 */ StrBuf content; - StrBuf reasoning; /* reasoning_content 累积(思考过程,不显示) */ + StrBuf reasoning; /* reasoning_content 累积 (思考过程, 不显示) */ ToolAcc *accs; size_t acc_count; size_t acc_cap; @@ -204,7 +204,7 @@ static void sse_ctx_free(SseCtx *c) json_free(c->usage); } -/* 处理一个 SSE data 负载(不含 "data:" 前缀) */ +/* 处理一个 SSE data 负载 (不含 "data:" 前缀) */ static void handle_payload(SseCtx *c, const char *payload, size_t len) { if (len == 0) return; @@ -235,7 +235,7 @@ static void handle_payload(SseCtx *c, const char *payload, size_t len) const char *fr = json_get_string(ch0, "finish_reason"); if (fr && *fr && !c->finish_reason) c->finish_reason = xstrdup(fr); - /* OpenAI 流式用 delta,少数实现直接给 message */ + /* OpenAI 流式用 delta, 少数实现直接给 message */ JsonValue *d = json_object_get(ch0, "delta"); if (!d) d = json_object_get(ch0, "message"); if (!d || d->type != JSON_OBJECT) { json_free(root); return; } @@ -250,10 +250,10 @@ static void handle_payload(SseCtx *c, const char *payload, size_t len) } } - /* 思考过程。DeepSeek 用 reasoning_content,有些 OpenAI 兼容实现用 - * reasoning。只累积、不回调 —— 用户明确要求不显示,但它必须随 - * assistant 消息一起回传:请求带 tools 时服务端要求这么做, - * 漏了会在下一轮直接 400。 */ + /* 思考过程. DeepSeek 用 reasoning_content, 有些 OpenAI 兼容实现用 + * reasoning. 只累积、不回调 —— 用户明确要求不显示, 但它必须随 + * assistant 消息一起回传:请求带 tools 时服务端要求这么做, + * 漏了会在下一轮直接 400. */ const char *think = json_get_string(d, "reasoning_content"); if (!think) think = json_get_string(d, "reasoning"); if (think && *think) { @@ -261,8 +261,8 @@ static void handle_payload(SseCtx *c, const char *payload, size_t len) c->overflow = 1; } else { sb_puts(&c->reasoning, think); - /* 不往正文回调里塞 —— 思维链不该被渲染。单独走一路, - * 让上层知道"模型开始推理了"。 */ + /* 不往正文回调里塞 —— 思维链不该被渲染. 单独走一路, + * 让上层知道"模型开始推理了". */ if (c->on_think) c->on_think(c->think_ud, think); } } @@ -282,7 +282,7 @@ static void handle_payload(SseCtx *c, const char *payload, size_t len) if (name && *name) { if (!a->name) a->name = xstrdup(name); else if (strcmp(a->name, name) != 0) { - /* 有些服务端会拆着发名字,追加处理 */ + /* 有些服务端会拆着发名字, 追加处理 */ char *joined = xasprintf("%s%s", a->name, name); free(a->name); a->name = joined; @@ -301,7 +301,7 @@ static void handle_payload(SseCtx *c, const char *payload, size_t len) json_free(root); } -/* 按行切分,逐条处理 "data:" */ +/* 按行切分, 逐条处理 "data:" */ static void feed(SseCtx *c, const char *data, size_t len) { sb_append(&c->linebuf, data, len); @@ -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); } @@ -334,7 +334,7 @@ static size_t sse_write_cb(char *ptr, size_t size, size_t nmemb, void *ud) { SseCtx *c = ud; size_t n = size * nmemb; - if (c->overflow) return 0; /* 超限,中止传输 */ + if (c->overflow) return 0; /* 超限, 中止传输 */ if (c->raw.len < 65536) { /* 仅保留开头用于回退判断 */ size_t take = n < 65536 - c->raw.len ? n : 65536 - c->raw.len; @@ -388,9 +388,9 @@ static LlmResponse *from_sse(SseCtx *c) else json_object_set_null(r->message, "content"); - /* 思考过程跟着 assistant 消息一起进历史。用户看不到它,但它必须原样回传: - * 只要请求里带 tools,服务端就要求把历史里的 reasoning_content 全带上, - * 否则下一轮直接 400。 */ + /* 思考过程跟着 assistant 消息一起进历史. 用户看不到它, 但它必须原样回传: + * 只要请求里带 tools, 服务端就要求把历史里的 reasoning_content 全带上, + * 否则下一轮直接 400. */ if (c->reasoning.len > 0) json_object_set_string(r->message, "reasoning_content", c->reasoning.data ? c->reasoning.data : ""); @@ -489,9 +489,9 @@ LlmResponse *llm_chat(const LlmConfig *cfg, const JsonValue *messages, "content", i18n_num((long long)SSE_MAX_CONTENT), "args", i18n_num((long long)SSE_MAX_TOOL_ARGS), NULL)); } else if (rc != 0 && (ctx.content.len > 0 || ctx.got_event)) { - /* 传输断了,但已经收到并显示了部分内容。 - * 这些内容用户已经看到,丢掉会让上下文和屏幕对不上; - * 所以保留成一条"不完整"的消息,交给上层提示。 */ + /* 传输断了, 但已经收到并显示了部分内容. + * 这些内容用户已经看到, 丢掉会让上下文和屏幕对不上; + * 所以保留成一条"不完整"的消息, 交给上层提示. */ result = from_sse(&ctx); result->incomplete = 1; result->incomplete_reason = xstrdup(http_err); @@ -512,7 +512,7 @@ LlmResponse *llm_chat(const LlmConfig *cfg, const JsonValue *messages, } else if (ctx.got_event) { result = from_sse(&ctx); } else { - /* 服务端忽略了 stream 参数,直接返回整包 JSON */ + /* 服务端忽略了 stream 参数, 直接返回整包 JSON */ char *raw = xstrndup(ctx.raw.data ? ctx.raw.data : "", ctx.raw.len); JsonValue *root = json_parse_ui(raw, http_err, sizeof http_err); if (root) { diff --git a/src/llm.h b/src/llm.h index c5dd465..1b1f781 100644 --- a/src/llm.h +++ b/src/llm.h @@ -11,12 +11,12 @@ typedef struct { char *model; double temperature; /* < 0 表示不发送该字段 */ long max_tokens; /* <= 0 表示不发送 */ - int thinking; /* config.h 的 THINKING_*;AUTO 表示不发 thinking 字段 */ + int thinking; /* config.h 的 THINKING_*; AUTO 表示不发 thinking 字段 */ const char *reasoning_effort; /* NULL/空 表示不发送 */ int stream; /* 1 = SSE 流式 */ int verbose; - long timeout; /* 秒,<=0 用默认 */ - int retries; /* 瞬态失败的额外重试次数,0 = 不重试 */ + long timeout; /* 秒, <=0 用默认 */ + int retries; /* 瞬态失败的额外重试次数, 0 = 不重试 */ } LlmConfig; typedef struct { @@ -24,31 +24,31 @@ typedef struct { JsonValue *usage; /* 可能为 NULL */ char *finish_reason; /* 可能为 NULL */ int interrupted; - int incomplete; /* 传输中断,内容是部分的 */ + int incomplete; /* 传输中断, 内容是部分的 */ char *incomplete_reason; } LlmResponse; -/* 流式正文增量回调。只有 delta.content 会触发它 —— 正文是"给用户看的", - * 上层据此停止等待动画、开始渲染。 */ +/* 流式正文增量回调. 只有 delta.content 会触发它 —— 正文是"给用户看的", + * 上层据此停止等待动画、开始渲染. */ typedef void (*LlmDeltaFn)(void *ud, const char *text); -/* 流式思维链增量回调。只有 reasoning_content(或部分实现的 reasoning)会触发它。 - * 思维链本身不显示,这个回调的用途是告诉上层"模型真的开始推理了", - * 好把等待动画从"等待响应"切成"思考中"。 */ +/* 流式思维链增量回调. 只有 reasoning_content (或部分实现的 reasoning)会触发它. + * 思维链本身不显示, 这个回调的用途是告诉上层"模型真的开始推理了", + * 好把等待动画从"等待响应"切成"思考中". */ typedef void (*LlmThinkFn)(void *ud, const char *text); -/* 拼出 /chat/completions 的完整 URL(malloc) */ +/* 拼出 /chat/completions 的完整 URL (malloc) */ char *llm_endpoint_url(const char *base_url); -/* 构造请求体(上层负责释放) */ +/* 构造请求体 (上层负责释放) */ JsonValue *llm_build_body(const LlmConfig *cfg, const JsonValue *messages, const JsonValue *tools, int stream); -/* 发起一次对话请求。成功返回 LlmResponse(调用者用 llm_response_free 释放), - * 失败返回 NULL 并写 errbuf。 +/* 发起一次对话请求. 成功返回 LlmResponse (调用者用 llm_response_free 释放), + * 失败返回 NULL 并写 errbuf. * - * on_delta / ud 收正文增量;on_think / think_ud 收思维链增量。 - * 两者都可以传 NULL(非流式时本来就都是 NULL)。 */ + * on_delta / ud 收正文增量; on_think / think_ud 收思维链增量. + * 两者都可以传 NULL (非流式时本来就都是 NULL). */ LlmResponse *llm_chat(const LlmConfig *cfg, const JsonValue *messages, const JsonValue *tools, LlmDeltaFn on_delta, void *ud, diff --git a/src/main.c b/src/main.c index aac4369..9f7ddad 100644 --- a/src/main.c +++ b/src/main.c @@ -61,7 +61,7 @@ static void print_usage(FILE *out) static void apply_config(Agent *a, LigentConfig *c) { - a->llm.base_url = c->base_url; /* 有意借用,配置对象活得和进程一样久 */ + a->llm.base_url = c->base_url; /* 有意借用, 配置对象活得和进程一样久 */ a->llm.api_key = c->api_key; a->llm.model = c->model; a->llm.temperature = c->temperature; @@ -110,11 +110,11 @@ static void print_banner(const LigentConfig *c, const Agent *a) t("repl.banner.hint"), a->color ? "\033[0m" : ""); } -/* 存档目录跟二进制走,和配置同一个原则 */ +/* 存档目录跟二进制走, 和配置同一个原则 */ static const char *g_bindir; -/* 当前对话的谱系:最近一次写出或载入的存档名(不含 .json)。 - * 下次存档就在它后面接一级分支,于是文件名本身就记录了分支关系。 */ +/* 当前对话的谱系:最近一次写出或载入的存档名 (不含 .json). + * 下次存档就在它后面接一级分支, 于是文件名本身就记录了分支关系. */ static char *g_lineage; static void set_lineage(const char *name) @@ -123,7 +123,7 @@ static void set_lineage(const char *name) g_lineage = (name && *name) ? xstrdup(name) : NULL; } -/* /back 摘掉的消息,供 /unback 撤销;g_back_len 记录摘完之后的长度, +/* /back 摘掉的消息, 供 /unback 撤销; g_back_len 记录摘完之后的长度, * 用来判断"之后是不是又开了新对话" */ static JsonValue *g_back_undo; static size_t g_back_len; @@ -135,25 +135,25 @@ static void back_undo_clear(void) g_back_len = 0; } -/* 上次自动存档时的状态,用来避免重复存同一段对话 */ +/* 上次自动存档时的状态, 用来避免重复存同一段对话 */ static size_t g_saved_count; static time_t g_saved_start; static int g_auto_save_on; -/* 自动存档。只在"有内容"且"自上次存档后又变了"时才写, - * 所以退出、重置、导入前各调一次不会产生重复文件。 */ +/* 自动存档. 只在"有内容"且"自上次存档后又变了"时才写, + * 所以退出、重置、导入前各调一次不会产生重复文件. */ static void auto_save(Agent *a, LigentConfig *c, const char *why, const char *prefix) { if (!g_auto_save_on) return; size_t n = agent_history_len(a); - if (n <= 1) return; /* 只有 system,没内容 */ + if (n <= 1) return; /* 只有 system, 没内容 */ if (n == g_saved_count && a->started_at == g_saved_start) return; /* 内容没变 */ 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), @@ -164,10 +164,10 @@ static void auto_save(Agent *a, LigentConfig *c, const char *why, } g_saved_count = n; g_saved_start = a->started_at; - /* 临时存档只是"换掉当前对话前留一手",不该成为以后分支的祖先 */ + /* 临时存档只是"换掉当前对话前留一手", 不该成为以后分支的祖先 */ if (!prefix || !*prefix) { set_lineage(name); - if (meta) meta_reroot_clear(); /* 新根已经落名,标记用掉了 */ + if (meta) meta_reroot_clear(); /* 新根已经落名, 标记用掉了 */ } fprintf(stderr, "\033[2m%s\033[0m\n", tf("main.auto_saved", "why", why, "path", path, @@ -176,8 +176,8 @@ static void auto_save(Agent *a, LigentConfig *c, const char *why, free(name); } -/* 真正落地一次模型切换。cap 缓存是按 base_url+model 索引的,所以下次请求 - * 会自动重新探测新模型的能力,这里不用额外处理。 */ +/* 真正落地一次模型切换. cap 缓存是按 base_url+model 索引的, 所以下次请求 + * 会自动重新探测新模型的能力, 这里不用额外处理. */ static void apply_model(Agent *a, LigentConfig *c, const char *name) { if (strcmp(c->model, name) == 0) { @@ -190,16 +190,16 @@ 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 搅乱 - * (重定向、日志、粘贴都会跟着遭殃),二是**没法输入列表里没有的东西** —— - * 而"列表里没有"恰恰很常见:内部模型、灰度模型、服务端没列出来的别名。 - * 所以这里是 /import 那一套:列表打在上面,输入行里既可以填序号,也可以 - * 直接写模型名,回车取消。 + * (重定向、日志、粘贴都会跟着遭殃), 二是**没法输入列表里没有的东西** —— + * 而"列表里没有"恰恰很常见:内部模型、灰度模型、服务端没列出来的别名. + * 所以这里是 /import 那一套:列表打在上面, 输入行里既可以填序号, 也可以 + * 直接写模型名, 回车取消. * - * 拉不到列表就明说原因,并提示还能直接用 /model <名字> —— 不能因为网络不行 - * 或者服务端不列,就把这条路堵死。 */ + * 拉不到列表就明说原因, 并提示还能直接用 /model <名字> —— 不能因为网络不行 + * 或者服务端不列, 就把这条路堵死. */ static void cmd_model_pick(Agent *a, LigentConfig *c) { printf("%s", tf("repl.model.fetching", "url", c->base_url, NULL)); @@ -238,8 +238,8 @@ static void cmd_model_pick(Agent *a, LigentConfig *c) return; } - /* 纯数字按序号理解;其它一律当模型名 —— 这条正是"内部模型"的入口, - * 所以不去列表里校验它,服务端认不认由后端说了算。 */ + /* 纯数字按序号理解; 其它一律当模型名 —— 这条正是"内部模型"的入口, + * 所以不去列表里校验它, 服务端认不认由后端说了算. */ char *numend = NULL; long idx = strtol(ans, &numend, 10); if (numend != ans && *str_trim(numend) == '\0') { @@ -259,15 +259,15 @@ static void cmd_model_pick(Agent *a, LigentConfig *c) models_free(list, n); } -/* /include 遇到图片时才会被问到。这时候才去跑能力探测 —— 纯文本的附带 - * 不该为了"可能出现的图"先花掉两个探测请求。探测结论按 base_url+model - * 缓存,所以一个模型只会真探测一次。 */ +/* /include 遇到图片时才会被问到. 这时候才去跑能力探测 —— 纯文本的附带 + * 不该为了"可能出现的图"先花掉两个探测请求. 探测结论按 base_url+model + * 缓存, 所以一个模型只会真探测一次. */ static int include_can_image(void *ud) { Agent *a = ud; agent_ensure_caps(a); - /* 只有**明确不支持**才拦。未测(探测被关掉或结论不确定)按支持放行, - * 和别处一个原则:宁可让服务端说不行,也不错误地藏掉东西。 */ + /* 只有**明确不支持**才拦. 未测 (探测被关掉或结论不确定)按支持放行, + * 和别处一个原则:宁可让服务端说不行, 也不错误地藏掉东西. */ return a->caps.vision != CAP_NO; } @@ -305,12 +305,12 @@ static void cmd_include(Agent *a, const char *arg) printf("%s", tf("repl.include.none", "n", i18n_num((long long)skipped), NULL)); } - /* added == 0 且 skipped == 0 = 用户在交互式界面里取消了,不用多嘴 */ + /* added == 0 且 skipped == 0 = 用户在交互式界面里取消了, 不用多嘴 */ } static void cmd_export(Agent *a, LigentConfig *c) { - /* 只有 system 一条说明这段对话还没发生过什么,导出没有意义 */ + /* 只有 system 一条说明这段对话还没发生过什么, 导出没有意义 */ if (agent_history_len(a) <= 1) { puts(t("repl.export.empty")); return; @@ -334,12 +334,12 @@ static void cmd_export(Agent *a, LigentConfig *c) strlen(g_lineage) + 1 + ARCHIVE_BRANCH_LEN + strlen(".json") > ARCHIVE_NAME_MAX) printf("%s\n", t("repl.export.name_limit")); - /* 手动导出也算"已存档",否则退出时的自动存档会把同样的内容 - * 再分一级存一遍,白占一个文件 */ + /* 手动导出也算"已存档", 否则退出时的自动存档会把同样的内容 + * 再分一级存一遍, 白占一个文件 */ g_saved_count = agent_history_len(a); g_saved_start = a->started_at; set_lineage(name); - if (meta) meta_reroot_clear(); /* 新根已经落名,标记用掉了 */ + if (meta) meta_reroot_clear(); /* 新根已经落名, 标记用掉了 */ free(path); free(name); } @@ -361,10 +361,10 @@ static const ArchiveEntry *find_entry(const ArchiveEntry *list, size_t n, return hit; } -/* 载入存档:给了名字就直接载入,否则交互式选一份。 - * 只把历史装回去,不重放历史内容。 */ -/* 问一个是非题,(Y/n) 语义:直接回车算"是"。 - * 非终端时没人能回答,按默认值返回 1 —— 和 confirm_tool 一致。 */ +/* 载入存档:给了名字就直接载入, 否则交互式选一份. + * 只把历史装回去, 不重放历史内容. */ +/* 问一个是非题, (Y/n) 语义:直接回车算"是". + * 非终端时没人能回答, 按默认值返回 1 —— 和 confirm_tool 一致. */ static int ask_yes_no(const char *question) { if (!isatty(STDIN_FILENO)) return 1; @@ -382,14 +382,14 @@ static int ask_yes_no(const char *question) } } -/* /import 载入后用户可能想看看这段历史到底聊了什么。 +/* /import 载入后用户可能想看看这段历史到底聊了什么. * - * 这里是**完整**转储:不截断正文、不省略工具参数、不砍总量。既然用户 - * 明确回答了 Y,就该看到全部 —— 截一半反而会让人以为历史里就这么点东西。 - * 所以只做排版,不做取舍。 + * 这里是**完整**转储:不截断正文、不省略工具参数、不砍总量. 既然用户 + * 明确回答了 Y, 就该看到全部 —— 截一半反而会让人以为历史里就这么点东西. + * 所以只做排版, 不做取舍. * - * 唯一"少打印"的地方是没有 content 的消息(工具调用型的 assistant), - * 那种消息本来就没有正文,打出来的是它的 tool_calls。 */ + * 唯一"少打印"的地方是没有 content 的消息 (工具调用型的 assistant), + * 那种消息本来就没有正文, 打出来的是它的 tool_calls. */ static void print_history(const Agent *a, const JsonValue *msgs) { const char *dim = a->color ? "\033[2m" : ""; @@ -414,7 +414,7 @@ static void print_history(const Agent *a, const JsonValue *msgs) printf("\n%s-- %zu/%zu %s --%s\n", dim, i + 1, n, label, off); - /* tool 结果要标出它回应的是哪次调用,否则一堆工具输出看不出配对 */ + /* tool 结果要标出它回应的是哪次调用, 否则一堆工具输出看不出配对 */ if (strcmp(role, "tool") == 0) { const char *id = json_get_string(m, "tool_call_id"); printf("%s[tool_call_id=%s]%s\n", dim, id ? id : "", off); @@ -530,16 +530,16 @@ static void cmd_import(Agent *a, const char *arg) } char *loaded = xstrdup(pick->name); agent_replace_history(a, msgs); /* 取得所有权 */ - /* 载入之后再数:换成和 /history 一样的口径(不含 system), - * 否则"已载入 N 条"和"历史消息 M 条"会差 1,看着像出了错 */ + /* 载入之后再数:换成和 /history 一样的口径 (不含 system), + * 否则"已载入 N 条"和"历史消息 M 条"会差 1, 看着像出了错 */ size_t count = agent_conversation_len(a); - /* 输入历史不跟着清:它现在是一份持久的、跨会话的用户资源(像 bash 的 - * HISTFILE),记的是"我在 ligent> 敲过什么",和当前装的是哪段对话无关。 */ + /* 输入历史不跟着清:它现在是一份持久的、跨会话的用户资源 (像 bash 的 + * HISTFILE), 记的是"我在 ligent> 敲过什么", 和当前装的是哪段对话无关. */ set_lineage(loaded); /* 之后的分支都挂在它下面 */ printf("%s", tf("repl.import.done", "name", loaded, "n", i18n_num((long long)count), NULL)); - /* 问一句再决定要不要把这段历史打出来 —— 直接回车就输出(默认 Y), - * 历史很长、不想被刷屏时回 n 即可,条数随时可以用 /history 看。 */ + /* 问一句再决定要不要把这段历史打出来 —— 直接回车就输出 (默认 Y), + * 历史很长、不想被刷屏时回 n 即可, 条数随时可以用 /history 看. */ if (ask_yes_no(t("repl.import.print_ask"))) print_history(a, a->messages); free(loaded); @@ -551,11 +551,11 @@ static void repl_help(void) fputs(t("repl.help"), stdout); } -/* 带参数命令的匹配:/name 之后必须是空白或字符串结束。 +/* 带参数命令的匹配:/name 之后必须是空白或字符串结束. * * 只用前缀匹配是不够的 —— 那样 "/modelxxx" 会被当成 "/model" 并把 xxx - * 当作模型名去切模型,"/thinkfoo" 也会落进 /think 分支。命中返回参数起点 - * (已跳过空白;指进 dup 里,调用方不要 free),不命中返回 NULL。 */ + * 当作模型名去切模型, "/thinkfoo" 也会落进 /think 分支. 命中返回参数起点 + * (已跳过空白; 指进 dup 里, 调用方不要 free), 不命中返回 NULL. */ static char *cmd_arg(char *cmd, const char *name) { size_t n = strlen(name); @@ -565,12 +565,12 @@ static char *cmd_arg(char *cmd, const char *name) return str_trim(cmd + n); } -/* 返回 0 继续,1 退出 */ +/* 返回 0 继续, 1 退出 */ static int handle_command(Agent *a, LigentConfig *c, const char *line) { char *dup = xstrdup(line); char *cmd = str_trim(dup); - char *arg = NULL; /* 当前分支的参数;指向 dup 内部,不单独释放 */ + char *arg = NULL; /* 当前分支的参数; 指向 dup 内部, 不单独释放 */ if (strcmp(cmd, "/exit") == 0 || strcmp(cmd, "/quit") == 0) { free(dup); @@ -582,10 +582,10 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line) } else if (strcmp(cmd, "/reset") == 0 || strcmp(cmd, "/clear") == 0) { auto_save(a, c, t("repl.why.before_reset"), NULL); agent_reset(a); - /* 刚存的是"上一段"对话,和新开的这段没关系了,所以把判重状态清掉。 - * 不清的话会漏存:判重只看 (条数, started_at),而 time() 只有秒级 - * 精度 —— 同一秒里重置、又聊出同样多条消息,退出时就会被当成 - * "内容没变"而跳过存档。 */ + /* 刚存的是"上一段"对话, 和新开的这段没关系了, 所以把判重状态清掉. + * 不清的话会漏存:判重只看 (条数, started_at), 而 time() 只有秒级 + * 精度 —— 同一秒里重置、又聊出同样多条消息, 退出时就会被当成 + * "内容没变"而跳过存档. */ g_saved_count = 0; g_saved_start = 0; set_lineage(NULL); /* 新对话另起一个根 */ @@ -601,7 +601,7 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line) free(c->system); c->system = xstrdup(arg); agent_reset(a); - attach_clear(); /* 重新开始,暂存的附件也一并丢掉 */ + attach_clear(); /* 重新开始, 暂存的附件也一并丢掉 */ agent_add_system(a, c->system); puts(t("repl.system.updated")); } else { @@ -631,33 +631,33 @@ 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; g_saved_start = 0; } } else if (strcmp(cmd, "/cls") == 0) { - /* 只清屏幕,不碰对话历史 —— 和 /reset 是两回事。 - * 3J 连回滚缓冲一起清,跟 clear(1) 的行为对齐;不支持 3J 的终端会 - * 忽略它,2J + H 照样把可见区域清干净。 */ + /* 只清屏幕, 不碰对话历史 —— 和 /reset 是两回事. + * 3J 连回滚缓冲一起清, 跟 clear(1) 的行为对齐; 不支持 3J 的终端会 + * 忽略它, 2J + H 照样把可见区域清干净. */ if (isatty(STDOUT_FILENO)) fputs("\033[2J\033[3J\033[H", stdout); fflush(stdout); } else if (strcmp(cmd, "/tools") == 0) { if (a->use_tools) { - /* 只查缓存,绝不为了显示这一行去发探测请求 */ + /* 只查缓存, 绝不为了显示这一行去发探测请求 */ Capabilities caps; tools_print_list(cap_cached(&a->llm, &caps) && caps.vision == CAP_NO); } else puts(t("repl.tools.disabled")); } else if (strcmp(cmd, "/probe") == 0) { - /* 手动测一次模型能力(收不收图片 / 收不收 tools)。 - * 刻意绕开 --no-probe 与缓存 —— 理由见 agent_probe_now 的注释。 */ + /* 手动测一次模型能力 (收不收图片 / 收不收 tools). + * 刻意绕开 --no-probe 与缓存 —— 理由见 agent_probe_now 的注释. */ agent_probe_now(a); } else if (strcmp(cmd, "/config") == 0) { config_dump(c); @@ -701,7 +701,7 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line) for (size_t i = 0; i < rn; i++) json_array_push(g_back_undo, json_clone(json_array_get(removed, i))); json_free(removed); - g_back_len = agent_history_len(a); /* 比对用,保持数组真实长度 */ + g_back_len = agent_history_len(a); /* 比对用, 保持数组真实长度 */ printf("%s", tf("repl.back.done", "n", i18n_num((long long)rn), "left", i18n_num((long long)agent_conversation_len(a)), NULL)); @@ -723,7 +723,7 @@ 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; @@ -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", @@ -786,14 +786,14 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line) i18n_num((long long)n), "file", arg, NULL)); } } else if (strcmp(cmd, "/history") == 0) { - /* 一行摘要 + 完整对话。 + /* 一行摘要 + 完整对话. * - * 摘要那行报的是"不含 system"的口径(和 /usage、/back、/export 一致), - * 而下面列出来的是**含** system 的全部消息 —— 两个数不一样,所以必须 - * 说清楚,否则用户会以为打印漏了或者多了一条。 + * 摘要那行报的是"不含 system"的口径 (和 /usage、/back、/export 一致), + * 而下面列出来的是**含** system 的全部消息 —— 两个数不一样, 所以必须 + * 说清楚, 否则用户会以为打印漏了或者多了一条. * - * 为什么要连 system 一起列:那是"模型被要求怎么做"的原文,想核对提示词 - * 时只有这里能看到全文(/config 里那行会把多行提示词挤成一团)。 */ + * 为什么要连 system 一起列:那是"模型被要求怎么做"的原文, 想核对提示词 + * 时只有这里能看到全文 (/config 里那行会把多行提示词挤成一团). */ printf("%s", tf("repl.history.count", "n", i18n_num((long long)agent_conversation_len(a)), NULL)); printf("%s", tf("repl.history.full", "total", @@ -806,20 +806,20 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line) return 0; } -/* 输入历史最多保留多少条。和 bash 一样有上限,否则文件会一直长下去; - * 只保留最新的,旧的在下次启动时被就地截掉。 */ +/* 输入历史最多保留多少条. 和 bash 一样有上限, 否则文件会一直长下去; + * 只保留最新的, 旧的在下次启动时被就地截掉. */ #define LIGENT_HISTORY_KEEP 1000 -/* 输入历史文件;NULL 表示禁用(--no-history)。 */ +/* 输入历史文件; NULL 表示禁用 (--no-history). */ static char *g_hist_path = NULL; -/* 提示符保持纯文本:绿色由行编辑器统一加在整个输入行(提示符 + 你敲的字)上。 - * 在这里塞颜色的话,提示符自带的 reset 会让后面的输入掉回默认色。 */ +/* 提示符保持纯文本:绿色由行编辑器统一加在整个输入行 (提示符 + 你敲的字)上. + * 在这里塞颜色的话, 提示符自带的 reset 会让后面的输入掉回默认色. */ #define PROMPT_MAIN "ligent> " #define PROMPT_CONT " ... " -/* 读一条完整输入。支持行尾反斜杠续行:每段都由行编辑器独立编辑, - * 因此方向键、Home/End、历史在续行的每一段里都可用。 */ +/* 读一条完整输入. 支持行尾反斜杠续行:每段都由行编辑器独立编辑, + * 因此方向键、Home/End、历史在续行的每一段里都可用. */ static char *read_prompt_line(const char *prompt, int *interrupted) { StrBuf sb; @@ -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,8 +863,8 @@ 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'; if (line_history_add(sb.data)) @@ -881,11 +881,11 @@ static char *read_prompt_line(const char *prompt, int *interrupted) static int run_repl(Agent *a, LigentConfig *c) { g_bindir = c->config_dir; - /* 图片备份也要落在二进制旁边。这个函数一度**从来没有被调用过**, - * image_bindir() 于是退化成 ".",备份跟着当前工作目录跑 —— /cd 到别的 - * 目录再带图,备份就写到那儿去了,之后从别处发消息会因为找不到备份而 - * 把图降级掉。测试一直从二进制所在目录跑,"." 恰好等于正确值,所以 - * 一直没暴露。 */ + /* 图片备份也要落在二进制旁边. 这个函数一度**从来没有被调用过**, + * image_bindir() 于是退化成 ".", 备份跟着当前工作目录跑 —— /cd 到别的 + * 目录再带图, 备份就写到那儿去了, 之后从别处发消息会因为找不到备份而 + * 把图降级掉. 测试一直从二进制所在目录跑, "." 恰好等于正确值, 所以 + * 一直没暴露. */ image_set_bindir(c->config_dir); print_banner(c, a); for (;;) { @@ -930,9 +930,9 @@ int main(int argc, char **argv) const char *explicit_config = NULL; char err[512]; - /* 界面语言:--lang > LIGENT_LANG > LC_ALL/LC_MESSAGES/LANG > 英文。 - * 先粗扫一遍 argv,好让 --help 和 --lang 的先后顺序不影响结果 —— - * 解析循环一遇到 --help 就直接返回,那之后再写 --lang 就来不及了。 */ + /* 界面语言:--lang > LIGENT_LANG > LC_ALL/LC_MESSAGES/LANG > 英文. + * 先粗扫一遍 argv, 好让 --help 和 --lang 的先后顺序不影响结果 —— + * 解析循环一遇到 --help 就直接返回, 那之后再写 --lang 就来不及了. */ { int lang = -1; for (int i = 1; i < argc; i++) { @@ -1154,7 +1154,7 @@ int main(int argc, char **argv) return 0; } - /* 没有 key 时尽早报错,而不是等第一次请求失败 */ + /* 没有 key 时尽早报错, 而不是等第一次请求失败 */ if (!cfg.api_key || !*cfg.api_key) { fprintf(stderr, "%s", t("cli.err.no_api_key")); config_free(&cfg); @@ -1198,8 +1198,8 @@ 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"); if (cli.history_off) { @@ -1211,8 +1211,8 @@ int main(int argc, char **argv) } else { g_hist_path = xasprintf("%s/ligent.history", cfg.config_dir); } - /* 读不回来不是错误:第一次跑本来就没有这个文件,只读的目录也不该 - * 拦住 REPL。加载失败静默降级成"这次没有历史"。 */ + /* 读不回来不是错误:第一次跑本来就没有这个文件, 只读的目录也不该 + * 拦住 REPL. 加载失败静默降级成"这次没有历史". */ if (g_hist_path) line_history_load(g_hist_path, LIGENT_HISTORY_KEEP); } diff --git a/src/markdown.c b/src/markdown.c index acd6d2f..15ea9de 100644 --- a/src/markdown.c +++ b/src/markdown.c @@ -1,8 +1,8 @@ /* markdown.c - 流式 Markdown 渲染状态机 * - * 核心约束:SSE 的分片位置是任意的,一个标记(`**`、行首的 `#`、`[`) - * 完全可能被切成两片。因此遇到"可能是标记前缀"的尾部时不能立即输出, - * 必须把这段字节留在缓冲区里等下一个分片凑齐后再判定。 + * 核心约束:SSE 的分片位置是任意的, 一个标记 (`**`、行首的 `#`、`[`) + * 完全可能被切成两片. 因此遇到"可能是标记前缀"的尾部时不能立即输出, + * 必须把这段字节留在缓冲区里等下一个分片凑齐后再判定. */ #include "markdown.h" #include "util.h" @@ -11,21 +11,21 @@ #include #include -/* 样式定义(硬编码 SGR,不依赖 terminfo) +/* 样式定义 (硬编码 SGR, 不依赖 terminfo) * - * **只用 16 色范围内的码**:前景 30-37,加粗/暗淡/斜体用 1/2/3。 - * 不用 90-97 那一段(aixterm 的"亮色")—— 它虽然到处都是,但严格讲不属于 - * 最窄的 16 色 ANSI,而这段代码是硬编码 SGR、不做终端能力探测的(连 terminfo - * 都不查)。既然不探测,就该待在**所有终端都认**的那一档里。 + * **只用 16 色范围内的码**:前景 30-37, 加粗/暗淡/斜体用 1/2/3. + * 不用 90-97 那一段 (aixterm 的"亮色")—— 它虽然到处都是, 但严格讲不属于 + * 最窄的 16 色 ANSI, 而这段代码是硬编码 SGR、不做终端能力探测的 (连 terminfo + * 都不查). 既然不探测, 就该待在**所有终端都认**的那一档里. * - * 原来链接用的是亮蓝(aixterm 的 94),是这里唯一一个越界的码。改成 - * "\033[4;36m"(下划线 + 青):链接在终端里的传统表示本来就是下划线, - * 而且青色和行内代码、列表符号同色系,视觉上"这些是标记不是正文"是一致的。 - * 更重要的是下划线是**属性**(SGR 4),比颜色更可靠 —— 单色终端、色盲用户、 - * 以及把颜色重映射过的主题里,下划线都还在。 + * 原来链接用的是亮蓝 (aixterm 的 94), 是这里唯一一个越界的码. 改成 + * "\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" @@ -38,14 +38,14 @@ #define MD_H3 "\033[1;34m" /* 粗体蓝 */ #define MD_H4 "\033[1m" /* 粗体 */ -/* 等待更多数据(当前字节可能是一个被切断的标记) */ +/* 等待更多数据 (当前字节可能是一个被切断的标记) */ #define MD_NEED_MORE ((size_t)-1) -/* 待处理缓冲上限:遇到始终等不到结束标记的超长行时直接吐出,保证内存有界 */ +/* 待处理缓冲上限:遇到始终等不到结束标记的超长行时直接吐出, 保证内存有界 */ #define MD_MAX_PENDING (64 * 1024) -/* 候选表格行超过这个长度就不再当表格等下去,避免长行被卡住 */ +/* 候选表格行超过这个长度就不再当表格等下去, 避免长行被卡住 */ #define MD_MAX_TABLE_LINE 4096 -/* 表格累积多少行后强制渲染(防止超大表格一直不输出) */ +/* 表格累积多少行后强制渲染 (防止超大表格一直不输出) */ #define MD_MAX_TABLE_ROWS 200 struct MdRenderer { @@ -53,9 +53,9 @@ struct MdRenderer { FILE *out; StrBuf in; /* 跨分片的残留数据 */ - int line_start; /* 下一个字符是否位于行首(块级语法只在行首生效) */ - int line_style; /* 本行处于行级样式(标题)中,换行时要复位 */ - int last_ch; /* 上一个已消费的输入字节,用于 _ 和 [ 的边界判断 */ + int line_start; /* 下一个字符是否位于行首 (块级语法只在行首生效) */ + int line_style; /* 本行处于行级样式 (标题)中, 换行时要复位 */ + int last_ch; /* 上一个已消费的输入字节, 用于 _ 和 [ 的边界判断 */ int in_fence; /* 位于围栏代码块内 */ char fence_char; @@ -71,9 +71,9 @@ struct MdRenderer { StrBuf table; /* 表格原始行 */ int table_rows; - /* 置位后表示"不会再有新数据",所有挂起都必须就地解开。 - * 这是"输出与分片方式无关"的关键:挂起只允许发生在还有后续数据时, - * 到 EOF 一律按固定规则判定,决策因此只依赖字节流本身。 */ + /* 置位后表示"不会再有新数据", 所有挂起都必须就地解开. + * 这是"输出与分片方式无关"的关键:挂起只允许发生在还有后续数据时, + * 到 EOF 一律按固定规则判定, 决策因此只依赖字节流本身. */ int eof; }; @@ -93,7 +93,7 @@ static void style(MdRenderer *r, const char *code) if (r->enabled) fputs(code, r->out); } -/* 行尾清掉所有行内/行级样式,避免染到下一行 */ +/* 行尾清掉所有行内/行级样式, 避免染到下一行 */ static void close_line_styles(MdRenderer *r) { if (r->in_bold || r->in_italic || r->in_code || @@ -106,7 +106,7 @@ static void close_line_styles(MdRenderer *r) } } -/* 显示宽度相关工具已移到 util.c(行编辑器也要用) */ +/* 显示宽度相关工具已移到 util.c (行编辑器也要用) */ /* ------------------------------------------------------------------ */ /* 表格:解析 */ @@ -127,7 +127,7 @@ static void row_free(TblRow *row) row->n = 0; } -/* 按 '|' 拆分一行,去掉首尾空白与首尾空单元格 */ +/* 按 '|' 拆分一行, 去掉首尾空白与首尾空单元格 */ static void row_split(const char *line, size_t n, TblRow *out) { out->cell = NULL; @@ -167,8 +167,8 @@ static int is_delim_cell(const char *c, size_t n) } /* 表头行 + 分隔行是否构成合法表格: - * 两者单元格数必须相同,且第二行每格都是 :?-+:? - * (这条校验同时挡掉了 "cat a | grep b" 跟着一行 "--------" 的误判) */ + * 两者单元格数必须相同, 且第二行每格都是 :?-+:? + * (这条校验同时挡掉了 "cat a | grep b" 跟着一行 "--------" 的误判) */ static int table_head_ok(const char *h, size_t hn, const char *d, size_t dn) { TblRow hrow, drow; @@ -265,7 +265,7 @@ static void table_flush(MdRenderer *r) } r->table.len = 0; - if (nrows < 2) { /* 不成表,原样吐出 */ + if (nrows < 2) { /* 不成表, 原样吐出 */ for (size_t i = 0; i < nrows; i++) { for (size_t c = 0; c < rows[i].n; c++) { if (c) putc_str(r, " | "); @@ -283,7 +283,7 @@ static void table_flush(MdRenderer *r) if (rows[i].n > ncols) ncols = rows[i].n; if (ncols == 0) { free(rows); return; } - /* 对齐方式来自分隔行(第 1 行) */ + /* 对齐方式来自分隔行 (第 1 行) */ TblAlign *al = xmalloc(ncols * sizeof(TblAlign)); for (size_t c = 0; c < ncols; c++) al[c] = ALIGN_LEFT; for (size_t c = 0; c < rows[1].n && c < ncols; c++) { @@ -295,7 +295,7 @@ static void table_flush(MdRenderer *r) else if (rr) al[c] = ALIGN_RIGHT; } - /* 列宽由表头与所有数据行决定(跳过第 1 行的分隔行) */ + /* 列宽由表头与所有数据行决定 (跳过第 1 行的分隔行) */ size_t *w = xcalloc(ncols, sizeof(size_t)); for (size_t i = 0; i < nrows; i++) { if (i == 1) continue; @@ -321,7 +321,7 @@ static void table_flush(MdRenderer *r) /* ------------------------------------------------------------------ */ /* 块级语法:只在行首调用 */ -/* 返回消耗的字节数;0 表示"不是块级语法,按普通字符处理"; +/* 返回消耗的字节数; 0 表示"不是块级语法, 按普通字符处理"; * MD_NEED_MORE 表示需要更多数据才能判定 */ /* ------------------------------------------------------------------ */ @@ -333,16 +333,16 @@ static size_t scan_line(const char *s, size_t len, size_t i, int *has_nl) return j - i; } -/* 规则表:从上往下依次尝试。 - * 关键约定:每条规则只有在"确实消费了字节"时才 return; - * 判定为"不适用"时必须继续往下走,绝不能中途 return 0 —— - * 否则会跳过后面所有规则(早期版本正是因此让表格探测吃掉了列表规则)。 */ +/* 规则表:从上往下依次尝试. + * 关键约定:每条规则只有在"确实消费了字节"时才 return; + * 判定为"不适用"时必须继续往下走, 绝不能中途 return 0 —— + * 否则会跳过后面所有规则 (早期版本正是因此让表格探测吃掉了列表规则). */ 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; @@ -353,7 +353,7 @@ static size_t try_block(MdRenderer *r, size_t i) size_t j = i + run; while (j < len && (s[j] == ' ' || s[j] == '\t')) j++; if (j >= len && !r->eof) return MD_NEED_MORE; - if (j < len && s[j] != '\n') return 0; /* 围栏后还有内容,不是闭合行 */ + if (j < len && s[j] != '\n') return 0; /* 围栏后还有内容, 不是闭合行 */ size_t emit = j - i; put(r, s + i, emit); @@ -379,8 +379,8 @@ 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] = { @@ -442,7 +442,7 @@ static size_t try_block(MdRenderer *r, size_t i) /* EOF 且只剩这一个字符:当字面量 */ } else { if (s[i + 1] == ' ') { - /* 保留模型输出的原始标记字符并上色,不替换成 • */ + /* 保留模型输出的原始标记字符并上色, 不替换成 • */ style(r, MD_BULLET); put(r, s + i, 1); style(r, MD_RESET); @@ -471,16 +471,16 @@ static size_t try_block(MdRenderer *r, size_t i) } } - /* ---- 表格起始:表头行 + 分隔行。放在最后,因为它需要看两行 ---- */ + /* ---- 表格起始:表头行 + 分隔行. 放在最后, 因为它需要看两行 ---- */ { int nl1; size_t ln1 = scan_line(s, len, i, &nl1); - /* 表格必须顶格以 '|' 开头。 + /* 表格必须顶格以 '|' 开头. * 这个判断在行的第 0 个字节就能做出 —— 这是关键:只要"是否挂起" - * 依赖行内更靠后的内容(例如"整行是否含竖线"),那么当分片边界 - * 落在竖线之前时,行的前半段已经被当成普通文本输出了,再也无法回头, - * 结果就会随分片方式而变。 */ + * 依赖行内更靠后的内容 (例如"整行是否含竖线"), 那么当分片边界 + * 落在竖线之前时, 行的前半段已经被当成普通文本输出了, 再也无法回头, + * 结果就会随分片方式而变. */ if (s[i] == '|') { if (!nl1 && !r->eof) { if (ln1 > MD_MAX_TABLE_LINE) return 0; @@ -488,7 +488,7 @@ static size_t try_block(MdRenderer *r, size_t i) } size_t j = i + ln1 + (nl1 ? 1 : 0); if (j >= len) { - if (r->eof) return 0; /* 没有第二行,必然不是表格 */ + if (r->eof) return 0; /* 没有第二行, 必然不是表格 */ return MD_NEED_MORE; } int nl2; @@ -518,8 +518,8 @@ static size_t try_block(MdRenderer *r, size_t i) static int is_word_char(int c) { - /* 只把 ASCII 词字符算作"词内",非 ASCII(中文等)不算。 - * 这样 "中文[链接](url)" 能高亮,而 "arr[i]" 不会。 */ + /* 只把 ASCII 词字符算作"词内", 非 ASCII (中文等)不算. + * 这样 "中文[链接](url)" 能高亮, 而 "arr[i]" 不会. */ return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'; } @@ -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,9 +583,9 @@ 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) { style(r, MD_RESET); @@ -616,12 +616,12 @@ 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; @@ -656,7 +656,7 @@ static void feed_enabled(MdRenderer *r, const char *text, size_t len) continue; } - /* ---- [链接文字]:蓝色高亮,其余部分(含 (url))原样保留 ---- */ + /* ---- [链接文字]:蓝色高亮, 其余部分 (含 (url))原样保留 ---- */ if (c == '[') { if (remain < 2) { if (!r->eof) break; /* 需要看后一个字符 */ @@ -709,14 +709,14 @@ static void feed_enabled(MdRenderer *r, const char *text, size_t len) if (i > 0) r->last_ch = (unsigned char)r->in.data[i - 1]; - /* 丢弃已处理的前缀,只保留等待更多数据的尾部 */ + /* 丢弃已处理的前缀, 只保留等待更多数据的尾部 */ if (i > 0) { memmove(r->in.data, r->in.data + i, r->in.len - i); r->in.len -= i; 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,8 +740,8 @@ 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); diff --git a/src/markdown.h b/src/markdown.h index 91d2669..4e3ec46 100644 --- a/src/markdown.h +++ b/src/markdown.h @@ -1,14 +1,14 @@ /* markdown.h - 面向终端输出的流式 Markdown 渲染器 * - * 设计目标:在"纯行式 REPL"的前提下把常见 Markdown 变成 ANSI 样式, - * 且必须能处理任意分片(SSE 的一小片文本可能把一个标记切成两半)。 + * 设计目标:在"纯行式 REPL"的前提下把常见 Markdown 变成 ANSI 样式, + * 且必须能处理任意分片 (SSE 的一小片文本可能把一个标记切成两半). * * 渲染: * 块级 # ~ ###### 标题、> 引用、- * + 无序列表、--- 分隔线、| 表格 - * 行内 **粗体**、*斜体* / _斜体_、`行内代码`、[链接文字](蓝色高亮) + * 行内 **粗体**、*斜体* / _斜体_、`行内代码`、[链接文字] (蓝色高亮) * - * 不渲染(原样保留,一个字节都不丢): - * 围栏代码块(含 ``` 标记本身)、图片 ![alt](src)、链接的 (url) 部分、 + * 不渲染 (原样保留, 一个字节都不丢): + * 围栏代码块 (含 ``` 标记本身)、图片 ![alt](src)、链接的 (url) 部分、 * 行内代码的内容 */ #ifndef LIGENT_MARKDOWN_H @@ -19,14 +19,14 @@ typedef struct MdRenderer MdRenderer; -/* enabled = 0 时完全透传,逐字节不改写内容(供管道 / --no-color 使用) */ +/* enabled = 0 时完全透传, 逐字节不改写内容 (供管道 / --no-color 使用) */ MdRenderer *md_new(int enabled); void md_set_enabled(MdRenderer *r, int enabled); -/* 输出目标,默认 stdout(测试时指向内存流) */ +/* 输出目标, 默认 stdout (测试时指向内存流) */ void md_set_out(MdRenderer *r, FILE *out); -/* 喂入一段文本(NUL 结尾)。内部保持状态,可被任意切分 */ +/* 喂入一段文本 (NUL 结尾). 内部保持状态, 可被任意切分 */ void md_feed(MdRenderer *r, const char *text); /* 一条消息结束:冲刷残留、闭合未闭合的样式 */ diff --git a/src/meta.c b/src/meta.c index 6acf29d..e1e5fe3 100644 --- a/src/meta.c +++ b/src/meta.c @@ -1,15 +1,15 @@ /* meta.c - /meta:用模型辅助编辑对话历史 * - * 结构上是一个"第二个 Agent":它有自己的 messages(meta 自己的会话)、 - * 自己的 tools、自己的 system 提示词,但共用主对话的 LLM 配置。 - * agent_run() 一行不改就能跑它 —— 工具分发已经从硬编码改成了函数指针。 + * 结构上是一个"第二个 Agent":它有自己的 messages (meta 自己的会话)、 + * 自己的 tools、自己的 system 提示词, 但共用主对话的 LLM 配置. + * agent_run() 一行不改就能跑它 —— 工具分发已经从硬编码改成了函数指针. * - * 三条硬约束(决定了下面所有工具的形状): - * 1. assistant 带 tool_calls 时,后面必须紧跟一一对应的 tool 消息; - * 2. system 必须在最前,且不归 meta 管(那是 /system 的事); - * 3. 改完之后,历史必须仍是一段合法的对话。 - * 所以"删单条消息"只对 user 和"不带 tool_calls 的 assistant"开放, - * 结构消息一律拒绝,让模型改用 delete_rounds。 + * 三条硬约束 (决定了下面所有工具的形状): + * 1. assistant 带 tool_calls 时, 后面必须紧跟一一对应的 tool 消息; + * 2. system 必须在最前, 且不归 meta 管 (那是 /system 的事); + * 3. 改完之后, 历史必须仍是一段合法的对话. + * 所以"删单条消息"只对 user 和"不带 tool_calls 的 assistant"开放, + * 结构消息一律拒绝, 让模型改用 delete_rounds. */ #include "meta.h" #include "i18n.h" @@ -24,42 +24,42 @@ #include #include -/* meta 模型的 system 提示词。措辞由用户逐字确定,不要"顺手润色"。 */ +/* meta 模型的 system 提示词. 措辞由用户逐字确定, 不要"顺手润色". */ static const char *META_SYSTEM_PROMPT = -"你是对话历史的元编辑器。你的工作对象是用户和一段已经存在的AI助手的对话记录。\n" -"你不是这段对话的参与者,不要把它当成自己的对话往下接。\n" +"你是对话历史的元编辑器. 你的工作对象是用户和一段已经存在的AI助手的对话记录. \n" +"你不是这段对话的参与者, 不要把它当成自己的对话往下接. \n" "\n" "## 为什么会有你\n" -"用户想改这段历史,通常是因为助手沿着一个他不满意的方向走下去了。他要的\n" -"往往不是“省 token”,而是**让这段历史不再诱导助手重蹈覆辙**——\n" -"删掉那句说错的前提、去掉那个被推翻的方案、删掉助手那次自信但错误的结论。\n" +"用户想改这段历史, 通常是因为助手沿着一个他不满意的方向走下去了. 他要的\n" +"往往不是 "省 token" , 而是**让这段历史不再诱导助手重蹈覆辙**——\n" +"删掉那句说错的前提、去掉那个被推翻的方案、删掉助手那次自信但错误的结论. \n" "另外 你拥有修改assistant的消息的能力, 这是常规的修改所不能完成的\n" "\n" "## 你的任务\n" "按用户的指示对这段历史做外科手术式的修改\n" "\n" "## 工作方式\n" -"1. 动手前先看清楚。先 list_rounds 看目录、再 read_messages 读原文。\n" -" 不要凭用户的转述猜内容。\n" -"2. 改最小必要范围。用户说“删掉那句用 Python”,就删那一句,\n" -" 不要顺手把整轮都删了。\n" -"3. 用户没要求压缩就不要压缩 —— 压缩会丢证据。\n" -"4. 每次改动后消息编号会重排。要继续改,先重新 list_rounds。\n" -"5. 改完汇报:改了什么、为什么、增减多少。不要复述历史。\n" +"1. 动手前先看清楚. 先 list_rounds 看目录、再 read_messages 读原文. \n" +" 不要凭用户的转述猜内容. \n" +"2. 改最小必要范围. 用户说 "删掉那句用 Python" , 就删那一句, \n" +" 不要顺手把整轮都删了. \n" +"3. 用户没要求压缩就不要压缩 —— 压缩会丢证据. \n" +"4. 每次改动后消息编号会重排. 要继续改, 先重新 list_rounds. \n" +"5. 改完汇报:改了什么、为什么、增减多少. 不要复述历史. \n" "\n" "## 红线\n" -"- 带 tool_calls 的 assistant 消息和紧随其后的 tool 结果是不可拆的整体, 否则会造成400错误。\n" -"- 不要为了“整齐”重排顺序。\n" +"- 带 tool_calls 的 assistant 消息和紧随其后的 tool 结果是不可拆的整体, 否则会造成400错误. \n" +"- 不要为了 "整齐" 重排顺序. \n" "\n" "## 拿不准就问\n" -"如果删掉某个点会让后面的回答失去上下文,把这个后果指出来,让用户决定。\n" -"不要猜。\n" +"如果删掉某个点会让后面的回答失去上下文, 把这个后果指出来, 让用户决定. \n" +"不要猜. \n" "\n" "你的修改应当也遵循原对话中助手的风格, 但你对用户的交互应当一如既往地专业可靠\n" "\n" -"用中文回复(除非用户用了别的语言)。"; +"用中文回复 (除非用户用了别的语言). "; -/* 快照栈:每次改动前压一份,供 /undo 和 undo_last_edit 回退 */ +/* 快照栈:每次改动前压一份, 供 /undo 和 undo_last_edit 回退 */ #define META_UNDO_MAX 10 /* 单条消息在目录/明细里的预览长度 */ @@ -67,14 +67,14 @@ static const char *META_SYSTEM_PROMPT = typedef struct { Agent *target; /* 被编辑的主对话 */ - JsonValue *snap[META_UNDO_MAX]; /* 快照栈(环形) */ + JsonValue *snap[META_UNDO_MAX]; /* 快照栈 (环形) */ size_t snap_n; /* 已压入总数 */ int edited; /* 本次会话是否成功改过 */ size_t ops; /* 成功改动的次数 */ } MetaCtx; -/* 工具分发签名里没有上下文参数,只能借文件级指针。 - * 同一时刻只允许一个 meta 会话(下面用 in_session 兜住)。 */ +/* 工具分发签名里没有上下文参数, 只能借文件级指针. + * 同一时刻只允许一个 meta 会话 (下面用 in_session 兜住). */ static MetaCtx *g_ctx; int meta_tools_attach(Agent *target) @@ -96,7 +96,7 @@ void meta_tools_detach(void) size_t meta_tools_ops(void) { return g_ctx ? g_ctx->ops : 0; } -/* "这段历史被 meta 动过"的标记。跨 meta 会话保留,直到下一次真正落盘。 */ +/* "这段历史被 meta 动过"的标记. 跨 meta 会话保留, 直到下一次真正落盘. */ static int g_edited_ever; int meta_reroot_pending(void) { return g_edited_ever; } @@ -143,10 +143,10 @@ int meta_rounds_build(const JsonValue *messages, MetaRounds *out) out->starts[0] = out->head; out->count = 1; - /* 轮起点必须用 agent_opens_round —— 和主循环(/back、请求前的结构体检) - * 同一个判据。以前这里是"任何 role == user"都算新的一轮,于是 read_image + /* 轮起点必须用 agent_opens_round —— 和主循环 (/back、请求前的结构体检) + * 同一个判据. 以前这里是"任何 role == user"都算新的一轮, 于是 read_image * 补的那条"只有图片附件"的 user 消息会被当成一轮的开头:/meta 报出的轮号 - * 和主循环的口径对不上,模型照着轮号删下去,删掉的也不是它以为的那一轮。 */ + * 和主循环的口径对不上, 模型照着轮号删下去, 删掉的也不是它以为的那一轮. */ for (size_t i = out->head + 1; i < n; i++) { if (!agent_opens_round(json_array_get(messages, i))) continue; if (out->count == cap) { @@ -165,7 +165,7 @@ void meta_rounds_free(MetaRounds *r) r->count = 0; } -/* 第 n 轮(1 起)的结束下标(不含)。越界返回 (size_t)-1 */ +/* 第 n 轮 (1 起)的结束下标 (不含). 越界返回 (size_t)-1 */ static size_t round_end(const MetaRounds *r, size_t n, size_t total) { if (n < 1 || n > r->count) return (size_t)-1; @@ -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; @@ -190,7 +190,7 @@ static void snap_push(MetaCtx *c) c->snap_n++; } -/* 回退一份快照。成功返回 1 */ +/* 回退一份快照. 成功返回 1 */ static int snap_pop(MetaCtx *c) { if (c->snap_n == 0) return 0; @@ -220,7 +220,7 @@ static char *preview_of(const char *s, size_t max) return sb_detach(&sb); } -/* 角色的中文短标签,给目录用 */ +/* 角色的中文短标签, 给目录用 */ static const char *role_short(const JsonValue *m) { const char *r = role_of(m); @@ -231,7 +231,7 @@ static const char *role_short(const JsonValue *m) return r; } -/* 一条消息的字节数(content + 工具参数),用于"省了多少"的汇报 */ +/* 一条消息的字节数 (content + 工具参数), 用于"省了多少"的汇报 */ static size_t msg_bytes(const JsonValue *m) { size_t n = 0; @@ -254,7 +254,7 @@ static size_t history_bytes(const JsonValue *messages) return n; } -/* 取一个整数参数,缺失或类型不对时把 *ok 置 0 */ +/* 取一个整数参数, 缺失或类型不对时把 *ok 置 0 */ static long arg_long(const JsonValue *args, const char *key, int *ok) { JsonValue *v = json_object_get(args, key); @@ -263,8 +263,8 @@ static long arg_long(const JsonValue *args, const char *key, int *ok) return (long)v->u.number; } -/* 把 (from,to) 两个参数解析成闭区间;缺省时用整段。 - * 返回 0 表示参数不合法。 */ +/* 把 (from,to) 两个参数解析成闭区间; 缺省时用整段. + * 返回 0 表示参数不合法. */ static int arg_range(const JsonValue *args, long lo, long hi, long *from, long *to) { @@ -300,19 +300,19 @@ static char *m_list_rounds(const JsonValue *args, int *is_error) MetaRounds r; if (!meta_rounds_build(msgs, &r)) { *is_error = 1; - return xstrdup("这段历史里没有可编辑的轮次(只有 system 消息?)。"); + return xstrdup("这段历史里没有可编辑的轮次 (只有 system 消息?). "); } long from, to; 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; sb_init(&sb); - char *head = xasprintf("共 %zu 轮,%zu 条消息,%zu 字节。\n" - "(system 不在其中,也不可编辑)\n\n", + char *head = xasprintf("共 %zu 轮, %zu 条消息, %zu 字节. \n" + " (system 不在其中, 也不可编辑)\n\n", r.count, total, history_bytes(msgs)); sb_puts(&sb, head); free(head); @@ -339,11 +339,11 @@ static char *m_read_messages(const JsonValue *args, int *is_error) MetaCtx *c = g_ctx; JsonValue *msgs = c->target->messages; size_t total = json_array_size(msgs); - if (total == 0) { *is_error = 1; return xstrdup("历史是空的。"); } + if (total == 0) { *is_error = 1; return xstrdup("历史是空的. "); } 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; @@ -352,7 +352,7 @@ static char *m_read_messages(const JsonValue *args, int *is_error) JsonValue *m = json_array_get(msgs, (size_t)i); const char *content = json_get_string(m, "content"); char *head = xasprintf("\n--- #%ld %s%s ---\n", i, role_short(m), - meta_deletable(msgs, (size_t)i) ? "" : " 〔结构消息,不可单删〕"); + meta_deletable(msgs, (size_t)i) ? "" : " 〔结构消息, 不可单删〕"); sb_puts(&sb, head); free(head); if (content && *content) { @@ -378,11 +378,11 @@ 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'; - sb_puts(&sb, "\n...(已截断,用更小的范围再读)\n"); + sb_puts(&sb, "\n... (已截断, 用更小的范围再读)\n"); } return sb_detach(&sb); } @@ -397,35 +397,35 @@ static char *m_replace_message(const JsonValue *args, int *is_error) const char *content = json_get_string(args, "content"); if (!iv || iv->type != JSON_NUMBER) { *is_error = 1; - return xstrdup("缺少 index(消息编号 #N)。"); + return xstrdup("缺少 index (消息编号 #N). "); } if (!content) { *is_error = 1; - return xstrdup("缺少 content。想清空就传空字符串。"); + return xstrdup("缺少 content. 想清空就传空字符串. "); } long i = (long)iv->u.number; if (i < 0 || (size_t)i >= total) { *is_error = 1; - return xasprintf("index #%ld 越界(0..%zu)。", i, total ? total - 1 : 0); + return xasprintf("index #%ld 越界 (0..%zu). ", i, total ? total - 1 : 0); } JsonValue *m = json_array_get(msgs, (size_t)i); const char *role = role_of(m); if (strcmp(role, "system") == 0) { *is_error = 1; - return xstrdup("system 提示词不归 meta 管,请用 /system 改。"); + return xstrdup("system 提示词不归 meta 管, 请用 /system 改. "); } if (strcmp(role, "tool") == 0) { *is_error = 1; - return xstrdup("这是 tool 结果,改它会让模型以为工具返回了别的东西。" - "如果内容有问题,应该改的是产生它的那次调用。"); + 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) @@ -437,10 +437,10 @@ static char *m_delete_messages(const JsonValue *args, int *is_error) JsonValue *arr = json_object_get(args, "indices"); if (!arr || arr->type != JSON_ARRAY || json_array_size(arr) == 0) { *is_error = 1; - return xstrdup("缺少 indices(消息编号数组,例如 [3,5])。"); + 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++) { @@ -448,27 +448,27 @@ static char *m_delete_messages(const JsonValue *args, int *is_error) if (!v || v->type != JSON_NUMBER) { free(idx); *is_error = 1; - return xstrdup("indices 里必须都是数字。"); + return xstrdup("indices 里必须都是数字. "); } long i = (long)v->u.number; if (i < 0 || (size_t)i >= total) { free(idx); *is_error = 1; - return xasprintf("indices 里有越界的编号 #%ld(0..%zu)。", i, + return xasprintf("indices 里有越界的编号 #%ld (0..%zu). ", i, total ? total - 1 : 0); } if (!meta_deletable(msgs, (size_t)i)) { free(idx); *is_error = 1; return xasprintf( - "#%ld 是%s,和它前后的消息是一个不可拆的整体(拆了下一轮请求会 400)。\n" - "要删它,请用 delete_rounds 删掉整轮。", + "#%ld 是%s, 和它前后的消息是一个不可拆的整体 (拆了下一轮请求会 400). \n" + "要删它, 请用 delete_rounds 删掉整轮. ", i, role_short(json_array_get(msgs, (size_t)i))); } 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,9 +489,9 @@ 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); } @@ -503,18 +503,18 @@ static char *m_delete_rounds(const JsonValue *args, int *is_error) MetaRounds r; if (!meta_rounds_build(msgs, &r)) { *is_error = 1; - return xstrdup("这段历史里没有可编辑的轮次。"); + return xstrdup("这段历史里没有可编辑的轮次. "); } long from, to; 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); *is_error = 1; - return xstrdup("这样会把整段历史删光。如果确实想清空,让用户敲 /reset。"); + return xstrdup("这样会把整段历史删光. 如果确实想清空, 让用户敲 /reset. "); } size_t s = r.starts[from - 1]; @@ -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); } @@ -544,18 +544,18 @@ static char *m_insert_message(const JsonValue *args, int *is_error) JsonValue *av = json_object_get(args, "after"); const char *role = json_get_string(args, "role"); const char *content = json_get_string(args, "content"); - if (!av || av->type != JSON_NUMBER) { *is_error = 1; return xstrdup("缺少 after(插到哪条之后)。"); } - if (!role || !*role) { *is_error = 1; return xstrdup("缺少 role。"); } - if (!content) { *is_error = 1; return xstrdup("缺少 content。"); } + if (!av || av->type != JSON_NUMBER) { *is_error = 1; return xstrdup("缺少 after (插到哪条之后). "); } + if (!role || !*role) { *is_error = 1; return xstrdup("缺少 role. "); } + if (!content) { *is_error = 1; return xstrdup("缺少 content. "); } if (strcmp(role, "assistant") != 0 && strcmp(role, "user") != 0) { *is_error = 1; - return xstrdup("role 只能是 user 或 assistant —— tool 消息必须配对 tool_calls," - "不能凭空插一条。"); + return xstrdup("role 只能是 user 或 assistant —— tool 消息必须配对 tool_calls, " + "不能凭空插一条. "); } long a = (long)av->u.number; if (a < -1 || (size_t)(a + 1) > total) { *is_error = 1; - return xasprintf("after #%ld 越界(-1 表示插到最前面,最大 %zu)。", a, total ? total - 1 : 0); + return xasprintf("after #%ld 越界 (-1 表示插到最前面, 最大 %zu). ", a, total ? total - 1 : 0); } snap_push(c); @@ -565,7 +565,7 @@ static char *m_insert_message(const JsonValue *args, int *is_error) size_t at = (size_t)(a + 1); json_array_insert(msgs, at, m); after_edit(c); - return xasprintf("已在 #%ld 之后插入一条 %s(%zu 字节)。", a, role, strlen(content)); + return xasprintf("已在 #%ld 之后插入一条 %s (%zu 字节). ", a, role, strlen(content)); } static char *m_undo(const JsonValue *args, int *is_error) @@ -574,10 +574,10 @@ static char *m_undo(const JsonValue *args, int *is_error) MetaCtx *c = g_ctx; if (!snap_pop(c)) { *is_error = 1; - return xstrdup("没有可撤销的改动了。"); + return xstrdup("没有可撤销的改动了. "); } - g_edited_ever = 1; /* 撤销本身也是"历史被动过",仍要另起一根 */ - return xstrdup("已撤销上一次改动。"); + g_edited_ever = 1; /* 撤销本身也是"历史被动过", 仍要另起一根 */ + return xstrdup("已撤销上一次改动. "); } /* ------------------------------------------------------------------ */ @@ -594,16 +594,16 @@ typedef struct { static const MetaTool TOOLS[] = { { "list_rounds", - "看这份对话的目录:每轮一行(轮号、消息编号范围、条数、字节数、开头预览)。" - "动手前先看它。", + "看这份对话的目录:每轮一行 (轮号、消息编号范围、条数、字节数、开头预览). " + "动手前先看它. ", "{\"type\":\"object\",\"properties\":{" - "\"from\":{\"type\":\"number\",\"description\":\"起始轮号(从 1 开始),默认 1\"}," - "\"to\":{\"type\":\"number\",\"description\":\"结束轮号,默认最后一轮\"}}}", + "\"from\":{\"type\":\"number\",\"description\":\"起始轮号 (从 1 开始), 默认 1\"}," + "\"to\":{\"type\":\"number\",\"description\":\"结束轮号, 默认最后一轮\"}}}", 0, m_list_rounds }, { "read_messages", - "读指定消息编号范围的完整内容(含工具调用参数)。编号是 #N,从 0 开始," - "和 list_rounds 显示的一致。", + "读指定消息编号范围的完整内容 (含工具调用参数). 编号是 #N, 从 0 开始, " + "和 list_rounds 显示的一致. ", "{\"type\":\"object\",\"properties\":{" "\"from\":{\"type\":\"number\",\"description\":\"起始消息编号 #N\"}," "\"to\":{\"type\":\"number\",\"description\":\"结束消息编号 #N\"}}," @@ -611,8 +611,8 @@ static const MetaTool TOOLS[] = { 0, m_read_messages }, { "replace_message", - "改写某一条消息的内容。只换文字,消息的角色和工具调用结构都不动。" - "这是唯一能修改 assistant 说过的话的办法。", + "改写某一条消息的内容. 只换文字, 消息的角色和工具调用结构都不动. " + "这是唯一能修改 assistant 说过的话的办法. ", "{\"type\":\"object\",\"properties\":{" "\"index\":{\"type\":\"number\",\"description\":\"消息编号 #N\"}," "\"content\":{\"type\":\"string\",\"description\":\"新的内容\"}}," @@ -620,9 +620,9 @@ static const MetaTool TOOLS[] = { 1, m_replace_message }, { "delete_messages", - "删掉若干条消息。只能删 user 和不带 tool_calls 的 assistant;" - "结构消息(带 tool_calls 的 assistant、tool 结果)会被拒绝," - "那种情况请用 delete_rounds。", + "删掉若干条消息. 只能删 user 和不带 tool_calls 的 assistant; " + "结构消息 (带 tool_calls 的 assistant、tool 结果)会被拒绝, " + "那种情况请用 delete_rounds. ", "{\"type\":\"object\",\"properties\":{" "\"indices\":{\"type\":\"array\",\"items\":{\"type\":\"number\"}," "\"description\":\"要删除的消息编号 #N 列表\"}}," @@ -630,25 +630,25 @@ static const MetaTool TOOLS[] = { 1, m_delete_messages }, { "delete_rounds", - "删掉连续的若干整轮(含轮内所有工具往返)。要清掉一次走不通的探索,用这个。", + "删掉连续的若干整轮 (含轮内所有工具往返). 要清掉一次走不通的探索, 用这个. ", "{\"type\":\"object\",\"properties\":{" - "\"from\":{\"type\":\"number\",\"description\":\"起始轮号(从 1 开始)\"}," + "\"from\":{\"type\":\"number\",\"description\":\"起始轮号 (从 1 开始)\"}," "\"to\":{\"type\":\"number\",\"description\":\"结束轮号\"}}," "\"required\":[\"from\",\"to\"]}", 1, m_delete_rounds }, { "insert_message", - "在某条消息之后插入一条新消息。用来补一句迟到的约束或澄清。" - "role 只能是 user 或 assistant。", + "在某条消息之后插入一条新消息. 用来补一句迟到的约束或澄清. " + "role 只能是 user 或 assistant. ", "{\"type\":\"object\",\"properties\":{" - "\"after\":{\"type\":\"number\",\"description\":\"插到这条 #N 之后;-1 表示插到最前面\"}," + "\"after\":{\"type\":\"number\",\"description\":\"插到这条 #N 之后; -1 表示插到最前面\"}," "\"role\":{\"type\":\"string\",\"enum\":[\"user\",\"assistant\"]}," "\"content\":{\"type\":\"string\"}}," "\"required\":[\"after\",\"role\",\"content\"]}", 1, m_insert_message }, { "undo_last_edit", - "撤销上一次改动。改错了就用它。", + "撤销上一次改动. 改错了就用它. ", "{\"type\":\"object\",\"properties\":{}}", 1, m_undo }, }; @@ -676,11 +676,11 @@ static JsonValue *meta_tools_schema(void) char *meta_tools_call(const char *name, const JsonValue *args, int *is_error) { *is_error = 0; - if (!g_ctx) { *is_error = 1; return xstrdup("meta 会话已经结束。"); } + if (!g_ctx) { *is_error = 1; return xstrdup("meta 会话已经结束. "); } 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,8 +690,8 @@ int meta_tools_mutating(const char *name) return 0; } -/* 让确认提示说人话:meta 工具的参数字段名和普通工具完全不同, - * 内置描述要么打不出来、要么会把整段消息内容刷屏。 */ +/* 让确认提示说人话:meta 工具的参数字段名和普通工具完全不同, + * 内置描述要么打不出来、要么会把整段消息内容刷屏. */ static char *meta_describe(const char *name, const JsonValue *args) { char *msg = NULL; @@ -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); @@ -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")); @@ -840,7 +840,7 @@ int meta_session(Agent *target, LigentConfig *cfg) line_edit_set_ink(NULL); /* 还回主提示符的绿色 */ confirm_set_describer(NULL); - agent_free(&m); /* m.tools 由它一起释放,别再 free 一次 */ + agent_free(&m); /* m.tools 由它一起释放, 别再 free 一次 */ meta_tools_detach(); printf("%s", t("meta.exit")); diff --git a/src/meta.h b/src/meta.h index 440cda6..2663939 100644 --- a/src/meta.h +++ b/src/meta.h @@ -1,11 +1,11 @@ /* meta.h - /meta:用模型辅助编辑对话历史 * - * 和主对话的区别:主模型是来"干活"的,meta 模型是来"改历史"的。 - * 它不是那场对话的参与者,而是拿用户指令去删改那段记录的编辑者。 + * 和主对话的区别:主模型是来"干活"的, meta 模型是来"改历史"的. + * 它不是那场对话的参与者, 而是拿用户指令去删改那段记录的编辑者. * * 两边工具集完全隔离:meta 会话的 Agent 用的是 meta_tools_schema() 和 - * meta_exec_tool(),主模型那边是 tools.c 那套。主模型就算瞎编一个 - * delete_rounds 也只会拿到"未知工具"。 + * meta_exec_tool(), 主模型那边是 tools.c 那套. 主模型就算瞎编一个 + * delete_rounds 也只会拿到"未知工具". */ #ifndef LIGENT_META_H #define LIGENT_META_H @@ -13,38 +13,38 @@ #include "agent.h" #include "config.h" -/* 被 meta 改过的对话要在下一次导出时另起一根(名字 <原名>_m<3位>、 - * parent 记空)。meta.c 只管打标记,具体命名由 main.c 的存档流程做。 */ +/* 被 meta 改过的对话要在下一次导出时另起一根 (名字 <原名>_m<3位>、 + * parent 记空). meta.c 只管打标记, 具体命名由 main.c 的存档流程做. */ int meta_reroot_pending(void); void meta_reroot_clear(void); -/* 进入 meta 模式。编辑作用在 target 上;meta 自己的会话不落地。 - * 返回 -1 = 没能进入;0 = 进来又退出、没改动;1 = 改过(调用方据此 - * 清掉 /back 撤销栈、让退出时必定重新存档)。 */ +/* 进入 meta 模式. 编辑作用在 target 上; meta 自己的会话不落地. + * 返回 -1 = 没能进入; 0 = 进来又退出、没改动; 1 = 改过 (调用方据此 + * 清掉 /back 撤销栈、让退出时必定重新存档). */ int meta_session(Agent *target, LigentConfig *cfg); -/* 供单测用:把一段历史按轮切开。返回 0 表示"没有可编辑的轮次" - * (空历史,或只有一条 system)。 +/* 供单测用:把一段历史按轮切开. 返回 0 表示"没有可编辑的轮次" + * (空历史, 或只有一条 system). * - * 轮的定义与主循环**共用同一个判据**(agent_opens_round):一轮从一条带文字的 - * user 消息开始,到下一条这样的消息之前;read_image 补的那条"只有图片附件"的 - * user 消息不算新的一轮。head 是第一条可编辑消息的下标(跳过 system)。 */ + * 轮的定义与主循环**共用同一个判据** (agent_opens_round):一轮从一条带文字的 + * user 消息开始, 到下一条这样的消息之前; read_image 补的那条"只有图片附件"的 + * user 消息不算新的一轮. head 是第一条可编辑消息的下标 (跳过 system). */ typedef struct { size_t *starts; /* 每轮第一条消息的全局下标 */ size_t count; - size_t head; /* 第一条可编辑消息的下标(跳过 system) */ + size_t head; /* 第一条可编辑消息的下标 (跳过 system) */ } MetaRounds; 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); /* ---- 工具分发 ---- - * meta_session 内部走的就是这几个;单测可以直接挂一段历史上来驱动它们, - * 不必起模型会话、不必联网。同一时刻只能挂一个。 */ -int meta_tools_attach(Agent *target); /* 成功 0,已在会话中返回 -1 */ + * meta_session 内部走的就是这几个; 单测可以直接挂一段历史上来驱动它们, + * 不必起模型会话、不必联网. 同一时刻只能挂一个. */ +int meta_tools_attach(Agent *target); /* 成功 0, 已在会话中返回 -1 */ void meta_tools_detach(void); char *meta_tools_call(const char *name, const JsonValue *args, int *is_err); int meta_tools_mutating(const char *name); diff --git a/src/models.c b/src/models.c index d12b67b..96a6e93 100644 --- a/src/models.c +++ b/src/models.c @@ -9,7 +9,7 @@ #include #include -#define MODELS_MAX 512 /* 再多就不是给人挑的了,多半是服务端出了什么问题 */ +#define MODELS_MAX 512 /* 再多就不是给人挑的了, 多半是服务端出了什么问题 */ static int already_have(char **list, size_t n, const char *id) { @@ -26,7 +26,7 @@ char **models_parse(const char *json, size_t *out_n) JsonValue *root = json_parse(json, NULL, 0); if (!root) return NULL; - /* 标准形状是 {"data": [...]};也见过直接回一个裸数组的兼容实现 */ + /* 标准形状是 {"data": [...]}; 也见过直接回一个裸数组的兼容实现 */ const JsonValue *arr = NULL; if (root->type == JSON_ARRAY) { arr = root; @@ -43,7 +43,7 @@ char **models_parse(const char *json, size_t *out_n) for (size_t i = 0; i < json_array_size(arr) && n < cap; i++) { const JsonValue *item = json_array_get(arr, i); - /* 元素可能是字符串本身,也可能是 {"id": "..."} */ + /* 元素可能是字符串本身, 也可能是 {"id": "..."} */ const char *id = NULL; if (item && item->type == JSON_STRING) id = json_string_value(item); else if (item) id = json_get_string(item, "id"); @@ -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; } @@ -104,7 +104,7 @@ char **models_fetch(const LigentConfig *cfg, size_t *out_n, char *body = NULL; long status = 0; char buf[512]; - /* 读列表是只读的,值得重试;用默认退避策略 */ + /* 读列表是只读的, 值得重试; 用默认退避策略 */ HttpRetry rt = HTTP_RETRY_DEFAULT; int rc = http_get(url, cfg->api_key, cfg->timeout, verbose, &rt, &body, &status, NULL, buf, sizeof buf); @@ -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 b10bf2d..3a30901 100644 --- a/src/models.h +++ b/src/models.h @@ -1,11 +1,11 @@ -/* models.h - 拉取并解析服务端的模型列表(GET {base_url}/models) +/* models.h - 拉取并解析服务端的模型列表 (GET {base_url}/models) * - * 只为了 /model 不带参数时的那个交互式选择列表。OpenAI 兼容的响应体长这样: + * 只为了 /model 不带参数时的那个交互式选择列表. OpenAI 兼容的响应体长这样: * * {"object": "list", "data": [{"id": "deepseek-chat", ...}, ...]} * - * 我们只取 id。别的字段(object / owned_by / created)对挑模型没有帮助, - * 各家还都不一样 —— 取了反而要跟着人家的变化改。 + * 我们只取 id. 别的字段 (object / owned_by / created)对挑模型没有帮助, + * 各家还都不一样 —— 取了反而要跟着人家的变化改. */ #ifndef LIGENT_MODELS_H #define LIGENT_MODELS_H @@ -14,20 +14,20 @@ #include -/* 从 /models 的响应体里取模型名。保序、去重,返回 malloc 的字符串数组 - * (元素也是 malloc),*out_n 回填个数。解析不出任何模型名时返回 NULL。 +/* 从 /models 的响应体里取模型名. 保序、去重, 返回 malloc 的字符串数组 + * (元素也是 malloc), *out_n 回填个数. 解析不出任何模型名时返回 NULL. * - * 兼容两种形状:{"data":[...]} 和裸数组 [...]。纯函数,可单测。 */ + * 兼容两种形状:{"data":[...]} 和裸数组 [...]. 纯函数, 可单测. */ char **models_parse(const char *json, size_t *out_n); /* 响应体是不是"格式没问题、但里面一个模型都没有"? * 用来把"服务端没给你任何模型"和"响应根本解析不了"分开报 —— - * 这两种情况的下一步动作完全不同,含糊地说成"拿不到列表"就是误导。 */ + * 这两种情况的下一步动作完全不同, 含糊地说成"拿不到列表"就是误导. */ int models_is_empty_list(const char *json); -/* 打一次网络:GET {base_url}/models,解析出模型名。 - * 成功返回数组(调用方用 models_free 释放),失败返回 NULL 并把原因写进 err。 - * verbose 会打印 HTTP 细节。 */ +/* 打一次网络:GET {base_url}/models, 解析出模型名. + * 成功返回数组 (调用方用 models_free 释放), 失败返回 NULL 并把原因写进 err. + * verbose 会打印 HTTP 细节. */ char **models_fetch(const LigentConfig *cfg, size_t *out_n, int verbose, char *err, size_t errlen); diff --git a/src/priv.c b/src/priv.c index 5ad9155..d9d07d5 100644 --- a/src/priv.c +++ b/src/priv.c @@ -23,11 +23,11 @@ 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 经手密码。 */ - /* execvp 的签名要 char *const[],但这里放进去的都是字面量和调用方的 - * 指针,**没有任何人会去改写它们**(execvp 本身也不会)。所以这里显式 - * 转换,而不是把整条链路都改成非 const —— 那会把 const 正确性一路丢掉。 */ + /* 刻意不带 -n:需要密码时就让 sudo 自己问. + * 也不带 -S:那要求把密码从 stdin 喂进去, 等于让 ligent 经手密码. */ + /* execvp 的签名要 char *const[], 但这里放进去的都是字面量和调用方的 + * 指针, **没有任何人会去改写它们** (execvp 本身也不会). 所以这里显式 + * 转换, 而不是把整条链路都改成非 const —— 那会把 const 正确性一路丢掉. */ char **w = xcalloc(n + 5, sizeof *w); w[0] = (char *)"sudo"; w[1] = (char *)"-u"; @@ -43,12 +43,12 @@ void priv_explain(int rc, const char *out, char *err, size_t errlen) const char *o = out ? out : ""; if (rc == 127 && strstr(o, "无法执行")) - snprintf(err, errlen, "这台机器上没有 sudo,无法提权"); + snprintf(err, errlen, "这台机器上没有 sudo, 无法提权"); else if (strstr(o, "no tty present") || strstr(o, "a terminal is required")) - snprintf(err, errlen, "提权需要终端来输入密码,当前没有可用的控制终端"); + snprintf(err, errlen, "提权需要终端来输入密码, 当前没有可用的控制终端"); else if (strstr(o, "incorrect password") || strstr(o, "Sorry, try again") || strstr(o, "认证失败") || strstr(o, "密码不正确")) - snprintf(err, errlen, "sudo 认证没有通过(密码不对,或者你取消了)"); + snprintf(err, errlen, "sudo 认证没有通过 (密码不对, 或者你取消了)"); else if (strstr(o, "not in the sudoers") || strstr(o, "不在 sudoers")) snprintf(err, errlen, "当前用户没有 sudo 权限"); else diff --git a/src/priv.h b/src/priv.h index b095300..8ca8bb9 100644 --- a/src/priv.h +++ b/src/priv.h @@ -1,42 +1,42 @@ /* priv.h - 提权:经 sudo 执行单条命令 * - * 先说清楚这个模块**不做**什么,以及为什么: + * 先说清楚这个模块**不做**什么, 以及为什么: * - * PAM 认证不等于获得权限。su 之所以能变 root,是因为 /usr/bin/su 这个文件 - * 本身是 setuid root —— 内核先把 root 给它,它再验密码、验不过就降权。普通 - * 进程调 pam_authenticate() 只能得到"密码对不对",uid 一位都不变。要让 ligent - * 内部认证后真的变 root,唯一的路是把它编译成 setuid root;而它的功能是 - * "让模型执行任意命令",setuid root 的它等于把 root 远程执行做成一等功能。 - * 所以不走那条路。 + * PAM 认证不等于获得权限. su 之所以能变 root, 是因为 /usr/bin/su 这个文件 + * 本身是 setuid root —— 内核先把 root 给它, 它再验密码、验不过就降权. 普通 + * 进程调 pam_authenticate() 只能得到"密码对不对", uid 一位都不变. 要让 ligent + * 内部认证后真的变 root, 唯一的路是把它编译成 setuid root; 而它的功能是 + * "让模型执行任意命令", setuid root 的它等于把 root 远程执行做成一等功能. + * 所以不走那条路. * - * 也不做"授权提示"。**密码本身就是授权**:模型敲不出来,你也不可能手滑敲出来。 - * 再加一道 y/N 只会是多余的摩擦,还会给出一个虚假的安全模型 —— 好像把关的是 - * ligent 的提示,其实真正把关的是 sudo。所以这里只做两件事: + * 也不做"授权提示". **密码本身就是授权**:模型敲不出来, 你也不可能手滑敲出来. + * 再加一道 y/N 只会是多余的摩擦, 还会给出一个虚假的安全模型 —— 好像把关的是 + * ligent 的提示, 其实真正把关的是 sudo. 所以这里只做两件事: * - * 1. 把命令包成 `sudo -u <用户> -- ` 交出去(**不经 shell**, - * 所以路径/命令里的引号、换行、`;` 注入不进 root 那条命令里); - * 2. 需要密码时,**让终端留在 sudo 手上**,由它自己问、自己存 —— ligent - * 不读、不存、不转发密码。 + * 1. 把命令包成 `sudo -u <用户> -- ` 交出去 (**不经 shell**, + * 所以路径/命令里的引号、换行、`;` 注入不进 root 那条命令里); + * 2. 需要密码时, **让终端留在 sudo 手上**, 由它自己问、自己存 —— ligent + * 不读、不存、不转发密码. * - * 唯一由 ligent 把守的是"别卡死":没有控制终端就**根本不去试**,直接说清楚。 - * 否则就会起一个在后台等一个没人能看见的密码提示的进程。 + * 唯一由 ligent 把守的是"别卡死":没有控制终端就**根本不去试**, 直接说清楚. + * 否则就会起一个在后台等一个没人能看见的密码提示的进程. */ #ifndef LIGENT_PRIV_H #define LIGENT_PRIV_H #include -/* 有可用的控制终端吗?判据是能不能打开 /dev/tty,**不是** isatty(0): - * `echo x | ligent ...` 时 stdin 是管道,但终端还在,sudo 照样问得出来。 */ +/* 有可用的控制终端吗?判据是能不能打开 /dev/tty, **不是** isatty(0): + * `echo x | ligent ...` 时 stdin 是管道, 但终端还在, sudo 照样问得出来. */ int priv_have_tty(void); -/* 把 argv 包成 `sudo -u -- <原 argv>`(user 为空则 "root")。 - * 故意**不带 -n**:需要密码时就让 sudo 自己去问。 - * 返回 malloc 的数组,元素是借用的指针 —— 调用方 free 数组本身即可。 */ +/* 把 argv 包成 `sudo -u -- <原 argv>` (user 为空则 "root"). + * 故意**不带 -n**:需要密码时就让 sudo 自己去问. + * 返回 malloc 的数组, 元素是借用的指针 —— 调用方 free 数组本身即可. */ char **priv_sudo_argv(const char *user, char *const argv[]); -/* sudo 失败时把它的原话换成一句能照做的中文(写进 err)。 - * 认不出来就原样透传 —— 宁可给原文,也不要编一句可能不对的解释。 */ +/* sudo 失败时把它的原话换成一句能照做的中文 (写进 err). + * 认不出来就原样透传 —— 宁可给原文, 也不要编一句可能不对的解释. */ void priv_explain(int rc, const char *out, char *err, size_t errlen); #endif /* LIGENT_PRIV_H */ diff --git a/src/sgr.h b/src/sgr.h new file mode 100644 index 0000000..eb1cc72 --- /dev/null +++ b/src/sgr.h @@ -0,0 +1,47 @@ +// ECMA-48 SGR 序列, 参见 console_codes(4) +#define C_OFF "\033[0m" // 复原所有属性 + +#define C_BOLD "\033[1m" // 粗体 +#define C_DIM "\033[2m" // 半亮度 +#define C_ITALIC "\033[3m" // 斜体 +#define C_UNDERLINE "\033[4m" // 下划线 +#define C_REVERSE "\033[7m" // 反显 + +#define C_BLACK "\033[30m" +#define C_RED "\033[31m" +#define C_GREEN "\033[32m" +#define C_YELLOW "\033[33m" +#define C_BLUE "\033[34m" +#define C_MAGENTA "\033[35m" +#define C_CYAN "\033[36m" +#define C_WHITE "\033[37m" + +#define C_FG_DEFAULT "\033[39m" // 默认前景色 +#define C_BG_DEFAULT "\033[49m" // 默认背景色 + +#define C_BG_BLACK "\033[40m" +#define C_BG_RED "\033[41m" +#define C_BG_GREEN "\033[42m" +#define C_BG_BROWN "\033[43m" +#define C_BG_YELLOW "\033[43m" +#define C_BG_BLUE "\033[44m" +#define C_BG_MAGENTA "\033[45m" +#define C_BG_CYAN "\033[46m" +#define C_BG_WHITE "\033[47m" + +#define C_BRIGHT_BLACK "\033[90m" +#define C_BRIGHT_RED "\033[91m" +#define C_BRIGHT_GREEN "\033[92m" +#define C_BRIGHT_YELLOW "\033[93m" +#define C_BRIGHT_BLUE "\033[94m" +#define C_BRIGHT_MAGENTA "\033[95m" +#define C_BRIGHT_CYAN "\033[96m" +#define C_BRIGHT_WHITE "\033[97m" + +#define C_OFF "\033[0m" // 复原 +#define C_BOLD "\033[1m" // 粗体 +#define C_DIM "\033[2m" // 半亮度 +#define C_ITALIC "\033[3m" // 斜体 +#define C_CYAN "\033[36m" // +#define C_GREEN "\033[32m" +#define C_RED "\033[31m" diff --git a/src/shell.c b/src/shell.c index cfbb07c..3e267ae 100644 --- a/src/shell.c +++ b/src/shell.c @@ -1,4 +1,4 @@ -/* shell.c - 交互式 shell 会话(pty 双向搬运) */ +/* shell.c - 交互式 shell 会话 (pty 双向搬运) */ #include "shell.h" #include "i18n.h" #include "util.h" @@ -16,10 +16,10 @@ #include #include -/* bash → ash → dash → sh。 +/* bash → ash → dash → sh. * - * 刻意不看 $SHELL:用户环境里的 $SHELL 可能是 xonsh 之类交互式外壳, - * 在容器/受限环境里经常起不来,反而把 /shell 弄坏。返回的路径一定存在。 */ + * 刻意不看 $SHELL:用户环境里的 $SHELL 可能是 xonsh 之类交互式外壳, + * 在容器/受限环境里经常起不来, 反而把 /shell 弄坏. 返回的路径一定存在. */ static const char *pick_shell(void) { static const char *cands[] = { @@ -31,26 +31,26 @@ static const char *pick_shell(void) } /* 把终端输出整理成"能读的记录": - * 去掉 ANSI 转义、把 CRLF 归一成 LF、丢掉退格与其它控制字符,并处理全屏程序。 - * 不清理的话模型看到的是一堆 ^[[32m 之类的噪音。 + * 去掉 ANSI 转义、把 CRLF 归一成 LF、丢掉退格与其它控制字符, 并处理全屏程序. + * 不清理的话模型看到的是一堆 ^[[32m 之类的噪音. * - * 状态必须跨 read() 保存,原因有三个: - * - 转义序列可能正好被 read() 从中间切断,分两次解析会串味; - * - 全屏程序的备用屏幕进入/退出天然跨越多次 read; - * - 连续空格要和后面的字符一起看才压得准。 */ + * 状态必须跨 read() 保存, 原因有三个: + * - 转义序列可能正好被 read() 从中间切断, 分两次解析会串味; + * - 全屏程序的备用屏幕进入/退出天然跨越多次 read; + * - 连续空格要和后面的字符一起看才压得准. */ enum { CLEAN_NORM = 0, CLEAN_ESC, CLEAN_CSI, CLEAN_OSC, CLEAN_OSC_ESC }; typedef struct { int state; - int in_alt; /* 正在备用屏幕里(vim/less/top 这类全屏程序) */ + int in_alt; /* 正在备用屏幕里 (vim/less/top 这类全屏程序) */ size_t space_run; /* 还没输出的连续空格 */ char csi[40]; size_t csi_len; } CleanCtx; -/* 全屏程序(nano/top 这类)靠成片空格重绘,一次刷新就是成百上千个。 - * 原样记录会让模型看到满屏空白,把真正的输出淹掉。 - * 短的缩进(<= 8)原样保留;更长的压成一个空格。 */ +/* 全屏程序 (nano/top 这类)靠成片空格重绘, 一次刷新就是成百上千个. + * 原样记录会让模型看到满屏空白, 把真正的输出淹掉. + * 短的缩进 (<= 8)原样保留; 更长的压成一个空格. */ static void clean_flush_spaces(CleanCtx *cx, StrBuf *out) { if (cx->space_run == 0 || cx->in_alt) { cx->space_run = 0; return; } @@ -67,8 +67,8 @@ static void clean_putc(CleanCtx *cx, StrBuf *out, unsigned char c) if (out->len < LIGENT_SHELL_MAX) sb_putc(out, (char)c); } -/* 备用屏幕的开启序列有 ?1049(vim/less)、?1047 和 ?47(老程序)几种写法, - * 结束用的是同样的编号加 'l'。 */ +/* 备用屏幕的开启序列有 ?1049 (vim/less)、?1047 和 ?47 (老程序)几种写法, + * 结束用的是同样的编号加 'l'. */ static int csi_alt_screen(const CleanCtx *cx, int *entering) { if (cx->csi_len == 0) return 0; @@ -95,7 +95,7 @@ static void append_clean(CleanCtx *cx, StrBuf *out, const char *data, size_t n) case CLEAN_CSI: if (cx->csi_len < sizeof cx->csi - 1) cx->csi[cx->csi_len++] = (char)c; - if (c >= 0x40 && c <= 0x7e) { /* 终结字节,序列到此为止 */ + if (c >= 0x40 && c <= 0x7e) { /* 终结字节, 序列到此为止 */ int entering = 0; cx->csi[cx->csi_len] = '\0'; if (csi_alt_screen(cx, &entering)) { @@ -103,7 +103,7 @@ static void append_clean(CleanCtx *cx, StrBuf *out, const char *data, size_t n) clean_flush_spaces(cx, out); cx->in_alt = 1; if (out->len < LIGENT_SHELL_MAX) - sb_puts(out, "\n[全屏程序运行中,画面内容已省略]\n"); + sb_puts(out, "\n[全屏程序运行中, 画面内容已省略]\n"); } else if (!entering && cx->in_alt) { cx->in_alt = 0; cx->space_run = 0; @@ -129,7 +129,7 @@ static void append_clean(CleanCtx *cx, StrBuf *out, const char *data, size_t n) } if (c == 0x1b) { cx->state = CLEAN_ESC; continue; } - if (c == '\r') continue; /* CRLF 归一成 LF;孤立的 CR 丢掉 */ + if (c == '\r') continue; /* CRLF 归一成 LF; 孤立的 CR 丢掉 */ if (c == '\b') continue; /* 退格在记录里没有意义 */ if (c < 0x09 || (c > 0x0d && c < 0x20)) continue; clean_putc(cx, out, c); @@ -178,13 +178,13 @@ char *shell_interactive(int *out_code, char *errbuf, size_t errlen) if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0) ioctl(master, TIOCSWINSZ, &ws); - /* 全屏程序(nano/vim/less/top)靠"备用屏幕"切换来还原退出前的画面, + /* 全屏程序 (nano/vim/less/top)靠"备用屏幕"切换来还原退出前的画面, * 但它们只在 TERM 声称支持时才这么做:TERM 为空或 dumb 时 nano 会退化成 - * 逐行重绘,几百个空格把记录淹掉,也没法整段忽略。 + * 逐行重绘, 几百个空格把记录淹掉, 也没法整段忽略. * * 而 ligent 自己的 REPL 就在用颜色、光标定位和括号粘贴 —— 这个终端至少是 - * ANSI 兼容的,TERM=dumb 属于谎报。所以只在 TERM 缺失或为 dumb 时补一个 - * xterm-256color;用户显式设了别的值就原样传下去,不擅自改。 */ + * ANSI 兼容的, TERM=dumb 属于谎报. 所以只在 TERM 缺失或为 dumb 时补一个 + * xterm-256color; 用户显式设了别的值就原样传下去, 不擅自改. */ char term_saved[64]; int term_restore = 0, term_was_set = 0; { @@ -200,8 +200,8 @@ char *shell_interactive(int *out_code, char *errbuf, size_t errlen) } pid_t pid = fork(); - /* 子进程已经继承了改过的 TERM,父进程立刻还原,免得影响 ligent 自己。 - * 必须显式判断 pid != 0 —— 子进程也会从 fork() 返回并往下走。 */ + /* 子进程已经继承了改过的 TERM, 父进程立刻还原, 免得影响 ligent 自己. + * 必须显式判断 pid != 0 —— 子进程也会从 fork() 返回并往下走. */ if (pid != 0 && term_restore) { if (term_was_set) setenv("TERM", term_saved, 1); else unsetenv("TERM"); @@ -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); @@ -296,8 +296,8 @@ char *shell_interactive(int *out_code, char *errbuf, size_t errlen) if (out_code) *out_code = code; clean_flush_spaces(&clean, &rec); /* 收尾:还压在手里的空格 */ - /* 全屏程序被 Ctrl-C/SIGKILL 干掉时会来不及发退出序列,补一个收尾标记, - * 免得记录里留下一个没有配对的"开始"。 */ + /* 全屏程序被 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 602d59a..5762973 100644 --- a/src/shell.h +++ b/src/shell.h @@ -4,17 +4,17 @@ #include -/* 起一个交互式 shell,用户在里面随便敲;退出时把整段会话录制下来。 +/* 起一个交互式 shell, 用户在里面随便敲; 退出时把整段会话录制下来. * - * 用伪终端(pty)做双向搬运:子进程以为自己接在真终端上,所以行编辑、 - * 颜色、Ctrl-C、作业控制都照常工作;我们在外面旁听并把输出记下来。 + * 用伪终端 (pty)做双向搬运:子进程以为自己接在真终端上, 所以行编辑、 + * 颜色、Ctrl-C、作业控制都照常工作; 我们在外面旁听并把输出记下来. * - * out_code 回填子进程退出码。 - * 返回 malloc 的录制文本(已去掉 ANSI 控制序列,最长 LIGENT_SHELL_MAX); - * 失败返回 NULL 并写 errbuf。退出码 127 表示 shell 都没找到。 */ + * out_code 回填子进程退出码. + * 返回 malloc 的录制文本 (已去掉 ANSI 控制序列, 最长 LIGENT_SHELL_MAX); + * 失败返回 NULL 并写 errbuf. 退出码 127 表示 shell 都没找到. */ char *shell_interactive(int *out_code, char *errbuf, size_t errlen); -/* 录制文本的长度上限。直接进上下文,不能没边 */ +/* 录制文本的长度上限. 直接进上下文, 不能没边 */ #define LIGENT_SHELL_MAX (100 * 1024) #endif /* LIGENT_SHELL_H */ diff --git a/src/spinner.c b/src/spinner.c index b0f7b6b..55b8e66 100644 --- a/src/spinner.c +++ b/src/spinner.c @@ -20,16 +20,16 @@ static volatile int g_running; static int g_started; static int g_enabled; static char g_label[64]; -/* 当前这段等待的起点。换标签时重置,于是秒数反映的是"这个阶段等了多久": - * 先看到"等待响应 (1s)",切成"思考中"后重新从 0 数起。 */ +/* 当前这段等待的起点. 换标签时重置, 于是秒数反映的是"这个阶段等了多久": + * 先看到"等待响应 (1s)", 切成"思考中"后重新从 0 数起. */ static struct timespec g_t0; -/* 条件变量必须显式指定用 CLOCK_MONOTONIC。 +/* 条件变量必须显式指定用 CLOCK_MONOTONIC. * - * pthread_cond_timedwait 默认按 CLOCK_REALTIME 解释超时,而我们算出来的 - * 是 CLOCK_MONOTONIC 的绝对时间。开机时长(几十万秒)永远小于 epoch 时间 - * (十几亿秒),于是"超时时刻"永远被当成已经过去 —— timedwait 立刻返回, - * worker 变成疯狂重绘的忙循环。 */ + * pthread_cond_timedwait 默认按 CLOCK_REALTIME 解释超时, 而我们算出来的 + * 是 CLOCK_MONOTONIC 的绝对时间. 开机时长 (几十万秒)永远小于 epoch 时间 + * (十几亿秒), 于是"超时时刻"永远被当成已经过去 —— timedwait 立刻返回, + * worker 变成疯狂重绘的忙循环. */ static void init_cond(void) { pthread_condattr_t attr; @@ -58,15 +58,15 @@ static void *worker(void *arg) elapsed = (long)(now.tv_sec - g_t0.tv_sec); fprintf(stderr, "\r\033[K%s%s%s%s", DIM, g_label, dots[i], OFF); - /* 等得越久越该说明"还活着",超过 5 秒就把秒数亮出来 */ + /* 等得越久越该说明"还活着", 超过 5 秒就把秒数亮出来 */ if (elapsed >= 5) fprintf(stderr, " %s(%lds)%s", DIM, elapsed, OFF); fflush(stderr); i = (i + 1) % 4; - /* 睡到下一帧。这里必须用条件变量定时等,不能 nanosleep: - * spinner_stop() 要 pthread_join,nanosleep 的话就得干等这一觉 - * 睡满 —— 每次收尾白搭最多 250ms,而这笔账直接加在用户感知的 - * 延迟上(每轮模型往返、每个工具来回都要付一次)。 */ + /* 睡到下一帧. 这里必须用条件变量定时等, 不能 nanosleep: + * spinner_stop() 要 pthread_join, nanosleep 的话就得干等这一觉 + * 睡满 —— 每次收尾白搭最多 250ms, 而这笔账直接加在用户感知的 + * 延迟上 (每轮模型往返、每个工具来回都要付一次). */ next.tv_nsec += 250000000L; if (next.tv_nsec >= 1000000000L) { next.tv_sec++; @@ -83,8 +83,8 @@ void spinner_init(int enabled) g_enabled = enabled ? 1 : 0; } -/* 调用方必须已经持有锁。标签真的变了才重置计时, - * 否则每来一片思维链都重置,"思考中 (12s)"永远停在 0。 */ +/* 调用方必须已经持有锁. 标签真的变了才重置计时, + * 否则每来一片思维链都重置, "思考中 (12s)"永远停在 0. */ static void set_label_locked(const char *label) { const char *want = (label && *label) ? label : t("spinner.thinking"); @@ -100,15 +100,15 @@ void spinner_start(const char *label) pthread_mutex_lock(&g_lock); if (!g_started) { /* 先把标签算成**一份副本**再写进 g_label:label 有可能就是 g_label 自己 - * —— spinner_notice() 从一条重试提示里恢复动画时正是这么调的。以前这里 - * 先执行 g_label[0] = '\0',于是要读的那个字符串被自己清空,标签退化成 - * 默认的"思考中":每次重试退避之后,屏幕上的"等待响应"都会变成"思考中", - * 而这两段等待的含义恰恰是它要区分的。 */ + * —— spinner_notice() 从一条重试提示里恢复动画时正是这么调的. 以前这里 + * 先执行 g_label[0] = '\0', 于是要读的那个字符串被自己清空, 标签退化成 + * 默认的"思考中":每次重试退避之后, 屏幕上的"等待响应"都会变成"思考中", + * 而这两段等待的含义恰恰是它要区分的. */ char want[sizeof g_label]; snprintf(want, sizeof want, "%s", (label && *label) ? label : t("spinner.thinking")); g_label[0] = '\0'; /* 让 set_label_locked 一定认成"变了" */ - set_label_locked(want); /* 传副本,不传 g_label 自己 */ + set_label_locked(want); /* 传副本, 不传 g_label 自己 */ g_running = 1; g_started = 1; if (pthread_create(&g_thread, NULL, worker, NULL) != 0) { @@ -133,7 +133,7 @@ void spinner_stop(void) pthread_mutex_lock(&g_lock); if (!g_started) { pthread_mutex_unlock(&g_lock); return; } g_running = 0; - pthread_cond_signal(&g_cond); /* 立刻叫醒 worker,别等它睡满一帧 */ + pthread_cond_signal(&g_cond); /* 立刻叫醒 worker, 别等它睡满一帧 */ pthread_mutex_unlock(&g_lock); pthread_join(g_thread, NULL); diff --git a/src/spinner.h b/src/spinner.h index 25ac2d5..c20e295 100644 --- a/src/spinner.h +++ b/src/spinner.h @@ -1,14 +1,14 @@ /* spinner.h - 等待指示器 * - * 存在的意义:模型不输出思考过程是好事,但等待期间屏幕上什么都没有, - * 和"卡死了"看起来一模一样。一个灰色的动画能把这个歧义消掉。 + * 存在的意义:模型不输出思考过程是好事, 但等待期间屏幕上什么都没有, + * 和"卡死了"看起来一模一样. 一个灰色的动画能把这个歧义消掉. * - * 分两段显示,因为这两种等待的性质不一样: - * 刚发出请求、还没收到任何东西 → "等待响应"(卡在网络或排队) - * 开始收到思维链 → "思考中"(模型确实在推理) - * 一收到正文就停 —— 那时已经有东西可看了。 + * 分两段显示, 因为这两种等待的性质不一样: + * 刚发出请求、还没收到任何东西 → "等待响应" (卡在网络或排队) + * 开始收到思维链 → "思考中" (模型确实在推理) + * 一收到正文就停 —— 那时已经有东西可看了. * - * 输出走 stderr,且只在 stderr 是终端时才启用,所以不会污染管道输出。 + * 输出走 stderr, 且只在 stderr 是终端时才启用, 所以不会污染管道输出. */ #ifndef LIGENT_SPINNER_H #define LIGENT_SPINNER_H @@ -18,20 +18,20 @@ /* enabled = 0 时所有接口都变成空操作 */ void spinner_init(int enabled); -/* 开始转动。已在转则忽略(幂等) */ +/* 开始转动. 已在转则忽略 (幂等) */ void spinner_start(const char *label); -/* 换标签(例如从"等待响应"切到"思考中")。 - * 标签真的变了才重置计时,所以每来一片思维链都调用它也没问题。 - * 未在转时只记下标签,什么都不打印。 */ +/* 换标签 (例如从"等待响应"切到"思考中"). + * 标签真的变了才重置计时, 所以每来一片思维链都调用它也没问题. + * 未在转时只记下标签, 什么都不打印. */ void spinner_set_label(const char *label); -/* 停止并擦掉那一行。未在转则忽略(幂等)。 - * 流式输出开始前必须先调用它,否则内容会和动画抢同一行。 */ +/* 停止并擦掉那一行. 未在转则忽略 (幂等). + * 流式输出开始前必须先调用它, 否则内容会和动画抢同一行. */ void spinner_stop(void); -/* 在动画中间插一条提示(例如"稍后重试"):先停并清行,打印,再继续转。 - * 未在转时就是普通打印。 */ +/* 在动画中间插一条提示 (例如"稍后重试"):先停并清行, 打印, 再继续转. + * 未在转时就是普通打印. */ void spinner_notice(const char *fmt, ...) LIGENT_PRINTF(1, 2); #endif /* LIGENT_SPINNER_H */ diff --git a/src/tools.c b/src/tools.c index c8f81aa..8032110 100644 --- a/src/tools.c +++ b/src/tools.c @@ -1,7 +1,7 @@ /* tools.c - 工具实现:shell、文件读写、编辑、列目录、搜索 * - * 设计取向:这是"全权执行"模式,不做沙箱限制;但仍对输出做截断, - * 避免一次命令把上下文撑爆。 + * 设计取向:这是"全权执行"模式, 不做沙箱限制; 但仍对输出做截断, + * 避免一次命令把上下文撑爆. */ #include "tools.h" #include "util.h" @@ -71,15 +71,15 @@ LIGENT_PRINTF(1, 2) static char *err_result(const char *fmt, ...) /* ---- 二进制判定 ---- * - * 判据(出现 NUL 字节)与探测窗口(前 8 KB)都在 util.h 里,由所有处理文本的 - * 地方共用一份 —— 以前这里和 attach.c 各写一份、窗口还不一样,于是同一个文件 - * 会出现"/include 说它是二进制、read_file 说它能读"的矛盾说法。 - * edit_file 是唯一的例外,它检查**全文件**(见那里的说明)。 */ + * 判据 (出现 NUL 字节)与探测窗口 (前 8 KB)都在 util.h 里, 由所有处理文本的 + * 地方共用一份 —— 以前这里和 attach.c 各写一份、窗口还不一样, 于是同一个文件 + * 会出现"/include 说它是二进制、read_file 说它能读"的矛盾说法. + * edit_file 是唯一的例外, 它检查**全文件** (见那里的说明). */ -/* 文件看起来是二进制吗?是则返回 1 并把第一个 NUL 的偏移写进 *at。 +/* 文件看起来是二进制吗?是则返回 1 并把第一个 NUL 的偏移写进 *at. * - * 打不开或读不出来时返回 0 —— 让真正做这件事的调用方去报"读不到", - * 否则同一次失败会同时冒出两条互相竞争的错误信息。 */ + * 打不开或读不出来时返回 0 —— 让真正做这件事的调用方去报"读不到", + * 否则同一次失败会同时冒出两条互相竞争的错误信息. */ static int file_is_binary(const char *path, long *at) { FILE *f = fopen(path, "rb"); @@ -90,11 +90,11 @@ 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) { - return err_result("%s 看起来是二进制文件(第 %ld 字节处有 NUL 字节)。%s", + return err_result("%s 看起来是二进制文件 (第 %ld 字节处有 NUL 字节). %s", path, at + 1, why); } @@ -118,19 +118,19 @@ static char *tool_run_command(const JsonValue *args, int *is_error) if (t > CMD_MAX_TIMEOUT) t = CMD_MAX_TIMEOUT; int64_t timeout_ms = (int64_t)(t * 1000); - /* elevated:以 root 执行这一条命令。 + /* elevated:以 root 执行这一条命令. * - * 不做额外的"授权提示" —— 密码本身就是授权:模型敲不出来,你也不可能 - * 手滑敲出来。确认提示(run_command 本来就有的)负责让你**看清要跑什么**, - * sudo 负责**不可伪造的授权**,各司其职。 - * 没有控制终端就根本不去试:那会起一个等不到输入的进程。 */ + * 不做额外的"授权提示" —— 密码本身就是授权:模型敲不出来, 你也不可能 + * 手滑敲出来. 确认提示 (run_command 本来就有的)负责让你**看清要跑什么**, + * sudo 负责**不可伪造的授权**, 各司其职. + * 没有控制终端就根本不去试:那会起一个等不到输入的进程. */ int elevated = json_get_bool(args, "elevated", 0); if (elevated && !priv_have_tty()) { *is_error = 1; return xstrdup( - "错误:这条命令需要提权(elevated),但当前没有可用的控制终端" - "(/dev/tty),sudo 没法问密码。请在终端里运行 ligent," - "或者换一个不需要提权的做法。"); + "错误:这条命令需要提权 (elevated), 但当前没有可用的控制终端" + " (/dev/tty), sudo 没法问密码. 请在终端里运行 ligent, " + "或者换一个不需要提权的做法. "); } int fds[2]; @@ -144,10 +144,10 @@ static char *tool_run_command(const JsonValue *args, int *is_error) return err_result("fork 失败:%s", strerror(errno)); } if (pid == 0) { - /* 非提权:独立进程组,超时时可以整组杀掉。 - * 提权:**不 setsid** —— sudo 是打开 /dev/tty 问密码的,摘掉控制终端 - * 它就永远问不出来(这正是上一版认证必然失败的成因)。 - * 代价是提权这条没法整组杀,超时只杀直接子进程。 */ + /* 非提权:独立进程组, 超时时可以整组杀掉. + * 提权:**不 setsid** —— sudo 是打开 /dev/tty 问密码的, 摘掉控制终端 + * 它就永远问不出来 (这正是上一版认证必然失败的成因). + * 代价是提权这条没法整组杀, 超时只杀直接子进程. */ if (!elevated) setsid(); int devnull = open("/dev/null", O_RDONLY); if (devnull >= 0) { dup2(devnull, STDIN_FILENO); close(devnull); } @@ -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 <命令>`, - * 命令内容不会被多解析一层。 */ - /* 同上:execvp 要 char*[],这里放进去的都不会被改写 */ + /* argv 形式执行:提权时是 `sudo -u root -- /bin/sh -c <命令>`, + * 命令内容不会被多解析一层. */ + /* 同上: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); @@ -226,19 +226,19 @@ static char *tool_run_command(const JsonValue *args, int *is_error) sb_init(&result); if (out.len > 0) sb_append(&result, out.data, out.len); if (truncated) - sb_puts(&result, "\n... [输出超过 100KB,已截断]"); - /* 提权失败时,别让模型去猜。sudo 的提示写的是 /dev/tty,多半不在我们 - * 捕获到的输出里 —— 所以要如实说"可能是认证没过",而不是编一个原因。 */ + sb_puts(&result, "\n... [输出超过 100KB, 已截断]"); + /* 提权失败时, 别让模型去猜. sudo 的提示写的是 /dev/tty, 多半不在我们 + * 捕获到的输出里 —— 所以要如实说"可能是认证没过", 而不是编一个原因. */ if (elevated && exit_code != 0) { char why[512] = ""; priv_explain(exit_code, out.data, why, sizeof why); sb_printf(&result, "\n[提权命令失败:%s]", why); if (!out.data || !*out.data) sb_puts(&result, - "\n(没有捕获到输出。sudo 的提示是直接写到终端上的," - "如果你刚看到它在问密码,那就是认证没过。)"); - sb_puts(&result, "\n不要反复重试同一个提权命令;" - "如果需要权限才能继续,说明你需要用户亲自做什么。"); + "\n (没有捕获到输出. sudo 的提示是直接写到终端上的, " + "如果你刚看到它在问密码, 那就是认证没过. )"); + sb_puts(&result, "\n不要反复重试同一个提权命令; " + "如果需要权限才能继续, 说明你需要用户亲自做什么. "); } if (result.len > 0 && result.data[result.len - 1] != '\n') sb_putc(&result, '\n'); @@ -246,7 +246,7 @@ static char *tool_run_command(const JsonValue *args, int *is_error) sb_puts(&result, "[已中断]"); *is_error = 1; } else if (timed_out) { - sb_printf(&result, "[超时 %d 秒,进程组已被终止]", (int)t); + sb_printf(&result, "[超时 %d 秒, 进程组已被终止]", (int)t); *is_error = 1; } else if (by_signal) { sb_printf(&result, "[被信号 %d 终止]", exit_code - 128); @@ -279,8 +279,8 @@ static char *tool_read_file(const JsonValue *args, int *is_error) FILE *f = fopen(path, "rb"); if (!f) { *is_error = 1; return err_result("无法打开 %s:%s", path, strerror(errno)); } - /* 二进制文件按文本读出来是乱码,而且会在 NUL 处被截断 —— 直接拒绝。 - * 文件已经打开了,就地探一遍前 8 KB(窗口与判据见 util.h),不必再开一次。 */ + /* 二进制文件按文本读出来是乱码, 而且会在 NUL 处被截断 —— 直接拒绝. + * 文件已经打开了, 就地探一遍前 8 KB (窗口与判据见 util.h), 不必再开一次. */ { char probe[LIGENT_BIN_PROBE]; size_t pn = fread(probe, 1, sizeof probe, f); @@ -290,33 +290,33 @@ static char *tool_read_file(const JsonValue *args, int *is_error) fclose(f); *is_error = 1; return binary_err(path, at, - "read_file 只按文本读(读出来会是乱码,还会在 NUL 处被截断)。" - "看二进制内容用 run_command(file / xxd / strings / od);" - "看图片用 read_image。"); + "read_file 只按文本读 (读出来会是乱码, 还会在 NUL 处被截断). " + "看二进制内容用 run_command (file / xxd / strings / od); " + "看图片用 read_image. "); } } - /* 按 '\n' 切行,而且行号口径必须与 edit_file 的 count_lines/line_range - * **完全一致**(末尾那个换行不算新的一行)—— 工具描述和 README 都写着 - * "read_file 打出来的行号可以直接喂给 edit_file 的 line"。 + /* 按 '\n' 切行, 而且行号口径必须与 edit_file 的 count_lines/line_range + * **完全一致** (末尾那个换行不算新的一行)—— 工具描述和 README 都写着 + * "read_file 打出来的行号可以直接喂给 edit_file 的 line". * - * 以前这里是 fgets + 固定 64 KB 缓冲,于是"一行比缓冲长"时它会被切成好几块、 - * 每块各带一个行号:一个「70000 字节的单行 + 两行」的文件,read_file 说第 3 行 - * 是 "second",而 edit_file 认为第 3 行是 "third"。模型照 read_file 的行号去 - * 改,要么改错地方、要么报越界 —— 而它没有任何办法察觉。 + * 以前这里是 fgets + 固定 64 KB 缓冲, 于是"一行比缓冲长"时它会被切成好几块、 + * 每块各带一个行号:一个「70000 字节的单行 + 两行」的文件, read_file 说第 3 行 + * 是 "second", 而 edit_file 认为第 3 行是 "third". 模型照 read_file 的行号去 + * 改, 要么改错地方、要么报越界 —— 而它没有任何办法察觉. * - * 单行的字节数靠"上一块是不是以换行结尾"(in_line)来跟踪,所以仍然**不** - * 需要把整个文件读进内存:一行再长也只占 line[] 这么多,超出输出上限就如实 - * 说明这一行读不出来,而不是把它伪装成若干行。 */ + * 单行的字节数靠"上一块是不是以换行结尾" (in_line)来跟踪, 所以仍然**不** + * 需要把整个文件读进内存:一行再长也只占 line[] 这么多, 超出输出上限就如实 + * 说明这一行读不出来, 而不是把它伪装成若干行. */ StrBuf sb; sb_init(&sb); char line[65536]; - long lineno = 0; /* 已读到的行号(只在真正换行时递增) */ + long lineno = 0; /* 已读到的行号 (只在真正换行时递增) */ long emitted = 0; /* 已输出的行数 */ - long partial_line = 0; /* 这一行只给了开头(它自己超过输出上限) */ + long partial_line = 0; /* 这一行只给了开头 (它自己超过输出上限) */ long empty_line = 0; /* 这一行一个字节都没能进去 */ size_t line_bytes = 0; /* 当前这一行已经进了缓冲多少字节 */ - int cut_by_bytes = 0; /* 截断的原因是字节上限,而不是行数上限 */ + int cut_by_bytes = 0; /* 截断的原因是字节上限, 而不是行数上限 */ int truncated = 0; int in_line = 0; /* 上一块不以 '\n' 结尾 => 这一块还是同一行 */ @@ -343,10 +343,10 @@ static char *tool_read_file(const JsonValue *args, int *is_error) in_line = !has_nl; if (sb.len + n > LIGENT_TOOL_OUTPUT_LIMIT) { - /* 撞的是**字节**上限,而且撞在"正在写的这一行"上(前面那些行都已经 - * 完整给出)。分两种:这一行连一个字节都没进去(前缀已写、内容为 0), - * 还是只给了它开头。两种情况都要如实说,不能让调用方以为拿到的是 - * 一份完整的行 —— 也不能把剩下的部分伪装成"下一行"。 */ + /* 撞的是**字节**上限, 而且撞在"正在写的这一行"上 (前面那些行都已经 + * 完整给出). 分两种:这一行连一个字节都没进去 (前缀已写、内容为 0), + * 还是只给了它开头. 两种情况都要如实说, 不能让调用方以为拿到的是 + * 一份完整的行 —— 也不能把剩下的部分伪装成"下一行". */ cut_by_bytes = 1; if (line_bytes == 0) { empty_line = lineno; @@ -360,7 +360,7 @@ static char *tool_read_file(const JsonValue *args, int *is_error) } sb_append(&sb, line, n); line_bytes += n; - /* 文件最后一行没有换行符时补一个,否则行号前缀会跟下一行粘连 */ + /* 文件最后一行没有换行符时补一个, 否则行号前缀会跟下一行粘连 */ if (!has_nl) sb_putc(&sb, '\n'); } fclose(f); @@ -373,21 +373,21 @@ static char *tool_read_file(const JsonValue *args, int *is_error) sb_free(&sb); if (empty_line > 0) return xasprintf( - "(第 %ld 行本身就超过 %u 字节的输出上限,读不出它的内容。" - "可以用 run_command 只取它的一部分,例如 " + "(第 %ld 行本身就超过 %u 字节的输出上限, 读不出它的内容. " + "可以用 run_command 只取它的一部分, 例如 " "sed -n '%ldp' <文件> | cut -c1-4000)", empty_line, (unsigned)LIGENT_TOOL_OUTPUT_LIMIT, empty_line); - return xasprintf("(文件只有 %ld 行,offset=%ld 超出范围)", lineno, offset); + return xasprintf("(文件只有 %ld 行, offset=%ld 超出范围)", lineno, offset); } if (truncated) { if (cut_by_bytes && partial_line > 0) - /* 撞的是字节上限:给出的是**半行**,所以不能再说"从下一行继续" */ + /* 撞的是字节上限:给出的是**半行**, 所以不能再说"从下一行继续" */ sb_printf(&sb, - "... [第 %ld 行本身就超过 %u 字节的输出上限,这里只给了它的开头;" - "要看后面的部分,用 run_command(例如 sed -n '%ldp' <文件> | cut -c1-4000)]", + "... [第 %ld 行本身就超过 %u 字节的输出上限, 这里只给了它的开头; " + "要看后面的部分, 用 run_command (例如 sed -n '%ldp' <文件> | cut -c1-4000)]", partial_line, (unsigned)LIGENT_TOOL_OUTPUT_LIMIT, partial_line); else - sb_printf(&sb, "... [已截断,从第 %ld 行继续读取]", offset + emitted); + sb_printf(&sb, "... [已截断, 从第 %ld 行继续读取]", offset + emitted); } return sb_detach(&sb); } @@ -404,7 +404,7 @@ static char *tool_write_file(const JsonValue *args, int *is_error) 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,24 +417,24 @@ static char *tool_write_file(const JsonValue *args, int *is_error) } free(dir); - /* 别拿文本去整体覆盖一个二进制文件:那等于把原内容毁掉,而模型多半没 - * 意识到(它只是想"写个文件")。要真的换掉它,先删或用 run_command。 */ + /* 别拿文本去整体覆盖一个二进制文件:那等于把原内容毁掉, 而模型多半没 + * 意识到 (它只是想"写个文件"). 要真的换掉它, 先删或用 run_command. */ { long at = 0; if (file_is_binary(path, &at)) { *is_error = 1; return binary_err(path, at, - "write_file 只写文本,整体覆盖它等于把原来的内容毁掉。" - "确实要换掉这个文件,先用 run_command 删掉它,或者换一个路径。"); + "write_file 只写文本, 整体覆盖它等于把原来的内容毁掉. " + "确实要换掉这个文件, 先用 run_command 删掉它, 或者换一个路径. "); } } - /* 落盘前再看一眼中断标志。确认环节已经会拦下 Ctrl-C,这里是纵深防御: - * "任何改盘的动作之前都检查中断"应当是一条无例外的约定, - * 而不是依赖"恰好上一层拦住了"。 */ + /* 落盘前再看一眼中断标志. 确认环节已经会拦下 Ctrl-C, 这里是纵深防御: + * "任何改盘的动作之前都检查中断"应当是一条无例外的约定, + * 而不是依赖"恰好上一层拦住了". */ if (g_ligent_interrupt) { *is_error = 1; - return xstrdup("[已中断] 用户中断了这次操作,文件没有被改动。"); + return xstrdup("[已中断] 用户中断了这次操作, 文件没有被改动. "); } FILE *f = fopen(path, "wb"); @@ -454,37 +454,37 @@ static char *tool_write_file(const JsonValue *args, int *is_error) /* edit_file */ /* ------------------------------------------------------------------ */ /* - * 替换受**行号集合**制约:old_string 的**起点**必须落在 line 圈住的行里,且在 - * 这些行里只有一处匹配。 + * 替换受**行号集合**制约:old_string 的**起点**必须落在 line 圈住的行里, 且在 + * 这些行里只有一处匹配. * - * line 是**集合型,而且可以整个省略**:省略 = 不设行范围,等于**全文件替换所有 - * 匹配**(就是编辑器里那个"全局替换")。给了它就是集合型 —— 单个整数(等价于 - * 只有一个元素的集合)或整数数组都收,`"line": 12` 和 `"line": [12]` 是同一件事。 - * 集合里的行号就是 read_file 打出来的那些(1 起)。 + * line 是**集合型, 而且可以整个省略**:省略 = 不设行范围, 等于**全文件替换所有 + * 匹配** (就是编辑器里那个"全局替换"). 给了它就是集合型 —— 单个整数 (等价于 + * 只有一个元素的集合)或整数数组都收, `"line": 12` 和 `"line": [12]` 是同一件事. + * 集合里的行号就是 read_file 打出来的那些 (1 起). * - * 为什么是集合而不是单个行号:模型看到的常是一整块内容("第 10~13 行是这段 - * 函数"),但决定命中与否的是 old_string 的**起点**落在哪一行;只给一个行号时 - * 它得先数清第一行到底是 10 还是 11,数错一次就白烧一轮。给了集合之后,照 - * read_file 的显示把覆盖到的那几行都填进来就行。集合只是**搜索范围**,不是 - * "每行都改一遍":真改动的仍是唯一那一处(replace_all 或省略 line 时才动全文件)。 + * 为什么是集合而不是单个行号:模型看到的常是一整块内容 ("第 10~13 行是这段 + * 函数"), 但决定命中与否的是 old_string 的**起点**落在哪一行; 只给一个行号时 + * 它得先数清第一行到底是 10 还是 11, 数错一次就白烧一轮. 给了集合之后, 照 + * read_file 的显示把覆盖到的那几行都填进来就行. 集合只是**搜索范围**, 不是 + * "每行都改一遍":真改动的仍是唯一那一处 (replace_all 或省略 line 时才动全文件). * - * 为什么不再只靠"全文唯一"来定位:行号是模型唯一的空间坐标(read_file 给的), + * 为什么不再只靠"全文唯一"来定位:行号是模型唯一的空间坐标 (read_file 给的), * 而"全文唯一"要求整个文件里恰好只有一处 —— 文件一长这个条件经常不成立 - * (同一个 `return 0;` 到处都是),模型只能不断加长上下文去凑唯一性;凑不成 - * 就只能放弃,或者赌一把改错地方。把行号钉住之后,"改哪一处"由坐标决定, - * 不再是个概率问题;old_string 也不必再为"唯一"而写得很长。 + * (同一个 `return 0;` 到处都是), 模型只能不断加长上下文去凑唯一性; 凑不成 + * 就只能放弃, 或者赌一把改错地方. 把行号钉住之后, "改哪一处"由坐标决定, + * 不再是个概率问题; old_string 也不必再为"唯一"而写得很长. * - * 省略 line 是**故意留着**的逃生门:有些改动本来就该全文件生效(比如统一改个 - * 名字),但不给行号就没法声称"我知道改的是哪一处" —— 回执里也会直说这是全文件 - * 替换。要"改一处"仍得给行号,模型没法靠省略来回避看文件。 + * 省略 line 是**故意留着**的逃生门:有些改动本来就该全文件生效 (比如统一改个 + * 名字), 但不给行号就没法声称"我知道改的是哪一处" —— 回执里也会直说这是全文件 + * 替换. 要"改一处"仍得给行号, 模型没法靠省略来回避看文件. * - * replace_all 仍在,含义不变:改文件里**所有**匹配。但 line 集合必须圈住其中 - * 真实的一处 —— 这等于强制模型先看过文件,而不是凭印象做全文替换。 + * replace_all 仍在, 含义不变:改文件里**所有**匹配. 但 line 集合必须圈住其中 + * 真实的一处 —— 这等于强制模型先看过文件, 而不是凭印象做全文替换. */ -/* 文件按 read_file 的口径有多少行:末尾的换行不算新的一行。 - * (read_file 用 fgets 读,末尾那个"空行"不会产生输出,两边口径必须一致, - * 否则 read_file 显示的行号和这里的越界判断会互相矛盾。) */ +/* 文件按 read_file 的口径有多少行:末尾的换行不算新的一行. + * (read_file 用 fgets 读, 末尾那个"空行"不会产生输出, 两边口径必须一致, + * 否则 read_file 显示的行号和这里的越界判断会互相矛盾. ) */ static long count_lines(const char *s, size_t len) { long n = 0; @@ -494,7 +494,7 @@ static long count_lines(const char *s, size_t len) return n; } -/* 第 n 行(1 起)的字节范围 [*start, *end),不含行尾换行。越界返回 0 */ +/* 第 n 行 (1 起)的字节范围 [*start, *end), 不含行尾换行. 越界返回 0 */ static int line_range(const char *s, size_t len, long n, size_t *start, size_t *end) { if (n < 1 || n > count_lines(s, len)) return 0; @@ -510,8 +510,8 @@ static int line_range(const char *s, size_t len, long n, size_t *start, size_t * return 1; } -/* line 集合里最多几个行号。它是用来圈住 old_string 覆盖的那几行的, - * 给到几十个以上就说明模型在瞎猜行号 —— 那时该做的是重新 read_file 看一遍。 */ +/* line 集合里最多几个行号. 它是用来圈住 old_string 覆盖的那几行的, + * 给到几十个以上就说明模型在瞎猜行号 —— 那时该做的是重新 read_file 看一遍. */ #define EDIT_LINE_MAX 64 static int cmp_long(const void *a, const void *b) @@ -520,14 +520,14 @@ static int cmp_long(const void *a, const void *b) return x < y ? -1 : (x > y ? 1 : 0); } -/* 把 line 参数解析成升序去重后的行号数组:写进 out(容量 cap),返回元素个数 - * (>=1)。失败返回 -1,并把给模型看的说法写进 *err(调用方 free)。 +/* 把 line 参数解析成升序去重后的行号数组:写进 out (容量 cap), 返回元素个数 + * (>=1). 失败返回 -1, 并把给模型看的说法写进 *err (调用方 free). * - * 两种写法都收:整数(历史写法)和整数数组(集合)。所以老调用和老录制里的 - * `"line": 41` 仍然照常工作,不必区分。 + * 两种写法都收:整数 (历史写法)和整数数组 (集合). 所以老调用和老录制里的 + * `"line": 41` 仍然照常工作, 不必区分. * - * 去重不是洁癖:同一个行号处理两遍,同一处匹配会被数成两处, - * 于是"行内只有一处"的判断会莫名其妙地失败。 */ + * 去重不是洁癖:同一个行号处理两遍, 同一处匹配会被数成两处, + * 于是"行内只有一处"的判断会莫名其妙地失败. */ static long parse_line_set(const JsonValue *lv, long *out, size_t cap, char **err) { *err = NULL; @@ -535,31 +535,31 @@ 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 " - "打出来的那个,从 1 开始),要么把 line 整个省略" - "(省略 = 全文件替换所有匹配)。"); + *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); + "要改的地方太多, 先 read_file 看清再分几次改. ", n, cap); return -1; } for (size_t i = 0; i < n; i++) { const JsonValue *e = is_arr ? json_array_get(lv, i) : lv; - /* 先把范围夹住再判断是不是整数:JSON 里什么都可能来,而把 1e300 - * 直接转成 long 是 UB(UBsan 会当场报出来)。 */ + /* 先把范围夹住再判断是不是整数: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 个不是合法的行号 —— " - "要的是 read_file 打出来的 >=1 的整数行号。", i + 1); + "要的是 read_file 打出来的 >=1 的整数行号. ", i + 1); else *err = xasprintf("错误:line=%g 不合法 —— 要的是 read_file " - "打出来的 >=1 的整数行号。", d); + "打出来的 >=1 的整数行号. ", d); return -1; } out[i] = (long)d; @@ -572,8 +572,8 @@ static long parse_line_set(const JsonValue *lv, long *out, size_t cap, char **er return (long)k; } -/* 把行号集合渲染成 "3、4、5"(报错与回执里用,给模型看)。 - * 超过 8 个就只列前 8 个:再往下只是噪声,源文件的行号本来就在 read_file 里。 */ +/* 把行号集合渲染成 "3、4、5" (报错与回执里用, 给模型看). + * 超过 8 个就只列前 8 个:再往下只是噪声, 源文件的行号本来就在 read_file 里. */ static void lines_text(const long *v, long n, char *buf, size_t cap) { size_t off = 0; @@ -597,25 +597,25 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) 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 必须是整数或整数数组(集合型)——" - "行号就是 read_file 打出来的那些,从 1 开始。" - "要全文件替换所有匹配,就把 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 不能为空"); } - /* line 是集合:解析成升序去重的行号数组。上界(超过文件总行数)要读过文件 - * 才知道,放在后面查;这里只管写法本身对不对。省略 line 时集合是空的, - * 下面所有"按行"的检查都跳过,直接当全文件替换。 */ + /* line 是集合:解析成升序去重的行号数组. 上界 (超过文件总行数)要读过文件 + * 才知道, 放在后面查; 这里只管写法本身对不对. 省略 line 时集合是空的, + * 下面所有"按行"的检查都跳过, 直接当全文件替换. */ long lines[EDIT_LINE_MAX]; long nlines = 0; char req[128]; @@ -628,15 +628,15 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) lines_text(lines, nlines, req, sizeof req); } - /* 先看大小再读:edit_file 需要把整个文件放进内存, - * 对超大文件直接拒绝,避免把内存吃干。 - * 用 stat 而不是读完再看,否则拒绝之前就已经分配了。 */ + /* 先看大小再读:edit_file 需要把整个文件放进内存, + * 对超大文件直接拒绝, 避免把内存吃干. + * 用 stat 而不是读完再看, 否则拒绝之前就已经分配了. */ struct stat st; if (stat(path, &st) == 0 && (unsigned long long)st.st_size > LIGENT_MAX_EDIT_FILE) { *is_error = 1; return err_result( - "%s 有 %lld 字节,超过 edit_file 的 %u 字节上限。" - "请改用 run_command(如 sed/awk)做定点修改,或先拆分文件", + "%s 有 %lld 字节, 超过 edit_file 的 %u 字节上限. " + "请改用 run_command (如 sed/awk)做定点修改, 或先拆分文件", path, (long long)st.st_size, (unsigned)LIGENT_MAX_EDIT_FILE); } @@ -644,23 +644,23 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) char *src = read_entire_file(path, &flen); if (!src) { *is_error = 1; return err_result("无法读取 %s:%s", path, strerror(errno)); } - /* 二进制拒绝。这里看**全文件**(不像读类工具只看前 8 KB):edit_file 在内存里 - * 是按 C 字符串处理的(strstr/strlen),文件里任何一个 NUL 都会让后面的内容 - * 被当成结束 —— 改下去就是把原文件毁掉。 */ + /* 二进制拒绝. 这里看**全文件** (不像读类工具只看前 8 KB):edit_file 在内存里 + * 是按 C 字符串处理的 (strstr/strlen), 文件里任何一个 NUL 都会让后面的内容 + * 被当成结束 —— 改下去就是把原文件毁掉. */ { long at = buf_nul_at(src, flen); if (at >= 0) { free(src); *is_error = 1; return binary_err(path, at, - "edit_file 是按字符串做替换的,NUL 会让结果从那里被截断 —— " - "改下去等于把原文件毁了。要改二进制内容,用 run_command" - "(printf / dd / perl -pi 这类)。"); + "edit_file 是按字符串做替换的, NUL 会让结果从那里被截断 —— " + "改下去等于把原文件毁了. 要改二进制内容, 用 run_command" + " (printf / dd / perl -pi 这类). "); } } - /* 上界检查:下界(>=1)和写法在 parse_line_set 里已经查过,这里要文件总行数。 - * 省略 line 时没有行号可查。 */ + /* 上界检查:下界 (>=1)和写法在 parse_line_set 里已经查过, 这里要文件总行数. + * 省略 line 时没有行号可查. */ if (!global) { long total_lines = count_lines(src, flen); for (long i = 0; i < nlines; i++) { @@ -668,19 +668,19 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) free(src); *is_error = 1; if (total_lines == 0) - return err_result("%s 是空文件,没有第 %ld 行。", path, lines[i]); + return err_result("%s 是空文件, 没有第 %ld 行. ", path, lines[i]); if (nlines == 1) - return err_result("%s 只有 %ld 行,line=%ld 越界(行号从 1 开始," - "用 read_file 看当前行号)。", path, total_lines, lines[i]); - return err_result("%s 只有 %ld 行,line 里的 %ld 越界(行号从 1 开始," - "用 read_file 看当前行号)。", path, total_lines, lines[i]); + return err_result("%s 只有 %ld 行, line=%ld 越界 (行号从 1 开始, " + "用 read_file 看当前行号). ", path, total_lines, lines[i]); + return err_result("%s 只有 %ld 行, line 里的 %ld 越界 (行号从 1 开始, " + "用 read_file 看当前行号). ", path, total_lines, lines[i]); } } size_t olen = strlen(old_s); /* 搜索范围:给了集合就是集合圈住的那几行 —— 只认起点落在其中某一行上的匹配 - * (old_string 可以跨行),集合已去重所以同一处不会被数两遍;省略 line 就是 - * 整个文件,那时命中几处换几处(见下面的替换分支)。 */ + * (old_string 可以跨行), 集合已去重所以同一处不会被数两遍; 省略 line 就是 + * 整个文件, 那时命中几处换几处 (见下面的替换分支). */ size_t hits = 0; const char *at = NULL; if (global) { @@ -694,7 +694,7 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) } else { for (long i = 0; i < nlines; i++) { size_t ls = 0, le = 0; - if (!line_range(src, flen, lines[i], &ls, &le)) continue; /* 上面查过,不会越界 */ + if (!line_range(src, flen, lines[i], &ls, &le)) continue; /* 上面查过, 不会越界 */ for (const char *p = src + ls; ; ) { const char *hit = strstr(p, old_s); if (!hit || (size_t)(hit - src) >= le) break; @@ -706,15 +706,15 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) } if (hits == 0 && global) { - /* 省略 line 时没有"给的哪几行"可说,只说整个文件里没有 */ + /* 省略 line 时没有"给的哪几行"可说, 只说整个文件里没有 */ free(src); *is_error = 1; - return err_result("%s 整个文件里没有 old_string(注意缩进与换行必须逐字符" - "一致)。要给行范围就填 line:整数或整数数组。", path); + return err_result("%s 整个文件里没有 old_string (注意缩进与换行必须逐字符" + "一致). 要给行范围就填 line:整数或整数数组. ", path); } if (hits == 0) { - /* 顺手说清它到底在哪几行 —— 不然模型只能一遍遍猜行号,白烧轮次 */ + /* 顺手说清它到底在哪几行 —— 不然模型只能一遍遍猜行号, 白烧轮次 */ StrBuf w; sb_init(&w); const char *q = src, *scan = src; @@ -730,15 +730,15 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) *is_error = 1; char *msg; if (found == 0) - msg = err_result("%s 第 %s 行没有 old_string,整个文件里也没有" - "(注意缩进与换行必须逐字符一致)。", path, req); + msg = err_result("%s 第 %s 行没有 old_string, 整个文件里也没有" + " (注意缩进与换行必须逐字符一致). ", path, req); else if (found <= 4) - msg = err_result("%s 第 %s 行没有 old_string,它在第 %s 行。" - "要改那里,就把那一行的行号填进 line。", + msg = err_result("%s 第 %s 行没有 old_string, 它在第 %s 行. " + "要改那里, 就把那一行的行号填进 line. ", path, req, where); else - msg = err_result("%s 第 %s 行没有 old_string。它在第 %s 行等共 %ld 处," - "要改哪一处就把那一行的行号填进 line。", + msg = err_result("%s 第 %s 行没有 old_string. 它在第 %s 行等共 %ld 处, " + "要改哪一处就把那一行的行号填进 line. ", path, req, where, found); free(where); return msg; @@ -747,9 +747,9 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) if (hits > 1 && !replace_all && !global) { free(src); *is_error = 1; - return err_result("%s 第 %s 行里有 %zu 处 old_string,定不下来是哪一处。" - "把 old_string 写长一点(带上这几行里的上下文)," - "或者设 replace_all=true 把文件里所有匹配都换掉。", + return err_result("%s 第 %s 行里有 %zu 处 old_string, 定不下来是哪一处. " + "把 old_string 写长一点 (带上这几行里的上下文), " + "或者设 replace_all=true 把文件里所有匹配都换掉. ", path, req, hits); } @@ -767,7 +767,7 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) replaced++; } } else { - /* 只替换集合里的那一处(hits==1,上面已经确认过) */ + /* 只替换集合里的那一处 (hits==1, 上面已经确认过) */ size_t off = (size_t)(at - src); sb_append(&out, src, off); sb_puts(&out, new_s); @@ -779,7 +779,7 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) if (g_ligent_interrupt) { /* 同 write_file:改盘前一律先看中断 */ sb_free(&out); *is_error = 1; - return xstrdup("[已中断] 用户中断了这次操作,文件没有被改动。"); + return xstrdup("[已中断] 用户中断了这次操作, 文件没有被改动. "); } FILE *f = fopen(path, "wb"); @@ -797,15 +797,15 @@ 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 省略 = " - "没设行范围)", path, replaced, total); + return xasprintf("已在 %s 全文件替换 %ld 处 (写入 %zu 字节; line 省略 = " + "没设行范围)", path, replaced, total); if (replace_all) - return xasprintf("已在 %s 替换 %ld 处(写入 %zu 字节;line=%s 圈住的" - "那几行里至少有一处)", path, replaced, total, req); - return xasprintf("已在 %s 第 %s 行替换 1 处(写入 %zu 字节)", + return xasprintf("已在 %s 替换 %ld 处 (写入 %zu 字节; line=%s 圈住的" + "那几行里至少有一处)", path, replaced, total, req); + return xasprintf("已在 %s 第 %s 行替换 1 处 (写入 %zu 字节)", path, req, total); } @@ -841,14 +841,14 @@ static char *tool_list_dir(const JsonValue *args, int *is_error) } closedir(d); - /* 空目录时 list 还是 NULL,而 qsort 的第一个参数声明为不可为空 —— - * 传 NULL 进去是 UB(UBSan 会当场报 "null pointer passed as argument 1")。 - * attach.c 的 list_dir_sorted 用的是同一条 count > 1 守卫,两边保持一致。 */ + /* 空目录时 list 还是 NULL, 而 qsort 的第一个参数声明为不可为空 —— + * 传 NULL 进去是 UB (UBSan 会当场报 "null pointer passed as argument 1"). + * attach.c 的 list_dir_sorted 用的是同一条 count > 1 守卫, 两边保持一致. */ if (count > 1) qsort(list, count, sizeof(char *), cmp_names); 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,20 +869,20 @@ static char *tool_list_dir(const JsonValue *args, int *is_error) return sb_detach(&out); } -/* read_image:把本地图片交给模型看。 +/* read_image:把本地图片交给模型看. * - * 协议里图片只有两条路:外部 URL,或者 base64 内联。ligent 不替用户去网上抓图, - * 所以只收本地文件、产出 base64 内联。 + * 协议里图片只有两条路:外部 URL, 或者 base64 内联. ligent 不替用户去网上抓图, + * 所以只收本地文件、产出 base64 内联. * - * 图片字节不进历史:这里只把图片存成本地备份,历史里挂一个对备份的引用, - * 真正构造请求时才读出来编码(见 image.c 的 image_expand_for_request)。 + * 图片字节不进历史:这里只把图片存成本地备份, 历史里挂一个对备份的引用, + * 真正构造请求时才读出来编码 (见 image.c 的 image_expand_for_request). * - * 尺寸在**本地**就判掉。理由是图片会留在历史里:一次超限不是一次失败, - * 而是从此每一轮都超限。与其发出去等一个 400,不如当场告诉模型"太大了", - * 并且**不备份** —— 一张永远发不出去的图,留着备份没有意义。 + * 尺寸在**本地**就判掉. 理由是图片会留在历史里:一次超限不是一次失败, + * 而是从此每一轮都超限. 与其发出去等一个 400, 不如当场告诉模型"太大了", + * 并且**不备份** —— 一张永远发不出去的图, 留着备份没有意义. * - * 工具结果只能是字符串(协议要求),所以图片挂在"待发附件"里,由 - * agent_run 补一条 user 消息带上去 —— 图片只能出现在 user 消息中。 */ + * 工具结果只能是字符串 (协议要求), 所以图片挂在"待发附件"里, 由 + * agent_run 补一条 user 消息带上去 —— 图片只能出现在 user 消息中. */ static char *tool_read_image(const JsonValue *args, int *is_error) { REQUIRE_ERR_PTR(is_error); @@ -900,9 +900,9 @@ static char *tool_read_image(const JsonValue *args, int *is_error) image_pending_add(backup); - char *msg = xasprintf("已读入图片 %s(%s,%zu 字节,base64 内联后约 %.1f MiB)。" - "对话历史里只有对它的引用,不发图片数据;" - "本机备份 %s,发请求时展开成 base64。", + char *msg = xasprintf("已读入图片 %s (%s, %zu 字节, base64 内联后约 %.1f MiB). " + "对话历史里只有对它的引用, 不发图片数据; " + "本机备份 %s, 发请求时展开成 base64. ", path, mime, len, (double)base64_encoded_len(len) / 1048576.0, backup); free(backup); @@ -913,48 +913,48 @@ static char *tool_read_image(const JsonValue *args, int *is_error) /* 工具表:所有工具的**唯一真相** */ /* ------------------------------------------------------------------ */ /* - * 命名约定(改名前先读这里;README §5 有同一份,两处必须同步) + * 命名约定 (改名前先读这里; README §5 有同一份, 两处必须同步) * - * 1. 形式:全小写 snake_case,动宾结构「动词_对象」。动词必须来自白名单, - * 而白名单**按域划分** —— 要用新动词,先把它加进对应域的白名单: + * 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。 - * 模型靠名字判断该不该先征求同意(.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, - * 文件名过滤 include。同一个概念在两个工具里必须同名。 + * 历史编辑域: list / read / replace / delete / insert / undo (meta.c) + * 两域的动词不混用 (run 不会出现在历史编辑域, undo 也不会出现在文件域). + * 禁用 do / handle / process / get / exec 这类不说明对象的空动词. + * 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, + * 文件名过滤 include. 同一个概念在两个工具里必须同名. * - * 这张表同时生成四样东西:请求里的 schema(tools_schema)、执行分发 - * (tool_execute)、要不要走确认(tool_mutating)、/tools 的输出 - * (tools_print_list)。所以**改名只需要改 .name 一列** —— 以前这四处各有 - * 一份 strcmp 链,改名必定漏一处,而且漏了不报错(只是静默地少认一个名字)。 + * 这张表同时生成四样东西:请求里的 schema (tools_schema)、执行分发 + * (tool_execute)、要不要走确认 (tool_mutating)、/tools 的输出 + * (tools_print_list). 所以**改名只需要改 .name 一列** —— 以前这四处各有 + * 一份 strcmp 链, 改名必定漏一处, 而且漏了不报错 (只是静默地少认一个名字). * - * 两条约束写在这里,因为从表里看不出来: + * 两条约束写在这里, 因为从表里看不出来: * - needs_vision == 1 的工具会在"探测到模型不收图片"时被摘掉 - * (agent.c 的 tools_for_request 会调 tools_drop)。 + * (agent.c 的 tools_for_request 会调 tools_drop). * - * 顺序 = 请求里工具表的顺序,也是 /tools 的打印顺序。改顺序不影响行为, - * 但会让 tests/fixtures 里录制的请求对不上,所以顺序变动要连着 fixtures 一起改。 + * 顺序 = 请求里工具表的顺序, 也是 /tools 的打印顺序. 改顺序不影响行为, + * 但会让 tests/fixtures 里录制的请求对不上, 所以顺序变动要连着 fixtures 一起改. * - * 为什么**没有**搜索内容的工具(曾经有过 search_files,后来改名 grep,最后删了): + * 为什么**没有**搜索内容的工具 (曾经有过 search_files, 后来改名 grep, 最后删了): * 模型用 run_command 里的真 grep 就行 —— `grep -rn` 支持正则、文件名过滤、 - * 上下文行、大小写、多模式,我们这个包装每一样都要自己重写一遍,还得跟着 - * 需求慢慢长(第一个版本就是"搜内容"却叫 search_files,让人以为按文件名搜)。 - * 少一个非必需的工具,工具表更短、模型的误用面更小。 + * 上下文行、大小写、多模式, 我们这个包装每一样都要自己重写一遍, 还得跟着 + * 需求慢慢长 (第一个版本就是"搜内容"却叫 search_files, 让人以为按文件名搜). + * 少一个非必需的工具, 工具表更短、模型的误用面更小. */ typedef struct { const char *name; - const char *type; /* JSON-Schema 类型:string / integer / boolean; - * 数组写成 "元素类型[]"(如 "integer[]"), + const char *type; /* JSON-Schema 类型:string / integer / boolean; + * 数组写成 "元素类型[]" (如 "integer[]"), * 交给 tools_schema 展开成 {type:array, items:{...}} */ const char *desc; int required; @@ -966,8 +966,8 @@ typedef struct { typedef struct { const char *name; const char *desc; /* 给模型看的完整说明 */ - const char *ui_key; /* /tools 那一行文案的 i18n 键(纯 UI) */ - const ToolParam *params; /* 以 PARAM_END 结尾;可以为 NULL */ + const char *ui_key; /* /tools 那一行文案的 i18n 键 (纯 UI) */ + const ToolParam *params; /* 以 PARAM_END 结尾; 可以为 NULL */ int mutating; /* 会改盘或执行命令 → 先问用户 */ int needs_vision; /* 模型不收图时摘掉 */ char *(*fn)(const JsonValue *args, int *is_error); @@ -975,26 +975,26 @@ typedef struct { static const ToolParam P_RUN_COMMAND[] = { { "command", "string", - "要执行的 shell 命令(由 /bin/sh -c 执行,支持管道与重定向)", 1 }, + "要执行的 shell 命令 (由 /bin/sh -c 执行, 支持管道与重定向)", 1 }, { "elevated", "boolean", - "置 true 时以 root 执行这一条命令。**只在真的撞到权限错误之后才用**," - "不要预防性地加。加了之后:你会先被要求确认(看清要跑什么)," - "再由 sudo 向用户要密码 —— 密码是用户敲的,你敲不出来。" - "所以被拒绝时不要反复重试,说明你需要用户亲自做什么即可。", 0 }, - { "timeout", "integer", "超时秒数,默认 60,最大 600", 0 }, + "置 true 时以 root 执行这一条命令. **只在真的撞到权限错误之后才用**, " + "不要预防性地加. 加了之后:你会先被要求确认 (看清要跑什么), " + "再由 sudo 向用户要密码 —— 密码是用户敲的, 你敲不出来. " + "所以被拒绝时不要反复重试, 说明你需要用户亲自做什么即可. ", 0 }, + { "timeout", "integer", "超时秒数, 默认 60, 最大 600", 0 }, PARAM_END }; static const ToolParam P_READ_FILE[] = { - { "path", "string", "文件路径,相对于工作目录或绝对路径", 1 }, - { "offset", "integer", "起始行号(从 1 开始),默认 1", 0 }, - { "limit", "integer", "最多读取的行数,默认 2000", 0 }, + { "path", "string", "文件路径, 相对于工作目录或绝对路径", 1 }, + { "offset", "integer", "起始行号 (从 1 开始), 默认 1", 0 }, + { "limit", "integer", "最多读取的行数, 默认 2000", 0 }, PARAM_END }; static const ToolParam P_WRITE_FILE[] = { { "path", "string", - "文件路径;父目录不存在会自动创建。**已经存在的二进制文件会被拒绝覆盖**", 1 }, + "文件路径; 父目录不存在会自动创建. **已经存在的二进制文件会被拒绝覆盖**", 1 }, { "content", "string", "要写入的完整文件内容", 1 }, PARAM_END }; @@ -1002,76 +1002,76 @@ static const ToolParam P_WRITE_FILE[] = { static const ToolParam P_EDIT_FILE[] = { { "path", "string", "要修改的文件路径", 1 }, { "line", "integer[]", - "old_string **起点**所在的行号,集合型,**可以整个省略**:单个整数(`12`)" - "或整数数组(`[12,13]`)都收,行号就是 read_file 打出来的那些(从 1 开始)。" - "数组的用法:old_string 跨几行、又不想赌起点是第几行时,把覆盖到的那几行" - "都填进来;替换只发生在这些行里。**省略 line = 全文件替换所有匹配**,", 0 }, + "old_string **起点**所在的行号, 集合型, **可以整个省略**:单个整数 (`12`)" + "或整数数组 (`[12,13]`)都收, 行号就是 read_file 打出来的那些 (从 1 开始). " + "数组的用法:old_string 跨几行、又不想赌起点是第几行时, 把覆盖到的那几行" + "都填进来; 替换只发生在这些行里. **省略 line = 全文件替换所有匹配**, ", 0 }, { "old_string", "string", - "被替换的原文,必须与文件内容逐字符一致(含缩进),且**在 line 圈住的行里**" - "只有一处匹配(匹配可以跨行,起点落在集合里即可)", 1 }, + "被替换的原文, 必须与文件内容逐字符一致 (含缩进), 且**在 line 圈住的行里**" + "只有一处匹配 (匹配可以跨行, 起点落在集合里即可)", 1 }, { "new_string", "string", "替换后的新文本", 1 }, { "replace_all", "boolean", - "为 true 时替换**全文件**里所有匹配(line 仍必须圈住其中真实的一处)," - "默认 false(只替换 line 圈住的那一处)", 0 }, + "为 true 时替换**全文件**里所有匹配 (line 仍必须圈住其中真实的一处), " + "默认 false (只替换 line 圈住的那一处)", 0 }, PARAM_END }; static const ToolParam P_LIST_DIR[] = { - { "path", "string", "目录路径,默认当前目录", 0 }, + { "path", "string", "目录路径, 默认当前目录", 0 }, PARAM_END }; static const ToolParam P_READ_IMAGE[] = { { "path", "string", - "本地图片路径。支持 PNG / JPEG / GIF / WebP(按文件内容判断,不看扩展名)", 1 }, + "本地图片路径. 支持 PNG / JPEG / GIF / WebP (按文件内容判断, 不看扩展名)", 1 }, PARAM_END }; static const ToolDef TOOLS[] = { { TOOL_RUN_COMMAND, - "在工作目录中执行 shell 命令,返回合并后的 stdout/stderr 与退出码。" - "适合编译、运行测试、git 操作等。", + "在工作目录中执行 shell 命令, 返回合并后的 stdout/stderr 与退出码. " + "适合编译、运行测试、git 操作等. ", "tools.ui.run_command", P_RUN_COMMAND, 1, 0, tool_run_command }, { TOOL_READ_FILE, - "读取文本文件内容,返回带行号的文本;行号可以直接喂给 edit_file 的 line。" - "大文件请用 offset/limit 分段读取。" - "**二进制文件会被拒绝**(它只处理文本)。", + "读取文本文件内容, 返回带行号的文本; 行号可以直接喂给 edit_file 的 line. " + "大文件请用 offset/limit 分段读取. " + "**二进制文件会被拒绝** (它只处理文本). ", "tools.ui.read_file", P_READ_FILE, 0, 0, tool_read_file }, { TOOL_WRITE_FILE, - "创建或整体覆盖一个文本文件(会覆盖原有内容)。修改已有文件优先用 edit_file。" - "目标已经是二进制文件时会被拒绝覆盖。", + "创建或整体覆盖一个文本文件 (会覆盖原有内容). 修改已有文件优先用 edit_file. " + "目标已经是二进制文件时会被拒绝覆盖. ", "tools.ui.write_file", P_WRITE_FILE, 1, 0, tool_write_file }, { TOOL_EDIT_FILE, - "在文件中做精确字符串替换。line 是集合型且**可省略**:给一个整数(`12`)" - "或整数数组(`[12,13]`)—— 把 old_string 覆盖到的那几行都填进来,起点到底" - "是第几行就不必赌;替换只发生在这些行里,old_string 也只需在这些行里唯一。" - "**省略 line = 全文件替换所有匹配**(就是编辑器的\"全局替换\"),只在你确实" - "要改全文件时才省。行号对不上会报出 old_string 真正在哪几行,照着改 line。", + "在文件中做精确字符串替换. line 是集合型且**可省略**:给一个整数 (`12`)" + "或整数数组 (`[12,13]`)—— 把 old_string 覆盖到的那几行都填进来, 起点到底" + "是第几行就不必赌; 替换只发生在这些行里, old_string 也只需在这些行里唯一. " + "**省略 line = 全文件替换所有匹配** (就是编辑器的\"全局替换\"), 只在你确实" + "要改全文件时才省. 行号对不上会报出 old_string 真正在哪几行, 照着改 line. ", "tools.ui.edit_file", P_EDIT_FILE, 1, 0, tool_edit_file }, { TOOL_LIST_DIR, - "列出目录内容,含文件类型与大小。", + "列出目录内容, 含文件类型与大小. ", "tools.ui.list_dir", P_LIST_DIR, 0, 0, tool_list_dir }, { TOOL_READ_IMAGE, - "读取一张本地图片并把它交给你看(截图、设计稿、图表、报错弹窗都行)。" - "图片会以 base64 内联进请求,本机留一份备份、历史里只记对备份的引用。" - "需要看图片内容时用它,不要试着用 read_file 去读二进制。" - "图片超过内联体积上限时会被直接拒绝,这时请让用户先缩小或压缩。", + "读取一张本地图片并把它交给你看 (截图、设计稿、图表、报错弹窗都行). " + "图片会以 base64 内联进请求, 本机留一份备份、历史里只记对备份的引用. " + "需要看图片内容时用它, 不要试着用 read_file 去读二进制. " + "图片超过内联体积上限时会被直接拒绝, 这时请让用户先缩小或压缩. ", "tools.ui.read_image", P_READ_IMAGE, 0, 1, tool_read_image }, @@ -1079,11 +1079,11 @@ static const ToolDef TOOLS[] = { #define TOOL_COUNT (sizeof TOOLS / sizeof TOOLS[0]) -/* 按名字找定义,找不到返回 NULL。 +/* 按名字找定义, 找不到返回 NULL. * - * **故意不保留旧名别名**:schema 里只发新名,dispatch 再认旧名只会掩盖 - * "调用方没跟着改"这件事(旧存档、用户自写的提示词、测试夹具都一样)。 - * 名字即协议,认不出来的就明确报"未知工具",比静默兼容更容易发现漏改。 */ + * **故意不保留旧名别名**:schema 里只发新名, dispatch 再认旧名只会掩盖 + * "调用方没跟着改"这件事 (旧存档、用户自写的提示词、测试夹具都一样). + * 名字即协议, 认不出来的就明确报"未知工具", 比静默兼容更容易发现漏改. */ static const ToolDef *find_tool(const char *name) { if (!name || !*name) return NULL; @@ -1092,7 +1092,7 @@ static const ToolDef *find_tool(const char *name) return NULL; } -/* 从表生成 chat/completions 的 tools 数组(调用者释放) */ +/* 从表生成 chat/completions 的 tools 数组 (调用者释放) */ JsonValue *tools_schema(void) { JsonValue *tools = json_new_array(); @@ -1113,8 +1113,8 @@ 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') { char elem[16]; @@ -1153,9 +1153,9 @@ void tools_drop(JsonValue *tools, const char *name) } } -/* hide_image:探测到模型不收图片时为 1 —— /tools 不该列一个已经藏起来的工具, - * 否则用户看到的是谎话。列表本身从工具表生成,所以它和真正发出去的工具表 - * 天然一致(以前这里是手写的一串 puts,就漏过一个工具)。 */ +/* hide_image:探测到模型不收图片时为 1 —— /tools 不该列一个已经藏起来的工具, + * 否则用户看到的是谎话. 列表本身从工具表生成, 所以它和真正发出去的工具表 + * 天然一致 (以前这里是手写的一串 puts, 就漏过一个工具). */ void tools_print_list(int hide_image) { puts(t("tools.ui.header")); @@ -1165,8 +1165,8 @@ void tools_print_list(int hide_image) printf("%s\n", tf("tools.ui.hidden", "name", d->name, NULL)); continue; } - /* 文案在 i18n 表里(键就在上面那一列):/tools 是纯给人看的东西, - * 而表里的 desc 是发给模型的,两者本来就该分开 */ + /* 文案在 i18n 表里 (键就在上面那一列):/tools 是纯给人看的东西, + * 而表里的 desc 是发给模型的, 两者本来就该分开 */ printf(" %-18s%s\n", d->name, t(d->ui_key)); } } @@ -1177,9 +1177,9 @@ 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) { JsonValue *lv = json_object_get(args, "line"); @@ -1199,11 +1199,11 @@ void tool_lines_label(const JsonValue *args, char *out, size_t cap) } } -/* ---- edit_file 的新旧对照(带行号)---- +/* ---- edit_file 的新旧对照 (带行号)---- * - * 行号取自 line 参数:替换就发生在那里,所以两侧的起始行号相同 —— 新内容落在 - * 旧内容原来的位置上。line 省略(全文件替换)时没有确定的起点,就**不标**行号: - * 编一个出来比不标更糟。 + * 行号取自 line 参数:替换就发生在那里, 所以两侧的起始行号相同 —— 新内容落在 + * 旧内容原来的位置上. line 省略 (全文件替换)时没有确定的起点, 就**不标**行号: + * 编一个出来比不标更糟. */ #define DIFF_MAX_LINES 8 #define DIFF_MAX_COLS 200 @@ -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"); @@ -1288,13 +1288,13 @@ 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) { if (is_error) *is_error = 1; - return err_result("工具 \"%s\" 不能在这里直接执行(它只在主对话里可用," - "由 agent.c 处理)", name); + return err_result("工具 \"%s\" 不能在这里直接执行 (它只在主对话里可用, " + "由 agent.c 处理)", name); } return d->fn(args, is_error); } diff --git a/src/tools.h b/src/tools.h index c59b805..efe3e5f 100644 --- a/src/tools.h +++ b/src/tools.h @@ -1,4 +1,4 @@ -/* tools.h - 提供给模型的工具(function calling) */ +/* tools.h - 提供给模型的工具 (function calling) */ #ifndef LIGENT_TOOLS_H #define LIGENT_TOOLS_H @@ -8,12 +8,12 @@ /* ---- 工具名 ---- * - * 名字是协议的一部分:它进 schema、进执行分发、进 /tools 的输出。所以**名字 - * 只在这里定义一次**,别处一律用宏 —— 改名时漏一处不会编译报错,只会静默地 - * 少认一个工具。 + * 名字是协议的一部分:它进 schema、进执行分发、进 /tools 的输出. 所以**名字 + * 只在这里定义一次**, 别处一律用宏 —— 改名时漏一处不会编译报错, 只会静默地 + * 少认一个工具. * - * 命名约定(动宾结构、动词白名单、副作用可读、长度上限……)见 tools.c 顶部的 - * 工具表注释,用户向的说明在 README §5,三处必须同步。 */ + * 命名约定 (动宾结构、动词白名单、副作用可读、长度上限……)见 tools.c 顶部的 + * 工具表注释, 用户向的说明在 README §5, 三处必须同步. */ #define TOOL_RUN_COMMAND "run_command" #define TOOL_READ_FILE "read_file" #define TOOL_WRITE_FILE "write_file" @@ -21,54 +21,54 @@ #define TOOL_LIST_DIR "list_dir" #define TOOL_READ_IMAGE "read_image" -/* 返回 tools 数组,用于 chat/completions 请求(调用者释放)。 - * 内容由 tools.c 的工具表生成,不在这里维护。 */ +/* 返回 tools 数组, 用于 chat/completions 请求 (调用者释放). + * 内容由 tools.c 的工具表生成, 不在这里维护. */ JsonValue *tools_schema(void); -/* 从工具表里摘掉一个工具(按 function.name 找,用上面的 TOOL_* 宏)。 - * 没找到就什么都不做。能力探测发现模型不收图片时,用它把 read_image 收敛掉。 */ +/* 从工具表里摘掉一个工具 (按 function.name 找, 用上面的 TOOL_* 宏). + * 没找到就什么都不做. 能力探测发现模型不收图片时, 用它把 read_image 收敛掉. */ void tools_drop(JsonValue *tools, const char *name); -/* 所有工具都通过 *is_error 回报失败。**调用方必须给一个有效指针** —— - * 这条约定在每个工具函数的入口用 assert 钉住(见 tools.c 的 REQUIRE_ERR_PTR): - * 它既是文档,也让静态分析不必为每个 `*is_error = 1` 报一遍"可能解引用空指针" - * (那 30 多条噪声会把真正的问题淹掉)。 */ +/* 所有工具都通过 *is_error 回报失败. **调用方必须给一个有效指针** —— + * 这条约定在每个工具函数的入口用 assert 钉住 (见 tools.c 的 REQUIRE_ERR_PTR): + * 它既是文档, 也让静态分析不必为每个 `*is_error = 1` 报一遍"可能解引用空指针" + * (那 30 多条噪声会把真正的问题淹掉). */ #define REQUIRE_ERR_PTR(p) do { assert((p) != NULL); } while (0) -/* 执行一个工具调用。返回 malloc 的结果文本;*is_error 置 1 表示失败 */ +/* 执行一个工具调用. 返回 malloc 的结果文本; *is_error 置 1 表示失败 */ char *tool_execute(const char *name, const JsonValue *args, int *is_error); -/* 该工具是否会改动磁盘或执行命令。 - * 只读工具(read_file / list_dir)返回 0。 - * 这份"哪些工具会写"的知识属于工具层,确认策略由上层决定。 */ +/* 该工具是否会改动磁盘或执行命令. + * 只读工具 (read_file / list_dir)返回 0. + * 这份"哪些工具会写"的知识属于工具层, 确认策略由上层决定. */ int tool_mutating(const char *name); -/* 工具的简短列表,用于 /tools 命令 */ +/* 工具的简短列表, 用于 /tools 命令 */ void tools_print_list(int hide_image); -/* 把 edit_file 的 line 参数(整数或整数数组)渲染成 "1122, 1123"(超过 8 个就 - * 省略尾部)。一个合法行号都没有(也就是没给 line)时留空串,调用方据此走 - * "全文件"那条文案。确认提示与调用摘要共用这一处。 */ +/* 把 edit_file 的 line 参数 (整数或整数数组)渲染成 "1122, 1123" (超过 8 个就 + * 省略尾部). 一个合法行号都没有 (也就是没给 line)时留空串, 调用方据此走 + * "全文件"那条文案. 确认提示与调用摘要共用这一处. */ void tool_lines_label(const JsonValue *args, char *out, size_t cap); -/* 把 edit_file 的旧/新文本渲染成**带行号的 diff**("- 旧 / + 新"),写进 out。 +/* 把 edit_file 的旧/新文本渲染成**带行号的 diff** ("- 旧 / + 新"), 写进 out. * - * 行号取自 line 参数 —— 替换就发生在那里,两侧起始行号相同(新内容落在原来的 - * 位置上);line 省略(全文件替换)时没有确定的起始行号,就不标 —— 编一个比 - * 不标更糟。每侧最多 8 行,其余折成一句"还有 N 行"。 + * 行号取自 line 参数 —— 替换就发生在那里, 两侧起始行号相同 (新内容落在原来的 + * 位置上); line 省略 (全文件替换)时没有确定的起始行号, 就不标 —— 编一个比 + * 不标更糟. 每侧最多 8 行, 其余折成一句"还有 N 行". * - * prefix 是每行前面那截(调用摘要用 " │ "、确认提示用 " "); - * use_color=1 时给 - / + 上红绿。返回 1 表示渲染过内容(args 里没有这两个字段 - * 时返回 0,调用方回退到别的显示)。 + * prefix 是每行前面那截 (调用摘要用 " │ "、确认提示用 " "); + * use_color=1 时给 - / + 上红绿. 返回 1 表示渲染过内容 (args 里没有这两个字段 + * 时返回 0, 调用方回退到别的显示). * - * 确认提示(动手前)和执行之后用的是**同一个**渲染 —— 用户确认时看到的和事后 - * 看到的必须是同一份东西,不能因为两处各写一遍而长得不一样。 */ + * 确认提示 (动手前)和执行之后用的是**同一个**渲染 —— 用户确认时看到的和事后 + * 看到的必须是同一份东西, 不能因为两处各写一遍而长得不一样. */ int tool_render_diff(FILE *out, const JsonValue *args, const char *prefix, int use_color); -/* 单次工具输出的上限(字节) */ +/* 单次工具输出的上限 (字节) */ #define LIGENT_TOOL_OUTPUT_LIMIT (100 * 1024) -/* edit_file 能处理的文件大小上限。它要把整个文件读进内存 */ +/* edit_file 能处理的文件大小上限. 它要把整个文件读进内存 */ #define LIGENT_MAX_EDIT_FILE (32u * 1024 * 1024) #endif /* LIGENT_TOOLS_H */ diff --git a/src/util.c b/src/util.c index 151dbe7..46c025f 100644 --- a/src/util.c +++ b/src/util.c @@ -110,12 +110,12 @@ void sb_clear(StrBuf *sb) void sb_reserve(StrBuf *sb, size_t extra) { - /* 提前返回要求 data 非空:不能只看容量够不够。 - * 代码里其实维护着「data == NULL 当且仅当 cap == 0」这个不变量(sb_init / - * sb_free / sb_detach 三处),但那是隐式的、没人强制;一旦哪次改动让它不成立, - * 这里就会带着 NULL 指针回去,下一行 memcpy 才炸,离原因很远。 - * 写成 sb->data && … 之后,函数出口天然保证 data 非空(xrealloc 失败即退出), - * 不变量坏掉也只会退化成一次多余的重分配。 */ + /* 提前返回要求 data 非空:不能只看容量够不够. + * 代码里其实维护着「data == NULL 当且仅当 cap == 0」这个不变量 (sb_init / + * sb_free / sb_detach 三处), 但那是隐式的、没人强制; 一旦哪次改动让它不成立, + * 这里就会带着 NULL 指针回去, 下一行 memcpy 才炸, 离原因很远. + * 写成 sb->data && … 之后, 函数出口天然保证 data 非空 (xrealloc 失败即退出), + * 不变量坏掉也只会退化成一次多余的重分配. */ if (sb->data && sb->len + extra + 1 <= sb->cap) return; size_t cap = sb->cap ? sb->cap : 64; while (cap < sb->len + extra + 1) cap *= 2; @@ -214,8 +214,8 @@ char *read_entire_file(const char *path, size_t *out_len) size_t n; while ((n = fread(buf, 1, sizeof buf, f)) > 0) sb_append(&sb, buf, n); - /* fread 返回 0 既可能是 EOF,也可能是读错误。分不清的话就会把**截断的 - * 内容**当成功返回 —— 对图片尤其糟:会拿半张图去备份、去上传。 */ + /* fread 返回 0 既可能是 EOF, 也可能是读错误. 分不清的话就会把**截断的 + * 内容**当成功返回 —— 对图片尤其糟:会拿半张图去备份、去上传. */ int bad = ferror(f); fclose(f); if (bad) { sb_free(&sb); return NULL; } @@ -282,15 +282,15 @@ size_t utf8_seq_len(const char *s, size_t len) if ((c & 0xE0) == 0xC0) { n = 2; cp = c & 0x1Fu; } else if ((c & 0xF0) == 0xE0) { n = 3; cp = c & 0x0Fu; } else if ((c & 0xF8) == 0xF0) { n = 4; cp = c & 0x07u; } - else return 0; /* 0x80-0xBF 是孤立的续字节;0xF8-0xFF 不可能是首字节 */ + else return 0; /* 0x80-0xBF 是孤立的续字节; 0xF8-0xFF 不可能是首字节 */ if (len < n) return 0; /* 被截断 */ for (size_t i = 1; i < n; i++) { if ((p[i] & 0xC0) != 0x80) return 0; cp = (cp << 6) | (p[i] & 0x3Fu); } - /* 下面这些"格式对但值非法"的情况,json 解析器一样会拒 —— 过长编码 - * (比如 0xC0 0xAF 表示 '/')、UTF-16 代理区、超出 U+10FFFF。 */ + /* 下面这些"格式对但值非法"的情况, json 解析器一样会拒 —— 过长编码 + * (比如 0xC0 0xAF 表示 '/')、UTF-16 代理区、超出 U+10FFFF. */ if (n == 2 && cp < 0x80) return 0; if (n == 3 && cp < 0x800) return 0; if (n == 4 && cp < 0x10000) return 0; @@ -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,8 +425,8 @@ void random_alnum(char *out, size_t n) size_t base64_encoded_len(size_t n) { - /* 每 3 字节出 4 个字符,不足的补 '='。先算长度再决定要不要编码: - * 一张 24 MiB 的图编码后是 32 MiB,正好卡在内联上限上。 */ + /* 每 3 字节出 4 个字符, 不足的补 '='. 先算长度再决定要不要编码: + * 一张 24 MiB 的图编码后是 32 MiB, 正好卡在内联上限上. */ return ((n + 2) / 3) * 4; } diff --git a/src/util.h b/src/util.h index aa78278..35a653c 100644 --- a/src/util.h +++ b/src/util.h @@ -5,12 +5,12 @@ #include #include -/* 让我们自己的 printf 风格函数也受编译器检查。 +/* 让我们自己的 printf 风格函数也受编译器检查. * * 之前全项目 __attribute__ 出现 0 次 —— 于是 xasprintf / sb_printf / - * err_result 这一百多个调用点的格式串**编译器一个都没在看**。直接写 fprintf - * 的能查(confirm.c 那次 %s 对 int 就是这么抓到的),走包装的就漏过去了。 - * gcc/clang 给属性,tcc 没有这个机制,空宏。 */ + * err_result 这一百多个调用点的格式串**编译器一个都没在看**. 直接写 fprintf + * 的能查 (confirm.c 那次 %s 对 int 就是这么抓到的), 走包装的就漏过去了. + * gcc/clang 给属性, tcc 没有这个机制, 空宏. */ #if defined(__GNUC__) || defined(__clang__) # define LIGENT_PRINTF(fmt_idx, arg_idx) \ __attribute__((format(printf, fmt_idx, arg_idx))) @@ -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); @@ -42,67 +42,67 @@ void sb_append(StrBuf *sb, const char *data, size_t len); void sb_puts(StrBuf *sb, const char *s); void sb_putc(StrBuf *sb, char c); void sb_printf(StrBuf *sb, const char *fmt, ...) LIGENT_PRINTF(2, 3); -/* 取出缓冲区所有权(NUL 结尾),并把 sb 重置为空 */ +/* 取出缓冲区所有权 (NUL 结尾), 并把 sb 重置为空 */ char *sb_detach(StrBuf *sb); /* ---- 字符串小工具 ---- */ int str_starts_with(const char *s, const char *prefix); int str_ends_with(const char *s, const char *suffix); char *str_trim(char *s); -/* 读取整个文件到内存;失败返回 NULL。*out_len 可为 NULL */ +/* 读取整个文件到内存; 失败返回 NULL. *out_len 可为 NULL */ char *read_entire_file(const char *path, size_t *out_len); -/* 递归创建目录(类似 mkdir -p);成功返回 0 */ +/* 递归创建目录 (类似 mkdir -p); 成功返回 0 */ int mkdir_p(const char *path); -/* 把 src 中所有 ${VAR} 展开为环境变量(未定义则展开为空串) */ +/* 把 src 中所有 ${VAR} 展开为环境变量 (未定义则展开为空串) */ char *expand_env(const char *src); -/* ---- 文本 / 二进制的判据(唯一定义处) ---- +/* ---- 文本 / 二进制的判据 (唯一定义处) ---- * - * 判据只有一条:**出现 NUL 字节**。文本文件里不该有它,而 UTF-16、压缩包、 - * 可执行文件、稀疏文件这类都会中招。 + * 判据只有一条:**出现 NUL 字节**. 文本文件里不该有它, 而 UTF-16、压缩包、 + * 可执行文件、稀疏文件这类都会中招. * - * 探测窗口是 8 KB —— 读类工具没必要为了拒绝一个 10 GB 的文件把它整个读一遍。 + * 探测窗口是 8 KB —— 读类工具没必要为了拒绝一个 10 GB 的文件把它整个读一遍. * 判据和窗口必须由所有"处理文本"的地方共用:以前 tools.c 和 attach.c 各写了 - * 一份,窗口还不一样(一个看前 8 KB、一个看全文件),于是同一个文件会出现 - * "/include 说它是二进制、read_file 说它能读"这种自相矛盾的说法。 + * 一份, 窗口还不一样 (一个看前 8 KB、一个看全文件), 于是同一个文件会出现 + * "/include 说它是二进制、read_file 说它能读"这种自相矛盾的说法. * - * 唯一的例外是 edit_file 的**全文件**检查:它在内存里按 C 字符串处理, - * 任何位置的 NUL 都会让结果从那里被截断,所以它不能只看开头。 */ + * 唯一的例外是 edit_file 的**全文件**检查:它在内存里按 C 字符串处理, + * 任何位置的 NUL 都会让结果从那里被截断, 所以它不能只看开头. */ #define LIGENT_BIN_PROBE (8 * 1024) -/* 返回第一个 NUL 的偏移;没有则返回 -1 */ +/* 返回第一个 NUL 的偏移; 没有则返回 -1 */ long buf_nul_at(const char *data, size_t n); -/* 这段字节看起来是二进制吗?*at(可传 NULL)回填第一个 NUL 的偏移 */ +/* 这段字节看起来是二进制吗?*at (可传 NULL)回填第一个 NUL 的偏移 */ int looks_binary(const char *data, size_t n, long *at); /* ---- 随机名 ---- * - * n 个 [a-z0-9] 字符 + NUL 结尾(out 至少 n+1 字节)。优先 /dev/urandom, - * 拿不到就用时间 ^ pid 播种的伪随机兜底 —— 它只用来避免文件名撞车,不是密钥。 - * 存档名与图片备份名共用这一处(以前各写了一份,连退化种子都各写一遍)。 */ + * n 个 [a-z0-9] 字符 + NUL 结尾 (out 至少 n+1 字节). 优先 /dev/urandom, + * 拿不到就用时间 ^ pid 播种的伪随机兜底 —— 它只用来避免文件名撞车, 不是密钥. + * 存档名与图片备份名共用这一处 (以前各写了一份, 连退化种子都各写一遍). */ void random_alnum(char *out, size_t n); -/* ---- base64(内联图片用) ---- */ +/* ---- base64 (内联图片用) ---- */ -/* 编码后的长度,不实际编码 —— 用来"先算尺寸,再决定值不值得编码" */ +/* 编码后的长度, 不实际编码 —— 用来"先算尺寸, 再决定值不值得编码" */ size_t base64_encoded_len(size_t n); -/* 标准 base64 编码(RFC 4648,带 = 补位,不换行),返回 malloc 的字符串 */ +/* 标准 base64 编码 (RFC 4648, 带 = 补位, 不换行), 返回 malloc 的字符串 */ char *base64_encode(const unsigned char *data, size_t n); /* ---- UTF-8 与终端显示宽度 ---- */ -/* 解码一个码点,*adv 为消耗的字节数(输入非法时退化为 1 字节)。 - * 注意它是**宽松**的:不校验续字节格式、过长编码、代理区,只按首字节定长度。 - * 想判断"这段字节是不是合法 UTF-8"要用下面的 utf8_seq_len。 */ +/* 解码一个码点, *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。 +/* s 处是否是一个**合法** UTF-8 序列:合法返回序列长度 (1-4), 非法/被截断返回 0. * 比 utf8_decode 严格 —— 它另外拒绝:续字节格式不对、过长编码、UTF-16 代理区 - * (U+D800..U+DFFF)、以及超出 U+10FFFF 的编码。这些在 JSON 里同样是非法的, - * 所以序列化前必须按这个判据过一遍。 */ + * (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 处前进/后退一个字符,返回新下标(不越界) */ +/* 从 i 处前进/后退一个字符, 返回新下标 (不越界) */ size_t utf8_next(const char *s, size_t n, size_t i); size_t utf8_prev(const char *s, size_t n, size_t i); /* 整段文本占用的终端列数 */ diff --git a/src/version.c b/src/version.c index 17a804a..1ddd897 100644 --- a/src/version.c +++ b/src/version.c @@ -4,10 +4,10 @@ #include #include -/* 长度账(最坏情况都要放得下,否则 gcc 会报 -Wformat-truncation, - * 而那个警告是对的 —— 截断的版本串比报错更糟): +/* 长度账 (最坏情况都要放得下, 否则 gcc 会报 -Wformat-truncation, + * 而那个警告是对的 —— 截断的版本串比报错更糟): * 主体 2026.09.22-abcdef7.dirty 25 - * 分支 LIGENT_BRANCH_MAX 40(见下,截断) + * 分支 LIGENT_BRANCH_MAX 40 (见下, 截断) * 时间 2026-09-22 04:32:50 19 * 编译器 GCC 16.2.1 20260810 20 * 平台 linux/x86_64 14 @@ -15,19 +15,19 @@ * --- * 130 * - * 下面这些尺寸是**按组成部分算出来的**,不是拍的。上一版我直接写 320 然后靠 - * 人肉估计"应该够",gcc 立刻用 -Wformat-truncation 指出它证明不了够 —— - * 因为 where[] 和 buf_short[] 的尺寸加起来就已经超过 320 了。让编译器能自己 - * 算出"装得下",比加大数字再赌一把可靠。 */ + * 下面这些尺寸是**按组成部分算出来的**, 不是拍的. 上一版我直接写 320 然后靠 + * 人肉估计"应该够", gcc 立刻用 -Wformat-truncation 指出它证明不了够 —— + * 因为 where[] 和 buf_short[] 的尺寸加起来就已经超过 320 了. 让编译器能自己 + * 算出"装得下", 比加大数字再赌一把可靠. */ #define LIGENT_BRANCH_MAX 40 -/* 编译器串和平台串的缓冲尺寸(在 ligent_compiler_string/platform_string 里用) */ +/* 编译器串和平台串的缓冲尺寸 (在 ligent_compiler_string/platform_string 里用) */ #define LIGENT_COMPILER_MAX 96 #define LIGENT_PLATFORM_MAX 64 /* 主体:CalVer(10) + "-" + hash(7) + ".dirty"(6) + 余量 = 160 够宽松 */ #define LIGENT_VERBODY_MAX 160 -/* branch 显示缓冲:截断到 LIGENT_BRANCH_MAX 个字符,每个 UTF-8 字符最多 4 - * 字节,再加 "..."(3) 和 NUL */ +/* branch 显示缓冲:截断到 LIGENT_BRANCH_MAX 个字符, 每个 UTF-8 字符最多 4 + * 字节, 再加 "..."(3) 和 NUL */ #define LIGENT_BRANCH_BUF (LIGENT_BRANCH_MAX * 4 + 8) /* where = branch + ", " + date(10) + " " + time(8) */ #define LIGENT_WHERE_BUF (LIGENT_BRANCH_BUF + 32) @@ -42,11 +42,11 @@ static char buf_full[LIGENT_FULL_BUF]; /* 编译器标识 */ /* ------------------------------------------------------------------ */ /* - * 顺序有讲究:clang 也定义 __GNUC__(它假装自己是 gcc 4.2),所以 clang 必须 - * 排在 GCC 前面判断。tcc 不定义 __GNUC__,放哪都行,但放在一起更清楚。 + * 顺序有讲究:clang 也定义 __GNUC__ (它假装自己是 gcc 4.2), 所以 clang 必须 + * 排在 GCC 前面判断. tcc 不定义 __GNUC__, 放哪都行, 但放在一起更清楚. * - * tcc 只给一个整数 __TINYC__(0.9.28 -> 928,也就是去掉小数点当十进制读)。 - * 拼出来是 "tcc 0.9.28",比原样输出一个 928 有用。 + * tcc 只给一个整数 __TINYC__ (0.9.28 -> 928, 也就是去掉小数点当十进制读). + * 拼出来是 "tcc 0.9.28", 比原样输出一个 928 有用. */ static const char *compiler_name(void) { @@ -64,23 +64,23 @@ static const char *compiler_name(void) static const char *compiler_version(void) { #if defined(__clang__) - /* clang 的 __clang_version__ 是 "22.1.8 " —— **带一个尾随空格**(它是从 - * 某个更长的串里切出来的)。原样用会在版本号里留下 "Clang 22.1.8 ]" - * 这种双空格,所以掐掉。不用 __VERSION__ 是因为各家格式不统一 - * (clang 给 "Clang 22.1.8",gcc 给 "16.2.1 20260810"), - * 用 __clang_version__ 再自己 trim 更可控。 */ + /* clang 的 __clang_version__ 是 "22.1.8 " —— **带一个尾随空格** (它是从 + * 某个更长的串里切出来的). 原样用会在版本号里留下 "Clang 22.1.8 ]" + * 这种双空格, 所以掐掉. 不用 __VERSION__ 是因为各家格式不统一 + * (clang 给 "Clang 22.1.8", gcc 给 "16.2.1 20260810"), + * 用 __clang_version__ 再自己 trim 更可控. */ static char buf[LIGENT_COMPILER_MAX]; snprintf(buf, sizeof buf, "%s", __clang_version__); size_t n = strlen(buf); while (n > 0 && (buf[n - 1] == ' ' || buf[n - 1] == '\t')) buf[--n] = '\0'; return buf; #elif defined(__TINYC__) - /* tcc 没有 __VERSION__,只有整数 __TINYC__。 - * 编码方式是**去掉小数点当十进制读**:0.9.28 -> 928。 + /* tcc 没有 __VERSION__, 只有整数 __TINYC__. + * 编码方式是**去掉小数点当十进制读**:0.9.28 -> 928. * 所以主版本要 /1000 而不是 /100: * 928 / 1000 = 0 <- 对 - * 928 / 100 = 9 <- 错(第一版就是这么写的,输出成 "tcc 9.2.8") - * 这对 1000 以上也成立(1.0.0 -> 1000,1000/1000 = 1)。 */ + * 928 / 100 = 9 <- 错 (第一版就是这么写的, 输出成 "tcc 9.2.8") + * 这对 1000 以上也成立 (1.0.0 -> 1000, 1000/1000 = 1). */ static char buf[LIGENT_COMPILER_MAX]; snprintf(buf, sizeof buf, "%d.%d.%d", __TINYC__ / 1000, (__TINYC__ / 100) % 10, __TINYC__ % 100); @@ -181,8 +181,8 @@ const char *ligent_version_full(void) { const char *v = ligent_version_string(); - /* 括号里:分支 + 构建时间。这两个是"这份二进制从哪来的",排查时都要。 - * 分支在 detached HEAD(CI 上常见)时是 unknown,那也照实说。 */ + /* 括号里:分支 + 构建时间. 这两个是"这份二进制从哪来的", 排查时都要. + * 分支在 detached HEAD (CI 上常见)时是 unknown, 那也照实说. */ char branch[LIGENT_BRANCH_BUF]; branch_display(branch, sizeof branch); diff --git a/src/version.h b/src/version.h index ef9cd64..7ef97a3 100644 --- a/src/version.h +++ b/src/version.h @@ -1,33 +1,33 @@ /* version.h - 版本号与构建信息 * - * 版本号由**构建系统注入**,不写死在源码里: + * 版本号由**构建系统注入**, 不写死在源码里: * - * LIGENT_VERSION CalVer(YYYY.MM.DD),来自 git tag;没有 tag 时用 + * LIGENT_VERSION CalVer (YYYY.MM.DD), 来自 git tag; 没有 tag 时用 * 源码里这个回退值 - * LIGENT_GIT_HASH git 短哈希;工作区脏了带 ".dirty";非 git 构建为 "unknown" - * LIGENT_GIT_BRANCH 分支名;detached HEAD 以及非 git 构建为 "unknown" - * LIGENT_BUILD_DATE 构建日期(UTC,YYYY-MM-DD) - * LIGENT_BUILD_TIME 构建时间(UTC,HH:MM:SS) + * LIGENT_GIT_HASH git 短哈希; 工作区脏了带 ".dirty"; 非 git 构建为 "unknown" + * LIGENT_GIT_BRANCH 分支名; detached HEAD 以及非 git 构建为 "unknown" + * 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 这类项目的通行做法。 + * 为什么不让源码当"唯一真相":那样每发一次版就要改一次源码、多一个提交, 而且 + * 从 tarball 还是从 git 编出来的版本号会不一致. 让构建期算出来、编进去, 源码 + * 只留一个**回退默认值** (给"没有构建系统"的场景, 比如有人直接拿 gcc 编 + * src 下的所有 .c), 这是 dunst/curl 这类项目的通行做法. * - * 为什么是 CalVer 而不是 SemVer:ligent 是个终端工具,不是被别人链接的库。 - * CalVer 表达的是"这是哪天的东西",对一个还在快速演进的工具比"第几个大版本" - * 有用得多 —— 用户看到 2026.02.14 立刻知道新旧,看到 1.4.2 还得去查。真到了 - * 需要表达"破坏性变更"的时候,git 历史和 README 比版本号数字更能说清楚。 + * 为什么是 CalVer 而不是 SemVer:ligent 是个终端工具, 不是被别人链接的库. + * CalVer 表达的是"这是哪天的东西", 对一个还在快速演进的工具比"第几个大版本" + * 有用得多 —— 用户看到 2026.02.14 立刻知道新旧, 看到 1.4.2 还得去查. 真到了 + * 需要表达"破坏性变更"的时候, git 历史和 README 比版本号数字更能说清楚. */ #ifndef LIGENT_VERSION_H #define LIGENT_VERSION_H -/* 回退值:只在没有通过 -D 注入时生效(直接拿 gcc 编、或从没有 .git 的 - * tarball 编)。改这个值等于"手动打了一个版本",正常发布流程不该动它。 */ +/* 回退值:只在没有通过 -D 注入时生效 (直接拿 gcc 编、或从没有 .git 的 + * tarball 编). 改这个值等于"手动打了一个版本", 正常发布流程不该动它. */ #ifndef LIGENT_VERSION # define LIGENT_VERSION "0.0.0-unknown" #endif @@ -49,31 +49,31 @@ #endif /* ------------------------------------------------------------------ */ -/* 编译器与平台:用编译期宏判断,不靠构建脚本传 */ +/* 编译器与平台:用编译期宏判断, 不靠构建脚本传 */ /* ------------------------------------------------------------------ */ /* - * 为什么要区分这么多分支:__VERSION__ 是 gcc/clang 的约定,**tcc 没有这个宏** - * (它只有 __TINYC__ 加一个数字),而 tcc 是这个项目明确支持的编译器之一 - * (make CC=tcc 是过全套测试的)。所以 tcc 那条腿得单独拼。 + * 为什么要区分这么多分支:__VERSION__ 是 gcc/clang 的约定, **tcc 没有这个宏** + * (它只有 __TINYC__ 加一个数字), 而 tcc 是这个项目明确支持的编译器之一 + * (make CC=tcc 是过全套测试的). 所以 tcc 那条腿得单独拼. * - * 平台同理用 __linux__ / __APPLE__ 这些宏,**不用 uname()**:宏描述的是 - * "这份二进制是给哪个平台编的",uname() 描述的是"现在跑在哪" —— 静态产物 - * 会被拷到别的机器上跑,交叉编译时两者也不一致。版本串里该记的是前者。 + * 平台同理用 __linux__ / __APPLE__ 这些宏, **不用 uname()**:宏描述的是 + * "这份二进制是给哪个平台编的", uname() 描述的是"现在跑在哪" —— 静态产物 + * 会被拷到别的机器上跑, 交叉编译时两者也不一致. 版本串里该记的是前者. */ -/* 编译器标识,例如 "GCC 16.2.1 20260810" / "Clang 22.1.8" / "tcc 0.9.28" */ +/* 编译器标识, 例如 "GCC 16.2.1 20260810" / "Clang 22.1.8" / "tcc 0.9.28" */ const char *ligent_compiler_string(void); -/* 目标平台,例如 "linux/x86_64" */ +/* 目标平台, 例如 "linux/x86_64" */ const char *ligent_platform_string(void); -/* 版本号主体:-,例如 2026.09.22-209538e - * hash 未知时只有 CalVer。 */ +/* 版本号主体:-, 例如 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); #endif /* LIGENT_VERSION_H */ diff --git a/tests/confirm_check.py b/tests/confirm_check.py index 0549eb1..f97174d 100755 --- a/tests/confirm_check.py +++ b/tests/confirm_check.py @@ -1,17 +1,17 @@ #!/usr/bin/env python3 """confirm_check.py - 确认提示上的每一种回答都必须被正确处理 -重点是 Ctrl-C。 +重点是 Ctrl-C. -`confirm_tool` 用 fgets 读输入,而 SIGINT 会让 fgets 以 EINTR 失败、返回 -NULL;代码把 NULL 当成"没人能回答,按默认值放行"。于是**在确认提示 -上按 Ctrl-C 等于批准这次操作**。run_command 侥幸自己查了中断标志才没执行, -write_file / edit_file 没查,文件照写。 +`confirm_tool` 用 fgets 读输入, 而 SIGINT 会让 fgets 以 EINTR 失败、返回 +NULL; 代码把 NULL 当成"没人能回答, 按默认值放行". 于是**在确认提示 +上按 Ctrl-C 等于批准这次操作**. run_command 侥幸自己查了中断标志才没执行, +write_file / edit_file 没查, 文件照写. -这个脚本把三种回答都跑一遍,直接看磁盘上有没有留下东西: +这个脚本把三种回答都跑一遍, 直接看磁盘上有没有留下东西: Ctrl-C -> 必须拒绝 n -> 必须拒绝 - y -> 必须执行(否则就是"修过头"了) + y -> 必须执行 (否则就是"修过头"了) 用法: confirm_check.py @@ -22,19 +22,19 @@ import select import sys import time -# 探针文件放在 build/ 下,别弄脏仓库根目录 -# WRITE_TEST 写这一个;WRITE2_TEST 写 PROBE1 / PROBE2(一轮里两次) +# 探针文件放在 build/ 下, 别弄脏仓库根目录 +# WRITE_TEST 写这一个; WRITE2_TEST 写 PROBE1 / PROBE2 (一轮里两次) 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: @@ -69,7 +69,7 @@ def run_case(binary, base, answer, wait_confirm=6.0): return True def wait_for(text, seconds): - """一直读到 text 出现(或超时)。按标记等,不靠 sleep 猜。""" + """一直读到 text 出现 (或超时). 按标记等, 不靠 sleep 猜. """ end = time.time() + seconds while time.time() < end: if text.encode("utf-8") in bytes(out): @@ -105,13 +105,13 @@ def run_case(binary, base, answer, wait_confirm=6.0): def run_turn_scope(binary, base): - """Always / neVer 的作用域必须是"这一轮",不是整个会话。 + """Always / neVer 的作用域必须是"这一轮", 不是整个会话. - 一轮里让模型连写两个文件:第一次问、答 Always,第二次不该再问。 - 然后开新的一轮(下一条用户消息),必须**重新问** —— 会话级的开关是 - /yolo,不是这个。 + 一轮里让模型连写两个文件:第一次问、答 Always, 第二次不该再问. + 然后开新的一轮 (下一条用户消息), 必须**重新问** —— 会话级的开关是 + /yolo, 不是这个. - 全程按标记同步("继续?"出现 / 回到提示符),不靠 sleep 猜时间。 + 全程按标记同步 ("继续?"出现 / 回到提示符), 不靠 sleep 猜时间. """ for p in (PROBE1, PROBE2): try: @@ -183,14 +183,14 @@ def run_turn_scope(binary, base): os.write(fd, "WRITE2_TEST 写两个文件\n".encode()) wait_for(has_prompt, 8.0) os.write(fd, b"Always\n") - # 等这一轮彻底跑完(回到提示符),别用固定时长猜 + # 等这一轮彻底跑完 (回到提示符), 别用固定时长猜 wait_for(lambda: state["ready"] > base_ready, 10.0) pump(0.3) first_turn_prompts = prompt_count() 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()) @@ -255,7 +255,7 @@ def main(): n1, both, again, n2 = run_turn_scope(binary, base) check("Always:同一轮里只问一次", n1 == 1, "实际问了 %d 次" % n1) check("Always:同轮的第二次改动被放行", both, "第二个文件没写") - check("新一轮会重新询问(作用域仅限本轮)", again and n2 >= 1, + check("新一轮会重新询问 (作用域仅限本轮)", again and n2 >= 1, "新一轮没有询问") for p in (PROBE, PROBE1, PROBE2): @@ -263,11 +263,11 @@ def main(): os.unlink(p) except OSError: pass - # 这里**不要** rmtree("build/tmp"):那是 e2e 各段共用的临时目录,删掉它 - # 会连带清掉别人放进去的东西(我就因此丢过两次日志),而它自己的三个文件 - # 上面已经删干净了。测试辅助脚本只该收拾自己造的东西。 + # 这里**不要** rmtree("build/tmp"):那是 e2e 各段共用的临时目录, 删掉它 + # 会连带清掉别人放进去的东西 (我就因此丢过两次日志), 而它自己的三个文件 + # 上面已经删干净了. 测试辅助脚本只该收拾自己造的东西. - print("\n%d 项检查,%d 项失败" % (checks, fails)) + print("\n%d 项检查, %d 项失败" % (checks, fails)) return 1 if fails else 0 diff --git a/tests/fixtures/error.log b/tests/fixtures/error.log index daa86c8..869c3aa 100644 --- a/tests/fixtures/error.log +++ b/tests/fixtures/error.log @@ -1,16 +1,16 @@ -# ligent 收发记录 —— 导出 2026-09-21 17:09:35,共 5981 字节 +# ligent 收发记录 —— 导出 2026-09-21 17:09:35, 共 5981 字节 ===== 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 -Date: Mon, 21 Sep 2026 09:09:35 GMT -Content-Type: application/json -Content-Length: 112 - +HTTP/1.0 400 Bad Request +Server: BaseHTTP/0.6 Python/3.14.7 +Date: Mon, 21 Sep 2026 09:09:35 GMT +Content-Type: application/json +Content-Length: 112 + {"error": {"message": "mock \u6a21\u62df\u7684\u670d\u52a1\u7aef\u9519\u8bef", "type": "invalid_request_error"}} ----- 结束: HTTP 400 ----- diff --git a/tests/fixtures/session.log b/tests/fixtures/session.log index 535acce..c14421d 100644 --- a/tests/fixtures/session.log +++ b/tests/fixtures/session.log @@ -1,16 +1,16 @@ -# ligent 收发记录 —— 导出 2026-09-21 17:09:24,共 19586 字节 +# ligent 收发记录 —— 导出 2026-09-21 17:09:24, 共 19586 字节 ===== 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 -Date: Mon, 21 Sep 2026 09:09:24 GMT -Content-Type: text/event-stream -Cache-Control: no-cache - +HTTP/1.0 200 OK +Server: BaseHTTP/0.6 Python/3.14.7 +Date: Mon, 21 Sep 2026 09:09:24 GMT +Content-Type: text/event-stream +Cache-Control: no-cache + data: {"choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": null}]} data: {"choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "id": "call_1", "type": "function", "function": {"name": "run_command", "arguments": ""}}]}, "finish_reason": null}]} @@ -29,14 +29,14 @@ 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 -Date: Mon, 21 Sep 2026 09:09:24 GMT -Content-Type: text/event-stream -Cache-Control: no-cache - +HTTP/1.0 200 OK +Server: BaseHTTP/0.6 Python/3.14.7 +Date: Mon, 21 Sep 2026 09:09:24 GMT +Content-Type: text/event-stream +Cache-Control: no-cache + data: {"choices": [{"index": 0, "delta": {"content": "FINAL: hello-from-t"}, "finish_reason": null}]} data: {"choices": [{"index": 0, "delta": {"content": "ool / [exit code: 0]"}, "finish_reason": null}]} @@ -51,14 +51,14 @@ 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 -Date: Mon, 21 Sep 2026 09:09:24 GMT -Content-Type: text/event-stream -Cache-Control: no-cache - +HTTP/1.0 200 OK +Server: BaseHTTP/0.6 Python/3.14.7 +Date: Mon, 21 Sep 2026 09:09:24 GMT +Content-Type: text/event-stream +Cache-Control: no-cache + data: {"choices": [{"index": 0, "delta": {"content": "FINAL: hello-from-t"}, "finish_reason": null}]} data: {"choices": [{"index": 0, "delta": {"content": "ool / [exit code: 0]"}, "finish_reason": null}]} diff --git a/tests/fuzz.c b/tests/fuzz.c index 31188c4..324ede1 100644 --- a/tests/fuzz.c +++ b/tests/fuzz.c @@ -1,12 +1,12 @@ /* fuzz.c - 对抗性模糊测试:JSON 解析器 + Markdown 渲染器 * - * 这两个模块是"解析不可信输入"的地方(服务端响应、模型输出), - * 也是最可能出崩溃/越界/泄漏的地方。所以用随机输入去砸它们。 + * 这两个模块是"解析不可信输入"的地方 (服务端响应、模型输出), + * 也是最可能出崩溃/越界/泄漏的地方. 所以用随机输入去砸它们. * * 检查的不变量: * JSON - * 1. 任何输入都不能崩溃、不能泄漏(靠 ASan/UBSan/LeakSanitizer) - * 2. 解析成功 => dump 再 parse 再 dump,两次 dump 必须一致(幂等) + * 1. 任何输入都不能崩溃、不能泄漏 (靠 ASan/UBSan/LeakSanitizer) + * 2. 解析成功 => dump 再 parse 再 dump, 两次 dump 必须一致 (幂等) * 3. 随机生成的合法 JSON 必须解析成功并完整往返 * Markdown * 4. 任何输入都不能崩溃、不能泄漏 @@ -104,17 +104,17 @@ 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; } - /* 每次突变都必须基于"当前"长度。早期版本用了突变前记下的旧长度, - * 截断之后再按下标写就会覆盖掉结尾的 '\0',后续 strlen 冲出分配区, - * 让 ASan 在 json_parse 里报出假阳性 —— 模糊测试器自己不可信比没有更糟。 */ + /* 每次突变都必须基于"当前"长度. 早期版本用了突变前记下的旧长度, + * 截断之后再按下标写就会覆盖掉结尾的 '\0', 后续 strlen 冲出分配区, + * 让 ASan 在 json_parse 里报出假阳性 —— 模糊测试器自己不可信比没有更糟. */ uint32_t muts = 1 + rnd_below(4); for (uint32_t i = 0; i < muts && sb.len > 0; i++) { uint32_t op = rnd_below(3); - if (op == 0) { /* 翻一个字节(下标恒 < len) */ + if (op == 0) { /* 翻一个字节 (下标恒 < len) */ sb.data[rnd_below((uint32_t)sb.len)] = (char)(rnd() & 0xFF); } else if (op == 1) { /* 截断 */ sb.len = rnd_below((uint32_t)sb.len); @@ -128,14 +128,14 @@ static int fuzz_json_once(char *err, size_t errlen) } } } - /* 无论怎么突变,结尾符必须是好的 */ + /* 无论怎么突变, 结尾符必须是好的 */ if (sb.data) sb.data[sb.len] = '\0'; n = sb.len; } else { /* 纯随机字节 */ uint32_t n = rnd_below(256); for (uint32_t i = 0; i < n; i++) { - /* 偏向 JSON 元字符,提高触发率 */ + /* 偏向 JSON 元字符, 提高触发率 */ static const char meta[] = "{}[]\",:0123456789.eE+-truefalsn \t\n\\/u"; char c = (rnd() & 3) ? meta[rnd_below(sizeof meta - 1)] : (char)(rnd() & 0xFF); @@ -146,7 +146,7 @@ static int fuzz_json_once(char *err, size_t errlen) const char *input = sb.data ? sb.data : ""; size_t inlen = sb.len; - /* 输入里不能有 NUL,否则不是合法的 C 字符串测试对象 */ + /* 输入里不能有 NUL, 否则不是合法的 C 字符串测试对象 */ for (size_t i = 0; i < inlen; i++) { if (input[i] == '\0') { sb_free(&sb); @@ -196,7 +196,7 @@ static int fuzz_json_once(char *err, size_t errlen) #define MD_RENDER_LIMIT (1u << 20) -/* 渲染到内存;chunk==0 表示一次性 */ +/* 渲染到内存; chunk==0 表示一次性 */ static char *md_render(const char *src, size_t len, size_t chunk) { char *buf = NULL; @@ -224,7 +224,7 @@ static char *md_render(const char *src, size_t len, size_t chunk) static int markdown_metachar(int c) { - /* 这些字符会触发渲染器改写输出;其余字符必须原样通过 */ + /* 这些字符会触发渲染器改写输出; 其余字符必须原样通过 */ return c == '*' || c == '_' || c == '`' || c == '[' || c == ']' || c == '#' || c == '>' || c == '-' || c == '+' || c == '|' || c == '\n' || c == '\r'; @@ -278,9 +278,9 @@ static int fuzz_md_once(char *err, size_t errlen) if (!part) continue; if (strcmp(whole, part) != 0) { snprintf(err, errlen, - "分片不变性被破坏(%zu 字节/片)", + "分片不变性被破坏 (%zu 字节/片)", chunks[k]); - /* 落盘,方便用 build/reduce 做最小化 */ + /* 落盘, 方便用 build/reduce 做最小化 */ FILE *fp = fopen("build/md_fail.bin", "wb"); if (fp) { fwrite(src, 1, len, fp); fclose(fp); } free(part); free(whole); sb_free(&sb); @@ -315,7 +315,7 @@ 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++) { @@ -325,7 +325,7 @@ int main(int argc, char **argv) if (++json_fail >= 3) break; } } - printf(" JSON %ld 轮,%ld 次失败\n", iters, json_fail); + printf(" JSON %ld 轮, %ld 次失败\n", iters, json_fail); for (long i = 0; i < iters; i++) { err[0] = '\0'; @@ -334,12 +334,12 @@ int main(int argc, char **argv) if (++md_fail >= 3) break; } } - printf(" Markdown %ld 轮,%ld 次失败\n", iters, md_fail); + printf(" Markdown %ld 轮, %ld 次失败\n", iters, md_fail); if (json_fail || md_fail) { printf("\n模糊测试发现 %ld 个问题\n", json_fail + md_fail); return 1; } - printf("\n模糊测试通过(无崩溃、无泄漏、不变量全部成立)\n"); + printf("\n模糊测试通过 (无崩溃、无泄漏、不变量全部成立)\n"); return 0; } diff --git a/tests/mock_server.py b/tests/mock_server.py index 574630a..8d13c50 100644 --- a/tests/mock_server.py +++ b/tests/mock_server.py @@ -1,13 +1,13 @@ #!/usr/bin/env python3 -"""mock_server.py - 最小的 OpenAI 兼容服务端,用于端到端测试 ligent。 +"""mock_server.py - 最小的 OpenAI 兼容服务端, 用于端到端测试 ligent. -不依赖任何第三方包。行为完全由请求内容决定,保持确定性: +不依赖任何第三方包. 行为完全由请求内容决定, 保持确定性: - 用户消息含 ERROR_TEST -> 返回 HTTP 400 + error 对象 - - 尚未有 tool 结果 -> 流式返回一个 tool_call(run_command echo ...) + - 尚未有 tool 结果 -> 流式返回一个 tool_call (run_command echo ...) - 用户消息含 twice 且只执行过一次工具 -> 再返回第二个 tool_call - 用户消息含 MANYSTEPS_TEST -> 连要 4 次工具往返才收工 - - 其余 -> 流式/非流式返回最终文本,内容里回显最近一次工具输出 + - 其余 -> 流式/非流式返回最终文本, 内容里回显最近一次工具输出 """ import json import os @@ -26,12 +26,12 @@ def sse(wfile, obj): def pairing_error(msgs): """服务端的硬要求:带 tool_calls 的 assistant 后面必须**紧跟**与每个 - tool_call_id 一一对应的 tool 消息,中间不能夹别的东西。 + tool_call_id 一一对应的 tool 消息, 中间不能夹别的东西. - 真实服务端就是这么回 400 的,mock 也照做 —— 这样任何会写坏历史的回归 - (比如把图片附件插在两条 tool 消息中间、或者中断时丢下没回应的调用) - 都会在 e2e 里当场炸出来,而不是等到用户真拿它去连服务端才发现。 - 返回错误文案,没问题返回 None。""" + 真实服务端就是这么回 400 的, mock 也照做 —— 这样任何会写坏历史的回归 + (比如把图片附件插在两条 tool 消息中间、或者中断时丢下没回应的调用) + 都会在 e2e 里当场炸出来, 而不是等到用户真拿它去连服务端才发现. + 返回错误文案, 没问题返回 None. """ i, n = 0, len(msgs) while i < n: m = msgs[i] @@ -57,9 +57,9 @@ class Handler(BaseHTTPRequestHandler): def log_message(self, *args): pass - # -- 工具调用(流式增量) -- + # -- 工具调用 (流式增量) -- def emit_tool_call(self, index, call_id, name, arguments): - # 故意把 arguments 切成多片,检验客户端的增量拼接 + # 故意把 arguments 切成多片, 检验客户端的增量拼接 half = len(arguments) // 2 sse(self.wfile, {"choices": [{"index": 0, "delta": { "role": "assistant", "content": ""}, "finish_reason": None}]}) @@ -87,7 +87,7 @@ class Handler(BaseHTTPRequestHandler): "total_tokens": 2}}) def reject(self, status, message): - """回一个错误响应(探测/校验用)""" + """回一个错误响应 (探测/校验用)""" payload = json.dumps({"error": {"message": message}}).encode() self.send_response(status) self.send_header("Content-Type", "application/json") @@ -96,7 +96,7 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(payload) def send_text(self, text, stream, model): - """按流式/非流式把一段最终文本发回去。""" + """按流式/非流式把一段最终文本发回去. """ if not stream: payload = json.dumps({"choices": [{"index": 0, "message": {"role": "assistant", @@ -118,13 +118,13 @@ class Handler(BaseHTTPRequestHandler): self.wfile.flush() def do_GET(self): - # doctor 的连通性/重定向检查走 GET。 - # /redirect 开头故意返回 307,用来验证客户端能把重定向指出来。 - # doctor 的认证探测:端点要求认证,且只认 "Bearer good-key"。 - # 带对 key -> 200(认证过了) - # 带错 key -> 401(密钥被拒) - # 不带 key -> 401(端点要求认证,而你没配) - # 客户端要靠"有没有配 key"把后两种 401 区分开。 + # doctor 的连通性/重定向检查走 GET. + # /redirect 开头故意返回 307, 用来验证客户端能把重定向指出来. + # doctor 的认证探测:端点要求认证, 且只认 "Bearer good-key". + # 带对 key -> 200 (认证过了) + # 带错 key -> 401 (密钥被拒) + # 不带 key -> 401 (端点要求认证, 而你没配) + # 客户端要靠"有没有配 key"把后两种 401 区分开. if self.path.startswith("/authed"): auth = self.headers.get("Authorization") or "" ok = (auth == "Bearer good-key") @@ -135,13 +135,13 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(b"{}") return - # /model 不带参数时拉模型列表。 - # 故意混进一个重复项和一个缺 id 的条目 —— 客户端要能去重、跳过坏条目。 - # MODEL_LIST=bare 时改回裸数组,MODEL_LIST=empty 时不返回任何模型, - # 两种兼容/异常形状都由 e2e 覆盖。 + # /model 不带参数时拉模型列表. + # 故意混进一个重复项和一个缺 id 的条目 —— 客户端要能去重、跳过坏条目. + # MODEL_LIST=bare 时改回裸数组, MODEL_LIST=empty 时不返回任何模型, + # 两种兼容/异常形状都由 e2e 覆盖. if "/models" in self.path: - # 模式从**文件**读,不从环境变量读:mock 是脚本开头起好的, - # 给客户端进程设的 env 它根本看不见(第一版就是这么假通过的)。 + # 模式从**文件**读, 不从环境变量读:mock 是脚本开头起好的, + # 给客户端进程设的 env 它根本看不见 (第一版就是这么假通过的). mode = "" mode_file = os.environ.get("MODELS_MODE_FILE", "") if mode_file: @@ -157,9 +157,9 @@ class Handler(BaseHTTPRequestHandler): elif mode == "garbage": payload = b"not json at all" else: - # 默认列表里**故意包含当前模型**(deepseek-flash): - # 这样才验得了"光标初始落在当前项上"。 - # 另外塞一个重复项和一个缺 id 的坏条目,验去重与跳过。 + # 默认列表里**故意包含当前模型** (deepseek-flash): + # 这样才验得了"光标初始落在当前项上". + # 另外塞一个重复项和一个缺 id 的坏条目, 验去重与跳过. payload = json.dumps({"object": "list", "data": [ {"id": "deepseek-flash", "object": "model", "owned_by": "mock"}, {"id": "alpha-model", "object": "model", "owned_by": "mock"}, @@ -195,11 +195,11 @@ class Handler(BaseHTTPRequestHandler): # DeepSeek 对请求体里非法的 UTF-8 回的是 # 400 Failed to parse the request body as JSON: # messages[43].content: invalid unicode code point - # 而坏数据一旦进了对话历史,之后**每一次**请求都会失败,除非丢掉整轮 - # 对话。所以 mock 必须一样严格,否则"序列化器吐出了非法 UTF-8"这类 bug - # 在 e2e 里永远看不见。 - # (json.loads(bytes) 其实也会因此抛异常、被下面那个 except 兜成 400, - # 但那条笼统的 400 什么都没说明,而且换个实现就可能漏掉,所以显式判。) + # 而坏数据一旦进了对话历史, 之后**每一次**请求都会失败, 除非丢掉整轮 + # 对话. 所以 mock 必须一样严格, 否则"序列化器吐出了非法 UTF-8"这类 bug + # 在 e2e 里永远看不见. + # (json.loads(bytes) 其实也会因此抛异常、被下面那个 except 兜成 400, + # 但那条笼统的 400 什么都没说明, 而且换个实现就可能漏掉, 所以显式判. ) try: raw.decode("utf-8") except UnicodeDecodeError as exc: @@ -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: @@ -219,9 +219,9 @@ class Handler(BaseHTTPRequestHandler): model = body.get("model", "?") stream = bool(body.get("stream")) - # 把 user 消息拍平成纯文本再匹配。带附件的消息 content 是**块数组** - # (/include、read_image 的附件都是),不拍平的话 first_user 会是个 - # list,`"标记" in list` 永远不成立 —— 分支会莫名其妙地匹配不上。 + # 把 user 消息拍平成纯文本再匹配. 带附件的消息 content 是**块数组** + # (/include、read_image 的附件都是), 不拍平的话 first_user 会是个 + # list, `"标记" in list` 永远不成立 —— 分支会莫名其妙地匹配不上. def user_text(m): c = m.get("content") if isinstance(c, str): @@ -244,9 +244,9 @@ class Handler(BaseHTTPRequestHandler): return # ---- 能力探测序列 ---- - # ligent 会在第一次真实请求之前发几个试探请求(基线 / 图片 / tools), - # 它们会被真实处理,所以这里必须排在所有标记分支之前。 - # 辨认靠形状:刚好一条 user 消息,内容是 "1",或者带一张 1x1 的图。 + # ligent 会在第一次真实请求之前发几个试探请求 (基线 / 图片 / tools), + # 它们会被真实处理, 所以这里必须排在所有标记分支之前. + # 辨认靠形状:刚好一条 user 消息, 内容是 "1", 或者带一张 1x1 的图. def looks_like_probe(msgs): if len(msgs) != 1 or msgs[0].get("role") != "user": return False @@ -293,15 +293,15 @@ class Handler(BaseHTTPRequestHandler): self.send_text("ELEV_SAW[%s]" % got.replace("\n", " | "), stream, model) return - # /include:把收到的 user 内容块报回来。 - # 用 first_user 判断——附带之后最新那条 user 是带块的消息,last_user 会漂。 + # /include:把收到的 user 内容块报回来. + # 用 first_user 判断——附带之后最新那条 user 是带块的消息, last_user 会漂. if "INCLUDE_TEST" in first_user: texts, imgs, names = 0, 0, [] for m in messages: if m.get("role") != "user": continue c = m.get("content") - # 没有附件时 content 是纯字符串,那也算"一个文本块" + # 没有附件时 content 是纯字符串, 那也算"一个文本块" if isinstance(c, str): texts += 1 continue @@ -315,14 +315,14 @@ class Handler(BaseHTTPRequestHandler): t = p.get("text", "") for line in t.split("\n"): if line.startswith("===== "): - names.append(line[6:].split("(")[0]) + names.append(line[6:].split(" (")[0]) elif p.get("type") == "image_url": imgs += 1 self.send_text("INC[text=%d img=%d files=%s]" % (texts, imgs, ",".join(names)), stream, model) return - # 回显收到的工具名,用来验证"不该暴露的工具确实没发出去" + # 回显收到的工具名, 用来验证"不该暴露的工具确实没发出去" if "TOOLS_ECHO_TEST" in first_user: names = [((t or {}).get("function") or {}).get("name") for t in (tools_sent or [])] @@ -343,8 +343,8 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(payload) return - # 消息条数超过阈值就报"上下文超限",用于验证客户端的自动裁剪。 - # 客户端丢掉最老的一轮后条数会降下来,于是下一轮就能成功。 + # 消息条数超过阈值就报"上下文超限", 用于验证客户端的自动裁剪. + # 客户端丢掉最老的一轮后条数会降下来, 于是下一轮就能成功. if "TOOLONG_TEST" in first_user: if len(messages) > 3: payload = json.dumps({"error": { @@ -358,7 +358,7 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(payload) return - text = "当前历史 %d 条消息,正常回复" % len(messages) + text = "当前历史 %d 条消息, 正常回复" % len(messages) payload = json.dumps({"choices": [{"index": 0, "message": {"role": "assistant", "content": text}, @@ -370,7 +370,7 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(payload) return - # 把收到的用户消息原样回显,用于验证"一次请求里到底装了什么" + # 把收到的用户消息原样回显, 用于验证"一次请求里到底装了什么" if "ECHO_TEST" in first_user: text = "ECHO[" + first_user.replace("\n", "\\n") + "]" payload = json.dumps({"choices": [{"index": 0, @@ -384,8 +384,8 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(payload) return - # 复刻 OpenRouter 对"key 格式不对"的回应:头其实带了, - # 但文案说的是 "Missing Authentication header",容易把人引偏 + # 复刻 OpenRouter 对"key 格式不对"的回应:头其实带了, + # 但文案说的是 "Missing Authentication header", 容易把人引偏 if "AUTHFAIL_TEST" in first_user: payload = json.dumps({"error": {"message": "Missing Authentication header", "code": 401}}).encode() @@ -396,8 +396,8 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(payload) return - # 重定向:用于验证客户端不静默跟随(跨源跟随会丢掉 Authorization, - # 服务端只会返回 401 "Missing Authentication header",误导用户去查 key) + # 重定向:用于验证客户端不静默跟随 (跨源跟随会丢掉 Authorization, + # 服务端只会返回 401 "Missing Authentication header", 误导用户去查 key) if "REDIRECT_TEST" in first_user: self.send_response(307) self.send_header("Location", "https://example.invalid/v1/chat/completions") @@ -405,14 +405,14 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() return - # 前几次返回 503,用于验证重试与 Retry-After + # 前几次返回 503, 用于验证重试与 Retry-After if "RETRY_TEST" in first_user: key = "retry:" + first_user STATE[key] = STATE.get(key, 0) + 1 STATE["retry"] = STATE[key] if STATE["retry"] < 3: payload = json.dumps({"error": { - "message": "mock 临时不可用(第 %d 次)" % STATE["retry"]}}).encode() + "message": "mock 临时不可用 (第 %d 次)" % STATE["retry"]}}).encode() self.send_response(503) self.send_header("Content-Type", "application/json") self.send_header("Retry-After", "1") @@ -420,7 +420,7 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(payload) return - text = "重试后成功(共尝试 %d 次)" % STATE["retry"] + text = "重试后成功 (共尝试 %d 次)" % STATE["retry"] payload = json.dumps({"choices": [{"index": 0, "message": {"role": "assistant", "content": text}, @@ -432,7 +432,7 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(payload) return - # 永远 503,用于验证重试次数耗尽后正确报错 + # 永远 503, 用于验证重试次数耗尽后正确报错 if "ALWAYS503_TEST" in first_user: payload = json.dumps({"error": {"message": "mock 持续不可用"}}).encode() self.send_response(503) @@ -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") @@ -490,14 +490,14 @@ class Handler(BaseHTTPRequestHandler): self.close_connection = True return - # 固定的 Markdown 回复,用于验证流式渲染 + # 固定的 Markdown 回复, 用于验证流式渲染 if "MARKDOWN_TEST" in first_user: md = ( "# 一级标题\n" "## 二级标题\n" "\n" - "这是 **粗体** 和 *斜体* 和 _也是斜体_ 和 `code` 混排。\n" - "参考 [官方文档](https://example.com/doc) 与 arr[i]。\n" + "这是 **粗体** 和 *斜体* 和 _也是斜体_ 和 `code` 混排. \n" + "参考 [官方文档](https://example.com/doc) 与 arr[i]. \n" "\n" "- 列表甲\n- 列表乙\n" "\n" @@ -526,7 +526,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Content-Type", "text/event-stream") self.send_header("Cache-Control", "no-cache") self.end_headers() - # 故意按 1 字符一片下发,最大化"标记被切断"的概率 + # 故意按 1 字符一片下发, 最大化"标记被切断"的概率 for ch in md: sse(self.wfile, {"choices": [{"index": 0, "delta": {"content": ch}, @@ -537,14 +537,14 @@ class Handler(BaseHTTPRequestHandler): self.wfile.flush() return - # 思考模式:验证 reasoning_content 既能被累积,也能随 assistant 消息 - # 回传。DeepSeek 要求请求带 tools 时把历史里的 reasoning_content 全带上, - # 漏了下一轮就 400,所以这里第二轮专门检查它有没有回来。 + # 思考模式:验证 reasoning_content 既能被累积, 也能随 assistant 消息 + # 回传. DeepSeek 要求请求带 tools 时把历史里的 reasoning_content 全带上, + # 漏了下一轮就 400, 所以这里第二轮专门检查它有没有回来. if "THINK_TEST" in first_user: if not tool_msgs: if not stream: msg = {"role": "assistant", "content": None, - "reasoning_content": "先想一下要不要调用工具。", + "reasoning_content": "先想一下要不要调用工具. ", "tool_calls": [{"id": "call_1", "type": "function", "function": { "name": "run_command", @@ -562,8 +562,8 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Content-Type", "text/event-stream") self.send_header("Cache-Control", "no-cache") self.end_headers() - # 思考过程分片下发:客户端必须累积,而不是只留最后一片 - for part in ("先想一下", "要不要调用工具。"): + # 思考过程分片下发:客户端必须累积, 而不是只留最后一片 + for part in ("先想一下", "要不要调用工具. "): sse(self.wfile, {"choices": [{"index": 0, "delta": {"reasoning_content": part}, "finish_reason": None}]}) @@ -587,10 +587,10 @@ class Handler(BaseHTTPRequestHandler): body.get("reasoning_effort") or "none"), stream, model) return - # 慢速响应:先静默一段(网络/排队),再吐思维链,再静默一段, - # 最后给正文。用来验证等待动画的两段标签:"等待响应" → "思考中"。 - # 名字刻意不含 "THINK_TEST" —— 上面那些标记都是按子串匹配的, - # 叫 SLOWTHINK_TEST 会被 THINK_TEST 那个分支先截胡。 + # 慢速响应:先静默一段 (网络/排队), 再吐思维链, 再静默一段, + # 最后给正文. 用来验证等待动画的两段标签:"等待响应" → "思考中". + # 名字刻意不含 "THINK_TEST" —— 上面那些标记都是按子串匹配的, + # 叫 SLOWTHINK_TEST 会被 THINK_TEST 那个分支先截胡. if "SLOWRESP_TEST" in first_user: self.send_response(200) self.send_header("Content-Type", "text/event-stream") @@ -598,14 +598,14 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() time.sleep(0.8) # 这期间一个字节都没发 sse(self.wfile, {"choices": [{"index": 0, - "delta": {"reasoning_content": "先想一下。"}, + "delta": {"reasoning_content": "先想一下. "}, "finish_reason": None}]}) time.sleep(0.8) # 这期间模型在推理 self.send_text("答案", stream=True, model=model) return - # meta 模式:第一轮让 meta 模型调 delete_rounds 删掉第 2 轮, - # 第二轮收工。用来端到端验证 /meta 的完整链路。 + # meta 模式:第一轮让 meta 模型调 delete_rounds 删掉第 2 轮, + # 第二轮收工. 用来端到端验证 /meta 的完整链路. if "META_TEST" in first_user: if not tool_msgs: self.send_response(200) @@ -623,11 +623,11 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(b"data: [DONE]\n\n") self.wfile.flush() return - self.send_text("已删除第 2 轮。", stream, model) + self.send_text("已删除第 2 轮. ", stream, model) return - # 按"最后一条用户消息"触发:上面那些模式都看 first_user, - # 同一个进程里的第二轮就再也触发不了了,没法验证"作用域仅限本轮"。 + # 按"最后一条用户消息"触发:上面那些模式都看 first_user, + # 同一个进程里的第二轮就再也触发不了了, 没法验证"作用域仅限本轮". if "WRITE_LAST_TEST" in last_user: if messages[-1].get("role") == "user": # 新一轮的开头 args = json.dumps({"path": "build/tmp/confirm_probe_turn2.txt", @@ -686,11 +686,11 @@ class Handler(BaseHTTPRequestHandler): self.send_text("写完了", stream, model) return - # 读一个**含非法 UTF-8 字节**的文件。工具输出里出现坏字节的来源很日常: + # 读一个**含非法 UTF-8 字节**的文件. 工具输出里出现坏字节的来源很日常: # 某条命令在非 UTF-8 locale 下打出来的路径、被截断的多字节字符、二进制 - # 文件里混的一小段文本。这段字节会进对话历史,然后被序列化进请求体 —— - # 如果序列化器原样吐出,服务端就会 400,而且之后每次请求都 400。 - # 这个分支的作用就是让那条路径在 e2e 里真的被走一遍。 + # 文件里混的一小段文本. 这段字节会进对话历史, 然后被序列化进请求体 —— + # 如果序列化器原样吐出, 服务端就会 400, 而且之后每次请求都 400. + # 这个分支的作用就是让那条路径在 e2e 里真的被走一遍. if "UTF8_TEST" in first_user: if not tool_msgs: args = json.dumps({"path": "build/tmp/bad_utf8.txt"}) @@ -715,18 +715,18 @@ 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: c = m.get("content") if isinstance(c, str): got += c - self.send_text("读完了,替换字符=%d" % got.count("\ufffd"), stream, model) + self.send_text("读完了, 替换字符=%d" % got.count("\ufffd"), stream, model) return # 一轮里连写两个文件:用来验证 Always/neVer 的作用域就是"这一轮" - # (名字刻意不含 "WRITE_TEST",免得被上面那个分支先截胡) + # (名字刻意不含 "WRITE_TEST", 免得被上面那个分支先截胡) if "WRITE2_TEST" in first_user: n = len(tool_msgs) if n < 2: @@ -757,12 +757,12 @@ class Handler(BaseHTTPRequestHandler): self.send_text("两个都写完了", stream, model) return - # 让模型去读一个"扩展名是 .png、内容不是图片"的文件, + # 让模型去读一个"扩展名是 .png、内容不是图片"的文件, # 验证 read_image 按内容判断、并明确拒绝 - # 两种"读不了":内容不是图片、以及大得内联不进去。 - # 注意:mock 的标记靠子串匹配,是平铺的命名空间 —— 上面第 249 行的 - # HUGE_TEST 分支会吃掉任何含 "HUGE_TEST" 的标记,所以这里叫 OVERSIZE。 - # 两者都该在**本地**被拒 —— 工具返回错误,之后就没有图片块可发。 + # 两种"读不了":内容不是图片、以及大得内联不进去. + # 注意:mock 的标记靠子串匹配, 是平铺的命名空间 —— 上面第 249 行的 + # HUGE_TEST 分支会吃掉任何含 "HUGE_TEST" 的标记, 所以这里叫 OVERSIZE. + # 两者都该在**本地**被拒 —— 工具返回错误, 之后就没有图片块可发. if "IMAGE_BAD_TEST" in last_user or "IMAGE_OVERSIZE_TEST" in last_user: if not tool_msgs: path = ("build/tmp/huge.png" if "IMAGE_OVERSIZE_TEST" in last_user @@ -779,11 +779,11 @@ class Handler(BaseHTTPRequestHandler): self.send_text("读不了就算了", stream, model) return - # 一条 assistant 里发**两个**工具调用,第一个是 read_image。 + # 一条 assistant 里发**两个**工具调用, 第一个是 read_image. # 这是"图片附件插在两条 tool 消息中间"那个 bug 的最小复现形状: - # 只要附件没被攒到最后一起推,上面的结构校验就会回 400。 - # 注意用 first_user:图片附件本身也是一条 user 消息,而且排在最后, - # 所以第二轮请求的 last_user 是那个附件(一个 list),不是这条标记。 + # 只要附件没被攒到最后一起推, 上面的结构校验就会回 400. + # 注意用 first_user:图片附件本身也是一条 user 消息, 而且排在最后, + # 所以第二轮请求的 last_user 是那个附件 (一个 list), 不是这条标记. if "IMAGE_PAIR_TEST" in first_user: if not tool_msgs: self.send_response(200) @@ -808,8 +808,8 @@ class Handler(BaseHTTPRequestHandler): % (len(found), len(tool_msgs)), stream, model) return - # 图片:先让模型调一次 read_image,下一轮把收到的图片内联块报回来。 - # Files API 已经不用了 —— 请求里应该直接是 image_url + data: base64。 + # 图片:先让模型调一次 read_image, 下一轮把收到的图片内联块报回来. + # Files API 已经不用了 —— 请求里应该直接是 image_url + data: base64. if "IMAGE_TEST" in first_user: if not tool_msgs: args = json.dumps({"path": "build/tmp/dot.png"}) @@ -835,7 +835,7 @@ class Handler(BaseHTTPRequestHandler): self.wfile.flush() return - # 第二轮:请求里应该已经带上内联图片了。 + # 第二轮:请求里应该已经带上内联图片了. # 报文形状:{"type":"image_url","image_url":{"url":"data:image/png;base64,..."}} found, notes = [], [] for m in messages: @@ -850,14 +850,14 @@ class Handler(BaseHTTPRequestHandler): head = url.split(";", 1)[0] # data:image/png b64 = url.split(",", 1)[1] if "," in url else "" found.append("%s:%d" % (head, len(b64))) - # 图片被拒之后,附件消息里应该换成一句给模型看的说明。 - # 把它回显出来,端到端才能证明**模型真的收到了**这句话, - # 而不只是我们自己的历史被改了。 + # 图片被拒之后, 附件消息里应该换成一句给模型看的说明. + # 把它回显出来, 端到端才能证明**模型真的收到了**这句话, + # 而不只是我们自己的历史被改了. 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 payload = json.dumps({"error": { @@ -874,16 +874,16 @@ class Handler(BaseHTTPRequestHandler): self.send_text(reply, stream, model) return - # edit_file 的**调用展示**:成功时屏幕上该是"路径 + 行号"加 -/+ 新旧对照, - # 而不是一整段参数 JSON(old_string/new_string 原样打出来会刷屏)。 - # EDITTEST_OK 成功剧本,EDITTEST_FAIL 行号越界剧本。 + # edit_file 的**调用展示**:成功时屏幕上该是"路径 + 行号"加 -/+ 新旧对照, + # 而不是一整段参数 JSON (old_string/new_string 原样打出来会刷屏). + # EDITTEST_OK 成功剧本, EDITTEST_FAIL 行号越界剧本. if "EDITTEST_OK" in first_user or "EDITTEST_FAIL" in first_user: if tool_msgs: self.send_text("DONE[%s]" % tool_msgs[-1].get("content", "")[:60], 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,8 +909,8 @@ class Handler(BaseHTTPRequestHandler): self.wfile.flush() return - # 多步(4 次工具往返才收工):用来钉住"往返次数默认不限"。 - # 默认配置下必须跑得完;显式设了正数上限时要停在那个上限上并如实报出。 + # 多步 (4 次工具往返才收工):用来钉住"往返次数默认不限". + # 默认配置下必须跑得完; 显式设了正数上限时要停在那个上限上并如实报出. if "MANYSTEPS_TEST" in first_user: done = len(tool_msgs) if done < 4: diff --git a/tests/reduce.c b/tests/reduce.c index 9462804..8cce0e8 100644 --- a/tests/reduce.c +++ b/tests/reduce.c @@ -1,6 +1,6 @@ /* reduce.c - 把 fuzz 找到的失败输入最小化到最短复现样例 * - * 判定(oracle):同一段输入,"一次性喂" 与 "逐字节喂" 的渲染结果不一致。 + * 判定 (oracle):同一段输入, "一次性喂" 与 "逐字节喂" 的渲染结果不一致. * 用法: build/reduce build/md_fail.bin */ #include "markdown.h" @@ -9,8 +9,8 @@ #include #include -/* 注意:md_feed 只接受 NUL 结尾的字符串,而最小化过程中的缓冲区 - * 是"长度受限"的,所以必须先做一份带结尾符的副本。 */ +/* 注意:md_feed 只接受 NUL 结尾的字符串, 而最小化过程中的缓冲区 + * 是"长度受限"的, 所以必须先做一份带结尾符的副本. */ static char *render(const char *src, size_t len, size_t chunk) { char *text = xmalloc(len + 1); @@ -44,7 +44,7 @@ static char *render(const char *src, size_t len, size_t chunk) static int fails(const char *src, size_t len) { if (len == 0) return 0; - /* md_feed 以 NUL 结尾,输入里不能有 NUL */ + /* md_feed 以 NUL 结尾, 输入里不能有 NUL */ for (size_t i = 0; i < len; i++) if (src[i] == '\0') return 0; char *a = render(src, len, 0); char *b = render(src, len, 1); diff --git a/tests/repl_drive.py b/tests/repl_drive.py index 6648485..3b6e192 100644 --- a/tests/repl_drive.py +++ b/tests/repl_drive.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 -"""repl_drive.py - 用 pty 驱动 ligent 的 REPL,跑多轮对话并打印全部输出。 +"""repl_drive.py - 用 pty 驱动 ligent 的 REPL, 跑多轮对话并打印全部输出. -交互式行为(多轮上下文、/命令、行编辑)没法用管道测 —— 管道模式会把 -整段 stdin 当成一个任务。所以这里开一个伪终端,逐条发送提示词。 +交互式行为 (多轮上下文、/命令、行编辑)没法用管道测 —— 管道模式会把 +整段 stdin 当成一个任务. 所以这里开一个伪终端, 逐条发送提示词. 用法: repl_drive.py --bin ./ligent --base-url URL --prompt "第一轮" --prompt "第二轮" @@ -24,25 +24,25 @@ def drive(binary, args, prompts, wait_per_prompt=2.0, quiet_after=1.5, os._exit(127) out = bytearray() - # 行编辑器每次回到提示符都会打开括号粘贴,所以这个序列就是"轮到用户输入了" - # 的信号。用它做同步点,比"等输出安静下来"可靠也快得多。 + # 行编辑器每次回到提示符都会打开括号粘贴, 所以这个序列就是"轮到用户输入了" + # 的信号. 用它做同步点, 比"等输出安静下来"可靠也快得多. PROMPT_READY = b"\x1b[?2004h" ready_seen = 0 tail = b"" def note(chunk): - # 标记可能被 read 切断,所以要连着上一块的尾巴一起找 + # 标记可能被 read 切断, 所以要连着上一块的尾巴一起找 nonlocal tail, ready_seen buf = tail + chunk ready_seen += buf.count(PROMPT_READY) tail = buf[-(len(PROMPT_READY) - 1):] def drain(timeout, idle=0.5, until_ready=False): - """等到"回到提示符"(until_ready),或者输出彻底安静下来。 + """等到"回到提示符" (until_ready), 或者输出彻底安静下来. - 必须按标记等,不能只按安静等:等待动画每 250ms 画一帧,pty 永远 - 不会安静超过 idle,于是每次都要白等满 timeout —— 而 mock 其实几 - 毫秒就答复了。这是整个 e2e 从 126 秒掉下来的关键。 + 必须按标记等, 不能只按安静等:等待动画每 250ms 画一帧, pty 永远 + 不会安静超过 idle, 于是每次都要白等满 timeout —— 而 mock 其实几 + 毫秒就答复了. 这是整个 e2e 从 126 秒掉下来的关键. """ want = ready_seen + 1 if until_ready else None deadline = time.time() + timeout @@ -67,7 +67,7 @@ def drive(binary, args, prompts, wait_per_prompt=2.0, quiet_after=1.5, drain(quiet_after, until_ready=True) # 等横幅画完、第一次回到提示符 if raw_keys is not None: - # 逐字节发送原始按键序列(用于测方向键等编辑行为) + # 逐字节发送原始按键序列 (用于测方向键等编辑行为) for chunk, pause in raw_keys: os.write(fd, chunk if isinstance(chunk, bytes) else chunk.encode("utf-8")) drain(pause) @@ -112,11 +112,11 @@ def main(): ap.add_argument("--extra", action="append", default=[]) ap.add_argument("--wait", type=float, default=2.0) ap.add_argument("--keys", action="append", default=[], - help="原始按键序列(支持 \\x1b 转义),按顺序发送") + help="原始按键序列 (支持 \\x1b 转义), 按顺序发送") a = ap.parse_args() - # --no-history:被驱动的实例不该往用户真实的 ligent.history 里写测试垃圾。 - # 需要历史的调用方用 --extra=--history PATH(排在后面,会覆盖这一条)。 + # --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 if a.keys: diff --git a/tests/repl_edit_check.py b/tests/repl_edit_check.py index a6070f0..9e4903a 100755 --- a/tests/repl_edit_check.py +++ b/tests/repl_edit_check.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 -"""repl_edit_check.py - 验证 REPL 的行编辑行为(方向键、Home/End、退格、历史) +"""repl_edit_check.py - 验证 REPL 的行编辑行为 (方向键、Home/End、退格、历史) -只抓字节流是不够的:光标移动是靠转义序列实现的,必须把输出回放到一个 -终端模型里才能知道"屏幕上最终长什么样"。所以这里带一个极简终端模拟器, -只实现本编辑器用到的那几条控制序列。 +只抓字节流是不够的:光标移动是靠转义序列实现的, 必须把输出回放到一个 +终端模型里才能知道"屏幕上最终长什么样". 所以这里带一个极简终端模拟器, +只实现本编辑器用到的那几条控制序列. """ import os import pty @@ -41,7 +41,7 @@ class Term: if j < n and data[j] in '[O': j += 1 start = j - # CSI 的参数可以包含 ? < > ! 等私有标记,一律扫到终结符为止 + # CSI 的参数可以包含 ? < > ! 等私有标记, 一律扫到终结符为止 while j < n and not ('@' <= data[j] <= '~'): j += 1 params = data[start:j] @@ -99,8 +99,8 @@ class Term: def to_bytes(s): - # 把源码里写的十六进制转义与真实非 ASCII 字符一起转成字节。 - # 不能直接用 unicode_escape:一条串里既有转义又有中文时,latin-1 那步会报错。 + # 把源码里写的十六进制转义与真实非 ASCII 字符一起转成字节. + # 不能直接用 unicode_escape:一条串里既有转义又有中文时, latin-1 那步会报错. if isinstance(s, bytes): return s out = bytearray() @@ -145,12 +145,12 @@ def run(binary, args, keys, keyset_settle=0.10, settle=0.45, idle=0.03): tail = buf[-(len(PROMPT_READY) - 1):] def drain(timeout, idle=idle, until_ready=False): - """安静下来就返回,不把整个 timeout 睡满。 + """安静下来就返回, 不把整个 timeout 睡满. - idle 取得很小(0.03s)是有依据的:行编辑器每次按键都在一个 fflush - 里把整屏重绘写完,所以一次 read 之后基本就没了。而提交输入触发请求 - 时,等待动画每 250ms 一帧,永远不会安静,于是照样等到 timeout —— - 那条路径的行为和以前一致,不会因为 idle 调小而抢跑。 + idle 取得很小 (0.03s)是有依据的:行编辑器每次按键都在一个 fflush + 里把整屏重绘写完, 所以一次 read 之后基本就没了. 而提交输入触发请求 + 时, 等待动画每 250ms 一帧, 永远不会安静, 于是照样等到 timeout —— + 那条路径的行为和以前一致, 不会因为 idle 调小而抢跑. """ want = ready_seen + 1 if until_ready else None end = time.time() + timeout @@ -178,7 +178,7 @@ def run(binary, args, keys, keyset_settle=0.10, settle=0.45, idle=0.03): drain(keyset_settle) drain(settle) - # 刻意不发 Ctrl-C(它会在光标处打出 ^C 标记并覆盖两个字), + # 刻意不发 Ctrl-C (它会在光标处打出 ^C 标记并覆盖两个字), # 直接关掉 pty:最后那次重绘就是缓冲区的真值 try: os.close(fd) @@ -192,10 +192,10 @@ 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,猜长了每处都在 - 白等。进程退出(EOF)也算等到。""" + 裸 pty 会话里不要用 time.sleep 猜时间:猜短了会 flaky, 猜长了每处都在 + 白等. 进程退出 (EOF)也算等到. """ READY = b"\x1b[?2004h" end = time.time() + timeout tail = b"" @@ -225,15 +225,15 @@ BS, CTRLC = '\\x7f', '\\x03' 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 显式打开。 + # 编辑测试不会真的发请求, 但程序在缺 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 里写测试垃圾,方向键用例的结果也不会取决于开发者机器 - # 上碰巧有什么历史。 + # 被驱动的实例会读写输入历史. 整轮都指到临时文件:既不往用户真实的 + # ligent.history 里写测试垃圾, 方向键用例的结果也不会取决于开发者机器 + # 上碰巧有什么历史. scratch = os.path.join(tempfile.gettempdir(), "ligent_edit_hist_%d" % os.getpid()) os.environ["LIGENT_HISTORY"] = scratch @@ -248,10 +248,10 @@ def main(): cases.append(("Home 回到行首后插入", ['world', '\\x01', 'hello '], 'ligent> hello world')) - cases.append(("CSI Home 序列(\\x1b[H)", + cases.append(("CSI Home 序列 (\\x1b[H)", ['world', HOME_END[0], 'hello '], 'ligent> hello world')) - cases.append(("End 序列(\\x1b[F)回到末尾", + cases.append(("End 序列 (\\x1b[F)回到末尾", ['world', HOME_END[0], 'hello ', HOME_END[1], '!'], 'ligent> hello world!')) cases.append(("行首再左移不越界", @@ -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(("粘贴后可继续编辑", @@ -293,16 +293,16 @@ def main(): ['中文abc'] + [LEFT] * 3 + ['X'], 'ligent> 中文Xabc')) - # 粘贴进来的原始字节里可能夹着控制字符。read_paste 是原样收下的(不走 - # classify),所以必须清洗:终端按制表位展开 \t,编辑器只当 1 列; - # ESC 会被终端真的执行掉;\r 把光标拉回行首。 + # 粘贴进来的原始字节里可能夹着控制字符. read_paste 是原样收下的 (不走 + # classify), 所以必须清洗:终端按制表位展开 \t, 编辑器只当 1 列; + # ESC 会被终端真的执行掉; \r 把光标拉回行首. cases.append(("粘贴里的制表符按制表位展开", [PASTE + 'a\\tb' + PASTE_END, 'X'], 'ligent> ' + 'a' + ' ' * 7 + 'bX')) - cases.append(("制表符展开后光标位置正确(左移再插入)", + cases.append(("制表符展开后光标位置正确 (左移再插入)", [PASTE + 'a\\tb' + PASTE_END, LEFT, 'X'], 'ligent> ' + 'a' + ' ' * 7 + 'Xb')) - cases.append(("粘贴里的 ESC 序列被丢弃,不让它执行", + cases.append(("粘贴里的 ESC 序列被丢弃, 不让它执行", [PASTE + 'before\\x1b[2Jafter' + PASTE_END], 'ligent> beforeafter')) cases.append(("粘贴里的 CRLF 归一成 LF", @@ -330,12 +330,12 @@ def main(): print(" 期望: %r" % want) print(" 实际: %r" % got) if literal_escapes: - print(" 输出里出现了字面量转义文本 ^[[(方向键没被识别)") + print(" 输出里出现了字面量转义文本 ^[[ (方向键没被识别)") else: print(" ✓ %s" % name) - # 提示符带颜色时,那串转义序列不能占列宽 —— 光标定位必须和不着色时一致。 - # 上面的用例都带 --no-color,所以这里单独补一组彩色的。 + # 提示符带颜色时, 那串转义序列不能占列宽 —— 光标定位必须和不着色时一致. + # 上面的用例都带 --no-color, 所以这里单独补一组彩色的. color_args = [a for a in args if a != '--no-color'] + ['--color'] color_cases = [ ("彩色提示符:左移后插入", @@ -362,9 +362,9 @@ def main(): print(" 期望: %r" % want) print(" 实际: %r" % got) - # 上面那些用例只比屏幕内容,而颜色在终端模型里是被剥掉的 —— 包没包住 - # 输入的字看不出来。所以这里直接查字节:绿色区间必须一直延续到用户敲的 - # 内容之后。只包提示符的话,提示符自带的 reset 会让输入掉回默认色。 + # 上面那些用例只比屏幕内容, 而颜色在终端模型里是被剥掉的 —— 包没包住 + # 输入的字看不出来. 所以这里直接查字节:绿色区间必须一直延续到用户敲的 + # 内容之后. 只包提示符的话, 提示符自带的 reset 会让输入掉回默认色. raw = run(binary, color_args, ['greenme']) txt = raw.decode('utf-8', 'replace') start = txt.rfind('\x1b[32m') @@ -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' @@ -434,12 +434,12 @@ def main(): with open(hist, encoding='utf-8') as fh: content = fh.read() if 'live_append' in content: - print(" ✓ 提交后立刻落盘(进程未退出)") + print(" ✓ 提交后立刻落盘 (进程未退出)") else: fail += 1 - print(" ✗ 提交后立刻落盘(进程未退出)") + print(" ✗ 提交后立刻落盘 (进程未退出)") print(" 文件内容: %r" % content) - # 继续敲两条再正常退出,供下一个实例翻 + # 继续敲两条再正常退出, 供下一个实例翻 for k in ('persist_one\r', '/think high\r'): os.write(fd, k.encode()) wait_ready(fd) @@ -454,7 +454,7 @@ def main(): except OSError: pass - # 文件里依次是 live_append / persist_one / /think high / /exit, + # 文件里依次是 live_append / persist_one / /think high / /exit, # 所以按三次上方向键才轮到 persist_one raw3 = run(binary, args, [UP, UP, UP], keyset_settle=0.3, settle=0.6, idle=0.03) term3 = Term() @@ -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: @@ -490,7 +490,7 @@ def main(): total = len(cases) + len(color_cases) + 2 print() - print("通过 %d 项,失败 %d 项" % (total - fail, fail)) + print("通过 %d 项, 失败 %d 项" % (total - fail, fail)) for p in (scratch, scratch + ".tmp"): if os.path.exists(p): os.unlink(p) diff --git a/tests/replay.py b/tests/replay.py index 10a153f..ad8a7ad 100644 --- a/tests/replay.py +++ b/tests/replay.py @@ -1,26 +1,26 @@ #!/usr/bin/env python3 -"""replay.py - 把 /log 录下来的真实会话当成测试夹具回放。 +"""replay.py - 把 /log 录下来的真实会话当成测试夹具回放. -为什么需要它:ligent 的协议适配一直只在自建的 mock 服务端上验证过,而 mock -是我们自己写的 —— 它接受什么,完全取决于**我们以为**服务端会接受什么。真实 -服务端的差异是最大的未知数。 +为什么需要它:ligent 的协议适配一直只在自建的 mock 服务端上验证过, 而 mock +是我们自己写的 —— 它接受什么, 完全取决于**我们以为**服务端会接受什么. 真实 +服务端的差异是最大的未知数. -/log 导出的是**原始字节**(请求体、响应头、未经解析的响应原文,流式就是原始 -SSE),正好够原样伺候回去。所以这里不重写一个"模拟客户端",而是当**离线 -服务端**:把录下来的响应按顺序发给一个真的 ligent 实例,于是 http.c 的分块 -读取、llm.c 的 SSE 行切分与 tool_calls 增量重组,全都在真实字节上跑。 +/log 导出的是**原始字节** (请求体、响应头、未经解析的响应原文, 流式就是原始 +SSE), 正好够原样伺候回去. 所以这里不重写一个"模拟客户端", 而是当**离线 +服务端**:把录下来的响应按顺序发给一个真的 ligent 实例, 于是 http.c 的分块 +读取、llm.c 的 SSE 行切分与 tool_calls 增量重组, 全都在真实字节上跑. 用法: replay.py --summary FILE 这份录制里有什么 replay.py --serve PORT FILE [--strict] 当离线服务端 replay.py --check FILE 自检:能不能解析出结构完整的往返 ---strict 会把每个进来的请求体与录制时那一份做**结构比对**(顶层键、消息角色 -序列、工具名集合),不一致就回 400 并在 stderr 打印差异。这是这个工具最有用 -的一半:它把"今天的客户端还在发同样的报文形状"变成一条会失败的断言。 +--strict 会把每个进来的请求体与录制时那一份做**结构比对** (顶层键、消息角色 +序列、工具名集合), 不一致就回 400 并在 stderr 打印差异. 这是这个工具最有用 +的一半:它把"今天的客户端还在发同样的报文形状"变成一条会失败的断言. -注意回放时要给 ligent 加 --no-probe:能力探测会插进额外的请求,而那几轮 -不在录制里。录制时也建议关掉(LIGENT_PROBE=0),两边一致。 +注意回放时要给 ligent 加 --no-probe:能力探测会插进额外的请求, 而那几轮 +不在录制里. 录制时也建议关掉 (LIGENT_PROBE=0), 两边一致. """ import argparse import json @@ -32,9 +32,9 @@ import threading def parse_log(text): - """把一份 /log 导出切成若干次 HTTP 往返。 + """把一份 /log 导出切成若干次 HTTP 往返. - 格式(见 http.c 的记录点): + 格式 (见 http.c 的记录点): ===== ===== 请求: POST ----- 请求体 (N 字节) ----- @@ -45,8 +45,8 @@ def parse_log(text): <响应原文> ----- 结束: HTTP ----- - 日志是**滚动缓冲**,开头那一段可能是被截断的,所以要容忍缺标记 —— 跳过 - 解析不出来的块,而不是整个失败。 + 日志是**滚动缓冲**, 开头那一段可能是被截断的, 所以要容忍缺标记 —— 跳过 + 解析不出来的块, 而不是整个失败. """ lines = text.split("\n") out = [] @@ -67,20 +67,20 @@ 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 - # 从这一行起累加字节,直到凑够 n + # 从这一行起累加字节, 直到凑够 n blob = "" while i < len(lines) and len(blob.encode()) < n: blob += (lines[i] + "\n") i += 1 ex["req"] = blob.encode()[:n] - # 上面多读了一行就把多余的还回去(blob 里包含了尾部换行) + # 上面多读了一行就把多余的还回去 (blob 里包含了尾部换行) consumed = len(blob.encode()) if consumed > n: - # 把这一行重新交回处理(只可能多出换行) + # 把这一行重新交回处理 (只可能多出换行) pass # 响应头 @@ -105,17 +105,17 @@ def parse_log(text): ex["status"] = 200 i += 1 - # 结构不完整(多半是滚动缓冲把开头截了)就丢掉 + # 结构不完整 (多半是滚动缓冲把开头截了)就丢掉 if ex["url"] and ex["status"] is not None: out.append(ex) return out def shape_of(req_bytes): - """一个请求体的**结构指纹**:只看形状,不看内容。 + """一个请求体的**结构指纹**:只看形状, 不看内容. - 内容每轮都不一样(历史在长),但形状必须稳定 —— 顶层键、消息角色序列、 - 工具名集合。客户端一旦改了报文形状,这里就会显示出来。 + 内容每轮都不一样 (历史在长), 但形状必须稳定 —— 顶层键、消息角色序列、 + 工具名集合. 客户端一旦改了报文形状, 这里就会显示出来. """ try: body = json.loads(req_bytes.decode("utf-8", "replace")) @@ -162,9 +162,9 @@ class ReplayServer: e = self.next_exchange() if e is None: sys.stderr.write( - "replay: 录制里没有更多响应了(已经回了 %d 个)。\n" - " 客户端比录制时多发了请求 —— 多半是多了能力探测(回放时请加 " - "--no-probe)或者客户端行为变了。\n" % len(self.ex)) + "replay: 录制里没有更多响应了 (已经回了 %d 个). \n" + " 客户端比录制时多发了请求 —— 多半是多了能力探测 (回放时请加 " + "--no-probe)或者客户端行为变了. \n" % len(self.ex)) return 500, {}, b'{"error":{"message":"replay: out of recorded responses"}}' if self.strict: @@ -197,8 +197,8 @@ def serve(exchanges, port, strict, loop): s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind(("127.0.0.1", port)) s.listen(8) - print("replay server on %d(%d 个录制往返%s)" - % (port, len(exchanges), ",strict" if strict else ""), flush=True) + print("replay server on %d (%d 个录制往返%s)" + % (port, len(exchanges), ", strict" if strict else ""), flush=True) while True: conn, _ = s.accept() @@ -250,7 +250,7 @@ def main(): ap.add_argument("--check", action="store_true", help="自检能否解析成完整往返") ap.add_argument("--serve", type=int, metavar="PORT", help="当离线服务端") ap.add_argument("--strict", action="store_true", - help="比对每个请求的结构,不一致就回 400") + help="比对每个请求的结构, 不一致就回 400") ap.add_argument("--loop", action="store_true", help="响应用完后从头再来") a = ap.parse_args() @@ -282,7 +282,7 @@ def main(): serve(ex, a.serve, a.strict, a.loop) return 0 - ap.error("要么 --summary/--check,要么 --serve PORT") + ap.error("要么 --summary/--check, 要么 --serve PORT") if __name__ == "__main__": diff --git a/tests/run_e2e.sh b/tests/run_e2e.sh index 9749034..286fdb4 100755 --- a/tests/run_e2e.sh +++ b/tests/run_e2e.sh @@ -2,17 +2,17 @@ # run_e2e.sh - 端到端测试:用本地 mock 服务端跑通 ligent 的完整链路 set -u -# 把脚本自己的 stdin 断掉。 +# 把脚本自己的 stdin 断掉. # -# 这不是洁癖:ligent 在写操作前会问 (Y/n),而它在 stdin 不是终端时默认放行。 -# 于是"直接调用 $BIN"的用例在 CI 里一路通过,在开发者的终端里却会停下来 -# 等人敲回车 —— 同一份脚本,两种行为。交互用例都走 pty 驱动脚本,不靠这里 -# 的 stdin,所以断开它不会误伤任何东西。 +# 这不是洁癖:ligent 在写操作前会问 (Y/n), 而它在 stdin 不是终端时默认放行. +# 于是"直接调用 $BIN"的用例在 CI 里一路通过, 在开发者的终端里却会停下来 +# 等人敲回车 —— 同一份脚本, 两种行为. 交互用例都走 pty 驱动脚本, 不靠这里 +# 的 stdin, 所以断开它不会误伤任何东西. exec "$LIGENT_CONFIG" -# 存档目录(<二进制目录>/ligent.archive)是**运行期状态**,不是构建产物: -# 里面是用户自己的 /export 存档和图片备份。而下面几十段用例为了隔离会反复 -# `rm -rf` 它,被测二进制又常常就在仓库根 —— 也就是说直接跑 e2e 会把人家的 -# 存档删掉。所以开机先看一眼:非空就整体挪到临时目录(打印路径,内容不动), -# 之后也不再碰它。挪不动就直接停 —— 宁可测试不跑,也不能把东西删了。 +# 存档目录 (<二进制目录>/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 " $SAVED_ARCHIVE/ligent.archive (e2e 需要这个目录是干净的)" + echo "注意:$ARCHIVE_DIR 里有东西, 已整体移到" + echo " $SAVED_ARCHIVE/ligent.archive (e2e 需要这个目录是干净的)" echo else - echo "✗ 移不动 $ARCHIVE_DIR —— 为免误删,测试不跑。先手动移开它。" >&2 + echo "✗ 移不动 $ARCHIVE_DIR —— 为免误删, 测试不跑. 先手动移开它. " >&2 exit 1 fi fi @@ -134,9 +134,9 @@ OUT="$(echo "piped task" | "$BIN" --api-key test --base-url "$BASE" --no-confirm contains "FINAL:" "$OUT"; check "从 stdin 读取任务" $? echo -echo "== 7. Markdown 流式渲染(mock 按 1 字符一片下发) ==" +echo "== 7. Markdown 流式渲染 (mock 按 1 字符一片下发) ==" OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --color "MARKDOWN_TEST" 2>&1)" -# 去掉 ANSI 后再做"可见文本"断言(样式会把标记和正文隔开) +# 去掉 ANSI 后再做"可见文本"断言 (样式会把标记和正文隔开) PLAIN="$(printf '%s' "$OUT" | sed 's/\x1b\[[0-9;]*m//g')" printf '%s' "$OUT" | grep -q $'\033\[1;36m' && r=0 || r=1 check "H1 渲染为粗体青" $r @@ -160,8 +160,8 @@ printf '%s' "$PLAIN" | grep -qF '│ 苹果 │' && r=0 || r=1 check "中文表格单元格对齐" $r printf '%s' "$PLAIN" | grep -qF '│ 3 │' && r=0 || r=1 check "表格右对齐标注生效" $r -# 下划线 + 青(SGR 4;36),不是亮蓝 —— 整个项目只用 16 色内的码, -# 理由见 tools/check-colors.sh。 +# 下划线 + 青 (SGR 4;36), 不是亮蓝 —— 整个项目只用 16 色内的码, +# 理由见 tools/check-colors.sh. printf '%s' "$OUT" | grep -q $'\033\[4;36m\[' && r=0 || r=1 check "链接 [] 加下划线并着青色" $r printf '%s' "$OUT" | grep -qF '(https://example.com/doc)' && r=0 || r=1 @@ -170,7 +170,7 @@ printf '%s' "$OUT" | grep -qF 'arr[i]' && r=0 || r=1 check "数组下标不被当成链接" $r echo -echo "== 8. 关闭渲染时输出原始 Markdown(管道场景) ==" +echo "== 8. 关闭渲染时输出原始 Markdown (管道场景) ==" OUT="$(echo "MARKDOWN_TEST" | "$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color 2>&1)" printf '%s' "$OUT" | grep -qF '**粗体**' && r=0 || r=1 check "--no-color 时保留原始 ** 标记" $r @@ -183,14 +183,14 @@ echo 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 "第一次失败后有退避重试" $? -contains "重试(第 2 次)" "$OUT"; check "第二次失败后继续重试" $? +contains "重试 (第 1 次)" "$OUT"; check "第一次失败后有退避重试" $? +contains "重试 (第 2 次)" "$OUT"; check "第二次失败后继续重试" $? echo -echo "== 10. 重试次数可配置(--retries 0 应立刻失败) ==" +echo "== 10. 重试次数可配置 (--retries 0 应立刻失败) ==" OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color --retries 0 "ALWAYS503_TEST" 2>&1)" RC=$? -contains "重试(第" "$OUT" && r=1 || r=0 +contains "重试 (第" "$OUT" && r=1 || r=0 check "--retries 0 时不做任何重试" $r contains "503" "$OUT" "$OUT"; check "--retries 0 时直接报出 503" $? [ "$RC" -ne 0 ]; check "--retries 0 失败时退出码非 0" $? @@ -206,7 +206,7 @@ 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 "明确提示传输中断" $? -contains "不重试" "$OUT"; check "已输出内容时不重试(避免重复输出)" $? +contains "不重试" "$OUT"; check "已输出内容时不重试 (避免重复输出)" $? echo echo "== 13. 畸形 SSE 不能导致崩溃 ==" @@ -225,10 +225,10 @@ OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no contains "超过" "$OUT"; check "非流式路径同样有上限" $? echo -echo "== 15. 上下文超限:如实报错,历史一个字节都不动 ==" -# 这里以前测的是"超限后自动丢弃最老的一轮再重试"。那套机制已经整个删掉: -# 判据只是错误文案里的关键词,代价却是**不可逆地**删掉几轮对话。现在的行为是 -# 把服务端的原始错误报出来、历史原样保留,怎么处理由用户决定(/back、/reset)。 +echo "== 15. 上下文超限:如实报错, 历史一个字节都不动 ==" +# 这里以前测的是"超限后自动丢弃最老的一轮再重试". 那套机制已经整个删掉: +# 判据只是错误文案里的关键词, 代价却是**不可逆地**删掉几轮对话. 现在的行为是 +# 把服务端的原始错误报出来、历史原样保留, 怎么处理由用户决定 (/back、/reset). OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --prompt "TOOLONG_TEST 第一轮" --prompt "TOOLONG_TEST 第二轮" \ --prompt "/history" --wait 1.2 2>&1)" @@ -236,9 +236,9 @@ 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 不计)" $? +check "历史条数原样 (system 不计)" $? echo echo "== 16. 自动裁剪的整套接口都已移除 ==" @@ -248,16 +248,16 @@ contains "未知选项" "$OUT"; check "拒绝时说清是未知选项" $? OUT="$("$BIN" --auto-trim "x" 2>&1)"; r=$? [ "$r" -eq 2 ]; check "--auto-trim 同样被拒绝" $? -# 旧配置里留着的 auto_trim 只是被忽略(未知键不该让程序起不来), +# 旧配置里留着的 auto_trim 只是被忽略 (未知键不该让程序起不来), # 而且它不该再出现在生效配置里 —— 否则用户以为它还有用 printf '{"auto_trim": false}\n' > "$ROOT/build/tmp/e2e-autotrim.json" OUT="$("$BIN" -c "$ROOT/build/tmp/e2e-autotrim.json" --print-config 2>&1)"; r=$? -[ "$r" -eq 0 ]; check "旧配置里的 auto_trim 不会拦住启动(未知键忽略)" $? +[ "$r" -eq 0 ]; check "旧配置里的 auto_trim 不会拦住启动 (未知键忽略)" $? contains "auto_trim" "$OUT" && r=1 || r=0 check "--print-config 里不再出现 auto_trim" $r echo -echo "== 17. 重定向必须被明确报出,不能静默跟随 ==" +echo "== 17. 重定向必须被明确报出, 不能静默跟随 ==" OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color --retries 0 "REDIRECT_TEST" 2>&1)" RC=$? contains "重定向到" "$OUT"; check "明确报出发生重定向" $? @@ -267,9 +267,9 @@ contains "example.invalid" "$OUT"; check "报出重定向目标地址" $? echo echo "== 18. --verbose 不得泄露 API key ==" -# 必须有 --no-confirm:这一轮会触发工具调用,缺了它就会在真终端上问 (Y/n), -# 整个套件停在那里等人敲回车。脚本开头的 exec &1)" contains "VERBOSE-LEAK-CHECK" "$OUT" && r=1 || r=0 @@ -284,7 +284,7 @@ OUT="$("$BIN" --api-key "sk-0f114e3dac8249f0a36fd8fd5c517360" --base-url "$BASE" contains "Missing Authentication header" "$OUT"; check "服务端原文被透传" $? contains "不同服务商的密钥互不通用" "$OUT"; check "指出 key 与服务商不匹配" $? contains "sk-or-" "$OUT"; check "点明 OpenRouter 的 key 前缀" $? -contains "sk-0f1" "$OUT"; check "显示实际发出的 key 前缀(已打码)" $? +contains "sk-0f1" "$OUT"; check "显示实际发出的 key 前缀 (已打码)" $? contains "0f114e3dac8249f0a36fd8fd5c517360" "$OUT" && r=1 || r=0 check "不回显完整密钥" $r @@ -296,11 +296,11 @@ contains "system : (内置默认提示词)" "$OUT"; check "空 system 回 rm -f build/empty_system.json echo -echo "== 21. REPL 行编辑(方向键 / Home / End / 退格 / 历史) ==" -# 这个套件要跑十几秒。用 $(...) 捕获的话,输出被接到管道会变成块缓冲, -# 中途十几秒一点动静都没有 —— 看着就像卡住了在等输入。所以用 tee 边走边打; -# python 的 -u 和 sed 的 -u 都不能省:stdout 接了管道就变块缓冲, -# 得每一环都关掉才对。 +echo "== 21. REPL 行编辑 (方向键 / Home / End / 退格 / 历史) ==" +# 这个套件要跑十几秒. 用 $(...) 捕获的话, 输出被接到管道会变成块缓冲, +# 中途十几秒一点动静都没有 —— 看着就像卡住了在等输入. 所以用 tee 边走边打; +# python 的 -u 和 sed 的 -u 都不能省:stdout 接了管道就变块缓冲, +# 得每一环都关掉才对. python3 -u tests/repl_edit_check.py "$BIN" "$BASE" 2>&1 \ | sed -u 's/^/ /' | tee build/edit_check.out RC=${PIPESTATUS[0]} @@ -314,12 +314,12 @@ OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --prompt "你好" --prompt "/log" --prompt "/log $LOG" --wait 1.2 2>&1)" contains "收发记录已缓存" "$OUT"; check "/log 无参数显示缓存大小" $? -# 从 pty 输出里取出"已把 N 字节"这句话(行编辑器会重绘,所以要按关键词找) +# 从 pty 输出里取出"已把 N 字节"这句话 (行编辑器会重绘, 所以要按关键词找) printf '%s' "$OUT" | grep -q "已把 .* 字节收发记录追加写入"; check "导出成功并报告字节数" $? [ -f "$LOG" ]; check "日志文件已创建" $? printf '%s' "$(cat "$LOG" 2>/dev/null)" | grep -q "请求: POST"; check "记录了请求" $? printf '%s' "$(cat "$LOG" 2>/dev/null)" | grep -q -- "----- 响应头 -----"; check "记录了响应头" $? -printf '%s' "$(cat "$LOG" 2>/dev/null)" | grep -q "^data: "; check "记录了 SSE 原文(可离线回放)" $? +printf '%s' "$(cat "$LOG" 2>/dev/null)" | grep -q "^data: "; check "记录了 SSE 原文 (可离线回放)" $? printf '%s' "$(cat "$LOG" 2>/dev/null)" | grep -q "结束: HTTP 200"; check "记录了结束状态" $? printf '%s' "$(cat "$LOG" 2>/dev/null)" | grep -q "Authorization"; r=$? [ "$r" -ne 0 ]; check "日志里不含认证头" $? @@ -354,12 +354,12 @@ OUT3="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ contains "还没有内容" "$OUT3"; check "空对话拒绝导出" $? COUNT="$(ls "$ARCHIVE_DIR"/*.json 2>/dev/null | wc -l)" -[ "$COUNT" -eq 2 ]; check "存放了两份存档(文件名不冲突)" $? +[ "$COUNT" -eq 2 ]; check "存放了两份存档 (文件名不冲突)" $? # 文件名是 8 位小写字母数字 ls "$ARCHIVE_DIR" | head -1 | sed 's/\.json$//' | grep -qE '^[a-z0-9]{8}$' check "根名是 8 位小写字母数字" $? -# 内容必须自描述,且记下自己的名字与来源 +# 内容必须自描述, 且记下自己的名字与来源 python3 - "$ARCHIVE_DIR" <<'PYEOF' import glob, json, sys files = glob.glob(sys.argv[1] + "/*.json") @@ -369,7 +369,7 @@ ok = (d.get("format") == "ligent.archive" and "messages" in d and d.get("created") and d.get("saved") and d.get("model")) sys.exit(0 if ok else 1) PYEOF -check "存档是自描述的 JSON(name/parent/created/messages)" $? +check "存档是自描述的 JSON (name/parent/created/messages)" $? echo echo "== 30b. 谱系命名:分支看得见 ==" @@ -382,7 +382,7 @@ contains "$AROOT ->" "$OUT"; check "从根分出的名字挂在根后面" $? OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --extra=--auto-save --prompt "/import $AROOT" --prompt "n" --prompt "分支乙" \ --prompt "/export" --prompt "/exit" --wait 1.3 2>&1)" -contains "$AROOT ->" "$OUT"; check "第二次仍从根分出(兄弟分支)" $? +contains "$AROOT ->" "$OUT"; check "第二次仍从根分出 (兄弟分支)" $? BRANCHES="$(ls "$ARCHIVE_DIR" | sed 's/\.json$//' | grep -c "^${AROOT}_")" [ "$BRANCHES" -eq 2 ]; check "根下有两个兄弟分支" $? @@ -401,32 +401,32 @@ ls "$ARCHIVE_DIR" | sed 's/\.json$//' | grep -qE "^${B1}_[a-z0-9]{3}$" check "孙分支名形如 <父>_<3位>_<3位>" $? echo -echo "== 31. /import 交互式载入(可以按需打印历史) ==" +echo "== 31. /import 交互式载入 (可以按需打印历史) ==" OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --prompt "/import" --prompt "1" --prompt "n" --prompt "/history" --wait 1.2 2>&1)" contains "份存档" "$OUT"; check "/import 列出存档" $? contains "条消息" "$OUT"; check "列表里显示消息条数" $? contains "已载入" "$OUT"; check "选中后载入成功" $? contains "是否输出这段历史的内容" "$OUT"; check "询问是否输出历史" $? -# 列表顺序会被新增的分支影响,所以不写死条数,改成核对两个数字一致 +# 列表顺序会被新增的分支影响, 所以不写死条数, 改成核对两个数字一致 LOADED="$(printf '%s' "$OUT" | tr '\r' '\n' | grep '已载入' | grep -oE '[0-9]+ 条消息' | grep -oE '^[0-9]+' | head -1)" HIST="$(printf '%s' "$OUT" | tr '\r' '\n' | grep '历史消息' | grep -oE '[0-9]+' | head -1)" -[ -n "$LOADED" ] && [ "$LOADED" = "$HIST" ]; check "载入后的历史条数与存档一致($LOADED)" $? +[ -n "$LOADED" ] && [ "$LOADED" = "$HIST" ]; check "载入后的历史条数与存档一致 ($LOADED)" $? printf '%s' "$OUT" | grep -q "紫水晶" && r=1 || r=0 check "回答 n 时不打印历史内容" $r -# 回答 y 时把历史逐条打出来。这里按名字指定含暗号那一份 —— 选择器的 -# 序号顺序由 archive_list 的排序决定,不该在测试里赌。 +# 回答 y 时把历史逐条打出来. 这里按名字指定含暗号那一份 —— 选择器的 +# 序号顺序由 archive_list 的排序决定, 不该在测试里赌. SECRET="$(grep -l 紫水晶 "$ARCHIVE_DIR"/*.json 2>/dev/null | head -1 \ | xargs -r basename | sed 's/\.json$//')" -[ -n "$SECRET" ]; check "找到含暗号的那份存档($SECRET)" $? +[ -n "$SECRET" ]; check "找到含暗号的那份存档 ($SECRET)" $? 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 "工具调用参数完整打印" $? contains "tool_call_id=call_" "$OUT"; check "工具结果标出回应的是哪次调用" $? printf '%s' "$OUT" | grep -qE "还有 [0-9]+ 字节|不再显示" && r=1 || r=0 @@ -435,10 +435,10 @@ printf '%s' "$OUT" | grep -qE "还有 [0-9]+ 字节|不再显示" && r=1 || r=0 # 直接回车等于 Y OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --prompt "/import $SECRET" --prompt "" --prompt "/exit" --wait 1.2 2>&1)" -contains "紫水晶" "$OUT"; check "直接回车默认输出(Y)" $? +contains "紫水晶" "$OUT"; check "直接回车默认输出 (Y)" $? echo -echo "== 31b. /cls 只清屏,不动对话 ==" +echo "== 31b. /cls 只清屏, 不动对话 ==" OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --extra=--no-auto-save \ --prompt "你好" --prompt "/history" --prompt "/cls" --prompt "/history" \ @@ -448,11 +448,11 @@ 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)" $? +[ -n "$N1" ] && [ "$N1" = "$N2" ]; check "/cls 前后历史条数不变 ($N1)" $? # /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 抢走)" $? +check "/clear 仍然清空对话 (没被 /cls 抢走)" $? echo echo "== 32. /import 取消与非法序号 ==" @@ -465,7 +465,7 @@ contains "超出范围" "$OUT"; check "非法序号被拒绝" $? rm -rf "$ARCHIVE_DIR" echo -echo "== 33. 写操作确认(默认开启,回车即允许) ==" +echo "== 33. 写操作确认 (默认开启, 回车即允许) ==" OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --extra=--confirm --extra=--no-auto-save \ --prompt "跑一下" --prompt "n" --prompt "/exit" --wait 1.2 2>&1)" @@ -497,7 +497,7 @@ N1="$(ls "$ARCHIVE_DIR"/*.json 2>/dev/null | wc -l)" N2="$(ls "$ARCHIVE_DIR"/*.json 2>/dev/null | wc -l)" [ "$N2" -eq "$N1" ]; check "--no-auto-save 时不存档" $? -# 只有 system 一条(没问过问题)不该产生存档 +# 只有 system 一条 (没问过问题)不该产生存档 "$BIN" --api-key test --base-url "$BASE" --no-color --no-confirm --print-config >/dev/null 2>&1 N3="$(ls "$ARCHIVE_DIR"/*.json 2>/dev/null | wc -l)" [ "$N3" -eq "$N1" ]; check "没有内容时不留下空存档" $? @@ -506,10 +506,10 @@ OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --extra=--auto-save \ --prompt "第一段对话" --prompt "/reset" --prompt "第二段对话" \ --prompt "/exit" --wait 1.3 2>&1)" -contains "已自动存档(重置前)" "$OUT"; check "/reset 前自动存档" $? -contains "已自动存档(退出)" "$OUT"; check "退出时自动存档" $? +contains "已自动存档 (重置前)" "$OUT"; check "/reset 前自动存档" $? +contains "已自动存档 (退出)" "$OUT"; check "退出时自动存档" $? -# 重置之后若没有任何内容,退出时不该留下空存档 +# 重置之后若没有任何内容, 退出时不该留下空存档 BEFORE="$(ls "$ARCHIVE_DIR"/*.json 2>/dev/null | wc -l)" python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --extra=--auto-save --prompt "/exit" --wait 1.0 >/dev/null 2>&1 @@ -526,7 +526,7 @@ python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" --extra=--auto-save R2="$(ls "$ARCHIVE_DIR" | sed 's/\.json$//' | head -1)" [ -n "$R2" ]; check "先建立了一个根存档" $? -# 先聊一轮(不导出),再导入 —— 这时才需要保底存档 +# 先聊一轮 (不导出), 再导入 —— 这时才需要保底存档 OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" --extra=--auto-save \ --prompt "临时的对话" --prompt "/import $R2" --prompt "n" --prompt "分支" \ --prompt "/export" --prompt "/exit" --wait 1.2 2>&1)" @@ -536,22 +536,22 @@ check "临时存档名为 tmp_<8位>" $? # 临时存档不该成为分支的祖先 printf '%s' "$OUT" | grep -qE "${R2} -> ${R2}_[a-z0-9]{3}" -check "分支仍挂在原来的根下,而不是 tmp_ 之下" $? +check "分支仍挂在原来的根下, 而不是 tmp_ 之下" $? ls "$ARCHIVE_DIR" | sed 's/\.json$//' | grep -qE "^${R2}_[a-z0-9]{3}$" check "分支名形如 <根>_<3位>" $? rm -rf "$ARCHIVE_DIR" echo -echo "== 36. /shell(带参数直接执行) ==" +echo "== 36. /shell (带参数直接执行) ==" OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --prompt "/shell echo 来自shell" --prompt "/history" --wait 1.4 2>&1)" contains "来自shell" "$OUT"; check "命令被执行" $? contains "已加入上下文" "$OUT"; check "结果记入上下文" $? contains "历史消息 1 条" "$OUT" -check "上下文里多了一条记录(不含 system 提示词)" $? +check "上下文里多了一条记录 (不含 system 提示词)" $? echo -echo "== 37. /shell(交互式会话被录制) ==" +echo "== 37. /shell (交互式会话被录制) ==" OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --prompt "/shell" --prompt "echo 交互录制" --prompt "exit" \ --prompt "/history" --wait 1.5 2>&1)" @@ -559,10 +559,10 @@ contains "进入交互式 shell" "$OUT"; check "进入交互式 shell" $? contains "交互录制" "$OUT"; check "shell 里的命令真的跑了" $? contains "已把" "$OUT"; check "报告录制的字节数" $? contains "不自动提问" "$OUT"; check "说明只是记入上下文、不自动提问" $? -contains "历史消息 1 条" "$OUT"; check "会话记入上下文(不含 system)" $? +contains "历史消息 1 条" "$OUT"; check "会话记入上下文 (不含 system)" $? echo -echo "== 37b. /shell(全屏程序与重绘空格的记录清洗) ==" +echo "== 37b. /shell (全屏程序与重绘空格的记录清洗) ==" OUT="$(python3 -u tests/shell_screen_check.py "$BIN" "$BASE" 2>&1)" echo "$OUT" | sed 's/^/ /' contains "0 项失败" "$OUT"; check "全屏程序记录清洗" $? @@ -609,17 +609,17 @@ 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 "默认不发思考字段" $? -# 显式开启 + 指定级别,且 reasoning_content 必须随 assistant 消息回传 +# 显式开启 + 指定级别, 且 reasoning_content 必须随 assistant 消息回传 OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color \ --thinking on --effort max "THINK_TEST 开启" 2>&1)" contains "THINK[echo=yes thinking=enabled effort=max]" "$OUT"; check "thinking=enabled + effort=max 已下发且思考内容回传" $? -# 非流式路径也要能带上 reasoning_content(它走的是另一条解析分支) +# 非流式路径也要能带上 reasoning_content (它走的是另一条解析分支) OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color \ --no-stream --thinking on "THINK_TEST 非流式" 2>&1)" contains "THINK[echo=yes thinking=enabled" "$OUT"; check "非流式路径也回传思考内容" $? @@ -641,7 +641,7 @@ OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ contains "由服务端决定" "$OUT"; check "/think 无参数显示当前设置" $? contains "思考级别已设为 high" "$OUT"; check "/think LEVEL 设置级别" $? contains "思考级别:high" "$OUT"; check "级别被记住" $? -contains "关闭(发 thinking.type=disabled)" "$OUT"; check "/think off 关闭思考" $? +contains "关闭 (发 thinking.type=disabled)" "$OUT"; check "/think off 关闭思考" $? contains "无法识别" "$OUT"; check "/think 非法值给出提示" $? echo @@ -653,21 +653,21 @@ contains "等待响应" "$OUT"; check "等网络时显示 等待响应" $? contains "思考中" "$OUT"; check "收到思维链后切到 思考中" $? first="$(printf '%s' "$OUT" | grep -o '等待响应\|思考中' | head -1)" [ "$first" = "等待响应" ]; check "两个标签的先后顺序正确" $? -# 非流式时拿不到任何增量,只能一直是"等待响应" +# 非流式时拿不到任何增量, 只能一直是"等待响应" OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --extra=--no-confirm --extra=--no-auto-save --extra=--no-stream \ --prompt "SLOWRESP_TEST 非流式" --wait 3.2 2>&1)" contains "等待响应" "$OUT"; check "非流式也显示 等待响应" $? printf '%s' "$OUT" | grep -q "思考中" && r=1 || r=0 -check "非流式拿不到增量,不应出现 思考中" $r +check "非流式拿不到增量, 不应出现 思考中" $r echo echo "== 44. /meta 元编辑对话历史 ==" ARCHIVE_DIR="$(dirname "$BIN")/ligent.archive" rm -rf "$ARCHIVE_DIR" -# 全都在同一个进程里做完:谱系(g_lineage)是进程内状态, -# 换一个 ligent 进程它就不知道上一份存档叫什么,会另起一个新根。 +# 全都在同一个进程里做完:谱系 (g_lineage)是进程内状态, +# 换一个 ligent 进程它就不知道上一份存档叫什么, 会另起一个新根. OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --extra=--no-confirm --extra=--no-auto-save --extra=--no-history \ --prompt "第一轮" --prompt "第二轮" --prompt "第三轮" \ @@ -676,7 +676,7 @@ OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --prompt "/diff" --prompt "/exit" \ --prompt "/history" --prompt "/export" --prompt "/exit" --wait 1.3 2>&1)" contains "已导出 8 条消息" "$OUT" -check "建好三段对话并导出(8 条对话消息,不含 system)" $? +check "建好三段对话并导出 (8 条对话消息, 不含 system)" $? contains "已进入 meta 模式" "$OUT"; check "能进入 meta 模式" $? contains "meta>" "$OUT"; check "提示符换成 meta>" $? contains "已删除轮 2..2" "$OUT"; check "meta 模型真的改了历史" $? @@ -686,9 +686,9 @@ contains "没有改动" "$OUT" && r=1 || r=0 [ "$r" -eq 0 ]; check "退出时报告本次不是空跑" $? contains "另起一根" "$OUT"; check "提示会重新存根" $? -# 新根必须叫 <原名>_m<3位>,并且 parent 记空 +# 新根必须叫 <原名>_m<3位>, 并且 parent 记空 AROOT="$(ls "$ARCHIVE_DIR" | sed 's/\.json$//' | grep -E '^[a-z0-9]{8}$' | head -1)" -[ -n "$AROOT" ]; check "找到初始根($AROOT)" $? +[ -n "$AROOT" ]; check "找到初始根 ($AROOT)" $? ls "$ARCHIVE_DIR" | sed 's/\.json$//' | grep -qE "^${AROOT}_m[a-z0-9]{3}$" check "meta 版名字形如 <原名>_m<3位>" $? META_NAME="$(ls "$ARCHIVE_DIR" | sed 's/\.json$//' | grep -E "^${AROOT}_m[a-z0-9]{3}$" | head -1)" @@ -697,7 +697,7 @@ import json, sys d = json.load(open(sys.argv[1], encoding="utf-8")) sys.exit(0 if d.get("parent") == "" else 1) PYEOF -check "meta 版是新根(parent 为空)" $? +check "meta 版是新根 (parent 为空)" $? python3 - "$ARCHIVE_DIR/$META_NAME.json" <<'PYEOF' import json, sys d = json.load(open(sys.argv[1], encoding="utf-8")) @@ -713,41 +713,41 @@ PYEOF check "删改之后工具调用配对仍然完整" $? # meta 模式里的未知命令不该被当成给模型的指令 -# 单引号很关键:双引号里 bash 会先把 $((6*7)) 算成 42, -# 那断言就成了"回显里有没有 42",毫无意义。 -# 命令写成"输出和输入不同"的样子,因为行编辑器会把敲进去的字原样回显, -# 直接 grep 命令文本永远命中,证明不了它有没有被执行。 +# 单引号很关键:双引号里 bash 会先把 $((6*7)) 算成 42, +# 那断言就成了"回显里有没有 42", 毫无意义. +# 命令写成"输出和输入不同"的样子, 因为行编辑器会把敲进去的字原样回显, +# 直接 grep 命令文本永远命中, 证明不了它有没有被执行. OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --extra=--no-confirm --extra=--no-auto-save --extra=--no-history \ --prompt "/meta" --prompt '/shell echo $((6*7))' --prompt "/exit" --wait 1.2 2>&1)" contains "meta 模式里没有" "$OUT"; check "meta 里拒绝主 REPL 的命令" $? printf '%s' "$OUT" | grep -q "42" && r=1 || r=0 -[ "$r" -eq 0 ]; check "被拒绝的命令确实没被执行(没出现 42)" $? +[ "$r" -eq 0 ]; check "被拒绝的命令确实没被执行 (没出现 42)" $? rm -rf "$ARCHIVE_DIR" echo echo "== 45. 确认提示上的每种回答 ==" -# 重点:在确认提示上按 Ctrl-C 必须等于拒绝。 -# confirm_tool 用 fgets 读输入,而 SIGINT 会让它 EINTR 返回 NULL —— -# 以前 NULL 被当成"没人能回答,按默认值放行",于是 Ctrl-C 批准了写盘。 +# 重点:在确认提示上按 Ctrl-C 必须等于拒绝. +# confirm_tool 用 fgets 读输入, 而 SIGINT 会让它 EINTR 返回 NULL —— +# 以前 NULL 被当成"没人能回答, 按默认值放行", 于是 Ctrl-C 批准了写盘. OUT="$(python3 tests/confirm_check.py "$BIN" "$BASE" 2>&1)" echo "$OUT" | sed 's/^/ /' contains "0 项失败" "$OUT"; check "确认提示的每种回答都正确" $? echo echo "== 47. doctor 的联网检查 ==" -# 这条路径以前从来没被跑过 —— `ligent doctor` 走到网络那一步会段错误, -# 原因只是 http.c 里对 GET 探测的 NULL 请求体做了 strlen。 +# 这条路径以前从来没被跑过 —— `ligent doctor` 走到网络那一步会段错误, +# 原因只是 http.c 里对 GET 探测的 NULL 请求体做了 strlen. OUT="$("$BIN" doctor --api-key test --base-url "$BASE" 2>&1)"; RC=$? -[ "$RC" -lt 128 ]; check "doctor 联网检查不崩(退出码 $RC)" $? +[ "$RC" -lt 128 ]; check "doctor 联网检查不崩 (退出码 $RC)" $? contains "【网络】" "$OUT"; check "打印了网络一节" $? contains "连通性" "$OUT"; check "报告了连通性" $? contains "HTTP 状态" "$OUT"; check "报告了 HTTP 状态码" $? contains "结论" "$OUT"; check "给出了结论" $? -# 认证这一节必须区分三种情况:密钥对 / 密钥错 / 根本没配。 -# 以前 http_probe 压根不带密钥,于是任何要求认证的端点都回 401,而 doctor -# 一律说"密钥被拒绝" —— 把用户引到一个根本不存在的问题上。 +# 认证这一节必须区分三种情况:密钥对 / 密钥错 / 根本没配. +# 以前 http_probe 压根不带密钥, 于是任何要求认证的端点都回 401, 而 doctor +# 一律说"密钥被拒绝" —— 把用户引到一个根本不存在的问题上. AUTHED="http://127.0.0.1:$PORT/authed" OUT="$(env -u LIGENT_API_KEY "$BIN" doctor --api-key good-key --base-url "$AUTHED" 2>&1)" contains "端点接受了这把密钥" "$OUT"; check "密钥正确时如实说接受" $? @@ -755,7 +755,7 @@ OUT="$(env -u LIGENT_API_KEY "$BIN" doctor --api-key bad-key --base-url "$AUTHED contains "服务端拒绝了这把密钥" "$OUT"; check "密钥错误时如实说被拒" $? OUT="$(env -u LIGENT_API_KEY -u DEEPSEEK_API_KEY -u OPENAI_API_KEY \ "$BIN" doctor --base-url "$AUTHED" 2>&1)" -contains "端点要求认证,但当前没有配置 API key" "$OUT"; check "没配密钥时不说"被拒绝"" $? +contains "端点要求认证, 但当前没有配置 API key" "$OUT"; check "没配密钥时不说"被拒绝"" $? contains "不是网络问题" "$OUT"; check "并说明这跟网络无关" $? # 404 也不能被说成"密钥没问题":请求根本没走到认证 @@ -767,16 +767,16 @@ contains "说明不了密钥好不好" "$OUT"; check "404 不冒充密钥结论" # 端点跳转那条分支也要走到 OUT="$("$BIN" doctor --api-key test --base-url "http://127.0.0.1:$PORT/redirect" 2>&1)" RC=$? -[ "$RC" -lt 128 ]; check "重定向场景也不崩(退出码 $RC)" $? +[ "$RC" -lt 128 ]; check "重定向场景也不崩 (退出码 $RC)" $? contains "重定向" "$OUT"; check "识别出端点跳转" $? contains "example.invalid" "$OUT"; check "报出跳转目标" $? contains "不自动跟随重定向" "$OUT"; check "解释了为什么不跟随" $? echo -echo "== 48. read_image:图片以 base64 内联发出,历史里只留备份引用 ==" -# 自己造测试图:一张最小的合法 PNG,和一个"扩展名是 .png、内容不是图片"的文件, -# 以及一张故意超过内联上限的大图。不能依赖 build/ 里已有的东西 —— -# make clean 会把它整个删掉。 +echo "== 48. read_image:图片以 base64 内联发出, 历史里只留备份引用 ==" +# 自己造测试图:一张最小的合法 PNG, 和一个"扩展名是 .png、内容不是图片"的文件, +# 以及一张故意超过内联上限的大图. 不能依赖 build/ 里已有的东西 —— +# make clean 会把它整个删掉. mkdir -p build/tmp python3 - <<'PYEOF' import struct, zlib @@ -789,7 +789,7 @@ png = (b"\x89PNG\r\n\x1a\n" + chunk(b"IEND", b"")) open("build/tmp/dot.png", "wb").write(png) open("build/tmp/notanimage.png", "wb").write(b"this is not an image") -# 内联上限是编码后的体积,4/3 膨胀,所以原始超过 3/4 上限就会触发体检 +# 内联上限是编码后的体积, 4/3 膨胀, 所以原始超过 3/4 上限就会触发体检 with open("build/tmp/huge.png", "wb") as f: f.write(png) f.truncate(25 * 1024 * 1024) @@ -803,16 +803,16 @@ OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ contains "已读入图片" "$OUT"; check "read_image 读入成功" $? contains "base64" "$OUT"; check "工具结果说明了会以 base64 内联" $? -# 关键:模型在下一轮**真的看到了**内联图片(图片只能出现在 user 消息里, -# 而 read_image 是工具、结果只能是字符串,所以中间补了一条附件消息) +# 关键:模型在下一轮**真的看到了**内联图片 (图片只能出现在 user 消息里, +# 而 read_image 是工具、结果只能是字符串, 所以中间补了一条附件消息) contains "IMAGE[data:image/png:" "$OUT"; check "模型收到了内联 base64 图片" $? contains "历史消息 5 条" "$OUT" -check "多出一条附件消息(5 条工具往返 + 1 条附件,不含 system)" $? +check "多出一条附件消息 (5 条工具往返 + 1 条附件, 不含 system)" $? # 本地备份 [ -d "$ARCHIVE_DIR/images" ]; check "本机留了备份目录" $? N="$(ls "$ARCHIVE_DIR"/images/*.png 2>/dev/null | wc -l)" -[ "$N" -eq 1 ]; check "只备份了成功那一张($N 份)" $? +[ "$N" -eq 1 ]; check "只备份了成功那一张 ($N 份)" $? python3 - "$ARCHIVE_DIR" <<'PYEOF' import glob, os, sys names = [os.path.basename(p) for p in glob.glob(sys.argv[1] + "/images/*.png")] @@ -821,17 +821,17 @@ sys.exit(0 if ok else 1) PYEOF check "备份名形如 <时间戳>_<4位>." $? -# 这是这一版最要紧的不变量:**归档里不能有图片字节**。 -# 请求里发的是 base64,但存进历史 / 归档的必须是那个几十字节的引用。 +# 这是这一版最要紧的不变量:**归档里不能有图片字节**. +# 请求里发的是 base64, 但存进历史 / 归档的必须是那个几十字节的引用. python3 - "$ARCHIVE_DIR" "build/tmp/dot.png" <<'PYEOF' 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 内联后约…",mock 的回复里还会出现 -# "IMAGE[data:image/png:92]" 这种对收到内容的描述。真正该消失的是那串载荷。 +# 判据只能是**图片载荷本身**. 不能拿 "base64" 这个词, 也不能拿 "data:image": +# 工具结果里会正当地写"base64 内联后约…", mock 的回复里还会出现 +# "IMAGE[data:image/png:92]" 这种对收到内容的描述. 真正该消失的是那串载荷. payload = base64.b64encode(open(png_path, "rb").read()).decode() if payload in raw: print("归档里出现了图片 base64 载荷") @@ -862,10 +862,10 @@ check "归档里只有对备份的引用、没有图片字节" $? rm -rf "$ARCHIVE_DIR" echo -echo "== 49. read_image:服务端拒图时把图片摘掉重试,而不是每轮都失败 ==" +echo "== 49. read_image:服务端拒图时把图片摘掉重试, 而不是每轮都失败 ==" rm -rf "$ARCHIVE_DIR" -# 三轮必须在**同一个** repl 进程里。mock 的 told_once 是一次性的,重开一个 -# 进程它就不再拒了;而且"降级要就地生效"这件事本身也只能在同一会话里验。 +# 三轮必须在**同一个** repl 进程里. mock 的 told_once 是一次性的, 重开一个 +# 进程它就不再拒了; 而且"降级要就地生效"这件事本身也只能在同一会话里验. OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --extra=--no-confirm --extra=--no-auto-save --extra=--no-history \ --prompt "IMAGE_TEST 先看一张图" \ @@ -876,39 +876,39 @@ contains "服务端拒绝了请求里的图片" "$OUT"; check "识别出是图 contains "从对话里移除后重试" "$OUT"; check "说明了是移除后重试" $? # 重试那一轮请求里就不该再有图片了 contains "IMAGE[none]" "$OUT"; check "重试时请求里确实没有图片了" $? -# 而且模型**真的收到了**那句话(mock 把 user 文本回显出来了) +# 而且模型**真的收到了**那句话 (mock 把 user 文本回显出来了) contains "不符合 API 所需的格式标准" "$OUT"; check "模型收到了"请规范化图片"的说明" $? contains "请规范化图片" "$OUT"; check "说明里给了下一步动作" $? -# 降级是**就地**的:同一会话的下一轮也不该再有图片, -# 否则每轮都会重新踩同一个 4xx,用户只能 /reset。 +# 降级是**就地**的:同一会话的下一轮也不该再有图片, +# 否则每轮都会重新踩同一个 4xx, 用户只能 /reset. LAST_IMG="$(printf '%s\n' "$OUT" | grep -o 'IMAGE\[[^]]*\]' | tail -1)" -[ "$LAST_IMG" = "IMAGE[none]" ]; check "下一轮也是干净的($LAST_IMG)" $? +[ "$LAST_IMG" = "IMAGE[none]" ]; check "下一轮也是干净的 ($LAST_IMG)" $? -# 备份不该因为被拒而多出来(被拒的是服务端,图片本身是好的,仍只有 1 份) +# 备份不该因为被拒而多出来 (被拒的是服务端, 图片本身是好的, 仍只有 1 份) N="$(ls "$ARCHIVE_DIR"/images/*.png 2>/dev/null | wc -l)" -[ "$N" -eq 1 ]; check "备份没有被重复写($N 份)" $? +[ "$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 也没用)" $? +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)" contains "图片太大" "$OUT"; check "过大的图在本地就被拒掉" $? contains "内联上限" "$OUT"; check "说明了上限是多少" $? N="$(ls "$ARCHIVE_DIR"/images/*.png 2>/dev/null | wc -l)" -[ "$N" -eq 0 ]; check "被拒的图不备份($N 份)" $? +[ "$N" -eq 0 ]; check "被拒的图不备份 ($N 份)" $? rm -rf "$ARCHIVE_DIR" echo "== 51. 能力探测序列:按模型能力决定暴露哪些工具 ==" -# 这一段自己把 LIGENT_PROBE 打开(脚本全局是关的,理由见文件开头)。 +# 这一段自己把 LIGENT_PROBE 打开 (脚本全局是关的, 理由见文件开头). PROBE="LIGENT_PROBE=1" run_probe() { # run_probe <模型名> <额外参数...> local model="$1"; shift @@ -917,20 +917,20 @@ 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" $? printf '%s\n' "$OUT" | grep -q 'TOOLS\[[^]]*run_command' check "其他工具不受影响" $? -contains "模型能力:多模态(图片输入)支持" "$OUT"; check "探测后打一条结论日志" $? +contains "模型能力:多模态 (图片输入)支持" "$OUT"; check "探测后打一条结论日志" $? contains "工具调用 支持" "$OUT"; check "日志里也说明工具调用能力" $? # --- 模型明确不收图片:read_image 必须从工具表里消失 --- OUT="$(run_probe no-vision-model --prompt "TOOLS_ECHO_TEST 看看工具表" \ --prompt "/tools" --prompt "/exit" --wait 2.0)" -contains "多模态(图片输入)不支持(已隐藏 read_image)" "$OUT" +contains "多模态 (图片输入)不支持 (已隐藏 read_image)" "$OUT" check "如实说明了图片能力与因此做的动作" $? contains "TOOLS[" "$OUT"; check "隐藏之后请求仍能正常发出" $? if printf '%s\n' "$OUT" | grep -q 'TOOLS\[[^]]*read_image'; then @@ -939,39 +939,39 @@ else check "read_image 确实没发给服务端" 0 fi printf '%s\n' "$OUT" | grep -q 'TOOLS\[[^]]*run_command' -check "只摘掉 read_image,别的工具留着" $? +check "只摘掉 read_image, 别的工具留着" $? contains "read_image 已隐藏" "$OUT"; check "/tools 不列已经藏掉的工具" $? # --- 模型完全不收 tools:整张表都不发 --- OUT="$(run_probe no-tools-model --prompt "TOOLS_ECHO_TEST 看看工具表" --prompt "/exit" --wait 2.0)" -contains "工具调用 不支持(不再发送工具表)" "$OUT" +contains "工具调用 不支持 (不再发送工具表)" "$OUT" check "如实说明了工具调用能力与动作" $? contains "TOOLS[]" "$OUT"; check "工具表确实是空的" $? -# --- 探测结论不确定:一律按"支持"处理,宁可多暴露 --- -# broken-probe 模型只在**探测请求**上回 401,真实请求照常。 +# --- 探测结论不确定:一律按"支持"处理, 宁可多暴露 --- +# broken-probe 模型只在**探测请求**上回 401, 真实请求照常. OUT="$(run_probe broken-probe-model --prompt "TOOLS_ECHO_TEST 看看工具表" --prompt "/exit" --wait 2.0)" -contains "多模态(图片输入)未测" "$OUT"; check "探不出来时如实说明是未测" $? +contains "多模态 (图片输入)未测" "$OUT"; check "探不出来时如实说明是未测" $? contains "没测出来的一律按「支持」处理" "$OUT"; check "说明按支持处理" $? printf '%s\n' "$OUT" | grep -q 'TOOLS\[[^]]*read_image' -check "结论不确定时不藏工具(宁可多暴露)" $? +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 - check "--no-probe 时既不该发探测请求,也不该有结论日志" 1 + check "--no-probe 时既不该发探测请求, 也不该有结论日志" 1 else - check "--no-probe 时既不该发探测请求,也不该有结论日志" 0 + check "--no-probe 时既不该发探测请求, 也不该有结论日志" 0 fi printf '%s\n' "$OUT" | grep -q 'TOOLS\[[^]]*read_image' check "--no-probe 时工具表保持完整" $? -echo "== 52. /model 不带参数:打印在线列表,输入行里选序号或直接写名字 ==" -# 这一段刻意不用光标菜单,而是 /import 那种"列表 + 普通输入行": -# 既能填序号,也能直接写一个**列表里没有**的模型名(内部模型就走这条)。 -run_model() { # run_model <要依次输入的行...>;第一行固定是 /model +echo "== 52. /model 不带参数:打印在线列表, 输入行里选序号或直接写名字 ==" +# 这一段刻意不用光标菜单, 而是 /import 那种"列表 + 普通输入行": +# 既能填序号, 也能直接写一个**列表里没有**的模型名 (内部模型就走这条). +run_model() { # run_model <要依次输入的行...>; 第一行固定是 /model local a=() for line in "$@"; do a+=(--prompt "$line"); done python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ @@ -981,20 +981,20 @@ run_model() { # run_model <要依次输入的行...>;第一行固定是 /model # --- 列表本身:编号、当前模型被标出、去重与坏条目被跳过 --- OUT="$(run_model "2")" -contains "正在获取模型列表($BASE/models)" "$OUT"; check "用的是标准的 /models 路径" $? +contains "正在获取模型列表 ($BASE/models)" "$OUT"; check "用的是标准的 /models 路径" $? contains "服务端列出了 3 个模型" "$OUT" -check "去重 + 跳过坏条目后剩 3 个(mock 塞了重复项和一个缺 id 的)" $? +check "去重 + 跳过坏条目后剩 3 个 (mock 塞了重复项和一个缺 id 的)" $? contains "1) deepseek-flash <- 当前" "$OUT"; check "当前模型被标出来" $? contains "2) alpha-model" "$OUT"; check "列表带编号" $? -contains "选择序号,或直接输入模型名" "$OUT"; check "输入行的提示说清了两种输入" $? +contains "选择序号, 或直接输入模型名" "$OUT"; check "输入行的提示说清了两种输入" $? # --- 按序号选 --- contains "模型已切换为 alpha-model" "$OUT"; check "填序号能选中对应模型" $? -# --- 直接写一个**列表里没有**的名字:这是内部模型的入口,必须允许 --- +# --- 直接写一个**列表里没有**的名字:这是内部模型的入口, 必须允许 --- OUT="$(run_model "internal-model-v9")" contains "模型已切换为 internal-model-v9" "$OUT" -check "能直接写列表里没有的模型名(内部模型走这条)" $? +check "能直接写列表里没有的模型名 (内部模型走这条)" $? if printf '%s\n' "$OUT" | grep -q "找不到"; then check "不该因为不在列表里就报错" 1 else @@ -1010,7 +1010,7 @@ else check "取消之后什么都没改" 0 fi -# --- 序号越界:报错并取消,不能当成模型名去用 --- +# --- 序号越界:报错并取消, 不能当成模型名去用 --- OUT="$(run_model "9")" contains "序号 9 超出范围" "$OUT"; check "序号越界要报出来" $? if printf '%s\n' "$OUT" | grep -q "模型已切换"; then @@ -1019,7 +1019,7 @@ else check "越界之后没有切换" 0 fi -# --- Ctrl-C 取消(这一条只能用原始按键驱动)--- +# --- Ctrl-C 取消 (这一条只能用原始按键驱动)--- OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --extra=--no-confirm --extra=--no-auto-save --extra=--no-history \ --keys "/model" --keys "\r" --keys "\x03" --keys "/exit" --keys "\r" --wait 1.4 2>&1)" @@ -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,14 +1044,14 @@ 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 contains "拿不到模型列表" "$OUT"; check "响应不是 JSON 时不崩" $? contains "/model <名字>" "$OUT"; check "同样提示可以直接指定" $? -# --- 裸数组形状(有些兼容实现这么回)也要能用 --- +# --- 裸数组形状 (有些兼容实现这么回)也要能用 --- printf 'bare' > build/tmp/models_mode OUT="$(run_model "2")" rm -f build/tmp/models_mode @@ -1059,16 +1059,16 @@ contains "服务端列出了 2 个模型" "$OUT"; check "裸数组形状也能 contains "模型已切换为 beta-model" "$OUT"; check "裸数组里也能按序号选" $? echo "== 53. 一条 assistant 里两个工具调用 + 图片:附件不能插在 tool 消息中间 ==" -# 回归用例。曾经的写法是"每推完一条 tool 消息就把图片附件推出去",于是 -# assistant 带两个调用、第一个是 read_image 时,历史变成 +# 回归用例. 曾经的写法是"每推完一条 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。 +# 而且这段历史留在上下文里, 之后每一轮都会 400 —— 用户只能 /reset. # -# mock 现在和真服务端一样校验这个结构(见 pairing_error),所以只要回归, -# 这一轮会直接 400,断言立刻失败。 +# mock 现在和真服务端一样校验这个结构 (见 pairing_error), 所以只要回归, +# 这一轮会直接 400, 断言立刻失败. 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 \ @@ -1076,34 +1076,34 @@ OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --prompt "/exit" --wait 2.0 2>&1)" contains "pair-ok" "$OUT"; check "两个工具调用都被执行" $? contains "PAIR[img=1 tool=2]" "$OUT" -check "模型既收到了图片,也收到了两条 tool 结果(结构合法)" $? +check "模型既收到了图片, 也收到了两条 tool 结果 (结构合法)" $? if printf '%s\n' "$OUT" | grep -q "must be followed by tool messages"; then check "不该出现工具消息配对错误" 1 else check "结构错误没有发生" 0 fi -# 没有触发自动修复 = 是**预防**住了,而不是事后补的 +# 没有触发自动修复 = 是**预防**住了, 而不是事后补的 if printf '%s\n' "$OUT" | grep -q "对话结构有问题"; then check "应当是预防而不是事后修复" 1 else - check "结构从一开始就是对的(没走修复路径)" 0 + check "结构从一开始就是对的 (没走修复路径)" 0 fi rm -rf "$ARCHIVE_DIR" -# 顺带验一下修复路径本身:mock 校验会让坏历史当场 400,所以这里换个角度 —— -# 直接看 agent_repair_tool_pairing 的单元测试(test_agent)。 +# 顺带验一下修复路径本身:mock 校验会让坏历史当场 400, 所以这里换个角度 —— +# 直接看 agent_repair_tool_pairing 的单元测试 (test_agent). echo "== 54. /back 报的条数不含 system 提示词 ==" -# 用户报过一个:聊一句「hi」再 /back,屏幕显示"历史剩 1 条",但那段对话其实 -# 已经空了 —— 那 1 条是 system 提示词。system 是配置不是对话,不该算进"历史"。 -# 现在 /history、/back、/unback、/export、/import 统一用"不含 system"的口径。 +# 用户报过一个:聊一句「hi」再 /back, 屏幕显示"历史剩 1 条", 但那段对话其实 +# 已经空了 —— 那 1 条是 system 提示词. system 是配置不是对话, 不该算进"历史". +# 现在 /history、/back、/unback、/export、/import 统一用"不含 system"的口径. OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --extra=--no-confirm --extra=--no-auto-save --extra=--no-history \ --prompt "hi" --prompt "/back" --prompt "/history" \ --prompt "/unback" --prompt "/history" --prompt "/exit" --wait 1.3 2>&1)" -contains "历史剩 0 条" "$OUT"; check "/back 之后历史剩 0 条(不是 1)" $? +contains "历史剩 0 条" "$OUT"; check "/back 之后历史剩 0 条 (不是 1)" $? contains "历史消息 0 条" "$OUT" -check "/history 也报 0 条(与 /back 口径一致)" $? +check "/history 也报 0 条 (与 /back 口径一致)" $? contains "历史回到 4 条" "$OUT"; check "/unback 之后回到 4 条" $? contains "不含 system" "$OUT"; check "说清楚这个口径不含 system 提示词" $? @@ -1125,7 +1125,7 @@ png = (b"\x89PNG\r\n\x1a\n" open("build/tmp/inc/pic.png", "wb").write(png) PYEOF rm -rf "$ARCHIVE_DIR" -inc() { # inc ;先切到场景目录(/cd 是 REPL 命令,没有同名命令行选项) +inc() { # inc ; 先切到场景目录 (/cd 是 REPL 命令, 没有同名命令行选项) python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --extra=--no-confirm --extra=--no-auto-save --extra=--no-history \ --prompt "/cd build/tmp/inc" "$@" --wait 1.5 2>&1 @@ -1135,7 +1135,7 @@ inc() { # inc ;先切到场景目录(/cd 是 REPL 命令,没有 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" -check "模型收到了用户的话 + 一个文件(2 个文本块)" $? +check "模型收到了用户的话 + 一个文件 (2 个文本块)" $? contains "下一条消息会带上它们" "$OUT"; check "说明了附带会在下一条消息生效" $? # --- 多个 glob / 多次附带会累加 --- @@ -1147,17 +1147,17 @@ contains "INC[text=3 img=0" "$OUT"; check "两次附带的文件都发出去了" 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 到别处 -# 带图就会写到那儿,之后从别处发消息会因为找不到备份把图降级掉。 -# 测试一直从二进制所在目录跑,"." 恰好等于正确值,所以一直没暴露。 +# 备份必须落在**二进制所在目录**, 不是当前工作目录 —— 这一条是有来历的: +# image_set_bindir() 曾经从来没被调用过, 于是备份跟着 cwd 走, /cd 到别处 +# 带图就会写到那儿, 之后从别处发消息会因为找不到备份把图降级掉. +# 测试一直从二进制所在目录跑, "." 恰好等于正确值, 所以一直没暴露. ls "$ARCHIVE_DIR"/images/*.png >/dev/null 2>&1 -check "图片备份落在二进制目录下(不是 cwd)" $? +check "图片备份落在二进制目录下 (不是 cwd)" $? BADCWD="$(dirname "$(dirname "$BIN")")/build/tmp/inc/ligent.archive" [ ! -d "$BADCWD" ]; check "当前工作目录下不该有 ligent.archive" $? N="$(ls "$ARCHIVE_DIR"/images/*.png 2>/dev/null | wc -l)" -[ "$N" -eq 1 ]; check "图片落了一份本机备份($N 份)" $? -# 归档里不能有图片字节(和 read_image 同一条不变量) +[ "$N" -eq 1 ]; check "图片落了一份本机备份 ($N 份)" $? +# 归档里不能有图片字节 (和 read_image 同一条不变量) python3 - "$ARCHIVE_DIR" "build/tmp/inc/pic.png" <<'PYEOF' import base64, glob, json, sys raw = open(glob.glob(sys.argv[1] + "/*.json")[0], encoding="utf-8").read() @@ -1168,7 +1168,7 @@ PYEOF check "归档里只有引用、没有图片字节" $? rm -rf "$ARCHIVE_DIR" -# --- 内容不是图片的 .png 按文本附带(认内容不认扩展名) --- +# --- 内容不是图片的 .png 按文本附带 (认内容不认扩展名) --- OUT="$(inc --prompt "/include fake.png" --prompt "INCLUDE_TEST 假的" --prompt "/exit")" contains "INC[text=2 img=0 files=fake.png]" "$OUT" check "按内容判断:不是图片就当文本带" $? @@ -1179,17 +1179,17 @@ OUT="$(inc --prompt "/include bin.dat" --prompt "/exit")" contains "跳过" "$OUT"; check "二进制文件被跳过" $? contains "二进制" "$OUT"; check "说清跳过原因" $? -# --- 没有匹配时明确报错,而不是静默 --- +# --- 没有匹配时明确报错, 而不是静默 --- OUT="$(inc --prompt "/include 不存在的模式*.zzz" --prompt "/exit")" contains "没有匹配到文件" "$OUT"; check "匹配不到要说出来" $? # --- 交互式:进目录、退回来、选文件 --- -# 序号 3 是 sub/(a.c / b.txt / sub 排序后),进去再回车结束; -# 然后回头选一个文件。 +# 序号 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 "选中后有回执" $? @@ -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 \ @@ -1208,9 +1208,9 @@ OUT="$(env LIGENT_PROBE=1 python3 tests/repl_drive.py --bin "$BIN" --base-url "$ --prompt "INCLUDE_TEST 混合" --prompt "/exit" --wait 1.8 2>&1)" contains "当前模型不支持图片输入" "$OUT"; check "如实说明为什么跳过图片" $? contains "INC[text=2 img=0 files=a.c]" "$OUT" -check "文本照常带上,图片没发出去" $? +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=probe-model \ @@ -1221,8 +1221,8 @@ contains "INC[text=1 img=1" "$OUT"; check "支持视觉时图片正常附带" $? rm -rf "$ARCHIVE_DIR" echo "== 56. run_command elevated:经 sudo 提权执行单条命令 ==" -# 全程用**替身 sudo**:不需要真权限,也永远不会碰到谁的真实密码。 -# 它把 argv 记下来,于是"ligent 到底怎么调 sudo 的"能断言到参数级。 +# 全程用**替身 sudo**:不需要真权限, 也永远不会碰到谁的真实密码. +# 它把 argv 记下来, 于是"ligent 到底怎么调 sudo 的"能断言到参数级. FAKEBIN="$ROOT/build/tmp/fakebin" mkdir -p "$FAKEBIN" cat > "$FAKEBIN/sudo" <<'SUDOEOF' @@ -1242,16 +1242,16 @@ 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)" -contains "提权后的输出" "$OUT"; check "提权命令真的执行了,输出也被捕获" $? +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 就意味着"不许提问", -# 于是需要密码时直接失败 —— 而用户明确要的是"提权要有身份验证"。 +check "调用形式是 sudo -u root -- (argv 形式, 不经多余 shell)" $? +# 这一条是这次设计的核心:**不能带 -n**. 带 -n 就意味着"不许提问", +# 于是需要密码时直接失败 —— 而用户明确要的是"提权要有身份验证". printf '%s\n' "$(cat "$SULOG")" | grep -q -- '-n' && r=1 || r=0 [ "$r" -eq 0 ]; check "不带 -n:需要密码时要让 sudo 能问出来" $? @@ -1262,34 +1262,34 @@ 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 \ --prompt "ELEVATED_TEST 提权跑个命令" --prompt "/exit" --wait 1.8 2>&1)" contains "提权命令失败" "$OUT"; check "提权失败要告诉模型" $? -contains "认证没有通过" "$OUT"; check "说清是认证没过(而不是命令本身的问题)" $? +contains "认证没有通过" "$OUT"; check "说清是认证没过 (而不是命令本身的问题)" $? contains "不要反复重试" "$OUT"; check "明确叫它别反复重试" $? -# --- 没有控制终端时:直接拒绝,绝不发起一个等不到输入的进程 --- -# setsid 摘掉控制终端,正是"没人能输密码"的情形。 +# --- 没有控制终端时:直接拒绝, 绝不发起一个等不到输入的进程 --- +# setsid 摘掉控制终端, 正是"没人能输密码"的情形. if command -v setsid >/dev/null 2>&1; then OUT="$(setsid "$BIN" --api-key test --base-url "$BASE" --no-color \ --no-confirm --no-auto-save --no-history "ELEVATED_TEST 提权" 2>&1)" RC=$? contains "没有可用的控制终端" "$OUT" - check "没有终端时拒绝提权,并说清原因" $? - [ "$RC" -lt 128 ]; check "没有终端时不崩(退出码 $RC)" $? - # 而且它必须是**立刻**失败,不能卡在那儿等密码 + check "没有终端时拒绝提权, 并说清原因" $? + [ "$RC" -lt 128 ]; check "没有终端时不崩 (退出码 $RC)" $? + # 而且它必须是**立刻**失败, 不能卡在那儿等密码 contains "没有可用的控制终端" "$OUT" || true else - echo " (跳过:没有 setsid)" + echo " (跳过:没有 setsid)" fi echo "== 57. 离线回放:把 /log 录下来的真实会话当夹具 ==" -# 这是 /log 存在的**真正理由**:录一次真的,之后离线反复回放。 -# 录的是原始字节(请求体 + 未解析的响应原文),所以回放时 http.c 的分块读取、 -# llm.c 的 SSE 行切分与 tool_calls 增量重组,全都在真实字节上跑。 +# 这是 /log 存在的**真正理由**:录一次真的, 之后离线反复回放. +# 录的是原始字节 (请求体 + 未解析的响应原文), 所以回放时 http.c 的分块读取、 +# llm.c 的 SSE 行切分与 tool_calls 增量重组, 全都在真实字节上跑. FIX="$ROOT/tests/fixtures/session.log" python3 tests/replay.py "$FIX" --check > build/tmp/replay_check.txt 2>&1 @@ -1317,7 +1317,7 @@ 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)" RC=$? kill $RPID 2>/dev/null; wait $RPID 2>/dev/null -[ "$RC" -eq 0 ]; check "在录制上跑完一轮(退出码 $RC)" $? +[ "$RC" -eq 0 ]; check "在录制上跑完一轮 (退出码 $RC)" $? contains "hello-from-tool" "$OUT"; check "工具调用从被切碎的 SSE 里正确重组" $? contains "FINAL:" "$OUT"; check "拿到了录制里的最终回答" $? # strict:请求的**结构**必须和录制时一致 —— 这才是"报文形状没变"的断言 @@ -1325,7 +1325,7 @@ if grep -q "结构" "$RLOG"; then check "请求结构与录制一致" 1 grep -A2 "结构" "$RLOG" | head -4 else - check "请求结构与录制一致(strict 没报差异)" 0 + check "请求结构与录制一致 (strict 没报差异)" 0 fi # --- 错误体:录下来的 400 也要能被原样解析出可读信息 --- @@ -1335,12 +1335,12 @@ OUT="$("$BIN" --api-key test --base-url "http://127.0.0.1:8898/v1" --no-color \ "ERROR_TEST 制造一个服务端错误" 2>&1)" RC=$? kill $RPID 2>/dev/null; wait $RPID 2>/dev/null -[ "$RC" -ne 0 ]; check "录下来的 400 仍然被判为失败(退出码 $RC)" $? +[ "$RC" -ne 0 ]; check "录下来的 400 仍然被判为失败 (退出码 $RC)" $? 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 \ @@ -1348,15 +1348,15 @@ for _ in 1 2; do done kill $RPID 2>/dev/null; wait $RPID 2>/dev/null contains "没有更多响应" "$(cat "$RLOG")" -check "录制用完后明确报出来(而不是静静回错东西)" $? +check "录制用完后明确报出来 (而不是静静回错东西)" $? echo "== 58. 工具输出里混进非法 UTF-8:不能让之后每个请求都被拒 ==" # 坏字节的来源很日常:某条命令在非 UTF-8 locale 下打出来的路径、被截断的多 -# 字节字符、二进制文件里混的一小段。它会跟着工具结果进对话历史,再被序列化 -# 进请求体 —— 序列化器若原样吐出,DeepSeek 回 +# 字节字符、二进制文件里混的一小段. 它会跟着工具结果进对话历史, 再被序列化 +# 进请求体 —— 序列化器若原样吐出, DeepSeek 回 # 400 ... invalid unicode code point -# 而且因为坏数据已经在历史里了,**之后每一次请求都会失败**,除非丢掉整轮对话。 -# mock server 现在也按这个严格校验,所以这条路径在 e2e 里真的会被走到。 +# 而且因为坏数据已经在历史里了, **之后每一次请求都会失败**, 除非丢掉整轮对话. +# mock server 现在也按这个严格校验, 所以这条路径在 e2e 里真的会被走到. printf 'ok \xe4\xb8\xad then bad: \xff\xfe and \x80 end\n' > build/tmp/bad_utf8.txt OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color \ --no-probe --retries 0 "UTF8_TEST" 2>&1)" @@ -1366,12 +1366,12 @@ 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_* ==" -# 前面几十段都跑在顶层锁死的 zh 下(见文件开头的 export)。这一段专门走英文, -# 因为"翻译漏了"和"翻译错了"只有真的把每条路径跑一遍才看得出来。 +# 前面几十段都跑在顶层锁死的 zh 下 (见文件开头的 export). 这一段专门走英文, +# 因为"翻译漏了"和"翻译错了"只有真的把每条路径跑一遍才看得出来. OUT="$(LIGENT_LANG=en "$BIN" --help 2>&1)" contains "Usage:" "$OUT"; check "--help 出英文" $? contains "--lang LANG" "$OUT"; check "帮助里列出 --lang" $? @@ -1386,25 +1386,25 @@ contains "未知选项" "$OUT"; check "--lang zh 覆盖环境变量" $? # 语言也要能在配置里看见 —— 否则"为什么它是英文"要猜 OUT="$(LIGENT_LANG=en "$BIN" --lang=en --print-config 2>&1)" -contains "language : en" "$OUT"; check "--print-config 标出当前语言(en)" $? +contains "language : en" "$OUT"; check "--print-config 标出当前语言 (en)" $? OUT="$(LIGENT_LANG=zh "$BIN" --lang=zh --print-config 2>&1)" -contains "language : zh" "$OUT"; check "--print-config 标出当前语言(zh)" $? +contains "language : zh" "$OUT"; check "--print-config 标出当前语言 (zh)" $? -# auto = 按环境;LIGENT_LANG 必须先清掉,否则它会把 LC_*/LANG 全压住 +# auto = 按环境; LIGENT_LANG 必须先清掉, 否则它会把 LC_*/LANG 全压住 OUT="$(env -u LIGENT_LANG LC_ALL=C LANG=C "$BIN" --lang auto --print-config 2>&1)" contains "language : en" "$OUT"; check "LANG=C 下 auto 落到英文" $? OUT="$(env -u LIGENT_LANG -u LC_ALL LANG=zh_CN.UTF-8 "$BIN" --lang auto --print-config 2>&1)" contains "language : zh" "$OUT"; check "LANG=zh_CN 下 auto 落到中文" $? OUT="$(env -u LIGENT_LANG -u LC_ALL LANG=fr_FR.UTF-8 "$BIN" --lang auto --print-config 2>&1)" -contains "language : en" "$OUT"; check "没翻译的语言(fr)回退英文" $? +contains "language : en" "$OUT"; check "没翻译的语言 (fr)回退英文" $? OUT="$("$BIN" --lang 乱写 "x" 2>&1)"; r=$? [ "$r" -eq 2 ]; check "非法 --lang 被拒绝" $? contains "只接受 auto / en / zh" "$OUT"; check "拒绝时说清可选值" $? -# doctor 是最长的一段界面文案,分节括号也要跟着语言走 +# 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)" @@ -1412,18 +1412,18 @@ contains "【二进制与配置位置】" "$OUT"; check "中文分节用【】" contains "结论:" "$OUT"; check "中文结论行" $? echo -echo "== 60. 单轮往返次数:默认不限,正数才是上限 ==" -# 默认(配置文件里没写 max_steps)要能跑完 4 次工具往返 —— 这正是"默认不限" -# 的可观测形式:任何小上限都会在这里当场暴露。 +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 "run_command" "$OUT"; check "每一步的工具调用都真实执行了" $? -# 显式写 0 也是不限(0 = 不限是这条接口的约定) +# 显式写 0 也是不限 (0 = 不限是这条接口的约定) OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color --max-steps 0 "MANYSTEPS_TEST" 2>&1)" contains "STEPS_DONE[4]" "$OUT"; check "--max-steps 0 显式表示不限" $? -# 设了正数上限时,到顶要停住并如实报出这个数 +# 设了正数上限时, 到顶要停住并如实报出这个数 OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color --max-steps 2 "MANYSTEPS_TEST" 2>&1)" contains "已达到最大步数 2" "$OUT"; check "--max-steps 2 时如实报出达到上限" $? if contains "STEPS_DONE" "$OUT"; then @@ -1437,15 +1437,15 @@ printf '{"max_steps": 2}\n' > "$ROOT/build/tmp/e2e-maxsteps.json" OUT="$("$BIN" -c "$ROOT/build/tmp/e2e-maxsteps.json" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "MANYSTEPS_TEST" 2>&1)" contains "已达到最大步数 2" "$OUT"; check "配置文件里的 max_steps 生效" $? -# --print-config 把"不限"说清楚,而不是打一个 0 让人猜 +# --print-config 把"不限"说清楚, 而不是打一个 0 让人猜 OUT="$("$BIN" --print-config 2>&1)" contains "max_steps : 不限" "$OUT"; check "--print-config 显示不限" $? echo -echo "== 61. edit_file 的调用行:\"路径\" [行号],成功后给带行号的新旧对照 ==" -# 以前这一行是把整个参数 JSON 原样打出来(截 256 字节)—— 对 edit_file 来说那是 -# 一整段 old_string/new_string:既刷屏,又看不出到底要改哪里。现在摘要只有 -# "哪个文件、哪几行"(路径带引号、行号用方括号),改动内容用带行号的 -/+ 两段摆开。 +echo "== 61. edit_file 的调用行:\"路径\" [行号], 成功后给带行号的新旧对照 ==" +# 以前这一行是把整个参数 JSON 原样打出来 (截 256 字节)—— 对 edit_file 来说那是 +# 一整段 old_string/new_string:既刷屏, 又看不出到底要改哪里. 现在摘要只有 +# "哪个文件、哪几行" (路径带引号、行号用方括号), 改动内容用带行号的 -/+ 两段摆开. mkprobe() { python3 -c " with open('$ROOT/build/tmp/edit_probe.txt', 'w') as f: @@ -1456,12 +1456,12 @@ with open('$ROOT/build/tmp/edit_probe.txt', 'w') as f: mkprobe OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "EDITTEST_OK" 2>&1)" contains '{/} edit_file "build/tmp/edit_probe.txt" [2]' "$OUT" -check "调用行是 \"路径\" [行号],不再抖 JSON" $? -contains " │ - 2 OLD1" "$OUT"; check "旧内容逐行显示,且带文件行号" $? -contains " │ - 9 OLD8" "$OUT"; check "行号随行递增(2..9)" $? +check "调用行是 \"路径\" [行号], 不再抖 JSON" $? +contains " │ - 2 OLD1" "$OUT"; check "旧内容逐行显示, 且带文件行号" $? +contains " │ - 9 OLD8" "$OUT"; check "行号随行递增 (2..9)" $? contains " │ + 2 NEW" "$OUT" -check "新内容也带行号(两侧起始行号相同:替换就落在原处)" $? -contains "还有 4 行" "$OUT"; check "每侧最多 8 行,其余折成一句" $? +check "新内容也带行号 (两侧起始行号相同:替换就落在原处)" $? +contains "还有 4 行" "$OUT"; check "每侧最多 8 行, 其余折成一句" $? contains " │ - 10 OLD9" "$OUT" && r=1 || r=0 check "第 9 行起不再逐行铺开" $r contains '"old_string"' "$OUT" && r=1 || r=0 @@ -1469,10 +1469,10 @@ check "参数 JSON 一个字节都不上屏" $r contains "DONE[" "$OUT"; check "工具确实执行了" $? echo -echo "== 62. 确认提示:先摆带行号的新旧对照,再问 Y/n ==" -# 这一版把确认提示里的"替换 <旧> -> <新>"删掉了 —— 两边各截 60 字符、压成一行, -# 看不出动了几行、动的是哪几行。现在摆的是和事后展示同一份带行号 diff, -# 而且必须在提问**之前**:先让人点头、再让人看改什么,那是反的。 +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 \ @@ -1493,9 +1493,9 @@ PY check "顺序:先摆 diff、再提问" $? contains "替换:" "$OUT" && r=1 || r=0 check "不再出现「替换: 旧 -> 新」那一行" $r -# 确认过的改动不该在执行后再摆一遍 diff(否则像是要改两次) +# 确认过的改动不该在执行后再摆一遍 diff (否则像是要改两次) contains "已在 build/tmp/edit_probe.txt 第 2 行替换 1 处" "$OUT" -check "确认之后只给一句回执,不重复 diff" $? +check "确认之后只给一句回执, 不重复 diff" $? echo echo "== 63. edit_file 失败时:同样只有摘要 + 错误 ==" @@ -1508,18 +1508,18 @@ contains '"old_string"' "$OUT" && r=1 || r=0 check "失败时同样不抖 JSON" $r echo -echo "== 64. /probe:手动测一次能力(绕开 --no-probe 与缓存)==" -# 这一段的 e2e 全局设了 LIGENT_PROBE=0(各处用例都按"请求序列"写,别被探测整体推后), -# 正好拿来验证 /probe 的两条语义:无视那个开关,也不吃缓存(敲两次就测两次)。 +echo "== 64. /probe:手动测一次能力 (绕开 --no-probe 与缓存)==" +# 这一段的 e2e 全局设了 LIGENT_PROBE=0 (各处用例都按"请求序列"写, 别被探测整体推后), +# 正好拿来验证 /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 "模型能力:") -[ "${n:-0}" -ge 2 ]; check "每次 /probe 都重测一遍(不吃缓存),实际 $n 次" $? +[ "${n:-0}" -ge 2 ]; check "每次 /probe 都重测一遍 (不吃缓存), 实际 $n 次" $? contains "支持" "$OUT"; check "结论照启动时的样子打出来" $? -# 帮助里要能看到它,否则没人知道有这个命令 +# 帮助里要能看到它, 否则没人知道有这个命令 OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" --prompt "/help" --wait 1.0 --prompt "/exit" 2>&1)" contains "/probe" "$OUT"; check "/help 里列了 /probe" $? OUT="$("$BIN" --help 2>&1)" @@ -1527,18 +1527,18 @@ contains "/probe" "$OUT"; check "--help 的 REPL 命令列表里也有" $? echo echo "== 65. /history:一行摘要 + 完整对话 ==" -# 摘要行报的是"不含 system"的口径(与 /usage、/back、/export 一致),下面列的 -# 是含 system 的全部消息 —— 两个数不同,所以第二行得自己说清楚,否则用户会 -# 以为打印漏了或者多了一条。 +# 摘要行报的是"不含 system"的口径 (与 /usage、/back、/export 一致), 下面列的 +# 是含 system 的全部消息 —— 两个数不同, 所以第二行得自己说清楚, 否则用户会 +# 以为打印漏了或者多了一条. OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ --prompt "跑一下" --prompt "/history" --wait 1.4 2>&1)" contains "历史消息 4 条" "$OUT"; check "摘要行给的是不含 system 的条数" $? contains "完整打印全部 5 条消息" "$OUT"; check "并说明下面列的是含 system 的全部" $? contains "-- 1/5 系统 --" "$OUT"; check "system 提示词全文也在里面" $? -contains "-- 2/5" "$OUT"; check "逐条带 i/N 表头,一条不落" $? +contains "-- 2/5" "$OUT"; check "逐条带 i/N 表头, 一条不落" $? contains "run_command" "$OUT"; check "工具调用与结果一并列出" $? contains "tool_call_id=" "$OUT"; check "标出工具结果回应的是哪次调用" $? echo -echo "通过 $PASS 项,失败 $FAIL 项" +echo "通过 $PASS 项, 失败 $FAIL 项" [ "$FAIL" -eq 0 ] diff --git a/tests/shell_screen_check.py b/tests/shell_screen_check.py index 226ec69..b4205d0 100644 --- a/tests/shell_screen_check.py +++ b/tests/shell_screen_check.py @@ -1,13 +1,13 @@ #!/usr/bin/env python3 """shell_screen_check.py - 验证 /shell 记录里对全屏程序的处理 -交互式 shell 的原始输出里混着转义序列、重绘空格和全屏程序的整屏画面。 -不清洗的话模型看到的就是几百个空格加一串光标定位;清过头又会把命令和 -状态行一起吃掉。这里用 printf 造出确定的输入来钉住行为: +交互式 shell 的原始输出里混着转义序列、重绘空格和全屏程序的整屏画面. +不清洗的话模型看到的就是几百个空格加一串光标定位; 清过头又会把命令和 +状态行一起吃掉. 这里用 printf 造出确定的输入来钉住行为: - 1. 备用屏幕(vim/less/top 走这条路)——画面必须省略,只留首尾标记; - 2. 成片空格(nano/top 靠空格重绘)——必须压掉,但短缩进要原样保留; - 3. 大输出里夹着的备用屏幕——跨 read 也要能认出来。 + 1. 备用屏幕 (vim/less/top 走这条路)——画面必须省略, 只留首尾标记; + 2. 成片空格 (nano/top 靠空格重绘)——必须压掉, 但短缩进要原样保留; + 3. 大输出里夹着的备用屏幕——跨 read 也要能认出来. 用法: shell_screen_check.py @@ -27,18 +27,18 @@ NORMAL_TEXT = "正常输出" def drive(binary, base, commands, wait=1.6, term="xterm-256color"): - """在 pty 里跑 ligent,进 /shell 执行 commands,导出后返回记录文本。 + """在 pty 里跑 ligent, 进 /shell 执行 commands, 导出后返回记录文本. - term=None 表示不设 TERM,用来观察退化行为。""" + term=None 表示不设 TERM, 用来观察退化行为. """ saved = os.environ.copy() os.environ["LIGENT_API_KEY"] = "sk-test" - # 没有 TERM 的话 less/vim 会退回非全屏模式,测不到备用屏幕那条路。 + # 没有 TERM 的话 less/vim 会退回非全屏模式, 测不到备用屏幕那条路. if term is None: os.environ.pop("TERM", None) else: os.environ["TERM"] = term - # 每轮都清空归档:归档名是随机的,按文件名排序取"最后一个"会拿到上一轮。 + # 每轮都清空归档:归档名是随机的, 按文件名排序取"最后一个"会拿到上一轮. bindir = os.path.dirname(os.path.abspath(binary)) shutil.rmtree(os.path.join(bindir, "ligent.archive"), ignore_errors=True) @@ -47,9 +47,9 @@ def drive(binary, base, commands, wait=1.6, term="xterm-256color"): os.execv(binary, [binary, "--base-url", base, "--no-confirm", "--auto-save"]) os._exit(127) - # 行编辑器每次回到提示符都会打开括号粘贴 —— 用它当"轮到输入了"的信号。 - # 只按"输出安静"等是不行的:等待动画每 250ms 画一帧,pty 永远不会安静 - # 超过 idle,于是每次都要白等满整个额度。 + # 行编辑器每次回到提示符都会打开括号粘贴 —— 用它当"轮到输入了"的信号. + # 只按"输出安静"等是不行的:等待动画每 250ms 画一帧, pty 永远不会安静 + # 超过 idle, 于是每次都要白等满整个额度. PROMPT_READY = b"\x1b[?2004h" ready_seen = 0 tail = b"" @@ -104,11 +104,11 @@ def drive(binary, base, commands, wait=1.6, term="xterm-256color"): return users[-1] if users else "" -def between_markers(rec, start="[全屏程序运行中,画面内容已省略]", end="[全屏程序结束]"): - """取出"全屏程序"首尾标记之间的内容。 +def between_markers(rec, start="[全屏程序运行中, 画面内容已省略]", end="[全屏程序结束]"): + """取出"全屏程序"首尾标记之间的内容. - 命令回显必然把输入里的字面文本也录进去,所以不能直接断言"机密文本不在 - 记录里"——只能断言它没出现在真正被省略的那段区间内。""" + 命令回显必然把输入里的字面文本也录进去, 所以不能直接断言"机密文本不在 + 记录里"——只能断言它没出现在真正被省略的那段区间内. """ parts = rec.split(start) if len(parts) < 2: return None @@ -135,8 +135,8 @@ def main(): fails += 1 print(" ✗ %s%s" % (name, (" " + detail) if detail else "")) - # ---- 1. 备用屏幕:画面省略,只留首尾标记 ---- - print("备用屏幕(vim/less 路径)") + # ---- 1. 备用屏幕:画面省略, 只留首尾标记 ---- + print("备用屏幕 (vim/less 路径)") rec = drive(binary, base, [ r"printf '\033[?1049h" + ALT_TEXT + r"\033[?1049l'", "echo " + NORMAL_TEXT, @@ -154,8 +154,8 @@ def main(): check("标记之外的输出还在", NORMAL_TEXT in rec) check("记录里没有裸转义符", "\x1b" not in rec) - # ---- 2. 成片空格:压掉,但缩进保留 ---- - print("成片空格(nano/top 重绘路径)") + # ---- 2. 成片空格:压掉, 但缩进保留 ---- + print("成片空格 (nano/top 重绘路径)") rec = drive(binary, base, [ r"printf 'A'; printf '%0.s ' $(seq 1 200); printf 'B\n'", r"printf ' 缩进四个空格\n'", @@ -171,7 +171,7 @@ def main(): check("空白占比低于 40%", blank < 0.40, "实际 %.0f%%" % (100 * blank)) # ---- 3. 大输出里夹着的备用屏幕 ---- - print("大输出(跨 read)里的备用屏幕") + print("大输出 (跨 read)里的备用屏幕") rec = drive(binary, base, [ "head -c 8000 /dev/zero | tr '\\0' 'x'; printf '\\033[?1049h" + ALT_TEXT + "\\033[?1049l'", @@ -185,7 +185,7 @@ def main(): "切断后漏了画面") check("跨块标记成对出现", body is not None) - # ---- 4. 同一个会话里进两次全屏,标记必须成对 ---- + # ---- 4. 同一个会话里进两次全屏, 标记必须成对 ---- print("连续两次进入全屏") rec = drive(binary, base, [ r"printf '\033[?1049hAAA\033[?1049l'; printf '\033[?1049hBBB\033[?1049l'", @@ -194,14 +194,14 @@ def main(): check("拿到记录", False, "没有生成归档") return 1 check("拿到记录", True) - check("两次开始标记", rec.count("[全屏程序运行中,画面内容已省略]") == 2, - "实际 %d 个" % rec.count("[全屏程序运行中,画面内容已省略]")) + check("两次开始标记", rec.count("[全屏程序运行中, 画面内容已省略]") == 2, + "实际 %d 个" % rec.count("[全屏程序运行中, 画面内容已省略]")) check("两次结束标记", rec.count("[全屏程序结束]") == 2, "实际 %d 个" % rec.count("[全屏程序结束]")) # ---- 5. TERM 退化时补一个像样的值 ---- - # TERM=dumb 时 nano 会放弃备用屏幕、改成逐行重绘,整段忽略就失效了。 - # 而 ligent 自己的 REPL 就在用光标定位,说明终端并非真的 dumb。 + # TERM=dumb 时 nano 会放弃备用屏幕、改成逐行重绘, 整段忽略就失效了. + # 而 ligent 自己的 REPL 就在用光标定位, 说明终端并非真的 dumb. for bad in ("dumb", None): label = "TERM=%s" % (bad if bad else "(未设置)") print("退化的 %s" % label) @@ -213,7 +213,7 @@ def main(): "实际记录:%s" % rec.strip().replace("\n", " | ")[-80:]) shutil.rmtree(archive, ignore_errors=True) - print("\n%d 项检查,%d 项失败" % (total, fails)) + print("\n%d 项检查, %d 项失败" % (total, fails)) return 1 if fails else 0 diff --git a/tests/test_agent.c b/tests/test_agent.c index ca86634..0ac7090 100644 --- a/tests/test_agent.c +++ b/tests/test_agent.c @@ -1,4 +1,4 @@ -/* test_agent.c - agent 上下文管理(不涉及网络) */ +/* test_agent.c - agent 上下文管理 (不涉及网络) */ #include "agent.h" #include "json.h" #include "util.h" @@ -71,14 +71,14 @@ static void add_tool_result(Agent *a, const char *id, const char *content) json_array_push(a->messages, m); } -/* 序列化历史,便于断言 */ +/* 序列化历史, 便于断言 */ static char *dump_history(const Agent *a) { return json_dump(a->messages); } -/* 配对不变量:任何 assistant(tool_calls) 后面必须**紧跟**同 id 的 tool 消息。 - * 服务端就是这么要求的,违反了下一轮直接 400 —— 所以每次动过历史之后都要查。 */ +/* 配对不变量:任何 assistant(tool_calls) 后面必须**紧跟**同 id 的 tool 消息. + * 服务端就是这么要求的, 违反了下一轮直接 400 —— 所以每次动过历史之后都要查. */ static void check_pairing(Agent *a) { size_t n = json_array_size(a->messages); @@ -92,18 +92,18 @@ static void check_pairing(Agent *a) JsonValue *nxt = json_array_get(a->messages, i + 1); const char *nid = json_get_string(nxt, "tool_call_id"); CHECK(nid && id && strcmp(nid, id) == 0, - "tool 结果必须紧跟其 tool_call(第 %zu 条)", i); + "tool 结果必须紧跟其 tool_call (第 %zu 条)", i); } } } -/* 一轮一轮摘下去(/back 走的就是这条路),历史在每一步都必须仍然自洽。 +/* 一轮一轮摘下去 (/back 走的就是这条路), 历史在每一步都必须仍然自洽. * - * 这里原来测的是"上下文超限自动裁剪"(agent_drop_oldest_round)—— 那套机制 - * 已经**整个删掉**:它的判据只是错误文案里的关键词,却会不可逆地删掉最老的 - * 几轮对话。现在超限就是如实报错、历史一个字节都不动,怎么处理由用户决定 - * (/back 摘一轮、/reset 重来)。但"摘掉一整轮之后 tool 配对不能散"这条 - * 不变量与由谁触发无关,所以留着继续测。 */ + * 这里原来测的是"上下文超限自动裁剪" (agent_drop_oldest_round)—— 那套机制 + * 已经**整个删掉**:它的判据只是错误文案里的关键词, 却会不可逆地删掉最老的 + * 几轮对话. 现在超限就是如实报错、历史一个字节都不动, 怎么处理由用户决定 + * (/back 摘一轮、/reset 重来). 但"摘掉一整轮之后 tool 配对不能散"这条 + * 不变量与由谁触发无关, 所以留着继续测. */ static void test_take_rounds_keeps_pairing(void) { puts("逐轮摘掉之后历史仍然自洽"); @@ -127,15 +127,15 @@ static void test_take_rounds_keeps_pairing(void) rounds++; check_pairing(&a); /* 每摘一轮都查一遍配对 */ } - CHECK(rounds == 4, "共应摘掉 4 轮,实际 %d", rounds); - CHECK(agent_history_len(&a) == 1, "最后应只剩 system,实际 %zu", + CHECK(rounds == 4, "共应摘掉 4 轮, 实际 %d", rounds); + CHECK(agent_history_len(&a) == 1, "最后应只剩 system, 实际 %zu", agent_history_len(&a)); agent_free(&a); } static void test_take_last_round(void) { - puts("摘掉最近一轮(/back 用)"); + puts("摘掉最近一轮 (/back 用)"); Agent a; agent_init(&a); agent_add_system(&a, "SYS"); @@ -146,13 +146,13 @@ static void test_take_last_round(void) add_msg(&a, "user", "U2"); add_msg(&a, "assistant", "A2"); - CHECK(agent_history_len(&a) == 7, "初始 7 条,实际 %zu", agent_history_len(&a)); + CHECK(agent_history_len(&a) == 7, "初始 7 条, 实际 %zu", agent_history_len(&a)); JsonValue *r = agent_take_last_round(&a); CHECK(r != NULL, "应摘到一轮"); - CHECK(r && json_array_size(r) == 2, "最后一轮应 2 条,实际 %zu", + CHECK(r && json_array_size(r) == 2, "最后一轮应 2 条, 实际 %zu", r ? json_array_size(r) : 0); - CHECK(agent_history_len(&a) == 5, "摘完应剩 5 条,实际 %zu", + CHECK(agent_history_len(&a) == 5, "摘完应剩 5 条, 实际 %zu", agent_history_len(&a)); char *h = dump_history(&a); CHECK(strstr(h, "U2") == NULL, "U2 应被摘掉"); @@ -162,11 +162,11 @@ static void test_take_last_round(void) /* 关键:工具调用与结果必须整组一起走 */ r = agent_take_last_round(&a); - CHECK(r && json_array_size(r) == 4, "第一轮应 4 条,实际 %zu", + CHECK(r && json_array_size(r) == 4, "第一轮应 4 条, 实际 %zu", r ? json_array_size(r) : 0); h = dump_history(&a); CHECK(strstr(h, "call_1") == NULL && strstr(h, "R1") == NULL, - "工具调用与结果应一并摘掉,不能只删一半"); + "工具调用与结果应一并摘掉, 不能只删一半"); CHECK(strstr(h, "SYS") != NULL, "system 必须保留"); free(h); json_free(r); @@ -191,7 +191,7 @@ static void test_take_last_round(void) /* 结构体检:assistant(tool_calls) 与 tool 消息的配对 */ /* ------------------------------------------------------------------ */ -/* 造一条 assistant,带 count 个工具调用(id 依次是 c0, c1, ...) */ +/* 造一条 assistant, 带 count 个工具调用 (id 依次是 c0, c1, ...) */ static JsonValue *mk_assistant(size_t count) { JsonValue *m = json_new_object(); @@ -271,13 +271,13 @@ static void test_repair_pairing(void) json_array_push(ok, mk_plain("assistant", "做完了")); CHECK(pairing_ok(ok), "构造出来的历史本身应是合法的"); CHECK(agent_repair_tool_pairing(ok, &fixed) == NULL, - "合法历史不该被重建(NULL = 什么都没改)"); + "合法历史不该被重建 (NULL = 什么都没改)"); CHECK(fixed == 0, "合法历史的修复处数应为 0"); json_free(ok); - puts("缺 tool 消息:补齐,且补在原位"); - /* 复现真实报错:assistant 带两个调用,中间夹了一条 user(图省事直接 - * 模仿当年那个 bug 的形状),第二个调用没人回应 */ + puts("缺 tool 消息:补齐, 且补在原位"); + /* 复现真实报错:assistant 带两个调用, 中间夹了一条 user (图省事直接 + * 模仿当年那个 bug 的形状), 第二个调用没人回应 */ JsonValue *bad = json_new_array(); json_array_push(bad, mk_plain("user", "看看图")); json_array_push(bad, mk_assistant(2)); @@ -289,20 +289,20 @@ static void test_repair_pairing(void) CHECK(r != NULL, "对不上时应该返回重建好的数组"); if (r) { CHECK(pairing_ok(r), "修完之后配对必须成立"); - /* 两处:给 c1 补一条占位(它没被回应),再把错位的那条 c1 结果丢掉 - * (它被 user 消息隔开,已经不在 tool 段里了 —— 留着就是孤儿)。 - * 丢掉真实结果听着可惜,但结构坏了只能二选一:要么丢它,要么留一段 - * 服务端必然拒绝的历史。模型会从占位消息里知道该重跑。 */ - CHECK(fixed == 2, "应补 1 处、丢 1 处孤儿,实际 %d", fixed); + /* 两处:给 c1 补一条占位 (它没被回应), 再把错位的那条 c1 结果丢掉 + * (它被 user 消息隔开, 已经不在 tool 段里了 —— 留着就是孤儿). + * 丢掉真实结果听着可惜, 但结构坏了只能二选一:要么丢它, 要么留一段 + * 服务端必然拒绝的历史. 模型会从占位消息里知道该重跑. */ + CHECK(fixed == 2, "应补 1 处、丢 1 处孤儿, 实际 %d", fixed); CHECK(json_array_size(r) == 5, - "补一条、丢一条之后仍是 5 条,实际 %zu", json_array_size(r)); - /* 占位消息必须紧跟在 assistant 的 tool 段里,而不是被塞到最后 */ + "补一条、丢一条之后仍是 5 条, 实际 %zu", json_array_size(r)); + /* 占位消息必须紧跟在 assistant 的 tool 段里, 而不是被塞到最后 */ CHECK(strcmp(nth_role(r, 2), "tool") == 0 && strcmp(nth_role(r, 3), "tool") == 0, "两条 tool 消息应紧跟在 assistant 之后"); const char *c = json_get_string(json_array_get(r, 3), "content"); CHECK(c && strstr(c, "没有执行") == NULL && strstr(c, "没有得到结果") != NULL, - "占位消息要说明它没拿到结果,实际 %s", c ? c : "(null)"); + "占位消息要说明它没拿到结果, 实际 %s", c ? c : "(null)"); json_free(r); } json_free(bad); @@ -313,17 +313,17 @@ static void test_repair_pairing(void) json_array_push(tail, mk_assistant(1)); fixed = 0; r = agent_repair_tool_pairing(tail, &fixed); - CHECK(r && pairing_ok(r) && fixed == 1, "结尾缺的也要补上(fixed=%d)", fixed); + CHECK(r && pairing_ok(r) && fixed == 1, "结尾缺的也要补上 (fixed=%d)", fixed); 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("谁也不是")); JsonValue *r2 = agent_repair_tool_pairing(orphan, &fixed); - CHECK(fixed == 1, "孤儿 tool 消息应被计为 1 处,实际 %d", fixed); - CHECK(r2 != NULL && json_array_size(r2) == 1, "孤儿应被丢掉,只剩 user"); + CHECK(fixed == 1, "孤儿 tool 消息应被计为 1 处, 实际 %d", fixed); + CHECK(r2 != NULL && json_array_size(r2) == 1, "孤儿应被丢掉, 只剩 user"); json_free(r2); json_free(orphan); @@ -348,25 +348,25 @@ static void test_repair_pairing(void) 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, - "只有 system 时**对话**长度必须是 0(用户报的就是这里)"); + "只有 system 时**对话**长度必须是 0 (用户报的就是这里)"); add_msg(&a, "user", "hi"); add_msg(&a, "assistant", "你好"); CHECK(agent_history_len(&a) == 3, "system + 一问一答 = 3"); CHECK(agent_conversation_len(&a) == 2, "对话长度 2"); - /* 没有 system 的历史(比如裸构造的)不该被误减 */ + /* 没有 system 的历史 (比如裸构造的)不该被误减 */ Agent b; agent_init(&b); add_msg(&b, "user", "hi"); @@ -397,7 +397,7 @@ int main(void) /* 关键不变量:重复设置 system 应替换而不是追加 */ agent_add_system(&a, "S2"); - CHECK(count_role(&a, "system") == 1, "重复设置 system 不应叠加,实际 %zu 条", + CHECK(count_role(&a, "system") == 1, "重复设置 system 不应叠加, 实际 %zu 条", count_role(&a, "system")); CHECK(strcmp(json_get_string(json_array_get(a.messages, 0), "content"), "S2") == 0, "新 system 应在第 0 条"); @@ -408,7 +408,7 @@ int main(void) CHECK(agent_history_len(&a) == 3, "应 3 条"); agent_reset(&a); - CHECK(agent_history_len(&a) == 1, "reset 后只保留 system,实际 %zu", + CHECK(agent_history_len(&a) == 1, "reset 后只保留 system, 实际 %zu", agent_history_len(&a)); CHECK(count_role(&a, "system") == 1, "reset 后 system 仍在"); CHECK(strcmp(json_get_string(json_array_get(a.messages, 0), "content"), "S2") == 0, @@ -419,7 +419,7 @@ int main(void) agent_init(&b); agent_add_user(&b, "only user"); agent_reset(&b); - CHECK(agent_history_len(&b) == 0, "无 system 时 reset 应为空,实际 %zu", + CHECK(agent_history_len(&b) == 0, "无 system 时 reset 应为空, 实际 %zu", agent_history_len(&b)); agent_free(&a); @@ -428,6 +428,6 @@ int main(void) test_take_last_round(); test_repair_pairing(); test_conversation_len(); - printf("\n%d 项检查,%d 项失败\n", checks, failures); + printf("\n%d 项检查, %d 项失败\n", checks, failures); return failures ? 1 : 0; } diff --git a/tests/test_archive.c b/tests/test_archive.c index 62e66fa..92b6bd8 100644 --- a/tests/test_archive.c +++ b/tests/test_archive.c @@ -1,4 +1,4 @@ -/* test_archive.c - 存档命名规则与读写往返(不需要网络) */ +/* test_archive.c - 存档命名规则与读写往返 (不需要网络) */ #include "archive.h" #include "util.h" @@ -40,7 +40,7 @@ int main(void) 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", + 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); free(n1); @@ -60,7 +60,7 @@ int main(void) 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); @@ -88,7 +88,7 @@ int main(void) char *fresh = NULL; CHECK(archive_next_name(dir, too, NULL, 0, &fresh, err, sizeof err) == 0, "超限生成失败"); CHECK(fresh && strlen(fresh) == ARCHIVE_ROOT_LEN, - "超限时应退回 %d 位根名,实际 %s(%zu 位)", + "超限时应退回 %d 位根名, 实际 %s (%zu 位)", ARCHIVE_ROOT_LEN, fresh ? fresh : "(null)", fresh ? strlen(fresh) : 0); free(fresh); puts("前缀:临时存档"); @@ -106,11 +106,11 @@ int main(void) "新根也应带前缀:%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, - "加了前缀装不下时应退回根名,实际 %s(%zu 位)", + "加了前缀装不下时应退回根名, 实际 %s (%zu 位)", edgep ? edgep : "(null)", edgep ? strlen(edgep) : 0); free(edgep); @@ -120,7 +120,7 @@ int main(void) JsonValue *msgs = json_new_array(); JsonValue *m = json_new_object(); json_object_set_string(m, "role", "user"); - json_object_set_string(m, "content", "你好,暗号是紫水晶"); + json_object_set_string(m, "content", "你好, 暗号是紫水晶"); json_array_push(msgs, m); char *path = NULL, *name = NULL; @@ -144,7 +144,7 @@ int main(void) CHECK(model && strcmp(model, "test-model") == 0, "model 应往返"); CHECK(created && strstr(created, "2023-") != NULL, "created 应可读:%s", created ? created : "(null)"); - /* saved 是"这份存档写出时的时间",与 created(这段对话的开始时间)是两回事 */ + /* saved 是"这份存档写出时的时间", 与 created (这段对话的开始时间)是两回事 */ CHECK(saved && strstr(saved, "2023-") != NULL, "saved 应可读:%s", saved ? saved : "(null)"); @@ -154,25 +154,25 @@ int main(void) puts("列举"); ArchiveEntry *list = NULL; size_t n = archive_list(dir, &list); - CHECK(n == 1, "应有 1 份存档,实际 %zu", n); + CHECK(n == 1, "应有 1 份存档, 实际 %zu", n); if (n == 1) { CHECK(strncmp(list[0].name, "parentname_", 11) == 0, "名字应保留"); - CHECK(list[0].count == 1, "条数应为 1,实际 %ld", list[0].count); + CHECK(list[0].count == 1, "条数应为 1, 实际 %ld", list[0].count); CHECK(list[0].created != NULL, "时间应从内容里读出来"); } archive_list_free(list, n); - /* 非 ligent 存档(没有 format 标记)应被忽略 */ + /* 非 ligent 存档 (没有 format 标记)应被忽略 */ char *jd = xasprintf("%s/junk.json", dir); FILE *j2 = fopen(jd, "w"); if (j2) { fputs("{\"hello\":1}", j2); fclose(j2); } n = archive_list(dir, &list); - CHECK(n == 1, "非 ligent 存档应被忽略,实际 %zu", n); + CHECK(n == 1, "非 ligent 存档应被忽略, 实际 %zu", n); archive_list_free(list, n); unlink(jd); free(jd); unlink(path); free(path); free(name); rmdir(dir); - printf("\n%d 项检查,%d 项失败\n", checks, failures); + printf("\n%d 项检查, %d 项失败\n", checks, failures); return failures ? 1 : 0; } diff --git a/tests/test_attach.c b/tests/test_attach.c index 841801d..6753a90 100644 --- a/tests/test_attach.c +++ b/tests/test_attach.c @@ -1,7 +1,7 @@ /* test_attach.c - /include 的暂存、glob 展开与体积/类型判断 * - * 交互式挑选(attach_pick)要真终端才能测,由 e2e 第 55 段覆盖; - * 这里测的是不需要人操作的那一半。 + * 交互式挑选 (attach_pick)要真终端才能测, 由 e2e 第 55 段覆盖; + * 这里测的是不需要人操作的那一半. */ #include "attach.h" #include "image.h" @@ -52,8 +52,8 @@ static size_t count_type(const JsonValue *parts, const char *type) return n; } -/* 记一下回调被问了几次 —— 它只在遇到图片时该被问到。 - * 答案和计数放在一起,免得"计数版只实现了一种答案"这种测试自己的错。 */ +/* 记一下回调被问了几次 —— 它只在遇到图片时该被问到. + * 答案和计数放在一起, 免得"计数版只实现了一种答案"这种测试自己的错. */ static int ask_count; static int ask_answer; static int counting_ask(void *ud) { (void)ud; ask_count++; return ask_answer; } @@ -64,7 +64,7 @@ int main(void) char *dir = mkdtemp(tmpl); if (!dir) { perror("mkdtemp"); return 2; } - /* 备份目录也指到临时目录,别污染仓库 */ + /* 备份目录也指到临时目录, 别污染仓库 */ image_set_bindir(dir); char *f_c = xasprintf("%s/a.c", dir); @@ -78,7 +78,7 @@ int main(void) write_file(f_bin, "\x00\x01\x02binary", 9); write_file(f_png, PNG, sizeof PNG); { - /* 超过单个文件上限(256 KB) */ + /* 超过单个文件上限 (256 KB) */ size_t n = ATTACH_MAX_FILE + 1024; char *big = xmalloc(n); memset(big, 'x', n); @@ -103,13 +103,13 @@ int main(void) CHECK(txt && strstr(txt, "int main") != NULL, "内容带上了"); CHECK(txt && strncmp(txt, "===== ", 6) == 0, "块开头标了来源路径"); CHECK(txt && strchr(txt, '\n') != NULL, "结尾有换行"); - /* 只在本地记的来源,绝不能进块里(那会跟着发给服务端) */ + /* 只在本地记的来源, 绝不能进块里 (那会跟着发给服务端) */ CHECK(p0 && json_get_string(p0, "source") == NULL, "块里不该有自造的 source 字段"); json_free(parts); CHECK(attach_count() == 0, "取走之后暂存清空"); - puts("取走即清空,clear 也是"); + puts("取走即清空, clear 也是"); attach_glob(f_c, NULL, NULL, &skipped, err, sizeof err); CHECK(attach_count() == 1, "再附带一次"); attach_clear(); @@ -119,15 +119,15 @@ int main(void) puts("二进制文件跳过"); attach_clear(); added = attach_glob(f_bin, NULL, NULL, &skipped, err, sizeof err); - CHECK(added == 0 && skipped == 1, "二进制被跳过(added=%d skipped=%zu)", + CHECK(added == 0 && skipped == 1, "二进制被跳过 (added=%d skipped=%zu)", added, skipped); puts("超过单个文件上限的跳过"); attach_clear(); added = attach_glob(f_big, NULL, NULL, &skipped, err, sizeof err); - CHECK(added == 0 && skipped == 1, "太大的文本被跳过(added=%d)", added); + 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 被收下"); @@ -136,17 +136,17 @@ int main(void) CHECK(count_type(parts, "ligent_image") == 1, "应当是一个 ligent_image 引用块"); json_free(parts); - puts("模型不收图时跳过图片(回调只在遇到图片时被问)"); + puts("模型不收图时跳过图片 (回调只在遇到图片时被问)"); attach_clear(); ask_count = 0; ask_answer = 1; added = attach_glob(f_txt, counting_ask, NULL, &skipped, err, sizeof err); CHECK(added == 1, "纯文本照带"); - CHECK(ask_count == 0, "纯文本不该去问'能不能收图'(问了一次就是白探测)"); + CHECK(ask_count == 0, "纯文本不该去问'能不能收图' (问了一次就是白探测)"); attach_clear(); ask_count = 0; ask_answer = 0; added = attach_glob(f_png, counting_ask, NULL, &skipped, err, sizeof err); CHECK(added == 0 && skipped == 1, "回调说不行就跳过"); - CHECK(ask_count == 1, "遇到图片时问了一次,实际 %d", ask_count); + CHECK(ask_count == 1, "遇到图片时问了一次, 实际 %d", ask_count); puts("匹配不到 / 参数非法"); attach_clear(); @@ -161,7 +161,7 @@ int main(void) attach_clear(); char *pat = xasprintf("%s/*.txt", dir); added = attach_glob(pat, NULL, NULL, &skipped, err, sizeof err); - CHECK(added == 1, "只有 b.txt 能带(big.txt 超限),实际 %d", added); + CHECK(added == 1, "只有 b.txt 能带 (big.txt 超限), 实际 %d", added); CHECK(skipped == 1, "big.txt 被跳过"); free(pat); attach_clear(); @@ -172,6 +172,6 @@ int main(void) if (system(cmd) != 0) { } free(cmd); - printf("\n%d 项检查,%d 项失败\n", checks, failures); + printf("\n%d 项检查, %d 项失败\n", checks, failures); return failures ? 1 : 0; } diff --git a/tests/test_capability.c b/tests/test_capability.c index 0a538c5..7060575 100644 --- a/tests/test_capability.c +++ b/tests/test_capability.c @@ -1,13 +1,13 @@ /* test_capability.c - 能力探测的纯逻辑部分 * - * 探测序列本身(基线 -> 合并 -> 拆开)要发 HTTP,属于端到端范畴, + * 探测序列本身 (基线 -> 合并 -> 拆开)要发 HTTP, 属于端到端范畴, * 由 e2e 第 51 段对着 mock 覆盖:正常模型 / 不收图片 / 不收 tools / - * 探测失败(401)/ --no-probe 五种走法都在那儿验。 + * 探测失败 (401)/ --no-probe 五种走法都在那儿验. * - * 这里只测不需要网络的:名字映射、关闭探测时的语义、缓存查询。 - * 其中"关闭探测必须给未测而不是不支持"那条是回归测试 —— 那句话看着像废话, - * 但 CAP_NO 恰好是 0,而 Agent 是 memset 出来的,忘了显式初始化就会一声不响地 - * 把所有工具摘掉。这个 bug 真的发生过一次。 + * 这里只测不需要网络的:名字映射、关闭探测时的语义、缓存查询. + * 其中"关闭探测必须给未测而不是不支持"那条是回归测试 —— 那句话看着像废话, + * 但 CAP_NO 恰好是 0, 而 Agent 是 memset 出来的, 忘了显式初始化就会一声不响地 + * 把所有工具摘掉. 这个 bug 真的发生过一次. */ #include "capability.h" #include "llm.h" @@ -31,9 +31,9 @@ static int checks = 0; int main(void) { - /* CAP_NO 必须是 0 —— Agent 是 memset 出来的,整个 capability 模块的 - * 设计都建立在"零值不能是某个真实结论"之上。这条断言是那个 bug 的护栏。 */ - CHECK(CAP_NO == 0, "CAP_NO 应为 0(零值即'不支持'是事故的根源)"); + /* CAP_NO 必须是 0 —— Agent 是 memset 出来的, 整个 capability 模块的 + * 设计都建立在"零值不能是某个真实结论"之上. 这条断言是那个 bug 的护栏. */ + CHECK(CAP_NO == 0, "CAP_NO 应为 0 (零值即'不支持'是事故的根源)"); CHECK(CAP_UNKNOWN != CAP_NO, "未知与不支持必须是两个不同的值"); CHECK(CAP_UNKNOWN != CAP_YES, "未知与支持必须是两个不同的值"); @@ -41,12 +41,12 @@ int main(void) CHECK(strcmp(cap_name(CAP_YES), "支持") == 0, "YES"); CHECK(strcmp(cap_name(CAP_NO), "不支持") == 0, "NO"); CHECK(strcmp(cap_name(CAP_UNKNOWN), "未测") == 0, "UNKNOWN"); - /* 任何不是 YES/NO 的值都该落到"未测",而不是碰巧落到某一个 */ + /* 任何不是 YES/NO 的值都该落到"未测", 而不是碰巧落到某一个 */ CHECK(strcmp(cap_name(42), "未测") == 0, "越界值应落到未测"); - puts("关掉探测:一律未测,绝不冒充结论"); - /* 用可变数组而不是字面量:LlmConfig 的这些字段是 char *(真要释放的), - * 而 -Wwrite-strings 下 "字面量" 是 const char * —— 直接赋值会被警告。 */ + puts("关掉探测:一律未测, 绝不冒充结论"); + /* 用可变数组而不是字面量:LlmConfig 的这些字段是 char * (真要释放的), + * 而 -Wwrite-strings 下 "字面量" 是 const char * —— 直接赋值会被警告. */ char model[] = "some-model"; char base_url[] = "http://127.0.0.1:1/v1"; LlmConfig cfg; @@ -58,17 +58,17 @@ int main(void) cap_cache_clear(); Capabilities c; - memset(&c, 0xAB, sizeof c); /* 故意填脏,验证会被完整覆盖 */ + memset(&c, 0xAB, sizeof c); /* 故意填脏, 验证会被完整覆盖 */ int probed = cap_get(&cfg, 0, &c, 0, NULL, 0); CHECK(probed == 0, "被禁用时不该声称探测过"); CHECK(c.probed == 0, "probed 应为 0"); - CHECK(c.vision == CAP_UNKNOWN, "禁用时 vision 应为未测,实际 %d", c.vision); - CHECK(c.tools == CAP_UNKNOWN, "禁用时 tools 应为未测,实际 %d", c.tools); + CHECK(c.vision == CAP_UNKNOWN, "禁用时 vision 应为未测, 实际 %d", c.vision); + 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, "禁用时不能表现为不支持(否则会摘掉整张工具表)"); + /* 关键回归:禁用时的结果拿去决定工具表, 必须"全都留着" */ + CHECK(c.vision != CAP_NO, "禁用时不能表现为不支持 (否则会藏掉 read_image)"); + CHECK(c.tools != CAP_NO, "禁用时不能表现为不支持 (否则会摘掉整张工具表)"); puts("缓存查询"); Capabilities got; @@ -87,6 +87,6 @@ int main(void) cap_cache_clear(); CHECK(1, "空指针路径没有崩"); - printf("\n%d 项检查,%d 项失败\n", checks, failures); + printf("\n%d 项检查, %d 项失败\n", checks, failures); return failures ? 1 : 0; } diff --git a/tests/test_config.c b/tests/test_config.c index 7f7e7e4..c74ec82 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -44,14 +44,14 @@ static void test_defaults(void) LigentConfig c; config_defaults(&c); CHECK(strcmp(c.model, "deepseek-flash") == 0, - "默认模型应为 deepseek-flash,实际 %s", c.model); + "默认模型应为 deepseek-flash, 实际 %s", c.model); CHECK(strcmp(c.base_url, "https://api.deepseek.com/v1") == 0, - "默认 base_url 应为 DeepSeek,实际 %s", c.base_url); + "默认 base_url 应为 DeepSeek, 实际 %s", c.base_url); CHECK(c.api_key && c.api_key[0] == '\0', "默认 api_key 应为空"); CHECK(c.stream == 1, "默认应开启流式"); CHECK(c.use_tools == 1, "默认应启用工具"); - /* 默认不限往返次数:0(或负数)= 不限,只有正数才是上限 */ - CHECK(c.max_steps == 0, "默认应不限往返次数,实际 %d", c.max_steps); + /* 默认不限往返次数:0 (或负数)= 不限, 只有正数才是上限 */ + CHECK(c.max_steps == 0, "默认应不限往返次数, 实际 %d", c.max_steps); config_free(&c); } @@ -70,13 +70,13 @@ static void test_env_key(void) setenv("DEEPSEEK_API_KEY", "sk-deepseek", 1); config_apply_env(&c); CHECK(strcmp(c.api_key, "sk-deepseek") == 0, - "应读 $DEEPSEEK_API_KEY,实际 [%s]", c.api_key); + "应读 $DEEPSEEK_API_KEY, 实际 [%s]", c.api_key); /* LIGENT_API_KEY 作为显式覆盖优先 */ setenv("LIGENT_API_KEY", "sk-explicit", 1); config_apply_env(&c); CHECK(strcmp(c.api_key, "sk-explicit") == 0, - "LIGENT_API_KEY 应覆盖,实际 [%s]", c.api_key); + "LIGENT_API_KEY 应覆盖, 实际 [%s]", c.api_key); /* OPENAI_API_KEY 只在端点确实是 OpenAI 时才兜底 —— * 把 OpenAI 的密钥发给 DeepSeek 的端点是没有意义的 */ @@ -92,7 +92,7 @@ static void test_env_key(void) c.base_url = xstrdup("https://api.openai.com/v1"); config_apply_env(&c); CHECK(strcmp(c.api_key, "sk-openai") == 0, - "OpenAI 端点应兜底到 OPENAI_API_KEY,实际 %s", c.api_key); + "OpenAI 端点应兜底到 OPENAI_API_KEY, 实际 %s", c.api_key); unset_all(); config_free(&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); @@ -198,7 +198,7 @@ static void test_file_thinking(void) unlink(t2); } - /* 写错了必须报错,不能静默忽略 —— 配置文件是长期存在的,静默会让人困惑 */ + /* 写错了必须报错, 不能静默忽略 —— 配置文件是长期存在的, 静默会让人困惑 */ char t3[] = "/tmp/ligent_think_XXXXXX"; fd = mkstemp(t3); if (fd >= 0) { @@ -259,7 +259,7 @@ static void test_file_override(void) CHECK(strcmp(c.base_url, "http://localhost:11434/v1") == 0, "base_url 覆盖"); CHECK(strcmp(c.model, "qwen2.5:14b") == 0, "model 覆盖"); CHECK(strcmp(c.api_key, "sk-from-env") == 0, - "${MY_SECRET} 应被展开,实际 [%s]", c.api_key); + "${MY_SECRET} 应被展开, 实际 [%s]", c.api_key); CHECK(c.temperature > 0.29 && c.temperature < 0.31, "temperature 覆盖"); CHECK(c.max_tokens == 4096, "max_tokens 覆盖"); CHECK(c.max_steps == 9, "max_steps 覆盖"); @@ -267,10 +267,10 @@ static void test_file_override(void) CHECK(c.use_tools == 0, "tools 覆盖"); CHECK(c.config_path && strcmp(c.config_path, tmpl) == 0, "记录配置文件路径"); - /* 坏配置应报错而不是静默通过。 + /* 坏配置应报错而不是静默通过. * - * 解析器的报错跟着界面语言走(json_parse_ui),所以下面这段断语先把语言 - * 钉住,再顺手把英文那份也验一遍 —— 位置信息两种语言都得有。 */ + * 解析器的报错跟着界面语言走 (json_parse_ui), 所以下面这段断语先把语言 + * 钉住, 再顺手把英文那份也验一遍 —— 位置信息两种语言都得有. */ char tmpl2[] = "/tmp/ligent_bad_XXXXXX"; int fd2 = mkstemp(tmpl2); if (fd2 >= 0) { @@ -284,7 +284,7 @@ static void test_file_override(void) CHECK(config_load_file(&c2, tmpl2, err, sizeof err) != 0, "非法 JSON 应加载失败"); CHECK(err[0] != '\0', "应给出错误信息"); - /* 报错必须带上文件路径与出错位置,否则用户无从下手 */ + /* 报错必须带上文件路径与出错位置, 否则用户无从下手 */ CHECK(strstr(err, tmpl2) != NULL, "错误信息应含文件路径: %s", err); CHECK(strstr(err, "行") != NULL, "错误信息应含出错位置: %s", err); config_free(&c2); @@ -294,7 +294,7 @@ static void test_file_override(void) config_defaults(&c3); CHECK(config_load_file(&c3, tmpl2, err, sizeof err) != 0 && strstr(err, "line") != NULL, - "英文界面下报错也带位置(且是英文): %s", err); + "英文界面下报错也带位置 (且是英文): %s", err); config_free(&c3); i18n_set_lang(LIGENT_LANG_ZH); unlink(tmpl2); @@ -305,7 +305,7 @@ static void test_file_override(void) unset_all(); } -/* 配置只认二进制所在目录:不做用户级、系统级,也不做当前目录查找 */ +/* 配置只认二进制所在目录:不做用户级、系统级, 也不做当前目录查找 */ static void test_binary_dir_lookup(void) { puts("配置位置 = 二进制所在目录"); @@ -319,7 +319,7 @@ static void test_binary_dir_lookup(void) CHECK(canon && dir && str_starts_with(canon, dir), "规范路径应位于二进制目录下: %s / %s", canon, dir); - /* 当前目录放一份 ligent.json,它绝不能被加载 */ + /* 当前目录放一份 ligent.json, 它绝不能被加载 */ char tmpl[] = "/tmp/ligent_cwd_XXXXXX"; char *tmp = mkdtemp(tmpl); if (!tmp) { free(dir); free(canon); return; } @@ -343,7 +343,7 @@ static void test_binary_dir_lookup(void) CHECK(!loaded_from_cwd, "不应加载当前工作目录下的 ligent.json"); CHECK(got_dir && dir && strcmp(got_dir, dir) == 0, - "config_dir 应是二进制目录,实际 %s", got_dir ? got_dir : "(null)"); + "config_dir 应是二进制目录, 实际 %s", got_dir ? got_dir : "(null)"); free(got_dir); free(saved); @@ -369,16 +369,16 @@ static int load_temp_config(LigentConfig *c, const char *json) return rc; } -/* 厂商专属的环境变量不能越权覆盖配置文件里写好的密钥。 - * 这正是用户踩到的坑:配置写了 OpenRouter 的 key, - * shell 里残留的 DEEPSEEK_API_KEY 把它顶掉,于是 A 家的密钥发给了 B 家。 */ +/* 厂商专属的环境变量不能越权覆盖配置文件里写好的密钥. + * 这正是用户踩到的坑:配置写了 OpenRouter 的 key, + * shell 里残留的 DEEPSEEK_API_KEY 把它顶掉, 于是 A 家的密钥发给了 B 家. */ static void test_env_key_precedence(void) { puts("api_key 优先级:厂商环境变量不得越权"); unset_all(); setenv("DEEPSEEK_API_KEY", "sk-leftover-in-shell", 1); - /* 1) 配置文件写了 key => 它就是最终值,环境变量顶不掉 */ + /* 1) 配置文件写了 key => 它就是最终值, 环境变量顶不掉 */ LigentConfig c; config_defaults(&c); CHECK(load_temp_config(&c, @@ -386,7 +386,7 @@ static void test_env_key_precedence(void) "\"api_key\":\"sk-or-v1-from-file\"}") == 0, "加载配置失败"); config_apply_env(&c); CHECK(strcmp(c.api_key, "sk-or-v1-from-file") == 0, - "配置文件里的 key 不应被残留环境变量顶掉,实际 %s", c.api_key); + "配置文件里的 key 不应被残留环境变量顶掉, 实际 %s", c.api_key); config_free(&c); /* 2) 配置文件没写 key + 端点就是 DeepSeek => 用 DEEPSEEK_API_KEY 兜底 */ @@ -395,19 +395,19 @@ static void test_env_key_precedence(void) "加载配置失败"); config_apply_env(&c); CHECK(strcmp(c.api_key, "sk-leftover-in-shell") == 0, - "DeepSeek 端点应能用 DEEPSEEK_API_KEY 兜底,实际 %s", c.api_key); + "DeepSeek 端点应能用 DEEPSEEK_API_KEY 兜底, 实际 %s", c.api_key); config_free(&c); - /* 3) 配置文件没写 key + 端点是别家 => 不猜,宁可空着让用户看到明确提示 */ + /* 3) 配置文件没写 key + 端点是别家 => 不猜, 宁可空着让用户看到明确提示 */ config_defaults(&c); CHECK(load_temp_config(&c, "{\"base_url\":\"https://openrouter.ai/api/v1\"}") == 0, "加载配置失败"); config_apply_env(&c); CHECK(c.api_key[0] == '\0', - "base_url 指向别家时不应拿 DeepSeek 的密钥去猜,实际 %s", c.api_key); + "base_url 指向别家时不应拿 DeepSeek 的密钥去猜, 实际 %s", c.api_key); config_free(&c); - /* 4) LIGENT_API_KEY 是显式覆盖,任何时候都最高 */ + /* 4) LIGENT_API_KEY 是显式覆盖, 任何时候都最高 */ setenv("LIGENT_API_KEY", "sk-explicit", 1); config_defaults(&c); CHECK(load_temp_config(&c, @@ -415,46 +415,46 @@ static void test_env_key_precedence(void) "\"api_key\":\"sk-or-v1-from-file\"}") == 0, "加载配置失败"); config_apply_env(&c); CHECK(strcmp(c.api_key, "sk-explicit") == 0, - "LIGENT_API_KEY 应最高优先,实际 %s", c.api_key); + "LIGENT_API_KEY 应最高优先, 实际 %s", c.api_key); config_free(&c); unset_all(); } -/* 软链接:配置目录要跟着"用户实际调用那个文件"走。 - * 否则把 ligent 软链到 ~/bin 之后,配置会被悄悄挪到源文件所在的目录, - * 用户放在 ~/bin 旁边的 ligent.json 就永远读不到。 */ +/* 软链接:配置目录要跟着"用户实际调用那个文件"走. + * 否则把 ligent 软链到 ~/bin 之后, 配置会被悄悄挪到源文件所在的目录, + * 用户放在 ~/bin 旁边的 ligent.json 就永远读不到. */ static void test_symlink_dir(void) { puts("软链接:配置目录跟着软链接走"); char self[PATH_MAX]; ssize_t n = readlink("/proc/self/exe", self, sizeof self - 1); - if (n <= 0) { puts(" (没有 /proc/self/exe,跳过)"); return; } + if (n <= 0) { puts(" (没有 /proc/self/exe, 跳过)"); return; } self[n] = '\0'; char tmpl[] = "/tmp/ligent_link_XXXXXX"; char *dir = mkdtemp(tmpl); - if (!dir) { puts(" (mkdtemp 失败,跳过)"); return; } + if (!dir) { puts(" (mkdtemp 失败, 跳过)"); return; } char *link = xasprintf("%s/ligent", dir); if (symlink(self, link) != 0) { - puts(" (symlink 失败,跳过)"); + puts(" (symlink 失败, 跳过)"); free(link); rmdir(dir); return; } - /* 核心:指向软链接时取软链接所在目录,而不是源文件所在目录 */ + /* 核心:指向软链接时取软链接所在目录, 而不是源文件所在目录 */ char *got = config_binary_dir(link); CHECK(got && strcmp(got, dir) == 0, - "应取软链接所在目录 %s,实际 %s", dir, got ? got : "(null)"); + "应取软链接所在目录 %s, 实际 %s", dir, got ? got : "(null)"); free(got); - /* 路径里的 . 应被规范化,但最后一段仍不解析 */ + /* 路径里的 . 应被规范化, 但最后一段仍不解析 */ char *dotted = xasprintf("%s/./ligent", dir); got = config_binary_dir(dotted); CHECK(got && strcmp(got, dir) == 0, - "带 . 的路径应规范化,实际 %s", got ? got : "(null)"); + "带 . 的路径应规范化, 实际 %s", got ? got : "(null)"); free(got); free(dotted); /* argv[0] 只是个名字时经 $PATH 找到的仍应是软链接本身 */ @@ -463,18 +463,18 @@ static void test_symlink_dir(void) setenv("PATH", newpath, 1); got = config_binary_dir("ligent"); CHECK(got && strcmp(got, dir) == 0, - "$PATH 找到软链接时应取它所在目录,实际 %s", got ? got : "(null)"); + "$PATH 找到软链接时应取它所在目录, 实际 %s", got ? got : "(null)"); free(got); if (saved) setenv("PATH", saved, 1); else unsetenv("PATH"); free(saved); free(newpath); - /* 直接用真实文件时,取的还是真实目录 */ + /* 直接用真实文件时, 取的还是真实目录 */ char *selfdir = xstrdup(self); char *slash = strrchr(selfdir, '/'); if (slash) *slash = '\0'; got = config_binary_dir(self); CHECK(got && strcmp(got, selfdir) == 0, - "真实文件应取真实目录 %s,实际 %s", selfdir, got ? got : "(null)"); + "真实文件应取真实目录 %s, 实际 %s", selfdir, got ? got : "(null)"); free(got); free(selfdir); unlink(link); @@ -493,6 +493,6 @@ int main(void) test_binary_dir_lookup(); test_env_key_precedence(); test_symlink_dir(); - printf("\n%d 项检查,%d 项失败\n", checks, failures); + printf("\n%d 项检查, %d 项失败\n", checks, failures); return failures ? 1 : 0; } diff --git a/tests/test_http.c b/tests/test_http.c index 6fd67c1..cb95f39 100644 --- a/tests/test_http.c +++ b/tests/test_http.c @@ -1,4 +1,4 @@ -/* test_http.c - HTTP 层纯逻辑测试(不联网) */ +/* test_http.c - HTTP 层纯逻辑测试 (不联网) */ #include "http.h" #include "util.h" @@ -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,12 +53,12 @@ int main(void) size_t sz = 0; FILE *f = open_memstream(&buf, &sz); - /* 这条正是曾经漏出去的:libcurl 的 HTTP/2 调试行走的是 CURLINFO_TEXT, - * 不是 HEADER_OUT,所以只处理 HEADER_OUT 的做法根本挡不住 */ + /* 这条正是曾经漏出去的: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); - /* 整块请求头(HEADER_OUT 是整块传来的,不是逐行) */ + /* 整块请求头 (HEADER_OUT 是整块传来的, 不是逐行) */ const char *hdrs = "POST /v1/chat/completions HTTP/2\r\n" "Authorization: Bearer sk-0f114e3dac8249f0a36fd8fd5c517360\r\n" "Content-Type: application/json\r\n"; @@ -79,20 +79,20 @@ int main(void) "请求头里的密钥应脱敏: %.200s", buf); CHECK(strstr(buf, "TLS handshake") != NULL, "其它调试信息不能被误改"); CHECK(strstr(buf, "no key here") != NULL, "无密钥时原样输出"); - /* 一次替换必须整段吃掉,不能留下半个密钥 */ + /* 一次替换必须整段吃掉, 不能留下半个密钥 */ CHECK(strstr(buf, "0f114e3dac") == NULL, "不应留下密钥片段"); free(buf); } puts("\n空请求体:POST 分支也要扛得住 body == NULL"); { - /* body 允许为 NULL(http_probe 就是这么调的),但 POST 分支以前写的是 - * 无条件的 strlen(body) —— 传 NULL 会在 curl_easy_setopt 期间就段错误。 + /* body 允许为 NULL (http_probe 就是这么调的), 但 POST 分支以前写的是 + * 无条件的 strlen(body) —— 传 NULL 会在 curl_easy_setopt 期间就段错误. * - * 这里故意用一个格式非法的 URL:libcurl 会在真正联网之前就拒绝它, - * 所以本测试仍然不碰网络。而 strlen(NULL) 发生在设置 POSTFIELDS 期间、 - * curl_easy_perform 之前,也就是说旧代码在这里必崩 —— 这个断言对旧 - * 代码是有效的回归守卫,不是走过场。 */ + * 这里故意用一个格式非法的 URL:libcurl 会在真正联网之前就拒绝它, + * 所以本测试仍然不碰网络. 而 strlen(NULL) 发生在设置 POSTFIELDS 期间、 + * curl_easy_perform 之前, 也就是说旧代码在这里必崩 —— 这个断言对旧 + * 代码是有效的回归守卫, 不是走过场. */ static const HttpRetry no_retry = HTTP_RETRY_NONE; char errbuf[256] = ""; int attempts = 0; @@ -103,10 +103,10 @@ int main(void) NULL, &status, &attempts, errbuf, sizeof errbuf); http_global_cleanup(); - CHECK(rc == -1, "非法 URL 应当返回 -1(实际返回 %d)", rc); + CHECK(rc == -1, "非法 URL 应当返回 -1 (实际返回 %d)", rc); CHECK(errbuf[0] != '\0', "失败时必须给出错误说明"); } - printf("\n%d 项检查,%d 项失败\n", checks, failures); + printf("\n%d 项检查, %d 项失败\n", checks, failures); return failures ? 1 : 0; } diff --git a/tests/test_i18n.c b/tests/test_i18n.c index d88e53f..a0b66fc 100644 --- a/tests/test_i18n.c +++ b/tests/test_i18n.c @@ -1,10 +1,10 @@ /* test_i18n.c - 界面文案表 * - * 这张表是"每种语言一条"。它最容易出的错不是逻辑,而是漏:加了键忘了翻, - * 或者译文里少了个占位符({path} 写成 {paths})—— 用户看到的就是一句缺字的话。 - * 所以这里的检查全部是机械的:遍历表,逐条比对。 + * 这张表是"每种语言一条". 它最容易出的错不是逻辑, 而是漏:加了键忘了翻, + * 或者译文里少了个占位符 ({path} 写成 {paths})—— 用户看到的就是一句缺字的话. + * 所以这里的检查全部是机械的:遍历表, 逐条比对. * - * 文案本身好不好不在这里管;"英文界面是不是每句都有着落"才是。 + * 文案本身好不好不在这里管; "英文界面是不是每句都有着落"才是. */ #include "i18n.h" @@ -26,7 +26,7 @@ static int checks = 0; } \ } while (0) -/* 收一份占位符名单({name} 里的 name),按出现顺序放进 out */ +/* 收一份占位符名单 ({name} 里的 name), 按出现顺序放进 out */ static int placeholders(const char *s, char (*out)[32], int max) { int n = 0; @@ -44,9 +44,9 @@ static int placeholders(const char *s, char (*out)[32], int max) return n; } -/* 收一份 printf 转换符名单(%zu、%c、%.20s …),原样带走标志/宽度/长度。 - * json.* 那几条是 printf 模板而不是 {name},两种语言只要转换符顺序或类型 - * 对不上,snprintf 就会读错参数 —— 比少字更糟,得由机器盯住。 */ +/* 收一份 printf 转换符名单 (%zu、%c、%.20s …), 原样带走标志/宽度/长度. + * json.* 那几条是 printf 模板而不是 {name}, 两种语言只要转换符顺序或类型 + * 对不上, snprintf 就会读错参数 —— 比少字更糟, 得由机器盯住. */ static int printf_specs(const char *s, char (*out)[16], int max) { int n = 0; @@ -71,9 +71,9 @@ static int printf_specs(const char *s, char (*out)[16], int max) return n; } -/* 有没有中日韩字符(含全角标点:〔〕())。英文那一列里出现它,基本就是 +/* 有没有中日韩字符 (含全角标点:〔〕 ()). 英文那一列里出现它, 基本就是 * "照抄了中文"或者"美化用的框线忘了换" —— 用户看到的是英文句子中间卡着一对 - * 中文括号。顺带把"英文栏里混进日文假名"这种复制粘贴也一并挡住。 */ + * 中文括号. 顺带把"英文栏里混进日文假名"这种复制粘贴也一并挡住. */ static int has_cjk(const char *s) { for (const unsigned char *p = (const unsigned char *)s; p && *p; p++) { @@ -106,8 +106,8 @@ int main(void) CHECK(!has_cjk(m->en ? m->en : ""), "%s 的英文里混进了中日韩字符", m->key); - /* key 唯一性:比较是 O(n²),但每个 key 只记一条检查 —— 写成循环里 - * CHECK 的话,check-docs 数出来的"单元项数"就没意义了。 */ + /* 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; @@ -116,7 +116,7 @@ int main(void) char pe[16][32], pz[16][32]; int ne = placeholders(m->en ? m->en : "", pe, 16); int nz = placeholders(m->zh ? m->zh : "", pz, 16); - CHECK(ne == nz, "%s 两种语言的占位符数量不同(en %d / zh %d)", + CHECK(ne == nz, "%s 两种语言的占位符数量不同 (en %d / zh %d)", m->key, ne, nz); for (int a = 0; a < ne && a < nz; a++) { int found = 0; @@ -124,7 +124,7 @@ int main(void) if (strcmp(pe[a], pz[b]) == 0) found = 1; 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++) @@ -133,21 +133,21 @@ int main(void) } } - /* 表里有一部分键是**当 printf 模板用的**(repl.usage.*、repl.model.row、 - * json.* …):调用方直接把 t(key) 交给 printf/snprintf。这些条目的 - * {name} 检查管不到,得单独比转换符 —— 少一个 %s 会读到别的参数, - * 类型写错(%d 对 %zu)更是直接踩内存。 */ + /* 表里有一部分键是**当 printf 模板用的** (repl.usage.*、repl.model.row、 + * json.* …):调用方直接把 t(key) 交给 printf/snprintf. 这些条目的 + * {name} 检查管不到, 得单独比转换符 —— 少一个 %s 会读到别的参数, + * 类型写错 (%d 对 %zu)更是直接踩内存. */ 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); + if (ce == 0 && cz == 0) continue; /* 不是模板, 跳过 */ + 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]); } @@ -162,7 +162,7 @@ int main(void) CHECK(i18n_lang_name()[0] == 'e', "i18n_lang_name"); CHECK(strstr(t("cli.err.unknown_option"), "unknown") != NULL, "英文那条"); CHECK(strncmp(t("no.such.key"), "[?", 2) == 0, - "漏掉的键要显眼地露出来(实际 %s),不能变成空白", t("no.such.key")); + "漏掉的键要显眼地露出来 (实际 %s), 不能变成空白", t("no.such.key")); puts("占位符替换按名字走"); i18n_set_lang(LIGENT_LANG_EN); @@ -170,11 +170,11 @@ int main(void) CHECK(strstr(r, "--nope") != NULL, "值要填进去"); CHECK(strstr(r, "{option}") == NULL, "填过的占位符不该留下"); CHECK(strstr(tf("cli.err.unknown_option", "wrong", "x", NULL), "{option}") != NULL, - "名字对不上时原样保留,别静默吞掉"); + "名字对不上时原样保留, 别静默吞掉"); i18n_set_lang(LIGENT_LANG_ZH); CHECK(strstr(tf("cli.err.unknown_option", "option", "选项X", NULL), "选项X") != NULL, "中文模板同样能填"); - /* 同一句里多个占位符,且顺序与调用顺序相反 */ + /* 同一句里多个占位符, 且顺序与调用顺序相反 */ i18n_set_lang(LIGENT_LANG_EN); const char *two = tf("doctor.val.match_bad", "guess", "OpenAI", "owner", "DeepSeek", NULL); @@ -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"); @@ -214,6 +214,6 @@ int main(void) unsetenv("LANG"); CHECK(i18n_lang_from_env() == LIGENT_LANG_EN, "清干净之后仍是英文"); - printf("\n%d 项检查,%d 项失败\n", checks, failures); + printf("\n%d 项检查, %d 项失败\n", checks, failures); return failures ? 1 : 0; } diff --git a/tests/test_image.c b/tests/test_image.c index 6b5d5fa..4b0eb83 100644 --- a/tests/test_image.c +++ b/tests/test_image.c @@ -30,7 +30,7 @@ static const char WEBP[] = "RIFF\x00\x00\x00\x00WEBPVP8 "; static void test_sniff(void) { - puts("按文件内容认格式(不看扩展名)"); + puts("按文件内容认格式 (不看扩展名)"); const char *ext = NULL, *mime = NULL; CHECK(image_sniff((const unsigned char *)PNG, 14, &ext, &mime) == 0 && @@ -44,7 +44,7 @@ static void test_sniff(void) CHECK(image_sniff((const unsigned char *)WEBP, 16, &ext, &mime) == 0 && strcmp(ext, "webp") == 0 && strcmp(mime, "image/webp") == 0, "WebP"); - /* 认不出来要明确失败,别猜一个格式报上去 */ + /* 认不出来要明确失败, 别猜一个格式报上去 */ CHECK(image_sniff((const unsigned char *)"not an image", 12, &ext, &mime) != 0, "杂文本不该被认成图片"); CHECK(image_sniff((const unsigned char *)"\x89PNG", 4, &ext, &mime) != 0, @@ -82,7 +82,7 @@ static void test_backup(void) CHECK(back && len == n && memcmp(back, data, n) == 0, "备份内容应逐字节一致"); free(back); free(p1); - /* 再存一次应该拿到不同的名字(随机后缀),而不是互相覆盖 */ + /* 再存一次应该拿到不同的名字 (随机后缀), 而不是互相覆盖 */ char *b2 = image_backup_save(dir, data, n, "png"); CHECK(b2 && strcmp(b1, b2) != 0, "两次备份不该同号:%s / %s", b1, b2 ? b2 : "(null)"); @@ -112,21 +112,21 @@ static void test_base64(void) size_t n = strlen(cases[i].in); char *got = base64_encode((const unsigned char *)cases[i].in, n); CHECK(got && strcmp(got, cases[i].want) == 0, - "\"%s\" 应编码成 %s,实际 %s", cases[i].in, cases[i].want, + "\"%s\" 应编码成 %s, 实际 %s", cases[i].in, cases[i].want, got ? got : "(null)"); CHECK(base64_encoded_len(n) == strlen(cases[i].want), - "长度函数应与实际一致(%zu)", n); + "长度函数应与实际一致 (%zu)", n); free(got); } - /* 二进制字节(0x00 / 0xFF 这类不能被当字符串处理) */ + /* 二进制字节 (0x00 / 0xFF 这类不能被当字符串处理) */ const unsigned char bin[] = { 0x00, 0xFF, 0x10, 0x80 }; char *b = base64_encode(bin, sizeof bin); - CHECK(b && strcmp(b, "AP8QgA==") == 0, "二进制应正确编码,实际 %s", + CHECK(b && strcmp(b, "AP8QgA==") == 0, "二进制应正确编码, 实际 %s", b ? b : "(null)"); free(b); } -/* 造一张内容合法(靠文件头就能嗅探出来)的假图,返回字节数 */ +/* 造一张内容合法 (靠文件头就能嗅探出来)的假图, 返回字节数 */ static size_t fake_png(unsigned char *out, size_t n) { static const char magic[] = "\x89PNG\r\n\x1a\n"; @@ -137,7 +137,7 @@ static size_t fake_png(unsigned char *out, size_t n) return n; } -/* 把一段备份引用塞进一条 user 消息,模拟 read_image 之后的历史 */ +/* 把一段备份引用塞进一条 user 消息, 模拟 read_image 之后的历史 */ static const char *ref_message_type = "ligent_image"; static JsonValue *history_with_ref(const char *backup) @@ -191,18 +191,18 @@ static void test_expand(void) "附件消息应有一个 content 块"); JsonValue *wp = json_array_get(wc, 0); CHECK(strcmp(json_get_string(wp, "type"), "image_url") == 0, - "发送形态的类型应是 image_url,实际 %s", + "发送形态的类型应是 image_url, 实际 %s", json_get_string(wp, "type")); JsonValue *iu = json_object_get(wp, "image_url"); const char *url = iu ? json_get_string(iu, "url") : NULL; CHECK(url && strncmp(url, "data:image/png;base64,", 22) == 0, - "应是 data: URL,实际 %.40s", url ? url : "(null)"); + "应是 data: URL, 实际 %.40s", url ? url : "(null)"); - /* base64 段应能解回原字节 —— 这里只验长度与首尾,避免在测试里再写一个解码器 */ + /* base64 段应能解回原字节 —— 这里只验长度与首尾, 避免在测试里再写一个解码器 */ 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"); @@ -223,23 +223,23 @@ static void test_expand(void) CHECK(strcmp(json_get_string(wp2, "type"), "text") == 0, "发送形态应降级成 text 块"); const char *dt = json_get_string(wp2, "text"); - CHECK(dt && strstr(dt, "无法交给模型") != NULL, "应说明原因,实际 %s", + CHECK(dt && strstr(dt, "无法交给模型") != NULL, "应说明原因, 实际 %s", dt ? dt : "(null)"); json_free(wire); - /* 降级是**就地**的:原历史里的坏引用应该已经被换掉,否则每轮都会重试 */ + /* 降级是**就地**的:原历史里的坏引用应该已经被换掉, 否则每轮都会重试 */ char *after = json_dump(msgs); CHECK(strstr(after, "ligent_image") == NULL, "原历史里的坏引用应已被清掉"); CHECK(strstr(after, "ligent_note") != NULL, "应换成内部说明块"); free(after); - /* 再来一次不该重复降级(历史已经干净了) */ + /* 再来一次不该重复降级 (历史已经干净了) */ degraded = 0; wire = image_expand_for_request(msgs, °raded); 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); @@ -247,13 +247,13 @@ static void test_expand(void) JsonValue *m2 = history_with_ref(backup); CHECK(image_has_refs(m2) == 1, "有引用时应报 1"); int dn = image_degrade_all(m2); - CHECK(dn == 1, "应降级 1 处,实际 %d", dn); + CHECK(dn == 1, "应降级 1 处, 实际 %d", dn); CHECK(image_has_refs(m2) == 0, "降级后不该还有引用"); JsonValue *c2 = json_object_get(json_array_get(m2, 1), "content"); const char *t2 = json_get_string(json_array_get(c2, 0), "text"); - /* 给模型的话必须是能直接照做的中文,而不是服务端的英文报错 */ + /* 给模型的话必须是能直接照做的中文, 而不是服务端的英文报错 */ CHECK(t2 && strstr(t2, "不符合 API 所需的格式标准") != NULL, - "应说明格式不符,实际 %s", t2 ? t2 : "(null)"); + "应说明格式不符, 实际 %s", t2 ? t2 : "(null)"); CHECK(t2 && strstr(t2, "请规范化图片") != NULL, "应给出下一步动作"); CHECK(t2 && strchr(t2, '\n') == NULL, "说明不该带换行"); json_free(m2); @@ -290,7 +290,7 @@ static void test_pending(void) if (arr && json_array_size(arr) == 2) { JsonValue *p0 = json_array_get(arr, 0); CHECK(strcmp(json_get_string(p0, "type"), "ligent_image") == 0, - "类型应是内部引用块,不是协议块"); + "类型应是内部引用块, 不是协议块"); CHECK(strcmp(json_get_string(p0, "backup"), "1700000000_aaaa.png") == 0, "第一个备份名"); CHECK(json_get_string(p0, "file_id") == NULL, @@ -313,6 +313,6 @@ int main(void) test_base64(); test_expand(); test_pending(); - printf("\n%d 项检查,%d 项失败\n", checks, failures); + printf("\n%d 项检查, %d 项失败\n", checks, failures); return failures ? 1 : 0; } diff --git a/tests/test_json.c b/tests/test_json.c index b728350..1562f1e 100644 --- a/tests/test_json.c +++ b/tests/test_json.c @@ -80,8 +80,8 @@ static void test_numbers(void) json_free(v); /* 严格按 RFC 8259 扫描数字:这些以前全都被接受 —— strtod 会把 "1e" 读成 1、 - * "5." 读成 5,于是一个手写错的数字会被静默当成另一个数用下去(配置文件、 - * 存档都是手写的,用户只会在很久之后从一个不相干的现象里发现它)。 */ + * "5." 读成 5, 于是一个手写错的数字会被静默当成另一个数用下去 (配置文件、 + * 存档都是手写的, 用户只会在很久之后从一个不相干的现象里发现它). */ const char *bad_nums[] = { "01", "-01", "5.", ".5", "+1", "1e", "1e+", "1e-", "1.2e", "-", "1_000", }; @@ -167,7 +167,7 @@ static void test_nested_depth(void) json_free(ok); } -/* 报错必须能定位:只有"语法错了"而不知道错在哪,等于没有报错 */ +/* 报错必须能定位:只有"语法错了"而不知道错在哪, 等于没有报错 */ static void test_error_location(void) { puts("test_error_location"); @@ -220,17 +220,17 @@ static void test_error_location(void) json_free(v); } -/* 序列化出去的东西必须是**合法 UTF-8**。 +/* 序列化出去的东西必须是**合法 UTF-8**. * * 服务端会因为请求体里有非法 UTF-8 直接回 * 400 ... invalid unicode code point - * 而那段坏数据一旦进了对话历史,之后每次请求都会失败 —— 除非丢掉整轮对话。 + * 而那段坏数据一旦进了对话历史, 之后每次请求都会失败 —— 除非丢掉整轮对话. * 坏字节的来源很日常:某条命令在非 UTF-8 locale 下打出来的路径、被截断的多 - * 字节字符、二进制垃圾。所以这条不能只靠"在入口清理",必须在序列化器这层兜住: - * 出口是唯一能保证"发出去的永远合法"的地方。 + * 字节字符、二进制垃圾. 所以这条不能只靠"在入口清理", 必须在序列化器这层兜住: + * 出口是唯一能保证"发出去的永远合法"的地方. * - * 这个校验函数故意用 utf8_seq_len 自己走一遍,而不是信任 dump 的实现 —— - * 断言的是**产物**的性质,不是实现的自述。 */ + * 这个校验函数故意用 utf8_seq_len 自己走一遍, 而不是信任 dump 的实现 —— + * 断言的是**产物**的性质, 不是实现的自述. */ static int utf8_all_valid(const char *s) { size_t n = strlen(s), i = 0; @@ -260,11 +260,11 @@ static void test_dump_utf8_safety(void) CHECK(utf8_seq_len("\xf5\x80\x80\x80", 4) == 0, "超出 U+10FFFF 应非法"); CHECK(utf8_seq_len("\xe4\x41\x42", 3) == 0, "续字节格式不对应非法"); - /* 各种坏字节经 json_dump 之后,整份输出必须是合法 UTF-8 */ + /* 各种坏字节经 json_dump 之后, 整份输出必须是合法 UTF-8 */ static const char *bad[] = { "a\x80" "b", /* 孤立续字节 */ "a\xe4\xb8" "b", /* 截断的 3 字节序列 */ - "a\xc0\xaf" "b", /* 过长编码(历史上被用来夹带 '/') */ + "a\xc0\xaf" "b", /* 过长编码 (历史上被用来夹带 '/') */ "a\xed\xa0\x80" "b", /* UTF-16 代理区 */ "a\xf5\x80\x80\x80" "b", /* 超出 U+10FFFF */ "\xff\xfe", /* 二进制垃圾 */ @@ -275,7 +275,7 @@ static void test_dump_utf8_safety(void) json_object_set_string(o, "content", bad[k]); char *d = json_dump(o); CHECK(utf8_all_valid(d), "第 %zu 个坏输入序列化后仍含非法 UTF-8: %s", k, d); - /* 而且我们的解析器(和服务端一样严格)必须收得下 */ + /* 而且我们的解析器 (和服务端一样严格)必须收得下 */ char err[256]; JsonValue *back = json_parse(d, err, sizeof err); CHECK(back != NULL, "第 %zu 个输出无法解析回来: %s", k, err); @@ -310,6 +310,6 @@ int main(void) test_nested_depth(); test_error_location(); test_dump_utf8_safety(); - printf("\n%d 项检查,%d 项失败\n", checks, failures); + printf("\n%d 项检查, %d 项失败\n", checks, failures); return failures ? 1 : 0; } diff --git a/tests/test_lineedit.c b/tests/test_lineedit.c index 9097f36..8ea6c36 100644 --- a/tests/test_lineedit.c +++ b/tests/test_lineedit.c @@ -1,4 +1,4 @@ -/* test_lineedit.c - 行编辑器的缓冲区逻辑与历史记录(不需要终端) */ +/* test_lineedit.c - 行编辑器的缓冲区逻辑与历史记录 (不需要终端) */ #include "lineedit.h" #include "util.h" @@ -23,9 +23,9 @@ static int checks = 0; #define BUFEQ(lb, want) \ CHECK(strcmp((lb).buf, (want)) == 0 && (lb).len == strlen(want), \ - "内容应为 [%s],实际 [%s]", want, (lb).buf) + "内容应为 [%s], 实际 [%s]", want, (lb).buf) -/* 读一个文本文件的前 n 字节到 buf(用于检查落盘格式) */ +/* 读一个文本文件的前 n 字节到 buf (用于检查落盘格式) */ static void slurp(const char *path, char *buf, size_t buflen) { buf[0] = '\0'; @@ -52,7 +52,7 @@ static void test_history_file(void) CHECK(line_history_append(path, "") == 0, "空串不写文件"); CHECK(access(path, F_OK) != 0, "空串不该把文件创建出来"); - /* 追加两条,再读回来 */ + /* 追加两条, 再读回来 */ CHECK(line_history_add("echo hello") == 1, "第一次应记下"); line_history_append(path, "echo hello"); CHECK(line_history_add("echo hello") == 0, "与上一条重复应被忽略"); @@ -62,11 +62,11 @@ static void test_history_file(void) char raw[512]; slurp(path, raw, sizeof raw); CHECK(strcmp(raw, "echo hello\n/think high\n") == 0, - "普通命令应原样一行,实际 %s", raw); + "普通命令应原样一行, 实际 %s", raw); line_history_clear(); CHECK(line_history_load(path, 100) == 0, "加载应成功"); - CHECK(line_history_len() == 2, "应读回 2 条,实际 %zu", line_history_len()); + CHECK(line_history_len() == 2, "应读回 2 条, 实际 %zu", line_history_len()); CHECK(strcmp(line_history_get(0), "echo hello") == 0, "第 0 条应还原"); CHECK(strcmp(line_history_get(1), "/think high") == 0, "第 1 条应还原"); line_history_clear(); @@ -82,22 +82,22 @@ static void test_history_file(void) slurp(path, raw, sizeof raw); CHECK(strchr(raw, '\n') != NULL && strstr(raw, "\n\n") == NULL, - "每条只占一行,实际 %s", raw); + "每条只占一行, 实际 %s", raw); int newlines = 0; for (const char *p = raw; *p; p++) if (*p == '\n') newlines++; - CHECK(newlines == 3, "3 条历史应占 3 行,实际 %d 行", newlines); + CHECK(newlines == 3, "3 条历史应占 3 行, 实际 %d 行", newlines); line_history_clear(); line_history_load(path, 100); - CHECK(line_history_len() == 3, "应读回 3 条,实际 %zu", line_history_len()); + CHECK(line_history_len() == 3, "应读回 3 条, 实际 %zu", line_history_len()); CHECK(strcmp(line_history_get(0), "echo C:\\Users\\me") == 0, - "反斜杠应原样还原,实际 [%s]", line_history_get(0)); + "反斜杠应原样还原, 实际 [%s]", line_history_get(0)); CHECK(line_history_get(1) && strcmp(line_history_get(1), "第一行\n第二行") == 0, - "多行条目应整条还原,实际 [%s]", line_history_get(1) ? line_history_get(1) : "(null)"); + "多行条目应整条还原, 实际 [%s]", line_history_get(1) ? line_history_get(1) : "(null)"); 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); @@ -108,13 +108,13 @@ static void test_history_file(void) int lines = 0; slurp(path, raw, sizeof raw); for (const char *p = raw; *p; p++) if (*p == '\n') lines++; - CHECK(lines == 10, "截短前应有 10 行,实际 %d", lines); + CHECK(lines == 10, "截短前应有 10 行, 实际 %d", lines); line_history_clear(); line_history_load(path, 4); - CHECK(line_history_len() == 4, "keep=4 时应只剩 4 条,实际 %zu", line_history_len()); + CHECK(line_history_len() == 4, "keep=4 时应只剩 4 条, 实际 %zu", line_history_len()); CHECK(line_history_get(0) && strcmp(line_history_get(0), "cmd-6") == 0, - "应保留最新的 4 条,第一条应是 cmd-6,实际 [%s]", + "应保留最新的 4 条, 第一条应是 cmd-6, 实际 [%s]", line_history_get(0) ? line_history_get(0) : "(null)"); CHECK(line_history_get(3) && strcmp(line_history_get(3), "cmd-9") == 0, "最后一条应是 cmd-9"); @@ -123,12 +123,12 @@ static void test_history_file(void) slurp(path, raw, sizeof raw); lines = 0; for (const char *p = raw; *p; p++) if (*p == '\n') lines++; - CHECK(lines == 4, "文件应被就地截短成 4 行,实际 %d", lines); + CHECK(lines == 4, "文件应被就地截短成 4 行, 实际 %d", lines); /* 截短后的文件读回来还是那 4 条 */ line_history_clear(); line_history_load(path, 100); - CHECK(line_history_len() == 4, "截短后应只读到 4 条,实际 %zu", line_history_len()); + CHECK(line_history_len() == 4, "截短后应只读到 4 条, 实际 %zu", line_history_len()); line_history_clear(); unlink(path); @@ -144,7 +144,7 @@ int main(void) linebuf_insert(&lb, "hello", 5); BUFEQ(lb, "hello"); - CHECK(lb.pos == 5, "插入后光标应在末尾,实际 %zu", lb.pos); + CHECK(lb.pos == 5, "插入后光标应在末尾, 实际 %zu", lb.pos); /* 在中间插入 */ linebuf_home(&lb); @@ -153,7 +153,7 @@ int main(void) linebuf_right(&lb); linebuf_insert(&lb, "XY", 2); BUFEQ(lb, "heXYllo"); - CHECK(lb.pos == 4, "插入后光标应后移,实际 %zu", lb.pos); + CHECK(lb.pos == 4, "插入后光标应后移, 实际 %zu", lb.pos); /* 退格删的是光标前的字符 */ linebuf_backspace(&lb); @@ -171,20 +171,20 @@ int main(void) puts("UTF-8:按字符而非字节移动"); linebuf_init(&lb); linebuf_set(&lb, "中文abc"); /* 中(3) 文(3) a b c = 9 字节 */ - CHECK(lb.len == 9, "应为 9 字节,实际 %zu", lb.len); + CHECK(lb.len == 9, "应为 9 字节, 实际 %zu", lb.len); linebuf_left(&lb); - CHECK(lb.pos == 8, "左移一格应为 8,实际 %zu", lb.pos); + CHECK(lb.pos == 8, "左移一格应为 8, 实际 %zu", lb.pos); linebuf_left(&lb); linebuf_left(&lb); linebuf_left(&lb); - CHECK(lb.pos == 3, "再左移三格应停在第二个汉字之后,实际 %zu", lb.pos); + CHECK(lb.pos == 3, "再左移三格应停在第二个汉字之后, 实际 %zu", lb.pos); linebuf_left(&lb); linebuf_left(&lb); - CHECK(lb.pos == 0, "应能移到行首,实际 %zu", lb.pos); + CHECK(lb.pos == 0, "应能移到行首, 实际 %zu", lb.pos); linebuf_left(&lb); CHECK(lb.pos == 0, "行首再左移不应越界"); - /* 退格必须整字删除,不能留下半个字符 */ + /* 退格必须整字删除, 不能留下半个字符 */ linebuf_end(&lb); linebuf_backspace(&lb); BUFEQ(lb, "中文ab"); @@ -205,10 +205,10 @@ int main(void) linebuf_right(&lb); /* 光标到 'a' 之后 */ linebuf_delete(&lb); /* 删掉 '中' */ BUFEQ(lb, "ab"); - CHECK(lb.pos == 1, "删除后光标不动,实际 %zu", lb.pos); + CHECK(lb.pos == 1, "删除后光标不动, 实际 %zu", lb.pos); linebuf_free(&lb); - puts("Home / End 走的是逻辑行,不是整个缓冲区"); + puts("Home / End 走的是逻辑行, 不是整个缓冲区"); linebuf_init(&lb); linebuf_set(&lb, "one two three"); linebuf_home(&lb); @@ -216,24 +216,24 @@ 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); + CHECK(lb.len == 11, "应为 11 字节, 实际 %zu", lb.len); linebuf_home(&lb); - CHECK(lb.pos == 8, "末尾按 Home 应到最后一行的行首(8),实际 %zu", lb.pos); + CHECK(lb.pos == 8, "末尾按 Home 应到最后一行的行首 (8), 实际 %zu", lb.pos); linebuf_end(&lb); - CHECK(lb.pos == 11, "End 应到本行行尾(11),实际 %zu", lb.pos); + CHECK(lb.pos == 11, "End 应到本行行尾 (11), 实际 %zu", lb.pos); lb.pos = 5; /* 落在 "bbb" 中间 */ linebuf_home(&lb); - CHECK(lb.pos == 4, "中间行按 Home 应到该行行首(4),实际 %zu", lb.pos); + CHECK(lb.pos == 4, "中间行按 Home 应到该行行首 (4), 实际 %zu", lb.pos); linebuf_end(&lb); - CHECK(lb.pos == 7, "中间行按 End 应到该行行尾(7),实际 %zu", lb.pos); + CHECK(lb.pos == 7, "中间行按 End 应到该行行尾 (7), 实际 %zu", lb.pos); lb.pos = 4; /* 正好在行首 */ linebuf_home(&lb); CHECK(lb.pos == 4, "已在行首时 Home 不应跳到整个缓冲区开头"); - lb.pos = 7; /* 正好在行尾(换行符前) */ + lb.pos = 7; /* 正好在行尾 (换行符前) */ linebuf_end(&lb); CHECK(lb.pos == 7, "已在行尾时 End 不应跳过换行符"); @@ -260,9 +260,9 @@ int main(void) CHECK(line_history_len() == 0, "初始应为空"); line_history_add("first"); line_history_add("second"); - line_history_add("second"); /* 与上一条重复,应忽略 */ + line_history_add("second"); /* 与上一条重复, 应忽略 */ line_history_add(""); /* 空行不入历史 */ - CHECK(line_history_len() == 2, "应有 2 条,实际 %zu", line_history_len()); + CHECK(line_history_len() == 2, "应有 2 条, 实际 %zu", line_history_len()); CHECK(strcmp(line_history_get(0), "first") == 0, "第 0 条"); CHECK(strcmp(line_history_get(1), "second") == 0, "第 1 条"); CHECK(line_history_get(2) == NULL, "越界应返回 NULL"); @@ -274,6 +274,6 @@ int main(void) test_history_file(); - printf("\n%d 项检查,%d 项失败\n", checks, failures); + printf("\n%d 项检查, %d 项失败\n", checks, failures); return failures ? 1 : 0; } diff --git a/tests/test_markdown.c b/tests/test_markdown.c index 90cfe94..52440a1 100644 --- a/tests/test_markdown.c +++ b/tests/test_markdown.c @@ -1,7 +1,7 @@ /* test_markdown.c - 流式 Markdown 渲染器测试 * - * 最关键的不变量:把同一段文本任意切分后逐片喂入,输出必须与一次性喂入 - * 完全一致。SSE 的分片位置是随机的,这条不成立就会漏出"半个星号"。 + * 最关键的不变量:把同一段文本任意切分后逐片喂入, 输出必须与一次性喂入 + * 完全一致. SSE 的分片位置是随机的, 这条不成立就会漏出"半个星号". */ #include "markdown.h" #include "util.h" @@ -24,7 +24,7 @@ static int checks = 0; } \ } while (0) -/* 去掉 ANSI 转义,得到"肉眼可见"的文本 */ +/* 去掉 ANSI 转义, 得到"肉眼可见"的文本 */ static char *strip_ansi(const char *s) { StrBuf out; @@ -50,7 +50,7 @@ static void show_escaped(const char *s) } } -/* 渲染到内存流;chunk == 0 表示一次性喂入 */ +/* 渲染到内存流; chunk == 0 表示一次性喂入 */ static char *do_render(const char *input, int enabled, size_t chunk) { char *buf = NULL; @@ -88,7 +88,7 @@ static char *render(const char *in, int enabled) static void test_split_invariance(void) { - puts("分片不变性(一次性 / 3 字节 / 逐字节 必须一致)"); + puts("分片不变性 (一次性 / 3 字节 / 逐字节 必须一致)"); static const char *samples[] = { "**粗体**\n", "普通 *斜体* 文本\n", @@ -121,8 +121,8 @@ static void test_split_invariance(void) "```\n# 不是标题\n[x](y)\n```\n", "ls -la | grep foo\n", "a | b\nc | d\n", - /* 以下三个是模糊测试找到并最小化出来的历史缺陷, - * 每个都曾让"一次性喂"与"分片喂"结果不一致,留作回归见证 */ + /* 以下三个是模糊测试找到并最小化出来的历史缺陷, + * 每个都曾让"一次性喂"与"分片喂"结果不一致, 留作回归见证 */ "1|`", /* 表格探测挂起整行 -> 行内代码被漏渲染 */ "- |", /* try_block 提前 return 跳过了列表规则 */ "s|\n-", /* 表格判断依赖分片边界 */ @@ -196,7 +196,7 @@ static void test_bold_italic(void) CHECK(strcmp(o, "2 * 3 * 4\n") == 0, "乘法星号应原样输出: [%s]", o); free(o); - /* snake_case 不能被误伤(这是不实现 _斜体_ 的原因) */ + /* snake_case 不能被误伤 (这是不实现 _斜体_ 的原因) */ o = render("foo_bar_baz\n", 1); CHECK(strcmp(o, "foo_bar_baz\n") == 0, "下划线标识符不应被改动: [%s]", o); free(o); @@ -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, @@ -248,7 +248,7 @@ static void test_code(void) static void test_blocks(void) { puts("列表 / 引用 / 分隔线"); - /* 列表标记必须保留模型输出的原始字符,只上色,不换成 • */ + /* 列表标记必须保留模型输出的原始字符, 只上色, 不换成 • */ char *o = render("- 甲\n- 乙\n", 1); char *vis = strip_ansi(o); CHECK(strcmp(vis, "- 甲\n- 乙\n") == 0, "列表应保留原始 - : [%s]", vis); @@ -326,7 +326,7 @@ static void test_fence_verbatim(void) "代码块内容应完整: [%s]", o); free(o); - /* 代码块结束之后,普通文本要恢复渲染 */ + /* 代码块结束之后, 普通文本要恢复渲染 */ o = render("```\nx\n```\n**加粗**\n", 1); CHECK(strstr(o, "\033[1m加粗\033[0m") != NULL, "代码块之后应恢复渲染: [%s]", o); @@ -362,7 +362,7 @@ static void test_underscore_italic(void) CHECK(strcmp(vis, "这是 斜体 文字\n") == 0, "可见文本: [%s]", vis); free(vis); free(o); - /* 中文紧邻下划线也应生效(非 ASCII 不算词字符) */ + /* 中文紧邻下划线也应生效 (非 ASCII 不算词字符) */ o = render("中文_斜体_中文\n", 1); CHECK(strstr(o, "\033[3m斜体") != NULL, "中文紧邻应生效: [%s]", o); free(o); @@ -378,18 +378,18 @@ 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。 */ + /* 这里是下划线 + 青 (SGR 4;36), 不是亮蓝. 整个项目只用 16 色范围内的 + * 码, 理由见 src/markdown.c 顶部和 tools/check-colors.sh. */ CHECK(strstr(o, "\033[4;36m[文档]") != NULL, "[链接文字] 应下划线+青色高亮: [%s]", o); char *vis = strip_ansi(o); CHECK(strcmp(vis, "见 [文档](https://example.com/a) 说明\n") == 0, - "可见文本应逐字节保留(含 URL): [%s]", vis); + "可见文本应逐字节保留 (含 URL): [%s]", vis); free(vis); free(o); - /* 图片也是 [...],同样只高亮方括号部分,整段保留 */ + /* 图片也是 [...], 同样只高亮方括号部分, 整段保留 */ o = render("![图](a.png)\n", 1); vis = strip_ansi(o); CHECK(strcmp(vis, "![图](a.png)\n") == 0, "图片应保留原文: [%s]", vis); @@ -418,7 +418,7 @@ static void test_tables(void) CHECK(strstr(o, "\033[1m") != NULL, "表头应加粗: [%s]", o); free(vis); free(o); - /* 中文列宽按 2 列计算,边框必须对齐 */ + /* 中文列宽按 2 列计算, 边框必须对齐 */ o = render("| 名称 | 值 |\n|---|---|\n| 苹果 | 3 |\n", 1); vis = strip_ansi(o); const char *want2 = @@ -442,10 +442,10 @@ static void test_tables(void) CHECK(strcmp(vis, want3) == 0, "对齐表格:\n期望:\n%s\n实际:\n%s", want3, vis); free(vis); free(o); - /* 顶格必须写 '|'。不带首竖线的写法按普通文本处理 —— + /* 顶格必须写 '|'. 不带首竖线的写法按普通文本处理 —— * 这是为保证"输出与分片方式无关"而做的取舍: - * 判断必须在行的第 0 个字节就能做出,否则分片落在竖线之前时, - * 行的前半段已被当作普通文本输出,结果会随分片方式而变。 */ + * 判断必须在行的第 0 个字节就能做出, 否则分片落在竖线之前时, + * 行的前半段已被当作普通文本输出, 结果会随分片方式而变. */ o = render("a | b\n--- | ---\n1 | 2\n", 1); vis = strip_ansi(o); CHECK(strcmp(vis, "a | b\n--- | ---\n1 | 2\n") == 0, @@ -458,7 +458,7 @@ static void test_tables(void) 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); @@ -480,7 +480,7 @@ static void test_tables(void) CHECK(strcmp(o, "ls -la | grep foo\n") == 0, "管道命令不应成表: [%s]", o); free(o); - /* 表格后面紧跟普通文本,两者都要正常 */ + /* 表格后面紧跟普通文本, 两者都要正常 */ o = render("| a | b |\n|---|---|\n| 1 | 2 |\n后续段落 **粗体**\n", 1); vis = strip_ansi(o); CHECK(strstr(vis, "└───┴───┘\n后续段落 粗体\n") != NULL, @@ -498,7 +498,7 @@ static void test_long_line_bounded(void) for (int i = 0; i < 200000; i++) sb_putc(&sb, 'x'); char *o = render(sb.data, 1); size_t n = strlen(o); - CHECK(n > 100000, "应该已经把内容吐出,而不是无限缓存(实际 %zu 字节)", n); + CHECK(n > 100000, "应该已经把内容吐出, 而不是无限缓存 (实际 %zu 字节)", n); free(o); sb_free(&sb); } @@ -517,6 +517,6 @@ int main(void) test_tables(); test_passthrough(); test_long_line_bounded(); - printf("\n%d 项检查,%d 项失败\n", checks, failures); + printf("\n%d 项检查, %d 项失败\n", checks, failures); return failures ? 1 : 0; } diff --git a/tests/test_meta.c b/tests/test_meta.c index dfb3fee..5b1e1bd 100644 --- a/tests/test_meta.c +++ b/tests/test_meta.c @@ -70,7 +70,7 @@ static JsonValue *sample(void) json_array_push(a, msg("tool", "file1\nfile2")); json_array_push(a, msg("assistant", "写好了")); json_array_push(a, msg("user", "改用 C")); - json_array_push(a, msg("assistant", "好的,改成 C")); + json_array_push(a, msg("assistant", "好的, 改成 C")); json_array_push(a, msg("user", "加个注释")); json_array_push(a, msg("assistant", "加好了")); return a; @@ -82,11 +82,11 @@ static void test_rounds(void) JsonValue *m = sample(); MetaRounds r; CHECK(meta_rounds_build(m, &r) == 1, "应该能切出轮次"); - CHECK(r.head == 1, "system 之后的 #1 才是第一条可编辑消息,实际 %zu", r.head); - CHECK(r.count == 3, "应切出 3 轮,实际 %zu", r.count); - CHECK(r.starts[0] == 1, "轮 1 从 #1 开始,实际 %zu", r.starts[0]); - CHECK(r.starts[1] == 5, "轮 2 从 #5 开始,实际 %zu", r.starts[1]); - CHECK(r.starts[2] == 7, "轮 3 从 #7 开始,实际 %zu", r.starts[2]); + CHECK(r.head == 1, "system 之后的 #1 才是第一条可编辑消息, 实际 %zu", r.head); + CHECK(r.count == 3, "应切出 3 轮, 实际 %zu", r.count); + CHECK(r.starts[0] == 1, "轮 1 从 #1 开始, 实际 %zu", r.starts[0]); + CHECK(r.starts[1] == 5, "轮 2 从 #5 开始, 实际 %zu", r.starts[1]); + CHECK(r.starts[2] == 7, "轮 3 从 #7 开始, 实际 %zu", r.starts[2]); meta_rounds_free(&r); /* 没有 system 也要能切 */ @@ -94,7 +94,7 @@ static void test_rounds(void) json_array_push(n, msg("user", "x")); json_array_push(n, msg("assistant", "y")); CHECK(meta_rounds_build(n, &r) == 1, "没有 system 也能切"); - CHECK(r.head == 0 && r.count == 1, "1 轮,从头开始"); + CHECK(r.head == 0 && r.count == 1, "1 轮, 从头开始"); meta_rounds_free(&r); json_free(n); @@ -106,13 +106,13 @@ static void test_rounds(void) json_free(only); /* read_image 补的那条"只有图片附件、没有文字"的 user 消息**不算新的一轮** —— - * 判据必须与主循环(agent_opens_round)一致,否则 /meta 报的轮号会和主循环的 - * 口径对不上,模型照轮号删下去,删掉的不是它以为的那一轮。 + * 判据必须与主循环 (agent_opens_round)一致, 否则 /meta 报的轮号会和主循环的 + * 口径对不上, 模型照轮号删下去, 删掉的不是它以为的那一轮. * 结构: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", "看图")); - json_array_push(withimg, msg("assistant", "ok")); /* 占位,模拟带 tool_calls 的轮内消息 */ + json_array_push(withimg, msg("assistant", "ok")); /* 占位, 模拟带 tool_calls 的轮内消息 */ json_array_push(withimg, msg("tool", "结果")); { JsonValue *att = json_new_object(); @@ -126,7 +126,7 @@ static void test_rounds(void) } json_array_push(withimg, msg("assistant", "看到图了")); CHECK(meta_rounds_build(withimg, &r) == 1, "带附件的历史也应该能切"); - CHECK(r.count == 1, "只有附件的那条 user 不该算新的一轮,实际 %zu 轮", r.count); + CHECK(r.count == 1, "只有附件的那条 user 不该算新的一轮, 实际 %zu 轮", r.count); meta_rounds_free(&r); json_free(withimg); @@ -159,7 +159,7 @@ static char *call(Agent *a, const char *name, const char *json) return r; } -/* 把一段历史挂上,跑 body,再摘下来 */ +/* 把一段历史挂上, 跑 body, 再摘下来 */ #define WITH_TOOLS(a) \ do { \ CHECK(meta_tools_attach(a) == 0, "attach 应成功"); \ @@ -223,13 +223,13 @@ 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(strstr(r, "没有对应的提问") != NULL, "删掉提问后应提示后续回答失去上下文"); @@ -298,7 +298,7 @@ static void test_insert_and_undo(void) free(r); /* 撤销应回到插入前 */ - CHECK(meta_tools_ops() == 1, "应有 1 次改动,实际 %zu", meta_tools_ops()); + CHECK(meta_tools_ops() == 1, "应有 1 次改动, 实际 %zu", meta_tools_ops()); r = call(&a, "undo_last_edit", "{}"); CHECK(r && strstr(r, "已撤销") != NULL, "撤销应成功"); CHECK(json_array_size(a.messages) == before, "撤销后条数应还原"); @@ -315,7 +315,7 @@ static void test_insert_and_undo(void) static void test_read_tools(void) { - puts("list_rounds / read_messages(只读)"); + puts("list_rounds / read_messages (只读)"); Agent a; agent_init(&a); json_free(a.messages); a.messages = sample(); meta_tools_attach(&a); @@ -333,7 +333,7 @@ static void test_read_tools(void) r = call(&a, "read_messages", "{\"from\":2,\"to\":3}"); CHECK(r && strstr(r, "run_command") != NULL, "应能看到工具调用"); CHECK(r && strstr(r, "file1") != NULL, "应能看到工具结果"); - CHECK(r && strstr(r, "结构消息,不可单删") != NULL, "结构消息应有标注"); + CHECK(r && strstr(r, "结构消息, 不可单删") != NULL, "结构消息应有标注"); free(r); CHECK(meta_tools_mutating("list_rounds") == 0, "list_rounds 不是写操作"); @@ -359,7 +359,7 @@ static void test_reroot_flag(void) CHECK(meta_reroot_pending() == 0, "初始不该有标记"); meta_tools_attach(&a); - char *r = call(&a, "list_rounds", "{}"); /* 只读,不该打标记 */ + char *r = call(&a, "list_rounds", "{}"); /* 只读, 不该打标记 */ free(r); CHECK(meta_reroot_pending() == 0, "只读操作不该打标记"); @@ -382,6 +382,6 @@ int main(void) test_delete_rounds(); test_insert_and_undo(); test_reroot_flag(); - printf("\n%d 项检查,%d 项失败\n", checks, failures); + printf("\n%d 项检查, %d 项失败\n", checks, failures); return failures ? 1 : 0; } diff --git a/tests/test_models.c b/tests/test_models.c index 74ca673..41c6a9e 100644 --- a/tests/test_models.c +++ b/tests/test_models.c @@ -1,12 +1,12 @@ /* test_models.c - /models 响应体的解析 * - * 只测纯解析。拉取(GET {base_url}/models)要走网络,由 e2e 第 52 段对着 - * mock 覆盖,那里还顺带验了交互式选择本身(方向键、确认、取消)。 + * 只测纯解析. 拉取 (GET {base_url}/models)要走网络, 由 e2e 第 52 段对着 + * mock 覆盖, 那里还顺带验了交互式选择本身 (方向键、确认、取消). * - * 为什么要容忍这么多种形状:OpenAI 兼容是个很大的家庭,/models 的返回花样 - * 比文档里写的多 —— 见过裸数组、见过重复列同一模型、见过元素里没有 id。 - * 解析器要么跳过,要么去重,但**不能**因此整个失败:列表里少一个模型远好过 - * 一个都选不了。 + * 为什么要容忍这么多种形状:OpenAI 兼容是个很大的家庭, /models 的返回花样 + * 比文档里写的多 —— 见过裸数组、见过重复列同一模型、见过元素里没有 id. + * 解析器要么跳过, 要么去重, 但**不能**因此整个失败:列表里少一个模型远好过 + * 一个都选不了. */ #include "models.h" @@ -37,9 +37,9 @@ static void test_standard(void) "]}"; size_t n = 0; char **l = models_parse(json, &n); - CHECK(l && n == 2, "应解析出 2 个,实际 %zu", n); + CHECK(l && n == 2, "应解析出 2 个, 实际 %zu", n); if (l && n == 2) { - CHECK(strcmp(l[0], "alpha") == 0, "顺序应保持服务端给的(第一个 alpha)"); + CHECK(strcmp(l[0], "alpha") == 0, "顺序应保持服务端给的 (第一个 alpha)"); CHECK(strcmp(l[1], "beta") == 0, "第二个 beta"); } models_free(l, n); @@ -47,7 +47,7 @@ static void test_standard(void) static void test_dedupe_and_skip(void) { - puts("重复项要去重,缺 id 的要跳过"); + puts("重复项要去重, 缺 id 的要跳过"); const char *json = "{\"data\":[" "{\"id\":\"a\"},{\"id\":\"a\"},{\"object\":\"model\"}," @@ -55,7 +55,7 @@ static void test_dedupe_and_skip(void) "]}"; size_t n = 0; char **l = models_parse(json, &n); - CHECK(l && n == 2, "去重 + 跳过后应剩 2 个,实际 %zu", n); + CHECK(l && n == 2, "去重 + 跳过后应剩 2 个, 实际 %zu", n); if (l && n == 2) { CHECK(strcmp(l[0], "a") == 0, "第一个是 a"); CHECK(strcmp(l[1], "b") == 0, "第二个是 b"); @@ -65,7 +65,7 @@ static void test_dedupe_and_skip(void) static void test_bare_array(void) { - puts("兼容裸数组(字符串 / 对象都认)"); + puts("兼容裸数组 (字符串 / 对象都认)"); size_t n = 0; char **l = models_parse("[\"alpha\",\"beta\"]", &n); CHECK(l && n == 2 && strcmp(l[1], "beta") == 0, "字符串数组应能解析"); @@ -79,7 +79,7 @@ static void test_bare_array(void) static void test_bad_input(void) { - puts("拿不到模型时返回 NULL,而不是空数组或崩溃"); + puts("拿不到模型时返回 NULL, 而不是空数组或崩溃"); size_t n = 123; CHECK(models_parse(NULL, &n) == NULL && n == 0, "NULL 输入"); n = 123; @@ -99,7 +99,7 @@ static void test_bad_input(void) CHECK(models_parse("{\"data\":[{\"object\":\"model\"}]}", &n) == NULL && n == 0, "全是坏条目"); - /* out_n 也可为 NULL(调用方只关心数组) */ + /* out_n 也可为 NULL (调用方只关心数组) */ char **l = models_parse("{\"data\":[{\"id\":\"x\"}]}", NULL); CHECK(l != NULL, "out_n 为 NULL 也要能工作"); models_free(l, 1); @@ -109,7 +109,7 @@ static void test_bad_input(void) "空的 data 应判为空列表"); CHECK(models_is_empty_list("[]") == 1, "空的裸数组也是空列表"); CHECK(models_is_empty_list("not json") == 0, - "解析不了不算空列表(是两回事)"); + "解析不了不算空列表 (是两回事)"); CHECK(models_is_empty_list("{\"data\":\"nope\"}") == 0, "data 不是数组不算空列表"); CHECK(models_is_empty_list("{\"data\":[{\"id\":\"a\"}]}") == 0, @@ -128,6 +128,6 @@ int main(void) test_dedupe_and_skip(); test_bare_array(); test_bad_input(); - printf("\n%d 项检查,%d 项失败\n", checks, failures); + printf("\n%d 项检查, %d 项失败\n", checks, failures); return failures ? 1 : 0; } diff --git a/tests/test_priv.c b/tests/test_priv.c index 9df35bd..7bcbd59 100644 --- a/tests/test_priv.c +++ b/tests/test_priv.c @@ -1,12 +1,12 @@ /* test_priv.c - 提权模块里那几条"不能搞错"的不变量 * - * 这个模块摔过一跤:上一版把认证路径也 setsid + 接管道,于是 sudo 拿不到 - * 控制终端,真机上认证 100% 失败(而替身 sudo 无条件成功,给了个假绿)。 + * 这个模块摔过一跤:上一版把认证路径也 setsid + 接管道, 于是 sudo 拿不到 + * 控制终端, 真机上认证 100% 失败 (而替身 sudo 无条件成功, 给了个假绿). * 所以这里把两条硬不变量钉住: * - * 1. **提权调用里不能有 -n**。带 -n 就等于"不许提问",需要密码时直接失败, - * 而用户要的恰恰是"提权要有身份验证"。 - * 2. **没有控制终端时能识别出来**。识别不出来就会起一个等不到输入的进程。 + * 1. **提权调用里不能有 -n**. 带 -n 就等于"不许提问", 需要密码时直接失败, + * 而用户要的恰恰是"提权要有身份验证". + * 2. **没有控制终端时能识别出来**. 识别不出来就会起一个等不到输入的进程. */ #include "priv.h" #include "util.h" @@ -41,32 +41,32 @@ static int argv_has(char *const *a, const char *want) int main(void) { puts("包装成 sudo -u <用户> -- "); - /* 显式转换,和 priv.c 里 w[0] = (char *)"sudo" 是同一个理由: - * priv_sudo_argv 的签名要 char *const[](execvp 的要求),而这里放进去的 - * 字面量没有任何人会改写。把整条链路改成非 const 才是真的丢 const 正确性。 */ + /* 显式转换, 和 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 }; char **w = priv_sudo_argv("root", plain); CHECK(w != NULL, "应当返回数组"); - CHECK(strcmp(w[0], "sudo") == 0, "第一个是 sudo,实际 %s", w[0]); + CHECK(strcmp(w[0], "sudo") == 0, "第一个是 sudo, 实际 %s", w[0]); CHECK(strcmp(w[1], "-u") == 0 && strcmp(w[2], "root") == 0, "-u <用户> 紧跟其后"); CHECK(strcmp(w[3], "--") == 0, "必须有 -- :否则命令里的选项会被 sudo 当成自己的"); CHECK(strcmp(w[4], "/bin/sh") == 0 && strcmp(w[5], "-c") == 0 && strcmp(w[6], "rm -rf /tmp/x") == 0, - "原 argv 原样接在后面,不被拆开"); + "原 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"); w = priv_sudo_argv(NULL, plain); - CHECK(strcmp(w[2], "root") == 0, "空用户应落到 root,实际 %s", w[2]); + CHECK(strcmp(w[2], "root") == 0, "空用户应落到 root, 实际 %s", w[2]); free(w); w = priv_sudo_argv("", plain); CHECK(strcmp(w[2], "root") == 0, "空串也应落到 root"); @@ -76,18 +76,18 @@ int main(void) char err[512]; memset(err, 0, sizeof err); priv_explain(1, "sudo: no tty present and no askpass program specified", err, sizeof err); - CHECK(strstr(err, "终端") != NULL, "缺终端要说终端,实际 %s", err); + CHECK(strstr(err, "终端") != NULL, "缺终端要说终端, 实际 %s", err); priv_explain(1, "Sorry, try again.", err, sizeof err); - CHECK(strstr(err, "认证") != NULL, "认证失败要说认证,实际 %s", err); + CHECK(strstr(err, "认证") != NULL, "认证失败要说认证, 实际 %s", err); priv_explain(1, "pluv is not in the sudoers file.", err, sizeof err); CHECK(strstr(err, "sudo") != NULL && strstr(err, "权限") != NULL, - "没有 sudo 权限要说清楚,实际 %s", err); - /* 认不出来就原样透传:宁可给原文,也不编一句可能不对的解释 */ + "没有 sudo 权限要说清楚, 实际 %s", err); + /* 认不出来就原样透传:宁可给原文, 也不编一句可能不对的解释 */ priv_explain(1, "some brand new message", err, sizeof err); CHECK(strstr(err, "some brand new message") != NULL, - "认不出来要透传原文,实际 %s", err); + "认不出来要透传原文, 实际 %s", err); priv_explain(127, "无法执行 sudo:No such file or directory", err, sizeof err); - CHECK(strstr(err, "没有 sudo") != NULL, "没有 sudo 时要说清楚,实际 %s", err); + CHECK(strstr(err, "没有 sudo") != NULL, "没有 sudo 时要说清楚, 实际 %s", err); puts("没有控制终端时能识别出来"); int fds[2]; @@ -111,8 +111,8 @@ int main(void) close(fds[0]); while (waitpid(pid, NULL, 0) < 0) { } CHECK(rd == (ssize_t)sizeof got && got == 0, - "setsid() 之后不该认为有终端(识别不出来就会卡住)"); + "setsid() 之后不该认为有终端 (识别不出来就会卡住)"); - printf("\n%d 项检查,%d 项失败\n", checks, failures); + printf("\n%d 项检查, %d 项失败\n", checks, failures); return failures ? 1 : 0; } diff --git a/tests/test_spinner.c b/tests/test_spinner.c index 3226f91..2ffc324 100644 --- a/tests/test_spinner.c +++ b/tests/test_spinner.c @@ -1,7 +1,7 @@ /* test_spinner.c - 等待动画的两段式标签 * - * 动画写的是 stderr,所以这里把 stderr 挪到一个临时文件,驱动几轮之后 - * 再把内容读回来断言。spinner_init(1) 是显式开关,测试里不依赖 isatty。 + * 动画写的是 stderr, 所以这里把 stderr 挪到一个临时文件, 驱动几轮之后 + * 再把内容读回来断言. spinner_init(1) 是显式开关, 测试里不依赖 isatty. */ #include "spinner.h" @@ -25,8 +25,8 @@ static int checks = 0; } \ } while (0) -/* 把 stderr 接到一个临时文件上,返回文件描述符;*saved 收原来的 stderr。 - * 用完必须调用 capture_end 还原,否则后面的失败信息也会被吞掉。 */ +/* 把 stderr 接到一个临时文件上, 返回文件描述符; *saved 收原来的 stderr. + * 用完必须调用 capture_end 还原, 否则后面的失败信息也会被吞掉. */ static int capture_begin(char *path, size_t pathlen, int *saved) { snprintf(path, pathlen, "/tmp/ligent_spin_XXXXXX"); @@ -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("思考中"); @@ -76,9 +76,9 @@ static void test_two_phase(void) const char *a = strstr(buf, "等待响应"); const char *b = strstr(buf, "思考中"); - CHECK(a != NULL, "应打印\"等待响应\",实际 [%.120s]", buf); - CHECK(b != NULL, "应打印\"思考中\",实际 [%.120s]", buf); - CHECK(a && b && a < b, "先\"等待响应\"后\"思考中\",顺序反了"); + CHECK(a != NULL, "应打印\"等待响应\", 实际 [%.120s]", buf); + CHECK(b != NULL, "应打印\"思考中\", 实际 [%.120s]", buf); + CHECK(a && b && a < b, "先\"等待响应\"后\"思考中\", 顺序反了"); CHECK(strstr(buf, "\r\033[K") != NULL, "每帧应先回车清行再打印"); /* 停在"思考中"之后就不该再有"等待响应"冒出来 */ if (b) CHECK(strstr(b, "等待响应") == NULL, "换标签后不应回退到\"等待响应\""); @@ -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:幂等 */ spinner_stop(); - /* start 两次:第二次忽略,不该起第二个线程 */ + /* start 两次:第二次忽略, 不该起第二个线程 */ spinner_start("等待响应"); spinner_start("思考中"); usleep(300 * 1000); @@ -111,7 +111,7 @@ static void test_idempotent(void) unlink(path); CHECK(strstr(buf, "等待响应") != NULL, "start 后应开始转"); CHECK(strstr(buf, "思考中") == NULL, - "已在转时再次 start 应被忽略,标签不该被改掉"); + "已在转时再次 start 应被忽略, 标签不该被改掉"); /* enabled = 0 时全部是空操作:连重定向都不需要 */ spinner_init(0); @@ -128,12 +128,12 @@ static double now_sec(void) return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9; } -/* spinner_stop 要 pthread_join worker。如果 worker 是 nanosleep 一整帧, +/* spinner_stop 要 pthread_join worker. 如果 worker 是 nanosleep 一整帧, * 这里就得干等最多 250ms —— 而这笔账会加在每一轮模型往返、每一次工具 - * 来回的用户感知延迟上。 */ + * 来回的用户感知延迟上. */ static void test_stop_is_prompt(void) { - puts("stop 不该等满一帧(250ms)"); + puts("stop 不该等满一帧 (250ms)"); char path[64]; int saved = -1; int fd = capture_begin(path, sizeof path, &saved); @@ -153,16 +153,16 @@ static void test_stop_is_prompt(void) char buf[4096]; capture_end(fd, saved, buf, sizeof buf); unlink(path); - CHECK(worst < 0.10, "stop 应立即返回,最慢一次 %.3fs(帧间隔是 0.25s)", worst); + CHECK(worst < 0.10, "stop 应立即返回, 最慢一次 %.3fs (帧间隔是 0.25s)", worst); } -/* 帧率必须受 250ms 限制。曾经踩过一次:pthread_cond_timedwait 默认按 - * CLOCK_REALTIME 解释超时,而传进去的是 CLOCK_MONOTONIC 的绝对时间 - * (开机时长永远小于 epoch 时间),于是超时永远"已经过了" —— worker - * 变成疯狂重绘的忙循环,把 pty 刷屏。 */ +/* 帧率必须受 250ms 限制. 曾经踩过一次:pthread_cond_timedwait 默认按 + * CLOCK_REALTIME 解释超时, 而传进去的是 CLOCK_MONOTONIC 的绝对时间 + * (开机时长永远小于 epoch 时间), 于是超时永远"已经过了" —— worker + * 变成疯狂重绘的忙循环, 把 pty 刷屏. */ static void test_frame_rate_is_bounded(void) { - puts("帧率受 250ms 限制,不能狂刷"); + puts("帧率受 250ms 限制, 不能狂刷"); char path[64]; int saved = -1; int fd = capture_begin(path, sizeof path, &saved); @@ -180,14 +180,14 @@ static void test_frame_rate_is_bounded(void) int frames = 0; for (const char *p = buf; (p = strstr(p, "等待响应")); p++) frames++; - CHECK(frames >= 1, "至少该画一帧,实际 %d", frames); - CHECK(frames <= 5, "0.6 秒内不该超过 5 帧,实际 %d(忙循环?)", frames); + CHECK(frames >= 1, "至少该画一帧, 实际 %d", frames); + CHECK(frames <= 5, "0.6 秒内不该超过 5 帧, 实际 %d (忙循环?)", frames); } -/* spinner_notice 会先停、打印一条提示、再**接着转**。恢复时它传的是 g_label - * 自己 —— 而 spinner_start 曾经先执行 g_label[0] = '\0' 再读那个 label,于是 - * 标签被自己清空、退化成默认的"思考中"。症状:每次 HTTP 重试退避之后,屏幕上 - * 的"等待响应"都会变成"思考中",而这两段等待的含义恰恰是它要区分的。 */ +/* spinner_notice 会先停、打印一条提示、再**接着转**. 恢复时它传的是 g_label + * 自己 —— 而 spinner_start 曾经先执行 g_label[0] = '\0' 再读那个 label, 于是 + * 标签被自己清空、退化成默认的"思考中". 症状:每次 HTTP 重试退避之后, 屏幕上 + * 的"等待响应"都会变成"思考中", 而这两段等待的含义恰恰是它要区分的. */ static void test_notice_keeps_label(void) { puts("notice 之后标签保持不变"); @@ -209,11 +209,11 @@ static void test_notice_keeps_label(void) unlink(path); const char *noticed = strstr(buf, "一条插进来的提示"); - CHECK(noticed != NULL, "提示本身应该打出来,实际 [%.200s]", buf); + CHECK(noticed != NULL, "提示本身应该打出来, 实际 [%.200s]", buf); /* 提示之后剩下的帧里必须还是原来那个标签 */ if (noticed) CHECK(strstr(noticed, "自定义标签A") != NULL, - "notice 之后标签被改写了(应保持\"自定义标签A\"):\n%.300s", + "notice 之后标签被改写了 (应保持\"自定义标签A\"):\n%.300s", noticed); } @@ -224,6 +224,6 @@ int main(void) test_stop_is_prompt(); test_frame_rate_is_bounded(); test_notice_keeps_label(); - printf("\n%d 项检查,%d 项失败\n", checks, failures); + printf("\n%d 项检查, %d 项失败\n", checks, failures); return failures ? 1 : 0; } diff --git a/tests/test_tools.c b/tests/test_tools.c index 921a1df..d23addc 100644 --- a/tests/test_tools.c +++ b/tests/test_tools.c @@ -47,7 +47,7 @@ int main(void) /* ---- 工具 schema 合法性 ---- */ puts("tools_schema"); JsonValue *schema = tools_schema(); - CHECK(json_array_size(schema) == 6, "应有 6 个工具,实际 %zu", + CHECK(json_array_size(schema) == 6, "应有 6 个工具, 实际 %zu", json_array_size(schema)); for (size_t i = 0; i < json_array_size(schema); i++) { JsonValue *t = json_array_get(schema, i); @@ -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; @@ -82,21 +82,21 @@ int main(void) reqd = json_object_get(par, "required"); } } - /* line 是可选的(省略 = 全文件替换),不该出现在 required 里 */ + /* line 是可选的 (省略 = 全文件替换), 不该出现在 required 里 */ int line_required = 0; for (size_t i = 0; i < json_array_size(reqd); i++) { const char *nm = json_string_value(json_array_get(reqd, i)); if (nm && strcmp(nm, "line") == 0) line_required = 1; } - CHECK(!line_required, "line 可以省略(= 全文件替换),不该是 required"); + CHECK(!line_required, "line 可以省略 (= 全文件替换), 不该是 required"); JsonValue *line = props ? json_object_get(props, "line") : NULL; const char *lt = line ? json_get_string(line, "type") : NULL; - CHECK(lt && strcmp(lt, "array") == 0, "edit_file 的 line 应是 array,实际 %s", + CHECK(lt && strcmp(lt, "array") == 0, "edit_file 的 line 应是 array, 实际 %s", lt ? lt : "(缺)"); JsonValue *items = line ? json_object_get(line, "items") : NULL; const char *it = items ? json_get_string(items, "type") : NULL; - CHECK(it && strcmp(it, "integer") == 0, "line 的元素应是 integer,实际 %s", + CHECK(it && strcmp(it, "integer") == 0, "line 的元素应是 integer, 实际 %s", it ? it : "(缺)"); } @@ -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); @@ -189,14 +189,14 @@ int main(void) free(r); /* 唯一性只要求**在这一行里**唯一:同一个词出现在别的行不该拦住替换 - * (这正是"全文唯一"最烦人的地方——文件一长,随便一个短串都不唯一) */ + * (这正是"全文唯一"最烦人的地方——文件一长, 随便一个短串都不唯一) */ r = call("edit_file", "{\"path\":\"sub/dir/a.txt\",\"line\":1,\"old_string\":\"line\"," "\"new_string\":\"LINE\"}", &err); - CHECK(err == 0, "同一行里唯一即可,别的行的重复不该拦: %s", r); + CHECK(err == 0, "同一行里唯一即可, 别的行的重复不该拦: %s", r); free(r); - /* 同一行里出现两处 → 仍然拒绝(行内也要定得下来) */ + /* 同一行里出现两处 → 仍然拒绝 (行内也要定得下来) */ r = call("write_file", "{\"path\":\"dup.txt\",\"content\":\"foo foo\\n\"}", &err); CHECK(err == 0, "写入 dup.txt: %s", r); free(r); @@ -207,7 +207,7 @@ int main(void) "同一行里多匹配应拒绝: %s", r); free(r); - /* replace_all 仍然换全文件,但 line 必须指向其中真实的一处 */ + /* replace_all 仍然换全文件, 但 line 必须指向其中真实的一处 */ r = call("edit_file", "{\"path\":\"dup.txt\",\"line\":1,\"old_string\":\"foo\",\"new_string\":\"bar\"," "\"replace_all\":true}", &err); @@ -224,15 +224,15 @@ int main(void) free(r); /* ---- edit_file:line 是集合型 ---- - * 集合是**搜索范围**(old_string 的起点落在其中任一行即可),不是 - * "每行都改一次":真改动的仍是唯一那一处。 */ + * 集合是**搜索范围** (old_string 的起点落在其中任一行即可), 不是 + * "每行都改一次":真改动的仍是唯一那一处. */ 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); @@ -243,17 +243,17 @@ int main(void) CHECK(strstr(r, "AAA") != NULL && !strstr(r, "aaa"), "替换应落盘: %s", r); free(r); - /* old_string 跨行时,把覆盖到的几行都填进集合——不必赌起点是第几行 */ + /* old_string 跨行时, 把覆盖到的几行都填进集合——不必赌起点是第几行 */ r = call("edit_file", "{\"path\":\"set.txt\",\"line\":[2,3],\"old_string\":\"bbb\\nccc\"," "\"new_string\":\"B\\nC\"}", &err); - CHECK(err == 0, "跨行替换(集合给两行)应成功: %s", r); + CHECK(err == 0, "跨行替换 (集合给两行)应成功: %s", r); free(r); r = call("read_file", "{\"path\":\"set.txt\"}", &err); CHECK(strstr(r, "B") && strstr(r, "C") && !strstr(r, "bbb"), "跨行替换应落盘: %s", r); free(r); - /* 集合里有多处匹配 → 仍然拒绝(集合不是"每行改一次"的授权) */ + /* 集合里有多处匹配 → 仍然拒绝 (集合不是"每行改一次"的授权) */ r = call("write_file", "{\"path\":\"two.txt\",\"content\":\"foo\\nx\\nfoo\\n\"}", &err); CHECK(err == 0, "写入 two.txt: %s", r); free(r); @@ -271,10 +271,10 @@ int main(void) r = call("edit_file", "{\"path\":\"dup3.txt\",\"line\":[1,1],\"old_string\":\"foo\"," "\"new_string\":\"bar\"}", &err); - CHECK(err == 0, "重复行号应去重,而不是当成两处: %s", r); + CHECK(err == 0, "重复行号应去重, 而不是当成两处: %s", r); free(r); - /* 集合里有一行越界 → 和单个行号时一样,报出文件总行数 */ + /* 集合里有一行越界 → 和单个行号时一样, 报出文件总行数 */ r = call("edit_file", "{\"path\":\"set.txt\",\"line\":[1,99],\"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,9 +348,9 @@ int main(void) CHECK(err == 1, "不存在的目录应报错: %s", r); free(r); - /* 这两个工具都已经删掉:搜索交给模型自己用 run_command 里的真 grep, - * 子代理(spawn_subagent)整个功能也去掉了。留着断言是为了钉住 - * "不要再冒出一个半吊子的替代品"。 */ + /* 这两个工具都已经删掉:搜索交给模型自己用 run_command 里的真 grep, + * 子代理 (spawn_subagent)整个功能也去掉了. 留着断言是为了钉住 + * "不要再冒出一个半吊子的替代品". */ r = call("grep", "{\"pattern\":\"x\",\"path\":\".\"}", &err); CHECK(err == 1 && strstr(r, "未知工具") != NULL, "不该再有 grep 工具: %s", r); free(r); @@ -361,10 +361,10 @@ int main(void) /* ---- 超大文件不能把内存吃干 ---- */ puts("超大文件防护"); { - /* 稀疏文件,创建几乎不耗时;关键是客户端要在读之前就发现它太大。 - * 开头 16 KB 写成 'x':这样它是"文本"(探测的前 8 KB 里没有 NUL), + /* 稀疏文件, 创建几乎不耗时; 关键是客户端要在读之前就发现它太大. + * 开头 16 KB 写成 'x':这样它是"文本" (探测的前 8 KB 里没有 NUL), * 否则连 read_file 都会先被二进制检测拦掉 —— 这个用例就测不到 - * "分段读大文件"了。 */ + * "分段读大文件"了. */ FILE *big = fopen("huge.txt", "wb"); CHECK(big != NULL, "创建测试文件失败"); if (big) { @@ -382,14 +382,14 @@ int main(void) CHECK(strstr(r, "上限") != NULL, "应说明是超上限: %.120s", r); free(r); - /* read_file 是分段读的,应当照常可用 */ + /* read_file 是分段读的, 应当照常可用 */ r = call("read_file", "{\"path\":\"huge.txt\",\"limit\":1}", &err); CHECK(err == 0, "分段读大文件应当可用: %.120s", r); free(r); unlink("huge.txt"); } - /* ---- 二进制文件:所有处理文本的工具都必须拒绝(而且要说明该用什么) ---- */ + /* ---- 二进制文件:所有处理文本的工具都必须拒绝 (而且要说明该用什么) ---- */ puts("二进制拒绝"); { FILE *bf = fopen("bin.dat", "wb"); @@ -434,23 +434,23 @@ int main(void) CHECK(err == 0 && strstr(r, "第 2 行") != NULL, "回执带行号: %s", r); free(r); - /* ---- 哪些工具会改动磁盘(决定是否弹确认) ---- */ + /* ---- 哪些工具会改动磁盘 (决定是否弹确认) ---- */ puts("写操作分类"); - CHECK(tool_mutating("run_command") == 1, "run_command 会执行命令,必须确认"); - CHECK(tool_mutating("write_file") == 1, "write_file 会写盘,必须确认"); - CHECK(tool_mutating("edit_file") == 1, "edit_file 会改盘,必须确认"); - CHECK(tool_mutating("read_file") == 0, "read_file 只读,不该打扰"); - CHECK(tool_mutating("list_dir") == 0, "list_dir 只读,不该打扰"); - /* read_image 会在 ligent 自己的目录里留备份(副作用), - * 但不改用户的任何东西 —— 它是"读",不该每次都拦一道 */ - CHECK(tool_mutating("read_image") == 0, "read_image 不改用户的文件,不该打扰"); + CHECK(tool_mutating("run_command") == 1, "run_command 会执行命令, 必须确认"); + CHECK(tool_mutating("write_file") == 1, "write_file 会写盘, 必须确认"); + CHECK(tool_mutating("edit_file") == 1, "edit_file 会改盘, 必须确认"); + CHECK(tool_mutating("read_file") == 0, "read_file 只读, 不该打扰"); + CHECK(tool_mutating("list_dir") == 0, "list_dir 只读, 不该打扰"); + /* read_image 会在 ligent 自己的目录里留备份 (副作用), + * 但不改用户的任何东西 —— 它是"读", 不该每次都拦一道 */ + CHECK(tool_mutating("read_image") == 0, "read_image 不改用户的文件, 不该打扰"); CHECK(tool_mutating("没有这个工具") == 0, "未知工具按只读处理"); CHECK(tool_mutating(NULL) == 0, "空名字不应崩"); /* ---- read_image:只收真的能内联进去的图 ---- * - * 两条拒绝路径是新的:格式不认识、体积超限。它们都必须**不备份** —— - * 一张交不出去的图,留备份只会让 ligent.archive 变成垃圾场。 */ + * 两条拒绝路径是新的:格式不认识、体积超限. 它们都必须**不备份** —— + * 一张交不出去的图, 留备份只会让 ligent.archive 变成垃圾场. */ puts("read_image:本地就把不合格的图拦掉"); { char btmp[] = "/tmp/ligent_tools_img_XXXXXX"; @@ -458,7 +458,7 @@ int main(void) CHECK(bdir != NULL, "建备份临时目录失败"); if (bdir) { image_set_bindir(bdir); - image_pending_take(); /* 清干净,免得上一段残留 */ + image_pending_take(); /* 清干净, 免得上一段残留 */ /* 1) 内容是文本、扩展名骗人 */ FILE *f = fopen("notanimage.png", "wb"); @@ -469,8 +469,8 @@ int main(void) CHECK(strstr(r, "按文件内容判断") != NULL, "应说明是看内容判断的"); free(r); - /* 2) 体积超限:base64 后超过 IMAGE_MAX_B64。 - * 4/3 * 原始大小 = 编码后大小,所以原始要 > 3/4 * 上限。 */ + /* 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"); if (f) { @@ -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); @@ -513,7 +513,7 @@ int main(void) int cnt = -1; if (p) { if (fscanf(p, "%d", &cnt) != 1) cnt = -1; pclose(p); } free(ls); - CHECK(cnt == 1, "只该留 1 份备份(成功那张),实际 %d", cnt); + CHECK(cnt == 1, "只该留 1 份备份 (成功那张), 实际 %d", cnt); image_set_bindir("."); } @@ -521,10 +521,10 @@ int main(void) /* ---- 超长单行:read_file 报出的行号必须与 edit_file 的口径一致 ---- * - * read_file 以前用 fgets + 固定 64 KB 缓冲,一行比缓冲长时会被切成"多行"、 - * 每块各带一个行号。于是模型按 read_file 看到的行号去 edit_file 改,要么改错 - * 地方、要么报越界 —— 而它没有任何办法察觉。工具描述和 README 都写着 - * "read_file 打出来的行号可以直接喂给 edit_file 的 line",这里把它钉住。 */ + * read_file 以前用 fgets + 固定 64 KB 缓冲, 一行比缓冲长时会被切成"多行"、 + * 每块各带一个行号. 于是模型按 read_file 看到的行号去 edit_file 改, 要么改错 + * 地方、要么报越界 —— 而它没有任何办法察觉. 工具描述和 README 都写着 + * "read_file 打出来的行号可以直接喂给 edit_file 的 line", 这里把它钉住. */ puts("超长单行的行号口径"); { FILE *lf = fopen("longline.txt", "wb"); @@ -537,12 +537,12 @@ 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); - /* 拿 read_file 报出的行号去 edit_file 改,必须命中同一处 */ + /* 拿 read_file 报出的行号去 edit_file 改, 必须命中同一处 */ r = call("edit_file", "{\"path\":\"longline.txt\",\"line\":3," "\"old_string\":\"third line\",\"new_string\":\"THIRD LINE\"}", &err); @@ -553,8 +553,8 @@ 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); fputc('\n', hf); @@ -574,7 +574,7 @@ int main(void) if (mkdir("emptydir", 0755) != 0) { /* 可能已存在 */ } r = call("list_dir", "{\"path\":\"emptydir\"}", &err); CHECK(err == 0 && strstr(r, "0 个条目") != NULL, - "空目录应报 0 个条目,实际 %s", r); + "空目录应报 0 个条目, 实际 %s", r); free(r); rmdir("emptydir"); @@ -588,10 +588,10 @@ int main(void) r = call("run_command", "{\"command\":\"head -c 300000 /dev/zero | tr '\\\\0' 'a'\"}", &err); CHECK(strlen(r) <= LIGENT_TOOL_OUTPUT_LIMIT + 200, - "输出应被截断,实际 %zu 字节", strlen(r)); + "输出应被截断, 实际 %zu 字节", strlen(r)); CHECK(strstr(r, "截断") != NULL, "应提示已截断"); free(r); - printf("\n%d 项检查,%d 项失败\n", checks, failures); + printf("\n%d 项检查, %d 项失败\n", checks, failures); return failures ? 1 : 0; } diff --git a/tools/build-static-deps.sh b/tools/build-static-deps.sh index 5a47c2f..48ec300 100755 --- a/tools/build-static-deps.sh +++ b/tools/build-static-deps.sh @@ -1,11 +1,11 @@ #!/bin/sh -# build-static-deps.sh - 造一份"musl 工具链编出来的静态 libcurl",给 make static 用。 +# build-static-deps.sh - 造一份"musl 工具链编出来的静态 libcurl", 给 make static 用. # -# 为什么需要它:发行版的 libcurl.a 是针对 glibc 编的,跟 musl 混link会得到一堆 -# "undefined reference to __libc_start_main"。所以必须自己用 musl 工具链把 -# libcurl 和它的依赖(zlib、OpenSSL)编一遍。 +# 为什么需要它:发行版的 libcurl.a 是针对 glibc 编的, 跟 musl 混link会得到一堆 +# "undefined reference to __libc_start_main". 所以必须自己用 musl 工具链把 +# libcurl 和它的依赖 (zlib、OpenSSL)编一遍. # -# 产物在 build/static-prefix/,之后 `make static` 就能链了。 +# 产物在 build/static-prefix/, 之后 `make static` 就能链了. # # 用法: # ./tools/build-static-deps.sh # 用默认版本 @@ -13,8 +13,8 @@ # CC=x86_64-linux-musl-gcc ./tools/build-static-deps.sh # PREFIX=/opt/musl-static ./tools/build-static-deps.sh # -# 需要: musl 工具链、make、perl(OpenSSL 的 Configure 是 perl 写的)、以及 -# **能联网**(要下载三个源码包)。构建大约几分钟。 +# 需要: musl 工具链、make、perl (OpenSSL 的 Configure 是 perl 写的)、以及 +# **能联网** (要下载三个源码包). 构建大约几分钟. set -eu ZLIB_VER=${ZLIB:-1.3.1} @@ -26,14 +26,14 @@ PREFIX=${PREFIX:-$ROOT/build/static-prefix} SRC=${SRC:-$ROOT/build/static-src} JOBS=${JOBS:-$( (nproc 2>/dev/null) || echo 2 )} -# musl-gcc 是包装脚本;交叉工具链要自己指定 +# musl-gcc 是包装脚本; 交叉工具链要自己指定 if [ -z "${CC:-}" ]; then if command -v musl-gcc >/dev/null 2>&1; then CC=musl-gcc elif command -v x86_64-linux-musl-gcc >/dev/null 2>&1; then CC=x86_64-linux-musl-gcc else - echo "找不到 musl 工具链。" >&2 + echo "找不到 musl 工具链. " >&2 echo " Artix/Arch: sudo pacman -S musl # 提供 musl-gcc" >&2 echo " 或指定: CC=x86_64-linux-musl-gcc $0" >&2 exit 1 @@ -41,23 +41,23 @@ if [ -z "${CC:-}" ]; then fi need() { - command -v "$1" >/dev/null 2>&1 || { echo "缺少 $1,装一下" >&2; exit 1; } + command -v "$1" >/dev/null 2>&1 || { echo "缺少 $1, 装一下" >&2; exit 1; } } need make need "$CC" need perl # OpenSSL 的 Configure -# 下载靠它。以前这里写的是 `need curl || true` —— 那是**假的容错**:need 失败时 -# 直接 exit,`|| true` 根本执行不到(那句注释"下面会再确认"也不成立)。于是缺 -# curl 时脚本会一路跑到 fetch,最后报"所有候选地址都不可用 / 要么是没网,要么 -# 是版本号写错了",把人引去查网络和版本号 —— 而真正的原因是工具没装。 +# 下载靠它. 以前这里写的是 `need curl || true` —— 那是**假的容错**:need 失败时 +# 直接 exit, `|| true` 根本执行不到 (那句注释"下面会再确认"也不成立). 于是缺 +# curl 时脚本会一路跑到 fetch, 最后报"所有候选地址都不可用 / 要么是没网, 要么 +# 是版本号写错了", 把人引去查网络和版本号 —— 而真正的原因是工具没装. need curl need tar # ---------------------------------------------------------------- 架构 -# OpenSSL 的 Configure 要一个明确的 target 名,它**不做**自动探测。以前这里 -# 写死 linux-x86_64,于是在 aarch64/riscv64 上会去编 x86 的汇编,直接失败 —— -# 而这个脚本的产物恰恰是"拷到别的机器也能跑"的静态件。 -# 认不出来时**不猜**:猜错的后果是编出一堆看着像成功、其实不对的东西。 +# OpenSSL 的 Configure 要一个明确的 target 名, 它**不做**自动探测. 以前这里 +# 写死 linux-x86_64, 于是在 aarch64/riscv64 上会去编 x86 的汇编, 直接失败 —— +# 而这个脚本的产物恰恰是"拷到别的机器也能跑"的静态件. +# 认不出来时**不猜**:猜错的后果是编出一堆看着像成功、其实不对的东西. OPENSSL_TARGET=${OPENSSL_TARGET:-} if [ -z "$OPENSSL_TARGET" ]; then case "$(uname -m)" in @@ -70,57 +70,57 @@ if [ -z "$OPENSSL_TARGET" ]; then s390x) OPENSSL_TARGET=linux64-s390x ;; loongarch64) OPENSSL_TARGET=linux64-loongarch64 ;; # 3.1+ 才有 *) - echo "认不出这台机器的架构($(uname -m)),而 OpenSSL 的 Configure" >&2 - echo "需要一个明确的 target 名。请显式指定,例如:" >&2 + echo "认不出这台机器的架构 ($(uname -m)), 而 OpenSSL 的 Configure" >&2 + echo "需要一个明确的 target 名. 请显式指定, 例如:" >&2 echo " OPENSSL_TARGET=linux-aarch64 $0" >&2 - echo "(可用的 target 见 OpenSSL 源码里的 Configurations/10-main.conf)" >&2 + echo " (可用的 target 见 OpenSSL 源码里的 Configurations/10-main.conf)" >&2 exit 1 ;; esac fi -# 刻意**不带** -fno-pie。依赖库编成默认的 PIE 兼容代码就行 —— PIC 代码放进 -# 非 PIE 可执行文件是合法的,最后那一步由 ligent 自己用 -no-pie 决定。 -# 反过来(依赖 -fno-pie、链接却不带 -no-pie)会立刻炸:这些项目在 make 里 -# 还会顺手链几个测试程序,而那些链接用的是默认的 PIE,于是 +# 刻意**不带** -fno-pie. 依赖库编成默认的 PIE 兼容代码就行 —— PIC 代码放进 +# 非 PIE 可执行文件是合法的, 最后那一步由 ligent 自己用 -no-pie 决定. +# 反过来 (依赖 -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 版本(实测踩过)。 -# 体积优先。静态产物是"拷过去就能跑"的发布件,不是拿来调试的,所以这里的 -# 取舍和普通构建相反 —— 目标是越小越好。三组开关要一起上才有意义: +# 而且 zlib 的 configure 探测 vsnprintf 也会因此失败, 悄悄退化成不安全的 +# vsprintf 版本 (实测踩过). +# 体积优先. 静态产物是"拷过去就能跑"的发布件, 不是拿来调试的, 所以这里的 +# 取舍和普通构建相反 —— 目标是越小越好. 三组开关要一起上才有意义: # -Os 别做以体积换速度的展开/内联 -# -ffunction-sections 依赖库也按函数分段,这样最后链接时 +# -ffunction-sections 依赖库也按函数分段, 这样最后链接时 # -fdata-sections --gc-sections 才**丢得掉**没被引用的部分 -# -fno-*-unwind-tables C 代码不需要 C++ 异常和栈回溯,.eh_frame 全省 -# 实测(连同 Makefile 那侧的 strip 等一起):6.24 MB → 3.29 MB。 +# -fno-*-unwind-tables C 代码不需要 C++ 异常和栈回溯, .eh_frame 全省 +# 实测 (连同 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 就死在这一步: +# musl-gcc 只搜 /usr/lib/musl/include, **不搜 /usr/include**, 于是内核的 UAPI +# 头 (linux/mman.h、asm/*.h 这些)找不到 —— OpenSSL 就死在这一步: # fatal error: linux/mman.h: No such file or directory -# 用 -idirafter:musl 自己的头优先,/usr/include 只在 musl 没有时兜底。 -# 直接写 -I 会让 glibc 的头盖过 musl 的,那是另一个坑。 +# 用 -idirafter:musl 自己的头优先, /usr/include 只在 musl 没有时兜底. +# 直接写 -I 会让 glibc 的头盖过 musl 的, 那是另一个坑. UAPI_INC="-idirafter /usr/include" -# ---- configure 参数(抽出来是为了进 WANT,见下面的 stamp 机制)---- +# ---- configure 参数 (抽出来是为了进 WANT, 见下面的 stamp 机制)---- # -# 以前这些参数直接写在各自的 configure 调用处,于是"改了参数"这件事**只有人 -# 记得把 BUILD_DEFS 加一**才能被发现;忘了就继续用旧参数编出来的库,症状是 -# "我明明加了个 --disable-x,怎么产物一点没变"。 +# 以前这些参数直接写在各自的 configure 调用处, 于是"改了参数"这件事**只有人 +# 记得把 BUILD_DEFS 加一**才能被发现; 忘了就继续用旧参数编出来的库, 症状是 +# "我明明加了个 --disable-x, 怎么产物一点没变". # zlib 只要 --static:我们要的是 libz.a ZLIB_CONFIGURE_ARGS="--static" -# OpenSSL:no-shared 只出 .a;no-tests 少编一堆测试程序(省几分钟); -# no-legacy 去掉已淘汰的算法(体积)。 +# 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 一个都不用 +# --without-* 静态链接下每个"可选增强"都会再拖一串依赖进来 (libpsl、idn2、 +# brotli、zstd、nghttp2/3…), 而 ligent 一个都不用 # --with-ca-* 把 CA 路径编进去 —— 静态件跑到别的机器上时得知道去哪找根证书 CURL_CONFIGURE_ARGS=" --disable-shared --enable-static @@ -135,24 +135,24 @@ CURL_CONFIGURE_ARGS=" mkdir -p "$SRC" "$PREFIX" -# 编译选项变了就得把依赖整个重建。否则下面每个守卫都会看到 libz.a 已存在而 -# 直接跳过,于是你一直用着旧选项编出来的库,而且**看不出来** —— 症状是 -# "我明明加了 -Os,怎么产物一点没小"。 +# 编译选项变了就得把依赖整个重建. 否则下面每个守卫都会看到 libz.a 已存在而 +# 直接跳过, 于是你一直用着旧选项编出来的库, 而且**看不出来** —— 症状是 +# "我明明加了 -Os, 怎么产物一点没小". # 这和 Makefile 里"对象目录按编译器/插桩分标签"是同一个坑的两处:make 和这个 -# 脚本都只看文件在不在,看不见命令行变了。 +# 脚本都只看文件在不在, 看不见命令行变了. STAMP="$PREFIX/.build-flags" -# BUILD_DEFS:配方版本号,留给"改了脚本逻辑、但 WANT 里的文本一个字都没变"的 -# 改动(比如换了一种绕坑的处理方式)。凡是会改变产物内容的改动都要加一。 +# BUILD_DEFS:配方版本号, 留给"改了脚本逻辑、但 WANT 里的文本一个字都没变"的 +# 改动 (比如换了一种绕坑的处理方式). 凡是会改变产物内容的改动都要加一. # # 注意它现在**不再是唯一的触发源**:编译选项、三个版本号、OpenSSL target、 -# 每个库的 configure 参数都直接进了下面的 WANT,改它们会自动触发重建,不需要 -# 谁记得来这里加一 —— 以前 configure 参数不在 WANT 里,全靠这个手写数字兜着, -# 而人是会忘的(忘了就是"一直用旧参数编出来的库,看不出来")。 -# 已有的历史值:1 = 加了体积优先的 CFLAGS;2 = configure 期间藏起 .pc 文件; -# 3 = 架构与 configure 参数进指纹(本版)。 +# 每个库的 configure 参数都直接进了下面的 WANT, 改它们会自动触发重建, 不需要 +# 谁记得来这里加一 —— 以前 configure 参数不在 WANT 里, 全靠这个手写数字兜着, +# 而人是会忘的 (忘了就是"一直用旧参数编出来的库, 看不出来"). +# 已有的历史值:1 = 加了体积优先的 CFLAGS; 2 = configure 期间藏起 .pc 文件; +# 3 = 架构与 configure 参数进指纹 (本版). BUILD_DEFS=3 -# 多行参数折叠成一行:好塞进戳文件,也好肉眼比对 +# 多行参数折叠成一行:好塞进戳文件, 也好肉眼比对 flatten() { printf '%s' "$1" | tr -s '[:space:]' ' ' | sed 's/^ *//; s/ *$//' } @@ -163,14 +163,14 @@ WANT="defs=$BUILD_DEFS | CC=$CC | zlib=$ZLIB_VER openssl=$OPENSSL_VER curl=$CURL | openssl:[$(flatten "$OPENSSL_CONFIGURE_ARGS")] \ | curl:[$(flatten "$CURL_CONFIGURE_ARGS")]" if [ ! -f "$STAMP" ] && [ -f "$PREFIX/lib/libz.a" ]; then - # 有库却没有选项记录 —— 那是**旧版脚本**编的,不知道用的什么选项。 - # 这一条不能省:没有它,下面的判断会认为"stamp 不存在 = 不用重建", - # 然后我们就给旧库写上一个声称是新选项的 stamp,把它洗白成"新的"。 - echo "== 这个前缀没有编译选项记录(旧版脚本编的),重建以确认选项一致" + # 有库却没有选项记录 —— 那是**旧版脚本**编的, 不知道用的什么选项. + # 这一条不能省:没有它, 下面的判断会认为"stamp 不存在 = 不用重建", + # 然后我们就给旧库写上一个声称是新选项的 stamp, 把它洗白成"新的". + echo "== 这个前缀没有编译选项记录 (旧版脚本编的), 重建以确认选项一致" rm -rf "$PREFIX" mkdir -p "$PREFIX" elif [ -f "$STAMP" ] && [ "$(cat "$STAMP" 2>/dev/null)" != "$WANT" ]; then - echo "== 编译选项变了,重建依赖" + echo "== 编译选项变了, 重建依赖" echo " 原: $(cat "$STAMP")" echo " 新: $WANT" rm -rf "$PREFIX" @@ -179,37 +179,37 @@ fi printf '%s' "$WANT" > "$STAMP" # ---- 被打断时把藏起来的 .pc 放回去 ---- -# curl 那一段会在 configure 期间把 pkgconfig 目录藏起来(原因见下面那段长注释)。 -# 那一步要几分钟,中途 Ctrl-C 很常见 —— 不兜住的话,用户的 openssl.pc 就会在这 -# 段时间里"消失"(他编别的东西时会找不到)。下一轮跑本脚本虽然会自愈,但空窗期 -# 是真实存在的,而且没人知道自己在等什么。 +# curl 那一段会在 configure 期间把 pkgconfig 目录藏起来 (原因见下面那段长注释). +# 那一步要几分钟, 中途 Ctrl-C 很常见 —— 不兜住的话, 用户的 openssl.pc 就会在这 +# 段时间里"消失" (他编别的东西时会找不到). 下一轮跑本脚本虽然会自愈, 但空窗期 +# 是真实存在的, 而且没人知道自己在等什么. PCDIR="$PREFIX/lib/pkgconfig" PCHIDE="$PREFIX/lib/pkgconfig.hidden" restore_pc() { if [ -d "$PCHIDE" ] && [ ! -d "$PCDIR" ]; then - echo "== 把 pkgconfig 目录放回原位(上一次跑本脚本被打断过)" >&2 + echo "== 把 pkgconfig 目录放回原位 (上一次跑本脚本被打断过)" >&2 mv "$PCHIDE" "$PCDIR" fi } trap 'restore_pc' EXIT INT TERM HUP -# fetch <输出文件> —— 多个地址依次试。 -# 各家下载站的目录结构会变(zlib 就把旧版本挪到了 /fossils/),写死一个地址 -# 迟早会 404,而那时的报错是 curl 的一句 "error: 404",看不出该改哪。 +# fetch <输出文件> —— 多个地址依次试. +# 各家下载站的目录结构会变 (zlib 就把旧版本挪到了 /fossils/), 写死一个地址 +# 迟早会 404, 而那时的报错是 curl 的一句 "error: 404", 看不出该改哪. fetch() { out=$1; shift - [ -f "$out" ] && { echo " 已有 $(basename "$out"),跳过下载"; return; } + [ -f "$out" ] && { echo " 已有 $(basename "$out"), 跳过下载"; return; } for url in "$@"; do echo " 下载 $url" if curl -fL --retry 3 --connect-timeout 20 -o "$out.part" "$url"; then mv "$out.part" "$out" return 0 fi - echo " 这个地址不行,换下一个" + 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 } @@ -236,13 +236,13 @@ else fi # ------------------------------------------------------------- OpenSSL -# curl 要 HTTPS 就得有个 TLS 后端。选 OpenSSL 是因为它最普遍;想更小的话 -# 可以换 mbedTLS(configure 里把 --with-openssl 换成 --with-mbedtls)。 -# 守卫必须看 OpenSSL **实际**装到哪。x86_64 上 OpenSSL 3 默认装 lib64, -# 而下面 --libdir=lib 把它拉回 lib —— 只查 lib 就会每次重编 OpenSSL(几分钟)。 +# curl 要 HTTPS 就得有个 TLS 后端. 选 OpenSSL 是因为它最普遍; 想更小的话 +# 可以换 mbedTLS (configure 里把 --with-openssl 换成 --with-mbedtls). +# 守卫必须看 OpenSSL **实际**装到哪. x86_64 上 OpenSSL 3 默认装 lib64, +# 而下面 --libdir=lib 把它拉回 lib —— 只查 lib 就会每次重编 OpenSSL (几分钟). if [ ! -f "$PREFIX/lib/libssl.a" ]; then echo - echo "== 编 OpenSSL $OPENSSL_VER(这一步最慢)" + echo "== 编 OpenSSL $OPENSSL_VER (这一步最慢)" fetch "$SRC/openssl-$OPENSSL_VER.tar.gz" \ "https://www.openssl.org/source/openssl-$OPENSSL_VER.tar.gz" \ "https://github.com/openssl/openssl/releases/download/openssl-$OPENSSL_VER/openssl-$OPENSSL_VER.tar.gz" @@ -267,34 +267,34 @@ if [ ! -x "$PREFIX/bin/curl-config" ]; then rm -rf "$SRC/curl-$CURL_VER" tar -xzf "$SRC/curl-$CURL_VER.tar.gz" -C "$SRC" - # ---- configure 期间把 .pc 文件藏起来,别让它走 pkg-config ---- + # ---- 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" # 直接给目录,路径原样 + # SSL_LDFLAGS="-L$PREFIX_OPENSSL/lib" # 直接给目录, 路径原样 # # 走 pkg-config 那条路会出事:configure 开头就 `LC_ALL=C; export LC_ALL` - # (autoconf 的标准开头,脚本外面设 LC_ALL 压不住它),而 pkg-config 在 + # (autoconf 的标准开头, 脚本外面设 LC_ALL 压不住它), 而 pkg-config 在 # C locale 下用 isprint() 判断字节要不要转义 —— 每个 >= 0x80 的字节都被判成 - # "不可打印",于是它给路径里每个非 ASCII 字节前面加个反斜杠: + # "不可打印", 于是它给路径里每个非 ASCII 字节前面加个反斜杠: # -L/…/开发/… -> -L/…/\开\发/… - # \ 后面跟 E5 不是合法续字节,所以那串是**非法 UTF-8**。curl 拿到就写进 - # curl-config、libcurl.pc 和一堆 Makefile(实测 26 个文件)。 + # \ 后面跟 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):这几步要几分钟, - # 中途 Ctrl-C 很常见,不能让 .pc 在没人知道的情况下"消失"一段。 - restore_pc # 上一次被打断留下的隐藏目录,先放回去 + # 本仓库路径带中文, 所以必中; 纯 ASCII 路径的人永远碰不到. + # 让 .pc 暂时消失, curl 就退回上面那条"直接给目录"的路, 路径干干净净. + # 藏而不是删:这些 .pc 是 OpenSSL/zlib 正常安装的一部分, 别人可能要用. + # PCDIR/PCHIDE 和恢复用的 trap 在脚本开头 (restore_pc):这几步要几分钟, + # 中途 Ctrl-C 很常见, 不能让 .pc 在没人知道的情况下"消失"一段. + restore_pc # 上一次被打断留下的隐藏目录, 先放回去 if [ -d "$PCDIR" ]; then mv "$PCDIR" "$PCHIDE"; fi rc=0 @@ -307,7 +307,7 @@ if [ ! -x "$PREFIX/bin/curl-config" ]; then restore_pc if [ "$rc" -ne 0 ]; then - echo "curl 构建失败(退出码 $rc)" >&2 + echo "curl 构建失败 (退出码 $rc)" >&2 exit "$rc" fi else @@ -315,8 +315,8 @@ else fi # ------------------------------------------------------------ 自检 -# 光看文件在不在不够 —— 要真的**链一个用 curl 的静态程序**,才能证明这份 -# 前缀是可用的(静态链接最常栽在依赖顺序和缺库上,而那只在链接时暴露)。 +# 光看文件在不在不够 —— 要真的**链一个用 curl 的静态程序**, 才能证明这份 +# 前缀是可用的 (静态链接最常栽在依赖顺序和缺库上, 而那只在链接时暴露). echo echo "== 自检:编一个最小程序并静态链接" CHECK="$SRC/check.c" @@ -334,25 +334,25 @@ LIBS=$("$PREFIX/bin/curl-config" --static-libs) # shellcheck disable=SC2086 "$CC" -static -o "$SRC/check" "$CHECK" -I"$PREFIX/include" $LIBS -pthread if ldd "$SRC/check" 2>&1 | grep -q "not a dynamic executable\|statically linked\|不是动态可执行文件"; then - echo " 静态链接成功,且确实不依赖动态库" + echo " 静态链接成功, 且确实不依赖动态库" else - echo " 注意:ldd 没说它是静态的,检查一下下面这行输出:" >&2 + echo " 注意:ldd 没说它是静态的, 检查一下下面这行输出:" >&2 ldd "$SRC/check" 2>&1 | head -3 >&2 fi -# curl-config 的输出必须是**合法 UTF-8**。 -# 它在 `make static` 里会被原样打印出来(recipe 里那句 `echo " 依赖: $LIBS"`), -# 于是会进工具输出、进对话历史、最后进请求体。一旦含非法字节,服务端会以 -# "invalid unicode code point" 拒掉整个请求 —— 而且坏数据已经留在历史里, -# 之后每次请求都会失败。上面 curl 那段的长注释解释了坏字节是怎么来的。 -# 用 iconv 做往返校验:截断的/被转义的序列都会被它拒掉。没有 iconv 就跳过。 +# curl-config 的输出必须是**合法 UTF-8**. +# 它在 `make static` 里会被原样打印出来 (recipe 里那句 `echo " 依赖: $LIBS"`), +# 于是会进工具输出、进对话历史、最后进请求体. 一旦含非法字节, 服务端会以 +# "invalid unicode code point" 拒掉整个请求 —— 而且坏数据已经留在历史里, +# 之后每次请求都会失败. 上面 curl 那段的长注释解释了坏字节是怎么来的. +# 用 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 " 这通常意味着 configure 走了 pkg-config 那条路(见上面 curl 那段)。" >&2 + echo " 错误:curl-config --static-libs 的输出含非法 UTF-8. " >&2 + echo " 这通常意味着 configure 走了 pkg-config 那条路 (见上面 curl 那段). " >&2 echo " 删掉这个前缀重跑本脚本:rm -rf \"$PREFIX\"" >&2 exit 1 fi diff --git a/tools/check-colors.sh b/tools/check-colors.sh index d08fb30..51193e6 100755 --- a/tools/check-colors.sh +++ b/tools/check-colors.sh @@ -1,23 +1,23 @@ #!/bin/sh -# check-colors.sh - 审计所有 ANSI 颜色码,确保都在 16 色内 +# check-colors.sh - 审计所有 ANSI 颜色码, 确保都在 16 色内 # # 为什么要单独一个脚本:颜色越界这件事**看代码看不出来** —— \033[94m 在人眼里 -# 也是"一个蓝色",只有拿尺子量才知道它属于 aixterm 的亮色段,不在最窄的 -# 16 色 ANSI 里。而这个项目是硬编码 SGR、不查 terminfo 的,既然不探测终端 -# 能力,就该待在所有终端都认的那一档。 +# 也是"一个蓝色", 只有拿尺子量才知道它属于 aixterm 的亮色段, 不在最窄的 +# 16 色 ANSI 里. 而这个项目是硬编码 SGR、不查 terminfo 的, 既然不探测终端 +# 能力, 就该待在所有终端都认的那一档. # # 允许的 SGR 参数: # 0 重置 -# 1/2/3/4 加粗/暗淡/斜体/下划线(属性,不是颜色) +# 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) cd "$ROOT" -# 只扫源码,不扫文档 —— README 里要举反例讲这件事,出现在那里是对的 +# 只扫源码, 不扫文档 —— README 里要举反例讲这件事, 出现在那里是对的 bad=$(grep -rhoE '\\033\[[0-9;]+m' src/*.c src/*.h 2>/dev/null \ | grep -oE '[0-9;]+' \ | tr ';' '\n' \ @@ -30,9 +30,9 @@ if [ -n "$bad" ]; then 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 "越界的: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 fc6ed21..743a7ab 100755 --- a/tools/get-version.sh +++ b/tools/get-version.sh @@ -1,47 +1,47 @@ #!/bin/sh -# get-version.sh - 算出版本号,供 Makefile 用 -D 注入 +# get-version.sh - 算出版本号, 供 Makefile 用 -D 注入 # -# 输出六行(顺序固定,Makefile 按行取): +# 输出六行 (顺序固定, Makefile 按行取): # 1. CalVer 主版本号 YYYY.MM.DD # 2. git 短哈希 abc1234 / 1a2b3c4.dirty / unknown -# 3. 分支名 master / unknown(detached HEAD 或非 git) -# 4. 构建日期 YYYY-MM-DD(UTC) -# 5. 构建时间 HH:MM:SS(UTC) -# 6. 本脚本算出的版本串(仅供人看/调试) +# 3. 分支名 master / unknown (detached HEAD 或非 git) +# 4. 构建日期 YYYY-MM-DD (UTC) +# 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 是个终端工具,不是被别人链接的库, -# "第几个大版本"这种信息对它没什么用;"这是哪天的东西"才有用 —— 用户看到 -# 2026.02.14 立刻知道新旧。真需要表达破坏性变更时,git 历史和 README 比版本 -# 号数字说得清楚。 +# 为什么 CalVer 而不是 SemVer:ligent 是个终端工具, 不是被别人链接的库, +# "第几个大版本"这种信息对它没什么用; "这是哪天的东西"才有用 —— 用户看到 +# 2026.02.14 立刻知道新旧. 真需要表达破坏性变更时, git 历史和 README 比版本 +# 号数字说得清楚. # -# 为什么还要带哈希:CalVer 的粒度是"天",同一天可能发好几次;而且用户手上 -# 的二进制未必来自 tag(可能自己改了源码)。带上哈希,报 bug 时能精确定位。 +# 为什么还要带哈希:CalVer 的粒度是"天", 同一天可能发好几次; 而且用户手上 +# 的二进制未必来自 tag (可能自己改了源码). 带上哈希, 报 bug 时能精确定位. # -# 分隔符用 "-" 而不是 git describe 传统的 "+g":我们只用 git,不存在"区分是 -# 哪种 SCM"的需求(那正是 g 前缀存在的理由),而 2026.09.22-1a2b3c4 少两个 -# 符号、念起来也顺。 +# 分隔符用 "-" 而不是 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,用文件的日期当版本) +# 取版本号的优先级 (从高到低): +# 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 是"可复现构建"的标准约定:设了它就用它当构建时间, -# 这样同一个源码在不同时间编出来的二进制是**逐字节相同**的。 -# 不认这个约定的话,CI 上每次构建的产物哈希都不一样,没法校验。 +# SOURCE_DATE_EPOCH 是"可复现构建"的标准约定:设了它就用它当构建时间, +# 这样同一个源码在不同时间编出来的二进制是**逐字节相同**的. +# 不认这个约定的话, CI 上每次构建的产物哈希都不一样, 没法校验. if [ -n "${SOURCE_DATE_EPOCH:-}" ]; then BUILD_DATE=$(date -u -d "@$SOURCE_DATE_EPOCH" +%Y-%m-%d 2>/dev/null \ || date -u -r "$SOURCE_DATE_EPOCH" +%Y-%m-%d 2>/dev/null \ @@ -61,17 +61,17 @@ else CALVER= # 只在真的在 git 工作区里、且 git 可用时才问 git if command -v git >/dev/null 2>&1 && [ -d "$ROOT/.git" ]; then - # 找最近的、长得像 CalVer 的 tag。用 grep 过滤而不是 --match:不同 - # git 版本对 --match 的 glob 支持略有差异,而这里只要"第一个符合 - # YYYY.MM.DD 形态的"就够了,过滤更直观也更可移植。 + # 找最近的、长得像 CalVer 的 tag. 用 grep 过滤而不是 --match:不同 + # git 版本对 --match 的 glob 支持略有差异, 而这里只要"第一个符合 + # YYYY.MM.DD 形态的"就够了, 过滤更直观也更可移植. CALVER=$(git -C "$ROOT" tag --list 2>/dev/null \ | grep -E '^v?[0-9]{4}\.[0-9]{2}\.[0-9]{2}$' \ | sed 's/^v//' \ | sort \ | tail -1 || true) fi - # 没有 tag:用源码目录的日期当版本。tarball 场景下这是"这份源码什么时候 - # 打的包",虽然不如 tag 精确,但比写死一个 0.0.0 有用。 + # 没有 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) fi @@ -79,14 +79,14 @@ 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" elif [ -n "$(git -C "$ROOT" ls-files --others --exclude-standard 2>/dev/null)" ]; then @@ -98,13 +98,13 @@ fi [ -n "$HASH" ] || HASH=unknown # ---------------------------------------------------------------- 分支名 -# LIGENT_BRANCH_OVERRIDE 同理给容器/CI 用。 +# LIGENT_BRANCH_OVERRIDE 同理给容器/CI 用. BRANCH=${LIGENT_BRANCH_OVERRIDE:-} if [ -z "$BRANCH" ] && command -v git >/dev/null 2>&1 && [ -d "$ROOT/.git" ]; then b=$(git -C "$ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || true) - # detached HEAD(CI 上 checkout 某个 tag/commit 时就是这种状态)会返回 - # 字面量 "HEAD" —— 那不是分支名,照实说成 unknown,别让读的人以为 - # 有个叫 HEAD 的分支。 + # detached HEAD (CI 上 checkout 某个 tag/commit 时就是这种状态)会返回 + # 字面量 "HEAD" —— 那不是分支名, 照实说成 unknown, 别让读的人以为 + # 有个叫 HEAD 的分支. if [ -n "$b" ] && [ "$b" != "HEAD" ]; then BRANCH=$b fi @@ -116,9 +116,9 @@ printf '%s\n' "$HASH" printf '%s\n' "$BRANCH" printf '%s\n' "$BUILD_DATE" printf '%s\n' "$BUILD_TIME" -# 第 6 行只是给人看的完整串。哈希未知时**不要**拼出 "…-unknown" —— -# 那既丑又会被误读成一个真实的哈希。src/version.c 的拼接遵循同一规则, -# 两处必须一致,否则这个"调试输出"会误导人。 +# 第 6 行只是给人看的完整串. 哈希未知时**不要**拼出 "…-unknown" —— +# 那既丑又会被误读成一个真实的哈希. src/version.c 的拼接遵循同一规则, +# 两处必须一致, 否则这个"调试输出"会误导人. if [ "$HASH" = unknown ]; then printf '%s\n' "$CALVER" else diff --git a/tools/md_demo.c b/tools/md_demo.c index c261650..892124c 100644 --- a/tools/md_demo.c +++ b/tools/md_demo.c @@ -1,7 +1,7 @@ /* md_demo.c - Markdown 渲染效果演示 * - * 一个独立的小程序:把内置示例(或你指定的文件 / stdin)喂进 ligent 的 - * 流式 Markdown 渲染器,直观看到每种语法的渲染结果。 + * 一个独立的小程序:把内置示例 (或你指定的文件 / stdin)喂进 ligent 的 + * 流式 Markdown 渲染器, 直观看到每种语法的渲染结果. * * 编译: make md_demo * 运行: ./build/md_demo @@ -24,9 +24,9 @@ static const char *SAMPLE = "\n" "## 行内样式\n" "\n" -"这是 **粗体**、*斜体*、_下划线斜体_ 和 `行内代码` 的混排效果。\n" -"标识符不会被误伤:foo_bar_baz、__init__、arr[i]、a_b_c 都保持原样。\n" -"链接只高亮方括号:[官方文档](https://example.com/doc) 后面还有普通文字。\n" +"这是 **粗体**、*斜体*、_下划线斜体_ 和 `行内代码` 的混排效果. \n" +"标识符不会被误伤:foo_bar_baz、__init__、arr[i]、a_b_c 都保持原样. \n" +"链接只高亮方括号:[官方文档](https://example.com/doc) 后面还有普通文字. \n" "图片保留原文:![架构图](./arch.png)\n" "\n" "## 列表与引用\n" @@ -36,23 +36,23 @@ static const char *SAMPLE = "* 星号也能当列表\n" "+ 加号也可以\n" "\n" -"> 这是一段引用,前缀是暗色的竖线。\n" -"> 引用可以有多行。\n" +"> 这是一段引用, 前缀是暗色的竖线. \n" +"> 引用可以有多行. \n" "\n" "---\n" "\n" -"## 代码块(整块不渲染)\n" +"## 代码块 (整块不渲染)\n" "\n" "```c\n" "#include \n" "int main(void) {\n" -" // **这里不会加粗**,[方括号] 也不会高亮\n" +" // **这里不会加粗**, [方括号] 也不会高亮\n" " printf(\"hello %s\\n\", \"world\");\n" " return 0;\n" "}\n" "```\n" "\n" -"行内代码同理:`a ** b` 里的星号不会被当成粗体标记。\n" +"行内代码同理:`a ** b` 里的星号不会被当成粗体标记. \n" "\n" "## 表格\n" "\n" @@ -76,7 +76,7 @@ static const char *SAMPLE = "\n"; /* ------------------------------------------------------------------ */ -/* 渲染到内存(用于 --compare 自检) */ +/* 渲染到内存 (用于 --compare 自检) */ /* ------------------------------------------------------------------ */ static char *render_to_mem(const char *src, int enabled, size_t chunk) @@ -107,7 +107,7 @@ static char *render_to_mem(const char *src, int enabled, size_t chunk) } /* ------------------------------------------------------------------ */ -/* 渲染到终端(可带分片与延时,模拟 SSE 流式) */ +/* 渲染到终端 (可带分片与延时, 模拟 SSE 流式) */ /* ------------------------------------------------------------------ */ static void render_to_tty(const char *src, int enabled, @@ -146,21 +146,21 @@ static void usage(FILE *out) "\n" "用法: md_demo [选项] [文件...]\n" "\n" -"不指定文件时使用内置示例;文件名为 - 或 stdin 不是终端时从标准输入读取。\n" +"不指定文件时使用内置示例; 文件名为 - 或 stdin 不是终端时从标准输入读取. \n" "\n" "选项:\n" -" -p, --plain 关闭渲染,输出原始 Markdown(对照用)\n" -" --color 强制开启渲染(默认仅在输出到终端时开启)\n" -" -c, --chunk N 按每 N 字节一片喂入(默认 0 = 一次性喂完)\n" -" -d, --delay MS 每片之间延时毫秒(默认 0)\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" +" --compare 自检:把同一段文本按多种分片方式渲染, 比对结果是否一致\n" " -h, --help 显示本帮助\n" "\n" "例子:\n" " md_demo 看渲染效果\n" -" md_demo --plain 看原始 Markdown,对照差异\n" -" md_demo -c 5 -d 12 模拟 SSE 流式(标记会被随机切断)\n" +" md_demo --plain 看原始 Markdown, 对照差异\n" +" md_demo -c 5 -d 12 模拟 SSE 流式 (标记会被随机切断)\n" " md_demo --compare 验证分片不变性\n" " md_demo README.md 渲染任意 Markdown 文件\n" " echo '# hi **there**' | md_demo\n", @@ -206,7 +206,7 @@ int main(int argc, char **argv) } else if (strcmp(a, "--compare") == 0) { compare = 1; } else if (a[0] == '-' && a[1] != '\0' && strcmp(a, "-") != 0) { - fprintf(stderr, "未知选项 %s(--help 查看用法)\n", a); + fprintf(stderr, "未知选项 %s (--help 查看用法)\n", a); return 2; } else { file = a; @@ -228,10 +228,10 @@ int main(int argc, char **argv) src = read_stream(stdin); source_name = "标准输入"; if (src[0] == '\0') { - /* stdin 是空管道(例如被 CI / make 调用)时退回内置示例 */ + /* stdin 是空管道 (例如被 CI / make 调用)时退回内置示例 */ free(src); src = xstrdup(SAMPLE); - source_name = "内置示例(stdin 为空)"; + source_name = "内置示例 (stdin 为空)"; } } else { src = xstrdup(SAMPLE); @@ -241,8 +241,8 @@ int main(int argc, char **argv) if (compare) { /* 分片不变性自检:不同分片大小必须得到完全相同的结果 */ - puts("分片不变性自检(同一段文本,不同分片方式,输出必须逐字节一致)"); - printf("输入:%s(%zu 字节)\n\n", source_name, strlen(src)); + puts("分片不变性自检 (同一段文本, 不同分片方式, 输出必须逐字节一致)"); + 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 }; @@ -254,7 +254,7 @@ int main(int argc, char **argv) if (!ok) all_ok = 0; free(part); } - /* 随机分片(模拟真实 SSE 的抖动) */ + /* 随机分片 (模拟真实 SSE 的抖动) */ srand(12345); char *buf = NULL; size_t bsize = 0; @@ -279,8 +279,8 @@ int main(int argc, char **argv) free(buf); free(whole); - printf("\n结论:%s\n", all_ok ? "全部一致,标记跨分片处理正确。" - : "存在不一致,渲染器有 bug。"); + printf("\n结论:%s\n", all_ok ? "全部一致, 标记跨分片处理正确. " + : "存在不一致, 渲染器有 bug. "); free(src); return all_ok ? 0 : 1; } @@ -288,7 +288,7 @@ int main(int argc, char **argv) if (isatty(fileno(stdout))) { fprintf(stderr, "── 输入:%s 渲染:%s%s ──\n\n", source_name, enabled ? "开" : "关", - chunk ? "(分片模式)" : ""); + chunk ? " (分片模式)" : ""); } render_to_tty(src, enabled, chunk, delay_ms); @@ -296,8 +296,8 @@ int main(int argc, char **argv) if (isatty(fileno(stdout))) { fprintf(stderr, "\n── 演示结束 ──\n"); if (enabled && chunk == 0) - fprintf(stderr, "试试 `md_demo -s` 看流式分片下标记被切断时的表现," - "或 `md_demo -p` 对照原始 Markdown。\n"); + fprintf(stderr, "试试 `md_demo -s` 看流式分片下标记被切断时的表现, " + "或 `md_demo -p` 对照原始 Markdown. \n"); } free(src);