commit e54390a9f8d6207b46ae69ff1bb9923fa2bc6fe2 Author: Wang Zhiyu Date: Sat Oct 3 14:13:21 2026 +0800 init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..eaf6196 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +# .dockerignore - 别把没用的东西送进构建上下文 +# +# 构建上下文越小,podman/docker 传得越快;更重要的是,**有些东西绝不能被 +# COPY 进去**。这里每一条都写清楚为什么。 + +# 构建产物:容器里会重新编,带进去只会让缓存失效、还可能串味 +# (比如本机 build/static-prefix 是给本机路径编的,进容器就废了) +build/ +ligent +ligent-static +*.o + +# 版本控制:容器里用 --build-arg VERSION 传版本号,不需要 .git。 +# 而且 .git 可能有几百 MB,是构建上下文里最大的一块。 +.git/ +.gitignore + +# 本地运行期状态:配置和密钥绝不能进镜像。 +# ligent.json 里可能写着 API key —— 这是最要紧的一条。 +ligent.json +ligent.archive/ +ligent.history +ligent.history.tmp + +# 测试:不需要它们来构建。带上会让"改一行测试"也触发镜像重建。 +tests/ + +# 文档在容器里用不上(scratch 镜像里没有 cat,放进去也读不了) +README.md +LICENSE + +# Python 缓存 +__pycache__/ +*.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..068f296 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# 构建产物 +build/ +ligent +ligent-static +*.o + +# 运行期本地状态(配置只放在二进制旁边,见 README §2) +ligent.json +ligent.archive/ +ligent.history +ligent.history.tmp + +# Python 测试辅助脚本 +__pycache__/ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6fa68b2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,135 @@ +# Dockerfile - 构建完全静态的 ligent +# +# 用法(podman 和 docker 都行,命令一样): +# +# podman build -t ligent-static . +# podman run --rm ligent-static --version +# podman create --name tmp ligent-static && podman cp tmp:/ligent-static . && podman rm tmp +# +# 或者直接跑 make 目标: +# make docker-build # 构建镜像 +# make docker-extract # 把产物拷到当前目录(./ligent-static) +# +# 只在构建期需要网络(下载 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 上根本不存在也不需要。 +# +# 具体地:Alpine 的 gcc 默认 `--target=x86_64-alpine-linux-musl`, +# 所以 tools/build-static-deps.sh 里的 CC 检测会落到 x86_64-linux-musl-gcc +# 分支(我们显式给它 CC=cc),产出的 .a 天然就是 musl 的。 +# +# +# 为什么分多阶段 +# -------------- +# 最终镜像里只有那个静态二进制。构建工具链(gcc/perl/make)加上 zlib/OpenSSL/curl +# 的源码和中间产物有好几百 MB,全都不该进最终镜像 —— 而 ligent 是静态链接的, +# 最终镜像连 libc 都不需要,scratch 就够。 +# +# +# 关于构建缓存 +# ------------ +# 依赖的编译(OpenSSL 那步最慢,几分钟)单独占一层,且只依赖脚本本身。 +# 源码 COPY 放在它后面:改一行 C 代码不会让"重编 OpenSSL"这件事再发生一遍。 +# 这是这个 Dockerfile 里唯一一处为了速度而做的结构调整,值得。 + +# ------------------------------------------------------------------ 构建阶段 +FROM docker.io/library/alpine:3.20 AS build + +# 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 解决的, +# 容器里显式装出来更干净。 +# tar/xz: 解压源码包 +RUN apk add --no-cache \ + build-base \ + perl \ + curl \ + tar \ + xz \ + linux-headers + +WORKDIR /src + +# 先只拷贝依赖脚本,让"编依赖"这一步能独立于源码变化被缓存。 +# 版本号通过 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,约几分钟。 +RUN CC=cc sh /src/tools/build-static-deps.sh + +# 再拷源码。放在依赖之后,改 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 应当显式传进来: +# 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,产物的构建时间就永远是午夜。 +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 目标。 \ + make static STATIC_CC=cc; \ + \ + # 自检:产物必须真的存在、真的是静态的、真的能跑。 \ + # 不检查的话,"构建成功"可能只是"链接器没报错",而产物是个空壳 \ + # (比如依赖没链全,或者 -s 把符号表剥没了导致入口找不到)。 \ + test -x /src/ligent-static; \ + if ldd /src/ligent-static 2>&1 | grep -qv "not a dynamic executable\|statically linked\|Not a valid dynamic program"; then \ + echo "产物不是静态链接的:" >&2; ldd /src/ligent-static >&2; exit 1; \ + fi; \ + V=$(/src/ligent-static --version); \ + echo "容器内产物版本: $V"; \ + # 版本号还是回退默认值 => -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、没有包管理器。 +FROM scratch + +# CA 证书是**唯一**需要的外部文件:TLS 握手要它。 +# 注意 ligent 用的是编进 libcurl 的默认路径 /etc/ssl/certs/ca-certificates.crt +# (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 org.opencontainers.image.title="ligent" \ + 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 的花活,直接就是那个二进制。 +ENTRYPOINT ["/ligent"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0d35147 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Wang Zhiyu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..817e486 --- /dev/null +++ b/Makefile @@ -0,0 +1,583 @@ +CC ?= cc +# 警告就是纪律:这一串是"改完就回不去"的那类问题(影子变量、丢 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 里带 % 是常事。 +# -Wno-format-nonliteral 关掉的是它的噪声面:我们自己那套包装内部转发 fmt 的 +# 地方(xvasprintf / err_result / …)天然就是非字面量。可查的部分已经由 +# LIGENT_PRINTF 属性在**调用点**查完了。 +CPPFLAGS += -Isrc -pthread +# 版本号的 -D 也挂在 CPPFLAGS 上:这样主程序、静态目标、以及二十来条测试规则 +# 全都自动带上,不用逐个改。测试程序确实需要它 —— 它们会链 version.c。 +CPPFLAGS += $(VERSION_DEFS) +# -lm 是必须的:代码里调了 fabs,而 gcc 会把它折成内建函数,于是"不写 -lm +# 也能链接"是个假象 —— 换个编译器(tcc)或加 -fno-builtin 就会露出来。 +# 显式写上,不要靠编译器的好意。 +LDLIBS += -lcurl -lm -pthread + +# make SAN=1 —— 打开 AddressSanitizer / UndefinedBehaviorSanitizer。 +# 链接行也要带,否则只有目标文件被插桩、链接期运行时库没进来。 +ifeq ($(SAN),1) +# 注意 override:命令行上传的变量会屏蔽 makefile 里对它的一切赋值(连 += 也算), +# 所以 `make SAN=1 CFLAGS="-O1"` 不加 override 的话会**静默地不带 sanitizer** —— +# 结果是一份没插桩却看起来「干净」的构建。这个坑踩过一次,别再踩。 +# -fno-sanitize-recover=all 让报错直接中止,而不是报告完继续跑。 +override CFLAGS += -fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all +override LDLIBS += -fsanitize=address,undefined +endif + +BIN := ligent +SRCDIR := src +BUILDDIR := build + +# 对象目录带"编译器 + 是否插桩"的标签,见文件末尾"为什么对象目录要带标签"。 +BUILD_TAG := $(basename $(notdir $(CC)))$(if $(filter 1,$(SAN)),-san,) +OBJDIR := $(BUILDDIR)/$(BUILD_TAG) + +# 头文件也是依赖。以前没有这一条,改了 .h 之后 make 会说"无需做任何事", +# 目标文件仍然是旧布局 —— 这正是"子代理读到的字段全是垃圾值"那次事故的成因。 +# 全量重编只要几秒,不值得为它引入 .d 文件那一套。 +HEADERS := $(wildcard $(SRCDIR)/*.h) + +# ------------------------------------------------------------------ */ +# 版本号:CalVer + git 哈希,构建期算出来注入 */ +# ------------------------------------------------------------------ */ +# +# 源码里**不写死**版本号,只留一个回退默认值(见 src/version.h 的说明)。 +# 这里取真值,用 -D 注入。算法和理由都在 tools/get-version.sh 里。 +# +# 用 := 而不是 = :这段要跑三次 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 +# 里的值。那样其实也对,但显式写出来更不容易被误读。 +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) +LIGENT_BUILD_DATE := $(if $(LIGENT_BUILD_DATE),$(LIGENT_BUILD_DATE),unknown) +LIGENT_BUILD_TIME := $(if $(LIGENT_BUILD_TIME),$(LIGENT_BUILD_TIME),unknown) + +VERSION_DEFS = -DLIGENT_VERSION='"$(LIGENT_VERSION)"' \ + -DLIGENT_GIT_HASH='"$(LIGENT_GIT_HASH)"' \ + -DLIGENT_GIT_BRANCH='"$(LIGENT_GIT_BRANCH)"' \ + -DLIGENT_BUILD_DATE='"$(LIGENT_BUILD_DATE)"' \ + -DLIGENT_BUILD_TIME='"$(LIGENT_BUILD_TIME)"' + +# ------------------------------------------------------------------ */ +# 版本号变了就自动清掉对象,免得 make 拿旧版本号蒙混过关 */ +# ------------------------------------------------------------------ */ +# +# 这是"make 看不见命令行变量变了"这个坑的第三个入口(前两个:混用编译器 +# 导致链接错误、make SAN=1 不重新插桩 —— 那两个是用 build// 分目录解决的)。 +# +# 版本号只走 -D,不进任何依赖关系,于是: +# $ make && ./ligent --version # 2027.01.01+... +# $ git tag -d v2027.01.01 # 把 tag 删了 +# $ make # "无需做任何事" +# $ ./ligent --version # 还是 2027.01.01 —— **在说谎** +# 二进制看着完全正常,只是版本号是旧的。而这恰恰是 .dirty 标记想避免的那类误导。 +# +# 用一个戳文件记下上次构建用的版本串,对不上就把**我们自己的**对象目录清掉。 +# +# 为什么不用 `make clean`:那个会连 build/static-prefix 和 build/static-src 一起删 +# (用户明确要的语义),而重建静态依赖要联网下载 + 编译好几分钟。为了一个版本号 +# 字符串付那个代价不合理。所以这里只删自己的产物,精确地清。 +VERSION_STAMP := $(BUILDDIR)/.version-stamp +# 戳文件的内容:版本号 + 哈希 + 构建日期。任意一项变了都要重编 —— +# 比如只切了 tag(版本号变)或只改了工作区(.dirty 出现)。 +# 戳文件的内容:把**每一个**注入进二进制的字符串都算进去。任意一项变了都要 +# 重编 —— 比如只切了 tag(版本号变)、只改了工作区(.dirty 出现)、只换了 +# 分支(branch 变)。少算一项,那一项就会在二进制里过期而 make 毫无察觉, +# 正是这个机制要防的事。 +# +# 注意 BUILD_TIME 不在这里:它每秒都在变,算进去等于每次 make 都全量重编。 +# 代价是同一分钟内改了源码、二进制里的时间戳可能停在上一分钟 —— 那无所谓, +# 时间戳是"什么时候编的",不是"源码什么时候改的",而日期(BUILD_DATE)在, +# 粒度够用了。 +VERSION_FINGERPRINT := $(LIGENT_VERSION)|$(LIGENT_GIT_HASH)|$(LIGENT_GIT_BRANCH)|$(LIGENT_BUILD_DATE) + +# 用 $(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 +else ifneq ($(CURRENT_STAMP),$(VERSION_FINGERPRINT)) +NEED_VERSION_CLEAN := 1 +else +NEED_VERSION_CLEAN := +endif + +ifeq ($(NEED_VERSION_CLEAN),1) +# 清掉再让 make 正常评估依赖 —— 这一步发生在**读 Makefile 的时候**, +# 所以后面的 $(OBJS) 会看到"对象不存在",自然全量重编。 +# 在 $(shell) 里写文件是安全的:它只在真的需要清的时候跑一次。 +$(shell rm -rf $(OBJDIR) $(BIN) && mkdir -p $(BUILDDIR) && printf '%s' '$(VERSION_FINGERPRINT)' > $(VERSION_STAMP)) +endif + +# $^ 里既有 .c 也有 .h(后者是为了依赖关系才加上的),真正要编译的只有 .c +SRCFILES = $(filter-out %.h,$^) + +SRCS := $(wildcard $(SRCDIR)/*.c) +OBJS := $(patsubst $(SRCDIR)/%.c,$(OBJDIR)/%.o,$(SRCS)) + +.PHONY: all clean test test-unit test-e2e test-fuzz demo md_demo static static-check static-test check-docs analyze docker-build docker-extract docker-test docker-clean + +all: $(BIN) + +$(BIN): $(OBJS) + $(CC) $(CFLAGS) -o $@ $(OBJS) $(LDLIBS) + +$(OBJDIR)/%.o: $(SRCDIR)/%.c $(HEADERS) | $(OBJDIR) + $(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $< + +$(OBJDIR): + mkdir -p $(OBJDIR) + +# 清掉**一切**构建产物: +# build/ 各 <编译器>[-san] 对象目录、build/static、测试可执行文件、 +# e2e 的临时文件,以及 tools/build-static-deps.sh 编出来的 +# static-prefix(静态库)和 static-src(源码 + 压缩包缓存) +# ligent / ligent-static +# tests/__pycache__ 跑测试时 python 生成的字节码 +# +# 特意**不动** ligent.history:那不是构建产物,是你的输入历史,删了找不回来。 +# (e2e 自己写的是 build/e2e.history,在 build/ 里,跟着一起清。) +clean: + rm -rf $(BUILDDIR) $(BIN) $(BIN)-static tests/__pycache__ + +# ------------------------------------------------------------------ */ +# 静态构建:musl libc + 静态 libcurl */ +# ------------------------------------------------------------------ */ +# +# 目标是一个**完全静态**的可执行文件:不依赖宿主机的 libc、libcurl、 +# OpenSSL。拷到任何同架构的 Linux 上就能跑。 +# +# 为什么不能直接拿发行版的 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_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 目标 +# 文件混在一起会互相覆盖,症状是莫名其妙的重链接或不重链接。 +STATIC_OBJDIR := $(BUILDDIR)/static +STATIC_OBJS := $(patsubst $(SRCDIR)/%.c,$(STATIC_OBJDIR)/%.o,$(SRCS)) + +static-check: + @test -n "$(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 " ./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"; \ + exit 1; } + +$(STATIC_OBJDIR): + mkdir -p $(STATIC_OBJDIR) + +# -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 是会过期的,而且顺序错了链接就失败。 +static: static-check $(STATIC_OBJS) + @CURL_CONFIG="$(STATIC_PREFIX)/bin/curl-config"; \ + LIBS="$$($$CURL_CONFIG --static-libs)" || exit 1; \ + echo " 依赖: $$LIBS"; \ + $(STATIC_CC) $(STATIC_CFLAGS) $(STATIC_CPPFLAGS) $(STATIC_LDFLAGS) \ + -o $(STATIC_BIN) $(STATIC_OBJS) $$LIBS -pthread; \ + echo " 产物: $(STATIC_BIN)"; \ + 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"。 +# 两头都关掉,省得踩。 +STATIC_CFLAGS ?= $(CFLAGS) -fno-pie +STATIC_LDFLAGS ?= -static -no-pie + +# ------------------------------------------------------------------ */ +# 静态产物优先体积(make static STATIC_RELEASE=0 关掉) */ +# ------------------------------------------------------------------ */ +# +# 普通构建的取舍是"能调试",静态产物的取舍是"拷过去就能跑、越小越好", +# 所以这里单独一套开关。实测 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++ 异常和栈回溯。 +# -Os 4.86 MB 我们自己的代码优先体积 +# -z noseparate-code 4.85 MB 不再为代码/数据段留页对齐空洞 +# --build-id=none 少一个 note 段 +# -g0 不生成调试信息(否则每个 .o 都白白大一截, +# 编译也慢,反正最后要 strip) +# +# 还能再小的地方(不在本文件范围):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 \ + -fno-asynchronous-unwind-tables -fno-unwind-tables +STATIC_LDFLAGS += -Wl,--gc-sections -Wl,-z,noseparate-code \ + -Wl,--build-id=none -s +endif + +# ------------------------------------------------------------------ +# 渲染效果演示 +# ------------------------------------------------------------------ + +$(OBJDIR)/md_demo: tools/md_demo.c $(SRCDIR)/markdown.c $(SRCDIR)/util.c $(SRCDIR)/i18n.c | $(OBJDIR) + $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS) + +# 编译并直接跑起来看效果 +demo: $(OBJDIR)/md_demo + @./$(OBJDIR)/md_demo + +# 只编译,不运行 +md_demo: $(OBJDIR)/md_demo + +# ------------------------------------------------------------------ +# 测试 +# ------------------------------------------------------------------ +# +# 测试与演示程序**复用主构建编出来的 .o**(只链它真正需要的那几个模块),而不是 +# 每个测试各自把源文件重新编译一遍。 +# +# 以前每条规则的依赖写的是源文件($(SRCDIR)/json.c 之类),recipe 是 +# `$(CC) … -o $@ $(SRCFILES)` —— 于是同一个源文件会被编很多遍:只改 util.c 一行, +# 它会按依赖被重编 16 次,实测 `make test-unit` 要 25 秒,而真正的工作量只有 +# "编一个 util.o + 重链 17 个二进制"。现在依赖写成 .o:编译一次、链接多次, +# 同样的场景降到几秒。而这些 .o 主构建本来就要用,等于没有额外成本。 +# +# 代价:测试的依赖里多了一层(.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) + +$(OBJDIR)/test_tools: tests/test_tools.c $(OBJDIR)/tools.o $(OBJDIR)/json.o \ + $(OBJDIR)/util.o $(OBJDIR)/http.o $(OBJDIR)/spinner.o \ + $(OBJDIR)/image.o $(OBJDIR)/priv.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR) + $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS) + +$(OBJDIR)/test_agent: tests/test_agent.c $(OBJDIR)/agent.o $(OBJDIR)/llm.o \ + $(OBJDIR)/json.o $(OBJDIR)/util.o $(OBJDIR)/http.o \ + $(OBJDIR)/tools.o $(OBJDIR)/markdown.o \ + $(OBJDIR)/spinner.o $(OBJDIR)/confirm.o \ + $(OBJDIR)/image.o $(OBJDIR)/capability.o \ + $(OBJDIR)/archive.o \ + $(OBJDIR)/priv.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR) + $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS) + +$(OBJDIR)/test_config: tests/test_config.c $(OBJDIR)/config.o $(OBJDIR)/json.o \ + $(OBJDIR)/util.o $(OBJDIR)/http.o \ + $(OBJDIR)/spinner.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR) + $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS) + +$(OBJDIR)/test_meta: tests/test_meta.c $(OBJDIR)/meta.o $(OBJDIR)/agent.o \ + $(OBJDIR)/json.o $(OBJDIR)/util.o $(OBJDIR)/http.o \ + $(OBJDIR)/llm.o $(OBJDIR)/lineedit.o \ + $(OBJDIR)/tools.o $(OBJDIR)/markdown.o \ + $(OBJDIR)/spinner.o $(OBJDIR)/confirm.o \ + $(OBJDIR)/image.o $(OBJDIR)/capability.o \ + $(OBJDIR)/archive.o \ + $(OBJDIR)/priv.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR) + $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS) + +$(OBJDIR)/test_priv: tests/test_priv.c $(OBJDIR)/priv.o $(OBJDIR)/util.o \ + $(OBJDIR)/http.o $(OBJDIR)/spinner.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR) + $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS) + +$(OBJDIR)/test_attach: tests/test_attach.c $(OBJDIR)/attach.o \ + $(OBJDIR)/image.o $(OBJDIR)/json.o $(OBJDIR)/util.o \ + $(OBJDIR)/lineedit.o $(OBJDIR)/http.o \ + $(OBJDIR)/spinner.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR) + $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS) + +$(OBJDIR)/test_models: tests/test_models.c $(OBJDIR)/models.o \ + $(OBJDIR)/json.o $(OBJDIR)/util.o \ + $(OBJDIR)/http.o $(OBJDIR)/spinner.o $(OBJDIR)/config.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR) + $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS) + +$(OBJDIR)/test_capability: tests/test_capability.c $(OBJDIR)/capability.o \ + $(OBJDIR)/json.o $(OBJDIR)/util.o \ + $(OBJDIR)/http.o $(OBJDIR)/spinner.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR) + $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS) + +$(OBJDIR)/test_image: tests/test_image.c $(OBJDIR)/image.o $(OBJDIR)/json.o \ + $(OBJDIR)/util.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR) + $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS) + +$(OBJDIR)/test_i18n: tests/test_i18n.c $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR) + $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS) + +$(OBJDIR)/test_spinner: tests/test_spinner.c $(OBJDIR)/spinner.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR) + $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS) + +$(OBJDIR)/test_markdown: tests/test_markdown.c $(OBJDIR)/markdown.o \ + $(OBJDIR)/util.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR) + $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS) + +$(OBJDIR)/test_http: tests/test_http.c $(OBJDIR)/http.o $(OBJDIR)/util.o \ + $(OBJDIR)/spinner.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR) + $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS) + +$(OBJDIR)/test_archive: tests/test_archive.c $(OBJDIR)/archive.o $(OBJDIR)/json.o \ + $(OBJDIR)/util.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR) + $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS) + +$(OBJDIR)/test_lineedit: tests/test_lineedit.c $(OBJDIR)/lineedit.o \ + $(OBJDIR)/util.o $(OBJDIR)/http.o \ + $(OBJDIR)/spinner.o $(OBJDIR)/i18n.o $(HEADERS) | $(OBJDIR) + $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS) + +$(OBJDIR)/fuzz: tests/fuzz.c $(OBJDIR)/json.o $(OBJDIR)/markdown.o \ + $(OBJDIR)/util.o $(OBJDIR)/i18n.o | $(OBJDIR) + $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $(SRCFILES) $(LDLIBS) + +# 单元测试清单:加一个测试只要改这里 + 它自己那条依赖规则 +UNIT_TESTS := test_json test_tools test_agent test_config test_markdown \ + test_http test_lineedit test_archive test_meta test_image \ + test_capability test_priv test_attach test_models test_spinner \ + test_i18n + +test-unit: $(addprefix $(OBJDIR)/,$(UNIT_TESTS)) $(OBJDIR)/md_demo $(OBJDIR)/fuzz + @export LIGENT_LANG=zh; for t in $(UNIT_TESTS); do \ + case $$t in \ + test_json) label="JSON" ;; \ + test_tools) label="工具" ;; \ + test_agent) label="agent 上下文" ;; \ + test_config) label="配置" ;; \ + test_markdown) label="Markdown 渲染" ;; \ + test_http) label="HTTP 层" ;; \ + test_lineedit) label="行编辑器" ;; \ + test_archive) label="存档" ;; \ + test_meta) label="meta 历史编辑" ;; \ + test_image) label="图片" ;; \ + test_capability) label="能力探测" ;; \ + test_priv) label="提权" ;; \ + test_attach) label="/include 暂存与展开" ;; \ + test_models) label="模型列表解析" ;; \ + test_spinner) label="等待动画" ;; \ + test_i18n) label="界面文案" ;; \ + *) label="$$t" ;; \ + esac; \ + echo "==== $$label 测试($$t)===="; \ + ./$(OBJDIR)/$$t || exit 1; \ + echo; \ + done + @echo "==== 演示程序分片自检 ====" + @./$(OBJDIR)/md_demo --compare < /dev/null + @echo + @echo "==== 颜色审计(只用 16 色内的 SGR) ====" + @./tools/check-colors.sh + @echo + @echo "==== 模糊测试(每轮固定种子,可复现) ====" + @./$(OBJDIR)/fuzz 4000 + +test-e2e: $(BIN) + @bash tests/run_e2e.sh + +# ------------------------------------------------------------------ */ +# make check-docs —— README 里写的测试数量必须和现实一致 */ +# ------------------------------------------------------------------ */ +# +# README 里引用了"831 单元 + 314 e2e"这样的数字。数字会随着加测试而过期, +# 而过期的数字比不写更糟 —— 它是**错的信息**,读的人没有理由怀疑它。 +# (这件事就发生过:加完 JSON 的 UTF-8 测试后,README 还写着 797。) +# +# 所以把"文档说的"和"实际跑出来的"对一遍。不做成 make test 的一部分: +# 它要跑一遍单元测试来数数,比 test-unit 本身还慢。 +check-docs: $(addprefix $(OBJDIR)/,$(UNIT_TESTS)) $(BIN) + @unit=0; \ + for t in $(UNIT_TESTS); do \ + n=$$(./$(OBJDIR)/$$t 2>/dev/null | grep -oE '^[0-9]+ 项检查' | grep -oE '^[0-9]+'); \ + 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_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; \ + if [ "$$unit" != "$$unit_docs" ]; then \ + 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 + +# 拿同一套 e2e 打静态产物。以前"静态构建可用"实际上只等于"它能启动" —— +# 依赖脚本的自检只做 curl_easy_init + setopt,连一个请求都不发;现在这 300 +# 多项跑一遍,才真的说明它是可用的。 +# 单独一个目标、不进 make test:要先编依赖,慢。 +static-test: static + @BIN="$(abspath $(STATIC_BIN))" bash tests/run_e2e.sh + +# ------------------------------------------------------------------ */ +# 容器里构建静态产物(CI/CD 用) */ +# ------------------------------------------------------------------ */ +# +# podman 和 docker 命令行兼容,默认用 podman(这个项目在 rootless 环境下开发)。 +# 用 CONTAINER=... 换。 +CONTAINER ?= podman +CONTAINER_IMAGE ?= ligent-static + +# 版本号显式传给容器:容器里没有 .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, +# 而 "2026-09-22" 被 date(1) 解析出来是那天的 00:00:00 UTC —— 于是容器产物 +# 的 --version 里时间永远显示午夜。传秒数就没有这个歧义,而且仍然可复现。 +# 设了 SOURCE_DATE_EPOCH 的话就沿用它(那正是"可复现构建"要求的:同一份 +# 源码无论何时构建都得到同样的时间戳)。 +CONTAINER_BUILD_EPOCH := $(if $(SOURCE_DATE_EPOCH),$(SOURCE_DATE_EPOCH),$(shell date -u +%s)) + +docker-build: + $(CONTAINER) build \ + --build-arg VERSION='$(CONTAINER_VERSION)' \ + --build-arg GIT_HASH='$(CONTAINER_GIT_HASH)' \ + --build-arg GIT_BRANCH='$(CONTAINER_GIT_BRANCH)' \ + --build-arg BUILD_EPOCH='$(CONTAINER_BUILD_EPOCH)' \ + -t $(CONTAINER_IMAGE) . + +# 把产物从镜像里拷出来。用 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 + @$(CONTAINER) cp $(CONTAINER_IMAGE)-tmp:/ligent $(STATIC_BIN) + @$(CONTAINER) rm -f $(CONTAINER_IMAGE)-tmp >/dev/null + @echo " 产物: $(STATIC_BIN) ($$(stat -c%s $(STATIC_BIN) 2>/dev/null || echo '?') 字节)" + @file $(STATIC_BIN) 2>/dev/null || true + +# 容器产物也要过一遍 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-fuzz: $(OBJDIR)/fuzz + @echo "==== 模糊测试(20 万轮)====" + @./$(OBJDIR)/fuzz 200000 + +# `make -j test` 会让单元测试与 e2e 并行,输出互相穿插、也互相抢资源 —— +# 这两个目标内部本来就是逐条跑的,没有并行的必要。 +.NOTPARALLEL: test test-unit test-e2e + +test: test-unit test-e2e + @echo + @echo "全部测试通过。" + +# 依赖统一由 $(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) */ +# ------------------------------------------------------------------ */ +# +# 它抓的是单靠警告看不出来的那类问题:读未初始化的值、空指针、资源泄漏。 +# 第一次跑就捞出两个真 bug(attempt() 里 curl_easy_init 失败那条提前返回 +# 会把未初始化的 cerr 当字符串读;POST 分支对 body==NULL 不设防)。 +# +# 用 -O0 而不是平时的 -O2:开了优化之后 gcc 会内联,而 -fanalyzer 的跨函数 +# 状态在遇到内联时并不可靠 —— image.c 里的 rand_suffix 在 -O1/-O2/-Os 下会 +# 报一条 "leak of FILE 'f'",而 -O0 下干净,fclose 也确实就写在 fread 下一行。 +# 分析器自己的分析是跨函数的,不依赖 -O 级别,所以 -O0 不会少查什么。 +# gcc 文档同样建议 -fanalyzer 配 -O0。 +# +# 单独一个目标、不进 make test:它比整个测试套件加起来还慢。 +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 "静态分析通过:无告警。"; \ + else \ + echo "静态分析发现问题(上面的 warning: 就是原因)。"; exit 1; \ + fi + +# ------------------------------------------------------------------ */ +# 为什么对象目录要带标签(build/gcc、build/clang、build/gcc-san …) */ +# ------------------------------------------------------------------ */ +# +# make 只按"源文件比目标文件新"决定要不要重编,它**看不见命令行变量变了**。 +# 于是有两类事故: +# +# 1) make CC=tcc 之后再 make CC=clang —— 只有被 touch 过的那几个 .o 是 clang +# 编的,其余还是 tcc 编的。tcc 的目标文件要 tcc 自己的运行时辅助例程 +# (__floatundidf 之类),拿 clang 去链接就是一堆 undefined reference。 +# 我自己的构建就是这么炸的,而且第一次还因为只用 ls 看文件大小而误判成 +# "clang 编译通过"。 +# +# 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 写死的位置,不能被标签带跑。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..2268f9c --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# ligent: 简洁而强大的 AI 工作代理, 使用 C99 写成 + +## 快速开始 + +## 设计特性 + +### 高移植性 + +### 终端兼容性 + +### 配置自包含 + +### 稳定与审计 + +## 功能特性 + +### 函数调用 + +### 元编辑器代理 (/meta) + +### 手动上下文控制 + +#### 导入外部文件 + +#### 导入 shell 到上下文 + +### 对话存档谱系 + +## 配置此程序 + +### 配置文件, 环境变量与命令行参数 + +### 运行时配置与命令表 + +## 项目构建与平台支持 + +## 已知限制 \ No newline at end of file diff --git a/README.zh.md b/README.zh.md new file mode 100644 index 0000000..db4d900 --- /dev/null +++ b/README.zh.md @@ -0,0 +1,2142 @@ +# ligent - C99 实现的命令行 AI Agent + +一个用 C99 从零写成的命令行 AI Agent:直连 **OpenAI 兼容**的 Chat Completions +接口,具备**原生 function calling**(工具调用)能力,能真正读写文件、执行命令、 +搜索代码,并在终端里流式输出。 + +对话、工具调用、图片走的都是通用协议(`image_url` + base64 内联,DeepSeek 和 +MiMo 都收);**只有思考模式的字段是方言**,不做跨厂商探测。代价见 §9。 + +除 `libcurl`(HTTPS 传输)外,**不依赖任何第三方库**: +JSON 解析器、SSE 流式协议解析、工具调用增量拼接、REPL 全部是本仓库自己实现的。 + +``` +$ ligent "把 src/util.c 里的 xmalloc 改成失败时打印调用栈,然后编译验证" +{/} read_file "src/util.c" + │ 1 /* util.c - 基础工具实现 */ + │ 2 #include "util.h" + │ ... +{/} edit_file "src/util.c" [41] + │ - 41 exit(1); + │ + 41 print_backtrace(); /* 失败时打印调用栈 */ + │ + 42 exit(1); +{/} run_command make 2>&1 | tail -20 + │ cc ... -o ligent ... + │ [exit code: 0] +已按要求改造 xmalloc 的失败路径,并确认编译通过。 +``` + +--- + +## 1. 编译 + +依赖:`gcc`(或 `clang`)、`make`、`libcurl` 开发包。 + +```bash +# Debian / Ubuntu +sudo apt install build-essential libcurl4-openssl-dev + +# Arch +sudo pacman -S base-devel curl + +# Fedora +sudo dnf install gcc make libcurl-devel +``` + +```bash +make # 生成 ./ligent +make test # 跑单元测试 + 端到端测试 +``` + +没有 `make install`。这是个自包含的小工具,不是系统级程序 —— 可执行文件在哪, +配置就在哪(见下一节),所以直接拷走 `ligent` 和 `ligent.json` 就是"安装"。 + +依赖只有 **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 static-test` | 拿同一套 e2e 打静态产物 | +| `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` 里会跑) | + +### 构建目录按编译器和配置分开 + +产物不在 `build/` 正下方,而是 `build/<编译器>[-san]/`: + +``` +build/cc/ # 默认 CC=cc +build/clang/ +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 clean` 清掉**一切**构建产物:各 `<编译器>[-san]` 对象目录、`build/static`、 +测试可执行文件、e2e 的临时文件、`ligent` / `ligent-static`,以及 +`tools/build-static-deps.sh` 编出来的 `build/static-prefix`(静态库)和 +`build/static-src`(源码 + 压缩包缓存),外加 `tests/__pycache__`。 +换句话说,`make clean` 之后 `git status --ignored` 应该只剩你自己的东西。 + +代价是明确的:**清掉依赖之后,下次静态构建要重新联网下载再编译几分钟**: + +```bash +make clean +./tools/build-static-deps.sh # 要联网、几分钟 +make static +``` + +`make static` 自己**不会**去编依赖(那等于让一个构建目标偷偷联网下载),它会明确 +报错并告诉你该跑上面那条命令。 + +特意**不动 `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 语义。 + +直接写 `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", …)`。 + +`-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"。 + +### 静态分析 + +```bash +make analyze # gcc -fanalyzer,独立目标,故意不进 make test(它比全套测试还慢) +``` + +它抓的是稀疏但致命的那类问题,第一次跑就捞出两个真 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 的分析器告警不影响退出码,这是我第一版踩的坑。 + +### 换个编译器:tcc 也能编 + +```bash +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 这种 +瓶颈在网络的程序完全无所谓。 + +它换不来三样东西,用之前得知道: + +- **没有 `-fsanitize`**:`make SAN=1` 仍然要用 gcc/clang。 +- **没有那些严格警告**(`-Wshadow` / `-Wmissing-prototypes` 等)。tcc 的诊断 + 弱得多,所以"零警告"这条纪律只在 gcc/clang 下成立。 +- 没有优化器(上面那 1.3 倍就是它的全部代价)。 + +顺带一提,tcc 一上来就帮我们抓出两个**一直存在的构建脆弱点**:代码调了 `fabs` +却没链 `-lm`(gcc 把它折成内建函数,所以看不出来),以及 6 条测试链接规则漏了 +`$(LDLIBS)`。两个都补上了 —— 这类"某个编译器恰好替你兜住"的洞,平时是看不见的。 + +### 静态构建:musl libc + 静态 libcurl + +想要一个**拷到哪都能跑**的可执行文件(不依赖宿主机的 glibc、libcurl、OpenSSL): + +```bash +./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`。必须有一份用 +musl 工具链编出来的静态 libcurl —— 这就是 `tools/build-static-deps.sh` 干的事: +用 musl 把 zlib、OpenSSL、curl 依次编到 `build/static-prefix/`,最后**真的链一个 +最小程序**自检(静态链接最常栽在依赖顺序和缺库上,而那只在链接时暴露)。 + +产物是 `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 回到能调试的版本 | + +### 把静态产物压到最小 + +静态产物是"拷过去就能跑"的发布件,不是拿来调试的,所以它的取舍和常规构建相反。 +**6.24 MB → 3.29 MB(-47%,几乎腰斩)**,分两半,缺一半效果就大打折扣: + +**A. 链接我们自己的东西(Makefile,`STATIC_RELEASE=1`)** + +| 开关 | 效果 | +|---|---| +| `-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 掉的调试信息 | +| `-z noseparate-code`、`--build-id=none` | 页对齐空洞和 note 段 | + +**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** —— +静态归档里本来就有一大堆我们永远走不到的算法和协议实现。 + +`CFLAGS_COMMON` 里带的这几个开关是**体积优先**的默认值;要回到调试友好版: +```bash +CFLAGS_COMMON="-O2" ./tools/build-static-deps.sh +``` + +脚本会把用过的选项记在 `$PREFIX/.build-flags` 里,**选项变了会自动重建**。 +没有这一层的话,下面那些 `[ ! -f libz.a ]` 守卫会看到库已存在就跳过,你会一直用着 +旧选项编出来的依赖,症状是"我明明加了 `-Os`,产物怎么一点没小" —— 和 Makefile 里 +对象目录按配置分标签是同一个坑的两处。 + +顺手排掉的:**`ld.gold` 不是更小而是更大**(4.89 MB vs 4.86 MB),`--icf=all` +相同代码折叠在这份代码上一点没省,所以没换链接器。 + +**验证方式**(这些数字都是量出来的,不是估的): + +```bash +make static # → 3,292,568 字节 +make static-test # 拿同一套 318 项 e2e 打静态产物(而不是只看它能不能启动) +``` + +TLS 单独验:本地起 `openssl s_server` 拿自签证书,让静态产物连它 —— 它会报 +`SSL certificate problem: self-signed certificate`。这个**失败**恰恰是成功的证据: +能走到"证书不被信任"说明 TCP、ClientHello、证书解析、证书链校验整条 OpenSSL 路径 +都通了。这也是静态二进制的真实 HTTPS 路径**第一次被实际验证**(依赖脚本的自检 +只做 `curl_easy_init` + `setopt`,从不发请求,验不到这一层)。 + +顺带发现(既有限制,与本次改动无关):**libcurl 库不读 `CURL_CA_BUNDLE`**, +只有 `curl` 命令行工具读它 —— `CURLINFO_CAINFO` 显示它永远用编进去的 +`/etc/ssl/certs/ca-certificates.crt`。所以 ligent 只能用系统 CA 包,想指定别的 +得改成显式 `CURLOPT_CAINFO`。 + +几个刻意的取舍: + +- **依赖列表交给 `curl-config --static-libs`**,不手写 `-lssl -lcrypto -lz`。 + curl 最清楚自己链了哪些库、什么顺序;手写的那串会过期,顺序错了还链不上。 +- **`-fno-pie` / `-no-pie`**:现代 gcc 默认编 PIE,而静态链接一份非 PIC 的 `.a` + 会报 `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) + +```bash +make docker-build # podman build -t ligent-static . +make docker-extract # 产物拷到 ./ligent-static +make docker-test # 拿同一套 318 项 e2e 打容器产物 +``` + +`make docker-*` 默认用 **podman**(本项目的开发环境是 rootless),换 docker +加 `CONTAINER=docker` 即可 —— 两者的 `build`/`run`/`cp` 参数在这里是一样的。 + +**为什么用 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 文件"的麻烦在容器里根本不存在。 + +**最终镜像 3.49 MB,里面只有两个文件**:静态二进制和 CA 证书。 + +``` +$ podman export $(podman create ligent-static) | tar -t +etc/ssl/certs/ca-certificates.crt +ligent +``` + +用 `scratch` 做运行阶段 —— 静态链接的产物连 libc 都不需要,所以镜像里没有 +shell、没有包管理器、没有任何可被利用的东西。CA 证书放在 +`/etc/ssl/certs/ca-certificates.crt` 是**必须**的:libcurl 用的是编进去的这个 +路径(见上一条),放别处它读不到。 + +版本号、分支、哈希都由 build-arg 注入 —— 容器里没有 `.git`(`.dockerignore` +把它排除了),脚本在里面**问不出**这些,只能由外面算好传进去: + +```bash +podman build \ + --build-arg VERSION=$(date -u +%Y.%m.%d) \ + --build-arg GIT_HASH=$(git rev-parse --short=7 HEAD) \ + --build-arg GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) \ + --build-arg BUILD_EPOCH=$(date -u +%s) . +``` + +`make docker-build` 会自动在本机算好传进去,不用手写。 + +两个容易踩的点: + +- **`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 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 +ligent 2026.09.22-209538e.dirty (master, 2026-09-22 04:44:33) [GCC 13.2.1 20240309] on linux/x86_64 +``` + +编译器那一段如实反映**实际用的**工具链 —— 这正是它值得记进版本号的原因: +两个产物的行为可能因编译器版本不同而有细微差异,排查时得知道是哪个编的。 +容器产物同样过了**全套 318 项 e2e**(`make docker-test`)。 + +### 版本号:CalVer + git 哈希 + +```console +$ ligent --version +ligent 2026.09.22-209538e.dirty (master, 2026-09-22 04:44:33) [GCC 16.2.1 20260810] on linux/x86_64 +``` +``` + │ │ │ │ │ │ + │ │ │ └ 构建时间(UTC) └ 编译器 └ 目标平台 + │ │ └ 分支(detached HEAD 时为 unknown) + │ └ git 短哈希,.dirty 表示工作区有未提交改动 + └ CalVer:发布日 +``` + +前两段(`2026.09.22-209538e.dirty`)是**版本号主体**,也是下面几节引用它时 +用的形式。后面几段是"这份二进制从哪来的",排查时要看的就是它们。 + +| 形态 | 什么时候出现 | +|---|---| +| `2026.02.14` | 打在 tag 上的发布版 | +| `2026.02.14-1a2b3c4` | tag 之后又提交了 | +| `2026.02.14-1a2b3c4.dirty` | 工作区还有没提交的改动 | + +分隔符用 `-` 而不是 `git describe` 传统的 `+g`:我们只用 git,不存在"区分是 +哪种 SCM"的需求(那正是 `g` 前缀存在的理由),而少两个符号念起来也顺。 + +**编译器与平台由编译期宏判断,不由构建脚本传。** 这是有意的:那份信息是 +"这份代码被什么编出来、给谁跑的",编译器自己最清楚。构建脚本去猜反而多一个 +出错点,而且**交叉编译时会猜错** —— `uname()` 说的是"现在跑在哪",`__linux__` +说的是"编给谁跑",静态产物会被拷到别的机器上跑,该记的是后者。 + +三个编译器各有一条腿,因为它们的宏并不统一: + +| 编译器 | 取法 | +|---|---| +| gcc | `__VERSION__` → `16.2.1 20260810` | +| clang | `__clang_version__`,**要掐掉尾随空格**(它自带一个) | +| tcc | 没有 `__VERSION__`,只有整数 `__TINYC__`,按"去掉小数点当十进制读"还原 | + +判断顺序也有讲究:**clang 也定义 `__GNUC__`**(它假装自己是 gcc 4.2),所以 +clang 必须排在 gcc 前面,否则会被认成 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 的粒度是"天",同一天可能发好几次;而且用户手上 +的二进制未必来自 tag(可能自己改了源码)。带上哈希,报 bug 时能精确定位。 +`.dirty` 标记尤其重要:不带它的话,改过源码的构建和干净构建会报**同样的** +版本号,排查时会被完全误导。未跟踪的新文件也算脏 —— 它可能正是被编进去的那个 +源文件。 + +**版本号不写死在源码里。** 构建期用 `tools/get-version.sh` 算出真值,Makefile +用 `-D` 注入;`src/version.h` 里只留一个回退默认值,给"没有构建系统"的场景 +(比如有人直接拿 gcc 编 `src` 下的所有 `.c`)。这样从 git 编和从 tarball 编 +不会出现版本号不一致,也不必为发版多做一个改源码的提交。这是 dunst / curl +这类项目的通行做法。 + +取值的优先级(`tools/get-version.sh` 里有完整注释): + +| 场景 | 结果 | +|---|---| +| 环境变量 `LIGENT_VERSION_OVERRIDE` | 直接用它(CI 用,tag 打错时能救) | +| git 里有 `v2026.02.14` 形态的 tag | 取最新的那个 | +| 没有 tag(tarball) | 回退到源码文件的日期 | +| 没有 `sh` 或全失败 | `0.0.0-unknown`,**绝不让构建挂掉** | + +`SOURCE_DATE_EPOCH` 也认(可复现构建的标准约定):设了它,同一份源码在不同 +时间编出来的二进制是逐字节相同的,CI 上才能校验产物哈希。 + +版本号只在 `--version` 和 `ligent doctor` 里出现,不参与任何协议交互。 +`doctor` 把它放在最上面 —— 报 bug 时第一件要问的就是"你用的是哪个版本"。 + +**实测过了**(Artix,musl-gcc / gcc 16.2.1,zlib 1.3.1 + OpenSSL 3.0.15 + +curl 8.11.1): + +``` +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,见上一节) +``` + +一路上真正会绊人的坑(脚本里都处理了,记下来免得重踩): + +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 个文件)。 + + 后果不只是难看:`make static` 的 recipe 里那句 `echo " 依赖: $LIBS"` 会把 + `curl-config --static-libs` 的输出原样打出来 → 进工具输出 → 进对话历史 → + 进请求体 → 服务端以 `invalid unicode code point` **拒掉整个请求**。而且坏 + 数据已经留在历史里,之后每一次请求都失败,只能丢掉整轮对话。 + + 修法是**在 configure 期间把 `.pc` 藏起来**(藏不是删,编完放回去):curl + 看不见 `openssl.pc` 就退回"直接用 `-L$prefix/lib`"那条路,路径干干净净。 + 脚本末尾还有一道 `iconv` 往返自检,curl-config 输出不是合法 UTF-8 就直接退出。 + + (这条不需要你做什么,脚本会处理。但如果你在别处手写 curl/OpenSSL 的构建、 + 又碰巧路径带中文,记住别让它走 pkg-config。) + +## 2. 配置 API key + +默认就是 DeepSeek:`https://api.deepseek.com/v1` + `deepseek-flash`, +密钥默认从 **`$DEEPSEEK_API_KEY`** 读取。所以最简单的用法是: + +```bash +export DEEPSEEK_API_KEY=sk-xxxxxxxx +ligent "你好" +``` + +### 配置文件:只有一个位置 —— 二进制旁边 + +配置**只**从可执行文件所在目录读取,文件名固定 `ligent.json`: + +``` +/path/to/ligent ← 可执行文件 +/path/to/ligent.json ← 配置(不存在就用内置默认值) +``` + +```bash +cp ligent.example.json ./ligent.json # 二进制就在当前目录时 +$EDITOR ./ligent.json +``` + +**不做用户级查找**(没有 `~/.config`、没有 `$XDG_CONFIG_HOME`、没有 `~/.ligent.json`), +**不做系统级查找**(没有 `/etc`),**也不看当前工作目录**。 + +这样整个程序是自包含的:拷到别的机器、塞进 U 盘、丢进容器,配置跟着走。 + +**用软链接时,跟着软链接走。** 如果你把 ligent 软链到 `~/bin`: + +```bash +ln -s /opt/ligent/ligent ~/bin/ligent +``` + +那么配置目录是 `~/bin`,而不是 `/opt/ligent` —— 你放在哪、就认哪。 +四种调用方式结果一致:绝对路径、相对路径、链接链(取第一层)、以及经 `$PATH` +调用(`argv[0]` 里没有斜杠的情况)。 + +实现上优先用 `argv[0]`(不解析最后一段软链接),只在拿不到时才退回 +`/proc/self/exe`。目录部分会被规范化(`.` / `..` / 目录自身的软链接都会解析), +但**最后一段不会** —— 那正是"软链接所在目录"的关键。另外会用 inode 比对确认 +找到的确实是 ligent 自己,避免 `$PATH` 里恰好有个同名文件把目录带偏。 + +```bash +$ ./ligent --print-config | grep config_file +config_file : /home/you/ligent/ligent.json (不存在,使用内置默认值) +``` + +`--print-config` 会显示规范位置,所以不用猜它在哪找。 + +想临时换一份配置时有两个显式入口(都不是"默认查找位置"): + +```bash +ligent -c /path/to/other.json "..." # 命令行显式指定 +LIGENT_CONFIG=/path/to/other.json ligent "..." +``` + +整体优先级:**内置默认值 → 配置文件 → 环境变量 → 命令行参数** +(`api_key` 按上面的规则细化)。 + + +配置文件里可以用 `${VAR}` 引用环境变量,避免把密钥明文写进磁盘: + +```json +{ + "base_url": "https://api.deepseek.com/v1", + "api_key": "${DEEPSEEK_API_KEY}", + "model": "deepseek-flash" +} +``` + +想接别家服务,只改 `base_url` 和 `model` 即可,例如: + +| 服务 | base_url | model 示例 | +|---|---|---| +| 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` | 你部署的模型名 | + +其他可选环境变量:`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`。 + +### 思考模式与级别 + +推理模型可以先"想"一段再回答。DeepSeek 的思考模式**默认就是开着的,默认级别 `high`**, +所以什么都不配也能用;想控制就加这两项: + +```json +{ + "thinking": "auto", + "reasoning_effort": "high" +} +``` + +- `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 里的 + +``` +/think 查看当前设置 +/think on | off | auto 切换开关 +/think high 设置级别 +``` + +**思考过程不会显示,也不会进上下文**(只累积、不回调)。但它必须跟着 assistant +消息一起回传:DeepSeek 要求**请求带 `tools` 时把历史里的 `reasoning_content` 全带上**, +漏了下一轮直接回 400。ligent 默认开着工具,所以这一条是硬要求 —— 流式和非流式 +两条解析路径都会保存它。 + +另外,思考模式下 `temperature` 是**不起作用**的(服务端会忽略),`top_p` 会被抬到 +≥ 0.95。`ligent doctor` 会在你同时设了 `thinking: on` 和 `temperature` 时提醒这一点。 + +### 取密钥的优先级 + +``` +LIGENT_API_KEY ← 显式覆盖,任何时候都最高 +ligent.json 的 api_key ← 你写死在配置里的 +DEEPSEEK_API_KEY / OPENAI_API_KEY ← 兜底,且只在端点确实是那一家时才读 +``` + +最后一条是刻意的:**厂商专属的环境变量只在"确实在用这家"时才生效**。 + +否则会出这种事:配置文件里写的是 OpenRouter 的 key,而 shell 里残留的 +`DEEPSEEK_API_KEY` 把它悄悄顶掉 —— 结果 A 家的密钥被发给了 B 家, +对方回一个含糊的 401,你明明配对了却怎么都调不通,排查方向还会被带偏。 +(OpenRouter 对格式不对的 key 回的是 `Missing Authentication header`, +听起来像"请求里没带认证头",实际上带了,只是钥匙不是这家的。) + +所以 `base_url` 指向 OpenRouter / 本地 Ollama / 自建网关时, +ligent **不会**去猜 `DEEPSEEK_API_KEY`,而是明确告诉你"未配置 API key"。 + +`OPENAI_BASE_URL` 同理,只在 `base_url` 还是内置默认值时才兜底。 + +想临时切换服务商,用 `LIGENT_API_KEY` 或 `-k`。 + +### 界面语言 + +界面文案(帮助、错误、诊断、确认提示、等待动画)有中英两份。**发给模型的 +那些不在这里**:系统提示词、工具描述、工具回执只有中文 —— 那是模型读的信息, +跟界面语言是两件事(见 §9)。 + +优先级:`--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`。 + +哪些**故意不译**(都不在这里,也不是漏翻): + +- **给模型看的**:系统提示词、工具描述、工具回执,以及插进对话历史的说明 + (`/include` 的文本块头、图片降级说明、`/shell` 的操作记录)。切成英文之后 + 模型读到的仍然是中文 —— 想换就得改提示词本身。 +- **机器读的格式**:`/log` 导出里 `请求:` / `----- 响应头 -----` 这些标记 + (`tests/replay.py` 按它们切分往返)、`<已隐藏>` 这种脱敏占位符、 + `cap_name()` 这类只给日志和断言用的状态常量。 + +`/tools` 列出来的那张表**不是**这一类:工具名后面那行是纯 UI,文案在表里 +(`tools.ui.*`),跟着界面语言走;发给模型的那份说明仍然只有中文。 + +`src/json.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` 读错参数。 + +## 3. 用法 + +```bash +ligent "统计当前目录下 C 代码的总行数,并找出最大的三个文件" +ligent # 进入交互式 REPL +echo "解释一下 Makefile" | ligent # 从管道读取任务 +ligent --print-config # 查看最终生效的配置(key 已打码) +``` + +### 命令行选项 + +``` +-m, --model NAME 模型名 +-u, --base-url URL API 基地址 +-k, --api-key KEY API key +-c, --config PATH 指定配置文件 + --history FILE 指定输入历史文件(默认 <二进制目录>/ligent.history) + --no-history 不读写输入历史 +-s, --system TEXT 覆盖 system 提示词 +-t, --temperature N 采样温度 + --max-tokens N 单次回复最大 token 数 + --thinking MODE 思考模式:on / off / auto(默认 auto = 由服务端决定) + --effort LEVEL 思考级别:minimal/low/medium/high/xhigh/max/ultra + --max-steps N 单轮任务里模型最多往返几次(默认不限,0 = 不限) + --timeout N 请求超时秒数 + --no-stream 关闭流式输出 + --no-tools 禁用工具,纯对话 + --no-probe 首个请求前不探测模型能力(不额外发请求) + --lang LANG 界面语言:auto / en / zh(默认 auto) +-q, --quiet 不打印工具的输出内容 +-v, --verbose 打印 HTTP 细节 + --color/--no-color 强制开关彩色输出(同时控制 Markdown 渲染) + --print-config 打印生效配置后退出 +-h, --help / -V, --version +``` + +### 子命令 + +``` +ligent doctor [--offline] 体检:配置、密钥、端点、网络逐环检查 +``` + +`--offline` 跳过联网检查。 + +### REPL 命令 + +``` +/help 帮助 /model [NAME] 切换模型;不带名字则在线选 +/exit, /quit 退出 /system TEXT 重设提示词并清空历史 +/reset 清空对话历史 /cls 清屏(不动对话历史) +/meta 元编辑模式:让模型帮你改这段历史 +/think [开关|级别] 思考模式与级别 /tools 列出可用工具(已按能力隐藏的不列) +/probe 现在测一次模型能力(图片/工具);无视 --no-probe 与缓存 +/config 显示当前配置 /cd PATH 切换工作目录 +/history 打印完整历史 /usage token 用量 +/log [FILE] 导出收发记录 /yolo 临时切换写操作确认 +/export 存档当前对话 /import [名] 载入存档(会问要不要打印历史) +/shell [命令] 进 shell / 直接执行命令 +/include [GLOB] 把文件附带给模型;不带参数则交互式挑选,clear 取消 +/back 删除最近一轮(破坏性) /unback 撤销 /back +``` + +`/reset`(别名 `/clear`)和 `/cls` 是两回事,别敲混: + +| | `/reset`、`/clear` | `/cls` | +|---|---|---| +| 作用 | 清空对话历史,重新开始 | 清屏 | +| 影响模型看到的上下文 | **是**,历史没了 | 否,一字不动 | +| 可恢复 | `/unback` 不行,只能 `/import` 旧存档 | 本来就没删东西 | + +`/cls` 发的是 `\033[2J\033[3J\033[H`,和 `clear(1)` 一致:可见区和回滚缓冲 +一起清。所以清完之后往上滚也看不到旧内容了 —— 想找回来看 `/import` 那份存档。 + +### `/model`:不带参数就在线挑 + +`/model NAME` 直接换。**不带参数**时会拉一次在线列表、打出来,然后问你: + +``` +ligent> /model +正在获取模型列表(https://api.deepseek.com/v1/models)... + +服务端列出了 3 个模型: + + 1) deepseek-flash <- 当前 + 2) deepseek-chat + 3) deepseek-reasoner + +选择序号,或直接输入模型名(回车取消)> 2 +模型已切换为 deepseek-chat +``` + +输入行里**既可以填序号,也可以直接写模型名**。后者是特意留的:列表里没有的 +东西恰恰很常见 —— 内部模型、灰度模型、服务端没列出来的别名,都得能填进去。 + +列表本身也是诚实的:顺序保持服务端给的(不去替它排序),重复项和缺 `id` 的 +坏条目会被跳过。序号越界会明确报出来(`序号 9 超出范围(1..3),已取消。`), +`Ctrl-C` 或直接回车取消。 + +拉列表走的是标准的 `GET {base_url}/models`。**拿不到就明说是什么情况**, +而不是笼统地报一句失败: + +``` +拿不到模型列表:服务端返回了空的模型列表(HTTP 200)—— 这个账号下可能没有可用的模型 +可以直接用 /model <名字> 指定,列表拿不到不影响这条。 +``` + +"响应不是 JSON"和"列表是空的"是分开报的 —— 前者多半是 base_url 指错了地方, +后者是账号/权限问题,下一步动作完全不同。 + +> **为什么不做成方向键菜单。** 试过一版,撤了。那种界面要靠 ANSI 反复重画, +> 会把 stdout 搅乱(重定向、日志、粘贴都跟着遭殃);更要命的是它**只让你在 +> 给定的选项里挑**,恰好堵死了"填一个列表里没有的模型名"这条最需要的路。 +> 所以这里是 `/import` 那一套:列表打在上面,输入行是普通的一行,随便写。 + +### `/include`:把文件附带给模型 + +模型看不到你的文件,除非你给它。`/include` 把文件**暂存**起来,跟着下一条消息 +一起发出去 —— 因为附带文件的意义几乎总是配合一个提问: + +``` +ligent> /include src/*.c +已附带 3 个文件: + src/agent.c + src/json.c + src/util.c +下一条消息会带上它们。用 /include clear 取消。 +ligent> 这三个文件里有没有重复的逻辑? +``` + +不带参数则进**交互式挑选**,可以逐层进目录(这就是"递归"的走法 —— glob 本身 +是 shell 语义,`*` 不会跨目录): + +``` +ligent> /include +进入交互式选择。序号进目录 / 选中文件,也可以直接敲 glob 或路径,回车结束。 + +当前目录:/home/you/proj + + 1) src/ (目录) + 2) README.md 12.3 KB + 3) logo.png 4.5 KB 〔图片〕 + .) .. 返回上级 + +已选 0 个。选择 > 1 +``` + +同样是 `/import` 那种普通输入行,不是方向键菜单:**序号、`..`、glob、路径都能 +敲**,空格分隔不支持的(一次一个模式)。`/include clear` 清空暂存,`/reset` +也会清。 + +几件事是有意为之的: + +- **认内容,不认扩展名。** 叫 `.png` 的文本文件当文本带;反过来,内容真是图片 + 的才走图片那条路。 +- **图片不进历史。** 图片存成本机备份,历史里只放一个几十字节的引用,发请求时 + 才展开成 base64 —— 和 `read_image` 完全同一条路。 +- **图片要先确认模型能看图。** 遇到图片时才会去跑能力探测(纯文本的附带不该为 + 了"可能出现的图"先花掉两个探测请求),明确不支持就跳过并说明: + + ``` + 跳过 src/logo.png:当前模型不支持图片输入 + 没有附带任何文件(1 个被跳过)。 + ``` + + 只有**明确不支持**才拦;探测关了或结论不确定时按支持放行 —— 和别处一个原则。 +- **体积有上限。** 单个文本文件 256 KB、一次累计 1 MB、最多 32 个文件。超了会 + 跳过并说明,让你改用 `read_file` 分段读。二进制文件(含 NUL 字节)直接跳过。 +- 暂存的文件名只用于在终端里给你看,**不会**塞进内容块 —— 那会跟着请求发给 + 服务端,而自造字段可能被严格的服务端判 400。 + +输入行尾加 `\` 可以续行写多行任务。`Ctrl-C` 中断当前请求(不会退出程序), +`Ctrl-D` 退出。 + +### 输入历史:像 bash 一样跨进程 + +你在 `ligent>` 敲过的东西会**实时**写进一个历史文件,新的 ligent 实例启动时读回来, +上下方向键就能翻,跟 bash 一样: + +``` +ligent> ↑ → 上一条 +ligent> ↑ ↑ → 再上一条 +ligent> ↓ → 回来一条 +``` + +- **落盘位置**:`<二进制目录>/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 # 同上,环境变量 +``` + +文件是一条历史一行。条目里的反斜杠和换行会被转义(`\` → `\\`,换行 → `\n`), +保证一条占一行且能原样读回;不含反斜杠的普通命令在文件里与输入完全一致。 + +### `/shell`:在子进程里干活 + +``` +ligent> /shell 进入交互式 shell,退出后整段会话记入上下文 +ligent> /shell git log --oneline -5 带参数则直接执行,不进入 shell +``` + +交互式那条路用**伪终端**做双向搬运:子进程以为自己接在真终端上,所以行编辑、 +颜色、Tab 补全、Ctrl-C、作业控制都照常工作;我们在外面旁听并把输出记下来。 + +``` +ligent> /shell +进入交互式 shell。输入 exit 或按 Ctrl-D 返回;整段会话会在退出后记入上下文。 +[you@host ligent]$ git status +[you@host ligent]$ exit + +已把 165 字节会话记录加入上下文(不自动提问)。 +``` + +录制进上下文的内容已经清理过 —— 去掉 ANSI 控制序列、把 CRLF 归一成 LF、 +丢掉退格,否则模型看到的是一堆 `^[[32m`。长度上限 100KB。 + +清理是**跨读取块**做的:转义序列被 `read()` 从中间切断也能接上,不会串味。 + +**全屏程序的内容完全不记进上下文。** `nano`、`vi`、`vim`、`nvim`、`less`、`top` +这类程序会切到终端的"备用屏幕",这段画面照常显示在你眼前,但记录里只剩你敲的 +命令行、下一行提示符,以及退出编辑器之后真实的输出 —— 和人眼在终端里看到的 +一模一样。中间留一对标记: + +``` +[全屏程序运行中,画面内容已省略] +[全屏程序结束] +``` + +理由是那一屏全是光标定位和重复重绘,记下来纯属噪音;而且 `vim` 打开的整个文件 +都会被灌进上下文。程序被强杀、来不及发退出序列时,收尾会补一个结束标记, +保证首尾配对。 + +全屏程序只在 `TERM` 声称支持时才切备用屏幕,所以 `/shell` 会在 `TERM` 缺失或为 +`dumb` 时给子进程补一个 `xterm-256color`(用户显式设了别的值就原样传下去)。 +依据是 ligent 自己的 REPL 就在用颜色、光标定位和括号粘贴 —— 这个终端至少是 +ANSI 兼容的,`TERM=dumb` 属于谎报。补上之后,即使是 `TERM=dumb`,`nano` 也会 +正常进入备用屏幕并被整段忽略。 + +对确实不用备用屏幕、靠成片空格清屏重绘的程序,退而求其次把连续空白压掉 +(短于 8 个空格的缩进原样保留)。 + +**记入上下文但不会自动提问**,你想问什么再问。这样你在 shell 里翻了一圈之后, +可以直接说"刚才那个报错怎么修"。 + +选 shell 的顺序是 `bash → ash → dash → sh`。刻意**不看 `$SHELL`**: +用户环境里的 `$SHELL` 可能是 xonsh 之类交互式外壳,在容器或受限环境里 +经常起不来,反而把 `/shell` 弄坏。 + +### `/usage` + +``` +ligent> /usage +token 用量(本次会话) + API 调用 : 3 次 + 输入 token : 12345 + 输出 token : 678 + 合计 : 13023 + 最近一次输入 : 8192 <- 大致等于当前上下文大小 + 历史消息 : 15 条 +``` + +服务端在每次响应里回报 `usage`,这里只做累加。有些服务端不返回该字段, +那样会明确说"还没回报过用量"而不是显示一堆 0。 + +### `/back` 与 `/unback` + +``` +ligent> /back 删除最近一轮(从最后一条 user 消息到末尾,整组删) +ligent> /unback 撤销,把删掉的装回去 +``` + +是**按轮**删的:模型调用的工具与结果必须整组一起走,只删中间几条会让服务端 400。 + +`/unback` 只在 `/back` 之后没有新对话时可用 —— 一旦你又问了什么,就没有 +"撤销"这一说了,会明确拒绝而不是悄悄恢复到一半状态。 + +### `/yolo` + +``` +ligent> /yolo 临时切换写操作确认(只影响本次会话,不进配置文件) +``` + +### 写操作确认(默认开启) + +任何**会改动磁盘或执行命令**的工具(`run_command` / `write_file` / `edit_file`) +在执行前都会先说明要做什么,然后问一句: + +``` + 即将执行 run_command + 命令: rm -rf build/ + 继续?(Y/N/Always/neVer) +``` + +四种回答: + +| 回答 | 含义 | +|---|---| +| **回车** / `Y` | 允许这一次(默认) | +| `N` | 拒绝这一次 | +| `A` / `Always` | 本轮剩下的改动**一律放行,不再询问** | +| `V` / `neVer` | 本轮剩下的改动**一律拒绝** | + +`Always` / `neVer` 的作用域是**这一轮** —— 一条用户消息引发的那串工具调用。 +下一条消息进来时自动复位,所以它不会像 `/yolo` 那样"一直生效到你关掉", +也不会写进配置。选了哪个都会当场提示你: + +``` + 继续?(Y/N/Always/neVer) A + 本轮后续的改动都不再询问。 +``` + +拒绝不等于失败:上层会把"用户拒绝了"作为工具结果回填给模型,让它换个做法, +而不是让整轮对话就此中断。选了 `neVer` 的话,回填的话会更硬("不要再尝试改动, +直接说明你的结论"),免得模型一个接一个地试。 + +只读工具(`read_file` / `list_dir`)不打扰。 + +> **非交互输入时按默认值放行。** stdin 不是终端(管道、重定向)时没有人能回答, +> 此时直接按 Y 处理、不显示提示。所以 `echo 任务 | ligent` 里的确认实际上是 +> 不生效的 —— 那条路径要靠 `--no-confirm` 的对面,也就是别指望它兜底。 + +关掉:`--no-confirm` / `LIGENT_CONFIRM=0` / 配置项 `confirm: false`。 + +### 自动存档(默认开启) + +在三个时间点自动把当前对话存进 `ligent.archive/`: + +- **退出**时(`/exit`、`Ctrl-D`、单次任务结束) +- **`/reset` 前** —— 免得清空历史时把刚才那段丢掉 +- **`/import` 前** —— 免得载入别的存档时覆盖掉当前的 + +只在"有内容"且"自上次存档后又变了"时才写,所以不会产生一堆重复文件; +只有 system 一条的空对话不会留下存档。关掉用 `--no-auto-save`。 + +### `/meta`:让模型帮你改对话历史 + +`/cls` 清屏、`/reset` 清空、`/back` 退一轮 —— 这些都是粗粒度的。真正难的是 +"把第 3 轮里我那句'用 Python'删掉"、"第 5 轮助手那个结论是错的,改掉"。 + +`/meta` 就是干这个的:进入 `meta>` 子提示符,那边是**另一个 AI**,它的工作 +对象不是继续对话,而是**编辑这段历史**。 + +``` +ligent> /meta +已进入 meta 模式 —— 改的是对话历史本身,不是继续对话。 +用自然语言说要改什么;/help 看命令,/exit 返回。 + +meta> 第 3 轮里我说的"用 Python"删掉 + {/} list_rounds + {/} read_messages {"from":5,"to":7} + {/} replace_message {"index":5,"content":"帮我写个脚本"} + 即将执行 replace_message + #5 的正文改成:帮我写个脚本 + 继续?(Y/N/Always/neVer) +[meta] 已改写 #5(user):28 -> 15 字节。 + +meta> /exit +退出 meta 模式。本次共改动 1 次,meta 自身消耗:3 次调用,1240 输入 / 86 输出 token。 +这段对话已被编辑过:下次 /export 会另起一根,名字形如 <原名>_m<3位>。 +``` + +meta 里的命令只有四个:`/exit` `/diff`(看当前历史,只读)`/undo` `/help`。 +其余输入都是给 meta 模型的指令。 + +#### 和主模型完全隔离 + +meta 会话有自己的 system 提示词、自己的工具表,分发走 `Agent` 上的两个函数 +指针(`exec_tool` / `is_mutating`)。主模型看不到也调不到这些工具,反过来也一样 —— +它就算瞎编一个 `delete_rounds`,也只会拿到"未知工具"。 + +| 工具 | 作用 | 参数里带整段正文 | +|---|---|---| +| `list_rounds [from,to]` | 轮目录:轮号、消息编号范围、条数、字节数、预览 | 否 | +| `read_messages from,to` | 读指定消息的全文(含工具调用参数) | 否 | +| `replace_message index,content` | 改写某条消息的正文 | 是 | +| `delete_messages indices` | 删若干条(只限可安全单删的) | 否 | +| `delete_rounds from,to` | 删连续整轮(含轮内所有工具往返) | 否 | +| `insert_message after,role,content` | 插一条(补一句迟到的约束) | 是 | +| `undo_last_edit` | 撤销上一次改动 | 否 | + +#### 结构安全是硬约束 + +对话历史不是一袋独立消息:`assistant` 带 `tool_calls` 时,后面必须紧跟 +`tool_call_id` 一一对应的 `tool` 消息,否则下一轮请求直接 400。所以: + +- `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. 每次改动都打印增减字节,不静默 + +#### 改过之后另起一根 + +只要成功改过,下次 `/export`(或退出时的自动存档)就是**新根**: +名字 `<原名>_m<3位>`,`parent` 记空。 + +``` +a1b2c3d4 原版 +a1b2c3d4_mq3z meta 改过的(新根) +a1b2c3d4_mq3z_p8w 从 meta 版再分支 +``` + +`_m` 后面是 3 位随机字符,所以下划线后面一共 **4** 个字符;普通分支固定 3 个。 +按长度就能分开,不会歧义。原版仍在 `tmp_` 存档里,丢不了。 + +meta 自身的 token 消耗单独汇报,不混进主对话的 `/usage`。 + +#### 它不是压缩工具 + +meta 模型的提示词里写着:**用户没要求压缩就不要压缩 —— 压缩会丢证据**。 +它的价值在精修:删掉说错的前提、去掉被推翻的方案、改掉助手那次自信但错误的 +结论,让这段历史不再诱导助手重蹈覆辙。 + +> ⚠️ 让 AI 改自己的历史,等于允许它改写"发生过什么"。排查问题时如果关键证据 +> 被"压掉"就麻烦了 —— 所以默认不压缩、每步要确认、改动可撤销、原版有存档。 + +### 等待时的动画:两段标签 + +模型不输出思考过程是有意的,但等待期间屏幕完全静止,和"卡死了"看不出区别。 +所以等待时会在 **stderr** 上转一个灰色的动画,并且**按等待的性质分两段**: + +``` +等待响应.... 请求发出去了,但一个字节都还没回来(网络、排队、服务端没醒) +思考中...... 思维链开始流回来了,模型确实在推理 +``` + +这个区分是有用的:同样是等了 30 秒,"一直卡在 `等待响应`"和"一直在 `思考中`" +是两种完全不同的处境 —— 前者多半该去查网络或端点,后者只是模型想得久。 + +切换的时机是**收到第一片 `reasoning_content`**,不是在发出请求时猜的。所以: + +- 关掉思考(`thinking: off`)或者用不支持思考的模型 → 只会看到 `等待响应`。 +- 非流式(`--no-stream`)拿不到任何增量 → 也只会看到 `等待响应`。 +- 一收到**正文**动画立刻停并擦掉 —— 那时已经有东西可看了。 + +计时是按阶段算的:标签一变就从 0 重新计。超过 5 秒会把秒数亮出来 +(`等待响应.... (12s)`),进一步和卡顿区分。只在 stderr 是终端时才转, +所以重定向或接管道时不会污染输出。 + +### 对话存档:文件名就是谱系 + +``` +ligent> /export 把当前对话存下来 +ligent> /import 列出全部存档,选一份载入 +ligent> /import a1b2c3d4_a9x 按名字直接载入(支持唯一前缀) +``` + +存档放在**二进制所在目录**下的 `ligent.archive/` —— 和配置同一个原则: +整个程序连同它的数据都待在一个目录里,拷走即走。 + +**命名不做时间戳,而是把分支关系写进文件名**: + +``` +a1b2c3d4 从零开始的一段对话 +a1b2c3d4_a9x 从 a1b2c3d4 分出来的分支 +a1b2c3d4_a9x_k2m 再往下分支 +a1b2c3d4_q4k 从 a1b2c3d4 分出的另一支(兄弟) +``` + +新会话第一次存档时随机取 8 位小写字母数字作为根;之后每存一次, +就在**当前所在的节点**后面接 `_` 加 3 位随机字符。 + +`/import` 之前那次保底存档会统一加上 `tmp_` 前缀: + +``` +tmp_mimgkjex 换掉当前对话之前先留的一手 +znmjjhzl 你主动存下来的 +znmjjhzl_ocq 从 znmjjhzl 分出的分支 +``` + +临时存档**不会成为以后分支的祖先**(它只是保底,不属于分支树), +所以上面那支仍然署名为 `znmjjhzl_ocq` 而不是 `tmp_mimgkjex_xxx`。 + +好处是不需要任何索引文件 —— 光 `ls` 一下就能看出谁从谁分出来, +也不会出现索引与实际文件不同步的问题。 + +``` +ligent.archive/ + mvd2bl1h 根 + mvd2bl1h_cpc 分支 + mvd2bl1h_cpc_3oe 分支的分支 + mvd2bl1h_q4k 另一支 +``` + +**长度上限**:完整文件名(含 `.json`)不超过 240 字符。接一级需要 4 个字符, +所以大约能到 58 层。再往下接就**丢掉谱系、重新起一个 8 位根** +(名字撑爆文件系统总不是个办法)。 + +存档内容仍是自描述的 JSON,并把 `name` 与 `parent` 也写进去 —— +这样即使文件被改名、或者谱系因超长而重置,也能从内容里看出它从哪来: + +```json +{ + "format": "ligent.archive", "version": 2, + "name": "mvd2bl1h_cpc", "parent": "mvd2bl1h", + "created": "2026-09-18 22:49:39", + "saved": "2026-09-18 23:10:02", + "model": "deepseek-flash", "base_url": "...", + "message_count": 7, + "messages": [ ... ] +} +``` + +`/import` 载入后会问一句要不要把这段历史打出来: + +``` +已载入 mvd2bl1h_cpc(7 条消息)。之后 /export 会存成 mvd2bl1h_cpc_xxx +是否输出这段历史的内容?(Y/n) +``` + +回车(默认 Y)就**完整**逐条打出来,带角色标签和颜色: + +``` +-- 1/7 系统 -- +你是用户的 AI 助手 +...(system 提示词全文) + +-- 2/7 你 -- +帮我把 build 目录清一下 + +-- 3/7 助手 -- + -> 调用 run_command id=call_1 + {"command": "rm -rf build/*"} + +-- 4/7 工具 -- +[tool_call_id=call_1] +已删除 12 个文件 +[exit code: 0] +``` + +"完整"是字面意思:**正文不截断、工具参数不省略、总量不封顶、`system` +提示词也全文展开**。都回答了 Y,就该看到全部 —— 截一半反而会让人以为历史里 +本来就这么点东西。`tool_call_id` 是特意标出来的,否则一堆工具输出看不出 +谁在回应谁。 + +回 `n` 就只装进上下文、屏幕保持干净。之后想随时再看一遍,敲 `/history` —— +它打的就是同一份东西(当前这段对话,一条不落)。 + +两点行为约定:存档里的 `system` 消息不会被带进来(否则载入旧存档会把你现在 +的提示词换掉),当前生效的那条保留;载入后对话开始时间重置。空对话不会产生 +存档。`/reset` 会断开谱系,下一次存档另起一个根。 + +(输入历史**不会**跟着清 —— 它是一份持久的、跨会话的用户资源,记的是 +"我在 `ligent>` 敲过什么",和当前装的是哪段对话无关。) + +### 先跑一次体检 + +配置调不通时,**先别猜**: + +```bash +ligent doctor +``` + +它会把整条链路逐环打出来 —— 二进制与配置目录怎么定的、配置文件在不在、 +密钥从哪来、格式与端点是否匹配、环境变量有没有互相顶掉、参数是否离谱、 +网络通不通、端点有没有跳转。 + +``` +【API key】 + 来源 配置文件 + 值 sk-or-v1...(73 字符) + 格式推断 OpenRouter + 与端点匹配 [v] 端点属于 OpenRouter,密钥格式吻合 + +【网络】 + 连通性 [v] 可连接,TLS 校验通过 + HTTP 状态 307 + 重定向 [x] 端点跳转到 https://example.invalid/v1/chat/completions + ligent 不自动跟随重定向:跨源跳转会丢掉 Authorization 头, + 服务端只会回 401 "Missing Authentication header",看起来像是密钥没带, + 实际是 URL 跳了。把 base_url 直接改成上面那个地址即可。 +``` + +它专门盯着几类"报错信息会把人带偏"的问题:配置被残留环境变量顶掉、 +密钥与服务商不匹配、端点重定向导致伪 401、配置文件语法错误。 +退出码:有问题为 1,否则 0。 + +> doctor 在**配置本身加载失败时也能跑** —— 那正是最需要它的时刻。 + +### 行编辑 + +REPL 自带行编辑器(`src/lineedit.c`),不依赖 readline,也不用终端的行规程: + +| 按键 | 作用 | +|---|---| +| `←` `→` | 光标左右移动(按**字符**而不是字节,中文不会移半个字) | +| `↑` `↓` | 翻历史记录 | +| `Home` / `End` / `Ctrl-A` / `Ctrl-E` | **当前这一行**的行首 / 行尾(粘进来多行时不会跳到整块两端) | +| `Backspace` / `Delete` | 删除光标前 / 光标处的字符 | +| `Ctrl-K` / `Ctrl-U` / `Ctrl-W` | 删到行尾 / 删到行首 / 往回删一个词 | +| `Ctrl-C` | 丢弃当前输入行并开新行(请求执行中则是中断请求,不退出程序);**在写操作确认提示上等于"拒绝"** | +| `Ctrl-D` | 空行时退出;行非空时删一个字符 | +| **粘贴多行** | 整段插入,**不会被逐行执行** | + +粘贴用 bracketed paste(`\033[?2004h`)实现:终端会把粘贴内容和真实按键区分开, +收到粘贴块就整段放进缓冲区,里面的换行不再触发提交。所以可以直接把一段 +多行需求、一个代码片段、一段日志粘进来,编辑好再回车,它会作为**一条**消息发出。 +`Ctrl+Shift+V`、中键、菜单里的粘贴走的都是这条路。 + +**粘贴进来的内容会被清洗一遍。** 因为 `read_paste()` 收的是终端给的原始字节、 +不经按键分类,里面夹着控制字符就会让"编辑器的算盘"和"终端实际显示"对不上: + +| 字符 | 不清洗会怎样 | 清洗后 | +|---|---|---| +| `\t` | 终端按制表位(8 的倍数)展开,编辑器只当 1 列 —— 粘一段带缩进的代码,**光标从此偏掉,之后每敲一个字都在错位置上** | 就地展开成空格,缓冲区里不留制表符 | +| `ESC` | 被终端当成转义序列**真的执行掉**:粘贴内容能改颜色、甚至清屏 | 连同后面整条序列一起丢弃 | +| `\r` | 把终端光标拉回行首,屏幕当场错位 | 丢弃(CRLF 归一成 LF) | +| `NUL` | 会把 C 字符串截断 | 丢弃 | + +制表符展开是按**插入点当时的列号**算的,不是从 0 开始 —— 在行中间粘贴时缩进才对。 + +为此缓冲区支持多行,渲染也相应升级为多行 + 折行感知。 + +实现方式是进 raw 模式自己接管按键。最早用 `fgets` 直接读时终端停在规范模式, +方向键发出的转义序列会被当成普通字节读进来、显示成 `^[[D`,所以必须自己处理。 + +超出终端宽度时自动折行,光标位置按行列重算;宽度计算复用与表格渲染同一套 +CJK 规则(中文按 2 列)。行尾 `\` 续行仍然可用,续行的每一段都能独立编辑。 + +**你敲的那一行整个是绿色的**(提示符 + 输入的文字),翻历史或滚屏时一眼能 +看出哪些是自己的输入、哪些是模型输出: + +``` +ligent> 你好 ← 绿色,整行都是输入 +你好!有什么可以帮你的吗? ← 普通色,是模型正文 +``` + +颜色是行编辑器统一加在整行两头的,所以提示符字符串本身必须保持纯文本 —— +在提示符里自带一个 reset,后面的输入就会掉回默认色。 + +配色跟着正文那个开关走(`--color` / `--no-color` / 非终端自动关闭)。 +绿色区间那两串转义序列在算光标位置时会被跳过 —— 否则 `\033[32m` 会被当成 +5 个可见字符,光标每次重绘都会偏 5 列。 + + +## 4. 终端渲染 + +助手回复会做一层**流式 Markdown 渲染**:模型吐出的 `#`、`**粗体**` 等标记 +在终端里直接变成样式,而不是原样显示。工具输出(文件内容、命令结果)**不做** +任何渲染,保持原样,避免破坏代码。 + +### 只用 16 色 + +全部配色都落在最窄的 **16 色 ANSI** 里:前景用 30-37,加粗/暗淡/斜体/下划线 +用 SGR 属性 1/2/3/4。不用 90-97 那一段(aixterm 的"亮色"),也不用 256 色或 +真彩。 + +理由是这个项目**硬编码 SGR、不查 terminfo**(也不查 `COLORTERM`)—— 既然不去 +探测终端能力,就该待在**所有终端都认**的那一档里。90-97 虽然到处都是,但严格 +讲不属于 16 色,在精简终端、把 `TERM` 设成不认识的值、或者老式串口终端上, +它可能被当成两个参数解析或者直接忽略。 + +这条约束由 `tools/check-colors.sh` 守着,`make test-unit` 里会跑。**它值得单独 +一个脚本,是因为颜色越界看代码看不出来** —— 一个越界的码在人眼里也只是"一个 +蓝色",只有拿尺子量才知道它在哪一段。(这个检查抓到过一处:链接原本用的 +`\033[94m` 就是越界的亮蓝。) + +配色语义在各模块之间是一致的: + +| 用途 | 码 | 出现在 | +|---|---|---| +| 成功 / 工具执行成功 / 输入行 | 32 绿 | agent、doctor、main、lineedit | +| 失败 / 错误 | 31 红 | agent、doctor | +| 警告 / 需要注意 | 33 黄 | doctor、confirm、main | +| 次要信息(提示、路径、引用) | 2 暗淡 | 各模块 | +| 强调(工具名、标题) | 1 粗体 | 各模块 | +| 链接 | 4;36 下划线+青 | markdown | + +链接用**下划线**而不是只靠颜色:下划线是 SGR 属性,在单色终端、色盲用户、 +以及把颜色重映射过的主题里都还在 —— 颜色会丢,属性不会。 + +| 语法 | 效果 | +|---|---| +| `#` ~ `######` | 标题,按层级着色(H1 粗体青 / H2 粗体品红 / H3 粗体蓝 / H4+ 粗体) | +| `**粗体**` | 粗体,标记被吃掉 | +| `*斜体*` / `_斜体_` | 斜体 | +| `` `代码` `` | 青色的行内代码,**反引号保留**(方便复制到 shell),内容不做任何解析 | +| `-` `*` `+` 列表 | **保留原始标记字符**并着青色(不替换成 `•`) | +| `> 引用` | 暗色 `│ ` 前缀 | +| `---` | 暗色分隔线 | +| `\| a \| b \|` 表格 | 带边框对齐的表格,表头加粗,支持 `:---` / `:---:` / `---:` 对齐 | +| `[链接文字](url)` | `[链接文字]` 加下划线并着青色,`(url)` 原样保留 | + +**完全不做渲染、逐字节原样保留**的四种情况: + +| 语法 | 处理 | +|---|---| +| ` ``` ` 围栏代码块 | 整块(含 ` ``` ` 标记本身)原样输出,零 ANSI 转义 | +| `![图片](src)` | 保留原文 | +| 链接的 `(url)` 部分 | 保留原文(不隐藏、不做超链接跳转) | +| 行内代码的内容 | 不做任何解析 | + +表格渲染示例(列宽按**显示宽度**计算,中文按 2 列,所以边框能对齐): + +``` +┌──────┬──────┬──────┐ +│ 名称 │ 数量 │ 说明 │ +├──────┼──────┼──────┤ +│ 苹果 │ 3 │ 常见 │ +│ 香蕉 │ 12 │ 较长 │ +└──────┴──────┴──────┘ +``` + +### 两个刻意的取舍 + +**`_斜体_` 会渲染,但下划线标识符不会被误伤。** 按 CommonMark 规则禁用"词内强调": +`foo_bar_baz`、`a_b_c` 原样保留;与另一个下划线相邻时也一律当字面量, +所以 Python 的 `__init__`、`__main__` 不会被拆成斜体。中文不算词字符, +因此 `中文_斜体_` 能正常生效。 + +**表格必须等整块收完才能渲染。** 列宽取决于所有行,所以表格在流式过程中会先缓存在 +缓冲区,遇到非表格行(或消息结束)才一次性输出。行数超过 200 行会先渲染已有部分, +避免超大表格一直不显示。 + +**表格必须顶格写 `|`。** 不带首竖线的写法(`a | b` / `--- | ---`)按普通文本处理。 + +这条限制不是偷懒,而是为了保证**输出与分片方式无关**:判断"这是不是表格"所需的信息 +必须在行的第 0 个字节就能拿到。如果允许"整行任意位置含竖线",那么当 SSE 的分片边界 +恰好落在竖线之前时,行的前半段已经被当成普通文本输出、无法收回;而同一行若整块到达 +则会被识别为表格——**同样的内容,渲染结果取决于网络怎么切的包**。这个问题是模糊测试 +找出来的(最小复现只有 4 字节:`s|\n-`)。 + +### 流式是这里的难点 + +SSE 的分片位置是随机的,`**bold**` 完全可能被切成 `**bo` / `ld**` 两片, +甚至两个 `*` 分别落在两片里。所以渲染器是一个**带状态、可挂起**的状态机 +(`src/markdown.c`): + +- 遇到可能是标记前缀的尾部(结尾的 `*`、行首的 `#`/`>`/`-`、`` ` ``、`[`), + 它**不立即输出**,而是把这段字节留在缓冲区里等下一个分片,凑齐后再判定。 +- 绝大多数情况下挂起只有几个字节,延迟不可感知;唯一的例外是**表格** + (列宽依赖所有行,必须整块收完才能渲染)。 +- 缓冲区有 64KB 硬上限兜底,表格另有 200 行 / 单行 4KB 的上限, + 防止异常输入把内存吃满或让输出长时间卡住。 +- 流式与非流式走同一个渲染器,因此两种模式的输出**逐字节一致**。 + +`tests/test_markdown.c` 用一条不变量来锁住这个行为:把同一段文本分别按 +**一次性 / 3 字节 / 逐字节** 三种方式喂进去,输出必须完全相同。 + +### 想看效果? + +仓库里带了一个独立的小演示程序,不需要 API key: + +```bash +make demo # 编译并运行,用内置示例展示所有语法 +./build/md_demo --plain # 对照:输出原始 Markdown +./build/md_demo -s # 流式模式:每 5 字节一片、间隔 12ms +./build/md_demo --compare # 自检:不同分片方式输出是否一致 +./build/md_demo README.md # 渲染任意 Markdown 文件 +echo '# hi **there**' | ./build/md_demo +``` + +`-s` 看起来和普通输出几乎一样,这正是重点:分片边界落在 `**`、行首 `#`、 +表格中间都察觉不到——因为状态机会把可能被切断的标记暂存到下一片再判定。 +`--compare` 会把同一段文本按 1/2/3/5/7/13/64 字节以及**随机**分片各渲染一遍, +逐字节比对结果,这也是 `make test` 会跑的一项。 + +### 什么时候不渲染 + +只有输出到终端时才渲染。以下情况会退化为**逐字节原样输出**: + +- 输出被重定向或接管道(`ligent "..." > out.md`) +- 显式 `--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` | + +单次工具输出上限 100 KB,超出会截断并提示,避免一次 `cat` 把上下文撑爆。 + +### `edit_file`:行号集合钉住的替换 + +`line` 是**集合型**参数,而且**可以整个省略**:省略 = 不设行范围 = **全文件替换 +所有匹配**(就是编辑器里那个"全局替换")。给了它就是集合型 —— 单个整数 +(`"line": 41`)或整数数组(`"line": [41, 42]`)都收,行号就是 `read_file` 打出来 +的那些(从 1 开始)。给了行号之后,替换只发生在这些行里,`old_string` 也只需 +**在这些行里**唯一。 + +判定命中看的是 `old_string` 的**起点**:起点落在集合里的任意一行上就算数。 +所以 `old_string` 跨好几行时,照 `read_file` 的显示把覆盖到的那几行都填进数组就行, +不必先赌起点到底是第 41 行还是第 42 行。集合是**搜索范围**,不是"每行都改一遍": +真改动的仍然只有唯一那一处(`replace_all=true` 或省略 `line` 时才动全文件)。 + +**省略 `line`** 时没有范围约束:整个文件都是搜索范围,而且**命中几处就换几处** +(不再要求唯一),回执里会写明"全文件替换 N 处"。这是故意留着的逃生门 —— 有些 +改动本来就该全文件生效(比如统一改个名字);但它不是默认路径:不给行号就没法 +声称"我知道改的是哪一处",要改一处仍得给行号。 + +为什么不再只靠"全文唯一":行号是模型唯一的空间坐标,而"全文唯一"要求整个文件里 +恰好只有一处 —— 文件一长这个条件经常不成立(同一个 `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` | +| 文件是二进制 | 拒绝并说明理由(见下节) | + +集合里的重复行号会被**去重**(`[1,1]` 等价于 `[1]`):不然同一处匹配会被数成两处, +"行内只有一处"的判断就会莫名其妙地失败。 + +`replace_all=true` 的含义没变:替换文件里**所有**匹配;但 `line` 集合仍必须圈住其中 +真实的一处 —— 等于强制模型先看过文件,而不是凭印象做全文替换。 + +### 文本工具只处理文本 + +`read_file` / `write_file` / `edit_file` 遇到二进制文件一律**拒绝**,而不是硬读或 +硬写。判据只有一条:**出现 NUL 字节**(和 `/include` 的判据一致 —— 两处必须一样, +否则会出现"同一个文件 `/include` 说能带、`read_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` 这类。 + +**搜索内容不在这套工具里**:模型要搜就用 `run_command` 里的真 `grep` +(`grep -rn 模式 目录`)。以前有个 `search_files`(后来改名 `grep`)的包装, +删掉的理由见下节。 + +### 工具命名约定 + +工具名是协议的一部分:它每个请求都要重发,还要被人、被模型、被代码里好几处引用。 +所以它有一套明写的规矩(`src/tools.c` 顶部有同一份,两处必须同步): + +1. **形式**:全小写 snake_case,动宾结构「动词_对象」。动词必须来自白名单, + 而白名单**按域划分** —— 要用新动词,先把它加进对应域: + - 文件与执行域:`read` / `write` / `edit` / `list` / `run` / `view` + - 历史编辑域(`/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`,凡是"一条标准命令就能干、而且干得更好"的事,就别再做一个 + 自己的版本 —— 多一个工具 = 多一份 schema、多一个要维护的边界、多一次被误用的 + 机会,换来的只是省一次 `run_command`。 + +### 为什么没有搜索工具 + +曾经有 `search_files`(后来按规矩改名 `grep`),现在删了 —— 它是第 8 条规矩的 +反例:真 `grep` 支持正则、文件名过滤、上下文行(`-A/-B/-C`)、大小写、多模式、 +二进制提示,我们那版每一样都要自己写一遍,还得跟着需求慢慢长(第一版就叫错了 +名字:明明是搜内容,却叫 `search_files`,模型自然会去传 `pattern="*.c"`)。 +删掉之后工具表从 8 个变成 7 个。 + +(顺带一提:原来还有个子代理功能 `spawn_subagent`,它拿不到 `run_command`, +所以既搜不了内容、也没法真正独立干活,等于只是"多花一份上下文把文件读一遍", +现在整个删掉了。) + +名字只定义一次(`src/tools.h` 的 `TOOL_*` 宏),工具表也只有一处 +(`src/tools.c` 的 `TOOLS[]`):schema、执行分发、要不要走确认、`/tools` 的输出 +全从那张表生成。所以**改名只需改 `.name` 一列** —— 这三样以前各有一份 `strcmp` +链,改名必定漏一处,而且漏了不报错(只是静默地少认一个工具)。 + +**不保留旧名别名**,这是刻意的:schema 里只发新名,dispatch 再认旧名只会掩盖 +"调用方没跟着改"(旧存档、用户自写的提示词、测试夹具都一样)。认不出来就明确报 +`未知工具 "xxx"`,比静默兼容更容易发现漏改。 + +### `read_image`:图片怎么进对话 + +模型自己看不到磁盘。给它一个路径,它得先把图**读进来**——这一步就是 +`read_image`: + +```bash +ligent "看看 build/screenshot.png 里的报错是什么" +``` + +内部做了四件事,每件都有它的理由: + +**1. 按内容认格式,不认扩展名。** 读文件头判断 PNG / JPEG / GIF / WebP。 +一个叫 `.png` 的文本文件会被明确拒绝,而不是发出去等一个莫名其妙的 400。 + +**2. 请求里走标准的 `image_url` + base64 内联。** 也就是: + +```json +{"type": "image_url", "image_url": {"url": "data:image/png;base64,...."}} +``` + +这是协议里图片仅有的两种走法之一(另一种是外部 URL)。**这是通用形状**: +DeepSeek 和 MiMo 都收,ligent 不去网上抓图,所以只产出 base64 这一种。 + +图片只能出现在 `user` 消息里,而工具结果只能是字符串。所以工具跑完之后, +ligent 会**补一条合成的 `user` 消息**承载图片。它算附件、不算提问——`/back` +按轮删除、上下文回收时都按这个规则处理,不会把它当成新的一轮。 + +**3. 但图片字节不进历史。** base64 内联有个直接后果:字节会躺在会话里, +每轮请求都重发。归档、`/history`、`/log` 全都跟着膨胀。所以**存储形态和发送 +形态是分开的**: + +``` +存进历史 {"type": "ligent_image", "backup": "1700000000_a1b2.png"} +发给服务端 {"type": "image_url", "image_url": {"url": "data:image/png;base64,...."}} +``` + +`read_image` 先把图存到 +`<二进制目录>/ligent.archive/images/<时间戳>_<4位随机>.`,历史里只挂一个 +对备份的引用;**备份文件才是字节的真身**,真正构造请求时才读出来编码。 +`/export` 出来的存档因此还是几十 KB,而不是几十 MB。 + +两个内部块类型都带 `ligent_` 前缀,就是为了万一哪天漏了展开、直接发出去, +服务端报的错能一眼看出是我们的问题: + +| 内部块 | 含义 | +|---|---| +| `ligent_image` | 引用一张本地备份 | +| `ligent_note` | 展开失败后的文字说明(备份读不到了、体积超了) | + +**4. 体积在本地就判掉。** 图片会留在历史里,所以一次超限不是一次失败, +而是从此**每一轮**都超限。与其发出去等一个 400,不如当场告诉模型"太大了"—— +而且**不备份**:一张永远发不出去的图,留着备份没有意义。 + +上限取各家文档里最严的一档(DeepSeek 单图 32 MiB、请求体 48 MiB;MiMo +base64 字符串 50 MB): + +| 限制 | 值 | +|---|---| +| 单张图(base64 之后的长度) | 32 MiB | +| 一次请求里图片的 base64 总量 | 44 MiB(给提示词/历史/工具描述留 4 MiB) | + +注意判的是**编码后**的长度:base64 会膨胀 4/3,所以一张 25 MiB 的原图就已经 +超了。被拒时工具直接把话带给模型,让它请用户先缩小或压缩。 + +**5. 服务端要是还是拒了呢?** 本地拦的是格式和体积,但服务端还可能因为别的 +原因不收(图片损坏、这个变体不支持……)。那条引用躺在历史里,不修的话之后 +每一轮都会以同样的 4xx 失败、历史却原样保留——用户只剩 `/reset` 一条路。 +所以 ligent 撞到 4xx 且请求里有图片时,会把图片**从历史里就地摘掉**、换成 +一句说明再重试一次。 + +给**模型**看的是能直接照做的一句话: + +``` +[图片 1700000000_a1b2.png 不符合 API 所需的格式标准,无法上传,请规范化图片后重试] +``` + +给**你**看的(stderr)是移除处数和服务端的原话 —— 原始报错通常是给人看的英文 +错误码,塞进上下文只会让模型分心,但完全不显示又会让你没法排查: + +``` +服务端拒绝了请求里的图片,已把 1 处图片从对话里移除后重试... +服务端原话:HTTP 400 —— image rejected: unsupported image variant +``` + +"就地"是要紧的:光在请求副本里摘掉,历史里那条坏引用下一轮还会再犯。 + +`read_image` 不写文件、不执行命令,所以**不触发写操作确认**。备份是副作用, +不是"改动你的东西"。 + +它会不会出现在工具表里,取决于模型收不收图片 —— 见下一节。 + +### 模型能力探测:怎么知道它收不收图片 + +`read_image` 只对**能看图的模型**有意义。可是**没有任何接口能问出来** —— +DeepSeek 和 MiMo 的 `GET /v1/models` 都只返回 `id` / `object` / `owned_by`, +没有 capability 或 modality 字段(OpenAI 自己的也是如此)。这是 OpenAI 兼容层 +的通病。 + +所以只能**发试探请求**问。ligent 在**首次请求之前**跑一个独立的"能力测试序列", +它不碰对话历史,也不进历史: + +| 步 | 发什么 | 结论 | +|---|---|---| +| 1 | 一句纯文本 + `max_tokens: 1` | 通了才说明模型是活的,后面的否定结论才作数 | +| 2 | 同一句 + 一张 1×1 的图 + `tools` | 200 → 两样都支持,到此为止 | +| 3 | 拆开单独发(图 / tools) | 第 2 步被拒时才知道是哪一个不行 | + +关键在于**否定结论只在基线通过时才敢下**:单个试探 400 而基线 200,差别就在 +那一样东西上。这样不需要猜服务端的错误文案("不支持图片"、"invalid content"、 +"multimodal not supported"……千家千样),也就不会因为文案变了而误判。 + +**猜不准时一律按"支持"处理**:多暴露一个工具,代价是模型可能白调一次(还有 +4xx 降级兜底);而错误地藏掉工具,你会以为模型看不见图。 + +结果按 `base_url + model` 缓存,所以**换模型会自动重测**,切回来命中缓存。 +探测完总会打一条灰字结论 —— 支持什么、不支持什么、以及我们因此**做了什么**, +都在同一行里,不用去猜: + +``` +模型能力:多模态(图片输入)支持 * 工具调用 支持 +模型能力:多模态(图片输入)不支持(已隐藏 read_image) * 工具调用 支持 +模型能力:多模态(图片输入)未测 * 工具调用 未测 —— 没测出来的一律按「支持」处理(HTTP 401) +``` + +探测出"不支持"时把动作也写在同行,是因为只报"不支持"而不说"我已经把工具藏了", +用户会以为工具还在。 + +代价要说清楚:**开启探测时,每个进程的第一次请求之前会多出 2~4 个小请求** +(常见情况是 2 个,模型受限时才拆开)。不想要这个开销就用 `--no-probe` +(或配置项 `"probe": false`)—— 那时不探测、什么结论都不下、 +`read_image` 照常暴露,也就是探测功能出现之前的行为。 + +想临时测一次(比如用了 `--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 干脆明确写了不支持上传本地文件。 + +也就是说 `{"type": "file", "file_id": …}` 这条路**只有 DeepSeek 一家认**。 +而 `image_url` + base64 内联是 DeepSeek 和 MiMo 都认的通用形状(OpenAI 兼容层 +普遍也认)。既然"图片不进历史"这件事可以靠**本机备份 + 请求前展开**自己做到, +就没有理由再为它绑定一个厂商私有的引用协议。 + +代价写在 §9 里,不藏着。思考模式则仍然是方言(见 §9),那一条没有通用替代。 + +> ⚠️ **安全提醒**:按需求本工具**不做沙箱限制**——模型可以执行任意 shell 命令、 +> 读写任意路径。建议在容器、虚拟机或独立用户下运行,不要在存有重要数据的 +> 生产环境里直接把 `--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)、 + 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 感知, + 以及输入历史的持久化(实时追加 + 启动加载 + 上限截短) + doctor.c/h 环境体检:逐环检查并给出可操作的结论 + archive.c/h 对话存档:导出/列举/载入(文件名即谱系) + image.c/h 图片:按文件头认格式、本机备份、请求前把备份展开成 base64 内联 + shell.c/h /shell:伪终端双向搬运 + 录制 + meta.c/h /meta:元编辑对话历史(独立工具集 + 快照栈 + 结构安全校验) + spinner.c/h 等待动画:两段标签(等待响应 → 思考中),后台线程, + 只在 stderr 是终端时启用 + i18n.c/h 界面文案表(中英各一份)、语言探测、{name} 占位符替换 + markdown.c/h 流式 Markdown 渲染状态机(标记跨分片挂起、表格列宽对齐、 + 近似 wcwidth 的 CJK 宽度计算) + agent.c/h 主循环:消息历史管理、多步工具调用、流式打印 + config.c/h 默认值 → 配置文件 → 环境变量 + main.c 参数解析、单次任务模式、REPL +tools/ + md_demo.c 渲染效果演示程序(独立可执行,不依赖网络) +tests/ + test_json.c JSON 解析/序列化/转义/非法输入/嵌套深度 + test_tools.c 工具的端到端行为(真实执行命令、真实读写文件) + test_agent.c 上下文不变量(system 不重复、reset 语义) + test_config.c 默认值、环境变量优先级、配置文件覆盖与 ${VAR} 展开 + 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_meta.c 元编辑:轮次切分、结构安全、七个工具、撤销、重新存根标记 + test_image.c 图片:格式嗅探、备份与索引、附件队列、损坏索引降级 + 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 + 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` 可以省略,省略就是 + **全文件替换所有匹配**:逃生门留着(有些改动本来就该全文件生效),但它不是默认 + 路径 —— 要改一处仍得给行号。 + +## 7. 测试 + +```bash +make test # 全部 +make test-unit # 纯单元测试,不需要网络(含演示程序自检 + 模糊测试) +make test-e2e # 用本地 mock 服务端验证完整链路,不需要真实 API key +make test-fuzz # 只跑模糊测试,迭代更多轮次 +make demo # 编译并运行渲染效果演示 +``` + +### 模糊测试 + +`tests/fuzz.c` 用随机与变异输入去砸两个"解析不可信数据"的模块(JSON 解析器、 +Markdown 渲染器),检查四条不变量: + +| 模块 | 不变量 | +|---|---| +| JSON | 任何输入都不崩溃、不泄漏 | +| JSON | 解析成功 ⇒ dump → parse → dump 两次结果一致(幂等) | +| Markdown | 任何输入都不崩溃、不泄漏 | +| Markdown | **一次性喂 与 任意分片喂 输出逐字节一致** | +| Markdown | 不含元字符的输入必须原样输出 | + +种子固定,失败可复现。失败时会把输入写到 `build/md_fail.bin`, +用 `build/reduce build/md_fail.bin` 可以自动最小化成最短复现样例。 + +`tests/reduce.c` 是个 delta-debugging 最小化器 —— 上面那条"表格必须顶格" +的规则,就是靠它把 376 字节的失败输入缩到 4 字节才定位到根因的。 + +```bash +make build/fuzz build/reduce +./build/fuzz 20000 42 # 指定种子跑 2 万轮 +./build/reduce build/md_fail.bin # 最小化最近一次失败 +``` + +端到端测试不需要联网也不需要密钥:`tests/mock_server.py` 会在本地起一个 +OpenAI 兼容服务端,按请求内容返回确定性的流式响应,从而验证「模型请求工具 → +本地真实执行 → 结果回填 → 模型给出最终回答」的完整闭环。 + +> 存档目录(`<二进制目录>/ligent.archive`)是运行期状态而不是构建产物,e2e 却 +> 需要它干净,所以脚本开机时会检查:**非空就先整体挪到 `/tmp/ligent.archive.saved.*`** +> 并打印路径,而不是直接删掉。移不动就直接中止(宁可测试不跑,也不误删)。 +> 挪走之后 e2e 才会在自己的那份临时目录里折腾 —— 跑两次也不会把你上一轮的 +> 存档叠进去。 + +用 AddressSanitizer / UBSan 跑一遍: + +```bash +make clean +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 项)在这个配置下是干净的。 + +## 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 都会把结果截断) | +| 错误体上限 | 只保留 256KB 用于诊断 | + +重试次数可配置:`--retries N`(默认 2,0 = 不重试)/ `LIGENT_RETRIES` / 配置项 `retries`。 + +### 传输中断 + +流式请求中途断连时,**已经收到的部分内容会保留**成一条消息并提示"传输中断", +而不是整段丢弃 —— 否则屏幕上有这段文字、上下文里却没有,后续对话会对不上。 + +### 上下文超限:如实报错,不替你做决定 + +历史超过模型窗口时,服务端回一个 4xx。ligent **不会自动删掉任何一条消息** —— +它把服务端的原始错误原样报出来,历史一个字节都不动: + +``` +ligent> 第二轮 +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`。它被**整个删掉**了,理由: + +- 触发它的判据只是错误文案里的关键词(`too long`、`上下文`、`超出`……), + 任何 4xx 蹭到这些词都会触发裁剪 —— 代价却是**不可逆地**删掉几轮对话。 + 用一句猜出来的判据去毁掉对话记录,代价不对等。 +- 它把"历史太大了"这件本该由用户决定的事(`/back` 摘掉哪一轮、`/reset` 重来、 + 还是换一个窗口更大的模型)自动化掉了;用户看到的是对话莫名其妙少了几轮。 + +现在怎么处理由你决定:`/back` 摘掉最近一轮(可 `/unback` 撤销)、`/reset` 清空 +重来、`/export` 存档后另起一段,或者直接换个模型。`/usage` 里的"最近一次输入 +token"≈ 当前上下文大小,可以据此判断还剩多少余量。 + +(`/meta` 里让模型帮你做这件事仍然是可行的 —— 它删的是你看得见、也点过头的那些轮。) + +### 对话结构不变量:tool_calls 必须连着 tool + +服务端对消息结构的要求很硬:**带 `tool_calls` 的 assistant 后面必须紧跟**与每个 +`tool_call_id` 一一对应的 `tool` 消息,中间不能夹别的东西,也不能少。违反了就是: + +``` +HTTP 400 — An assistant message with 'tool_calls' must be followed by tool +messages responding to each 'tool_call_id'. (insufficient tool messages +following tool_calls message) +``` + +麻烦在于这条历史**留在上下文里**——一旦写坏,之后每一轮都会以同样的 400 失败, +用户只能 `/reset`。所以 ligent 两头都做了: + +**发之前先体检。** 每次构造请求前扫一遍历史:缺的 `tool` 消息补一条占位 +("这次调用没有拿到结果,不要假设它执行过"),孤儿 `tool` 消息(找不到对应 +调用的)丢掉,并如实打一行说明。没问题时它只是一次只读扫描,不分配任何东西。 + +``` +对话结构有问题(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` 消息再返回。 + +回归用例是第 53 段,而且 `tests/mock_server.py` **和真服务端一样校验这个结构** +(`pairing_error`):以后任何写坏历史的回归都会在 e2e 里当场 400,而不是等到 +用户真拿它去连服务端才发现。 + +### 图片降级只在错误确实冲着图片来时动手 + +服务端 4xx 时会检查请求里有没有图片引用,有就把图片从历史里摘掉再重试一次 +(见 §5)。这一条**加了关键词判断**:只有错误里出现 `image` / `vision` / +`multimodal` / `content type` / `图片` 这类词才动手。 + +理由是它会把图片**永久删掉**:一条"tool_calls 后面缺 tool 消息"的 400 也被当成 +图片问题处理的话,图白丢,而且真正的错误被我们的提示盖住了 —— 排障方向直接被 +带偏。**不确定就不动手**,宁可让错误如实报出来。这终究是启发式;主要防线还是 +发送前就把格式和体积查掉。 + +### 提权:模型请求,你输密码 + +`run_command` 有一个 `elevated` 参数。模型撞到权限墙时带上它,那一条命令就以 +root 执行: + +``` +run_command {"command": "apt install -y nginx", "elevated": true} +``` + +``` + 即将执行 run_command + 命令: apt install -y nginx + 继续?(Y/N/Always/neVer) ← 让你看清要跑什么 +[sudo] password for pluv: ← 真正的授权在这里 +``` + +**先说清楚为什么没有 `/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 负责授权,各司其职。 + +| 环节 | 做法 | 为什么 | +|---|---|---| +| 传参 | `sudo -u root -- ` | argv 形式,**不经 shell** —— 命令里的引号、换行、`;` 注入不进 root 那条命令 | +| 认证 | **不带 `-n`** | `-n` 是"不许提问",带上它需要密码时就直接失败;而这里要的正是让 sudo 能问 | +| 终端 | 提权路径**不 `setsid`** | sudo 是打开 `/dev/tty` 问密码的,摘掉控制终端它就永远问不出来 | +| 输出 | 仍然接管道 | 命令输出照常捕获回填给模型,和普通 `run_command` 一样 | +| 没有终端 | **直接拒绝,不去试** | 否则就是一个在后台等一个没人能看见的密码提示的进程 | + +想立刻收回授权:在 `/shell` 里敲 `sudo -k`,或者等 sudo 自己的超时。 + +**踩过的坑,值得单独记**:第一版把认证也放进通用的 `run_capture()`,而它 +`setsid()` + 全接管道 —— 真机上认证 **100% 失败**。更糟的是当时的 e2e 替身 +sudo 无条件成功,给了个**假绿**。现在替身必须像真 sudo 一样要求终端,并且有一条 +断言专门盯"认证时 sudo 真的拿到了终端";`tests/test_priv.c` 还钉住了"包装里不许 +出现 `-n`"和"没终端时能识别出来"这两条。 + +### 排障:/log 导出收发记录 + +进程会把每一次请求的**请求体**、响应头、以及响应原文(流式就是原始 SSE 字节) +滚动缓存在内存里。不给这个问题事先准备什么,出事了再捞: + +``` +ligent> /log # 看看现在缓存了多少 +ligent> /log /tmp/session.log # 把已经积累的全部追加写入文件 +ligent> /log clear # 清空缓存 +``` + +记录里**不含请求头**,所以不会把 API key 写进去(有测试断言)。 + +它的真正用途是:**录一次真实会话,之后离线回放**。ligent 的协议适配一直只在 +自建的 mock 服务端上验证过,而 mock 是我们自己写的 —— 它接受什么,完全取决于 +我们**以为**服务端会接受什么。有了这个文件,真实服务端的响应就能当成测试夹具 +反复使用,不必每次都烧 token。 + +`tests/replay.py` 就是这个回放器。它不重写一个"模拟客户端",而是当**离线 +服务端**:把录下来的响应原样发给一个真的 ligent,于是 `http.c` 的分块读取、 +`llm.c` 的 SSE 行切分与 `tool_calls` 增量重组,全都在真实字节上跑一遍。 + +```bash +python3 tests/replay.py session.log --summary # 这份录制里有什么 +python3 tests/replay.py session.log --check # 能否解析成完整往返 +python3 tests/replay.py --serve 8899 session.log --strict # 当离线服务端 +./ligent --no-probe --base-url http://127.0.0.1:8899/v1 "please run it" +``` + +`--strict` 是它最有用的一半:它把每个进来的请求体与录制时那一份做**结构比对** +(顶层键、消息角色序列、工具名集合),不一致就回 400 并在 stderr 打印差异。 +于是"今天的客户端还在发同样的报文形状"变成了一条**会失败的断言**——改坏了 +报文而没发现,从此不可能。 + +回放时要给 ligent 加 `--no-probe`:能力探测会插进额外的请求,而那几轮不在 +录制里(录制时也建议 `LIGENT_PROBE=0`,两边一致)。录制用完之后回放器会明确 +报"没有更多响应",而不是静静回错东西。 + +仓库里冻了两份夹具(`tests/fixtures/`),都是对着 mock 录的 —— mock 故意把 +SSE 切成 1 字符、把 `tool_calls` 也切碎,所以它们冻结的正是最值得冻结的形状: + +| 夹具 | 冻住的是 | +|---|---| +| `session.log` | 流式 SSE + 被切碎的 `tool_calls` 重组 + 工具往返 + 最终回答 | +| `error.log` | 一个 400 错误体,以及错误信息的可读化 | + +**录一份真实会话**(你自己的 key,跑一次真问题): + +```bash +ligent # 正常聊,直到复现出你要冻住的那个行为 +ligent> /log session.log # 导出 +python3 tests/replay.py session.log --summary +``` + +之后把它放进 `tests/fixtures/` 并加段断言,那次真实故障就永久变成了测试。 + +内存上限 16MB,超出后丢弃较早的部分(排障时通常更关心刚刚发生了什么), +导出时会在文件开头注明发生了截断。 + +> 注意:`/log` 是 REPL 命令。单次任务模式(`ligent "任务"`)下记录同样会累积, +> 但没有地方把它导出来。 + +### 对抗性测试 + +`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` 的列表只影响"你能看到什么",不影响"你能填什么":输入行里直接写 + 名字可以指定任何模型,包括服务端没列出来的内部模型。 +- `/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` 的 + 服务端可能会丢失思考上下文。 +- `/shell` 的记录对全屏程序是"整段忽略画面"而不是"还原画面":`nano`/`vim` 这类 + 用备用屏幕的程序,记录里只剩命令行和退出后的输出。极少数既不用备用屏幕、 + 又逐行重绘的程序(`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` 报 + 的那条 FILE 泄漏同理:`-O0` 下干净、`-O1` 起才报、把函数原样抽出来单独编也 + 不报,我判断是内联导致的误报 —— 换个 gcc 版本结论可能变。 +- 打断(Ctrl-C)路径只在几处有覆盖,不全面。 diff --git a/README2.md b/README2.md new file mode 100644 index 0000000..4634d35 --- /dev/null +++ b/README2.md @@ -0,0 +1,2142 @@ +# ligent: Self-contained yet powerful command-line AI Agent, implemented in C99 + +C99 从零写成的命令行 AI Agent:连接 **OpenAI 兼容**的 Chat Completions +接口, 具备**原生 function calling**(工具调用)能力,能读写文件、执行命令、 +搜索代码,并在终端里流式输出。 + +对话、工具调用、图片走的都是通用协议(`image_url` + base64 内联,DeepSeek 和 +MiMo 都收);**只有思考模式的字段是方言**,不做跨厂商探测。代价见 §9。 + +除 `libcurl`(HTTPS 传输)外,**不依赖任何第三方库**: +JSON 解析器、SSE 流式协议解析、工具调用增量拼接、REPL 全部是本仓库自己实现的。 + +``` +$ ligent "把 src/util.c 里的 xmalloc 改成失败时打印调用栈,然后编译验证" +{/} read_file "src/util.c" + │ 1 /* util.c - 基础工具实现 */ + │ 2 #include "util.h" + │ ... +{/} edit_file "src/util.c" [41] + │ - 41 exit(1); + │ + 41 print_backtrace(); /* 失败时打印调用栈 */ + │ + 42 exit(1); +{/} run_command make 2>&1 | tail -20 + │ cc ... -o ligent ... + │ [exit code: 0] +已按要求改造 xmalloc 的失败路径,并确认编译通过。 +``` + +--- + +## 1. 编译 + +依赖:`gcc`(或 `clang`)、`make`、`libcurl` 开发包。 + +```bash +# Debian / Ubuntu +sudo apt install build-essential libcurl4-openssl-dev + +# Arch +sudo pacman -S base-devel curl + +# Fedora +sudo dnf install gcc make libcurl-devel +``` + +```bash +make # 生成 ./ligent +make test # 跑单元测试 + 端到端测试 +``` + +没有 `make install`。这是个自包含的小工具,不是系统级程序 —— 可执行文件在哪, +配置就在哪(见下一节),所以直接拷走 `ligent` 和 `ligent.json` 就是"安装"。 + +依赖只有 **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 static-test` | 拿同一套 e2e 打静态产物 | +| `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` 里会跑) | + +### 构建目录按编译器和配置分开 + +产物不在 `build/` 正下方,而是 `build/<编译器>[-san]/`: + +``` +build/cc/ # 默认 CC=cc +build/clang/ +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 clean` 清掉**一切**构建产物:各 `<编译器>[-san]` 对象目录、`build/static`、 +测试可执行文件、e2e 的临时文件、`ligent` / `ligent-static`,以及 +`tools/build-static-deps.sh` 编出来的 `build/static-prefix`(静态库)和 +`build/static-src`(源码 + 压缩包缓存),外加 `tests/__pycache__`。 +换句话说,`make clean` 之后 `git status --ignored` 应该只剩你自己的东西。 + +代价是明确的:**清掉依赖之后,下次静态构建要重新联网下载再编译几分钟**: + +```bash +make clean +./tools/build-static-deps.sh # 要联网、几分钟 +make static +``` + +`make static` 自己**不会**去编依赖(那等于让一个构建目标偷偷联网下载),它会明确 +报错并告诉你该跑上面那条命令。 + +特意**不动 `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 语义。 + +直接写 `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", …)`。 + +`-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"。 + +### 静态分析 + +```bash +make analyze # gcc -fanalyzer,独立目标,故意不进 make test(它比全套测试还慢) +``` + +它抓的是稀疏但致命的那类问题,第一次跑就捞出两个真 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 的分析器告警不影响退出码,这是我第一版踩的坑。 + +### 换个编译器:tcc 也能编 + +```bash +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 这种 +瓶颈在网络的程序完全无所谓。 + +它换不来三样东西,用之前得知道: + +- **没有 `-fsanitize`**:`make SAN=1` 仍然要用 gcc/clang。 +- **没有那些严格警告**(`-Wshadow` / `-Wmissing-prototypes` 等)。tcc 的诊断 + 弱得多,所以"零警告"这条纪律只在 gcc/clang 下成立。 +- 没有优化器(上面那 1.3 倍就是它的全部代价)。 + +顺带一提,tcc 一上来就帮我们抓出两个**一直存在的构建脆弱点**:代码调了 `fabs` +却没链 `-lm`(gcc 把它折成内建函数,所以看不出来),以及 6 条测试链接规则漏了 +`$(LDLIBS)`。两个都补上了 —— 这类"某个编译器恰好替你兜住"的洞,平时是看不见的。 + +### 静态构建:musl libc + 静态 libcurl + +想要一个**拷到哪都能跑**的可执行文件(不依赖宿主机的 glibc、libcurl、OpenSSL): + +```bash +./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`。必须有一份用 +musl 工具链编出来的静态 libcurl —— 这就是 `tools/build-static-deps.sh` 干的事: +用 musl 把 zlib、OpenSSL、curl 依次编到 `build/static-prefix/`,最后**真的链一个 +最小程序**自检(静态链接最常栽在依赖顺序和缺库上,而那只在链接时暴露)。 + +产物是 `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 回到能调试的版本 | + +### 把静态产物压到最小 + +静态产物是"拷过去就能跑"的发布件,不是拿来调试的,所以它的取舍和常规构建相反。 +**6.24 MB → 3.29 MB(-47%,几乎腰斩)**,分两半,缺一半效果就大打折扣: + +**A. 链接我们自己的东西(Makefile,`STATIC_RELEASE=1`)** + +| 开关 | 效果 | +|---|---| +| `-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 掉的调试信息 | +| `-z noseparate-code`、`--build-id=none` | 页对齐空洞和 note 段 | + +**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** —— +静态归档里本来就有一大堆我们永远走不到的算法和协议实现。 + +`CFLAGS_COMMON` 里带的这几个开关是**体积优先**的默认值;要回到调试友好版: +```bash +CFLAGS_COMMON="-O2" ./tools/build-static-deps.sh +``` + +脚本会把用过的选项记在 `$PREFIX/.build-flags` 里,**选项变了会自动重建**。 +没有这一层的话,下面那些 `[ ! -f libz.a ]` 守卫会看到库已存在就跳过,你会一直用着 +旧选项编出来的依赖,症状是"我明明加了 `-Os`,产物怎么一点没小" —— 和 Makefile 里 +对象目录按配置分标签是同一个坑的两处。 + +顺手排掉的:**`ld.gold` 不是更小而是更大**(4.89 MB vs 4.86 MB),`--icf=all` +相同代码折叠在这份代码上一点没省,所以没换链接器。 + +**验证方式**(这些数字都是量出来的,不是估的): + +```bash +make static # → 3,292,568 字节 +make static-test # 拿同一套 318 项 e2e 打静态产物(而不是只看它能不能启动) +``` + +TLS 单独验:本地起 `openssl s_server` 拿自签证书,让静态产物连它 —— 它会报 +`SSL certificate problem: self-signed certificate`。这个**失败**恰恰是成功的证据: +能走到"证书不被信任"说明 TCP、ClientHello、证书解析、证书链校验整条 OpenSSL 路径 +都通了。这也是静态二进制的真实 HTTPS 路径**第一次被实际验证**(依赖脚本的自检 +只做 `curl_easy_init` + `setopt`,从不发请求,验不到这一层)。 + +顺带发现(既有限制,与本次改动无关):**libcurl 库不读 `CURL_CA_BUNDLE`**, +只有 `curl` 命令行工具读它 —— `CURLINFO_CAINFO` 显示它永远用编进去的 +`/etc/ssl/certs/ca-certificates.crt`。所以 ligent 只能用系统 CA 包,想指定别的 +得改成显式 `CURLOPT_CAINFO`。 + +几个刻意的取舍: + +- **依赖列表交给 `curl-config --static-libs`**,不手写 `-lssl -lcrypto -lz`。 + curl 最清楚自己链了哪些库、什么顺序;手写的那串会过期,顺序错了还链不上。 +- **`-fno-pie` / `-no-pie`**:现代 gcc 默认编 PIE,而静态链接一份非 PIC 的 `.a` + 会报 `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) + +```bash +make docker-build # podman build -t ligent-static . +make docker-extract # 产物拷到 ./ligent-static +make docker-test # 拿同一套 318 项 e2e 打容器产物 +``` + +`make docker-*` 默认用 **podman**(本项目的开发环境是 rootless),换 docker +加 `CONTAINER=docker` 即可 —— 两者的 `build`/`run`/`cp` 参数在这里是一样的。 + +**为什么用 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 文件"的麻烦在容器里根本不存在。 + +**最终镜像 3.49 MB,里面只有两个文件**:静态二进制和 CA 证书。 + +``` +$ podman export $(podman create ligent-static) | tar -t +etc/ssl/certs/ca-certificates.crt +ligent +``` + +用 `scratch` 做运行阶段 —— 静态链接的产物连 libc 都不需要,所以镜像里没有 +shell、没有包管理器、没有任何可被利用的东西。CA 证书放在 +`/etc/ssl/certs/ca-certificates.crt` 是**必须**的:libcurl 用的是编进去的这个 +路径(见上一条),放别处它读不到。 + +版本号、分支、哈希都由 build-arg 注入 —— 容器里没有 `.git`(`.dockerignore` +把它排除了),脚本在里面**问不出**这些,只能由外面算好传进去: + +```bash +podman build \ + --build-arg VERSION=$(date -u +%Y.%m.%d) \ + --build-arg GIT_HASH=$(git rev-parse --short=7 HEAD) \ + --build-arg GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) \ + --build-arg BUILD_EPOCH=$(date -u +%s) . +``` + +`make docker-build` 会自动在本机算好传进去,不用手写。 + +两个容易踩的点: + +- **`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 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 +ligent 2026.09.22-209538e.dirty (master, 2026-09-22 04:44:33) [GCC 13.2.1 20240309] on linux/x86_64 +``` + +编译器那一段如实反映**实际用的**工具链 —— 这正是它值得记进版本号的原因: +两个产物的行为可能因编译器版本不同而有细微差异,排查时得知道是哪个编的。 +容器产物同样过了**全套 318 项 e2e**(`make docker-test`)。 + +### 版本号:CalVer + git 哈希 + +```console +$ ligent --version +ligent 2026.09.22-209538e.dirty (master, 2026-09-22 04:44:33) [GCC 16.2.1 20260810] on linux/x86_64 +``` +``` + │ │ │ │ │ │ + │ │ │ └ 构建时间(UTC) └ 编译器 └ 目标平台 + │ │ └ 分支(detached HEAD 时为 unknown) + │ └ git 短哈希,.dirty 表示工作区有未提交改动 + └ CalVer:发布日 +``` + +前两段(`2026.09.22-209538e.dirty`)是**版本号主体**,也是下面几节引用它时 +用的形式。后面几段是"这份二进制从哪来的",排查时要看的就是它们。 + +| 形态 | 什么时候出现 | +|---|---| +| `2026.02.14` | 打在 tag 上的发布版 | +| `2026.02.14-1a2b3c4` | tag 之后又提交了 | +| `2026.02.14-1a2b3c4.dirty` | 工作区还有没提交的改动 | + +分隔符用 `-` 而不是 `git describe` 传统的 `+g`:我们只用 git,不存在"区分是 +哪种 SCM"的需求(那正是 `g` 前缀存在的理由),而少两个符号念起来也顺。 + +**编译器与平台由编译期宏判断,不由构建脚本传。** 这是有意的:那份信息是 +"这份代码被什么编出来、给谁跑的",编译器自己最清楚。构建脚本去猜反而多一个 +出错点,而且**交叉编译时会猜错** —— `uname()` 说的是"现在跑在哪",`__linux__` +说的是"编给谁跑",静态产物会被拷到别的机器上跑,该记的是后者。 + +三个编译器各有一条腿,因为它们的宏并不统一: + +| 编译器 | 取法 | +|---|---| +| gcc | `__VERSION__` → `16.2.1 20260810` | +| clang | `__clang_version__`,**要掐掉尾随空格**(它自带一个) | +| tcc | 没有 `__VERSION__`,只有整数 `__TINYC__`,按"去掉小数点当十进制读"还原 | + +判断顺序也有讲究:**clang 也定义 `__GNUC__`**(它假装自己是 gcc 4.2),所以 +clang 必须排在 gcc 前面,否则会被认成 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 的粒度是"天",同一天可能发好几次;而且用户手上 +的二进制未必来自 tag(可能自己改了源码)。带上哈希,报 bug 时能精确定位。 +`.dirty` 标记尤其重要:不带它的话,改过源码的构建和干净构建会报**同样的** +版本号,排查时会被完全误导。未跟踪的新文件也算脏 —— 它可能正是被编进去的那个 +源文件。 + +**版本号不写死在源码里。** 构建期用 `tools/get-version.sh` 算出真值,Makefile +用 `-D` 注入;`src/version.h` 里只留一个回退默认值,给"没有构建系统"的场景 +(比如有人直接拿 gcc 编 `src` 下的所有 `.c`)。这样从 git 编和从 tarball 编 +不会出现版本号不一致,也不必为发版多做一个改源码的提交。这是 dunst / curl +这类项目的通行做法。 + +取值的优先级(`tools/get-version.sh` 里有完整注释): + +| 场景 | 结果 | +|---|---| +| 环境变量 `LIGENT_VERSION_OVERRIDE` | 直接用它(CI 用,tag 打错时能救) | +| git 里有 `v2026.02.14` 形态的 tag | 取最新的那个 | +| 没有 tag(tarball) | 回退到源码文件的日期 | +| 没有 `sh` 或全失败 | `0.0.0-unknown`,**绝不让构建挂掉** | + +`SOURCE_DATE_EPOCH` 也认(可复现构建的标准约定):设了它,同一份源码在不同 +时间编出来的二进制是逐字节相同的,CI 上才能校验产物哈希。 + +版本号只在 `--version` 和 `ligent doctor` 里出现,不参与任何协议交互。 +`doctor` 把它放在最上面 —— 报 bug 时第一件要问的就是"你用的是哪个版本"。 + +**实测过了**(Artix,musl-gcc / gcc 16.2.1,zlib 1.3.1 + OpenSSL 3.0.15 + +curl 8.11.1): + +``` +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,见上一节) +``` + +一路上真正会绊人的坑(脚本里都处理了,记下来免得重踩): + +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 个文件)。 + + 后果不只是难看:`make static` 的 recipe 里那句 `echo " 依赖: $LIBS"` 会把 + `curl-config --static-libs` 的输出原样打出来 → 进工具输出 → 进对话历史 → + 进请求体 → 服务端以 `invalid unicode code point` **拒掉整个请求**。而且坏 + 数据已经留在历史里,之后每一次请求都失败,只能丢掉整轮对话。 + + 修法是**在 configure 期间把 `.pc` 藏起来**(藏不是删,编完放回去):curl + 看不见 `openssl.pc` 就退回"直接用 `-L$prefix/lib`"那条路,路径干干净净。 + 脚本末尾还有一道 `iconv` 往返自检,curl-config 输出不是合法 UTF-8 就直接退出。 + + (这条不需要你做什么,脚本会处理。但如果你在别处手写 curl/OpenSSL 的构建、 + 又碰巧路径带中文,记住别让它走 pkg-config。) + +## 2. 配置 API key + +默认就是 DeepSeek:`https://api.deepseek.com/v1` + `deepseek-flash`, +密钥默认从 **`$DEEPSEEK_API_KEY`** 读取。所以最简单的用法是: + +```bash +export DEEPSEEK_API_KEY=sk-xxxxxxxx +ligent "你好" +``` + +### 配置文件:只有一个位置 —— 二进制旁边 + +配置**只**从可执行文件所在目录读取,文件名固定 `ligent.json`: + +``` +/path/to/ligent ← 可执行文件 +/path/to/ligent.json ← 配置(不存在就用内置默认值) +``` + +```bash +cp ligent.example.json ./ligent.json # 二进制就在当前目录时 +$EDITOR ./ligent.json +``` + +**不做用户级查找**(没有 `~/.config`、没有 `$XDG_CONFIG_HOME`、没有 `~/.ligent.json`), +**不做系统级查找**(没有 `/etc`),**也不看当前工作目录**。 + +这样整个程序是自包含的:拷到别的机器、塞进 U 盘、丢进容器,配置跟着走。 + +**用软链接时,跟着软链接走。** 如果你把 ligent 软链到 `~/bin`: + +```bash +ln -s /opt/ligent/ligent ~/bin/ligent +``` + +那么配置目录是 `~/bin`,而不是 `/opt/ligent` —— 你放在哪、就认哪。 +四种调用方式结果一致:绝对路径、相对路径、链接链(取第一层)、以及经 `$PATH` +调用(`argv[0]` 里没有斜杠的情况)。 + +实现上优先用 `argv[0]`(不解析最后一段软链接),只在拿不到时才退回 +`/proc/self/exe`。目录部分会被规范化(`.` / `..` / 目录自身的软链接都会解析), +但**最后一段不会** —— 那正是"软链接所在目录"的关键。另外会用 inode 比对确认 +找到的确实是 ligent 自己,避免 `$PATH` 里恰好有个同名文件把目录带偏。 + +```bash +$ ./ligent --print-config | grep config_file +config_file : /home/you/ligent/ligent.json (不存在,使用内置默认值) +``` + +`--print-config` 会显示规范位置,所以不用猜它在哪找。 + +想临时换一份配置时有两个显式入口(都不是"默认查找位置"): + +```bash +ligent -c /path/to/other.json "..." # 命令行显式指定 +LIGENT_CONFIG=/path/to/other.json ligent "..." +``` + +整体优先级:**内置默认值 → 配置文件 → 环境变量 → 命令行参数** +(`api_key` 按上面的规则细化)。 + + +配置文件里可以用 `${VAR}` 引用环境变量,避免把密钥明文写进磁盘: + +```json +{ + "base_url": "https://api.deepseek.com/v1", + "api_key": "${DEEPSEEK_API_KEY}", + "model": "deepseek-flash" +} +``` + +想接别家服务,只改 `base_url` 和 `model` 即可,例如: + +| 服务 | base_url | model 示例 | +|---|---|---| +| 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` | 你部署的模型名 | + +其他可选环境变量:`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`。 + +### 思考模式与级别 + +推理模型可以先"想"一段再回答。DeepSeek 的思考模式**默认就是开着的,默认级别 `high`**, +所以什么都不配也能用;想控制就加这两项: + +```json +{ + "thinking": "auto", + "reasoning_effort": "high" +} +``` + +- `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 里的 + +``` +/think 查看当前设置 +/think on | off | auto 切换开关 +/think high 设置级别 +``` + +**思考过程不会显示,也不会进上下文**(只累积、不回调)。但它必须跟着 assistant +消息一起回传:DeepSeek 要求**请求带 `tools` 时把历史里的 `reasoning_content` 全带上**, +漏了下一轮直接回 400。ligent 默认开着工具,所以这一条是硬要求 —— 流式和非流式 +两条解析路径都会保存它。 + +另外,思考模式下 `temperature` 是**不起作用**的(服务端会忽略),`top_p` 会被抬到 +≥ 0.95。`ligent doctor` 会在你同时设了 `thinking: on` 和 `temperature` 时提醒这一点。 + +### 取密钥的优先级 + +``` +LIGENT_API_KEY ← 显式覆盖,任何时候都最高 +ligent.json 的 api_key ← 你写死在配置里的 +DEEPSEEK_API_KEY / OPENAI_API_KEY ← 兜底,且只在端点确实是那一家时才读 +``` + +最后一条是刻意的:**厂商专属的环境变量只在"确实在用这家"时才生效**。 + +否则会出这种事:配置文件里写的是 OpenRouter 的 key,而 shell 里残留的 +`DEEPSEEK_API_KEY` 把它悄悄顶掉 —— 结果 A 家的密钥被发给了 B 家, +对方回一个含糊的 401,你明明配对了却怎么都调不通,排查方向还会被带偏。 +(OpenRouter 对格式不对的 key 回的是 `Missing Authentication header`, +听起来像"请求里没带认证头",实际上带了,只是钥匙不是这家的。) + +所以 `base_url` 指向 OpenRouter / 本地 Ollama / 自建网关时, +ligent **不会**去猜 `DEEPSEEK_API_KEY`,而是明确告诉你"未配置 API key"。 + +`OPENAI_BASE_URL` 同理,只在 `base_url` 还是内置默认值时才兜底。 + +想临时切换服务商,用 `LIGENT_API_KEY` 或 `-k`。 + +### 界面语言 + +界面文案(帮助、错误、诊断、确认提示、等待动画)有中英两份。**发给模型的 +那些不在这里**:系统提示词、工具描述、工具回执只有中文 —— 那是模型读的信息, +跟界面语言是两件事(见 §9)。 + +优先级:`--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`。 + +哪些**故意不译**(都不在这里,也不是漏翻): + +- **给模型看的**:系统提示词、工具描述、工具回执,以及插进对话历史的说明 + (`/include` 的文本块头、图片降级说明、`/shell` 的操作记录)。切成英文之后 + 模型读到的仍然是中文 —— 想换就得改提示词本身。 +- **机器读的格式**:`/log` 导出里 `请求:` / `----- 响应头 -----` 这些标记 + (`tests/replay.py` 按它们切分往返)、`<已隐藏>` 这种脱敏占位符、 + `cap_name()` 这类只给日志和断言用的状态常量。 + +`/tools` 列出来的那张表**不是**这一类:工具名后面那行是纯 UI,文案在表里 +(`tools.ui.*`),跟着界面语言走;发给模型的那份说明仍然只有中文。 + +`src/json.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` 读错参数。 + +## 3. 用法 + +```bash +ligent "统计当前目录下 C 代码的总行数,并找出最大的三个文件" +ligent # 进入交互式 REPL +echo "解释一下 Makefile" | ligent # 从管道读取任务 +ligent --print-config # 查看最终生效的配置(key 已打码) +``` + +### 命令行选项 + +``` +-m, --model NAME 模型名 +-u, --base-url URL API 基地址 +-k, --api-key KEY API key +-c, --config PATH 指定配置文件 + --history FILE 指定输入历史文件(默认 <二进制目录>/ligent.history) + --no-history 不读写输入历史 +-s, --system TEXT 覆盖 system 提示词 +-t, --temperature N 采样温度 + --max-tokens N 单次回复最大 token 数 + --thinking MODE 思考模式:on / off / auto(默认 auto = 由服务端决定) + --effort LEVEL 思考级别:minimal/low/medium/high/xhigh/max/ultra + --max-steps N 单轮任务里模型最多往返几次(默认不限,0 = 不限) + --timeout N 请求超时秒数 + --no-stream 关闭流式输出 + --no-tools 禁用工具,纯对话 + --no-probe 首个请求前不探测模型能力(不额外发请求) + --lang LANG 界面语言:auto / en / zh(默认 auto) +-q, --quiet 不打印工具的输出内容 +-v, --verbose 打印 HTTP 细节 + --color/--no-color 强制开关彩色输出(同时控制 Markdown 渲染) + --print-config 打印生效配置后退出 +-h, --help / -V, --version +``` + +### 子命令 + +``` +ligent doctor [--offline] 体检:配置、密钥、端点、网络逐环检查 +``` + +`--offline` 跳过联网检查。 + +### REPL 命令 + +``` +/help 帮助 /model [NAME] 切换模型;不带名字则在线选 +/exit, /quit 退出 /system TEXT 重设提示词并清空历史 +/reset 清空对话历史 /cls 清屏(不动对话历史) +/meta 元编辑模式:让模型帮你改这段历史 +/think [开关|级别] 思考模式与级别 /tools 列出可用工具(已按能力隐藏的不列) +/probe 现在测一次模型能力(图片/工具);无视 --no-probe 与缓存 +/config 显示当前配置 /cd PATH 切换工作目录 +/history 打印完整历史 /usage token 用量 +/log [FILE] 导出收发记录 /yolo 临时切换写操作确认 +/export 存档当前对话 /import [名] 载入存档(会问要不要打印历史) +/shell [命令] 进 shell / 直接执行命令 +/include [GLOB] 把文件附带给模型;不带参数则交互式挑选,clear 取消 +/back 删除最近一轮(破坏性) /unback 撤销 /back +``` + +`/reset`(别名 `/clear`)和 `/cls` 是两回事,别敲混: + +| | `/reset`、`/clear` | `/cls` | +|---|---|---| +| 作用 | 清空对话历史,重新开始 | 清屏 | +| 影响模型看到的上下文 | **是**,历史没了 | 否,一字不动 | +| 可恢复 | `/unback` 不行,只能 `/import` 旧存档 | 本来就没删东西 | + +`/cls` 发的是 `\033[2J\033[3J\033[H`,和 `clear(1)` 一致:可见区和回滚缓冲 +一起清。所以清完之后往上滚也看不到旧内容了 —— 想找回来看 `/import` 那份存档。 + +### `/model`:不带参数就在线挑 + +`/model NAME` 直接换。**不带参数**时会拉一次在线列表、打出来,然后问你: + +``` +ligent> /model +正在获取模型列表(https://api.deepseek.com/v1/models)... + +服务端列出了 3 个模型: + + 1) deepseek-flash <- 当前 + 2) deepseek-chat + 3) deepseek-reasoner + +选择序号,或直接输入模型名(回车取消)> 2 +模型已切换为 deepseek-chat +``` + +输入行里**既可以填序号,也可以直接写模型名**。后者是特意留的:列表里没有的 +东西恰恰很常见 —— 内部模型、灰度模型、服务端没列出来的别名,都得能填进去。 + +列表本身也是诚实的:顺序保持服务端给的(不去替它排序),重复项和缺 `id` 的 +坏条目会被跳过。序号越界会明确报出来(`序号 9 超出范围(1..3),已取消。`), +`Ctrl-C` 或直接回车取消。 + +拉列表走的是标准的 `GET {base_url}/models`。**拿不到就明说是什么情况**, +而不是笼统地报一句失败: + +``` +拿不到模型列表:服务端返回了空的模型列表(HTTP 200)—— 这个账号下可能没有可用的模型 +可以直接用 /model <名字> 指定,列表拿不到不影响这条。 +``` + +"响应不是 JSON"和"列表是空的"是分开报的 —— 前者多半是 base_url 指错了地方, +后者是账号/权限问题,下一步动作完全不同。 + +> **为什么不做成方向键菜单。** 试过一版,撤了。那种界面要靠 ANSI 反复重画, +> 会把 stdout 搅乱(重定向、日志、粘贴都跟着遭殃);更要命的是它**只让你在 +> 给定的选项里挑**,恰好堵死了"填一个列表里没有的模型名"这条最需要的路。 +> 所以这里是 `/import` 那一套:列表打在上面,输入行是普通的一行,随便写。 + +### `/include`:把文件附带给模型 + +模型看不到你的文件,除非你给它。`/include` 把文件**暂存**起来,跟着下一条消息 +一起发出去 —— 因为附带文件的意义几乎总是配合一个提问: + +``` +ligent> /include src/*.c +已附带 3 个文件: + src/agent.c + src/json.c + src/util.c +下一条消息会带上它们。用 /include clear 取消。 +ligent> 这三个文件里有没有重复的逻辑? +``` + +不带参数则进**交互式挑选**,可以逐层进目录(这就是"递归"的走法 —— glob 本身 +是 shell 语义,`*` 不会跨目录): + +``` +ligent> /include +进入交互式选择。序号进目录 / 选中文件,也可以直接敲 glob 或路径,回车结束。 + +当前目录:/home/you/proj + + 1) src/ (目录) + 2) README.md 12.3 KB + 3) logo.png 4.5 KB 〔图片〕 + .) .. 返回上级 + +已选 0 个。选择 > 1 +``` + +同样是 `/import` 那种普通输入行,不是方向键菜单:**序号、`..`、glob、路径都能 +敲**,空格分隔不支持的(一次一个模式)。`/include clear` 清空暂存,`/reset` +也会清。 + +几件事是有意为之的: + +- **认内容,不认扩展名。** 叫 `.png` 的文本文件当文本带;反过来,内容真是图片 + 的才走图片那条路。 +- **图片不进历史。** 图片存成本机备份,历史里只放一个几十字节的引用,发请求时 + 才展开成 base64 —— 和 `read_image` 完全同一条路。 +- **图片要先确认模型能看图。** 遇到图片时才会去跑能力探测(纯文本的附带不该为 + 了"可能出现的图"先花掉两个探测请求),明确不支持就跳过并说明: + + ``` + 跳过 src/logo.png:当前模型不支持图片输入 + 没有附带任何文件(1 个被跳过)。 + ``` + + 只有**明确不支持**才拦;探测关了或结论不确定时按支持放行 —— 和别处一个原则。 +- **体积有上限。** 单个文本文件 256 KB、一次累计 1 MB、最多 32 个文件。超了会 + 跳过并说明,让你改用 `read_file` 分段读。二进制文件(含 NUL 字节)直接跳过。 +- 暂存的文件名只用于在终端里给你看,**不会**塞进内容块 —— 那会跟着请求发给 + 服务端,而自造字段可能被严格的服务端判 400。 + +输入行尾加 `\` 可以续行写多行任务。`Ctrl-C` 中断当前请求(不会退出程序), +`Ctrl-D` 退出。 + +### 输入历史:像 bash 一样跨进程 + +你在 `ligent>` 敲过的东西会**实时**写进一个历史文件,新的 ligent 实例启动时读回来, +上下方向键就能翻,跟 bash 一样: + +``` +ligent> ↑ → 上一条 +ligent> ↑ ↑ → 再上一条 +ligent> ↓ → 回来一条 +``` + +- **落盘位置**:`<二进制目录>/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 # 同上,环境变量 +``` + +文件是一条历史一行。条目里的反斜杠和换行会被转义(`\` → `\\`,换行 → `\n`), +保证一条占一行且能原样读回;不含反斜杠的普通命令在文件里与输入完全一致。 + +### `/shell`:在子进程里干活 + +``` +ligent> /shell 进入交互式 shell,退出后整段会话记入上下文 +ligent> /shell git log --oneline -5 带参数则直接执行,不进入 shell +``` + +交互式那条路用**伪终端**做双向搬运:子进程以为自己接在真终端上,所以行编辑、 +颜色、Tab 补全、Ctrl-C、作业控制都照常工作;我们在外面旁听并把输出记下来。 + +``` +ligent> /shell +进入交互式 shell。输入 exit 或按 Ctrl-D 返回;整段会话会在退出后记入上下文。 +[you@host ligent]$ git status +[you@host ligent]$ exit + +已把 165 字节会话记录加入上下文(不自动提问)。 +``` + +录制进上下文的内容已经清理过 —— 去掉 ANSI 控制序列、把 CRLF 归一成 LF、 +丢掉退格,否则模型看到的是一堆 `^[[32m`。长度上限 100KB。 + +清理是**跨读取块**做的:转义序列被 `read()` 从中间切断也能接上,不会串味。 + +**全屏程序的内容完全不记进上下文。** `nano`、`vi`、`vim`、`nvim`、`less`、`top` +这类程序会切到终端的"备用屏幕",这段画面照常显示在你眼前,但记录里只剩你敲的 +命令行、下一行提示符,以及退出编辑器之后真实的输出 —— 和人眼在终端里看到的 +一模一样。中间留一对标记: + +``` +[全屏程序运行中,画面内容已省略] +[全屏程序结束] +``` + +理由是那一屏全是光标定位和重复重绘,记下来纯属噪音;而且 `vim` 打开的整个文件 +都会被灌进上下文。程序被强杀、来不及发退出序列时,收尾会补一个结束标记, +保证首尾配对。 + +全屏程序只在 `TERM` 声称支持时才切备用屏幕,所以 `/shell` 会在 `TERM` 缺失或为 +`dumb` 时给子进程补一个 `xterm-256color`(用户显式设了别的值就原样传下去)。 +依据是 ligent 自己的 REPL 就在用颜色、光标定位和括号粘贴 —— 这个终端至少是 +ANSI 兼容的,`TERM=dumb` 属于谎报。补上之后,即使是 `TERM=dumb`,`nano` 也会 +正常进入备用屏幕并被整段忽略。 + +对确实不用备用屏幕、靠成片空格清屏重绘的程序,退而求其次把连续空白压掉 +(短于 8 个空格的缩进原样保留)。 + +**记入上下文但不会自动提问**,你想问什么再问。这样你在 shell 里翻了一圈之后, +可以直接说"刚才那个报错怎么修"。 + +选 shell 的顺序是 `bash → ash → dash → sh`。刻意**不看 `$SHELL`**: +用户环境里的 `$SHELL` 可能是 xonsh 之类交互式外壳,在容器或受限环境里 +经常起不来,反而把 `/shell` 弄坏。 + +### `/usage` + +``` +ligent> /usage +token 用量(本次会话) + API 调用 : 3 次 + 输入 token : 12345 + 输出 token : 678 + 合计 : 13023 + 最近一次输入 : 8192 <- 大致等于当前上下文大小 + 历史消息 : 15 条 +``` + +服务端在每次响应里回报 `usage`,这里只做累加。有些服务端不返回该字段, +那样会明确说"还没回报过用量"而不是显示一堆 0。 + +### `/back` 与 `/unback` + +``` +ligent> /back 删除最近一轮(从最后一条 user 消息到末尾,整组删) +ligent> /unback 撤销,把删掉的装回去 +``` + +是**按轮**删的:模型调用的工具与结果必须整组一起走,只删中间几条会让服务端 400。 + +`/unback` 只在 `/back` 之后没有新对话时可用 —— 一旦你又问了什么,就没有 +"撤销"这一说了,会明确拒绝而不是悄悄恢复到一半状态。 + +### `/yolo` + +``` +ligent> /yolo 临时切换写操作确认(只影响本次会话,不进配置文件) +``` + +### 写操作确认(默认开启) + +任何**会改动磁盘或执行命令**的工具(`run_command` / `write_file` / `edit_file`) +在执行前都会先说明要做什么,然后问一句: + +``` + 即将执行 run_command + 命令: rm -rf build/ + 继续?(Y/N/Always/neVer) +``` + +四种回答: + +| 回答 | 含义 | +|---|---| +| **回车** / `Y` | 允许这一次(默认) | +| `N` | 拒绝这一次 | +| `A` / `Always` | 本轮剩下的改动**一律放行,不再询问** | +| `V` / `neVer` | 本轮剩下的改动**一律拒绝** | + +`Always` / `neVer` 的作用域是**这一轮** —— 一条用户消息引发的那串工具调用。 +下一条消息进来时自动复位,所以它不会像 `/yolo` 那样"一直生效到你关掉", +也不会写进配置。选了哪个都会当场提示你: + +``` + 继续?(Y/N/Always/neVer) A + 本轮后续的改动都不再询问。 +``` + +拒绝不等于失败:上层会把"用户拒绝了"作为工具结果回填给模型,让它换个做法, +而不是让整轮对话就此中断。选了 `neVer` 的话,回填的话会更硬("不要再尝试改动, +直接说明你的结论"),免得模型一个接一个地试。 + +只读工具(`read_file` / `list_dir`)不打扰。 + +> **非交互输入时按默认值放行。** stdin 不是终端(管道、重定向)时没有人能回答, +> 此时直接按 Y 处理、不显示提示。所以 `echo 任务 | ligent` 里的确认实际上是 +> 不生效的 —— 那条路径要靠 `--no-confirm` 的对面,也就是别指望它兜底。 + +关掉:`--no-confirm` / `LIGENT_CONFIRM=0` / 配置项 `confirm: false`。 + +### 自动存档(默认开启) + +在三个时间点自动把当前对话存进 `ligent.archive/`: + +- **退出**时(`/exit`、`Ctrl-D`、单次任务结束) +- **`/reset` 前** —— 免得清空历史时把刚才那段丢掉 +- **`/import` 前** —— 免得载入别的存档时覆盖掉当前的 + +只在"有内容"且"自上次存档后又变了"时才写,所以不会产生一堆重复文件; +只有 system 一条的空对话不会留下存档。关掉用 `--no-auto-save`。 + +### `/meta`:让模型帮你改对话历史 + +`/cls` 清屏、`/reset` 清空、`/back` 退一轮 —— 这些都是粗粒度的。真正难的是 +"把第 3 轮里我那句'用 Python'删掉"、"第 5 轮助手那个结论是错的,改掉"。 + +`/meta` 就是干这个的:进入 `meta>` 子提示符,那边是**另一个 AI**,它的工作 +对象不是继续对话,而是**编辑这段历史**。 + +``` +ligent> /meta +已进入 meta 模式 —— 改的是对话历史本身,不是继续对话。 +用自然语言说要改什么;/help 看命令,/exit 返回。 + +meta> 第 3 轮里我说的"用 Python"删掉 + {/} list_rounds + {/} read_messages {"from":5,"to":7} + {/} replace_message {"index":5,"content":"帮我写个脚本"} + 即将执行 replace_message + #5 的正文改成:帮我写个脚本 + 继续?(Y/N/Always/neVer) +[meta] 已改写 #5(user):28 -> 15 字节。 + +meta> /exit +退出 meta 模式。本次共改动 1 次,meta 自身消耗:3 次调用,1240 输入 / 86 输出 token。 +这段对话已被编辑过:下次 /export 会另起一根,名字形如 <原名>_m<3位>。 +``` + +meta 里的命令只有四个:`/exit` `/diff`(看当前历史,只读)`/undo` `/help`。 +其余输入都是给 meta 模型的指令。 + +#### 和主模型完全隔离 + +meta 会话有自己的 system 提示词、自己的工具表,分发走 `Agent` 上的两个函数 +指针(`exec_tool` / `is_mutating`)。主模型看不到也调不到这些工具,反过来也一样 —— +它就算瞎编一个 `delete_rounds`,也只会拿到"未知工具"。 + +| 工具 | 作用 | 参数里带整段正文 | +|---|---|---| +| `list_rounds [from,to]` | 轮目录:轮号、消息编号范围、条数、字节数、预览 | 否 | +| `read_messages from,to` | 读指定消息的全文(含工具调用参数) | 否 | +| `replace_message index,content` | 改写某条消息的正文 | 是 | +| `delete_messages indices` | 删若干条(只限可安全单删的) | 否 | +| `delete_rounds from,to` | 删连续整轮(含轮内所有工具往返) | 否 | +| `insert_message after,role,content` | 插一条(补一句迟到的约束) | 是 | +| `undo_last_edit` | 撤销上一次改动 | 否 | + +#### 结构安全是硬约束 + +对话历史不是一袋独立消息:`assistant` 带 `tool_calls` 时,后面必须紧跟 +`tool_call_id` 一一对应的 `tool` 消息,否则下一轮请求直接 400。所以: + +- `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. 每次改动都打印增减字节,不静默 + +#### 改过之后另起一根 + +只要成功改过,下次 `/export`(或退出时的自动存档)就是**新根**: +名字 `<原名>_m<3位>`,`parent` 记空。 + +``` +a1b2c3d4 原版 +a1b2c3d4_mq3z meta 改过的(新根) +a1b2c3d4_mq3z_p8w 从 meta 版再分支 +``` + +`_m` 后面是 3 位随机字符,所以下划线后面一共 **4** 个字符;普通分支固定 3 个。 +按长度就能分开,不会歧义。原版仍在 `tmp_` 存档里,丢不了。 + +meta 自身的 token 消耗单独汇报,不混进主对话的 `/usage`。 + +#### 它不是压缩工具 + +meta 模型的提示词里写着:**用户没要求压缩就不要压缩 —— 压缩会丢证据**。 +它的价值在精修:删掉说错的前提、去掉被推翻的方案、改掉助手那次自信但错误的 +结论,让这段历史不再诱导助手重蹈覆辙。 + +> ⚠️ 让 AI 改自己的历史,等于允许它改写"发生过什么"。排查问题时如果关键证据 +> 被"压掉"就麻烦了 —— 所以默认不压缩、每步要确认、改动可撤销、原版有存档。 + +### 等待时的动画:两段标签 + +模型不输出思考过程是有意的,但等待期间屏幕完全静止,和"卡死了"看不出区别。 +所以等待时会在 **stderr** 上转一个灰色的动画,并且**按等待的性质分两段**: + +``` +等待响应.... 请求发出去了,但一个字节都还没回来(网络、排队、服务端没醒) +思考中...... 思维链开始流回来了,模型确实在推理 +``` + +这个区分是有用的:同样是等了 30 秒,"一直卡在 `等待响应`"和"一直在 `思考中`" +是两种完全不同的处境 —— 前者多半该去查网络或端点,后者只是模型想得久。 + +切换的时机是**收到第一片 `reasoning_content`**,不是在发出请求时猜的。所以: + +- 关掉思考(`thinking: off`)或者用不支持思考的模型 → 只会看到 `等待响应`。 +- 非流式(`--no-stream`)拿不到任何增量 → 也只会看到 `等待响应`。 +- 一收到**正文**动画立刻停并擦掉 —— 那时已经有东西可看了。 + +计时是按阶段算的:标签一变就从 0 重新计。超过 5 秒会把秒数亮出来 +(`等待响应.... (12s)`),进一步和卡顿区分。只在 stderr 是终端时才转, +所以重定向或接管道时不会污染输出。 + +### 对话存档:文件名就是谱系 + +``` +ligent> /export 把当前对话存下来 +ligent> /import 列出全部存档,选一份载入 +ligent> /import a1b2c3d4_a9x 按名字直接载入(支持唯一前缀) +``` + +存档放在**二进制所在目录**下的 `ligent.archive/` —— 和配置同一个原则: +整个程序连同它的数据都待在一个目录里,拷走即走。 + +**命名不做时间戳,而是把分支关系写进文件名**: + +``` +a1b2c3d4 从零开始的一段对话 +a1b2c3d4_a9x 从 a1b2c3d4 分出来的分支 +a1b2c3d4_a9x_k2m 再往下分支 +a1b2c3d4_q4k 从 a1b2c3d4 分出的另一支(兄弟) +``` + +新会话第一次存档时随机取 8 位小写字母数字作为根;之后每存一次, +就在**当前所在的节点**后面接 `_` 加 3 位随机字符。 + +`/import` 之前那次保底存档会统一加上 `tmp_` 前缀: + +``` +tmp_mimgkjex 换掉当前对话之前先留的一手 +znmjjhzl 你主动存下来的 +znmjjhzl_ocq 从 znmjjhzl 分出的分支 +``` + +临时存档**不会成为以后分支的祖先**(它只是保底,不属于分支树), +所以上面那支仍然署名为 `znmjjhzl_ocq` 而不是 `tmp_mimgkjex_xxx`。 + +好处是不需要任何索引文件 —— 光 `ls` 一下就能看出谁从谁分出来, +也不会出现索引与实际文件不同步的问题。 + +``` +ligent.archive/ + mvd2bl1h 根 + mvd2bl1h_cpc 分支 + mvd2bl1h_cpc_3oe 分支的分支 + mvd2bl1h_q4k 另一支 +``` + +**长度上限**:完整文件名(含 `.json`)不超过 240 字符。接一级需要 4 个字符, +所以大约能到 58 层。再往下接就**丢掉谱系、重新起一个 8 位根** +(名字撑爆文件系统总不是个办法)。 + +存档内容仍是自描述的 JSON,并把 `name` 与 `parent` 也写进去 —— +这样即使文件被改名、或者谱系因超长而重置,也能从内容里看出它从哪来: + +```json +{ + "format": "ligent.archive", "version": 2, + "name": "mvd2bl1h_cpc", "parent": "mvd2bl1h", + "created": "2026-09-18 22:49:39", + "saved": "2026-09-18 23:10:02", + "model": "deepseek-flash", "base_url": "...", + "message_count": 7, + "messages": [ ... ] +} +``` + +`/import` 载入后会问一句要不要把这段历史打出来: + +``` +已载入 mvd2bl1h_cpc(7 条消息)。之后 /export 会存成 mvd2bl1h_cpc_xxx +是否输出这段历史的内容?(Y/n) +``` + +回车(默认 Y)就**完整**逐条打出来,带角色标签和颜色: + +``` +-- 1/7 系统 -- +你是用户的 AI 助手 +...(system 提示词全文) + +-- 2/7 你 -- +帮我把 build 目录清一下 + +-- 3/7 助手 -- + -> 调用 run_command id=call_1 + {"command": "rm -rf build/*"} + +-- 4/7 工具 -- +[tool_call_id=call_1] +已删除 12 个文件 +[exit code: 0] +``` + +"完整"是字面意思:**正文不截断、工具参数不省略、总量不封顶、`system` +提示词也全文展开**。都回答了 Y,就该看到全部 —— 截一半反而会让人以为历史里 +本来就这么点东西。`tool_call_id` 是特意标出来的,否则一堆工具输出看不出 +谁在回应谁。 + +回 `n` 就只装进上下文、屏幕保持干净。之后想随时再看一遍,敲 `/history` —— +它打的就是同一份东西(当前这段对话,一条不落)。 + +两点行为约定:存档里的 `system` 消息不会被带进来(否则载入旧存档会把你现在 +的提示词换掉),当前生效的那条保留;载入后对话开始时间重置。空对话不会产生 +存档。`/reset` 会断开谱系,下一次存档另起一个根。 + +(输入历史**不会**跟着清 —— 它是一份持久的、跨会话的用户资源,记的是 +"我在 `ligent>` 敲过什么",和当前装的是哪段对话无关。) + +### 先跑一次体检 + +配置调不通时,**先别猜**: + +```bash +ligent doctor +``` + +它会把整条链路逐环打出来 —— 二进制与配置目录怎么定的、配置文件在不在、 +密钥从哪来、格式与端点是否匹配、环境变量有没有互相顶掉、参数是否离谱、 +网络通不通、端点有没有跳转。 + +``` +【API key】 + 来源 配置文件 + 值 sk-or-v1...(73 字符) + 格式推断 OpenRouter + 与端点匹配 [v] 端点属于 OpenRouter,密钥格式吻合 + +【网络】 + 连通性 [v] 可连接,TLS 校验通过 + HTTP 状态 307 + 重定向 [x] 端点跳转到 https://example.invalid/v1/chat/completions + ligent 不自动跟随重定向:跨源跳转会丢掉 Authorization 头, + 服务端只会回 401 "Missing Authentication header",看起来像是密钥没带, + 实际是 URL 跳了。把 base_url 直接改成上面那个地址即可。 +``` + +它专门盯着几类"报错信息会把人带偏"的问题:配置被残留环境变量顶掉、 +密钥与服务商不匹配、端点重定向导致伪 401、配置文件语法错误。 +退出码:有问题为 1,否则 0。 + +> doctor 在**配置本身加载失败时也能跑** —— 那正是最需要它的时刻。 + +### 行编辑 + +REPL 自带行编辑器(`src/lineedit.c`),不依赖 readline,也不用终端的行规程: + +| 按键 | 作用 | +|---|---| +| `←` `→` | 光标左右移动(按**字符**而不是字节,中文不会移半个字) | +| `↑` `↓` | 翻历史记录 | +| `Home` / `End` / `Ctrl-A` / `Ctrl-E` | **当前这一行**的行首 / 行尾(粘进来多行时不会跳到整块两端) | +| `Backspace` / `Delete` | 删除光标前 / 光标处的字符 | +| `Ctrl-K` / `Ctrl-U` / `Ctrl-W` | 删到行尾 / 删到行首 / 往回删一个词 | +| `Ctrl-C` | 丢弃当前输入行并开新行(请求执行中则是中断请求,不退出程序);**在写操作确认提示上等于"拒绝"** | +| `Ctrl-D` | 空行时退出;行非空时删一个字符 | +| **粘贴多行** | 整段插入,**不会被逐行执行** | + +粘贴用 bracketed paste(`\033[?2004h`)实现:终端会把粘贴内容和真实按键区分开, +收到粘贴块就整段放进缓冲区,里面的换行不再触发提交。所以可以直接把一段 +多行需求、一个代码片段、一段日志粘进来,编辑好再回车,它会作为**一条**消息发出。 +`Ctrl+Shift+V`、中键、菜单里的粘贴走的都是这条路。 + +**粘贴进来的内容会被清洗一遍。** 因为 `read_paste()` 收的是终端给的原始字节、 +不经按键分类,里面夹着控制字符就会让"编辑器的算盘"和"终端实际显示"对不上: + +| 字符 | 不清洗会怎样 | 清洗后 | +|---|---|---| +| `\t` | 终端按制表位(8 的倍数)展开,编辑器只当 1 列 —— 粘一段带缩进的代码,**光标从此偏掉,之后每敲一个字都在错位置上** | 就地展开成空格,缓冲区里不留制表符 | +| `ESC` | 被终端当成转义序列**真的执行掉**:粘贴内容能改颜色、甚至清屏 | 连同后面整条序列一起丢弃 | +| `\r` | 把终端光标拉回行首,屏幕当场错位 | 丢弃(CRLF 归一成 LF) | +| `NUL` | 会把 C 字符串截断 | 丢弃 | + +制表符展开是按**插入点当时的列号**算的,不是从 0 开始 —— 在行中间粘贴时缩进才对。 + +为此缓冲区支持多行,渲染也相应升级为多行 + 折行感知。 + +实现方式是进 raw 模式自己接管按键。最早用 `fgets` 直接读时终端停在规范模式, +方向键发出的转义序列会被当成普通字节读进来、显示成 `^[[D`,所以必须自己处理。 + +超出终端宽度时自动折行,光标位置按行列重算;宽度计算复用与表格渲染同一套 +CJK 规则(中文按 2 列)。行尾 `\` 续行仍然可用,续行的每一段都能独立编辑。 + +**你敲的那一行整个是绿色的**(提示符 + 输入的文字),翻历史或滚屏时一眼能 +看出哪些是自己的输入、哪些是模型输出: + +``` +ligent> 你好 ← 绿色,整行都是输入 +你好!有什么可以帮你的吗? ← 普通色,是模型正文 +``` + +颜色是行编辑器统一加在整行两头的,所以提示符字符串本身必须保持纯文本 —— +在提示符里自带一个 reset,后面的输入就会掉回默认色。 + +配色跟着正文那个开关走(`--color` / `--no-color` / 非终端自动关闭)。 +绿色区间那两串转义序列在算光标位置时会被跳过 —— 否则 `\033[32m` 会被当成 +5 个可见字符,光标每次重绘都会偏 5 列。 + + +## 4. 终端渲染 + +助手回复会做一层**流式 Markdown 渲染**:模型吐出的 `#`、`**粗体**` 等标记 +在终端里直接变成样式,而不是原样显示。工具输出(文件内容、命令结果)**不做** +任何渲染,保持原样,避免破坏代码。 + +### 只用 16 色 + +全部配色都落在最窄的 **16 色 ANSI** 里:前景用 30-37,加粗/暗淡/斜体/下划线 +用 SGR 属性 1/2/3/4。不用 90-97 那一段(aixterm 的"亮色"),也不用 256 色或 +真彩。 + +理由是这个项目**硬编码 SGR、不查 terminfo**(也不查 `COLORTERM`)—— 既然不去 +探测终端能力,就该待在**所有终端都认**的那一档里。90-97 虽然到处都是,但严格 +讲不属于 16 色,在精简终端、把 `TERM` 设成不认识的值、或者老式串口终端上, +它可能被当成两个参数解析或者直接忽略。 + +这条约束由 `tools/check-colors.sh` 守着,`make test-unit` 里会跑。**它值得单独 +一个脚本,是因为颜色越界看代码看不出来** —— 一个越界的码在人眼里也只是"一个 +蓝色",只有拿尺子量才知道它在哪一段。(这个检查抓到过一处:链接原本用的 +`\033[94m` 就是越界的亮蓝。) + +配色语义在各模块之间是一致的: + +| 用途 | 码 | 出现在 | +|---|---|---| +| 成功 / 工具执行成功 / 输入行 | 32 绿 | agent、doctor、main、lineedit | +| 失败 / 错误 | 31 红 | agent、doctor | +| 警告 / 需要注意 | 33 黄 | doctor、confirm、main | +| 次要信息(提示、路径、引用) | 2 暗淡 | 各模块 | +| 强调(工具名、标题) | 1 粗体 | 各模块 | +| 链接 | 4;36 下划线+青 | markdown | + +链接用**下划线**而不是只靠颜色:下划线是 SGR 属性,在单色终端、色盲用户、 +以及把颜色重映射过的主题里都还在 —— 颜色会丢,属性不会。 + +| 语法 | 效果 | +|---|---| +| `#` ~ `######` | 标题,按层级着色(H1 粗体青 / H2 粗体品红 / H3 粗体蓝 / H4+ 粗体) | +| `**粗体**` | 粗体,标记被吃掉 | +| `*斜体*` / `_斜体_` | 斜体 | +| `` `代码` `` | 青色的行内代码,**反引号保留**(方便复制到 shell),内容不做任何解析 | +| `-` `*` `+` 列表 | **保留原始标记字符**并着青色(不替换成 `•`) | +| `> 引用` | 暗色 `│ ` 前缀 | +| `---` | 暗色分隔线 | +| `\| a \| b \|` 表格 | 带边框对齐的表格,表头加粗,支持 `:---` / `:---:` / `---:` 对齐 | +| `[链接文字](url)` | `[链接文字]` 加下划线并着青色,`(url)` 原样保留 | + +**完全不做渲染、逐字节原样保留**的四种情况: + +| 语法 | 处理 | +|---|---| +| ` ``` ` 围栏代码块 | 整块(含 ` ``` ` 标记本身)原样输出,零 ANSI 转义 | +| `![图片](src)` | 保留原文 | +| 链接的 `(url)` 部分 | 保留原文(不隐藏、不做超链接跳转) | +| 行内代码的内容 | 不做任何解析 | + +表格渲染示例(列宽按**显示宽度**计算,中文按 2 列,所以边框能对齐): + +``` +┌──────┬──────┬──────┐ +│ 名称 │ 数量 │ 说明 │ +├──────┼──────┼──────┤ +│ 苹果 │ 3 │ 常见 │ +│ 香蕉 │ 12 │ 较长 │ +└──────┴──────┴──────┘ +``` + +### 两个刻意的取舍 + +**`_斜体_` 会渲染,但下划线标识符不会被误伤。** 按 CommonMark 规则禁用"词内强调": +`foo_bar_baz`、`a_b_c` 原样保留;与另一个下划线相邻时也一律当字面量, +所以 Python 的 `__init__`、`__main__` 不会被拆成斜体。中文不算词字符, +因此 `中文_斜体_` 能正常生效。 + +**表格必须等整块收完才能渲染。** 列宽取决于所有行,所以表格在流式过程中会先缓存在 +缓冲区,遇到非表格行(或消息结束)才一次性输出。行数超过 200 行会先渲染已有部分, +避免超大表格一直不显示。 + +**表格必须顶格写 `|`。** 不带首竖线的写法(`a | b` / `--- | ---`)按普通文本处理。 + +这条限制不是偷懒,而是为了保证**输出与分片方式无关**:判断"这是不是表格"所需的信息 +必须在行的第 0 个字节就能拿到。如果允许"整行任意位置含竖线",那么当 SSE 的分片边界 +恰好落在竖线之前时,行的前半段已经被当成普通文本输出、无法收回;而同一行若整块到达 +则会被识别为表格——**同样的内容,渲染结果取决于网络怎么切的包**。这个问题是模糊测试 +找出来的(最小复现只有 4 字节:`s|\n-`)。 + +### 流式是这里的难点 + +SSE 的分片位置是随机的,`**bold**` 完全可能被切成 `**bo` / `ld**` 两片, +甚至两个 `*` 分别落在两片里。所以渲染器是一个**带状态、可挂起**的状态机 +(`src/markdown.c`): + +- 遇到可能是标记前缀的尾部(结尾的 `*`、行首的 `#`/`>`/`-`、`` ` ``、`[`), + 它**不立即输出**,而是把这段字节留在缓冲区里等下一个分片,凑齐后再判定。 +- 绝大多数情况下挂起只有几个字节,延迟不可感知;唯一的例外是**表格** + (列宽依赖所有行,必须整块收完才能渲染)。 +- 缓冲区有 64KB 硬上限兜底,表格另有 200 行 / 单行 4KB 的上限, + 防止异常输入把内存吃满或让输出长时间卡住。 +- 流式与非流式走同一个渲染器,因此两种模式的输出**逐字节一致**。 + +`tests/test_markdown.c` 用一条不变量来锁住这个行为:把同一段文本分别按 +**一次性 / 3 字节 / 逐字节** 三种方式喂进去,输出必须完全相同。 + +### 想看效果? + +仓库里带了一个独立的小演示程序,不需要 API key: + +```bash +make demo # 编译并运行,用内置示例展示所有语法 +./build/md_demo --plain # 对照:输出原始 Markdown +./build/md_demo -s # 流式模式:每 5 字节一片、间隔 12ms +./build/md_demo --compare # 自检:不同分片方式输出是否一致 +./build/md_demo README.md # 渲染任意 Markdown 文件 +echo '# hi **there**' | ./build/md_demo +``` + +`-s` 看起来和普通输出几乎一样,这正是重点:分片边界落在 `**`、行首 `#`、 +表格中间都察觉不到——因为状态机会把可能被切断的标记暂存到下一片再判定。 +`--compare` 会把同一段文本按 1/2/3/5/7/13/64 字节以及**随机**分片各渲染一遍, +逐字节比对结果,这也是 `make test` 会跑的一项。 + +### 什么时候不渲染 + +只有输出到终端时才渲染。以下情况会退化为**逐字节原样输出**: + +- 输出被重定向或接管道(`ligent "..." > out.md`) +- 显式 `--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` | + +单次工具输出上限 100 KB,超出会截断并提示,避免一次 `cat` 把上下文撑爆。 + +### `edit_file`:行号集合钉住的替换 + +`line` 是**集合型**参数,而且**可以整个省略**:省略 = 不设行范围 = **全文件替换 +所有匹配**(就是编辑器里那个"全局替换")。给了它就是集合型 —— 单个整数 +(`"line": 41`)或整数数组(`"line": [41, 42]`)都收,行号就是 `read_file` 打出来 +的那些(从 1 开始)。给了行号之后,替换只发生在这些行里,`old_string` 也只需 +**在这些行里**唯一。 + +判定命中看的是 `old_string` 的**起点**:起点落在集合里的任意一行上就算数。 +所以 `old_string` 跨好几行时,照 `read_file` 的显示把覆盖到的那几行都填进数组就行, +不必先赌起点到底是第 41 行还是第 42 行。集合是**搜索范围**,不是"每行都改一遍": +真改动的仍然只有唯一那一处(`replace_all=true` 或省略 `line` 时才动全文件)。 + +**省略 `line`** 时没有范围约束:整个文件都是搜索范围,而且**命中几处就换几处** +(不再要求唯一),回执里会写明"全文件替换 N 处"。这是故意留着的逃生门 —— 有些 +改动本来就该全文件生效(比如统一改个名字);但它不是默认路径:不给行号就没法 +声称"我知道改的是哪一处",要改一处仍得给行号。 + +为什么不再只靠"全文唯一":行号是模型唯一的空间坐标,而"全文唯一"要求整个文件里 +恰好只有一处 —— 文件一长这个条件经常不成立(同一个 `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` | +| 文件是二进制 | 拒绝并说明理由(见下节) | + +集合里的重复行号会被**去重**(`[1,1]` 等价于 `[1]`):不然同一处匹配会被数成两处, +"行内只有一处"的判断就会莫名其妙地失败。 + +`replace_all=true` 的含义没变:替换文件里**所有**匹配;但 `line` 集合仍必须圈住其中 +真实的一处 —— 等于强制模型先看过文件,而不是凭印象做全文替换。 + +### 文本工具只处理文本 + +`read_file` / `write_file` / `edit_file` 遇到二进制文件一律**拒绝**,而不是硬读或 +硬写。判据只有一条:**出现 NUL 字节**(和 `/include` 的判据一致 —— 两处必须一样, +否则会出现"同一个文件 `/include` 说能带、`read_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` 这类。 + +**搜索内容不在这套工具里**:模型要搜就用 `run_command` 里的真 `grep` +(`grep -rn 模式 目录`)。以前有个 `search_files`(后来改名 `grep`)的包装, +删掉的理由见下节。 + +### 工具命名约定 + +工具名是协议的一部分:它每个请求都要重发,还要被人、被模型、被代码里好几处引用。 +所以它有一套明写的规矩(`src/tools.c` 顶部有同一份,两处必须同步): + +1. **形式**:全小写 snake_case,动宾结构「动词_对象」。动词必须来自白名单, + 而白名单**按域划分** —— 要用新动词,先把它加进对应域: + - 文件与执行域:`read` / `write` / `edit` / `list` / `run` / `view` + - 历史编辑域(`/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`,凡是"一条标准命令就能干、而且干得更好"的事,就别再做一个 + 自己的版本 —— 多一个工具 = 多一份 schema、多一个要维护的边界、多一次被误用的 + 机会,换来的只是省一次 `run_command`。 + +### 为什么没有搜索工具 + +曾经有 `search_files`(后来按规矩改名 `grep`),现在删了 —— 它是第 8 条规矩的 +反例:真 `grep` 支持正则、文件名过滤、上下文行(`-A/-B/-C`)、大小写、多模式、 +二进制提示,我们那版每一样都要自己写一遍,还得跟着需求慢慢长(第一版就叫错了 +名字:明明是搜内容,却叫 `search_files`,模型自然会去传 `pattern="*.c"`)。 +删掉之后工具表从 8 个变成 7 个。 + +(顺带一提:原来还有个子代理功能 `spawn_subagent`,它拿不到 `run_command`, +所以既搜不了内容、也没法真正独立干活,等于只是"多花一份上下文把文件读一遍", +现在整个删掉了。) + +名字只定义一次(`src/tools.h` 的 `TOOL_*` 宏),工具表也只有一处 +(`src/tools.c` 的 `TOOLS[]`):schema、执行分发、要不要走确认、`/tools` 的输出 +全从那张表生成。所以**改名只需改 `.name` 一列** —— 这三样以前各有一份 `strcmp` +链,改名必定漏一处,而且漏了不报错(只是静默地少认一个工具)。 + +**不保留旧名别名**,这是刻意的:schema 里只发新名,dispatch 再认旧名只会掩盖 +"调用方没跟着改"(旧存档、用户自写的提示词、测试夹具都一样)。认不出来就明确报 +`未知工具 "xxx"`,比静默兼容更容易发现漏改。 + +### `read_image`:图片怎么进对话 + +模型自己看不到磁盘。给它一个路径,它得先把图**读进来**——这一步就是 +`read_image`: + +```bash +ligent "看看 build/screenshot.png 里的报错是什么" +``` + +内部做了四件事,每件都有它的理由: + +**1. 按内容认格式,不认扩展名。** 读文件头判断 PNG / JPEG / GIF / WebP。 +一个叫 `.png` 的文本文件会被明确拒绝,而不是发出去等一个莫名其妙的 400。 + +**2. 请求里走标准的 `image_url` + base64 内联。** 也就是: + +```json +{"type": "image_url", "image_url": {"url": "data:image/png;base64,...."}} +``` + +这是协议里图片仅有的两种走法之一(另一种是外部 URL)。**这是通用形状**: +DeepSeek 和 MiMo 都收,ligent 不去网上抓图,所以只产出 base64 这一种。 + +图片只能出现在 `user` 消息里,而工具结果只能是字符串。所以工具跑完之后, +ligent 会**补一条合成的 `user` 消息**承载图片。它算附件、不算提问——`/back` +按轮删除、上下文回收时都按这个规则处理,不会把它当成新的一轮。 + +**3. 但图片字节不进历史。** base64 内联有个直接后果:字节会躺在会话里, +每轮请求都重发。归档、`/history`、`/log` 全都跟着膨胀。所以**存储形态和发送 +形态是分开的**: + +``` +存进历史 {"type": "ligent_image", "backup": "1700000000_a1b2.png"} +发给服务端 {"type": "image_url", "image_url": {"url": "data:image/png;base64,...."}} +``` + +`read_image` 先把图存到 +`<二进制目录>/ligent.archive/images/<时间戳>_<4位随机>.`,历史里只挂一个 +对备份的引用;**备份文件才是字节的真身**,真正构造请求时才读出来编码。 +`/export` 出来的存档因此还是几十 KB,而不是几十 MB。 + +两个内部块类型都带 `ligent_` 前缀,就是为了万一哪天漏了展开、直接发出去, +服务端报的错能一眼看出是我们的问题: + +| 内部块 | 含义 | +|---|---| +| `ligent_image` | 引用一张本地备份 | +| `ligent_note` | 展开失败后的文字说明(备份读不到了、体积超了) | + +**4. 体积在本地就判掉。** 图片会留在历史里,所以一次超限不是一次失败, +而是从此**每一轮**都超限。与其发出去等一个 400,不如当场告诉模型"太大了"—— +而且**不备份**:一张永远发不出去的图,留着备份没有意义。 + +上限取各家文档里最严的一档(DeepSeek 单图 32 MiB、请求体 48 MiB;MiMo +base64 字符串 50 MB): + +| 限制 | 值 | +|---|---| +| 单张图(base64 之后的长度) | 32 MiB | +| 一次请求里图片的 base64 总量 | 44 MiB(给提示词/历史/工具描述留 4 MiB) | + +注意判的是**编码后**的长度:base64 会膨胀 4/3,所以一张 25 MiB 的原图就已经 +超了。被拒时工具直接把话带给模型,让它请用户先缩小或压缩。 + +**5. 服务端要是还是拒了呢?** 本地拦的是格式和体积,但服务端还可能因为别的 +原因不收(图片损坏、这个变体不支持……)。那条引用躺在历史里,不修的话之后 +每一轮都会以同样的 4xx 失败、历史却原样保留——用户只剩 `/reset` 一条路。 +所以 ligent 撞到 4xx 且请求里有图片时,会把图片**从历史里就地摘掉**、换成 +一句说明再重试一次。 + +给**模型**看的是能直接照做的一句话: + +``` +[图片 1700000000_a1b2.png 不符合 API 所需的格式标准,无法上传,请规范化图片后重试] +``` + +给**你**看的(stderr)是移除处数和服务端的原话 —— 原始报错通常是给人看的英文 +错误码,塞进上下文只会让模型分心,但完全不显示又会让你没法排查: + +``` +服务端拒绝了请求里的图片,已把 1 处图片从对话里移除后重试... +服务端原话:HTTP 400 —— image rejected: unsupported image variant +``` + +"就地"是要紧的:光在请求副本里摘掉,历史里那条坏引用下一轮还会再犯。 + +`read_image` 不写文件、不执行命令,所以**不触发写操作确认**。备份是副作用, +不是"改动你的东西"。 + +它会不会出现在工具表里,取决于模型收不收图片 —— 见下一节。 + +### 模型能力探测:怎么知道它收不收图片 + +`read_image` 只对**能看图的模型**有意义。可是**没有任何接口能问出来** —— +DeepSeek 和 MiMo 的 `GET /v1/models` 都只返回 `id` / `object` / `owned_by`, +没有 capability 或 modality 字段(OpenAI 自己的也是如此)。这是 OpenAI 兼容层 +的通病。 + +所以只能**发试探请求**问。ligent 在**首次请求之前**跑一个独立的"能力测试序列", +它不碰对话历史,也不进历史: + +| 步 | 发什么 | 结论 | +|---|---|---| +| 1 | 一句纯文本 + `max_tokens: 1` | 通了才说明模型是活的,后面的否定结论才作数 | +| 2 | 同一句 + 一张 1×1 的图 + `tools` | 200 → 两样都支持,到此为止 | +| 3 | 拆开单独发(图 / tools) | 第 2 步被拒时才知道是哪一个不行 | + +关键在于**否定结论只在基线通过时才敢下**:单个试探 400 而基线 200,差别就在 +那一样东西上。这样不需要猜服务端的错误文案("不支持图片"、"invalid content"、 +"multimodal not supported"……千家千样),也就不会因为文案变了而误判。 + +**猜不准时一律按"支持"处理**:多暴露一个工具,代价是模型可能白调一次(还有 +4xx 降级兜底);而错误地藏掉工具,你会以为模型看不见图。 + +结果按 `base_url + model` 缓存,所以**换模型会自动重测**,切回来命中缓存。 +探测完总会打一条灰字结论 —— 支持什么、不支持什么、以及我们因此**做了什么**, +都在同一行里,不用去猜: + +``` +模型能力:多模态(图片输入)支持 * 工具调用 支持 +模型能力:多模态(图片输入)不支持(已隐藏 read_image) * 工具调用 支持 +模型能力:多模态(图片输入)未测 * 工具调用 未测 —— 没测出来的一律按「支持」处理(HTTP 401) +``` + +探测出"不支持"时把动作也写在同行,是因为只报"不支持"而不说"我已经把工具藏了", +用户会以为工具还在。 + +代价要说清楚:**开启探测时,每个进程的第一次请求之前会多出 2~4 个小请求** +(常见情况是 2 个,模型受限时才拆开)。不想要这个开销就用 `--no-probe` +(或配置项 `"probe": false`)—— 那时不探测、什么结论都不下、 +`read_image` 照常暴露,也就是探测功能出现之前的行为。 + +想临时测一次(比如用了 `--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 干脆明确写了不支持上传本地文件。 + +也就是说 `{"type": "file", "file_id": …}` 这条路**只有 DeepSeek 一家认**。 +而 `image_url` + base64 内联是 DeepSeek 和 MiMo 都认的通用形状(OpenAI 兼容层 +普遍也认)。既然"图片不进历史"这件事可以靠**本机备份 + 请求前展开**自己做到, +就没有理由再为它绑定一个厂商私有的引用协议。 + +代价写在 §9 里,不藏着。思考模式则仍然是方言(见 §9),那一条没有通用替代。 + +> ⚠️ **安全提醒**:按需求本工具**不做沙箱限制**——模型可以执行任意 shell 命令、 +> 读写任意路径。建议在容器、虚拟机或独立用户下运行,不要在存有重要数据的 +> 生产环境里直接把 `--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)、 + 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 感知, + 以及输入历史的持久化(实时追加 + 启动加载 + 上限截短) + doctor.c/h 环境体检:逐环检查并给出可操作的结论 + archive.c/h 对话存档:导出/列举/载入(文件名即谱系) + image.c/h 图片:按文件头认格式、本机备份、请求前把备份展开成 base64 内联 + shell.c/h /shell:伪终端双向搬运 + 录制 + meta.c/h /meta:元编辑对话历史(独立工具集 + 快照栈 + 结构安全校验) + spinner.c/h 等待动画:两段标签(等待响应 → 思考中),后台线程, + 只在 stderr 是终端时启用 + i18n.c/h 界面文案表(中英各一份)、语言探测、{name} 占位符替换 + markdown.c/h 流式 Markdown 渲染状态机(标记跨分片挂起、表格列宽对齐、 + 近似 wcwidth 的 CJK 宽度计算) + agent.c/h 主循环:消息历史管理、多步工具调用、流式打印 + config.c/h 默认值 → 配置文件 → 环境变量 + main.c 参数解析、单次任务模式、REPL +tools/ + md_demo.c 渲染效果演示程序(独立可执行,不依赖网络) +tests/ + test_json.c JSON 解析/序列化/转义/非法输入/嵌套深度 + test_tools.c 工具的端到端行为(真实执行命令、真实读写文件) + test_agent.c 上下文不变量(system 不重复、reset 语义) + test_config.c 默认值、环境变量优先级、配置文件覆盖与 ${VAR} 展开 + 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_meta.c 元编辑:轮次切分、结构安全、七个工具、撤销、重新存根标记 + test_image.c 图片:格式嗅探、备份与索引、附件队列、损坏索引降级 + 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 + 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` 可以省略,省略就是 + **全文件替换所有匹配**:逃生门留着(有些改动本来就该全文件生效),但它不是默认 + 路径 —— 要改一处仍得给行号。 + +## 7. 测试 + +```bash +make test # 全部 +make test-unit # 纯单元测试,不需要网络(含演示程序自检 + 模糊测试) +make test-e2e # 用本地 mock 服务端验证完整链路,不需要真实 API key +make test-fuzz # 只跑模糊测试,迭代更多轮次 +make demo # 编译并运行渲染效果演示 +``` + +### 模糊测试 + +`tests/fuzz.c` 用随机与变异输入去砸两个"解析不可信数据"的模块(JSON 解析器、 +Markdown 渲染器),检查四条不变量: + +| 模块 | 不变量 | +|---|---| +| JSON | 任何输入都不崩溃、不泄漏 | +| JSON | 解析成功 ⇒ dump → parse → dump 两次结果一致(幂等) | +| Markdown | 任何输入都不崩溃、不泄漏 | +| Markdown | **一次性喂 与 任意分片喂 输出逐字节一致** | +| Markdown | 不含元字符的输入必须原样输出 | + +种子固定,失败可复现。失败时会把输入写到 `build/md_fail.bin`, +用 `build/reduce build/md_fail.bin` 可以自动最小化成最短复现样例。 + +`tests/reduce.c` 是个 delta-debugging 最小化器 —— 上面那条"表格必须顶格" +的规则,就是靠它把 376 字节的失败输入缩到 4 字节才定位到根因的。 + +```bash +make build/fuzz build/reduce +./build/fuzz 20000 42 # 指定种子跑 2 万轮 +./build/reduce build/md_fail.bin # 最小化最近一次失败 +``` + +端到端测试不需要联网也不需要密钥:`tests/mock_server.py` 会在本地起一个 +OpenAI 兼容服务端,按请求内容返回确定性的流式响应,从而验证「模型请求工具 → +本地真实执行 → 结果回填 → 模型给出最终回答」的完整闭环。 + +> 存档目录(`<二进制目录>/ligent.archive`)是运行期状态而不是构建产物,e2e 却 +> 需要它干净,所以脚本开机时会检查:**非空就先整体挪到 `/tmp/ligent.archive.saved.*`** +> 并打印路径,而不是直接删掉。移不动就直接中止(宁可测试不跑,也不误删)。 +> 挪走之后 e2e 才会在自己的那份临时目录里折腾 —— 跑两次也不会把你上一轮的 +> 存档叠进去。 + +用 AddressSanitizer / UBSan 跑一遍: + +```bash +make clean +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 项)在这个配置下是干净的。 + +## 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 都会把结果截断) | +| 错误体上限 | 只保留 256KB 用于诊断 | + +重试次数可配置:`--retries N`(默认 2,0 = 不重试)/ `LIGENT_RETRIES` / 配置项 `retries`。 + +### 传输中断 + +流式请求中途断连时,**已经收到的部分内容会保留**成一条消息并提示"传输中断", +而不是整段丢弃 —— 否则屏幕上有这段文字、上下文里却没有,后续对话会对不上。 + +### 上下文超限:如实报错,不替你做决定 + +历史超过模型窗口时,服务端回一个 4xx。ligent **不会自动删掉任何一条消息** —— +它把服务端的原始错误原样报出来,历史一个字节都不动: + +``` +ligent> 第二轮 +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`。它被**整个删掉**了,理由: + +- 触发它的判据只是错误文案里的关键词(`too long`、`上下文`、`超出`……), + 任何 4xx 蹭到这些词都会触发裁剪 —— 代价却是**不可逆地**删掉几轮对话。 + 用一句猜出来的判据去毁掉对话记录,代价不对等。 +- 它把"历史太大了"这件本该由用户决定的事(`/back` 摘掉哪一轮、`/reset` 重来、 + 还是换一个窗口更大的模型)自动化掉了;用户看到的是对话莫名其妙少了几轮。 + +现在怎么处理由你决定:`/back` 摘掉最近一轮(可 `/unback` 撤销)、`/reset` 清空 +重来、`/export` 存档后另起一段,或者直接换个模型。`/usage` 里的"最近一次输入 +token"≈ 当前上下文大小,可以据此判断还剩多少余量。 + +(`/meta` 里让模型帮你做这件事仍然是可行的 —— 它删的是你看得见、也点过头的那些轮。) + +### 对话结构不变量:tool_calls 必须连着 tool + +服务端对消息结构的要求很硬:**带 `tool_calls` 的 assistant 后面必须紧跟**与每个 +`tool_call_id` 一一对应的 `tool` 消息,中间不能夹别的东西,也不能少。违反了就是: + +``` +HTTP 400 — An assistant message with 'tool_calls' must be followed by tool +messages responding to each 'tool_call_id'. (insufficient tool messages +following tool_calls message) +``` + +麻烦在于这条历史**留在上下文里**——一旦写坏,之后每一轮都会以同样的 400 失败, +用户只能 `/reset`。所以 ligent 两头都做了: + +**发之前先体检。** 每次构造请求前扫一遍历史:缺的 `tool` 消息补一条占位 +("这次调用没有拿到结果,不要假设它执行过"),孤儿 `tool` 消息(找不到对应 +调用的)丢掉,并如实打一行说明。没问题时它只是一次只读扫描,不分配任何东西。 + +``` +对话结构有问题(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` 消息再返回。 + +回归用例是第 53 段,而且 `tests/mock_server.py` **和真服务端一样校验这个结构** +(`pairing_error`):以后任何写坏历史的回归都会在 e2e 里当场 400,而不是等到 +用户真拿它去连服务端才发现。 + +### 图片降级只在错误确实冲着图片来时动手 + +服务端 4xx 时会检查请求里有没有图片引用,有就把图片从历史里摘掉再重试一次 +(见 §5)。这一条**加了关键词判断**:只有错误里出现 `image` / `vision` / +`multimodal` / `content type` / `图片` 这类词才动手。 + +理由是它会把图片**永久删掉**:一条"tool_calls 后面缺 tool 消息"的 400 也被当成 +图片问题处理的话,图白丢,而且真正的错误被我们的提示盖住了 —— 排障方向直接被 +带偏。**不确定就不动手**,宁可让错误如实报出来。这终究是启发式;主要防线还是 +发送前就把格式和体积查掉。 + +### 提权:模型请求,你输密码 + +`run_command` 有一个 `elevated` 参数。模型撞到权限墙时带上它,那一条命令就以 +root 执行: + +``` +run_command {"command": "apt install -y nginx", "elevated": true} +``` + +``` + 即将执行 run_command + 命令: apt install -y nginx + 继续?(Y/N/Always/neVer) ← 让你看清要跑什么 +[sudo] password for pluv: ← 真正的授权在这里 +``` + +**先说清楚为什么没有 `/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 负责授权,各司其职。 + +| 环节 | 做法 | 为什么 | +|---|---|---| +| 传参 | `sudo -u root -- ` | argv 形式,**不经 shell** —— 命令里的引号、换行、`;` 注入不进 root 那条命令 | +| 认证 | **不带 `-n`** | `-n` 是"不许提问",带上它需要密码时就直接失败;而这里要的正是让 sudo 能问 | +| 终端 | 提权路径**不 `setsid`** | sudo 是打开 `/dev/tty` 问密码的,摘掉控制终端它就永远问不出来 | +| 输出 | 仍然接管道 | 命令输出照常捕获回填给模型,和普通 `run_command` 一样 | +| 没有终端 | **直接拒绝,不去试** | 否则就是一个在后台等一个没人能看见的密码提示的进程 | + +想立刻收回授权:在 `/shell` 里敲 `sudo -k`,或者等 sudo 自己的超时。 + +**踩过的坑,值得单独记**:第一版把认证也放进通用的 `run_capture()`,而它 +`setsid()` + 全接管道 —— 真机上认证 **100% 失败**。更糟的是当时的 e2e 替身 +sudo 无条件成功,给了个**假绿**。现在替身必须像真 sudo 一样要求终端,并且有一条 +断言专门盯"认证时 sudo 真的拿到了终端";`tests/test_priv.c` 还钉住了"包装里不许 +出现 `-n`"和"没终端时能识别出来"这两条。 + +### 排障:/log 导出收发记录 + +进程会把每一次请求的**请求体**、响应头、以及响应原文(流式就是原始 SSE 字节) +滚动缓存在内存里。不给这个问题事先准备什么,出事了再捞: + +``` +ligent> /log # 看看现在缓存了多少 +ligent> /log /tmp/session.log # 把已经积累的全部追加写入文件 +ligent> /log clear # 清空缓存 +``` + +记录里**不含请求头**,所以不会把 API key 写进去(有测试断言)。 + +它的真正用途是:**录一次真实会话,之后离线回放**。ligent 的协议适配一直只在 +自建的 mock 服务端上验证过,而 mock 是我们自己写的 —— 它接受什么,完全取决于 +我们**以为**服务端会接受什么。有了这个文件,真实服务端的响应就能当成测试夹具 +反复使用,不必每次都烧 token。 + +`tests/replay.py` 就是这个回放器。它不重写一个"模拟客户端",而是当**离线 +服务端**:把录下来的响应原样发给一个真的 ligent,于是 `http.c` 的分块读取、 +`llm.c` 的 SSE 行切分与 `tool_calls` 增量重组,全都在真实字节上跑一遍。 + +```bash +python3 tests/replay.py session.log --summary # 这份录制里有什么 +python3 tests/replay.py session.log --check # 能否解析成完整往返 +python3 tests/replay.py --serve 8899 session.log --strict # 当离线服务端 +./ligent --no-probe --base-url http://127.0.0.1:8899/v1 "please run it" +``` + +`--strict` 是它最有用的一半:它把每个进来的请求体与录制时那一份做**结构比对** +(顶层键、消息角色序列、工具名集合),不一致就回 400 并在 stderr 打印差异。 +于是"今天的客户端还在发同样的报文形状"变成了一条**会失败的断言**——改坏了 +报文而没发现,从此不可能。 + +回放时要给 ligent 加 `--no-probe`:能力探测会插进额外的请求,而那几轮不在 +录制里(录制时也建议 `LIGENT_PROBE=0`,两边一致)。录制用完之后回放器会明确 +报"没有更多响应",而不是静静回错东西。 + +仓库里冻了两份夹具(`tests/fixtures/`),都是对着 mock 录的 —— mock 故意把 +SSE 切成 1 字符、把 `tool_calls` 也切碎,所以它们冻结的正是最值得冻结的形状: + +| 夹具 | 冻住的是 | +|---|---| +| `session.log` | 流式 SSE + 被切碎的 `tool_calls` 重组 + 工具往返 + 最终回答 | +| `error.log` | 一个 400 错误体,以及错误信息的可读化 | + +**录一份真实会话**(你自己的 key,跑一次真问题): + +```bash +ligent # 正常聊,直到复现出你要冻住的那个行为 +ligent> /log session.log # 导出 +python3 tests/replay.py session.log --summary +``` + +之后把它放进 `tests/fixtures/` 并加段断言,那次真实故障就永久变成了测试。 + +内存上限 16MB,超出后丢弃较早的部分(排障时通常更关心刚刚发生了什么), +导出时会在文件开头注明发生了截断。 + +> 注意:`/log` 是 REPL 命令。单次任务模式(`ligent "任务"`)下记录同样会累积, +> 但没有地方把它导出来。 + +### 对抗性测试 + +`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` 的列表只影响"你能看到什么",不影响"你能填什么":输入行里直接写 + 名字可以指定任何模型,包括服务端没列出来的内部模型。 +- `/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` 的 + 服务端可能会丢失思考上下文。 +- `/shell` 的记录对全屏程序是"整段忽略画面"而不是"还原画面":`nano`/`vim` 这类 + 用备用屏幕的程序,记录里只剩命令行和退出后的输出。极少数既不用备用屏幕、 + 又逐行重绘的程序(`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` 报 + 的那条 FILE 泄漏同理:`-O0` 下干净、`-O1` 起才报、把函数原样抽出来单独编也 + 不报,我判断是内联导致的误报 —— 换个 gcc 版本结论可能变。 +- 打断(Ctrl-C)路径只在几处有覆盖,不全面。 diff --git a/ligent.example.json b/ligent.example.json new file mode 100644 index 0000000..e3c43dc --- /dev/null +++ b/ligent.example.json @@ -0,0 +1,24 @@ +{ + "_注释": "把本文件复制为「二进制所在目录」下的 ligent.json 即生效(例如 ./ligent.json);支持 ${VAR} 环境变量展开", + + "base_url": "https://api.deepseek.com/v1", + "api_key": "${DEEPSEEK_API_KEY}", + "model": "deepseek-flash", + + "temperature": 0.7, + "max_tokens": 8192, + "timeout": 300, + + "thinking": "auto", + "reasoning_effort": "high", + + "stream": true, + "_max_steps注释": "单轮最多几次模型往返;0 = 不限(默认也是 0,即不限制)", + "max_steps": 0, + "tools": true, + + "_probe注释": "首个请求前先试探模型收不收图片/tools,据此决定暴露哪些工具;关掉就不额外发请求", + "probe": true, + + "system": "可选:完全覆盖内置的 system 提示词" +} diff --git a/src/agent.c b/src/agent.c new file mode 100644 index 0000000..a9c4831 --- /dev/null +++ b/src/agent.c @@ -0,0 +1,915 @@ +/* agent.c - agent 主循环 */ +#include "agent.h" +#include "tools.h" +#include "i18n.h" +#include "util.h" +#include "http.h" +#include "spinner.h" +#include "confirm.h" +#include "image.h" + +#include +#include +#include +#include +#include + +/* ------------------------------------------------------------------ */ +/* 颜色与打印 */ +/* ------------------------------------------------------------------ */ + +/* 前置声明:定义在文件靠后的上下文管理一节 */ +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 行 */ +static void print_indented(const char *text, const char *prefix, + int max_lines, const char *color_code, int use_color) +{ + if (!text) return; + const char *p = text; + int lines = 0; + while (*p) { + const char *nl = strchr(p, '\n'); + size_t n = nl ? (size_t)(nl - p) : strlen(p); + if (lines >= max_lines) { + printf("%s%s%s%s\n", use_color ? C_DIM : "", prefix, + t("agent.output.elided"), use_color ? C_OFF : ""); + break; + } + printf("%s%s%.*s%s\n", use_color ? color_code : "", prefix, (int)n, p, + use_color ? C_OFF : ""); + lines++; + if (!nl) break; + p = nl + 1; + } + if (lines == 0) + printf("%s%s%s%s\n", use_color ? C_DIM : "", prefix, + t("agent.output.empty"), use_color ? C_OFF : ""); +} + +/* ------------------------------------------------------------------ */ +/* 流式回调 */ +/* ------------------------------------------------------------------ */ + +typedef struct { + int printed; + MdRenderer *md; +} DeltaCtx; + +static void on_delta(void *ud, const char *text) +{ + DeltaCtx *c = ud; + /* 一出正文就把等待动画收掉,否则动画会和正文抢同一行 */ + spinner_stop(); + /* 走 Markdown 渲染器:它会处理被 SSE 切断的标记 */ + md_feed(c->md, text); + fflush(stdout); + c->printed = 1; +} + +/* 收到思维链:说明请求早就发出去了,模型确实在推理,而不是卡在网络。 + * 只换标签,不打印内容 —— 思维链不显示。 */ +static void on_think(void *ud, const char *text) +{ + (void)ud; + (void)text; + spinner_set_label(t("spinner.thinking")); +} + +/* ------------------------------------------------------------------ */ +/* 生命周期 */ +/* ------------------------------------------------------------------ */ + +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" + "除非用户说明, 不得直接或间接(如使用curl)获取任何在线资源.\n"); +} + +void agent_init(Agent *a) +{ + memset(a, 0, sizeof *a); + a->messages = json_new_array(); + a->max_steps = 0; /* 0 = 不限往返次数(默认,由配置覆盖) */ + a->use_tools = 1; + a->color = 0; + a->show_tool_output = 1; + a->confirm = 1; + a->md = md_new(0); + a->started_at = time(NULL); + a->exec_tool = tool_execute; /* 默认就是普通工具集 */ + a->is_mutating = tool_mutating; + a->probe_caps = 0; + /* 必须显式初始化成"未测"。CAP_NO 正好是 0,而 memset 出来的零值就是它 —— + * 忘了这一步的话,"不探测"会被当成"探测结论是不支持",于是一声不响地 + * 把所有工具都摘掉(这个 bug 真的被 e2e 抓过一次)。 */ + a->caps.vision = CAP_UNKNOWN; + a->caps.tools = CAP_UNKNOWN; +} + +void agent_set_color(Agent *a, int enabled) +{ + a->color = enabled ? 1 : 0; + md_set_enabled(a->md, a->color); +} + +void agent_free(Agent *a) +{ + if (!a) return; + json_free(a->messages); + json_free(a->tools); + md_free(a->md); + a->messages = NULL; + a->tools = NULL; + a->md = NULL; +} + +void agent_add_system(Agent *a, const char *prompt) +{ + if (!prompt) return; + JsonValue *m = json_new_object(); + json_object_set_string(m, "role", "system"); + json_object_set_string(m, "content", prompt); + + /* system 永远是第一条;历史里已有的旧 system 会被替换而不是叠加 */ + JsonValue *tmp = json_new_array(); + json_array_push(tmp, m); + for (size_t i = 0; i < json_array_size(a->messages); i++) { + JsonValue *old = json_array_get(a->messages, i); + const char *role = json_get_string(old, "role"); + if (i == 0 && role && strcmp(role, "system") == 0) continue; + json_array_push(tmp, json_clone(old)); + } + json_free(a->messages); + a->messages = tmp; +} + +/* 加一条 user 消息:文本 + 若干内容块(parts 的所有权转移给这条消息, + * 可以为 NULL)。块排在文本之后。 + * + * 为什么需要它:/include 要把文件、图片跟着用户这一句话一起发出去,而 + * agent_add_user 只能放纯字符串。 */ +void agent_add_user_parts(Agent *a, const char *text, JsonValue *parts) +{ + JsonValue *m = json_new_object(); + json_object_set_string(m, "role", "user"); + + if (!parts || json_array_size(parts) == 0) { + json_free(parts); + json_object_set_string(m, "content", text ? text : ""); + json_array_push(a->messages, m); + return; + } + + JsonValue *content = json_new_array(); + if (text && *text) { + JsonValue *tp = json_new_object(); + json_object_set_string(tp, "type", "text"); + json_object_set_string(tp, "text", text); + json_array_push(content, tp); + } + json_array_steal(content, parts); /* 偷所有权 */ + json_free(parts); + + json_object_set(m, "content", content); + json_array_push(a->messages, m); +} + +void agent_add_user(Agent *a, const char *text) +{ + JsonValue *m = json_new_object(); + json_object_set_string(m, "role", "user"); + json_object_set_string(m, "content", text); + json_array_push(a->messages, m); +} + +void agent_reset(Agent *a) +{ + a->started_at = time(NULL); + JsonValue *keep = NULL; + JsonValue *first = json_array_get(a->messages, 0); + if (first && strcmp(json_get_string(first, "role") ? json_get_string(first, "role") : "", "system") == 0) + keep = json_clone(first); + json_free(a->messages); + a->messages = json_new_array(); + if (keep) json_array_push(a->messages, keep); +} + +size_t agent_history_len(const Agent *a) +{ + return json_array_size(a->messages); +} + +/* 面向用户的"历史消息数":**不含 system 提示词**。 + * + * system 是配置不是对话。删掉唯一一轮之后,用户看到的应该是"历史剩 0 条", + * 而不是"1 条"(那 1 条是提示词)。/history、/back、/unback、/export、/import + * 统一用这个口径,数字之间才对得上。 + * + * 内部判断"历史变了没有"仍然用 agent_history_len(数组真实长度)—— 那是在比 + * 大小,不是在报数。 */ +size_t agent_conversation_len(const Agent *a) +{ + size_t n = json_array_size(a->messages); + if (n > 0 && role_is(json_array_get(a->messages, 0), "system")) n--; + return n; +} + +JsonValue *agent_take_last_round(Agent *a) +{ + JsonValue *msgs = a->messages; + size_t n = json_array_size(msgs); + + size_t start = 0; + if (n > 0 && role_is(json_array_get(msgs, 0), "system")) start = 1; + + /* 最后一条"开启一轮"的 user 是本轮的起点 */ + size_t last_user = (size_t)-1; + for (size_t i = start; i < n; i++) + if (agent_opens_round(json_array_get(msgs, i))) last_user = i; + if (last_user == (size_t)-1 || last_user < start) return NULL; + + JsonValue *removed = json_new_array(); + JsonValue **items = msgs->u.array.items; + for (size_t i = last_user; i < n; i++) { + json_array_push(removed, items[i]); /* 转移所有权 */ + } + msgs->u.array.count = last_user; /* 截断 */ + return removed; +} + +void agent_replace_history(Agent *a, JsonValue *messages) +{ + /* 当前生效的 system 提示词要保留 */ + JsonValue *sys = NULL; + JsonValue *first = json_array_get(a->messages, 0); + if (first && role_is(first, "system")) sys = json_clone(first); + + json_free(a->messages); + a->messages = json_new_array(); + if (sys) json_array_push(a->messages, sys); + + if (messages && messages->type == JSON_ARRAY) { + for (size_t i = 0; i < json_array_size(messages); i++) { + JsonValue *m = json_array_get(messages, i); + if (role_is(m, "system")) continue; /* 存档里的 system 不带进来 */ + json_array_push(a->messages, json_clone(m)); + } + } + json_free(messages); + a->started_at = time(NULL); +} + +/* ------------------------------------------------------------------ */ +/* 错误分类 */ +/* ------------------------------------------------------------------ */ + +/* 是不是 4xx。图片引用失效没有稳定的措辞可认(各家不同),所以只认状态码, + * 再配合"历史里确实有图片引用"来决定要不要把图摘掉重试。 */ +int agent_is_http_4xx(const char *err) +{ + return err && strstr(err, "HTTP 4") != NULL; +} + +static int role_is(const JsonValue *m, const char *want) +{ + const char *r = json_get_string(m, "role"); + return r && strcmp(r, want) == 0; +} + +/* 打开新一轮的 user 消息(agent.h 里有声明 —— meta.c 切轮时用的是同一个判据, + * 两边对"轮"的理解必须一致,否则 /meta 报的轮号和主循环的口径对不上)。 + * + * read_image 之后会补一条"只有图片附件、没有文字"的 user 消息(协议规定图片 + * 只能出现在 user 消息里)。它不算新的一轮 —— 按它切轮会把紧邻的 + * assistant(tool_calls) + tool 那对切散,下一轮直接 400。 + * + * 判据很直白:content 是数组、且里面一个 text 块都没有。 */ +int agent_opens_round(const JsonValue *m) +{ + if (!role_is(m, "user")) return 0; + JsonValue *c = json_object_get(m, "content"); + if (!c || c->type != JSON_ARRAY) return 1; /* 纯字符串 = 真的一轮 */ + for (size_t i = 0; i < json_array_size(c); i++) { + const char *t = json_get_string(json_array_get(c, i), "type"); + if (t && strcmp(t, "text") == 0) return 1; + } + return 0; /* 只有附件 */ +} + +/* ------------------------------------------------------------------ */ +/* 工具调用展示与执行 */ +/* ------------------------------------------------------------------ */ + +/* 调用摘要里,兜底那些工具(参数照原样看一眼)最多打多少字符 */ +#define CALL_JSON_MAX 120 + +/* 工具调用的一行摘要:**人话**,不是原始 JSON。 + * + * 以前这里把整个参数 JSON 原样打出来(截到 256 字节)。对 edit_file 来说那是 + * 一整段 old_string / new_string:屏幕上是一坨转义过的引号,既刷屏,又完全看 + * 不出"要改哪里" —— 而人想知道的恰恰是"哪个文件的第几行、改成什么"。 + * + * 现在这里只摆最要紧的那一两项(改动的具体内容交给 show_edit_diff 去对照), + * 参数解析不出来(模型给了非法 JSON)时就只有工具名 —— 原因在结果里说。 */ +static void show_tool_call(const Agent *a, const char *name, const JsonValue *args) +{ + char shown[400] = ""; + const char *path = args ? json_get_string(args, "path") : NULL; + + if (args && strcmp(name, TOOL_EDIT_FILE) == 0) { + char lines[128]; + tool_lines_label(args, lines, sizeof lines); + /* 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 打出来的那些行号。 */ + 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"); + snprintf(shown, sizeof shown, "%s", + 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 跑 */ + const char *cmd = json_get_string(args, "command"); + if (json_get_bool(args, "elevated", 0)) + snprintf(shown, sizeof shown, "%s", + tf("agent.call.elevated", "cmd", cmd ? cmd : "", NULL)); + 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 反而是最省事也最准的摘要。 */ + char *j = json_dump(args); + size_t n = strlen(j); + snprintf(shown, sizeof shown, "%.*s%s", CALL_JSON_MAX, j, + n > (size_t)CALL_JSON_MAX ? "…" : ""); + free(j); + } + + /* 压成一行 */ + for (char *p = shown; *p; p++) + if (*p == '\n' || *p == '\r' || *p == '\t') *p = ' '; + + printf("%s%s{/} %s%s", col(a, C_DIM), col(a, C_CYAN), name, col(a, C_OFF)); + if (shown[0]) printf("%s %s%s", col(a, C_DIM), shown, col(a, C_OFF)); + putchar('\n'); + fflush(stdout); +} + +static void show_tool_result(const Agent *a, const char *name, const JsonValue *args, + const char *result, int is_error, int diff_shown) +{ + if (!a->show_tool_output) { + printf("%s -> %s%s\n", col(a, is_error ? C_RED : C_DIM), + is_error ? t("agent.tool.failed") : t("agent.tool.done"), + col(a, C_OFF)); + fflush(stdout); + return; + } + /* 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); + return; + } + + print_indented(result, " │ ", 12, is_error ? C_RED : C_DIM, a->color); + fflush(stdout); +} + +/* 执行一批 tool_calls,把结果作为 tool 消息追加到历史 */ +static int execute_tool_calls(Agent *a, JsonValue *tool_calls) +{ + size_t count = json_array_size(tool_calls); + size_t interrupted_at = count; /* 中断发生在第几个调用之前 */ + + for (size_t i = 0; i < count; i++) { + if (g_ligent_interrupt) { interrupted_at = i; break; } + + JsonValue *tc = json_array_get(tool_calls, i); + const char *id = json_get_string(tc, "id"); + JsonValue *fn = json_object_get(tc, "function"); + const char *name = fn ? json_get_string(fn, "name") : NULL; + const char *args_raw = fn ? json_get_string(fn, "arguments") : NULL; + if (!args_raw) args_raw = "{}"; + + 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); + is_error = 1; + } else if (a->confirm && a->is_mutating(name) && !confirm_tool(name, args)) { + /* 拒绝不等于失败:把结果回填给模型,让它换个做法, + * 而不是让整轮对话就此中断 */ + if (confirm_turn_mode() == CONFIRM_NEVER) + /* 用户在确认提示上选了 neVer = 本轮别再改了。 + * 话说死一点,否则模型会一个接一个地试下去。 */ + result = xstrdup("用户已表示这一轮不再执行任何改动。" + "不要再尝试改动,直接说明你的结论或需要用户做什么。"); + else + 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 的新旧对照就是从这里取的 */ + show_tool_result(a, name, args, result, is_error, diff_shown); + json_free(args); + + JsonValue *tm = json_new_object(); + json_object_set_string(tm, "role", "tool"); + json_object_set_string(tm, "tool_call_id", id ? id : ""); + json_object_set_string(tm, "content", result); + json_array_push(a->messages, tm); + free(result); + } + + /* ---- 被中断:剩下的 tool_calls 也得补齐 ---- + * + * 这里以前直接 return 1,于是 assistant(tool_calls) 后面少了几条 tool + * 消息 —— 和插错附件是同一个 400,而且同样是"之后每轮都失败、历史却留着"。 + * 宁可给没跑成的调用补一句"没执行",也不能把一段坏历史留在上下文里。 + * + * 顺序要紧:占位 tool 消息必须在附件之前,否则配对又断了。 */ + if (g_ligent_interrupt) { + for (size_t i = interrupted_at; i < count; i++) { + JsonValue *tc = json_array_get(tool_calls, i); + const char *id = json_get_string(tc, "id"); + JsonValue *tm = json_new_object(); + 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 消息都推完再推 ---- + * + * read_image 把图片挂在待发附件里,而图片只能出现在 user 消息中(tool + * 消息的内容只能是字符串),所以要补一条 user 消息。 + * + * 关键是补在哪儿:服务端要求 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。攒到现在一次性推出去就没有这个问题。 */ + JsonValue *attach = image_pending_take(); + if (attach && json_array_size(attach) > 0) { + JsonValue *um = json_new_object(); + json_object_set_string(um, "role", "user"); + json_object_set(um, "content", attach); /* 取得所有权 */ + json_array_push(a->messages, um); + } else { + json_free(attach); + } + + return g_ligent_interrupt ? 1 : 0; +} + +/* ------------------------------------------------------------------ */ +/* 主循环 */ +/* ------------------------------------------------------------------ */ + +/* 构造"这一轮请求"用的消息副本:把历史里的图片引用展开成 image_url + base64。 + * + * 历史里存的是对本地备份的引用(几十字节),图片字节在备份文件里;展开只发生 + * 在请求这一层,所以归档 / /history / /log 都不会被 base64 撑大。 + * + * 展开不出来的引用会被就地降级成文字说明(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; + if (a->caps.tools == CAP_NO) return NULL; + JsonValue *t = json_clone(a->tools); + if (a->caps.vision == CAP_NO) tools_drop(t, TOOL_READ_IMAGE); + return t; +} + +/* 一次请求要用的一切:消息副本(图片引用已展开)+ 收敛过的工具表。 + * 两者都是新分配的,用完一起释放 —— 三处 llm_chat 调用点各来一份。 */ +typedef struct { + JsonValue *messages; + JsonValue *tools; +} Req; + +static Req req_prepare(Agent *a) +{ + Req r; + + /* 每次构造请求之前体检一遍结构:assistant(tool_calls) 后面必须紧跟对应的 + * tool 消息,中间不能夹别的东西,也不能少。 + * + * 放在这里而不是 agent_run 开头一次 —— 历史在本轮之内也会变(推 tool + * 结果、补图片附件、中断),而坏历史一旦发出去就是 400,并且会一直留在 + * 上下文里,之后每轮都失败。放在这里才是"发出之前最后一道关"。 + * 没问题时它只是一次只读扫描,不分配任何东西。 */ + { + int fixed = 0; + JsonValue *repaired = agent_repair_tool_pairing(a->messages, &fixed); + if (repaired) { + json_free(a->messages); + a->messages = repaired; + fprintf(stderr, "%s%s%s\n", col(a, C_DIM), + tf("agent.structure.fixed", "n", i18n_num(fixed), NULL), + col(a, C_OFF)); + } + } + + int degraded = 0; + JsonValue *wire = image_expand_for_request(a->messages, °raded); + if (degraded > 0) + fprintf(stderr, "%s%s%s\n", col(a, C_DIM), + tf("agent.image.degraded", "n", i18n_num(degraded), NULL), + col(a, C_OFF)); + r.messages = wire ? wire : json_clone(a->messages); + r.tools = tools_for_request(a); + return r; +} + +static void req_free(Req *r) +{ + json_free(r->messages); + json_free(r->tools); + r->messages = r->tools = NULL; +} + +/* ------------------------------------------------------------------ */ +/* 结构体检:assistant(tool_calls) 后面必须紧跟对应的 tool 消息 */ +/* ------------------------------------------------------------------ */ + +/* 服务端要求得很死:带 tool_calls 的 assistant 后面必须**紧跟**与每个 + * tool_call_id 一一对应的 tool 消息,中间不能夹别的东西,也不能少。 + * + * 我们的代码现在会维持这条不变量,但历史还可能从别处被改坏:/meta 的编辑、 + * 手工改过的存档、以及早先版本的 bug(附件插错位置、中断时丢下没回应的调用)。 + * 一旦坏了,症状是之后**每一轮**都 400 而历史原样保留 —— 用户只能 /reset。 + * 所以发请求之前统一体检一次:缺的补占位、孤儿 tool 消息丢掉,并如实说明。 + * + * 没问题返回 NULL;修过则返回重建好的数组(调用方替换旧的)。 */ +JsonValue *agent_repair_tool_pairing(const JsonValue *messages, int *out_fixed) +{ + if (out_fixed) *out_fixed = 0; + if (!messages || messages->type != JSON_ARRAY) return NULL; + + typedef struct { char *id; int answered; } Pending; + Pending *pend = NULL; + size_t npend = 0; + JsonValue *out = json_new_array(); + int fixed = 0; + + /* 给还没被回应的调用补一条占位 tool 消息 */ + #define FLUSH_PENDING(where) \ + do { \ + for (size_t k_ = 0; k_ < npend; k_++) { \ + if (pend[k_].answered) continue; \ + JsonValue *tm_ = json_new_object(); \ + 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++; \ + } \ + for (size_t k_ = 0; k_ < npend; k_++) free(pend[k_].id); \ + free(pend); pend = NULL; npend = 0; \ + } while (0) + + for (size_t i = 0; i < json_array_size(messages); i++) { + JsonValue *m = json_array_get(messages, i); + const char *role = json_get_string(m, "role"); + + /* tool 消息:只留下"确实在等它"的那些,孤儿丢掉 */ + if (role && strcmp(role, "tool") == 0) { + const char *id = json_get_string(m, "tool_call_id"); + size_t hit = (size_t)-1; + for (size_t k = 0; k < npend; k++) + if (!pend[k].answered && id && strcmp(pend[k].id, id) == 0) { hit = k; break; } + if (hit == (size_t)-1) { fixed++; continue; } + pend[hit].answered = 1; + json_array_push(out, json_clone(m)); + continue; + } + + /* 别的消息:先把上一组没回应的补齐,再推自己 */ + FLUSH_PENDING(); + + json_array_push(out, json_clone(m)); + + if (!role || strcmp(role, "assistant") != 0) continue; + JsonValue *tcs = json_object_get(m, "tool_calls"); + if (!tcs || tcs->type != JSON_ARRAY) continue; + for (size_t k = 0; k < json_array_size(tcs); k++) { + const char *id = json_get_string(json_array_get(tcs, k), "id"); + if (!id || !*id) continue; + pend = xrealloc(pend, (npend + 1) * sizeof *pend); + pend[npend].id = xstrdup(id); + pend[npend].answered = 0; + npend++; + } + } + FLUSH_PENDING(); + #undef FLUSH_PENDING + + if (fixed == 0) { json_free(out); return NULL; } + if (out_fixed) *out_fixed = fixed; + return out; +} + +/* 这个 4xx 看起来是不是冲着图片来的? + * + * 降级会把历史里的图片**永久拿掉**,所以不能见到 4xx 就动手:一条 + * "tool_calls 后面缺少 tool 消息"的 400 也被当成图片问题处理的话,图片白丢, + * 而且真正的错误被我们的提示盖住了 —— 排障方向直接被带偏。 + * + * 这里是关键词启发式,不是精确判断,所以: + * 1. 它是**兜底**,主要防线是发送前就把格式和体积查掉; + * 2. 关键词只收那些几乎不可能出现在别处的词; + * 3. 不确定时**不动手** —— 宁可让错误如实报出来,也不误删用户的图。 */ +static int err_mentions_image(const char *err) +{ + static const char *kw[] = { + "image", "vision", "multimodal", "content type", "media type", + "图片", "图像", "模态", NULL + }; + if (!err) return 0; + for (size_t i = 0; kw[i]; i++) + if (strcasestr(err, kw[i]) != NULL) return 1; + return 0; +} + +/* 能力探测序列。独立于对话:那几次试探请求不带任何历史,也不进历史。 + * 结果按 base_url+model 缓存,所以只有本进程第一次(或换了模型)才真发请求。 + * + * 除了 agent_run 每轮开头,/include 要附带图片时也会调它 —— "确保模型支持" + * 这件事不能靠猜。探测被关掉时它什么都不做(结论保持"未测",一切照旧放行)。 + * + * force=1 是给 /probe 用的手动触发:它绕开上面这两条(开关与缓存)。 + * 两条理由不同:开关说的是"启动时别自动探测",不是"禁止探测";而缓存是 + * "这次进程里已经测过" —— 用户显式敲了 /probe,就该真的重测一遍,否则 + * 敲了等于没敲。 */ +static void ensure_caps(Agent *a, int force) +{ + 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; /* 命中缓存,不用再啰嗦一遍 */ + + /* 一行灰字把结论说清楚。探测到"不支持"时把**因此做了什么**也写在同一行里, + * 否则用户只看到"不支持",不知道我们顺手把工具藏了。 */ + const char *vis = + a->caps.vision == CAP_YES ? t("agent.caps.yes") : + a->caps.vision == CAP_NO ? t("agent.caps.no.vision") + : t("agent.caps.unknown"); + const char *tl = + a->caps.tools == CAP_YES ? t("agent.caps.yes") : + a->caps.tools == CAP_NO ? t("agent.caps.no.tools") + : t("agent.caps.unknown"); + + char note[320] = ""; + if (a->caps.vision == CAP_UNKNOWN || a->caps.tools == CAP_UNKNOWN) + snprintf(note, sizeof note, "%s", + tf("agent.caps.note", "why", + cerr[0] ? cerr : t("agent.caps.baseline_failed"), NULL)); + + fprintf(stderr, "%s%s%s\n", col(a, C_DIM), + tf("agent.caps.line", "vision", vis, "tools", tl, "note", note, NULL), + col(a, C_OFF)); +} + +void agent_ensure_caps(Agent *a) { ensure_caps(a, 0); } +void agent_probe_now(Agent *a) { ensure_caps(a, 1); } + +int agent_run(Agent *a) +{ + char err[1024]; + + /* "这一轮"的定义就在这儿:一条用户消息引发的一整串工具调用。 + * 确认提示上的 Always/neVer 只活到这里,下一条消息进来时复位。 */ + confirm_reset_turn(); + int img_dropped = 0; /* 图片被服务端拒了只降级一次,免得来回打转 */ + + agent_ensure_caps(a); + + + /* max_steps <= 0 时不限往返次数(默认)。计数用 long long:不限次数的会话 + * 真的可能跑很多步,而 int 溢出是 UB。 */ + for (long long step = 0; ; step++) { + DeltaCtx dc; + dc.printed = 0; + dc.md = a->md; + + spinner_start(t("spinner.waiting")); + Req req = req_prepare(a); + LlmResponse *resp = llm_chat(&a->llm, req.messages, req.tools, + a->llm.stream ? on_delta : NULL, &dc, + a->llm.stream ? on_think : NULL, NULL, + err, sizeof err); + req_free(&req); + spinner_stop(); + + /* 上下文超限**不再自动裁剪历史**。 + * + * 这里曾经有一整套"丢最老一轮 → 重试,最多丢 5 轮"的机制(配套 + * --no-auto-trim / auto_trim / LIGENT_AUTO_TRIM)。它被删掉的理由: + * + * 1. 判断依据只是错误文案里的关键词("too long"、"上下文"、"超出"…), + * 任何 4xx 蹭到这些词都会触发裁剪 —— 而裁剪是把最老的几轮从历史里 + * **真的删掉**,不可逆。用一句猜出来的判据去毁掉对话记录,代价不对等。 + * 2. 它把"历史太大"这个本该由用户决定的事(/back 摘掉哪一轮、/reset + * 重来、还是换个窗口更大的模型)自动化掉了,用户看到的是对话莫名其妙 + * 少了几轮。 + * + * 现在超限就是如实报错,历史一个字节都不动,怎么处理由用户决定。 */ + + /* ---- 图片被服务端拒绝:把图片从历史里摘掉再试一次 ---- + * + * 格式和体积本地已经拦了,但服务端还可能因为别的原因不收(图片损坏、 + * 这个变体不支持……)。那条引用躺在历史里,不修的话之后每一轮都会以 + * 同样的 4xx 失败、历史却原样保留 —— 用户只剩 /reset 一条路。 + * 这里宁可丢掉这些图,也不能让整段对话死在这儿。 */ + if (!resp && !dc.printed && !img_dropped && + agent_is_http_4xx(err) && image_has_refs(a->messages) && + err_mentions_image(err)) { + int n = image_degrade_all(a->messages); + if (n > 0) { + img_dropped = 1; + 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), + col(a, C_OFF)); + md_finish(a->md); + dc.printed = 0; + spinner_start(t("spinner.waiting")); + Req req3 = req_prepare(a); + resp = llm_chat(&a->llm, req3.messages, req3.tools, + a->llm.stream ? on_delta : NULL, &dc, + a->llm.stream ? on_think : NULL, NULL, + err, sizeof err); + req_free(&req3); + spinner_stop(); + } + } + + if (!resp) { + md_finish(a->md); + fprintf(stderr, "%s%s%s\n", col(a, C_RED), + tf("agent.error", "error", err, NULL), col(a, C_OFF)); + return -1; + } + + if (resp->interrupted) { + md_finish(a->md); + 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 渲染;工具输出保持原样,避免破坏代码/命令结果。 + * + * 判据是"流式回调有没有真的打印过",而不是"配置是不是流式模式": + * 服务端可能忽略 stream 参数直接返回整包 JSON(llm.c 会回退解析), + * 这种情况下不会有任何 delta 回调,必须在这里补打印, + * 否则内容被解析出来却永远不显示。 */ + int wrote_text = dc.printed; + if (!wrote_text) { + const char *content = json_get_string(resp->message, "content"); + if (content && *content) { + md_feed(a->md, content); + wrote_text = 1; + } + } + md_finish(a->md); + if (wrote_text) fputc('\n', stdout); + fflush(stdout); + + if (resp->incomplete) { + fprintf(stderr, "%s%s%s\n", col(a, C_DIM), + tf("agent.stream.broken", "why", + resp->incomplete_reason ? resp->incomplete_reason : "", + NULL), + col(a, C_OFF)); + } + + if (resp->usage) { + a->tok_prompt += (long long)json_get_number(resp->usage, "prompt_tokens", 0); + a->tok_completion += (long long)json_get_number(resp->usage, "completion_tokens", 0); + a->tok_total += (long long)json_get_number(resp->usage, "total_tokens", 0); + a->tok_last_prompt = (long long)json_get_number(resp->usage, "prompt_tokens", 0); + a->usage_reported = 1; + } + a->api_calls++; + + json_array_push(a->messages, json_clone(resp->message)); + + JsonValue *tool_calls = json_object_get(resp->message, "tool_calls"); + int has_tools = a->use_tools && tool_calls && + json_array_size(tool_calls) > 0; + if (has_tools) tool_calls = json_clone(tool_calls); + llm_response_free(resp); + + 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"); + if (!c || !*c) + printf("%s%s%s\n", col(a, C_DIM), t("agent.empty_reply"), + col(a, C_OFF)); + } + return 0; + } + + int rc = execute_tool_calls(a, tool_calls); + json_free(tool_calls); + if (rc != 0) return 1; + + /* 只有设了正数上限才可能"到顶"。不限时这一轮一直跑到模型自己收尾 + * (或用户 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), + col(a, C_OFF)); + return 0; + } + } + return 0; +} diff --git a/src/agent.h b/src/agent.h new file mode 100644 index 0000000..31a6844 --- /dev/null +++ b/src/agent.h @@ -0,0 +1,107 @@ +/* agent.h - agent 主循环:多轮对话 + 工具调用 */ +#ifndef LIGENT_AGENT_H +#define LIGENT_AGENT_H + +#include "capability.h" +#include "json.h" +#include "llm.h" +#include "markdown.h" +#include + +/* 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 = 不限 */ + int use_tools; + int color; /* 是否输出 ANSI 颜色 */ + int show_tool_output; + int confirm; /* 写操作与命令执行前是否询问 */ + int probe_caps; /* 首个请求前跑一次能力探测序列 */ + Capabilities caps; /* 探测结果:决定要不要暴露 read_image / 工具表 */ + + /* 工具分发。默认指向 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 用量(服务端在响应里回报,这里只做累加) */ + 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; /* 本轮对话开始时间,存档文件名要用 */ + MdRenderer *md; /* 助手回复的 Markdown 渲染器 */ +}; +typedef struct Agent Agent; + +void agent_init(Agent *a); +void agent_free(Agent *a); + +/* 内置默认 system 提示词,返回值需 free */ +char *agent_default_system_prompt(void); + +/* 设置/覆盖 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 用它把文件和图片跟用户这一句话一起发出去。 */ +void agent_add_user_parts(Agent *a, const char *text, JsonValue *parts); + +/* 确保模型能力已探测(按 base_url+model 缓存,只真跑一次)。 + * 探测被关掉时什么都不做。 */ +void agent_ensure_caps(Agent *a); + +/* 手动探测一次:**无视** --no-probe 与缓存,重新测(REPL 的 /probe 用它)。 + * 与自动探测的唯一区别就是这两点 —— 打出来的结论是同一份。 */ +void agent_probe_now(Agent *a); + +/* 开关彩色 + Markdown 渲染。 + * 关闭时助手回复逐字节原样输出,保证管道 / 重定向拿到干净文本。 */ +void agent_set_color(Agent *a, int enabled); + +/* 清空对话历史(保留 system 提示词) */ +void agent_reset(Agent *a); + +/* 从最后一条 user 消息开始跑,直到模型不再请求工具。 + * 返回 0 正常结束,-1 出错,1 被用户中断。 */ +int agent_run(Agent *a); + +/* 结构体检:保证 assistant(tool_calls) 后面紧跟对应的 tool 消息。 + * 没问题返回 NULL;修过则返回重建好的数组(调用方负责替换旧的)。 + * *out_fixed 回填补/删的处数。 */ +JsonValue *agent_repair_tool_pairing(const JsonValue *messages, int *out_fixed); + +/* 当前历史的消息条数(含 system 提示词,内部比对用) */ +size_t agent_history_len(const Agent *a); + +/* 面向用户的"历史消息数":不含 system 提示词 */ +size_t agent_conversation_len(const Agent *a); + +/* 用 given 整体替换历史(取得所有权)。存档里的 system 消息会被丢掉, + * 保留当前生效的那条 —— 否则载入旧存档会把用户现在的提示词换掉。 + * 同时把对话开始时间重置为 now。 */ +void agent_replace_history(Agent *a, JsonValue *messages); + +/* 摘掉最近一整轮(最后一条 user 消息及其之后的一切),返回被摘掉的消息数组, + * 没有可摘的则返回 NULL。调用者负责释放。 + * 用于 /back:模型调用的工具与结果必须整组一起走,不能只删中间几条。 */ +JsonValue *agent_take_last_round(Agent *a); + +/* 服务端回了 4xx(图片引用失效的重传就靠它触发) */ +int agent_is_http_4xx(const char *err); + +/* 这条 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 new file mode 100644 index 0000000..3fe21a1 --- /dev/null +++ b/src/archive.c @@ -0,0 +1,305 @@ +/* archive.c - 对话存档实现(文件名即谱系) */ +#include "archive.h" +#include "i18n.h" +#include "util.h" + +#include +#include +#include +#include +#include +#include +#include + +#define ARCHIVE_SUBDIR "ligent.archive" +#define ARCHIVE_FORMAT "ligent.archive" +#define ARCHIVE_EXT ".json" + +char *archive_dir(const char *bindir) +{ + return xasprintf("%s/%s", bindir && *bindir ? bindir : ".", ARCHIVE_SUBDIR); +} + +static void stamp(time_t t, const char *fmt, char *out, size_t n) +{ + struct tm tm; + localtime_r(&t, &tm); + strftime(out, n, fmt, &tm); +} + +/* ------------------------------------------------------------------ */ +/* 名字生成 */ +/* ------------------------------------------------------------------ */ + +/* 随机字母数字串由 util.c 的 random_alnum 提供([a-z0-9],与用户给的例子一致)。 + * 图片备份名用的也是同一个函数 —— 以前两处各写了一份,连退化种子都各写一遍。 */ + +/* 接一级分支之后是否还塞得下(前缀也算在内)。 + * 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 + + strlen(ARCHIVE_EXT); + return need <= ARCHIVE_NAME_MAX; +} + +int archive_next_name(const char *bindir, const char *lineage, + const char *prefix, int meta, char **out, + char *errbuf, size_t errlen) +{ + if (out) *out = NULL; + char *dir = archive_dir(bindir); + mkdir_p(dir); + + const char *p = prefix ? prefix : ""; + size_t plen = strlen(p); + size_t sfx = meta ? ARCHIVE_META_LEN : ARCHIVE_BRANCH_LEN; + int as_branch = (lineage && *lineage && branch_fits(lineage, plen, sfx)); + + for (int attempt = 0; attempt < 64; attempt++) { + char *name; + if (as_branch) { + char suffix[ARCHIVE_META_LEN + 1]; + random_alnum(suffix, ARCHIVE_BRANCH_LEN); + if (meta) { + /* _m<3位>:标记 + 随机,长度 4 与普通分支的 3 区分开 */ + char tail[ARCHIVE_META_LEN + 1]; + tail[0] = ARCHIVE_META_MARK; + memcpy(tail + 1, suffix, ARCHIVE_BRANCH_LEN + 1); + name = xasprintf("%s%s_%s", p, lineage, tail); + } else { + name = xasprintf("%s%s_%s", p, lineage, suffix); + } + } else { + char root[ARCHIVE_ROOT_LEN + 1]; + random_alnum(root, ARCHIVE_ROOT_LEN); + name = xasprintf("%s%s", p, root); + } + char *full = xasprintf("%s/%s%s", dir, name, ARCHIVE_EXT); + if (access(full, F_OK) != 0) { + free(dir); + if (out) *out = name; else free(name); + free(full); + return 0; + } + free(full); + free(name); + } + + /* 极不可能走到这里(64 次都撞名),把话说清楚而不是静默失败 */ + snprintf(errbuf, errlen, "%s", t("archive.err.name_exhausted")); + free(dir); + return -1; +} + +/* ------------------------------------------------------------------ */ +/* 导出 */ +/* ------------------------------------------------------------------ */ + +int archive_export(const JsonValue *messages, const char *bindir, + const char *lineage, const char *prefix, int meta, + time_t created, time_t saved, + const char *model, const char *base_url, + char **out_path, char **out_name, + char *errbuf, size_t errlen) +{ + if (out_path) *out_path = NULL; + if (out_name) *out_name = NULL; + + char *dir = archive_dir(bindir); + if (mkdir_p(dir) != 0) { + snprintf(errbuf, errlen, "%s", + tf("archive.err.mkdir", "dir", dir, + "error", strerror(errno), NULL)); + free(dir); + return -1; + } + + char *name = NULL; + if (archive_next_name(bindir, lineage, prefix, meta, &name, errbuf, errlen) != 0) { + free(dir); + return -1; + } + char *path = xasprintf("%s/%s%s", dir, name, ARCHIVE_EXT); + free(dir); + + char ctime_s[32], stime_s[32]; + stamp(created, "%Y-%m-%d %H:%M:%S", ctime_s, sizeof ctime_s); + stamp(saved, "%Y-%m-%d %H:%M:%S", stime_s, sizeof stime_s); + + JsonValue *root = json_new_object(); + json_object_set_string(root, "format", ARCHIVE_FORMAT); + json_object_set_number(root, "version", 2); + json_object_set_string(root, "name", name); + /* 记下来源,这样即使文件名被改名或谱系因超长而重置, + * 也能从内容里看出它从哪来的 */ + /* meta 版是另起的新根,所以 parent 记空 —— 出处已经写在文件名里了 */ + json_object_set_string(root, "parent", + (!meta && lineage && *lineage) ? lineage : ""); + json_object_set_string(root, "created", ctime_s); + json_object_set_string(root, "saved", stime_s); + json_object_set_string(root, "model", model ? model : ""); + json_object_set_string(root, "base_url", base_url ? base_url : ""); + json_object_set_number(root, "message_count", + (double)json_array_size(messages)); + json_object_set(root, "messages", json_clone(messages)); + + char *text = json_dump_pretty(root); + json_free(root); + + FILE *f = fopen(path, "wb"); + if (!f) { + snprintf(errbuf, errlen, "%s", + tf("archive.err.write_open", "path", path, + "error", strerror(errno), NULL)); + free(text); free(path); free(name); + return -1; + } + size_t n = strlen(text); + int ok = (fwrite(text, 1, n, f) == n); + if (fclose(f) != 0) ok = 0; + free(text); + + if (!ok) { + snprintf(errbuf, errlen, "%s", + tf("archive.err.write_failed", "path", path, NULL)); + free(path); free(name); + return -1; + } + if (out_path) *out_path = path; else free(path); + if (out_name) *out_name = name; else free(name); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* 列举与载入 */ +/* ------------------------------------------------------------------ */ + +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 : ""); +} + +size_t archive_list(const char *bindir, ArchiveEntry **out) +{ + if (out) *out = NULL; + char *dir = archive_dir(bindir); + DIR *d = opendir(dir); + if (!d) { free(dir); return 0; } + + ArchiveEntry *list = NULL; + size_t count = 0, cap = 0; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (!str_ends_with(de->d_name, ARCHIVE_EXT)) continue; + + char *path = xasprintf("%s/%s", dir, de->d_name); + size_t stemlen = strlen(de->d_name) - strlen(ARCHIVE_EXT); + char *stem = xstrndup(de->d_name, stemlen); + + char err[256]; + char *created = NULL, *saved = NULL, *model = NULL, *name = NULL, *parent = NULL; + JsonValue *msgs = archive_load(path, &created, &saved, &model, &name, &parent, + err, sizeof err); + + if (count == cap) { + cap = cap ? cap * 2 : 16; + list = xrealloc(list, cap * sizeof(ArchiveEntry)); + } + ArchiveEntry *e = &list[count++]; + memset(e, 0, sizeof *e); + e->path = path; + e->name = stem; + e->created = created; /* 时间只从内容里读,不看 mtime */ + e->saved = saved; /* 上次写出这份存档的时间 */ + e->model = model; + e->count = msgs ? (long)json_array_size(msgs) : -1; + /* parent 只用于展示,这里不单独留存 */ + free(name); + free(parent); + json_free(msgs); + } + closedir(d); + free(dir); + + if (count > 1) qsort(list, count, sizeof(ArchiveEntry), cmp_entry); + if (out) *out = list; + else archive_list_free(list, count); + return count; +} + +void archive_list_free(ArchiveEntry *list, size_t n) +{ + if (!list) return; + for (size_t i = 0; i < n; i++) { + free(list[i].path); + free(list[i].name); + free(list[i].parent); + free(list[i].created); + free(list[i].saved); + free(list[i].model); + } + free(list); +} + +static void dup_opt(char **dst, const char *src) +{ + if (dst) *dst = src ? xstrdup(src) : NULL; +} + +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) +{ + if (out_created) *out_created = NULL; + if (out_saved) *out_saved = NULL; + if (out_model) *out_model = NULL; + if (out_name) *out_name = NULL; + if (out_parent) *out_parent = NULL; + + size_t len = 0; + char *text = read_entire_file(path, &len); + if (!text) { + snprintf(errbuf, errlen, "%s", + tf("archive.err.read_failed", "path", path, NULL)); + return NULL; + } + char jerr[256]; + JsonValue *root = json_parse_ui(text, jerr, sizeof jerr); + free(text); + if (!root) { + snprintf(errbuf, errlen, "%s", + tf("archive.err.not_json", "path", path, "error", jerr, NULL)); + return NULL; + } + + const char *fmt = json_get_string(root, "format"); + if (!fmt || strcmp(fmt, ARCHIVE_FORMAT) != 0) { + snprintf(errbuf, errlen, "%s", + tf("archive.err.not_archive", "path", path, NULL)); + json_free(root); + return NULL; + } + JsonValue *msgs = json_object_get(root, "messages"); + if (!msgs || msgs->type != JSON_ARRAY) { + snprintf(errbuf, errlen, "%s", + tf("archive.err.no_messages", "path", path, NULL)); + json_free(root); + return NULL; + } + + dup_opt(out_created, json_get_string(root, "created")); + dup_opt(out_saved, json_get_string(root, "saved")); + dup_opt(out_model, json_get_string(root, "model")); + dup_opt(out_name, json_get_string(root, "name")); + dup_opt(out_parent, json_get_string(root, "parent")); + + JsonValue *copy = json_clone(msgs); + json_free(root); + return copy; +} diff --git a/src/archive.h b/src/archive.h new file mode 100644 index 0000000..e5d5b70 --- /dev/null +++ b/src/archive.h @@ -0,0 +1,81 @@ +/* archive.h - 对话存档:导出与载入 + * + * 命名方式刻意做成"文件名即谱系": + * + * a1b2c3d4 从零开始的一段对话 + * a1b2c3d4_a9x 从 a1b2c3d4 分出来的分支 + * a1b2c3d4_a9x_k2m 再往下分支 + * + * 这样不需要任何索引文件,光看目录就能看出分支关系,也不会因为索引与 + * 实际文件不同步而出错。代价是名字会越来越长,所以超过长度上限时 + * 重新起一个 8 位随机根(谱系就此断开,但至少不会撑爆文件名)。 + * + * 存档放在「二进制所在目录」下的 ligent.archive/ 里 —— 和配置同一个原则: + * 整个程序连同它的数据都待在一个目录里,拷走即走。 + */ +#ifndef LIGENT_ARCHIVE_H +#define LIGENT_ARCHIVE_H + +#include "json.h" +#include + +/* 完整文件名(含 .json)的长度上限 */ +#define ARCHIVE_NAME_MAX 240 +/* 根名与每级分支后缀的长度 */ +#define ARCHIVE_ROOT_LEN 8 +#define ARCHIVE_BRANCH_LEN 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 *created; /* "YYYY-MM-DD HH:MM:SS" */ + char *saved; + char *model; + long count; /* 消息条数;-1 表示读取失败 */ +} ArchiveEntry; + +/* 存档目录:/ligent.archive(malloc) */ +char *archive_dir(const char *bindir); + +/* 临时存档的前缀。/import 前那次保底存档用它,以便和用户主动存下来的分支 + * 区分开:它只是"换掉当前对话之前先留一手",不属于分支树的一部分。 */ +#define ARCHIVE_TMP_PREFIX "tmp_" + +/* 生成下一个可用的文件名(不含 .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)。 */ +int archive_export(const JsonValue *messages, const char *bindir, + const char *lineage, const char *prefix, int meta, + time_t created, time_t saved, + const char *model, const char *base_url, + char **out_path, char **out_name, + char *errbuf, size_t errlen); + +/* 列出全部存档,按开始时间从新到旧排序。返回条数,*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)。 */ +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); + +#endif /* LIGENT_ARCHIVE_H */ diff --git a/src/attach.c b/src/attach.c new file mode 100644 index 0000000..95591cc --- /dev/null +++ b/src/attach.c @@ -0,0 +1,473 @@ +/* attach.c - /include 的实现:暂存、glob 展开、交互式挑选 */ +#include "attach.h" +#include "i18n.h" +#include "image.h" +#include "lineedit.h" +#include "util.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* 暂存区。每一项都已经是一个可以直接塞进 content 数组的块 */ +static JsonValue *g_parts; /* 懒创建 */ +static size_t g_text_bytes; /* 文本累计字节数,用来卡总量 */ + +/* 每个块对应的来源路径,只用于给用户看。 + * + * 刻意**不**把它塞进块里:那会跟着请求一起发给服务端,而 content 块里多一个 + * 自造字段是可以被严格的服务端判 400 的。显示用的东西就该留在本地。 */ +static char **g_names; +static size_t g_names_n, g_names_cap; + +static void names_clear(void) +{ + for (size_t i = 0; i < g_names_n; i++) free(g_names[i]); + free(g_names); + g_names = NULL; + g_names_n = g_names_cap = 0; +} + +void attach_clear(void) +{ + json_free(g_parts); + g_parts = NULL; + g_text_bytes = 0; + names_clear(); +} + +/* 把来源路径记下来(和 g_parts 同步增长) */ +static void remember(const char *path) +{ + if (g_names_n == g_names_cap) { + g_names_cap = g_names_cap ? g_names_cap * 2 : 8; + g_names = xrealloc(g_names, g_names_cap * sizeof *g_names); + } + g_names[g_names_n++] = xstrdup(path); +} + +static JsonValue *parts_arr(void) +{ + if (!g_parts) g_parts = json_new_array(); + return g_parts; +} + +size_t attach_count(void) +{ + return g_parts ? json_array_size(g_parts) : 0; +} + +int attach_has_image(void) +{ + if (!g_parts) return 0; + for (size_t i = 0; i < json_array_size(g_parts); i++) { + const char *t = json_get_string(json_array_get(g_parts, i), "type"); + if (t && strcmp(t, "ligent_image") == 0) return 1; + } + return 0; +} + +JsonValue *attach_take_parts(void) +{ + JsonValue *p = g_parts; + g_parts = NULL; + g_text_bytes = 0; + names_clear(); + return p; +} + +void attach_print(void) +{ + if (!g_parts) return; + for (size_t i = 0; i < json_array_size(g_parts); i++) { + JsonValue *p = json_array_get(g_parts, i); + const char *ty = json_get_string(p, "type"); + const char *name = (i < g_names_n) ? g_names[i] : "?"; + if (ty && strcmp(ty, "ligent_image") == 0) + printf(" %s%s\n", name, t("attach.image_mark")); + else + printf(" %s\n", name); + } +} + +/* ------------------------------------------------------------------ */ +/* 单个文件的处理 */ +/* ------------------------------------------------------------------ */ + +/* 二进制判定(判据与 8 KB 窗口都在 util.h,和 read_file 共用一份): + * 以前这里扫的是**整个文件**而 read_file 只看前 8 KB,于是同一个文件会出现 + * "/include 说它是二进制、read_file 说它能读"这种自相矛盾的说法。 */ + +/* 把一个文件变成 content 块推入暂存区。 + * 返回 1 = 已附带,0 = 跳过(原因打到 skipped_why),-1 = 出错了(同 0 处理) */ +static int add_one(const char *path, AttachVisionFn can_image, void *ud, + const char **why) +{ + struct stat st; + 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 一个规矩) */ + { + size_t len = 0; + char *raw = read_entire_file(path, &len); + if (!raw) { *why = t("attach.err.unreadable"); return 0; } + + const char *ext = NULL, *mime = NULL; + int is_image = (image_sniff((const unsigned char *)raw, len, &ext, &mime) == 0); + free(raw); + + if (is_image) { + /* 只有真遇到图片才去问"模型收不收图" */ + if (can_image && !can_image(ud)) { + *why = t("attach.err.no_vision"); + return 0; + } + char *err = NULL; + char *backup = image_backup_from_path(path, NULL, NULL, &err); + if (!backup) { + /* 超限/写不了备份:把原话带出去 */ + static char buf[400]; + snprintf(buf, sizeof buf, "%s", + err ? err : t("attach.err.read_failed")); + free(err); + *why = buf; + return 0; + } + JsonValue *part = json_new_object(); + json_object_set_string(part, "type", "ligent_image"); + json_object_set_string(part, "backup", backup); + free(backup); + json_array_push(parts_arr(), part); + remember(path); + return 1; + } + } + + /* 文本 */ + if ((size_t)st.st_size > ATTACH_MAX_FILE) { + *why = t("attach.err.too_big"); + return 0; + } + if (g_text_bytes + (size_t)st.st_size > ATTACH_MAX_TOTAL) { + *why = t("attach.err.total_big"); + return 0; + } + + size_t len = 0; + char *raw = read_entire_file(path, &len); + if (!raw) { *why = t("attach.err.unreadable"); return 0; } + + if (looks_binary(raw, len, NULL)) { + free(raw); + *why = t("attach.err.binary"); + return 0; + } + + /* 头 + 正文。头用 ===== 包起来,模型一眼能看出文件从哪开始。 + * 文件不以换行结尾时补一个,免得下一个文件头黏在最后一行后面。 */ + StrBuf sb; + sb_init(&sb); + 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'); + + JsonValue *part = json_new_object(); + json_object_set_string(part, "type", "text"); + json_object_set_string(part, "text", sb.data ? sb.data : ""); + sb_free(&sb); + json_array_push(parts_arr(), part); + remember(path); + g_text_bytes += len; + return 1; +} + +/* ------------------------------------------------------------------ */ +/* glob 展开 */ +/* ------------------------------------------------------------------ */ + +int attach_glob(const char *pattern, AttachVisionFn can_image, void *ud, + size_t *skipped, char *err, size_t errlen) +{ + if (skipped) *skipped = 0; + if (err && errlen) err[0] = '\0'; + if (!pattern || !*pattern) { + if (err && errlen) + snprintf(err, errlen, "%s", t("attach.err.no_pattern")); + return -1; + } + + glob_t g; + memset(&g, 0, sizeof g); + /* 认 ~/。**用 #ifdef 包起来**:GLOB_TILDE 是 GNU 扩展,glibc 一定有, + * 而 musl 那边不敢打包票(它挂在编译期 —— 没有这个宏就是编译失败, + * 静态 musl 构建会直接挂在这儿)。有就带,没有就退化成普通路径展开。 + * 不开 GLOB_BRACE:{a,b} 是 shell 特有写法,开了会让用户以为别的 shell + * 语法也能用。 */ + int gflags = 0; +#ifdef GLOB_TILDE + gflags |= GLOB_TILDE; +#endif + int rc = glob(pattern, gflags, NULL, &g); + if (rc == GLOB_NOMATCH) { + globfree(&g); + if (err && errlen) + snprintf(err, errlen, "%s", + tf("attach.err.no_match", "pattern", pattern, NULL)); + return -1; + } + if (rc != 0) { + globfree(&g); + if (err && errlen) + snprintf(err, errlen, "%s", + tf("attach.err.glob_failed", "pattern", pattern, NULL)); + return -1; + } + + int added = 0; + size_t skipped_n = 0; + for (size_t i = 0; i < g.gl_pathc; i++) { + if (attach_count() >= ATTACH_MAX_FILES) { skipped_n++; continue; } + const char *why = NULL; + if (add_one(g.gl_pathv[i], can_image, ud, &why) == 1) { + added++; + } else { + skipped_n++; + /* 这是用户自己敲的命令的回执,走 stdout(stderr 留给诊断) */ + if (why) + printf("%s\n", tf("attach.glob.skipped", "file", g.gl_pathv[i], + "why", why, NULL)); + } + } + globfree(&g); + if (skipped) *skipped = skipped_n; + return added; +} + +/* ------------------------------------------------------------------ */ +/* 交互式挑选:逐层进入目录 */ +/* ------------------------------------------------------------------ */ + +#define PICK_MAX_SHOW 200 + +static int cmp_str(const void *a, const void *b) +{ + return strcmp(*(const char *const *)a, *(const char *const *)b); +} + +/* 列一个目录。返回 malloc 的完整路径数组(目录带尾斜杠),*n 回填个数。 */ +static char **list_dir_sorted(const char *dir, size_t *n) +{ + char **out = NULL; + size_t cnt = 0, cap = 0; + DIR *d = opendir(dir); + if (!d) { *n = 0; return NULL; } + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (de->d_name[0] == '.') continue; /* 隐藏文件先不列 */ + if (cnt == cap) { + cap = cap ? cap * 2 : 32; + out = xrealloc(out, cap * sizeof *out); + } + char *full = xasprintf("%s/%s", dir, de->d_name); + struct stat st; + if (stat(full, &st) == 0 && S_ISDIR(st.st_mode)) { + char *with_slash = xasprintf("%s/", full); + free(full); + full = with_slash; + } + out[cnt++] = full; + } + closedir(d); + if (cnt > 1) qsort(out, cnt, sizeof *out, cmp_str); + *n = cnt; + return out; +} + +static void free_list(char **l, size_t n) +{ + /* 空表就是 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 回填个数。 */ +static char **resolve_input(const char *dir, const char *input, size_t *n) +{ + *n = 0; + char *pat; + if (input[0] == '/') pat = xstrdup(input); + else pat = xasprintf("%s/%s", dir, input); + + glob_t g; + memset(&g, 0, sizeof g); + int gflags = 0; +#ifdef GLOB_TILDE + gflags |= GLOB_TILDE; +#endif + int rc = glob(pat, gflags, NULL, &g); + free(pat); + if (rc != 0) { globfree(&g); return NULL; } + + char **out = xcalloc(g.gl_pathc ? g.gl_pathc : 1, sizeof *out); + size_t cnt = 0; + for (size_t i = 0; i < g.gl_pathc; i++) out[cnt++] = xstrdup(g.gl_pathv[i]); + globfree(&g); + *n = cnt; + return out; +} + +static int human_size(long long n, char *buf, size_t len) +{ + if (n < 1024) return snprintf(buf, len, "%lld B", n); + if (n < 1024 * 1024) return snprintf(buf, len, "%.1f KB", (double)n / 1024.0); + return snprintf(buf, len, "%.1f MB", (double)n / (1024.0 * 1024.0)); +} + +int attach_pick(const char *start, AttachVisionFn can_image, void *ud, + size_t *skipped) +{ + if (skipped) *skipped = 0; + + char *dir = start && *start ? xstrdup(start) : getcwd(NULL, 0); + if (!dir) dir = xstrdup("."); + int added = 0; + size_t skipped_n = 0; + + puts(t("attach.pick.intro")); + + for (;;) { + size_t n = 0; + char **items = list_dir_sorted(dir, &n); + + printf("%s", tf("attach.pick.dir", "dir", dir, NULL)); + if (n == 0) { + puts(t("attach.pick.empty_dir")); + } else { + size_t show = n > PICK_MAX_SHOW ? PICK_MAX_SHOW : n; + for (size_t i = 0; i < show; i++) { + const char *p = items[i]; + size_t pl = strlen(p); + int isdir = (pl > 0 && p[pl - 1] == '/'); + struct stat st; + char sz[48] = ""; + if (!isdir && stat(p, &st) == 0) { + char raw[32]; + human_size((long long)st.st_size, raw, sizeof raw); + snprintf(sz, sizeof sz, " %s", raw); + } + printf(" %3zu) %s%s%s\n", i + 1, p, + isdir ? t("attach.pick.is_dir") : "", sz); + } + if (n > show) + printf("%s", tf("attach.pick.more", "n", + i18n_num((long long)(n - show)), NULL)); + } + printf("%s\n", t("attach.pick.parent")); + printf("%s", tf("attach.pick.prompt", "n", + i18n_num((long long)attach_count()), NULL)); + + int intr = 0; + char *line = line_edit("", &intr); + if (!line) { + printf("\n%s\n", t("attach.pick.cancelled")); + free_list(items, n); + break; + } + + char *ans = str_trim(line); + if (*ans == '\0') { free(line); free_list(items, n); break; } + if (strcmp(ans, ".") == 0 || strcmp(ans, "..") == 0) { + char *up = xasprintf("%s/..", dir); + char *real = realpath(up, NULL); + free(up); + if (real) { free(dir); dir = real; } + free(line); free_list(items, n); + continue; + } + + /* 纯数字 = 序号 */ + char *numend = NULL; + long idx = strtol(ans, &numend, 10); + if (numend != ans && *str_trim(numend) == '\0') { + if (idx < 1 || (size_t)idx > n) { + printf("%s", tf("attach.pick.index_range", "n", i18n_num(idx), + "max", i18n_num((long long)n), NULL)); + free(line); free_list(items, n); + continue; + } + char *picked = xstrdup(items[idx - 1]); + size_t pl = strlen(picked); + if (pl > 0 && picked[pl - 1] == '/') { + /* 进目录:去掉尾斜杠 */ + picked[pl - 1] = '\0'; + free(dir); + dir = picked; + } else { + const char *why = NULL; + if (add_one(picked, can_image, ud, &why) == 1) { + added++; + printf("%s\n", tf("attach.pick.selected", "file", picked, NULL)); + } else { + skipped_n++; + printf("%s\n", tf("attach.pick.skipped", "file", picked, + "why", why ? why : t("attach.err.read_failed"), + NULL)); + } + free(picked); + } + free(line); free_list(items, n); + continue; + } + + /* 其它:当成 glob / 路径 / 目录名 */ + size_t rn = 0; + char **res = resolve_input(dir, ans, &rn); + free(line); + if (rn == 0) { + printf("%s", tf("attach.pick.no_match", "input", ans, NULL)); + free_list(items, n); + continue; + } + if (rn == 1) { + struct stat st; + if (stat(res[0], &st) == 0 && S_ISDIR(st.st_mode)) { + free(dir); + dir = xstrdup(res[0]); + free_list(res, rn); + free_list(items, n); + continue; + } + } + for (size_t i = 0; i < rn; i++) { + if (attach_count() >= ATTACH_MAX_FILES) { skipped_n++; continue; } + const char *why = NULL; + if (add_one(res[i], can_image, ud, &why) == 1) { + added++; + printf("%s\n", tf("attach.pick.selected", "file", res[i], NULL)); + } else { + skipped_n++; + printf("%s\n", tf("attach.pick.skipped", "file", res[i], + "why", why ? why : t("attach.err.read_failed"), + NULL)); + } + } + free_list(res, rn); + free_list(items, n); + } + + free(dir); + if (skipped) *skipped = skipped_n; + return added; +} diff --git a/src/attach.h b/src/attach.h new file mode 100644 index 0000000..6afc75b --- /dev/null +++ b/src/attach.h @@ -0,0 +1,64 @@ +/* attach.h - /include:把文件附带进下一条消息 + * + * 设计成"暂存"而不是"立刻发出去":附带文件的意义在于配合一个提问 + * ("这几个文件有什么问题"),所以 /include 先把文件攒起来,下一条消息 + * 带上它们一起发。暂存是进程内状态,发完就清空。 + * + * 附带的文件在会话里有两种形态: + * 文本 -> 直接作为 text 块(就是内容本身) + * 图片 -> 存成本机备份,历史里只放 ligent_image 引用 —— 和 read_image + * 完全同一条路,图片字节不进 JSON,构造请求时才展开成 base64。 + * + * 图片有额外前提:**模型得能看图**。所以附带图片前会先用能力探测的结论 + * 确认一次,不支持就跳过并如实说明,而不是把图塞进去让服务端 400。 + */ +#ifndef LIGENT_ATTACH_H +#define LIGENT_ATTACH_H + +#include "json.h" + +#include + +/* 单个文本文件的体积上限。比 read_file 的工具输出上限(100 KB)宽一些, + * 因为这是用户点名要带的,不是模型顺手的输出。 */ +#define ATTACH_MAX_FILE (256u * 1024) +/* 一次暂存的文本总量。再多就该用 read_file 的分段读,或者先挑一挑。 */ +#define ATTACH_MAX_TOTAL (1024u * 1024) +/* 一次最多附带几个文件 */ +#define ATTACH_MAX_FILES 32 + +/* 清空暂存 */ +void attach_clear(void); + +/* 当前暂存了几个文件 */ +size_t attach_count(void); + +/* 暂存里有图片吗 */ +int attach_has_image(void); + +/* 遇到图片时回调一次,问"当前模型能收图吗",返回 0 就跳过这张图。 + * + * 做成回调而不是一个布尔入参:判断"能不能收图"要跑能力探测,而探测是要发 + * 请求的。纯文本的 /include 不该为了一张图都没有的一次附带去探测模型。 */ +typedef int (*AttachVisionFn)(void *ud); + +/* 按 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。 */ +int attach_pick(const char *dir, AttachVisionFn can_image, void *ud, + size_t *skipped); + +/* 取出暂存的附件(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 new file mode 100644 index 0000000..41e24f6 --- /dev/null +++ b/src/capability.c @@ -0,0 +1,279 @@ +/* capability.c - 模型能力探测:基线 -> 合并 -> 单独拆开 */ +#include "capability.h" +#include "http.h" +#include "i18n.h" +#include "json.h" +#include "util.h" + +#include +#include +#include + +/* 一张 1x1 的 PNG。探测只需要"一个合法的图片字节流",越小越好: + * 服务端会把它缩放后按图计费,尺寸再大也换不来更多信息。 */ +static const char PROBE_PNG_B64[] = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLv" + "AAAAAElFTkSuQmCC"; + +/* 缓存:按 base_url + model 索引。同一个进程里换模型会自然重测, + * 切回来则命中。条数很少(人手切的模型个数)。 */ +#define CAP_CACHE_MAX 8 + +typedef struct { + char *base_url; + char *model; + Capabilities caps; +} CapEntry; + +static CapEntry g_cache[CAP_CACHE_MAX]; +static size_t g_cache_n; + +void cap_cache_clear(void) +{ + for (size_t i = 0; i < g_cache_n; i++) { + free(g_cache[i].base_url); + free(g_cache[i].model); + g_cache[i].base_url = g_cache[i].model = NULL; + } + g_cache_n = 0; +} + +const char *cap_name(int v) +{ + switch (v) { + case CAP_YES: return "支持"; + case CAP_NO: return "不支持"; + default: return "未测"; + } +} + +/* 给人看的标签(--verbose 的探测记录)。cap_name() 是内部/测试用的中文常量, + * 不随界面语言变,所以这里另走一遍界面文案表。 */ +static const char *cap_label(int v, int is_tools) +{ + if (v == CAP_YES) return t("agent.caps.yes"); + if (v == CAP_NO) return t(is_tools ? "agent.caps.no.tools" + : "agent.caps.no.vision"); + return t("agent.caps.unknown"); +} + +/* https://host/v1 + /chat/completions(base_url 末尾可能带斜杠) */ +static char *chat_url(const char *base_url) +{ + const char *bu = base_url ? base_url : ""; + size_t bl = strlen(bu); + while (bl > 0 && bu[bl - 1] == '/') bl--; + return xasprintf("%.*s/chat/completions", (int)bl, bu); +} + +/* 一个试探请求的结论 */ +/* 注意别叫 R_OK —— 里那个是 access() 的权限位,会撞名 */ +typedef enum { + PROBE_OK = 0, /* 2xx:接受 */ + PROBE_REJECT = 1, /* 400:明确拒绝(只有在基线通过时才有意义) */ + PROBE_UNKNOWN = 2 /* 网络、401/403/404/429/5xx……:什么也说明不了 */ +} ProbeResult; + +static ProbeResult probe(const LlmConfig *cfg, const char *url, + const JsonValue *messages, int with_tools, + int verbose, long *out_status, + char *errbuf, size_t errlen) +{ + JsonValue *body = json_new_object(); + json_object_set_string(body, "model", cfg->model); + json_object_set(body, "messages", json_clone(messages)); + /* 只让它回一个 token:探测要的是"收不收",不是回复内容。 + * 不设这个的话,默认开着思考的模型会为了回答"1"生成一大段推理。 */ + json_object_set_number(body, "max_tokens", 1); + json_object_set_bool(body, "stream", 0); + if (with_tools) { + JsonValue *tools = json_new_array(); + JsonValue *t = json_new_object(); + 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", "能力探测用,永远不该被调用"); + JsonValue *params = json_new_object(); + json_object_set_string(params, "type", "object"); + json_object_set(fn, "parameters", params); + json_object_set(t, "function", fn); + json_array_push(tools, t); + json_object_set(body, "tools", tools); + } + + char *text = json_dump(body); + json_free(body); + + char *resp_body = NULL; + 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, + local_err, sizeof local_err); + free(text); + free(resp_body); + if (out_status) *out_status = status; + + if (rc == 0) return PROBE_OK; + if (status == 400) return PROBE_REJECT; + + if (errbuf && errlen && !errbuf[0] && local_err[0]) + snprintf(errbuf, errlen, "%s", local_err); + return PROBE_UNKNOWN; +} + +/* 探测用的消息:一句 "1",可选带一张图 */ +static JsonValue *probe_messages(int with_image) +{ + JsonValue *msgs = json_new_array(); + JsonValue *m = json_new_object(); + json_object_set_string(m, "role", "user"); + + if (!with_image) { + json_object_set_string(m, "content", "1"); + json_array_push(msgs, m); + return msgs; + } + + JsonValue *content = json_new_array(); + JsonValue *tp = json_new_object(); + json_object_set_string(tp, "type", "text"); + json_object_set_string(tp, "text", "1"); + json_array_push(content, tp); + + JsonValue *ip = json_new_object(); + json_object_set_string(ip, "type", "image_url"); + JsonValue *iu = json_new_object(); + char *data_url = xasprintf("data:image/png;base64,%s", PROBE_PNG_B64); + json_object_set_string(iu, "url", data_url); + free(data_url); + json_object_set(ip, "image_url", iu); + json_array_push(content, ip); + + json_object_set(m, "content", content); + json_array_push(msgs, m); + return msgs; +} + +static void run_sequence(const LlmConfig *cfg, Capabilities *out, + int verbose, char *errbuf, size_t errlen) +{ + out->vision = out->tools = CAP_UNKNOWN; + out->probed = 1; + out->conclusive = 0; + + char *url = chat_url(cfg->base_url); + long status = 0; + + /* --- 1. 基线 --- */ + JsonValue *plain = probe_messages(0); + ProbeResult base = probe(cfg, url, plain, 0, verbose, &status, errbuf, errlen); + json_free(plain); + + if (base != PROBE_OK) { + /* 连基线都不通,后面的否定结论一概不作数 */ + if (verbose) + fprintf(stderr, "%s", + tf("capability.note.baseline_failed", "status", i18n_num(status), + NULL)); + free(url); + return; + } + out->conclusive = 1; + + /* --- 2. 合并试探:图片 + tools --- */ + JsonValue *both = probe_messages(1); + ProbeResult combined = probe(cfg, url, both, 1, verbose, &status, errbuf, errlen); + json_free(both); + + if (combined == PROBE_OK) { + /* 常见情况:两样都支持,两个请求收工 */ + out->vision = out->tools = CAP_YES; + if (verbose) fprintf(stderr, "%s", t("capability.note.both_ok")); + free(url); + return; + } + + /* --- 3. 拆开单独试:才知道是哪一个不行 --- */ + JsonValue *img = probe_messages(1); + ProbeResult vr = probe(cfg, url, img, 0, verbose, &status, errbuf, errlen); + json_free(img); + out->vision = (vr == PROBE_OK) ? CAP_YES : (vr == PROBE_REJECT ? CAP_NO : CAP_UNKNOWN); + + JsonValue *txt = probe_messages(0); + ProbeResult tr = probe(cfg, url, txt, 1, verbose, &status, errbuf, errlen); + json_free(txt); + out->tools = (tr == PROBE_OK) ? CAP_YES : (tr == PROBE_REJECT ? CAP_NO : CAP_UNKNOWN); + + if (verbose) + /* 用界面语言的标签,不用 cap_name() —— 后者是给日志和断言用的中文常量 */ + fprintf(stderr, "%s", + tf("capability.note.split", "vision", cap_label(out->vision, 0), + "tools", cap_label(out->tools, 1), NULL)); + free(url); +} + +int cap_cached(const LlmConfig *cfg, Capabilities *out) +{ + const char *model = (cfg && cfg->model) ? cfg->model : ""; + const char *base = (cfg && cfg->base_url) ? cfg->base_url : ""; + for (size_t i = 0; i < g_cache_n; i++) { + if (strcmp(g_cache[i].model, model) == 0 && + strcmp(g_cache[i].base_url, base) == 0) { + if (out) *out = g_cache[i].caps; + return 1; + } + } + return 0; +} + +int cap_get(const LlmConfig *cfg, int enable, Capabilities *out, + int verbose, char *errbuf, size_t errlen) +{ + if (!out) return 0; + if (errbuf && errlen) errbuf[0] = '\0'; + if (!cfg) { /* 没有配置就什么都测不了,按"未测"处理 */ + out->vision = out->tools = CAP_UNKNOWN; + out->probed = 0; + out->conclusive = 0; + return 0; + } + + const char *model = (cfg && cfg->model) ? cfg->model : ""; + const char *base = (cfg && cfg->base_url) ? cfg->base_url : ""; + + if (!enable) { + /* 探测被关掉:不下任何结论,一切都按"支持" */ + out->vision = out->tools = CAP_UNKNOWN; + out->probed = 0; + out->conclusive = 0; + return 0; + } + + for (size_t i = 0; i < g_cache_n; i++) { + if (strcmp(g_cache[i].model, model) == 0 && + strcmp(g_cache[i].base_url, base) == 0) { + *out = g_cache[i].caps; + return 0; + } + } + + 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); + memmove(&g_cache[0], &g_cache[1], sizeof g_cache[0] * (CAP_CACHE_MAX - 1)); + g_cache_n--; + } + g_cache[g_cache_n].base_url = xstrdup(base); + g_cache[g_cache_n].model = xstrdup(model); + g_cache[g_cache_n].caps = *out; + g_cache_n++; + return 1; +} diff --git a/src/capability.h b/src/capability.h new file mode 100644 index 0000000..1f9670a --- /dev/null +++ b/src/capability.h @@ -0,0 +1,64 @@ +/* capability.h - 模型能力探测(vision / tools) + * + * 为什么需要它:OpenAI 兼容层**没有任何查询能力的接口**。DeepSeek 和 MiMo 的 + * GET /v1/models 都只返回 id / object / owned_by,没有 capability / modality + * 字段。所以"这个模型收不收图片"只能靠**发一个试探请求**问出来。 + * + * 这里叫"序列"是因为它不是一次请求,而是有先后的几步: + * + * 1. 基线:一句纯文本 + max_tokens=1 + * └ 失败 → 什么都别下结论(可能是 key、网络、模型名的问题) + * └ 成功 → 说明模型是活的,后面的否定结论才有意义 + * 2. 合并试探:图片 + tools 一起发 + * └ 200 → 两样都支持(常见情况,到此为止,只花了 2 个请求) + * └ 非 200 → 拆开单独试,才知道是哪一个不行 + * 3. 单独试探:图片、tools 各发一次 + * + * 关键在于**否定结论只在基线成功时才敢下**:单个试探 400 而基线 200,说明 + * 差别就在那一样东西上。这样不需要去猜服务端的错误文案("不支持图片"、 + * "invalid content"、"multimodal not supported"…… 各家写法都不一样), + * 也就不会因为文案变化而误判。 + * + * 猜不准的时候一律按"支持"处理:多暴露一个工具,代价是模型可能白调一次 + * (我们还有 4xx 降级兜底);而错误地藏掉工具,用户会以为模型看不见图。 + * + * 结果按 base_url + model 缓存,所以**换模型会自动重新测**,切回来则命中缓存。 + */ +#ifndef LIGENT_CAPABILITY_H +#define LIGENT_CAPABILITY_H + +#include "llm.h" + +#include + +#define CAP_UNKNOWN (-1) +#define CAP_NO 0 +#define CAP_YES 1 + +typedef struct { + int vision; /* 能不能收图片输入 */ + int tools; /* 能不能收 tools 参数 */ + int probed; /* 真的测过吗(0 = 没测或被禁用,结论一律按"支持") */ + int conclusive; /* 基线成功了吗 —— 只有它成功,否定结论才作数 */ +} Capabilities; + +/* 探测(或命中缓存)。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 这种"想知道但绝不该为此发请求"的地方用。 */ +int cap_cached(const LlmConfig *cfg, Capabilities *out); + +/* 把已缓存的结果清掉(下次调用重新探测)。 + * 正常换模型不需要它 —— 缓存按 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 new file mode 100644 index 0000000..c292760 --- /dev/null +++ b/src/config.c @@ -0,0 +1,555 @@ +/* config.c - 配置文件 / 环境变量加载 */ +#include "config.h" +#include "http.h" +#include "i18n.h" +#include "json.h" +#include "util.h" + +#include +#include +#include +#include +#include +#include +#include + +#define DEFAULT_BASE_URL "https://api.deepseek.com/v1" +#define DEFAULT_MODEL "deepseek-flash" + +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->model = xstrdup(DEFAULT_MODEL); + c->system = NULL; + c->temperature = -1; /* 不发送,用服务端默认 */ + c->max_tokens = 0; /* 不发送 */ + c->thinking = THINKING_AUTO; /* 不发送 thinking,由服务端决定 */ + c->reasoning_effort = NULL; /* 不发送 */ + c->timeout = 300; + c->stream = 1; + /* max_steps <= 0 = 不限制单轮往返次数(默认)。这是**默认**值:模型陷进 + * "调工具 → 看结果 → 再调" 的循环时不会再被我们自己掐断,由用户 Ctrl-C + * (或模型自己收尾)来结束。要恢复上限就设个正数。 */ + c->max_steps = 0; + c->use_tools = 1; + c->retries = 2; + c->confirm = 1; + c->auto_save = 1; + c->probe = 1; + c->config_path = NULL; +} + +static void set_str(char **dst, const char *val) +{ + free(*dst); + *dst = val ? xstrdup(val) : NULL; +} + +/* 服务端认识的思考级别。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 +}; + +int config_effort_valid(const char *s) +{ + if (!s || !*s) return 0; + for (size_t i = 0; EFFORTS[i]; i++) + if (strcmp(s, EFFORTS[i]) == 0) return 1; + return 0; +} + +const char *config_effort_list(void) +{ + static char buf[128]; + if (!buf[0]) { + size_t n = 0; + for (size_t i = 0; EFFORTS[i]; i++) + n += (size_t)snprintf(buf + n, sizeof buf - n, "%s%s", + i ? "/" : "", EFFORTS[i]); + } + return buf; +} + +const char *config_thinking_name(int thinking) +{ + if (thinking == THINKING_ON) return t("cfg.thinking.on"); + if (thinking == THINKING_OFF) return t("cfg.thinking.off"); + return t("cfg.thinking.auto"); +} + +int config_parse_thinking(const char *s, int *out) +{ + if (!s) return -1; + if (!*s || strcmp(s, "auto") == 0) { *out = THINKING_AUTO; return 0; } + if (strcmp(s, "on") == 0 || strcmp(s, "true") == 0 || + strcmp(s, "enabled") == 0) { *out = THINKING_ON; return 0; } + if (strcmp(s, "off") == 0 || strcmp(s, "false") == 0 || + strcmp(s, "disabled") == 0) { *out = THINKING_OFF; return 0; } + return -1; +} + +/* 配置文件里 thinking 既可以是布尔也可以是字符串: + * `"thinking": true` 比 `"thinking": "on"` 自然,两种都收。 */ +static int load_thinking(LigentConfig *c, const JsonValue *v, + const char *path, char *errbuf, size_t errlen) +{ + if (v->type == JSON_BOOL) { + c->thinking = v->u.boolean ? THINKING_ON : THINKING_OFF; + return 0; + } + const char *s = json_string_value(v); + if (s && config_parse_thinking(s, &c->thinking) == 0) return 0; + snprintf(errbuf, errlen, "%s", tf("cfg.err.thinking_type", "path", path, NULL)); + return -1; +} + +/* ------------------------------------------------------------------ */ +/* 配置文件 */ +/* ------------------------------------------------------------------ */ + +/* ---- 定位二进制所在目录 ---- + * + * 关键点:路径要"跟着用户看到的那个文件走"。如果 ligent 是个软链接, + * 用户是把它当 ./bin/ligent 用的,配置就该在 ./bin/ 旁边,而不是被 + * 悄悄挪到源文件所在的目录去。 + * + * 所以这里优先用 argv[0](不解析最后一段的软链接),只在拿不到时 + * 才退回 /proc/self/exe —— 那个会解析软链接。 */ + +/* 确认 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; /* 无法比对,放行 */ + return a.st_dev == b.st_dev && a.st_ino == b.st_ino; +} + +/* 在 $PATH 里查找可执行且确实是我们自己的文件,返回完整路径(malloc)或 NULL */ +static char *search_path(const char *name) +{ + const char *path = getenv("PATH"); + if (!path || !*path) return NULL; + + char *copy = xstrdup(path); + char *save = NULL; + char *dir = strtok_r(copy, ":", &save); + char *found = NULL; + while (dir) { + char *cand = xasprintf("%s/%s", *dir ? dir : ".", name); + if (access(cand, X_OK) == 0 && is_self(cand)) { found = cand; break; } + free(cand); + dir = strtok_r(NULL, ":", &save); + } + free(copy); + return found; +} + +/* 取路径中最后一段之前的部分;没有 '/' 时返回 NULL */ +static char *dir_of(const char *path) +{ + const char *slash = path ? strrchr(path, '/') : NULL; + if (!slash) return NULL; + if (slash == path) return xstrdup("/"); + return xstrndup(path, (size_t)(slash - path)); +} + +/* 取所在目录并规范化(解析目录部分里的软链接、. 和 ..), + * 但刻意不解析最后一段 —— 那正是"软链接所在目录"的关键 */ +static char *dir_of_canonical(const char *path) +{ + char *dir = dir_of(path); + if (!dir) return NULL; + + 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)) { + char *full = xasprintf("%s/%s", cwd, dir); + free(dir); + return full; + } + return dir; +} + +char *config_binary_dir(const char *argv0) +{ + /* 优先 argv[0]:它反映用户实际调用的那个路径 */ + if (argv0 && *argv0) { + if (strchr(argv0, '/')) { + if (is_self(argv0)) { + char *d = dir_of_canonical(argv0); + if (d) return d; + } + } else { + /* 只是个名字:去 $PATH 里找(返回的仍是软链接本身) */ + char *full = search_path(argv0); + if (full) { + char *d = dir_of_canonical(full); + free(full); + if (d) return d; + } + } + } + + /* 兜底:问内核可执行文件在哪。注意这会解析软链接, + * 只在 argv[0] 给不出答案时使用 */ + char buf[PATH_MAX]; + ssize_t n = readlink("/proc/self/exe", buf, sizeof buf - 1); + if (n > 0 && (size_t)n < sizeof buf) { + buf[n] = '\0'; + char *d = dir_of_canonical(buf); + if (d) return d; + } + + /* 都失败就认当前目录,至少不会崩 */ + return xstrdup("."); +} + +char *config_default_path(const char *argv0) +{ + char *dir = config_binary_dir(argv0); + char *path = xasprintf("%s/ligent.json", dir ? dir : "."); + free(dir); + return path; +} + +/* ---- 查找要加载的配置文件 ---- */ + +/* 只认两个来源:命令行的显式指定,以及二进制目录下的 ligent.json。 + * 不做用户级 / 系统级 / 当前工作目录的查找。 */ +static char *find_config_file(const char *explicit_path, const char *argv0) +{ + if (explicit_path && *explicit_path) { + /* 显式指定的路径不做存在性判断,读不到会明确报错 */ + return xstrdup(explicit_path); + } + + const char *env = getenv("LIGENT_CONFIG"); + if (env && *env) return xstrdup(env); + + char *path = config_default_path(argv0); + if (access(path, R_OK) == 0) return path; + free(path); /* 不存在就表示"用内置默认值" */ + return NULL; +} + +int config_load_file(LigentConfig *c, const char *path, + char *errbuf, size_t errlen) +{ + size_t len = 0; + char *text = read_entire_file(path, &len); + if (!text) { + snprintf(errbuf, errlen, "%s", + tf("cfg.err.read_failed", "path", path, NULL)); + return -1; + } + char jerr[256]; + JsonValue *root = json_parse_ui(text, jerr, sizeof jerr); + free(text); + if (!root) { + snprintf(errbuf, errlen, "%s", + tf("cfg.err.bad_json", "path", path, "error", jerr, NULL)); + return -1; + } + if (root->type != JSON_OBJECT) { + json_free(root); + snprintf(errbuf, errlen, "%s", + tf("cfg.err.not_object", "path", path, NULL)); + return -1; + } + + const char *s; + if ((s = json_get_string(root, "base_url"))) set_str(&c->base_url, s); + if ((s = json_get_string(root, "api_key"))) { + char *expanded = expand_env(s); + if (*expanded) c->api_key_from_file = 1; + set_str(&c->api_key, expanded); + free(expanded); + } + if ((s = json_get_string(root, "model"))) set_str(&c->model, s); + /* 空字符串视为"没写":内置提示词里带着工具使用规范, + * 静默丢掉会让 agent 行为明显变差;而且下发一条空的 system 消息 + * 既浪费又没有意义。 */ + if ((s = json_get_string(root, "system"))) { + if (*s) set_str(&c->system, s); + } + + JsonValue *v; + if ((v = json_object_get(root, "temperature")) && v->type == JSON_NUMBER) + c->temperature = v->u.number; + if ((v = json_object_get(root, "max_tokens")) && v->type == JSON_NUMBER) + c->max_tokens = (long)v->u.number; + if ((v = json_object_get(root, "thinking"))) { + if (load_thinking(c, v, path, errbuf, errlen) != 0) { + json_free(root); + return -1; + } + } + if ((s = json_get_string(root, "reasoning_effort")) && *s) { + if (!config_effort_valid(s)) { + snprintf(errbuf, errlen, "%s", + tf("cfg.err.bad_effort", "path", path, + "list", config_effort_list(), NULL)); + json_free(root); + return -1; + } + set_str(&c->reasoning_effort, s); + } + if ((v = json_object_get(root, "timeout")) && v->type == JSON_NUMBER) + c->timeout = (long)v->u.number; + if ((v = json_object_get(root, "stream"))) + c->stream = json_bool_value(v, c->stream); + if ((v = json_object_get(root, "max_steps")) && v->type == JSON_NUMBER) + c->max_steps = (int)v->u.number; + if ((v = json_object_get(root, "tools"))) + c->use_tools = json_bool_value(v, c->use_tools); + if ((v = json_object_get(root, "retries")) && v->type == JSON_NUMBER) + c->retries = (int)v->u.number; + if ((v = json_object_get(root, "confirm"))) + c->confirm = json_bool_value(v, c->confirm); + if ((v = json_object_get(root, "auto_save"))) + c->auto_save = json_bool_value(v, c->auto_save); + if ((v = json_object_get(root, "probe"))) + c->probe = json_bool_value(v, c->probe); + + json_free(root); + free(c->config_path); + c->config_path = xstrdup(path); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* 环境变量 */ +/* ------------------------------------------------------------------ */ + +static void env_str(char **dst, const char *name) +{ + const char *v = getenv(name); + if (v && *v) set_str(dst, v); +} + +void config_apply_env(LigentConfig *c) +{ + env_str(&c->base_url, "LIGENT_BASE_URL"); + env_str(&c->model, "LIGENT_MODEL"); + { + const char *v = getenv("LIGENT_SYSTEM"); + if (v && *v) set_str(&c->system, v); + } + + /* 厂商专属的环境变量只在"确实在用这家"时才读。 + * + * 否则会出这种事:配置文件里写的是 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(显式覆盖) > 配置文件 > 对应厂商的环境变量兜底 */ + const char *key = getenv("LIGENT_API_KEY"); + if (key && *key) { + set_str(&c->api_key, key); + c->api_key_env = "LIGENT_API_KEY"; + } else if (!c->api_key || !c->api_key[0]) { + const char *k = NULL; + const char *src = NULL; + if (use_deepseek) { k = getenv("DEEPSEEK_API_KEY"); src = "DEEPSEEK_API_KEY"; } + else if (use_openai) { k = getenv("OPENAI_API_KEY"); src = "OPENAI_API_KEY"; } + if (k && *k) { set_str(&c->api_key, k); c->api_key_env = src; } + } + + /* 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); + } + + const char *v; + if ((v = getenv("LIGENT_TEMPERATURE")) && *v) c->temperature = atof(v); + if ((v = getenv("LIGENT_MAX_TOKENS")) && *v) c->max_tokens = atol(v); + if ((v = getenv("LIGENT_TIMEOUT")) && *v) c->timeout = atol(v); + if ((v = getenv("LIGENT_MAX_STEPS")) && *v) c->max_steps = atoi(v); + if ((v = getenv("LIGENT_STREAM")) && *v) + c->stream = !(strcmp(v, "0") == 0 || strcmp(v, "false") == 0); + if ((v = getenv("LIGENT_TOOLS")) && *v) + c->use_tools = !(strcmp(v, "0") == 0 || strcmp(v, "false") == 0); + if ((v = getenv("LIGENT_RETRIES")) && *v) c->retries = atoi(v); + if ((v = getenv("LIGENT_CONFIRM")) && *v) + c->confirm = !(strcmp(v, "0") == 0 || strcmp(v, "false") == 0); + if ((v = getenv("LIGENT_AUTO_SAVE")) && *v) + 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; + } + if ((v = getenv("LIGENT_REASONING_EFFORT")) && *v) { + if (config_effort_valid(v)) set_str(&c->reasoning_effort, v); + } +} + +int config_init(LigentConfig *c, const char *explicit_path, const char *argv0, + char *errbuf, size_t errlen) +{ + config_defaults(c); + if (errbuf && errlen) errbuf[0] = '\0'; + + /* 记下二进制目录:配置和以后任何数据都只放这里 */ + free(c->config_dir); + c->config_dir = config_binary_dir(argv0); + + char *path = find_config_file(explicit_path, 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)); + if (errbuf && errlen) errbuf[0] = '\0'; + } + free(path); + } + + config_apply_env(c); + return 0; +} + +void config_free(LigentConfig *c) +{ + if (!c) return; + free(c->base_url); + free(c->api_key); + free(c->model); + free(c->system); + free(c->reasoning_effort); + free(c->config_dir); + free(c->config_path); + memset(c, 0, sizeof *c); +} + +/* ------------------------------------------------------------------ */ +/* 展示 */ +/* ------------------------------------------------------------------ */ + +static void mask_key(const char *key, char *out, size_t outlen) +{ + if (!key || !*key) { snprintf(out, outlen, "%s", t("cfg.val.unset")); return; } + size_t n = strlen(key); + if (n <= 8) { snprintf(out, outlen, "%s", t("cfg.val.empty_key")); return; } + char num[32]; + snprintf(num, sizeof num, "%zu", n); + snprintf(out, outlen, "%.4s...%.4s %s", key, key + n - 4, + tf("cfg.val.chars", "n", num, NULL)); +} + +/* 字段名按显示宽度补齐。中文标签一个字占 2 列,用 %-13s 会整体顶歪, + * 而英文标签没有这个问题 —— 所以宽度得算,不能写死空格(同 doctor 的 label)。 */ +static void dump_field(const char *lbl, const char *fmt, ...) +{ + size_t w = display_width(lbl, strlen(lbl)); + fputs(lbl, stdout); + while (w < 13) { putchar(' '); w++; } + fputs(": ", stdout); + va_list ap; + va_start(ap, fmt); + vprintf(fmt, ap); + va_end(ap); + putchar('\n'); +} + +void config_dump(const LigentConfig *c) +{ + char masked[128], num[32]; + mask_key(c->api_key, masked, sizeof masked); + + dump_field("base_url", "%s", c->base_url ? c->base_url : ""); + dump_field("model", "%s", c->model ? c->model : ""); + dump_field("api_key", "%s", masked); + + if (c->temperature < 0) { + dump_field("temperature", "%s", t("cfg.val.server_default")); + } else { + snprintf(num, sizeof num, "%.2f", c->temperature); + dump_field("temperature", "%s", num); + } + + if (c->max_tokens > 0) { + snprintf(num, sizeof num, "%ld", c->max_tokens); + dump_field("max_tokens", "%s", num); + } else { + dump_field("max_tokens", "%s", t("cfg.val.server_default")); + } + + dump_field("thinking", "%s", config_thinking_name(c->thinking)); + dump_field(t("cfg.dump.label.effort"), "%s", + (c->reasoning_effort && *c->reasoning_effort) + ? c->reasoning_effort : t("cfg.val.server_default")); + + snprintf(num, sizeof num, "%ld", c->timeout); + dump_field("timeout", "%s", tf("cfg.val.seconds", "n", num, NULL)); + dump_field("stream", "%s", t(c->stream ? "cfg.val.yes" : "cfg.val.no")); + if (c->max_steps > 0) { + snprintf(num, sizeof num, "%d", c->max_steps); + dump_field("max_steps", "%s", num); + } else { + dump_field("max_steps", "%s", t("cfg.val.unlimited")); + } + dump_field("tools", "%s", + t(c->use_tools ? "cfg.val.enabled" : "cfg.val.disabled")); + + snprintf(num, sizeof num, "%d", c->retries); + dump_field("retries", "%s", tf("cfg.val.times", "n", num, NULL)); + dump_field("confirm", "%s", + t(c->confirm ? "cfg.val.confirm_on" : "cfg.val.disabled")); + dump_field("auto_save", "%s", + t(c->auto_save ? "cfg.val.auto_save_on" : "cfg.val.disabled")); + dump_field("probe", "%s", + t(c->probe ? "cfg.val.probe_on" : "cfg.val.disabled")); + + snprintf(num, sizeof num, "%zu", http_log_size()); + dump_field(t("cfg.dump.label.log"), "%s", tf("cfg.val.log", "n", num, NULL)); + dump_field("system", "%s", + c->system ? c->system : t("cfg.val.builtin_system")); + dump_field("language", "%s", i18n_lang_name()); + + if (c->config_path) { + dump_field("config_file", "%s", c->config_path); + } else if (c->config_dir) { + char *p = xasprintf("%s/ligent.json", c->config_dir); + dump_field("config_file", "%s", + tf("cfg.dump.config_file_missing", "path", p, NULL)); + free(p); + } else { + dump_field("config_file", "%s", t("cfg.dump.config_file_none")); + } +} + +/* 密钥来源的可读标签(诊断用)。诊断代码不该去 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); + if (c->api_key_from_cli) return t("cfg.src.cli"); + if (c->api_key_from_file) return t("cfg.src.file"); + return t("cfg.val.unknown"); +} + diff --git a/src/config.h b/src/config.h new file mode 100644 index 0000000..0dfcf73 --- /dev/null +++ b/src/config.h @@ -0,0 +1,92 @@ +/* config.h - 配置:内置默认值 < 配置文件 < 环境变量 < 命令行 + * + * 唯一的配置文件位置是**二进制所在目录**下的 ligent.json: + * + * /path/to/ligent ← 可执行文件 + * /path/to/ligent.json ← 配置(不存在则用内置默认值) + * + * 不做用户级(~/.config、$XDG_CONFIG_HOME)也不做系统级(/etc)查找, + * 以便整个程序和它的数据可以自包含在一个目录里(U 盘 / 容器 / 拷贝即走)。 + */ +#ifndef LIGENT_CONFIG_H +#define LIGENT_CONFIG_H + +#include + +/* 思考模式开关。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 表示不是环境变量 */ + 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/空 = 不发送 */ + long timeout; /* 秒 */ + int stream; + 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 */ +} LigentConfig; + +void config_defaults(LigentConfig *c); + +/* 加载配置:先读配置文件,再用环境变量覆盖。返回 0 成功。 + * + * 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 内部与测试使用) */ +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 共用)---- */ + +/* 把 "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) */ +int config_effort_valid(const char *s); + +/* 白名单拼成的展示串,形如 "minimal/low/.../ultra"(静态缓冲,不用释放) */ +const char *config_effort_list(void); + +/* THINKING_* 的中文说明(静态字面量) */ +const char *config_thinking_name(int thinking); + +/* 二进制所在目录(malloc);失败返回 NULL。 + * 优先 /proc/self/exe,其次 argv0 里的路径,最后在 $PATH 里查 */ +char *config_binary_dir(const char *argv0); + +/* 配置文件的规范位置(malloc):<二进制目录>/ligent.json */ +char *config_default_path(const char *argv0); + +/* 打印当前生效配置(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 new file mode 100644 index 0000000..59624b3 --- /dev/null +++ b/src/confirm.c @@ -0,0 +1,168 @@ +/* confirm.c - 写操作确认 */ +#include "confirm.h" +#include "i18n.h" +#include "tools.h" /* TOOL_* 宏:工具名只在那里定义一次 */ +#include "util.h" + +#include +#include +#include +#include +#include +#include + +#define BOLD "\033[1m" +#define DIM "\033[2m" +#define YEL "\033[33m" +#define OFF "\033[0m" + +static int g_enabled = 1; +static ConfirmDescriber g_describer; +static int g_color; +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; } + size_t n = 0; + for (const char *p = s; *p && n + 1 < outlen; p++) { + if (*p == '\n' || *p == '\r' || *p == '\t') { + if (n + 1 < outlen) out[n++] = ' '; + continue; + } + out[n++] = *p; + if (n >= max) break; + } + out[n] = '\0'; + if (s[n] && n >= max) { + size_t l = strlen(out); + if (l + 4 < outlen) snprintf(out + l, outlen - l, "..."); + } +} + +/* line 集合的渲染在 tools.c 的 tool_lines_label —— 调用摘要(agent.c)用的是 + * 同一份,两处对"改的是哪几行"的说法必须一致。 */ + +/* 把这次调用做什么讲清楚 —— 只说工具名等于没说,用户没法判断 */ +static void describe(const char *name, const JsonValue *args) +{ + if (g_describer) { + char *text = g_describer(name, args); + if (text) { fprintf(stderr, "%s", text); free(text); return; } + } + char buf[256], num[32]; + const char *path = json_get_string(args, "path"); + + if (strcmp(name, TOOL_RUN_COMMAND) == 0) { + preview(json_get_string(args, "command"), 200, buf, sizeof buf); + fprintf(stderr, " %s %s%s%s\n", t("confirm.lbl.cmd"), c(BOLD), buf, c(OFF)); + } else if (strcmp(name, TOOL_WRITE_FILE) == 0) { + const char *content = json_get_string(args, "content"); + preview(path, 160, buf, sizeof buf); + snprintf(num, sizeof num, "%zu", content ? strlen(content) : 0); + fprintf(stderr, " %s %s%s%s %s\n", t("confirm.lbl.write"), c(BOLD), buf, c(OFF), + tf("confirm.note.overwrite", "bytes", num, NULL)); + } else if (strcmp(name, TOOL_EDIT_FILE) == 0) { + char lines[96]; + preview(path, 160, buf, sizeof buf); + tool_lines_label(args, lines, sizeof lines); + /* 行号必须打出来:用户要确认的第一件事就是"改的是哪几行"。 + * 没给 line 就是全文件替换(工具不拒绝它),摘要里直说"全文件"。 */ + const char *scope = lines[0] ? lines : t("tool.call.all_lines"); + if (json_get_bool(args, "replace_all", 0)) + 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 字符、压成一行,看不出 + * 到底动了几行、动的是哪几行,长一点的改动更是只剩两个省略号。 */ + tool_render_diff(stderr, args, " ", g_color); + } else if (path) { + preview(path, 160, buf, sizeof buf); + fprintf(stderr, " %s %s%s%s\n", t("confirm.lbl.path"), c(BOLD), buf, c(OFF)); + } +} + +void confirm_set_describer(ConfirmDescriber fn) +{ + g_describer = fn; +} + +void confirm_init(int enabled) +{ + g_enabled = enabled ? 1 : 0; + g_color = isatty(STDERR_FILENO) ? 1 : 0; +} + +void confirm_set_enabled(int on) { g_enabled = on ? 1 : 0; } +int confirm_enabled(void) { return g_enabled; } + +void confirm_reset_turn(void) { g_turn = CONFIRM_ASK; } +int confirm_turn_mode(void) { return g_turn; } + +int confirm_tool(const char *name, const JsonValue *args) +{ + if (!g_enabled) return 1; + + /* 本轮已经表过态就不再打扰。Always/neVer 只作用于这一轮: + * 下一条用户消息进来时 agent_run 会把策略复位成 ASK。 */ + if (g_turn == CONFIRM_ALWAYS) return 1; + if (g_turn == CONFIRM_NEVER) return 0; + /* 名字都没有就没什么可描述的,也没有什么"改动"值得拦 —— 交给工具层 + * 去回一句"未知工具"。strcmp(NULL, ...) 会直接崩。 */ + if (!name) return 1; + + /* 非交互输入:没有人能回答,按默认值(Y)放行。 + * 这也是"默认 Y"的自然推论 —— 问不到就等于不反对。 */ + if (!isatty(STDIN_FILENO)) return 1; + + /* 提权命令会在参数里明说 elevated,describe() 已经把它打出来了 —— + * 这里不需要额外的身份标注:没有常驻提权状态,一次命令一次身份。 */ + fprintf(stderr, "\n%s %s %s%s\n", c(YEL), t("confirm.lbl.about"), + name ? name : "?", c(OFF)); + describe(name, args); + + for (;;) { + fprintf(stderr, "%s", t("confirm.prompt")); + fflush(stderr); + + char line[128]; + errno = 0; + if (!fgets(line, sizeof line, stdin)) { + fputc('\n', stderr); + /* 这里必须区分两种"读不到": + * + * EINTR 用户按了 Ctrl-C。他是在说"别问了" —— 必须拒绝。 + * 真 EOF stdin 关了,没人能回答 —— 才按默认值放行。 + * + * 原来两者走同一个分支、都返回 1(允许),于是"在确认提示上按 + * Ctrl-C"变成了"批准这次操作"。run_command 侥幸自己查了中断 + * 标志才没执行,write_file / edit_file 没查,文件照写。 + * + * 返回 0 之后 g_ligent_interrupt 仍是 1,agent_run 会把这一轮 + * 收掉 —— 和 Ctrl-C 在别处的语义(中断当前请求)保持一致。 */ + if (errno == EINTR) return 0; + return 1; + } + char *ans = str_trim(line); + /* 回车 = 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 || + strcasecmp(ans, "always") == 0) { + g_turn = CONFIRM_ALWAYS; + fprintf(stderr, " %s%s%s\n", c(DIM), t("confirm.always"), c(OFF)); + return 1; + } + if (strcmp(ans, "v") == 0 || strcmp(ans, "V") == 0 || + strcasecmp(ans, "never") == 0) { + g_turn = CONFIRM_NEVER; + fprintf(stderr, " %s%s%s\n", c(DIM), t("confirm.never"), c(OFF)); + return 0; + } + fprintf(stderr, " %s%s%s\n", c(DIM), t("confirm.bad_input"), c(OFF)); + } +} diff --git a/src/confirm.h b/src/confirm.h new file mode 100644 index 0000000..56ad8c6 --- /dev/null +++ b/src/confirm.h @@ -0,0 +1,48 @@ +/* confirm.h - 写操作与命令执行的确认 + * + * 默认开启。ligent 最初是"全权执行"的设计,但模型一旦看错文件或理解偏了 + * 需求,后果是直接落到磁盘上的;加一道确认是目前唯一有效的止损点。 + * + * 只读工具(read_file / list_dir)不打扰。 + */ +#ifndef LIGENT_CONFIRM_H +#define LIGENT_CONFIRM_H + +#include "json.h" + +void confirm_init(int enabled); +void confirm_set_enabled(int on); +int confirm_enabled(void); + +/* 询问是否允许执行某个会改动东西的工具:显示将要做什么,然后问 (Y/n)。 + * 返回 1 允许,0 拒绝。回车即默认允许。 + * + * 拒绝时返回 0 而不是让整轮失败 —— 上层会把"用户拒绝了"作为工具结果 + * 回填给模型,让它换一种做法。 + * + * stdin 不是终端时(管道/重定向)没有人能回答,按默认值放行。 */ +/* 本轮的提问策略。作用域是"一轮"——一条用户消息引发的那串工具调用, + * 下一轮自动复位。会话级的开关是 /yolo,配置级的是 confirm。 */ +enum { + CONFIRM_ASK = 0, /* 每次都要问(默认) */ + CONFIRM_ALWAYS, /* 本轮剩下的改动一律放行 */ + CONFIRM_NEVER /* 本轮剩下的改动一律拒绝 */ +}; + +/* 复位成 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 表示回退到内置描述。 + * + * meta 的工具需要它:那些参数里带整段消息内容,内置描述既打不出来 + * 也不该打出来 —— 得总结成人话("压缩 2..6 轮,6 条 -> 1 条")。 */ +typedef char *(*ConfirmDescriber)(const char *name, const JsonValue *args); +void confirm_set_describer(ConfirmDescriber fn); + +#endif /* LIGENT_CONFIRM_H */ diff --git a/src/doctor.c b/src/doctor.c new file mode 100644 index 0000000..5aba1cb --- /dev/null +++ b/src/doctor.c @@ -0,0 +1,536 @@ +/* doctor.c - 环境体检 + * + * 这是把前几轮踩过的坑固化成检查项: + * 1. 配置文件解析失败却只给一句"语法错误"(现在带行列号) + * 2. shell 里残留的厂商环境变量顶掉配置文件里的密钥 + * 3. base_url 重定向导致 Authorization 头被丢掉,服务端回 401 + * 4. 密钥与服务商不匹配(把 DeepSeek 的 key 发给 OpenRouter) + * 这些问题的共同点是:报错信息指向的方向是错的,靠猜要花很久。 + */ +#include "doctor.h" +#include "version.h" +#include "http.h" +#include "i18n.h" +#include "json.h" +#include "llm.h" +#include "util.h" + +#include +#include +#include +#include +#include +#include +#include + +#define BOLD "\033[1m" +#define DIM "\033[2m" +#define RED "\033[31m" +#define YEL "\033[33m" +#define GRN "\033[32m" +#define OFF "\033[0m" + +#define LABEL_W 16 + +static int color_on; +static int problems; +static int warnings; + +static const char *c(const char *code) { return color_on ? code : ""; } + +static void section(const char *name) +{ + /* 括号也随语言走:英文里写【binary】很别扭 */ + printf("\n%s%s%s\n", c(BOLD), tf("fmt.section", "name", name, NULL), c(OFF)); +} + +/* 按"显示宽度"补齐:中文一个字占 3 字节但只有 2 列, + * 用 %-16s 会把中文标签那一列全部顶歪 */ +static void label(const char *s, size_t width) +{ + fputs(s, stdout); + size_t w = display_width(s, strlen(s)); + while (w < width) { putchar(' '); w++; } + putchar(' '); /* 标签刚好占满时也要有分隔 */ +} + +LIGENT_PRINTF(2, 3) static void field(const char *lbl, const char *fmt, ...) +{ + printf(" "); + label(lbl, LABEL_W); + va_list ap; + va_start(ap, fmt); + vprintf(fmt, ap); + va_end(ap); + putchar('\n'); +} + +/* 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 ? "[!]" : + level == 3 ? "[-]" : "[v]"; + const char *col = level == 2 ? RED : level == 1 ? YEL : + level == 3 ? DIM : GRN; + printf(" "); + label(lbl, LABEL_W); + printf("%s%s%s ", c(col), tag, c(OFF)); + va_list ap; + va_start(ap, fmt); + vprintf(fmt, ap); + va_end(ap); + putchar('\n'); + if (level == 2) problems++; + else if (level == 1) warnings++; +} + +/* 无标签的缩进说明行 */ +LIGENT_PRINTF(1, 2) static void sub(const char *fmt, ...) +{ + printf(" "); + va_list ap; + va_start(ap, fmt); + vprintf(fmt, ap); + va_end(ap); + putchar('\n'); +} + +/* ------------------------------------------------------------------ */ +/* 小工具 */ +/* ------------------------------------------------------------------ */ + +static char *host_of(const char *url) +{ + if (!url) return NULL; + const char *p = strstr(url, "://"); + p = p ? p + 3 : url; + const char *end = p; + while (*end && *end != '/' && *end != ':' && *end != '?') end++; + return xstrndup(p, (size_t)(end - p)); +} + +static void mask_key(const char *key, char *out, size_t n) +{ + char num[32]; + size_t len = key ? strlen(key) : 0; + snprintf(num, sizeof num, "%zu", len); + if (len == 0) snprintf(out, n, "%s", t("doctor.val.empty")); + else if (len <= 10) snprintf(out, n, "%.4s...%s", key, + tf("doctor.val.chars", "n", num, NULL)); + else snprintf(out, n, "%.8s...%s", key, + tf("doctor.val.chars", "n", num, NULL)); +} + +/* 从密钥前缀推断它属于哪家。返回 NULL 表示认不出来 */ +static const char *key_owner(const char *key) +{ + if (!key || !*key) return NULL; + if (strncmp(key, "sk-or-", 6) == 0) return "OpenRouter"; + if (strncmp(key, "sk-ant-", 7) == 0) return "Anthropic"; + if (strncmp(key, "sk-proj-", 8) == 0) return "OpenAI"; + if (strncmp(key, "gsk_", 4) == 0) return "Groq"; + if (strncmp(key, "AIza", 4) == 0) return "Google"; + if (strncmp(key, "sk-", 3) == 0) return t("doctor.val.owner.sk"); + return NULL; +} + +/* 从端点推断它属于哪家 */ +static const char *host_owner(const char *host) +{ + if (!host) return NULL; + if (strstr(host, "openrouter")) return "OpenRouter"; + if (strstr(host, "deepseek")) return "DeepSeek"; + if (strstr(host, "anthropic")) return "Anthropic"; + if (strstr(host, "openai.com")) return "OpenAI"; + if (strstr(host, "groq")) return "Groq"; + if (strstr(host, "googleapis")) return "Google"; + return NULL; +} + +/* ------------------------------------------------------------------ */ +/* 各部分检查 */ +/* ------------------------------------------------------------------ */ + +static void check_binary(const char *argv0) +{ + section(t("doctor.sec.binary")); + + /* 版本放最前面:报 bug 时第一件要问的就是"你用的是哪个版本"。 + * 带 git hash 才能在对方改过源码的情况下知道到底是哪份代码。 */ + field(t("doctor.lbl.version"), "%s", ligent_version_full()); + + char exe[PATH_MAX]; + ssize_t n = readlink("/proc/self/exe", exe, sizeof exe - 1); + if (n > 0) { exe[n] = '\0'; field(t("doctor.lbl.exe"), "%s", exe); } + else field(t("doctor.lbl.exe"), "%s", t("doctor.val.unreadable_exe")); + + if (argv0 && *argv0) { + field(t("doctor.lbl.argv0"), "%s", argv0); + if (n > 0) { + /* argv[0] 指向的文件与真实文件不同 => 是个软链接 */ + struct stat a, b; + if (stat(argv0, &a) == 0 && stat(exe, &b) == 0 && + (a.st_dev != b.st_dev || a.st_ino != b.st_ino)) { + marked(1, t("doctor.lbl.symlink"), "%s", + t("doctor.val.symlink_diff")); + } else { + marked(0, t("doctor.lbl.symlink"), "%s", t("doctor.val.none")); + } + } + } + + char *dir = config_binary_dir(argv0); + field(t("doctor.lbl.config_dir"), "%s", dir ? dir : t("doctor.val.unknown")); + free(dir); +} + +static void check_config_file(const LigentConfig *cfg, const char *argv0, + const char *config_error) +{ + section(t("doctor.sec.config")); + + char *canon = config_default_path(argv0); + field(t("doctor.lbl.canon_path"), "%s", canon ? canon : t("doctor.val.unknown")); + + if (config_error && *config_error) { + marked(2, t("doctor.lbl.status"), "%s", + tf("doctor.val.load_failed", "error", config_error, NULL)); + sub("%s", t("doctor.sub.all_ignored")); + free(canon); + return; + } + + if (cfg->config_path) { + marked(0, t("doctor.lbl.status"), "%s", t("doctor.val.loaded")); + } else if (canon && access(canon, R_OK) == 0) { + /* 文件在、但没加载成功 —— 把具体原因再解析一遍给出来 */ + size_t len = 0; + char *text = read_entire_file(canon, &len); + char jerr[256] = {0}; + JsonValue *v = text ? json_parse_ui(text, jerr, sizeof jerr) : NULL; + if (!v) + marked(2, t("doctor.lbl.status"), "%s", + tf("doctor.val.exists_unparsable", "error", jerr, NULL)); + else + marked(2, t("doctor.lbl.status"), "%s", + t("doctor.val.exists_unloadable")); + json_free(v); + free(text); + } else { + marked(1, t("doctor.lbl.status"), "%s", t("doctor.val.missing")); + } + + if (cfg->config_path) { + JsonValue *v = NULL; + size_t len = 0; + char *text = read_entire_file(cfg->config_path, &len); + if (text) v = json_parse(text, NULL, 0); + if (v) { + /* 这份清单必须与 config.c 的 config_load_file 认识的键一一对应: + * 它是"配置文件里到底设了哪些键"的唯一依据,多写一个不存在的键、 + * 或者漏掉一个真键,这一行说的就是假话。(以前这里写的是 "log", + * 而配置文件根本没有这个键,同时真有的 "probe" 没有列出来。) */ + static const char *keys[] = { + "base_url", "api_key", "model", "system", "temperature", + "max_tokens", "thinking", "reasoning_effort", + "timeout", "stream", "max_steps", "tools", + "retries", "confirm", "auto_save", "probe" + }; + StrBuf sb; + sb_init(&sb); + for (size_t i = 0; i < sizeof keys / sizeof keys[0]; i++) + if (json_object_get(v, keys[i])) { + if (sb.len) sb_puts(&sb, ", "); + sb_puts(&sb, keys[i]); + } + field(t("doctor.lbl.set_keys"), "%s", + sb.len ? sb.data : t("doctor.val.none_set")); + sb_free(&sb); + } + json_free(v); + free(text); + } + free(canon); +} + +static void check_key(const LigentConfig *cfg) +{ + section(t("doctor.sec.key")); + + size_t len = cfg->api_key ? strlen(cfg->api_key) : 0; + if (len == 0) { + marked(2, t("doctor.lbl.status"), "%s", t("doctor.val.not_configured")); + field(t("doctor.lbl.how_to_set"), "%s", t("doctor.val.how_to_set_key")); + return; + } + + char masked[128]; + mask_key(cfg->api_key, masked, sizeof masked); + field(t("doctor.lbl.source"), "%s", config_api_key_source(cfg)); + field(t("doctor.lbl.value"), "%s", masked); + + const char *owner = key_owner(cfg->api_key); + field(t("doctor.lbl.format_guess"), "%s", + owner ? owner : t("doctor.val.prefix_unclear")); + + char *host = host_of(cfg->base_url); + const char *expect = host_owner(host); + + if (expect && owner) { + int match = 1; + if (strcmp(expect, "OpenRouter") == 0) + match = (strncmp(cfg->api_key, "sk-or-", 6) == 0); + else if (strcmp(expect, "DeepSeek") == 0) + match = (strncmp(cfg->api_key, "sk-", 3) == 0 && + strncmp(cfg->api_key, "sk-or-", 6) != 0); + else if (strcmp(expect, "Anthropic") == 0) + match = (strncmp(cfg->api_key, "sk-ant-", 7) == 0); + else if (strcmp(expect, "OpenAI") == 0) + match = (strncmp(cfg->api_key, "sk-", 3) == 0 && + strncmp(cfg->api_key, "sk-or-", 6) != 0); + else if (strcmp(expect, "Groq") == 0) + match = (strncmp(cfg->api_key, "gsk_", 4) == 0); + + if (match) + marked(0, t("doctor.lbl.match"), "%s", + tf("doctor.val.match_ok", "owner", expect, NULL)); + else + marked(2, t("doctor.lbl.match"), "%s", + tf("doctor.val.match_bad", "owner", expect, "guess", owner, NULL)); + } else if (expect) { + marked(1, t("doctor.lbl.match"), "%s", + tf("doctor.val.match_unknown", "owner", expect, NULL)); + } else { + field(t("doctor.lbl.match"), "%s", t("doctor.val.match_skipped")); + } + free(host); +} + +static void check_endpoint(const LigentConfig *cfg) +{ + section(t("doctor.sec.endpoint")); + + field("base_url", "%s", + cfg->base_url ? cfg->base_url : t("doctor.val.base_url_unset")); + field(t("doctor.lbl.model"), "%s", + cfg->model ? cfg->model : t("doctor.val.base_url_unset")); + + char *url = llm_endpoint_url(cfg->base_url); + field(t("doctor.lbl.url"), "%s", url); + + if (cfg->base_url && strncmp(cfg->base_url, "http://", 7) == 0) { + int local = strstr(cfg->base_url, "127.0.0.1") || + strstr(cfg->base_url, "localhost") || + strstr(cfg->base_url, "[::1]"); + if (local) marked(0, t("doctor.lbl.transport"), "%s", t("doctor.val.plain_local")); + else marked(1, t("doctor.lbl.transport"), "%s", t("doctor.val.plain_remote")); + } else if (cfg->base_url && strncmp(cfg->base_url, "https://", 8) == 0) { + marked(0, t("doctor.lbl.transport"), "%s", t("doctor.val.https")); + } else { + marked(2, t("doctor.lbl.transport"), "%s", t("doctor.val.bad_scheme")); + } + + size_t n = cfg->base_url ? strlen(cfg->base_url) : 0; + if (n > 0 && cfg->base_url[n - 1] == '/') + marked(1, t("doctor.lbl.trailing_slash"), "%s", + t("doctor.val.trailing_slash_yes")); + else + marked(0, t("doctor.lbl.trailing_slash"), "%s", t("doctor.val.none")); + + free(url); +} + +static void check_env(const LigentConfig *cfg) +{ + section(t("doctor.sec.env")); + + static const struct { const char *name; const char *note_key; } vars[] = { + { "LIGENT_API_KEY", "doctor.env.note.api_key" }, + { "DEEPSEEK_API_KEY", "doctor.env.note.deepseek" }, + { "OPENAI_API_KEY", "doctor.env.note.openai" }, + { "LIGENT_BASE_URL", "doctor.env.note.base_url" }, + { "LIGENT_MODEL", "doctor.env.note.model" }, + { "LIGENT_CONFIG", "doctor.env.note.config" }, + { "LIGENT_LANG", "doctor.env.note.lang" }, + { "HTTPS_PROXY", "doctor.env.note.https_proxy" }, + { "HTTP_PROXY", "doctor.env.note.http_proxy" }, + { "ALL_PROXY", "doctor.env.note.all_proxy" }, + { "NO_PROXY", "doctor.env.note.no_proxy" }, + }; + + for (size_t i = 0; i < sizeof vars / sizeof vars[0]; i++) { + const char *v = getenv(vars[i].name); + if (v && *v) { + char masked[64]; + if (strstr(vars[i].name, "KEY")) mask_key(v, masked, sizeof masked); + else snprintf(masked, sizeof masked, "%s", v); + marked(0, vars[i].name, "%s", + tf("doctor.val.env_set", "value", masked, NULL)); + } else { + marked(3, vars[i].name, "%s", t("doctor.val.env_unset")); + } + } + + /* 最坑的一种:厂商变量设了,但这次用的是配置文件里的密钥 */ + const char *ds = getenv("DEEPSEEK_API_KEY"); + const char *ok = getenv("OPENAI_API_KEY"); + int used_from_env = cfg->api_key_env != NULL; + if (!used_from_env && ((ds && *ds) || (ok && *ok))) { + const char *which = (ds && *ds) ? "DEEPSEEK_API_KEY" : "OPENAI_API_KEY"; + marked(1, t("doctor.lbl.note"), "%s", + tf("doctor.val.env_ignored", "var", which, + "source", config_api_key_source(cfg), NULL)); + } +} + +static void check_params(const LigentConfig *cfg) +{ + char num[32]; + section(t("doctor.sec.params")); + + field("temperature", "%s", cfg->temperature < 0 + ? t("doctor.val.server_default") : (snprintf(num, sizeof num, "%.2f", + cfg->temperature), num)); + if (cfg->temperature > 2.0) + sub("%s", t("doctor.sub.temp_too_high")); + + if (cfg->max_tokens > 0) { + field("max_tokens", "%ld", cfg->max_tokens); + if (cfg->max_tokens > 200000) + sub("%s", t("doctor.sub.max_tokens_huge")); + } else { + field("max_tokens", "%s", t("doctor.val.server_default")); + } + + snprintf(num, sizeof num, "%ld", cfg->timeout); + field("timeout", "%s", tf("doctor.val.timeout", "n", num, NULL)); + field("thinking", "%s", config_thinking_name(cfg->thinking)); + field(t("doctor.lbl.effort"), "%s", (cfg->reasoning_effort && *cfg->reasoning_effort) + ? cfg->reasoning_effort : t("doctor.val.server_default")); + if (cfg->thinking == THINKING_ON && cfg->temperature >= 0) + sub("%s", t("doctor.sub.thinking_temp")); + + snprintf(num, sizeof num, "%d", cfg->retries); + field("retries", "%s", tf("doctor.val.retries", "n", num, NULL)); + /* 往返次数看不到就会让人以为它还卡在某个默认值上(默认 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)); + } else { + field("max_steps", "%s", t("cfg.val.unlimited")); + } + field("tools", "%s", t(cfg->use_tools ? "cfg.val.enabled" : "cfg.val.disabled")); + field("stream", "%s", t(cfg->stream ? "cfg.val.enabled" : "cfg.val.disabled")); + + snprintf(num, sizeof num, "%zu", http_log_size()); + field(t("doctor.lbl.log"), "%s", tf("doctor.val.log", "n", num, NULL)); +} + +static void check_network(const LigentConfig *cfg, int verbose) +{ + char num[32]; + section(t("doctor.sec.net")); + + const char *proxy = getenv("HTTPS_PROXY"); + if (!proxy) proxy = getenv("https_proxy"); + if (!proxy) proxy = getenv("ALL_PROXY"); + if (!proxy) proxy = getenv("all_proxy"); + /* 一定要过 "%s":代理 URL 里带 % 是常事(密码 %40、路径 %2F), + * 直接当格式串会读到不存在的参数。 */ + field(t("doctor.lbl.proxy"), "%s", + proxy && *proxy ? proxy : t("doctor.val.proxy_unset")); + + char *url = llm_endpoint_url(cfg->base_url); + long status = 0; + char *location = NULL; + char err[512]; + + int have_key = cfg->api_key && *cfg->api_key; + int rc = http_probe(url, have_key ? cfg->api_key : NULL, + cfg->timeout > 0 ? cfg->timeout : 300, verbose, + &status, &location, err, sizeof err); + + if (rc != 0) { + marked(2, t("doctor.lbl.connectivity"), "%s", + tf("doctor.val.connect_failed", "error", err, NULL)); + field(t("doctor.lbl.tip"), "%s", t("doctor.val.net_tip")); + free(url); + free(location); + return; + } + + marked(0, t("doctor.lbl.connectivity"), "%s", t("doctor.val.connect_ok")); + field(t("doctor.lbl.http_status"), "%ld", status); + + if (status >= 300 && status < 400) { + marked(2, t("doctor.lbl.redirect"), "%s", + tf("doctor.val.redirect_to", "location", + location ? location : t("doctor.val.location_missing"), NULL)); + sub("%s", t("doctor.sub.redirect_1")); + sub("%s", t("doctor.sub.redirect_2")); + sub("%s", t("doctor.sub.redirect_3")); + } else if (status == 401 || status == 403) { + /* 这两种 401 含义完全不同,绝不能混着说 —— 把"你没配密钥"说成 + * "你的密钥被拒绝",会让人去换一把本来没问题的钥匙。 */ + snprintf(num, sizeof num, "%ld", status); + if (have_key) { + marked(2, t("doctor.lbl.auth"), "%s", + tf("doctor.val.auth_rejected", "status", num, NULL)); + sub("%s", t("doctor.sub.auth_redirect")); + } else { + marked(2, t("doctor.lbl.auth"), "%s", + tf("doctor.val.auth_missing", "status", num, NULL)); + sub("%s", t("doctor.sub.auth_not_network")); + } + } else if (status == 404) { + /* 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 的端点,正常现象。) */ + 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")); + } + + free(url); + free(location); +} + +/* ------------------------------------------------------------------ */ + +int doctor_run(const LigentConfig *cfg, const char *argv0, int do_network, + const char *config_error) +{ + color_on = isatty(STDOUT_FILENO) ? 1 : 0; + + printf("%s%s%s\n", c(BOLD), t("doctor.title"), c(OFF)); + + check_binary(argv0); + check_config_file(cfg, argv0, config_error); + check_key(cfg); + check_endpoint(cfg); + check_env(cfg); + check_params(cfg); + if (do_network) check_network(cfg, 0); + else printf("\n%s%s%s\n", c(DIM), t("doctor.sec.net_skipped"), c(OFF)); + + printf("\n%s", c(BOLD)); + if (problems == 0 && warnings == 0) { + printf("%s%s\n", t("doctor.conclusion.ok"), c(OFF)); + } else { + char pb[16], wb[16]; + snprintf(pb, sizeof pb, "%d", problems); + snprintf(wb, sizeof wb, "%d", warnings); + printf("%s%s\n", tf("doctor.conclusion.bad", "problems", pb, + "warnings", wb, NULL), c(OFF)); + } + if (problems > 0) + printf("%s\n", t("doctor.conclusion.next")); + + return problems; +} diff --git a/src/doctor.h b/src/doctor.h new file mode 100644 index 0000000..6403845 --- /dev/null +++ b/src/doctor.h @@ -0,0 +1,17 @@ +/* doctor.h - 环境体检 */ +#ifndef LIGENT_DOCTOR_H +#define LIGENT_DOCTOR_H + +#include "config.h" + +/* 把整条链路逐环检查一遍:二进制定位、配置文件、密钥来源与格式、 + * 端点、环境变量干扰、参数合理性、网络连通性与重定向。 + * + * 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); + +#endif /* LIGENT_DOCTOR_H */ diff --git a/src/http.c b/src/http.c new file mode 100644 index 0000000..3440252 --- /dev/null +++ b/src/http.c @@ -0,0 +1,729 @@ +/* http.c - libcurl 封装:同步 POST、流式 POST、瞬态失败重试退避 */ +#include "http.h" +#include "i18n.h" +#include "util.h" +#include "spinner.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +volatile sig_atomic_t g_ligent_interrupt = 0; + +/* 调用方没传 errbuf 时用的安全落点 */ +static char errsink[1]; + +/* ------------------------------------------------------------------ */ +/* 收发记录:进程内滚动缓存 */ +/* ------------------------------------------------------------------ */ + +/* 上限。一场长会话可能产生很多流量,不能无限涨; + * 超出后丢掉较早的部分,保留最近的(排障时通常更关心刚刚发生了什么) */ +#define LOG_MAX (16u * 1024 * 1024) + +static StrBuf g_log; +static int g_log_init; +static int g_log_truncated; + +static void log_append(const void *data, size_t n) +{ + if (!data || n == 0) return; + if (!g_log_init) { sb_init(&g_log); g_log_init = 1; } + + const char *p = data; + if (n > LOG_MAX) { /* 单块就超限:只留尾部 */ + p += n - LOG_MAX; + n = LOG_MAX; + g_log_truncated = 1; + } + if (g_log.len + n > LOG_MAX) { /* 滚动丢弃较早的部分 */ + size_t drop = g_log.len + n - LOG_MAX; + memmove(g_log.data, g_log.data + drop, g_log.len - drop); + g_log.len -= drop; + g_log.data[g_log.len] = '\0'; + g_log_truncated = 1; + } + sb_append(&g_log, p, n); +} + +/* 追加一个 C 字符串。不要手写长度 —— 上面那行中文标签就是因为 + * 手数成 20 字节(实际 22)而被吃掉了两个字符。 */ +static void log_line(const char *s) +{ + if (s) log_append(s, strlen(s)); +} + +LIGENT_PRINTF(1, 2) static void log_printf(const char *fmt, ...) +{ + char buf[512]; + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(buf, sizeof buf, fmt, ap); + va_end(ap); + if (n > 0) log_append(buf, n < (int)sizeof buf ? (size_t)n : sizeof buf - 1); +} + +static void log_stamp(void) +{ + time_t t = time(NULL); + struct tm tm; + localtime_r(&t, &tm); + char buf[32]; + strftime(buf, sizeof buf, "%Y-%m-%d %H:%M:%S", &tm); + log_printf("\n===== %s =====\n", buf); +} + +size_t http_log_size(void) { return g_log_init ? g_log.len : 0; } +int http_log_truncated(void) { return g_log_truncated; } + +void http_log_clear(void) +{ + if (g_log_init) sb_clear(&g_log); + g_log_truncated = 0; +} + +void http_log_free(void) +{ + if (g_log_init) sb_free(&g_log); + g_log_init = 0; + g_log_truncated = 0; +} + +int http_log_save(const char *path, size_t *out_bytes, + char *errbuf, size_t errlen) +{ + /* 和其它 HTTP 入口一个规矩:没有落点也不能空手写字 */ + if (!errbuf || !errlen) { errbuf = errsink; errlen = 1; } + if (out_bytes) *out_bytes = 0; + FILE *f = fopen(path, "a"); + if (!f) { + snprintf(errbuf, errlen, "%s", + tf("http.err.log_open", "path", path, + "error", strerror(errno), NULL)); + return -1; + } + if (g_log_init && g_log.len > 0) { + time_t t = time(NULL); + struct tm tm; + localtime_r(&t, &tm); + char when[32]; + strftime(when, sizeof when, "%Y-%m-%d %H:%M:%S", &tm); + + fprintf(f, "%s", tf("http.log.header", "time", when, + "n", i18n_num((long long)g_log.len), NULL)); + /* 这里以前是 fputs —— 不处理格式,于是文件里真的会出现字面量 "%u" */ + if (g_log_truncated) + fprintf(f, "%s", tf("http.log.truncated", "limit", + i18n_num((long long)LOG_MAX), NULL)); + fwrite(g_log.data, 1, g_log.len, f); + if (g_log.data[g_log.len - 1] != '\n') fputc('\n', f); + if (out_bytes) *out_bytes = g_log.len; + } + int bad = (fclose(f) != 0); + if (bad) { + snprintf(errbuf, errlen, "%s", + tf("http.err.log_write", "path", path, NULL)); + return -1; + } + return 0; +} + +int http_global_init(void) { return curl_global_init(CURL_GLOBAL_DEFAULT); } +void http_global_cleanup(void) { curl_global_cleanup(); } + +/* ------------------------------------------------------------------ */ +/* 重试判定 */ +/* ------------------------------------------------------------------ */ + +int http_status_retryable(long status) +{ + switch (status) { + case 408: /* Request Timeout */ + case 409: /* Conflict(部分网关用它表示临时冲突) */ + case 429: /* Too Many Requests */ + case 500: + case 502: + case 503: + case 504: + return 1; + default: + return 0; + } +} + +/* 注意这里刻意不含 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_SEND_ERROR: + case CURLE_RECV_ERROR: + case CURLE_GOT_NOTHING: + case CURLE_PARTIAL_FILE: + return 1; + default: + return 0; + } +} + +/* 可被 Ctrl-C 打断的睡眠 */ +static void sleep_ms_interruptible(long ms) +{ + while (ms > 0 && !g_ligent_interrupt) { + long step = ms > 100 ? 100 : ms; + struct timespec ts; + ts.tv_sec = step / 1000; + ts.tv_nsec = (step % 1000) * 1000000L; + nanosleep(&ts, NULL); + ms -= step; + } +} + +/* 第 attempt 次失败后的退避时长(attempt 从 1 开始) */ +static long backoff_ms(const HttpRetry *r, int attempt, long retry_after_s) +{ + if (retry_after_s > 0) { + long ms = retry_after_s * 1000; + return ms > r->max_delay_ms ? r->max_delay_ms : ms; + } + 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; +} + +/* ------------------------------------------------------------------ */ +/* 请求上下文 */ +/* ------------------------------------------------------------------ */ + +typedef struct { + StrBuf body; /* 非流式成功响应体 */ + StrBuf errbody; /* 错误响应体 */ + HttpSink sink; /* 流式回调;NULL 表示非流式 */ + void *ud; + long status; + long retry_after; /* 服务端给的 Retry-After(秒) */ + size_t delivered; /* 已交给 sink 的字节数 */ + int overflow; /* 响应体超过上限 */ + curl_off_t content_length; /* 服务端声明的长度,<=0 表示未知 */ + char *location; /* 3xx 重定向的目标 */ + int log_hdr_started; /* 响应头只写一次分隔标记 */ + int probe; /* 1 = GET 探测,不发请求体 */ +} HttpCtx; + +static void ctx_init(HttpCtx *c, HttpSink sink, void *ud) +{ + memset(c, 0, sizeof *c); + sb_init(&c->body); + sb_init(&c->errbody); + c->sink = sink; + c->ud = ud; +} + +static void ctx_free(HttpCtx *c) +{ + sb_free(&c->body); + sb_free(&c->errbody); + free(c->location); + c->location = NULL; +} + +/* 必须按 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; + size_t n = size * nitems; + if (!c->log_hdr_started) { + log_line("----- 响应头 -----\n"); + c->log_hdr_started = 1; + } + log_append(buffer, n); + 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; /* 新响应开始,重置 */ + } 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) { + c->content_length = (curl_off_t)strtoll(buffer + 15, NULL, 10); + } else if (n > 9 && strncasecmp(buffer, "Location:", 9) == 0) { + const char *v = buffer + 9; + size_t vl = n - 9; + while (vl && (*v == ' ' || *v == '\t')) { v++; vl--; } + while (vl && (v[vl - 1] == '\r' || v[vl - 1] == '\n')) vl--; + free(c->location); + c->location = xstrndup(v, vl); + } + return n; +} + +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); /* 原样记下,便于之后回放 */ + if (g_ligent_interrupt) return 0; + + /* 一眼就能看出太大时,连读都不读,直接中止。 + * 这是流式路径唯一的整体大小防线 —— 流式没有"最终 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); + } + return n; + } + + if (c->sink) { + size_t r = c->sink(ptr, size, nmemb, c->ud); + if (r == n) c->delivered += n; + return r; + } + + /* 非流式:带上限累积,超了就中止传输 */ + if (c->body.len + n > HTTP_MAX_BODY) { + c->overflow = 1; + return 0; + } + sb_append(&c->body, ptr, n); + return n; +} + +void http_write_redacted(FILE *out, const char *data, size_t size, + const char *secret) +{ + size_t slen = secret ? strlen(secret) : 0; + if (slen == 0) { /* 没有密钥就没什么可隐去的 */ + fwrite(data, 1, size, out); + return; + } + for (size_t i = 0; i < size; ) { + if (i + slen <= size && memcmp(data + i, secret, slen) == 0) { + fputs("<已隐藏>", out); + i += slen; + continue; + } + fputc(data[i], out); + i++; + } +} + +/* 自己接管 libcurl 的调试输出,只为把密钥隐去。 + * 装了 DEBUGFUNCTION 就得自己打印,所以这里复刻 libcurl 的默认格式。 + * + * 早期版本只处理 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) +{ + (void)handle; + const char *secret = ud; + const char *prefix; + switch (type) { + case CURLINFO_TEXT: prefix = "== Info: "; break; + case CURLINFO_HEADER_OUT: prefix = "=> Send header: "; break; + case CURLINFO_HEADER_IN: prefix = "<= Recv header: "; break; + case CURLINFO_DATA_OUT: prefix = "=> Send data: "; break; + case CURLINFO_DATA_IN: prefix = "<= Recv data: "; break; + default: return 0; /* SSL 原始数据不打 */ + } + fputs(prefix, stderr); + http_write_redacted(stderr, data, size, secret); + if (size && data[size - 1] != '\n') fputc('\n', stderr); + return 0; +} + +static int xferinfo_cb(void *ud, curl_off_t a, curl_off_t b, + curl_off_t c, curl_off_t d) +{ + (void)ud; (void)a; (void)b; (void)c; (void)d; + return g_ligent_interrupt ? 1 : 0; +} + +static struct curl_slist *make_headers(const char *api_key) +{ + struct curl_slist *h = NULL; + h = curl_slist_append(h, "Content-Type: application/json"); + h = curl_slist_append(h, "Accept: text/event-stream, application/json"); + if (api_key && *api_key) { + char *auth = xasprintf("Authorization: Bearer %s", api_key); + h = curl_slist_append(h, auth); + free(auth); + } + return h; +} + +/* ------------------------------------------------------------------ */ +/* 单次尝试 */ +/* ------------------------------------------------------------------ */ + +/* 返回 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,而调用方 + * 随后会拿 cerr 去判断 `*cerr ? cerr : curl_easy_strerror(rc)` —— 原先 + * 这行在两百行之后、日志区段下面,于是那条提前返回的路径会把一个未初始化 + * 的栈数组当字符串读。(gcc -fanalyzer 抓到的,测试跑不出来:curl_easy_init + * 几乎不会失败。) */ + if (cerr && cerrlen) cerr[0] = '\0'; + + CURL *curl = curl_easy_init(); + if (!curl) return CURLE_FAILED_INIT; + + struct curl_slist *headers = make_headers(api_key); + + /* 长度先算好:POSTFIELDSIZE 要用,而 body 允许为 NULL(GET 探测)。 */ + size_t blen = body ? strlen(body) : 0; + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + if (ctx->probe) { + curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L); + } else { + /* 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。 */ + 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); + curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 120L); + if (verbose) { + curl_easy_setopt(curl, CURLOPT_VERBOSE, 1L); + curl_easy_setopt(curl, CURLOPT_DEBUGFUNCTION, debug_cb); + curl_easy_setopt(curl, CURLOPT_DEBUGDATA, (void *)api_key); + } + + log_stamp(); + log_printf("请求: %s %s\n", ctx->probe ? "GET" : "POST", url); + log_printf("----- 请求体 (%zu 字节) -----\n", blen); + if (blen) log_append(body, blen); + log_line("\n"); + + curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, cerr); + + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, ctx); + curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, header_cb); + curl_easy_setopt(curl, CURLOPT_HEADERDATA, ctx); + curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, xferinfo_cb); + curl_easy_setopt(curl, CURLOPT_XFERINFODATA, NULL); + curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); + + CURLcode rc = curl_easy_perform(curl); + long status = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status); + ctx->status = status; + + log_printf("\n----- 结束: HTTP %ld%s%s -----\n", status, + rc == CURLE_OK ? "" : " / ", + rc == CURLE_OK ? "" : curl_easy_strerror(rc)); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + return rc; +} + +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) +{ + if (!errbuf || !errlen) { errbuf = errsink; errlen = 1; } + errbuf[0] = '\0'; + if (out_status) *out_status = 0; + if (out_location) *out_location = NULL; + + HttpCtx ctx; + ctx_init(&ctx, NULL, NULL); + ctx.probe = 1; + + char cerr[CURL_ERROR_SIZE]; + /* 把密钥带上:探测的目的之一就是验证"这把密钥能不能过", + * 不带密钥去问,401 只说明端点要认证,说明不了密钥好不好。 */ + CURLcode rc = attempt(url, api_key, NULL, timeout_s, verbose, &ctx, + cerr, sizeof cerr); + + if (out_status) *out_status = ctx.status; + if (out_location) *out_location = ctx.location ? xstrdup(ctx.location) : NULL; + + int ret = 0; + if (rc != CURLE_OK) { + snprintf(errbuf, errlen, "%s", + *cerr ? cerr : curl_easy_strerror(rc)); + ret = -1; + } + ctx_free(&ctx); + return ret; +} + +/* ------------------------------------------------------------------ */ +/* 重试循环(两者共用) */ +/* ------------------------------------------------------------------ */ + +/* 返回 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) +{ + if (g_ligent_interrupt) { + snprintf(errbuf, errlen, "%s", t("http.err.interrupted")); + return -1; + } + + /* 重定向单独处理:这几乎总是 base_url 写错了 */ + if (rc == CURLE_OK && ctx->status >= 300 && ctx->status < 400) { + snprintf(errbuf, errlen, "%s", + tf("http.err.redirect", "status", i18n_num(ctx->status), + "location", ctx->location ? ctx->location + : t("http.val.no_location"), + NULL)); + return -1; + } + + if (ctx->overflow) { + snprintf(errbuf, errlen, "%s", + tf("http.err.body_too_big", "limit", + i18n_num((long long)HTTP_MAX_BODY), NULL)); + return -1; + } + + int retryable = 0; + if (rc != CURLE_OK) retryable = curl_code_retryable(rc); + else if (ctx->status < 200 || ctx->status >= 300) + retryable = http_status_retryable(ctx->status); + + if (!retryable) { + if (rc != CURLE_OK) { + snprintf(errbuf, errlen, "%s", + tf("http.err.network", "error", + *cerr ? cerr : curl_easy_strerror(rc), NULL)); + } else { + snprintf(errbuf, errlen, "HTTP %ld", ctx->status); + } + return -1; + } + + if (attempt > rt->max_retries) { + if (rc != CURLE_OK) + snprintf(errbuf, errlen, "%s", + tf("http.err.network_retried", "error", + *cerr ? cerr : curl_easy_strerror(rc), + "n", i18n_num(attempt - 1), NULL)); + else + snprintf(errbuf, errlen, "%s", + tf("http.err.status_retried", "status", + i18n_num(ctx->status), + "n", i18n_num(attempt - 1), NULL)); + return -1; + } + return 0; +} + +static void normalize_retry(const HttpRetry *in, HttpRetry *out) +{ + if (in) { *out = *in; return; } + HttpRetry def = HTTP_RETRY_DEFAULT; + *out = def; + if (out->base_delay_ms <= 0) out->base_delay_ms = 500; + if (out->max_delay_ms <= 0) out->max_delay_ms = 8000; +} + +/* ------------------------------------------------------------------ */ +/* 同步请求(POST / GET 共用同一套重试与错误处理) */ +/* ------------------------------------------------------------------ */ + +/* 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, + char **out_body, long *out_status, int *out_attempts, + char *errbuf, size_t errlen) +{ + if (!errbuf || !errlen) { errbuf = errsink; errlen = 1; } + errbuf[0] = '\0'; + if (out_body) *out_body = NULL; + if (out_status) *out_status = 0; + if (out_attempts) *out_attempts = 0; + + HttpRetry rt; + normalize_retry(retry, &rt); + + char cerr[CURL_ERROR_SIZE]; + int attempt_no = 0; + + for (;;) { + HttpCtx ctx; + ctx_init(&ctx, NULL, NULL); + if (is_get) ctx.probe = 1; + attempt_no++; + + CURLcode rc = attempt(url, api_key, body, timeout_s, verbose, + &ctx, cerr, sizeof cerr); + + if (out_status) *out_status = ctx.status; + + if (rc == CURLE_OK && ctx.status >= 200 && ctx.status < 300) { + if (out_body) *out_body = sb_detach(&ctx.body); + if (out_attempts) *out_attempts = attempt_no; + ctx_free(&ctx); + return 0; + } + + 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); + return -1; + } + + long delay = backoff_ms(&rt, attempt_no, ctx.retry_after); + ctx_free(&ctx); + spinner_notice("%s", + tf("http.retry.notice", "delay", i18n_num(delay), + "n", i18n_num(attempt_no), NULL)); + sleep_ms_interruptible(delay); + } +} + +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) +{ + return http_request(0, url, api_key, body, timeout_s, verbose, retry, + out_body, out_status, out_attempts, errbuf, errlen); +} + +/* 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, + char *errbuf, size_t errlen) +{ + return http_request(1, url, api_key, NULL, timeout_s, verbose, retry, + out_body, out_status, out_attempts, errbuf, errlen); +} + +/* ------------------------------------------------------------------ */ +/* 流式 */ +/* ------------------------------------------------------------------ */ + +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, + char **out_errbody, long *out_status, int *out_attempts, + size_t *out_delivered, + char *errbuf, size_t errlen) +{ + if (!errbuf || !errlen) { errbuf = errsink; errlen = 1; } + errbuf[0] = '\0'; + if (out_errbody) *out_errbody = NULL; + if (out_status) *out_status = 0; + if (out_attempts) *out_attempts = 0; + if (out_delivered) *out_delivered = 0; + + HttpRetry rt; + normalize_retry(retry, &rt); + + char cerr[CURL_ERROR_SIZE]; + int attempt_no = 0; + size_t total_delivered = 0; + + for (;;) { + HttpCtx ctx; + ctx_init(&ctx, sink, userdata); + attempt_no++; + + CURLcode rc = attempt(url, api_key, body, timeout_s, verbose, + &ctx, cerr, sizeof cerr); + + if (out_status) *out_status = ctx.status; + total_delivered += ctx.delivered; + if (out_delivered) *out_delivered = total_delivered; + + if (rc == CURLE_OK && ctx.status >= 200 && ctx.status < 300) { + if (out_errbody) *out_errbody = sb_detach(&ctx.errbody); + if (out_attempts) *out_attempts = attempt_no; + ctx_free(&ctx); + return 0; + } + + if (ctx.overflow) { + snprintf(errbuf, errlen, "%s", + tf("http.err.body_too_big", "limit", + i18n_num((long long)HTTP_MAX_BODY), NULL)); + if (out_errbody) *out_errbody = sb_detach(&ctx.errbody); + if (out_attempts) *out_attempts = attempt_no; + ctx_free(&ctx); + return -1; + } + + /* 已经吐过数据就不再重试:重发会把用户已经看到的内容再来一遍 */ + if (ctx.delivered > 0) { + if (rc != CURLE_OK) + snprintf(errbuf, errlen, "%s", + tf("http.err.network_partial", "error", + *cerr ? cerr : curl_easy_strerror(rc), NULL)); + else + snprintf(errbuf, errlen, "%s", + tf("http.err.status_partial", "status", + i18n_num(ctx.status), NULL)); + if (out_errbody) *out_errbody = sb_detach(&ctx.errbody); + if (out_attempts) *out_attempts = attempt_no; + ctx_free(&ctx); + return -1; + } + + if (should_retry(&rt, attempt_no, rc, &ctx, cerr, errbuf, errlen) != 0) { + if (out_errbody) *out_errbody = sb_detach(&ctx.errbody); + if (out_attempts) *out_attempts = attempt_no; + ctx_free(&ctx); + return -1; + } + + long delay = backoff_ms(&rt, attempt_no, ctx.retry_after); + ctx_free(&ctx); + spinner_notice("%s", + tf("http.retry.notice", "delay", i18n_num(delay), + "n", i18n_num(attempt_no), NULL)); + sleep_ms_interruptible(delay); + } +} diff --git a/src/http.h b/src/http.h new file mode 100644 index 0000000..da75cd4 --- /dev/null +++ b/src/http.h @@ -0,0 +1,98 @@ +/* http.h - libcurl 的薄封装:同步 POST、流式 POST、瞬态失败重试 */ +#ifndef LIGENT_HTTP_H +#define LIGENT_HTTP_H + +#include +#include +#include + +/* 全局中断标志(Ctrl-C 置位,由 main 安装信号处理器) */ +extern volatile sig_atomic_t g_ligent_interrupt; + +/* libcurl 流式写回调,语义同 libcurl CURLOPT_WRITEFUNCTION: + * 返回已处理的字节数;返回不等于 nmemb*size 的值即中止传输 */ +typedef size_t (*HttpSink)(char *data, size_t size, size_t nmemb, void *userdata); + +/* 重试策略。只重试"服务端明确报错"或"连接根本没建立"的情况: + * 超时(OPERATION_TIMEDOUT)不重试 —— 服务端可能已经处理完并计费了, + * 重发会造成重复扣费。 */ +typedef struct { + int max_retries; /* 额外重试次数;0 = 只尝试一次 */ + int base_delay_ms; /* 退避基数 */ + int max_delay_ms; /* 单次退避上限 */ +} HttpRetry; + +#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); +void http_global_cleanup(void); + +/* ---- 收发记录 ---- + * + * 进程内滚动缓存每次请求的收发原文(请求体、响应头、响应原文), + * 由 REPL 的 /log 命令导出到文件。这样不必事先指定文件, + * 出问题时可以"回放式"地把已经发生过的事情捞出来。 + * + * 只记请求体与响应,不记请求头,所以不会写入密钥。 + * 内存上限 LOG_MAX,超出后丢弃较早的部分。 */ +size_t http_log_size(void); +int http_log_truncated(void); +/* 追加写入 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) */ +int http_status_retryable(long status); + +/* 把 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 时明确报错,而不是把内存吃干。 */ +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 拉取模型列表用。 */ +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 说成"密钥被拒",会把用户 + * 引到一个根本不存在的问题上。 */ +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),就不再重试 —— + * 否则会把已经显示过的内容重复输出一遍。 */ +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, + char **out_errbody, long *out_status, int *out_attempts, + size_t *out_delivered, + char *errbuf, size_t errlen); + +#endif /* LIGENT_HTTP_H */ diff --git a/src/i18n.c b/src/i18n.c new file mode 100644 index 0000000..d89ada4 --- /dev/null +++ b/src/i18n.c @@ -0,0 +1,1325 @@ +/* i18n.c - 界面文案表与查找 + * + * 表里每条两种语言各一份。条目按 key 排序(不需要,但好查)。 + * 测试会遍历这张表:两种语言都不许空,占位符集合必须一致。 + */ +#include "i18n.h" + +#include +#include +#include +#include +#include + +#define M(key, en, zh) { key, en, zh } + +static const LigentMsg MSGS[] = { +/* ---- 帮助与命令行错误 ---- */ +M("cli.help", +"ligent - an AI agent in plain C (OpenAI-compatible)\n" +"\n" +"Usage:\n" +" ligent [options] [task...] run the task once, then exit\n" +" ligent [options] start the interactive REPL\n" +" echo \"task\" | ligent read the task from stdin\n" +"\n" +"Options:\n" +" -m, --model NAME model name (default deepseek-flash)\n" +" -u, --base-url URL API base URL (default https://api.deepseek.com/v1)\n" +" -k, --api-key KEY API key (default $DEEPSEEK_API_KEY)\n" +" -c, --config PATH config file to use\n" +" --history FILE input history file (default /ligent.history)\n" +" --no-history do not read or write input history\n" +" -s, --system TEXT override the system prompt\n" +" -t, --temperature N sampling temperature\n" +" --max-tokens N max tokens per reply\n" +" --thinking MODE thinking mode: on / off / auto (default auto = server decides)\n" +" --effort LEVEL reasoning effort: minimal/low/medium/high/xhigh/max/ultra\n" +" --max-steps N max model round trips per task (default: no limit)\n" +" --timeout N per-request timeout, seconds (default 300)\n" +" --retries N retries for transient failures: 429/5xx/connect\n" +" (default 2, 0 = never retry)\n" +" --no-stream turn streaming off\n" +" --no-tools disable tools, plain conversation only\n" +" --no-confirm stop asking before writes and command execution\n" +" --no-auto-save do not auto-archive before exit/reset/import\n" +" --no-probe do not probe model capabilities before the first request\n" +" --lang LANG interface language: auto / en / zh (default auto)\n" +" -q, --quiet do not print tool output\n" +" -v, --verbose print HTTP details and debug info\n" +" --color / --no-color force colors on / off\n" +" --print-config print the effective config, then exit\n" +" -h, --help show this help\n" +" -V, --version show version\n" +"\n" +"Config file (JSON):\n" +" Only ligent.json next to the binary is read, e.g. /usr/local/bin/ligent\n" +" uses /usr/local/bin/ligent.json. Missing means built-in defaults.\n" +" There is no user-level (~/.config) or system-level (/etc) lookup, and the\n" +" current directory is not searched either. Use -c PATH or $LIGENT_CONFIG\n" +" to point at a different file.\n" +"\n" +"Config keys: base_url, api_key, model, system, temperature, max_tokens,\n" +" thinking, reasoning_effort,\n" +" timeout, stream, max_steps, tools, retries,\n" +" confirm, auto_save, probe\n" +"\n" +"REPL commands: /help /exit /reset /cls /meta /model /system /tools /probe\n" +" /think /config /cd /history /log /export /import /shell /usage\n" +" /include\n" +" /back /unback /yolo\n" +"\n" +"Subcommands:\n" +" ligent doctor [--offline] check config, key, endpoint and network in order\n", + +"ligent - 纯 C 实现的 AI Agent(OpenAI 兼容接口)\n" +"\n" +"用法:\n" +" ligent [选项] [任务描述...] 带任务描述时一次性执行后退出\n" +" ligent [选项] 进入交互式 REPL\n" +" 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" +" -c, --config PATH 指定配置文件\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" +" --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" +" --no-stream 关闭流式输出\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" +" --print-config 打印最终生效的配置后退出\n" +" -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" +"\n" +"配置项:base_url, api_key, model, system, temperature, max_tokens,\n" +" thinking, reasoning_effort,\n" +" timeout, stream, max_steps, tools, retries,\n" +" confirm, auto_save, probe\n" +"\n" +"REPL 命令:/help /exit /reset /cls /meta /model /system /tools /probe\n" +" /think /config /cd /history /log /export /import /shell /usage\n" +" /include\n" +" /back /unback /yolo\n" +"\n" +"子命令:\n" +" ligent doctor [--offline] 体检:配置、密钥、端点、网络逐环检查\n"), + +M("cli.err.bad_effort", + "ligent: --effort accepts only {list} (got \"{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"), +M("cli.err.bad_thinking", + "ligent: --thinking accepts only on / off / auto (got \"{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", + "ligent: 选项 {option} 缺少参数\n"), +M("cli.err.no_api_key", + "ligent: no API key configured.\n" + " 1) export DEEPSEEK_API_KEY=sk-xxx\n" + " 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" + " 方式一:export DEEPSEEK_API_KEY=sk-xxx\n" + " 方式二:在二进制所在目录建一个 ligent.json(见 --print-config 显示的路径),\n" + " 内容形如 {\"api_key\": \"${DEEPSEEK_API_KEY}\", \"model\": \"deepseek-flash\"}\n" + " 方式三:ligent --api-key sk-xxx \"你好\"\n"), +M("cli.err.unknown_option", + "ligent: unknown option {option} (try --help)\n", + "ligent: 未知选项 {option}(--help 查看用法)\n"), + +/* ---- 配置展示(--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.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 导出)"), +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} 次"), +M("cfg.val.unlimited", "no limit", "不限"), +M("cfg.val.unknown", "unknown", "未知"), +M("cfg.val.unset", "(not set)", "(未设置)"), +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.off", "off (sends thinking.type=disabled)", + "关闭(发 thinking.type=disabled)"), +M("cfg.thinking.on", "on (sends thinking.type=enabled)", + "开启(发 thinking.type=enabled)"), +M("cfg.err.bad_json", "{path}: {error}", "{path}:{error}"), +M("cfg.err.bad_effort", + "{path}: reasoning_effort accepts only {list} (or leave it out)", + "{path}:reasoning_effort 只能是 {list}(或留空不发)"), +M("cfg.err.not_object", "{path}: the top level must be a JSON object ({...})", + "{path}:顶层必须是 JSON 对象({...})"), +M("cfg.err.parse_ignored", + "ligent: config file could not be parsed; using built-in defaults instead\n" + " {error}", + "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\""), + +/* ---- 写操作确认 ---- */ +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"), +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} 字节,将整体覆盖)"), +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} 处提醒。"), +M("doctor.conclusion.next", "start with the items marked [x].", + "先处理标 [x] 的项。"), +M("doctor.conclusion.ok", "Bottom line: no problems found.", + "结论:没发现问题。"), +M("doctor.env.note.all_proxy", "proxy for all protocols", "通用代理"), +M("doctor.env.note.api_key", "explicit override, highest priority", + "显式覆盖,优先级最高"), +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", + "仅在端点确实是 DeepSeek 时生效"), +M("doctor.env.note.http_proxy", "HTTP proxy", "HTTP 代理"), +M("doctor.env.note.https_proxy", "HTTPS proxy", "HTTPS 代理"), +M("doctor.env.note.lang", "overrides the interface language", "覆盖界面语言"), +M("doctor.env.note.model", "overrides the model", "覆盖模型"), +M("doctor.env.note.no_proxy", "proxy exceptions", "代理例外"), +M("doctor.env.note.openai", "used only when the endpoint really is OpenAI", + "仅在端点确实是 OpenAI 时生效"), +M("doctor.lbl.argv0", "argv[0]", "argv[0]"), +M("doctor.lbl.canon_path", "canonical path", "规范路径"), +M("doctor.lbl.model", "model", "模型"), +M("doctor.lbl.note", "note", "注意"), +M("doctor.lbl.url", "request URL", "实际请求地址"), +M("doctor.val.not_configured", "not configured", "未配置"), +M("doctor.lbl.auth", "auth", "认证"), +M("doctor.lbl.config_dir", "config dir", "配置目录"), +M("doctor.lbl.connectivity", "connectivity", "连通性"), +M("doctor.lbl.endpoint", "endpoint", "端点"), +M("doctor.lbl.exe", "real executable", "真实可执行文件"), +M("doctor.lbl.format_guess", "prefix looks like", "格式推断"), +M("doctor.lbl.how_to_set", "how to set", "怎么配"), +M("doctor.lbl.http_status", "HTTP status", "HTTP 状态"), +M("doctor.lbl.log", "wire log", "收发记录"), +M("doctor.lbl.match", "matches endpoint", "与端点匹配"), +M("doctor.lbl.proxy", "proxy", "代理"), +M("doctor.lbl.redirect", "redirect", "重定向"), +M("doctor.lbl.set_keys", "keys set", "已设置的项"), +M("doctor.lbl.source", "source", "来源"), +M("doctor.lbl.status", "status", "状态"), +M("doctor.lbl.symlink", "symlink", "软链接"), +M("doctor.lbl.tip", "tip", "提示"), +M("doctor.lbl.transport", "transport", "传输安全"), +M("doctor.lbl.trailing_slash", "trailing slash", "结尾斜杠"), +M("doctor.lbl.version", "version", "版本"), +M("doctor.lbl.effort", "effort", "思考级别"), +M("doctor.lbl.value", "value", "值"), +M("doctor.sec.binary", "binary & config location", "二进制与配置位置"), +M("doctor.sec.config", "config file", "配置文件"), +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.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);先照下面「重定向」那节的方法核对"), +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),可能被拒或被截断"), +M("doctor.sub.redirect_1", + "ligent does not follow redirects: a cross-origin hop drops the Authorization header,", + "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\",看起来像是密钥没带,"), +M("doctor.sub.redirect_3", + "when in fact the URL moved. Point base_url straight at the address above.", + "实际是 URL 跳了。把 base_url 直接改成上面那个地址即可。"), +M("doctor.sub.temp_too_high", "above 2.0, most providers reject this", + "超过 2.0,多数服务商会拒绝"), +M("doctor.sub.thinking_temp", + "temperature has no effect in thinking mode; the server ignores it", + "思考模式下 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"), +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」一节核对格式与来源"), +M("doctor.val.bad_scheme", "URL must start with http:// or https://", + "URL 必须以 http:// 或 https:// 开头"), +M("doctor.val.base_url_unset", "(not set)", "(未设置)"), +M("doctor.val.chars", "({n} chars)", "({n} 字符)"), +M("doctor.val.connect_failed", "failed: {error}", "失败:{error}"), +M("doctor.val.connect_ok", "reachable, TLS verified (for HTTPS)", + "可连接,TLS 校验通过(HTTPS 时)"), +M("doctor.val.empty", "(empty)", "(空)"), +M("doctor.val.endpoint_404", "HTTP 404 — no such address: {url}", + "HTTP 404 —— 这个地址不存在:{url}"), +M("doctor.val.env_ignored", + "{var} is set but unused this run (the key came from {source}). " + "If that is not what you want, unset it or use LIGENT_API_KEY", + "{var} 已设置,但本次没用上(密钥来自「{source}」)。" + "如果这不是你想要的,unset 它或改用 LIGENT_API_KEY"), +M("doctor.val.env_set", "set: {value}", "已设置:{value}"), +M("doctor.val.env_unset", "not set", "未设置"), +M("doctor.val.exists_unloadable", + "present but not loaded (the top level must be a JSON object)", + "存在但加载失败(顶层必须是 JSON 对象)"), +M("doctor.val.exists_unparsable", "present but unparsable: {error}", + "存在但无法解析:{error}"), +M("doctor.val.how_to_set_key", + "export DEEPSEEK_API_KEY=... or -k KEY or api_key in the config file", + "export DEEPSEEK_API_KEY=... 或 -k KEY 或配置文件 api_key"), +M("doctor.val.https", "HTTPS", "HTTPS"), +M("doctor.val.load_failed", "load failed: {error}", "加载失败:{error}"), +M("doctor.val.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 导出)"), +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"), +M("doctor.val.match_ok", "endpoint is {owner} and the key format matches", + "端点属于 {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},但密钥前缀认不出来"), +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 是否写对"), +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,但目标是本机"), +M("doctor.val.plain_remote", "plain HTTP, the key will travel in the clear", + "明文 HTTP,密钥会以明文上网"), +M("doctor.val.prefix_unclear", "(unrecognized)", "(认不出前缀)"), +M("doctor.val.proxy_unset", "(not set)", "(未设置)"), +M("doctor.val.redirect_to", "endpoint redirects to {location}", + "端点跳转到 {location}"), +M("doctor.val.retries", "{n}", "{n} 次"), +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.trailing_slash_yes", "base_url ends with /, which tends to add a second slash", + "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}"), +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.", "再见。"), +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", + ":", + ":"), +M("repl.export.done", + "exported {n} messages: {lineage}{arrow}{name}\n", + "已导出 {n} 条消息:{lineage}{arrow}{name}\n"), +M("repl.export.empty", + "this conversation is still empty, nothing to export.", + "当前对话还没有内容,无需导出。"), +M("repl.export.failed", + "export failed: {error}\n", + "导出失败:{error}\n"), +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 取消。"), +M("repl.include.none", + "nothing attached ({n} skipped).\n", + "没有附带任何文件({n} 个被跳过)。\n"), +M("repl.include.skipped_more", + " ({n} more skipped)", + "(另有 {n} 个被跳过)"), +M("repl.index_out_of_range", + "index {n} is out of range (1..{max}), cancelled.\n", + "序号 {n} 超出范围(1..{max}),已取消。\n"), +M("repl.model.current", + " <- current", + " <- 当前"), +M("repl.model.fetching", + "fetching the model list ({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 <名字> 指定,列表拿不到不影响这条。"), +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选择序号,或直接输入模型名(回车取消)> "), +M("repl.model.row", + " %2zu) %s%s\n", + " %2zu) %s%s\n"), +M("repl.model.same", + "model unchanged: {model}\n", + "模型未变:{model}\n"), +M("repl.model.switched", + "switched to model {model}\n", + "模型已切换为 {model}\n"), +M("repl.no_content", + "(no content)", + "(没有内容)"), +M("repl.role.assistant", + "assistant", + "助手"), +M("repl.role.system", + "system", + "系统"), +M("repl.role.tool", + "tool", + "工具"), +M("repl.role.user", + "you", + "你"), +M("repl.tool_call", + " -> call {name} id={id}", + " -> 调用 {name} id={id}"), +M("repl.unknown_error", + "unknown error", + "未知错误"), +M("repl.yes_no.hint", + "please answer y or n; Enter means y.", + "请输入 y 或 n,直接回车表示 y。"), +M("repl.back.done", + "removed the last round ({n} messages); {left} left. Undo with /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"), +M("repl.cd_failed", + "cd failed", + "cd 失败"), +M("repl.help", + "/help show this help\n/exit, /quit exit\n/reset clear the conversation (keeps the system prompt)\n/cls clear the screen (history untouched)\n/meta meta mode: edit the conversation with the model's help\n/model [NAME] switch model; with no name, pick from the online list\n/include [GLOB] attach files to the model; no argument = pick interactively, clear cancels\n/system TEXT set a new system prompt and clear the history\n/tools list available tools\n/probe probe model capabilities now (vision/tools); ignores --no-probe and the cache\n/think show thinking mode and effort\n/think on|off|auto switch thinking mode (auto = server decides)\n/think LEVEL set reasoning effort (minimal/low/medium/high/xhigh/max/ultra)\n/config show the effective config\n/cd PATH change working directory\n/history print the whole conversation (every message, tools included)\n/usage show token usage\n/shell [CMD] open an interactive shell, or run a single command\n/back roll back the most recent round\n/unback undo the last /back\n/yolo toggle write confirmation for this session\n/log [FILE] export the wire log (no argument = show size, /log clear = drop it)\n/export save this conversation under ligent.archive/\n/import load one of the archives (asks before printing the history)\n", + "/help 显示本帮助\n/exit, /quit 退出\n/reset 清空对话历史(保留 system 提示词)\n/cls 清屏(只动屏幕,不动对话历史)\n/meta 进入 meta 模式:用模型辅助编辑对话历史\n/model [NAME] 切换模型;不带名字则拉取在线列表交互式选择\n/include [GLOB] 把文件附带给模型;不带参数则交互式挑选,clear 取消\n/system TEXT 重新设置 system 提示词,并清空历史\n/tools 列出可用工具\n/probe 现在测一次模型能力(图片/工具);无视 --no-probe 与缓存\n/think 查看思考模式与级别\n/think on|off|auto 切换思考模式(auto = 由服务端决定)\n/think LEVEL 设置思考级别(minimal/low/medium/high/xhigh/max/ultra)\n/config 显示当前生效配置\n/cd PATH 切换工作目录\n/history 把当前对话完整打印出来(每条消息,含工具调用与结果)\n/usage 显示 token 用量\n/shell [命令] 进入交互式 shell,或直接执行一条命令\n/back 回退最近一轮对话\n/unback 撤销刚才的 /back\n/yolo 临时开关写操作确认\n/log [FILE] 导出收发记录(无参数看大小,/log clear 清空)\n/export 把当前对话存到 ligent.archive/\n/import 从存档里选一份载入(默认会问要不要打印历史)\n"), +M("repl.import.done", + "loaded {name} ({n} messages). Future /export will save as {name}_xxx\n", + "已载入 {name}({n} 条消息)。之后 /export 会存成 {name}_xxx\n"), +M("repl.import.list_header", + "\n{dir} holds {n} archives:\n\n", + "\n{dir} 里有 {n} 份存档:\n\n"), +M("repl.import.load_failed", + "loading failed: {error}\n", + "载入失败:{error}\n"), +M("repl.import.none", + "no archives yet ({dir} is empty). Save one with /export first.\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"), +M("repl.import.not_found_cancelled", + "no archive named {name}, cancelled.\n", + "找不到名为 {name} 的存档,已取消。\n"), +M("repl.import.pick_prompt", + "\nselect a number or a name (Enter cancels) > ", + "\n选择序号或名字(直接回车取消)> "), +M("repl.import.print_ask", + "print the contents of this history? (Y/n) ", + "是否输出这段历史的内容?(Y/n) "), +M("repl.import.row", + " %2zu) %-40s %s %3ld entries %s\n", + " %2zu) %-40s %s %3ld 条 %s\n"), +M("repl.import.row_failed", + " %2zu) %-40s unreadable\n", + " %2zu) %-40s 读取失败\n"), +M("repl.index_out_of_range_plain", + "index {n} is out of range, cancelled.\n", + "序号 {n} 超出范围,已取消。\n"), +M("repl.reset.done", + "conversation history cleared.", + "已清空对话历史。"), +M("repl.system.builtin", + "(built-in default)", + "(内置默认)"), +M("repl.system.current", + "current system prompt:", + "当前 system 提示词:"), +M("repl.system.updated", + "system prompt updated and history cleared.", + "system 提示词已更新,历史已清空。"), +M("repl.think.bad", + "not recognised: \"{value}\".\n mode: on / off / auto\n effort: {list}\n", + "无法识别 \"{value}\"。\n 开关:on / off / auto\n 级别:{list}\n"), +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"), +M("repl.think.mode", + "thinking mode: %s\n", + "思考模式:%s\n"), +M("repl.tools.disabled", + "tools are disabled (--no-tools).", + "工具已禁用(--no-tools)。"), +M("repl.unback.done", + "restored {n} messages; history is back to {total}.\n", + "已恢复 {n} 条消息,历史回到 {total} 条。\n"), +M("repl.unback.new_chat", + "a new conversation started after /back, cannot undo.", + "/back 之后已经开始了新对话,无法撤销。"), +M("repl.unback.none", + "nothing to undo.", + "没有可撤销的操作。"), +M("repl.usage.calls", + " API calls : %d\n", + " API 调用 : %d 次\n"), +M("repl.usage.completion", + " completion : %lld\n", + " 输出 token : %lld\n"), +M("repl.usage.header", + "token usage (this session)\n", + "token 用量(本次会话)\n"), +M("repl.usage.history", + " history : %zu messages (excluding 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 字段)。"), +M("repl.usage.prompt", + " prompt : %lld\n", + " 输入 token : %lld\n"), +M("repl.usage.total", + " total : %lld\n", + " 合计 : %lld\n"), +M("repl.why.before_import", + "before import", + "导入前"), +M("repl.why.before_meta", + "before entering meta", + "进入 meta 前"), +M("repl.why.before_reset", + "before reset", + "重置前"), +M("repl.why.exit", + "exit", + "退出"), +M("repl.yolo.off", + "off — no more prompts; run /yolo again to restore", + "关闭 —— 之后不再询问,想恢复再敲一次 /yolo"), +M("repl.yolo.on", + "on", + "开启"), +M("repl.yolo.set", + "write confirmation is now {state} (this session only).\n", + "写操作确认已{state}(只影响本次会话)。\n"), +M("repl.history.count", + "{n} messages in history (excluding the system prompt).\n", + "历史消息 {n} 条(不含 system 提示词)。\n"), +M("repl.history.full", + "printing all {total} messages below (system prompt included):\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"), +M("repl.log.save_failed", + "export failed: {error}\n", + "导出失败:{error}\n"), +M("repl.log.saved", + "appended {n} bytes of wire log to {file}\n", + "已把 {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"), +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 返回;整段会话会在退出后记入上下文。"), +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"), +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"), +M("repl.unknown_command", + "unknown command: {cmd} (/help lists them)\n", + "未知命令:{cmd}(/help 查看帮助)\n"), +M("agent.call.elevated", "as root: {cmd}", "以 root:{cmd}"), +M("agent.call.write", "\"{path}\" ({n} bytes)", "\"{path}\"({n} 字节)"), +M("agent.caps.baseline_failed", + "the baseline request did not go through", + "基线请求未通过"), +M("agent.caps.line", + "model capabilities: multimodal (image input) {vision} * tool calls {tools}{note}", + "模型能力:多模态(图片输入){vision} * 工具调用 {tools}{note}"), +M("agent.caps.no.tools", + "no (tool table no longer sent)", + "不支持(不再发送工具表)"), +M("agent.caps.no.vision", + "no (read_image hidden)", + "不支持(已隐藏 read_image)"), +M("agent.caps.note", + " — anything untested is treated as supported ({why})", + " —— 没测出来的一律按「支持」处理({why})"), +M("agent.caps.unknown", + "untested", + "未测"), +M("agent.caps.yes", + "yes", + "支持"), +M("agent.empty_reply", + "(the model returned no content)", + "(模型没有返回任何内容)"), +M("agent.error", + "error: {error}\n", + "错误:{error}\n"), +M("agent.image.degraded", + "{n} image reference(s) could not be expanded (local backup missing or over the size cap); they were downgraded to a text note in place, no retry.", + "有 {n} 处图片引用展开不出来(本机备份缺失或体积超限),已就地降级成文字说明,不再重试。"), +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"), +M("agent.img.server_said", + "the server said: {error}", + "服务端原话:{error}"), +M("agent.interrupted", + "[interrupted]", + "[已中断]"), +M("agent.max_steps", + "reached the step limit ({n}); stopping. Set --max-steps 0 for no limit.\n", + "已达到最大步数 {n},停止。要不设上限就写 --max-steps 0。\n"), +M("agent.output.elided", + "... [rest of output elided]", + "... [省略剩余输出]"), +M("agent.output.empty", + "(no output)", + "(无输出)"), +M("agent.stream.broken", + " transfer interrupted; the partial content above is all that arrived: {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} 处 —— 不修的话之后每一轮都会被服务端拒绝。"), +M("agent.tool.done", + "done", + "完成"), +M("agent.tool.failed", + "failed", + "失败"), +M("agent.tool.name_unknown", + "(unknown)", + "(未知)"), +M("attach.err.binary", + "looks like a binary file", + "看起来是二进制文件"), +M("attach.err.glob_failed", + "glob failed: {pattern}", + "展开失败:{pattern}"), +M("attach.err.no_match", + "no files matched: {pattern}", + "没有匹配到文件:{pattern}"), +M("attach.err.no_pattern", + "no pattern given", + "没有给出模式"), +M("attach.err.no_vision", + "the current model does not take image input", + "当前模型不支持图片输入"), +M("attach.err.not_regular", + "not a regular file", + "不是普通文件"), +M("attach.err.read_failed", + "cannot read", + "读不了"), +M("attach.err.too_big", + "file too large (256 KB per file; use read_file with offsets instead)", + "文件太大(单个上限 256 KB,请改用 read_file 分段读)"), +M("attach.err.total_big", + "the attached text exceeds the total limit (1 MB)", + "本次附带的文本总量超过上限(1 MB)"), +M("attach.err.unreadable", + "cannot read", + "读不到"), +M("attach.glob.skipped", + " skipped {file}: {why}", + " 跳过 {file}:{why}"), +M("attach.image_mark", + " [image]", + " 〔图片〕"), +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"), +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 或路径,回车结束。"), +M("attach.pick.is_dir", + " (dir)", + " (目录)"), +M("attach.pick.more", + " ...{n} more not listed\n", + " ...还有 {n} 项没列出来\n"), +M("attach.pick.no_match", + "no match: {input}\n", + "没有匹配到:{input}\n"), +M("attach.pick.parent", + " .) .. go up one level", + " .) .. 返回上级"), +M("attach.pick.prompt", + "\nselected {n}. pick > ", + "\n已选 {n} 个。选择 > "), +M("attach.pick.selected", + "selected {file}\n", + "已选中 {file}\n"), +M("attach.pick.skipped", + "skipped {file}: {why}", + "跳过 {file}:{why}"), + +/* ---- 存档(archive.c):/export /import 的底层错误 ---- */ +M("archive.err.mkdir", + "cannot create the archive directory {dir}: {error}", + "无法创建存档目录 {dir}:{error}"), +M("archive.err.name_exhausted", + "could not come up with a non-clashing file name in 64 tries", + "连续 64 次都没能生成不冲突的文件名"), +M("archive.err.no_messages", + "{path} has no messages array", + "{path} 里没有 messages 数组"), +M("archive.err.not_archive", + "{path} is not a ligent archive (the format marker is missing)", + "{path} 不是 ligent 存档(缺少 format 标记)"), +M("archive.err.not_json", + "{path} is not valid JSON: {error}", + "{path} 不是合法 JSON:{error}"), +M("archive.err.read_failed", + "cannot read {path}", + "无法读取 {path}"), +M("archive.err.write_failed", + "error while writing {path}", + "写入 {path} 时出错"), +M("archive.err.write_open", + "cannot write {path}: {error}", + "无法写入 {path}:{error}"), + +/* ---- 模型列表(models.c)---- */ +M("models.err.empty_body", + "the server returned an empty response (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})—— " + "这个账号下可能没有可用的模型"), +M("models.err.no_base_url", + "no base_url configured", + "没有配置 base_url"), +M("models.err.request_failed", + "request failed", + "请求失败"), +M("models.err.unparsable", + "the server did not return a usable model list (HTTP {status}, response {body})", + "服务端没有返回可用的模型列表(HTTP {status},响应 {body})"), + +/* ---- 请求与网络(http.c / llm.c)---- */ +M("http.err.body_too_big", + "the response body exceeded the {limit}-byte cap, aborted", + "响应体超过 {limit} 字节上限,已中止"), +M("http.err.interrupted", + "interrupted", + "已中断"), +M("http.err.log_open", + "cannot write {path}: {error}", + "无法写入 {path}:{error}"), +M("http.err.log_write", + "error while writing {path}", + "写入 {path} 时出错"), +M("http.err.network", + "network error: {error}", + "网络错误:{error}"), +M("http.err.network_partial", + "network error: {error} (some content was already shown, not retrying)", + "网络错误:{error}(已输出部分内容,不重试)"), +M("http.err.network_retried", + "network error: {error} (after {n} retries)", + "网络错误:{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)"), +M("http.err.status_partial", + "HTTP {status} (some content was already shown, not retrying)", + "HTTP {status}(已输出部分内容,不重试)"), +M("http.err.status_retried", + "HTTP {status} (after {n} retries)", + "HTTP {status}(已重试 {n} 次)"), +M("http.log.header", + "# ligent request/response log — exported {time}, {n} bytes\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"), +M("http.retry.notice", + "ligent: request failed, retrying in {delay} ms (attempt {n})", + "ligent: 请求失败,{delay} 毫秒后重试(第 {n} 次)"), +M("http.val.no_location", + "(the server sent no Location)", + "(服务端未提供 Location)"), +M("llm.err.auth_hint", + "\n the key just sent is {masked}. Keys are not interchangeable across " + "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 里配的是哪一个。"), +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 环境变量或写入配置文件)"), +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} 字节),已中止"), +M("llm.err.server", + "server error: {error}", + "服务端错误:{error}"), +M("llm.err.unparsable_response", + "cannot parse the server response (neither streaming nor JSON)", + "无法解析服务端响应(非流式且非 JSON)"), +M("llm.mask.empty", + "(empty)", + "(空)"), +M("llm.mask.partial", + "{head}... ({n} chars)", + "{head}...({n} 字符)"), +M("llm.val.no_base_url", + "(base_url not set)", + "(未设置 base_url)"), +M("llm.val.unknown", + "(unknown)", + "未知"), + +/* ---- /shell(shell.c)---- */ +M("shell.err.fork", + "fork failed: {error}", + "fork 失败:{error}"), +M("shell.err.no_pty", + "cannot create a pty: {error}", + "无法创建伪终端:{error}"), +M("shell.err.no_slave_name", + "cannot get the pty slave device name", + "无法取得伪终端从设备名"), +M("shell.err.pty_init", + "pty initialization failed: {error}", + "伪终端初始化失败:{error}"), + +/* ---- 能力探测(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"), +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"), + +/* ---- JSON 解析诊断(json.c):给人看的那份 ---- + * + * 这一组是"两条腿":json_parse() 的报错固定中文(它会被原样回填给模型, + * 属于给模型看的信息);json_parse_ui() 的报错跟随界面语言(配置、存档、 + * 服务端响应这些给人看的路径)。文案只有这一份,两种走法各取一列。 + * + * 它们是 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(输入在此处结束)"), +M("json.err.at_got", + "line %zu, column %zu: %s, got \"%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", "输入为空"), +M("json.err.object_unterminated", "object is not closed", "对象未闭合"), +M("json.err.raw_control", + "unescaped control character 0x%02X in a string", + "字符串中出现未转义的控制字符 0x%02X"), +M("json.err.string_escape_truncated", + "a string escape is truncated at the end", + "字符串转义在末尾被截断"), +M("json.err.string_unterminated", "string is not closed", "字符串未闭合"), +M("json.err.too_deep", + "JSON nested too deeply (over %d levels)", + "JSON 嵌套过深(超过 %d 层)"), +M("json.err.trailing", + "extra content after the JSON: '%.20s'", + "JSON 末尾存在多余内容:'%.20s'"), +M("json.err.u_bad_hex", + "invalid \\u hex digit '%c'", + "非法的 \\u 十六进制字符 '%c'"), +M("json.err.u_incomplete", "\\u escape is incomplete", "\\u 转义不完整"), +M("json.err.unexpected_char", "unexpected character '%c'", "意外的字符 '%c'"), +M("json.err.unexpected_eof", "unexpected end of input", "意外的输入结束"), +M("json.err.unknown_escape", "unknown escape '\\%c'", "未知的转义字符 '\\%c'"), +M("json.err.want_colon", + "expected ':' after key '%s' in an object", + "对象中键 '%s' 之后期望 ':'"), +M("json.err.want_comma_in_array", + "expected ',' or ']' in an array, got '%c'", + "数组中期望 ',' 或 ']',实际是 '%c'"), +M("json.err.want_comma_in_object", + "expected ',' or '}' in an object, got '%c'", + "对象中期望 ',' 或 '}',实际是 '%c'"), +M("json.err.want_false", + "invalid literal (expected false)", + "非法字面量(期望 false)"), +M("json.err.want_key", + "expected an object key (a string)", + "期望对象的键(字符串)"), +M("json.err.want_null", + "invalid literal (expected null)", + "非法字面量(期望 null)"), +M("json.err.want_true", + "invalid literal (expected true)", + "非法字面量(期望 true)"), + +/* ---- 工具参数的呈现(tools.c 的 tool_lines_label / tool_render_diff): + * 调用摘要、确认提示、执行后的展示共用同一份 ---- */ +M("tool.call.all_lines", "whole file", "全文件"), +M("tool.call.scope_all", "{scope} (all matches)", "{scope}(所有匹配)"), +M("tool.diff.more", "... ({n} more lines)", "...(还有 {n} 行)"), + +/* ---- /tools 的列表(tools.c):纯给人看的一行 ---- */ +M("tools.ui.edit_file", + "replace inside a set of lines (omit line to replace everywhere)", + "按行号集合精确替换(省略 line = 全文件替换)"), +M("tools.ui.header", + "available tools:", + "可用工具:"), +M("tools.ui.hidden", + " ({name} is hidden: the current model does not accept image input)", + " ({name} 已隐藏:当前模型不接受图片输入)"), +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)"), +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)---- */ +M("util.oom", + "ligent: out of memory\n", + "ligent: 内存分配失败\n"), + +/* ---- meta 模式(meta.c):只有给人看的那部分,工具回执仍是中文 ---- */ +M("meta.already_in", + "already in meta mode.", + "已经在 meta 模式里了。"), +M("meta.desc.delete_rounds", + " rounds {from}..{to} deleted whole\n", + " 轮 {from}..{to} 整轮删除\n"), +M("meta.desc.insert", + " inserting {role} after #{after}: {preview}\n", + " 在 #{after} 之后插入 {role}:{preview}\n"), +M("meta.desc.replace", + " rewriting #{index}: {preview}\n", + " #{index} 的正文改成:{preview}\n"), +M("meta.desc.undo", + " undo the last edit\n", + " 撤销上一次改动\n"), +M("meta.diff.header", + "current history: {n} message(s), {bytes} bytes:\n", + "当前历史 {n} 条消息,{bytes} 字节:\n"), +M("meta.diff.no_text", + "(no text)", + "(无正文)"), +M("meta.diff.structural", + " [structural]", + " 〔结构〕"), +M("meta.enter", + "entered meta mode — you are now editing the conversation history itself, " + "not continuing it.", + "已进入 meta 模式 —— 改的是对话历史本身,不是继续对话。"), +M("meta.enter.hint", + "say what to change in plain words; /help lists the commands, /exit goes back.", + "用自然语言说要改什么;/help 看命令,/exit 返回。"), +M("meta.exit", + "\nleft meta mode. ", + "\n退出 meta 模式。"), +M("meta.exit.edits", + "made {n} edit(s) this time, ", + "本次共改动 {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位>。"), +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"), +M("meta.help", + "/exit, /quit leave meta mode, back to the main conversation\n" + "/diff show the conversation as it stands (changes nothing)\n" + "/undo undo the last edit\n" + "/help show this help\n" + "\n" + "anything else is an instruction for the meta model, for example:\n" + " drop \"use Python\" from what I said in round 3\n" + " 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" + "/undo 撤销上一次改动\n" + "/help 显示本帮助\n" + "\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"), +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"), +}; + +#undef M + +#define NMSGS (sizeof MSGS / sizeof MSGS[0]) +/* tf() 用一圈静态缓冲轮流返回字符串(这样调用点不必自己 malloc)。 + * 轮转的层数就是**嵌套调用**的上限:`tf(a, "x", tf(b, ...), NULL)` 里的内层 + * 返回值必须在外层拼完之前一直有效,嵌套超过 TF_BUFS 层就会踩坏自己。 */ +#define TF_BUFS 8 +#define TF_BUFLEN 4096 + +static int g_lang = LIGENT_LANG_EN; +static char g_buf[TF_BUFS][TF_BUFLEN]; +static int g_buf_i; + +static const LigentMsg *find(const char *key) +{ + for (size_t i = 0; i < NMSGS; i++) + if (strcmp(MSGS[i].key, key) == 0) return &MSGS[i]; + return NULL; +} + +static char *next_buf(void) +{ + char *b = g_buf[g_buf_i]; + g_buf_i = (g_buf_i + 1) % TF_BUFS; + return b; +} + +void i18n_set_lang(int lang) +{ + g_lang = (lang == LIGENT_LANG_ZH) ? LIGENT_LANG_ZH : LIGENT_LANG_EN; +} + +int i18n_lang(void) { return g_lang; } + +const char *i18n_lang_name(void) +{ + return g_lang == LIGENT_LANG_ZH ? "zh" : "en"; +} + +int i18n_parse_lang(const char *name) +{ + if (!name || !*name) return -1; + char lang[16]; + size_t k = 0; + /* 语言码取到第一个 '_' / '-' / '.' / '@' 之前:"zh_CN.UTF-8" → "zh" */ + for (const char *p = name; *p && k < sizeof lang - 1; p++) { + if (*p == '_' || *p == '-' || *p == '.' || *p == '@') break; + lang[k++] = (char)tolower((unsigned char)*p); + } + lang[k] = '\0'; + if (strcmp(lang, "zh") == 0) return LIGENT_LANG_ZH; + if (strcmp(lang, "en") == 0) return LIGENT_LANG_EN; + if (strcmp(lang, "c") == 0 || strcmp(lang, "posix") == 0) return LIGENT_LANG_EN; + return -1; +} + +int i18n_lang_from_env(void) +{ + static const char *vars[] = { "LIGENT_LANG", "LC_ALL", "LC_MESSAGES", "LANG" }; + for (size_t i = 0; i < sizeof vars / sizeof vars[0]; i++) { + const char *v = getenv(vars[i]); + if (!v || !*v) continue; + int lang = i18n_parse_lang(v); + if (lang >= 0) return lang; + /* 认不出来的语言码(fr_FR、ja_JP…):我们只有两种翻译, + * 与其猜,不如按"没设"处理,最后落到英文。 */ + return LIGENT_LANG_EN; + } + return LIGENT_LANG_EN; +} + +void i18n_init(int lang) +{ + i18n_set_lang(lang < 0 ? i18n_lang_from_env() : lang); +} + +const LigentMsg *i18n_table(size_t *count) +{ + if (count) *count = NMSGS; + return MSGS; +} + +const char *i18n_lookup(int lang, const char *key) +{ + const LigentMsg *m = find(key); + if (!m) return NULL; + return lang == LIGENT_LANG_ZH ? m->zh : m->en; +} + +const char *i18n_num(long long v) +{ + char *b = next_buf(); + snprintf(b, TF_BUFLEN, "%lld", v); + return b; +} + +const char *t(const char *key) +{ + const LigentMsg *m = find(key); + if (!m) { /* 漏掉的键要看得见,别静默成空白 */ + char *b = next_buf(); + snprintf(b, TF_BUFLEN, "[?%s?]", key ? key : ""); + return b; + } + const char *s = (g_lang == LIGENT_LANG_ZH) ? m->zh : m->en; + if (!s || !*s) s = (g_lang == LIGENT_LANG_ZH) ? m->en : m->zh; + if (!s || !*s) s = m->zh; + return s; +} + +const char *tf(const char *key, ...) +{ + const char *fmt = t(key); + const char *names[16], *vals[16]; + int n = 0; + + va_list ap; + va_start(ap, key); + while (n < (int)(sizeof names / sizeof names[0])) { + const char *nm = va_arg(ap, const char *); + if (!nm) break; + names[n] = nm; + vals[n] = va_arg(ap, const char *); + n++; + } + va_end(ap); + + char *out = next_buf(); + size_t o = 0; + for (const char *p = fmt; *p; ) { + if (*p == '{') { + const char *e = strchr(p + 1, '}'); + if (e) { + size_t want = (size_t)(e - (p + 1)); + for (int i = 0; i < n; i++) { + if (strlen(names[i]) != want) continue; + if (strncmp(names[i], p + 1, want) != 0) continue; + size_t l = strlen(vals[i]); + if (o + l >= TF_BUFLEN) l = TF_BUFLEN - 1 - o; + memcpy(out + o, vals[i], l); + o += l; + p = e + 1; + goto next; + } + /* 没给这个占位符的值:原样留着,方便一眼看出漏了参数 */ + if (o + want + 2 < TF_BUFLEN) { + out[o++] = '{'; + memcpy(out + o, p + 1, want); + o += want; + out[o++] = '}'; + } + p = e + 1; + continue; + } + } + if (o + 1 >= TF_BUFLEN) break; + out[o++] = *p++; +next: ; + } + out[o] = '\0'; + return out; +} diff --git a/src/i18n.h b/src/i18n.h new file mode 100644 index 0000000..8f85b55 --- /dev/null +++ b/src/i18n.h @@ -0,0 +1,62 @@ +/* i18n.h - 界面文案的多语言表 + * + * 只覆盖**给人看**的文案:帮助、错误、诊断、确认提示、spinner 标签。 + * 送给模型的那些(系统提示词、工具描述、工具回执)**不在这里**, + * 它们跟界面语言是两件事,见 README「界面语言」一节。 + * + * 键用点分 ID(`err.unknown_option`)而不是中文原文:这样两种语言的 + * 条目能机械对齐 —— 少一条、占位符对不上,测试都会报出来。 + */ +#ifndef LIGENT_I18N_H +#define LIGENT_I18N_H + +#include + +enum { LIGENT_LANG_EN = 0, LIGENT_LANG_ZH = 1 }; + +typedef struct { + const char *key; + const char *en; + const char *zh; +} LigentMsg; + +/* 设置 / 查询当前语言 */ +void i18n_set_lang(int lang); +int i18n_lang(void); + +/* 解析语言名:"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 是英文)。 */ +int i18n_lang_from_env(void); + +/* 初始化。lang 为 -1 时按环境探测。 */ +void i18n_init(int lang); + +/* "en" / "zh"(静态字面量) */ +const char *i18n_lang_name(void); + +/* 查表取当前语言的文案。未知键返回 "[?key?]" —— 显眼, + * 免得漏掉的条目静默变成空白。 */ +const char *t(const char *key); + +/* 带占位符:模板里写 {name},调用时按名字成对给值,最后传 NULL。 + * tf("err.unknown_option", "option", arg, NULL) + * 按名字匹配(不是按位置),所以译文可以调整语序。 */ +const char *tf(const char *key, ...); + +/* 整数转字符串(静态轮转缓冲),专供 tf 的参数位置用: + * tf("k", "n", i18n_num(count), NULL) + * 省得每个调用点都开一个 char buf[32]。 */ +const char *i18n_num(long long v); + +/* 表本身,供测试遍历(检查两种语言是否齐全、占位符是否一致) */ +const LigentMsg *i18n_table(size_t *count); + +/* 指定语言取原文(测试用)。找不到返回 NULL。 */ +const char *i18n_lookup(int lang, const char *key); + +#endif /* LIGENT_I18N_H */ diff --git a/src/image.c b/src/image.c new file mode 100644 index 0000000..feb9f11 --- /dev/null +++ b/src/image.c @@ -0,0 +1,385 @@ +/* image.c - 图片格式嗅探、本地备份、请求前展开 */ +#include "image.h" +#include "json.h" +#include "util.h" + +#include +#include +#include +#include +#include +#include +#include + +/* 识别四种服务端支持的格式。都靠文件头那几个字节 —— 服务端也明说了 + * "格式由文件实际内容判断,而非文件名或声明的 MIME 类型",我们得跟它一致, + * 否则会把一个改名成 .png 的 JPEG 当成 PNG 报出去。 */ +int image_sniff(const unsigned char *d, size_t n, + const char **ext, const char **mime) +{ + if (!d) return -1; + + /* PNG: 89 50 4E 47 0D 0A 1A 0A */ + if (n >= 8 && d[0] == 0x89 && d[1] == 'P' && d[2] == 'N' && d[3] == 'G' && + d[4] == 0x0D && d[5] == 0x0A && d[6] == 0x1A && d[7] == 0x0A) { + *ext = "png"; *mime = "image/png"; + return 0; + } + /* JPEG: FF D8 FF */ + if (n >= 3 && d[0] == 0xFF && d[1] == 0xD8 && d[2] == 0xFF) { + *ext = "jpg"; *mime = "image/jpeg"; + return 0; + } + /* GIF: "GIF87a" / "GIF89a" */ + if (n >= 6 && memcmp(d, "GIF8", 4) == 0 && (d[4] == '7' || d[4] == '9') && + d[5] == 'a') { + *ext = "gif"; *mime = "image/gif"; + return 0; + } + /* WebP: "RIFF" + 4 字节长度 + "WEBP" */ + if (n >= 12 && memcmp(d, "RIFF", 4) == 0 && memcmp(d + 8, "WEBP", 4) == 0) { + *ext = "webp"; *mime = "image/webp"; + return 0; + } + return -1; /* 不认识的格式,交给上层把话说清楚 */ +} + +char *image_backup_dir(const char *bindir) +{ + return xasprintf("%s/ligent.archive/images", + bindir && *bindir ? bindir : "."); +} + +char *image_backup_path(const char *bindir, const char *name) +{ + char *dir = image_backup_dir(bindir); + char *p = xasprintf("%s/%s", dir, name); + free(dir); + return p; +} + +/* 名字里的随机后缀由 util.c 的 random_alnum 提供(和存档名共用一份实现) */ + +char *image_backup_save(const char *bindir, const unsigned char *data, size_t n, + const char *ext) +{ + char *dir = image_backup_dir(bindir); + if (mkdir_p(dir) != 0) { free(dir); return NULL; } + + /* <上传 UNIX 时间戳>_<4 位随机>.。名字里带时间戳是为了能一眼看出 + * 这张图是什么时候传的,跟 file_id 的有效期对得上。 */ + for (int attempt = 0; attempt < 64; attempt++) { + char sfx[5]; + random_alnum(sfx, 4); + char *name = xasprintf("%ld_%s.%s", (long)time(NULL), sfx, ext); + char *path = xasprintf("%s/%s", dir, name); + + if (access(path, F_OK) != 0) { + FILE *f = fopen(path, "wb"); + if (!f) { free(path); free(name); free(dir); return NULL; } + size_t w = fwrite(data, 1, n, f); + int ok = (w == n) && (fclose(f) == 0); + free(path); + free(dir); + if (!ok) { free(name); return NULL; } + return name; + } + free(path); + free(name); + } + free(dir); + return NULL; +} + +/* ------------------------------------------------------------------ */ +/* 请求前展开:ligent_image -> image_url + base64 */ +/* ------------------------------------------------------------------ */ + +/* 内部块类型。带 ligent_ 前缀是为了万一漏了展开、直接发出去时, + * 服务端报的错能看出是我们的问题。 */ +#define PART_IMAGE "ligent_image" +#define PART_NOTE "ligent_note" + +/* 块是不是某种内部类型 */ +static int part_is(const JsonValue *part, const char *type) +{ + const char *t = json_get_string(part, "type"); + return t && strcmp(t, type) == 0; +} + +/* 造一个降级说明块。text 会被同时用在存储形态和发送形态里 —— + * 存储用 ligent_note(opens_round 不认它,附件消息不会因此变成"一轮"), + * 发送时再换成协议认的 text。 */ +static JsonValue *make_note(const char *text) +{ + JsonValue *n = json_new_object(); + json_object_set_string(n, "type", PART_NOTE); + json_object_set_string(n, "text", text); + return n; +} + +/* 存储形态 -> 发送形态。只处理内部块,别的原样克隆。 */ +static JsonValue *to_wire_part(const JsonValue *part) +{ + if (part_is(part, PART_NOTE)) { + JsonValue *w = json_new_object(); + json_object_set_string(w, "type", "text"); + json_object_set_string(w, "text", json_get_string(part, "text")); + return w; + } + /* 已经是协议形态(比如用户自己塞进来的 text / image_url)就照抄 */ + return json_clone(part); +} + +/* 读一份备份并编码成 data URL。失败返回 NULL,*why 给出给人看的理由。 */ +static char *backup_to_data_url(const char *name, size_t *budget, const char **why) +{ + const char *bindir = image_bindir(); + char *path = image_backup_path(bindir, name); + size_t len = 0; + char *raw = read_entire_file(path, &len); + if (!raw) { + free(path); + *why = "本机备份读不到了"; + return NULL; + } + free(path); + + const char *ext = NULL, *mime = NULL; + if (image_sniff((const unsigned char *)raw, len, &ext, &mime) != 0) { + free(raw); + *why = "本机备份的内容已经不是图片了"; + return NULL; + } + + /* 体积要先算再编码:编码本身就要吃一份内存,超限的话没必要做 */ + size_t b64len = base64_encoded_len(len); + if (b64len > IMAGE_MAX_B64) { + free(raw); + *why = "图片太大"; + return NULL; + } + if (b64len > *budget) { + free(raw); + *why = "本次请求里图片总量超过上限"; + return NULL; + } + *budget -= b64len; + + char *b64 = base64_encode((const unsigned char *)raw, len); + free(raw); + if (!b64) { *why = "编码失败"; return NULL; } + + char *url = xasprintf("data:%s;base64,%s", mime, b64); + free(b64); + return url; +} + +JsonValue *image_expand_for_request(JsonValue *messages, int *degraded) +{ + if (degraded) *degraded = 0; + if (!messages || messages->type != JSON_ARRAY) return NULL; + + size_t budget = IMAGE_MAX_TOTAL_B64; + JsonValue *wire = json_new_array(); + + for (size_t i = 0; i < json_array_size(messages); i++) { + JsonValue *m = json_array_get(messages, i); + JsonValue *c = json_object_get(m, "content"); + if (!c || c->type != JSON_ARRAY) { + json_array_push(wire, json_clone(m)); + continue; + } + + JsonValue *wc = json_new_array(); + for (size_t k = 0; k < json_array_size(c); k++) { + JsonValue *part = json_array_get(c, k); + + if (!part_is(part, PART_IMAGE)) { + json_array_push(wc, to_wire_part(part)); + continue; + } + + const char *name = json_get_string(part, "backup"); + const char *why = NULL; + char *url = NULL; + if (name && *name) { + url = backup_to_data_url(name, &budget, &why); + } else { + why = "引用里没有备份名"; + } + + if (url) { + JsonValue *w = json_new_object(); + json_object_set_string(w, "type", "image_url"); + JsonValue *iu = json_new_object(); + json_object_set_string(iu, "url", url); + json_object_set(w, "image_url", iu); + json_array_push(wc, w); + free(url); + continue; + } + + /* 展开不出来:把历史里这条坏引用就地换成文字说明, + * 否则它每轮都会再失败一次。 */ + char *text = xasprintf("[图片 %s 无法交给模型:%s]", + name && *name ? name : "?", why); + JsonValue *note = make_note(text); + json_array_push(wc, to_wire_part(note)); + + json_array_remove(c, k); + json_array_insert(c, k, note); /* 存储形态就地降级 */ + if (degraded) (*degraded)++; + free(text); + } + + JsonValue *wm = json_clone(m); + json_object_remove(wm, "content"); + json_object_set(wm, "content", wc); + json_array_push(wire, wm); + } + return wire; +} + +char *image_backup_from_path(const char *path, size_t *out_len, + const char **out_mime, char **out_msg) +{ + if (out_len) *out_len = 0; + if (out_mime) *out_mime = NULL; + if (out_msg) *out_msg = NULL; + + size_t len = 0; + char *raw = read_entire_file(path, &len); + if (!raw) { + if (out_msg) *out_msg = xasprintf("读不到图片 %s:%s", path, strerror(errno)); + return NULL; + } + + const char *ext = NULL, *mime = NULL; + 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); + 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。" + "(图片会留在对话历史里,所以这里当场拒掉,而不是发出去等报错。" + "如果要看大图,请先缩小尺寸或压缩后再读。)", + path, (double)len / 1048576.0, (double)b64len / 1048576.0, + (double)IMAGE_MAX_B64 / 1048576.0); + return NULL; + } + + const char *bindir = image_bindir(); + char *backup = image_backup_save(bindir, (const unsigned char *)raw, len, ext); + free(raw); + if (!backup) { + if (out_msg) *out_msg = xasprintf("无法在 %s 下写备份", bindir); + return NULL; + } + + if (out_len) *out_len = len; + if (out_mime) *out_mime = mime; + return backup; +} + +/* ------------------------------------------------------------------ */ +/* 服务端 4xx 之后的降级 */ +/* ------------------------------------------------------------------ */ + +int image_has_refs(const JsonValue *messages) +{ + if (!messages || messages->type != JSON_ARRAY) return 0; + for (size_t i = 0; i < json_array_size(messages); i++) { + JsonValue *c = json_object_get(json_array_get(messages, i), "content"); + if (!c || c->type != JSON_ARRAY) continue; + for (size_t k = 0; k < json_array_size(c); k++) + if (part_is(json_array_get(c, k), PART_IMAGE)) return 1; + } + return 0; +} + +int image_degrade_all(JsonValue *messages) +{ + if (!messages || messages->type != JSON_ARRAY) return 0; + + int n = 0; + for (size_t i = 0; i < json_array_size(messages); i++) { + JsonValue *c = json_object_get(json_array_get(messages, i), "content"); + if (!c || c->type != JSON_ARRAY) continue; + for (size_t k = 0; k < json_array_size(c); k++) { + JsonValue *part = json_array_get(c, k); + if (!part_is(part, PART_IMAGE)) continue; + + /* 给模型看的话要能直接照做,所以不把服务端的原始报错抄进来 —— + * 那通常是给人看的英文错误码。原始原因由 agent.c 打到 stderr, + * 想查的人看得到,上下文里不脏。 */ + const char *name = json_get_string(part, "backup"); + char *text = xasprintf( + "[图片 %s 不符合 API 所需的格式标准,无法上传,请规范化图片后重试]", + name && *name ? name : "?"); + JsonValue *note = make_note(text); + free(text); + + json_array_remove(c, k); + json_array_insert(c, k, note); + n++; + } + } + return n; +} + +/* ------------------------------------------------------------------ */ +/* 备份根目录 */ +/* ------------------------------------------------------------------ */ + +static char *g_bindir; + +void image_set_bindir(const char *dir) +{ + free(g_bindir); + g_bindir = (dir && *dir) ? xstrdup(dir) : xstrdup("."); +} + +const char *image_bindir(void) { return g_bindir ? g_bindir : "."; } + +/* ------------------------------------------------------------------ */ +/* 待发附件 */ +/* ------------------------------------------------------------------ */ + +#define IMAGE_PENDING_MAX 8 +static char *g_pending[IMAGE_PENDING_MAX]; +static size_t g_pending_n; + +void image_pending_add(const char *backup) +{ + if (!backup || !*backup) return; + if (g_pending_n >= IMAGE_PENDING_MAX) return; /* 一次读 8 张够多了 */ + g_pending[g_pending_n++] = xstrdup(backup); +} + +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(); + json_object_set_string(part, "type", PART_IMAGE); + json_object_set_string(part, "backup", g_pending[i]); + json_array_push(arr, part); + free(g_pending[i]); + g_pending[i] = NULL; + } + g_pending_n = 0; + return arr; +} diff --git a/src/image.h b/src/image.h new file mode 100644 index 0000000..575acfa --- /dev/null +++ b/src/image.h @@ -0,0 +1,108 @@ +/* image.h - 图片:格式嗅探、本地备份、请求前展开 + * + * 协议层面图片只有两条路能走:给一个外部 URL,或者把 base64 内联进请求。 + * ligent 不替用户去网上抓图,所以 read_image 只收本地文件,产出 base64 内联。 + * + * 但 base64 内联有个直接后果:图片字节会躺在会话历史里,每轮请求都重发一遍。 + * 归档、/history、/log 全都会跟着膨胀。所以历史的**存储形态**和**发送形态** + * 是分开的: + * + * 存进历史 {"type":"ligent_image","backup":"1700000000_a1b2.png"} + * 发给服务端 {"type":"image_url","image_url":{"url":"data:image/png;base64,...."}} + * + * 也就是说历史里只有对本地备份的引用(几十字节),备份文件才是字节的真身, + * 真正构造请求时才读出来编码。备份目录是 /ligent.archive/images/。 + * + * 两个内部块类型都带 ligent_ 前缀,就是为了万一哪天漏了展开、直接把它发出去, + * 服务端报的错能一眼看出是我们的问题,而不是一个莫名其妙的 400: + * ligent_image 引用一张本地备份 + * ligent_note 展开失败后的文字说明(引用读不到了、或体积超了) + */ +#ifndef LIGENT_IMAGE_H +#define LIGENT_IMAGE_H + +#include "json.h" + +#include + +/* ---- 内联图片的上限 ---- + * + * 取各家文档里最严的一档(ligent 同时对着 DeepSeek 和 MiMo 跑,两边都发了 + * 文档里的数字): + * DeepSeek:请求体 48 MiB;单张(base64 / 外部 URL)32 MiB + * MiMo :base64 字符串 50 MB + * 于是单张按 32 MiB 的 **base64 字符串** 算 —— 注意是编码后的长度,一张 + * 24 MiB 的原图编码后就已经超了。宁可在这里拒掉,也别发出去等一个 400: + * 图片是躺在历史里的,一次超限会变成每轮都超限。 + */ +#define IMAGE_MAX_B64 (32u * 1024 * 1024) +/* 一个请求里所有内联图片的 base64 总量。48 MiB 的请求体还要装提示词、历史、 + * 工具描述,给它们留 4 MiB。这条是兜底:read_image 一次最多挂 8 张, + * 每张都卡在 32 MiB 的话,加起来早就冲爆请求体了。 */ +#define IMAGE_MAX_TOTAL_B64 (44u * 1024 * 1024) + +/* 支持的四种格式。认出来返回 0,并回填扩展名与 MIME(都是字面量,不用释放)。 + * 按文件实际内容判断,不看文件名 —— 服务端也是这么判的。 */ +int image_sniff(const unsigned char *data, size_t n, + const char **ext, const char **mime); + +/* 备份目录:/ligent.archive/images(malloc) */ +char *image_backup_dir(const char *bindir); + +/* 备份文件所在目录下的完整路径(malloc)。name 是 image_backup_save 返回的名字 */ +char *image_backup_path(const char *bindir, const char *name); + +/* 把一段图片字节存成备份,返回文件名(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,启动时设一次即可。 */ +void image_set_bindir(const char *dir); +const char *image_bindir(void); + +/* 读一个本地图片文件:按内容认格式、判体积、落备份。 + * + * 成功返回备份文件名(malloc),*out_len 回填原始字节数、*out_mime 回填 MIME + * (字面量)、*out_msg 置 NULL。 + * 失败返回 NULL,*out_msg 是一句能直接给模型/用户看的中文说明(malloc)。 + * + * 体积超限的图**不备份** —— 一张发不出去的图,留着备份没有意义。 + * read_image 和 /include 都走这一条,免得两处的判断慢慢长歪。 */ +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 消息带上它。 + * + * 取走即清空。返回 content 块数组(调用者释放),没有则返回 NULL。 */ +void image_pending_add(const char *backup); +JsonValue *image_pending_take(void); + +/* ---- 请求前展开 ---- + * + * 把历史里的 ligent_image 块换成 image_url + base64,返回一份**请求副本** + * (调用者 json_free)。原 messages 不变,除非有引用展开不出来 —— 那种情况 + * 会**就地**把它降级成 ligent_note,把坏引用从历史里清掉。 + * + * 为什么就地降级:那条引用躺在历史里,不修的话每一轮都会重新失败一次, + * 用户除了 /reset 没有别的路。这和上下文超限要丢老轮次是同一个道理。 + * + * *degraded(可传 NULL)回填就地降级的处数,调用方据此提示用户。 + * 返回 NULL 表示分配失败或 messages 不是数组。 */ +JsonValue *image_expand_for_request(JsonValue *messages, int *degraded); + +/* 历史里还有没有未展开的图片引用。给"服务端 4xx 了,是不是图片惹的"这种 + * 判断用 —— 没有图片就别瞎猜。 */ +int image_has_refs(const JsonValue *messages); + +/* 把历史里所有图片引用就地降级成文字说明("不符合 API 所需的格式标准, + * 无法上传,请规范化图片后重试"),返回处理处数。 + * 不给理由参数:给模型的话要能直接照做,服务端的原始英文报错由调用方打到 + * stderr,不往上下文里塞。 */ +int image_degrade_all(JsonValue *messages); + +#endif /* LIGENT_IMAGE_H */ diff --git a/src/json.c b/src/json.c new file mode 100644 index 0000000..ed5221e --- /dev/null +++ b/src/json.c @@ -0,0 +1,789 @@ +/* json.c - 手写递归下降 JSON 解析器 + 构造器 + 序列化器 */ +#include "json.h" +#include "i18n.h" +#include "util.h" + +#include +#include +#include +#include +#include + +#define JSON_MAX_DEPTH 200 + +/* ------------------------------------------------------------------ */ +/* 构造 */ +/* ------------------------------------------------------------------ */ + +static JsonValue *jv_new(JsonType t) +{ + JsonValue *v = xcalloc(1, sizeof(JsonValue)); + v->type = t; + return v; +} + +JsonValue *json_new_null(void) { return jv_new(JSON_NULL); } +JsonValue *json_new_array(void) { return jv_new(JSON_ARRAY); } +JsonValue *json_new_object(void) { return jv_new(JSON_OBJECT); } + +JsonValue *json_new_bool(int b) +{ + JsonValue *v = jv_new(JSON_BOOL); + v->u.boolean = b ? 1 : 0; + return v; +} + +JsonValue *json_new_number(double n) +{ + JsonValue *v = jv_new(JSON_NUMBER); + v->u.number = n; + return v; +} + +JsonValue *json_new_string(const char *s) +{ + JsonValue *v = jv_new(JSON_STRING); + v->u.string = xstrdup(s ? s : ""); + return v; +} + +void json_array_insert(JsonValue *arr, size_t i, JsonValue *item) +{ + if (!arr || arr->type != JSON_ARRAY) { json_free(item); return; } + if (i > arr->u.array.count) i = arr->u.array.count; + if (arr->u.array.count == arr->u.array.cap) { + arr->u.array.cap = arr->u.array.cap ? arr->u.array.cap * 2 : 8; + arr->u.array.items = xrealloc(arr->u.array.items, + arr->u.array.cap * sizeof(JsonValue *)); + } + memmove(arr->u.array.items + i + 1, arr->u.array.items + i, + (arr->u.array.count - i) * sizeof(JsonValue *)); + arr->u.array.items[i] = item; + arr->u.array.count++; +} + +void json_array_steal(JsonValue *dst, JsonValue *src) +{ + if (!dst || dst->type != JSON_ARRAY || !src || src->type != JSON_ARRAY) return; + if (dst == src) return; + for (size_t i = 0; i < src->u.array.count; i++) { + if (dst->u.array.count == dst->u.array.cap) { + dst->u.array.cap = dst->u.array.cap ? dst->u.array.cap * 2 : 8; + dst->u.array.items = xrealloc(dst->u.array.items, + dst->u.array.cap * sizeof(JsonValue *)); + } + dst->u.array.items[dst->u.array.count++] = src->u.array.items[i]; + } + free(src->u.array.items); + src->u.array.items = NULL; + src->u.array.count = 0; + src->u.array.cap = 0; +} + +void json_array_remove(JsonValue *arr, size_t i) +{ + if (!arr || arr->type != JSON_ARRAY || i >= arr->u.array.count) return; + json_free(arr->u.array.items[i]); + memmove(arr->u.array.items + i, arr->u.array.items + i + 1, + (arr->u.array.count - i - 1) * sizeof(JsonValue *)); + arr->u.array.count--; +} + +void json_array_push(JsonValue *arr, JsonValue *item) +{ + if (!arr || arr->type != JSON_ARRAY) { json_free(item); return; } + if (arr->u.array.count == arr->u.array.cap) { + arr->u.array.cap = arr->u.array.cap ? arr->u.array.cap * 2 : 8; + arr->u.array.items = xrealloc(arr->u.array.items, + arr->u.array.cap * sizeof(JsonValue *)); + } + arr->u.array.items[arr->u.array.count++] = item; +} + +void json_object_remove(JsonValue *obj, const char *key) +{ + if (!obj || obj->type != JSON_OBJECT) return; + for (size_t i = 0; i < obj->u.object.count; i++) { + if (strcmp(obj->u.object.members[i].key, key) == 0) { + free(obj->u.object.members[i].key); + json_free(obj->u.object.members[i].value); + memmove(&obj->u.object.members[i], &obj->u.object.members[i + 1], + (obj->u.object.count - i - 1) * sizeof(JsonMember)); + obj->u.object.count--; + return; + } + } +} + +void json_object_set(JsonValue *obj, const char *key, JsonValue *value) +{ + if (!obj || obj->type != JSON_OBJECT) { json_free(value); return; } + for (size_t i = 0; i < obj->u.object.count; i++) { + if (strcmp(obj->u.object.members[i].key, key) == 0) { + json_free(obj->u.object.members[i].value); + obj->u.object.members[i].value = value; + return; + } + } + if (obj->u.object.count == obj->u.object.cap) { + obj->u.object.cap = obj->u.object.cap ? obj->u.object.cap * 2 : 8; + obj->u.object.members = xrealloc(obj->u.object.members, + obj->u.object.cap * sizeof(JsonMember)); + } + obj->u.object.members[obj->u.object.count].key = xstrdup(key); + obj->u.object.members[obj->u.object.count].value = value; + obj->u.object.count++; +} + +void json_object_set_string(JsonValue *obj, const char *key, const char *value) +{ json_object_set(obj, key, value ? json_new_string(value) : json_new_null()); } + +void json_object_set_number(JsonValue *obj, const char *key, double value) +{ json_object_set(obj, key, json_new_number(value)); } + +void json_object_set_bool(JsonValue *obj, const char *key, int value) +{ json_object_set(obj, key, json_new_bool(value)); } + +void json_object_set_null(JsonValue *obj, const char *key) +{ json_object_set(obj, key, json_new_null()); } + +/* ------------------------------------------------------------------ */ +/* 释放 */ +/* ------------------------------------------------------------------ */ + +void json_free(JsonValue *v) +{ + if (!v) return; + switch (v->type) { + case JSON_STRING: + free(v->u.string); + break; + case JSON_ARRAY: + for (size_t i = 0; i < v->u.array.count; i++) + json_free(v->u.array.items[i]); + free(v->u.array.items); + break; + case JSON_OBJECT: + for (size_t i = 0; i < v->u.object.count; i++) { + free(v->u.object.members[i].key); + json_free(v->u.object.members[i].value); + } + free(v->u.object.members); + break; + default: + break; + } + free(v); +} + +JsonValue *json_clone(const JsonValue *v) +{ + if (!v) return NULL; + switch (v->type) { + case JSON_NULL: return json_new_null(); + case JSON_BOOL: return json_new_bool(v->u.boolean); + case JSON_NUMBER: return json_new_number(v->u.number); + case JSON_STRING: return json_new_string(v->u.string); + case JSON_ARRAY: { + JsonValue *a = json_new_array(); + for (size_t i = 0; i < v->u.array.count; i++) + json_array_push(a, json_clone(v->u.array.items[i])); + return a; + } + case JSON_OBJECT: { + JsonValue *o = json_new_object(); + for (size_t i = 0; i < v->u.object.count; i++) + json_object_set(o, v->u.object.members[i].key, + json_clone(v->u.object.members[i].value)); + return o; + } + } + return json_new_null(); +} + +/* ------------------------------------------------------------------ */ +/* 读取 */ +/* ------------------------------------------------------------------ */ + +JsonValue *json_object_get(const JsonValue *obj, const char *key) +{ + if (!obj || obj->type != JSON_OBJECT || !key) return NULL; + for (size_t i = 0; i < obj->u.object.count; i++) + if (strcmp(obj->u.object.members[i].key, key) == 0) + return obj->u.object.members[i].value; + return NULL; +} + +const char *json_string_value(const JsonValue *v) +{ + if (!v || v->type != JSON_STRING) return NULL; + return v->u.string; +} + +double json_number_value(const JsonValue *v, double def) +{ + if (!v || v->type != JSON_NUMBER) return def; + return v->u.number; +} + +int json_bool_value(const JsonValue *v, int def) +{ + if (!v) return def; + if (v->type == JSON_BOOL) return v->u.boolean; + if (v->type == JSON_NUMBER) return v->u.number != 0; + return def; +} + +size_t json_array_size(const JsonValue *v) +{ + if (!v || v->type != JSON_ARRAY) return 0; + return v->u.array.count; +} + +JsonValue *json_array_get(const JsonValue *v, size_t i) +{ + if (!v || v->type != JSON_ARRAY || i >= v->u.array.count) return NULL; + return v->u.array.items[i]; +} + +const char *json_get_string(const JsonValue *obj, const char *key) +{ return json_string_value(json_object_get(obj, key)); } + +double json_get_number(const JsonValue *obj, const char *key, double def) +{ return json_number_value(json_object_get(obj, key), def); } + +int json_get_bool(const JsonValue *obj, const char *key, int def) +{ return json_bool_value(json_object_get(obj, key), def); } + +/* ------------------------------------------------------------------ */ +/* 解析器 */ +/* ------------------------------------------------------------------ */ + +typedef struct { + const char *start; /* 原文起点,用来算出行列号 */ + const char *p; + const char *end; + char *err; + size_t errlen; + int depth; + int ui; /* 1 = 报错跟随界面语言(见 jfmt) */ +} Parser; + +/* 取一条诊断的模板。ui 为 0 时固定用中文那份 —— 解析器的"母语"是中文, + * 而它同时出现在**给模型看的**工具参数报错里(agent.c 把 json_parse 的 + * 结论原样回填给模型),那些不该随界面语言变,见 README「界面语言」。 + * + * 表里的 json.* 用的是 printf 转换(%s/%c/%zu…)而不是 {name} —— 这里是 + * 格式化转发,不是占位符替换。两种语言的转换符序列必须逐字一致, + * tests/test_i18n.c 会机械地比一遍。 */ +static const char *jfmt(int ui, const char *key) +{ + if (ui) return t(key); + const char *zh = i18n_lookup(LIGENT_LANG_ZH, key); + return zh ? zh : key; +} + +/* 只记录第一个错误;带上行列号和出错处的原文片段, + * 否则用户只知道"语法错了"却不知道错在哪 —— 手写 JSON 时这几乎是必然的。 */ +LIGENT_PRINTF(2, 3) static void perr(Parser *ps, const char *fmt, ...) +{ + if (!ps->err || ps->errlen == 0 || ps->err[0] != '\0') return; + + size_t line = 1, col = 1; + for (const char *q = ps->start; q < ps->p && q < ps->end; q++) { + if (*q == '\n') { line++; col = 1; } + else col++; + } + + char detail[192]; + va_list ap; + va_start(ap, 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++) { + unsigned char c = (unsigned char)*q; + if (c == '\n') break; + excerpt[n++] = c < 0x20 || c == 0x7f ? '?' : (char)c; + } + excerpt[n] = '\0'; + + /* 模板里是同序的 %zu/%zu/%s(+%s)—— 两种语言必须完全一致 */ + if (n > 0) + snprintf(ps->err, ps->errlen, jfmt(ps->ui, "json.err.at_got"), + line, col, detail, excerpt); + else + snprintf(ps->err, ps->errlen, jfmt(ps->ui, "json.err.at_end"), + line, col, detail); + + /* 最后统一净化:错误信息里不允许出现原始控制字符。 + * 像 "意外的字符 '%c'" 这种格式化会直接把控制字符带进消息本体, + * 原样打到终端上会把用户的屏幕搞乱。 */ + for (char *q = ps->err; *q; q++) { + unsigned char c = (unsigned char)*q; + if (c < 0x20 || c == 0x7f) *q = '?'; + } +} + +static void skip_ws(Parser *ps) +{ + while (ps->p < ps->end) { + char c = *ps->p; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ps->p++; + else break; + } +} + +static JsonValue *parse_value(Parser *ps); + +/* 解析一个 JSON 字符串内容(ps->p 指向起始双引号之后),写入 sb */ +static int parse_string_raw(Parser *ps, StrBuf *sb, char *errb, size_t errlen) +{ + (void)errb; (void)errlen; + while (ps->p < ps->end) { + unsigned char c = (unsigned char)*ps->p++; + if (c == '"') return 0; + if (c == '\\') { + if (ps->p >= ps->end) { perr(ps, "%s", jfmt(ps->ui, "json.err.string_escape_truncated")); return -1; } + char e = *ps->p++; + switch (e) { + case '"': sb_putc(sb, '"'); break; + case '\\': sb_putc(sb, '\\'); break; + case '/': sb_putc(sb, '/'); break; + case 'b': sb_putc(sb, '\b'); break; + case 'f': sb_putc(sb, '\f'); break; + case 'n': sb_putc(sb, '\n'); break; + case 'r': sb_putc(sb, '\r'); break; + case 't': sb_putc(sb, '\t'); break; + case 'u': { + unsigned int cp = 0; + if (ps->end - ps->p < 4) { perr(ps, "%s", jfmt(ps->ui, "json.err.u_incomplete")); return -1; } + for (int i = 0; i < 4; i++) { + char h = ps->p[i]; + cp <<= 4; + if (h >= '0' && h <= '9') cp |= (unsigned)(h - '0'); + else if (h >= 'a' && h <= 'f') cp |= (unsigned)(h - 'a' + 10); + else if (h >= 'A' && h <= 'F') cp |= (unsigned)(h - 'A' + 10); + else { perr(ps, jfmt(ps->ui, "json.err.u_bad_hex"), h); return -1; } + } + ps->p += 4; + /* 代理对 */ + if (cp >= 0xD800 && cp <= 0xDBFF && + ps->end - ps->p >= 6 && ps->p[0] == '\\' && ps->p[1] == 'u') { + unsigned int lo = 0; + int ok = 1; + for (int i = 0; i < 4; i++) { + char h = ps->p[2 + i]; + lo <<= 4; + if (h >= '0' && h <= '9') lo |= (unsigned)(h - '0'); + else if (h >= 'a' && h <= 'f') lo |= (unsigned)(h - 'a' + 10); + else if (h >= 'A' && h <= 'F') lo |= (unsigned)(h - 'A' + 10); + else { ok = 0; break; } + } + if (ok && lo >= 0xDC00 && lo <= 0xDFFF) { + cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00); + ps->p += 6; + } + } + /* 编码为 UTF-8 */ + if (cp < 0x80) { + sb_putc(sb, (char)cp); + } else if (cp < 0x800) { + sb_putc(sb, (char)(0xC0 | (cp >> 6))); + sb_putc(sb, (char)(0x80 | (cp & 0x3F))); + } else if (cp < 0x10000) { + sb_putc(sb, (char)(0xE0 | (cp >> 12))); + sb_putc(sb, (char)(0x80 | ((cp >> 6) & 0x3F))); + sb_putc(sb, (char)(0x80 | (cp & 0x3F))); + } else { + sb_putc(sb, (char)(0xF0 | (cp >> 18))); + sb_putc(sb, (char)(0x80 | ((cp >> 12) & 0x3F))); + sb_putc(sb, (char)(0x80 | ((cp >> 6) & 0x3F))); + sb_putc(sb, (char)(0x80 | (cp & 0x3F))); + } + break; + } + default: + perr(ps, jfmt(ps->ui, "json.err.unknown_escape"), e); + return -1; + } + } else if (c < 0x20) { + perr(ps, jfmt(ps->ui, "json.err.raw_control"), c); + return -1; + } else { + sb_putc(sb, (char)c); + } + } + perr(ps, "%s", jfmt(ps->ui, "json.err.string_unterminated")); + return -1; +} + +static JsonValue *parse_string(Parser *ps) +{ + StrBuf sb; + sb_init(&sb); + if (parse_string_raw(ps, &sb, NULL, 0) != 0) { + sb_free(&sb); + return NULL; + } + JsonValue *v = jv_new(JSON_STRING); + v->u.string = sb_detach(&sb); + return v; +} + +/* 严格按 RFC 8259 扫描一个数字: + * -?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)? + * + * 以前这里是"能吃多少吃多少",于是 01、5.、1e、1e+ 全都被当成合法数字 —— + * 而 strtod("1e") 会老老实实返回 1,那个残缺的指数就被静默吞掉了。 + * 配置文件和存档都是手写的,把写错的数字当成另一个数继续用下去,用户只会在 + * 很久之后从一个完全不相干的现象里发现它。所以语法必须自己严查一遍: + * strtod 只负责最后那步文本 -> double 的转换。 */ +static JsonValue *parse_number(Parser *ps) +{ + const char *start = ps->p; + + 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 非法) */ + else while (ps->p < ps->end && isdigit((unsigned char)*ps->p)) ps->p++; + + if (ps->p < ps->end && *ps->p == '.') { + ps->p++; + if (ps->p >= ps->end || !isdigit((unsigned char)*ps->p)) goto bad; + while (ps->p < ps->end && isdigit((unsigned char)*ps->p)) ps->p++; + } + if (ps->p < ps->end && (*ps->p == 'e' || *ps->p == 'E')) { + ps->p++; + if (ps->p < ps->end && (*ps->p == '+' || *ps->p == '-')) ps->p++; + if (ps->p >= ps->end || !isdigit((unsigned char)*ps->p)) goto bad; + while (ps->p < ps->end && isdigit((unsigned char)*ps->p)) ps->p++; + } + + { + char *tmp = xstrndup(start, (size_t)(ps->p - start)); + char *endp = NULL; + double d = strtod(tmp, &endp); + int all = (endp && *endp == '\0'); + free(tmp); + if (!all) goto bad; /* 语法扫过了 strtod 却不认:不该发生 */ + return json_new_number(d); + } + +bad: + /* 回退到数字的起点再报错:这样行列号指向"这个数字从哪开始", + * 而不是指向我们自己刚消费掉的那个字节。 */ + { + char *tmp = xstrndup(start, (size_t)(ps->p - start)); + ps->p = start; + perr(ps, jfmt(ps->ui, "json.err.bad_number_got"), tmp); + free(tmp); + } + return NULL; +} + +/* 解析 ps->p 指向的 "key" */ +static char *parse_key(Parser *ps) +{ + if (ps->p >= ps->end || *ps->p != '"') { + perr(ps, "%s", jfmt(ps->ui, "json.err.want_key")); + return NULL; + } + ps->p++; + StrBuf sb; + sb_init(&sb); + if (parse_string_raw(ps, &sb, NULL, 0) != 0) { sb_free(&sb); return NULL; } + return sb_detach(&sb); +} + +static JsonValue *parse_array(Parser *ps) +{ + JsonValue *arr = json_new_array(); + ps->p++; /* '[' */ + skip_ws(ps); + if (ps->p < ps->end && *ps->p == ']') { ps->p++; return arr; } + for (;;) { + skip_ws(ps); + JsonValue *item = parse_value(ps); + if (!item) { json_free(arr); return NULL; } + json_array_push(arr, item); + skip_ws(ps); + if (ps->p >= ps->end) { perr(ps, "%s", jfmt(ps->ui, "json.err.array_unterminated")); json_free(arr); return NULL; } + if (*ps->p == ',') { ps->p++; continue; } + if (*ps->p == ']') { ps->p++; return arr; } + perr(ps, jfmt(ps->ui, "json.err.want_comma_in_array"), *ps->p); + json_free(arr); + return NULL; + } +} + +static JsonValue *parse_object(Parser *ps) +{ + JsonValue *obj = json_new_object(); + ps->p++; /* '{' */ + skip_ws(ps); + if (ps->p < ps->end && *ps->p == '}') { ps->p++; return obj; } + for (;;) { + skip_ws(ps); + char *key = parse_key(ps); + if (!key) { json_free(obj); return NULL; } + skip_ws(ps); + if (ps->p >= ps->end || *ps->p != ':') { + perr(ps, jfmt(ps->ui, "json.err.want_colon"), key); + free(key); + json_free(obj); + return NULL; + } + ps->p++; + skip_ws(ps); + JsonValue *val = parse_value(ps); + if (!val) { free(key); json_free(obj); return NULL; } + json_object_set(obj, key, val); + free(key); + skip_ws(ps); + if (ps->p >= ps->end) { perr(ps, "%s", jfmt(ps->ui, "json.err.object_unterminated")); json_free(obj); return NULL; } + if (*ps->p == ',') { ps->p++; continue; } + if (*ps->p == '}') { ps->p++; return obj; } + perr(ps, jfmt(ps->ui, "json.err.want_comma_in_object"), *ps->p); + json_free(obj); + return NULL; + } +} + +static JsonValue *parse_value(Parser *ps) +{ + if (++ps->depth > JSON_MAX_DEPTH) { + perr(ps, jfmt(ps->ui, "json.err.too_deep"), JSON_MAX_DEPTH); + ps->depth--; + return NULL; + } + skip_ws(ps); + JsonValue *result = NULL; + if (ps->p >= ps->end) { + perr(ps, "%s", jfmt(ps->ui, "json.err.unexpected_eof")); + goto done; + } + switch (*ps->p) { + case '{': result = parse_object(ps); break; + case '[': result = parse_array(ps); break; + case '"': ps->p++; result = parse_string(ps); break; + case 't': + if (ps->end - ps->p >= 4 && memcmp(ps->p, "true", 4) == 0) { + ps->p += 4; + result = json_new_bool(1); + } else perr(ps, "%s", jfmt(ps->ui, "json.err.want_true")); + break; + case 'f': + if (ps->end - ps->p >= 5 && memcmp(ps->p, "false", 5) == 0) { + ps->p += 5; + result = json_new_bool(0); + } else perr(ps, "%s", jfmt(ps->ui, "json.err.want_false")); + break; + case 'n': + if (ps->end - ps->p >= 4 && memcmp(ps->p, "null", 4) == 0) { + ps->p += 4; + result = json_new_null(); + } else perr(ps, "%s", jfmt(ps->ui, "json.err.want_null")); + break; + default: + if (*ps->p == '-' || isdigit((unsigned char)*ps->p)) + result = parse_number(ps); + else + perr(ps, jfmt(ps->ui, "json.err.unexpected_char"), *ps->p); + break; + } +done: + ps->depth--; + return result; +} + +/* 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'; + if (!text) { + if (errbuf && errlen) + snprintf(errbuf, errlen, "%s", jfmt(ui, "json.err.empty_input")); + return NULL; + } + Parser ps; + ps.start = text; + ps.p = text; + ps.end = text + strlen(text); + ps.err = errbuf; + ps.errlen = errlen; + ps.depth = 0; + ps.ui = ui; + + JsonValue *v = parse_value(&ps); + if (!v) return NULL; + skip_ws(&ps); + if (ps.p != ps.end) { + perr(&ps, jfmt(ps.ui, "json.err.trailing"), ps.p); + json_free(v); + return NULL; + } + return v; +} + +JsonValue *json_parse(const char *text, char *errbuf, size_t errlen) +{ + return parse_impl(text, errbuf, errlen, 0); +} + +JsonValue *json_parse_ui(const char *text, char *errbuf, size_t errlen) +{ + return parse_impl(text, errbuf, errlen, 1); +} + +/* ------------------------------------------------------------------ */ +/* 序列化 */ +/* ------------------------------------------------------------------ */ + +static void dump_string(StrBuf *sb, const char *s) +{ + size_t len = strlen(s), i = 0; + sb_putc(sb, '"'); + while (i < len) { + unsigned char c = (unsigned char)s[i]; + const char *esc = NULL; + switch (c) { + case '"': esc = "\\\""; break; + case '\\': esc = "\\\\"; break; + case '\b': esc = "\\b"; break; + case '\f': esc = "\\f"; break; + case '\n': esc = "\\n"; break; + case '\r': esc = "\\r"; break; + case '\t': esc = "\\t"; break; + default: break; + } + if (esc) { sb_puts(sb, esc); i++; continue; } + 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,服务端直接回 + * 400 ... invalid unicode code point + * 更糟的是那段坏数据已经进了对话历史,之后每次请求都会失败 —— + * 除非 /reset 把整轮对话丢掉。 + * + * 兜在出口而不是只在入口清理,是因为出口才是能保证"发出去的永远合法" + * 的那一层:对话内容还可能来自存档、粘贴、/include 进来的文件…… + * 这里统一把非法字节换成 U+FFFD,既让请求成立,也让模型看到"这里有个 + * 无法解码的字符",而不是收到一段乱码。 */ + size_t n = utf8_seq_len(s + i, len - i); + if (n == 0) { sb_puts(sb, "\\ufffd"); i++; continue; } + /* U+FFFD 一律写成 \ufffd 转义。它是上面替换坏字节时用的字符,如果这里 + * 让 EF BF BD 原样输出,dump→parse→dump 就不稳定了:第一次是 \ufffd, + * 解析回来变成真的 U+FFFD,第二次就原样吐三个字节。 + * fuzz 的"dump 幂等"这条不变量就是被这个当场抓住的 —— 幂等是有价值的 + * 性质,它保证存档反复读写在字节层面也是收敛的。给这一个字符定死一个 + * 规范形式,代价是真正合法的 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"); + i += 3; + continue; + } + sb_append(sb, s + i, n); + i += n; + } + sb_putc(sb, '"'); +} + +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 抓不到)。 */ + if (fabs(n) < 1e15 && n == (double)(long long)n) + snprintf(buf, sizeof buf, "%lld", (long long)n); + else + snprintf(buf, sizeof buf, "%.17g", n); + sb_puts(sb, buf); +} + +static void dump_value(StrBuf *sb, const JsonValue *v, int indent, int pretty, int level) +{ + if (!v) { sb_puts(sb, "null"); return; } + + switch (v->type) { + case JSON_NULL: sb_puts(sb, "null"); break; + case JSON_BOOL: sb_puts(sb, v->u.boolean ? "true" : "false"); break; + case JSON_NUMBER: dump_number(sb, v->u.number); break; + case JSON_STRING: dump_string(sb, v->u.string); break; + + case JSON_ARRAY: + if (v->u.array.count == 0) { sb_puts(sb, "[]"); break; } + sb_putc(sb, '['); + for (size_t i = 0; i < v->u.array.count; i++) { + if (i) sb_putc(sb, ','); + if (pretty) { + sb_putc(sb, '\n'); + for (int k = 0; k < (level + 1) * indent; k++) sb_putc(sb, ' '); + } + dump_value(sb, v->u.array.items[i], indent, pretty, level + 1); + } + if (pretty) { + sb_putc(sb, '\n'); + for (int k = 0; k < level * indent; k++) sb_putc(sb, ' '); + } + sb_putc(sb, ']'); + break; + + case JSON_OBJECT: + if (v->u.object.count == 0) { sb_puts(sb, "{}"); break; } + sb_putc(sb, '{'); + for (size_t i = 0; i < v->u.object.count; i++) { + if (i) sb_putc(sb, ','); + if (pretty) { + sb_putc(sb, '\n'); + for (int k = 0; k < (level + 1) * indent; k++) sb_putc(sb, ' '); + } + dump_string(sb, v->u.object.members[i].key); + sb_putc(sb, ':'); + if (pretty) sb_putc(sb, ' '); + dump_value(sb, v->u.object.members[i].value, indent, pretty, level + 1); + } + if (pretty) { + sb_putc(sb, '\n'); + for (int k = 0; k < level * indent; k++) sb_putc(sb, ' '); + } + sb_putc(sb, '}'); + break; + } +} + +char *json_dump(const JsonValue *v) +{ + StrBuf sb; + sb_init(&sb); + dump_value(&sb, v, 0, 0, 0); + return sb_detach(&sb); +} + +char *json_dump_pretty(const JsonValue *v) +{ + StrBuf sb; + sb_init(&sb); + dump_value(&sb, v, 2, 1, 0); + return sb_detach(&sb); +} + +char *json_quote_string(const char *s) +{ + StrBuf sb; + sb_init(&sb); + dump_string(&sb, s ? s : ""); + return sb_detach(&sb); +} diff --git a/src/json.h b/src/json.h new file mode 100644 index 0000000..a2e1754 --- /dev/null +++ b/src/json.h @@ -0,0 +1,97 @@ +/* json.h - 自带的 JSON 解析 / 构造 / 序列化 */ +#ifndef LIGENT_JSON_H +#define LIGENT_JSON_H + +#include + +typedef enum { + JSON_NULL, + JSON_BOOL, + JSON_NUMBER, + JSON_STRING, + JSON_ARRAY, + JSON_OBJECT +} JsonType; + +typedef struct JsonValue JsonValue; + +typedef struct { + char *key; + JsonValue *value; +} JsonMember; + +struct JsonValue { + JsonType type; + union { + int boolean; + double number; + char *string; + struct { + JsonValue **items; + size_t count; + size_t cap; + } array; + struct { + JsonMember *members; + size_t count; + size_t cap; + } object; + } u; +}; + +/* ---- 解析:失败返回 NULL 并写入 errbuf ---- */ +JsonValue *json_parse(const char *text, char *errbuf, size_t errlen); + +/* 同上,但报错跟随界面语言(给人看的配置/存档/响应报错用这个)。 + * json_parse 的报错固定中文:那些会原样回填给模型(工具参数解析), + * 属于"给模型看的信息",不随界面语言变。 */ +JsonValue *json_parse_ui(const char *text, char *errbuf, size_t errlen); + +/* ---- 释放 ---- */ +void json_free(JsonValue *v); + +/* ---- 读取 ---- */ +JsonValue *json_object_get(const JsonValue *obj, const char *key); +const char *json_string_value(const JsonValue *v); /* 非字符串返回 NULL */ +double json_number_value(const JsonValue *v, double def); +int json_bool_value(const JsonValue *v, int def); +size_t json_array_size(const JsonValue *v); +JsonValue *json_array_get(const JsonValue *v, size_t i); +/* 从对象里按 key 取值 */ +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); +JsonValue *json_new_string(const char *s); +JsonValue *json_new_array(void); +JsonValue *json_new_object(void); + +void json_array_push(JsonValue *arr, JsonValue *item); +/* 在 i 处插入(i 可等于 count,即追加);取得所有权 */ +void json_array_insert(JsonValue *arr, size_t i, JsonValue *item); +/* 删掉第 i 条并释放它。越界不动 */ +void json_array_remove(JsonValue *arr, size_t i); +/* 把 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); +void json_object_set_number(JsonValue *obj, const char *key, double value); +void json_object_set_bool(JsonValue *obj, const char *key, int value); +void json_object_set_null(JsonValue *obj, const char *key); +void json_object_remove(JsonValue *obj, const char *key); + +/* ---- 序列化:返回 malloc 的字符串 ---- */ +char *json_dump(const JsonValue *v); /* 紧凑格式 */ +char *json_dump_pretty(const JsonValue *v); /* 2 空格缩进 */ +/* 把任意 C 字符串转义成 JSON 字符串字面量(含首尾引号) */ +char *json_quote_string(const char *s); + +/* 深拷贝 */ +JsonValue *json_clone(const JsonValue *v); + +#endif /* LIGENT_JSON_H */ diff --git a/src/lineedit.c b/src/lineedit.c new file mode 100644 index 0000000..238dee4 --- /dev/null +++ b/src/lineedit.c @@ -0,0 +1,781 @@ +/* lineedit.c - REPL 行编辑器实现 */ +#include "lineedit.h" +#include "util.h" +#include "http.h" /* g_ligent_interrupt */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* ------------------------------------------------------------------ */ +/* 缓冲区操作(纯逻辑,可单测) */ +/* ------------------------------------------------------------------ */ + +void linebuf_init(LineBuf *lb) +{ + memset(lb, 0, sizeof *lb); +} + +void linebuf_free(LineBuf *lb) +{ + free(lb->buf); + memset(lb, 0, sizeof *lb); +} + +static void lb_reserve(LineBuf *lb, size_t extra) +{ + if (lb->len + extra + 1 <= lb->cap) return; + size_t cap = lb->cap ? lb->cap : 64; + while (cap < lb->len + extra + 1) cap *= 2; + lb->buf = xrealloc(lb->buf, cap); + lb->cap = cap; +} + +static void lb_terminate(LineBuf *lb) +{ + lb_reserve(lb, 0); + lb->buf[lb->len] = '\0'; +} + +void linebuf_set(LineBuf *lb, const char *text) +{ + size_t n = text ? strlen(text) : 0; + lb->len = 0; + lb_reserve(lb, n); + if (n) memcpy(lb->buf, text, n); + lb->len = n; + lb->pos = n; + lb_terminate(lb); +} + +void linebuf_insert(LineBuf *lb, const char *text, size_t n) +{ + if (!text || n == 0) return; + lb_reserve(lb, n); + memmove(lb->buf + lb->pos + n, lb->buf + lb->pos, lb->len - lb->pos); + memcpy(lb->buf + lb->pos, text, n); + lb->len += n; + lb->pos += n; + lb_terminate(lb); +} + +void linebuf_backspace(LineBuf *lb) +{ + if (lb->pos == 0) return; + size_t prev = utf8_prev(lb->buf, lb->len, lb->pos); + size_t n = lb->pos - prev; + memmove(lb->buf + prev, lb->buf + lb->pos, lb->len - lb->pos); + lb->len -= n; + lb->pos = prev; + lb_terminate(lb); +} + +void linebuf_delete(LineBuf *lb) +{ + if (lb->pos >= lb->len) return; + size_t next = utf8_next(lb->buf, lb->len, lb->pos); + size_t n = next - lb->pos; + memmove(lb->buf + lb->pos, lb->buf + next, lb->len - next); + lb->len -= n; + lb_terminate(lb); +} + +void linebuf_left(LineBuf *lb) +{ + lb->pos = utf8_prev(lb->buf, lb->len, lb->pos); +} + +void linebuf_right(LineBuf *lb) +{ + lb->pos = utf8_next(lb->buf, lb->len, lb->pos); +} + +/* Home / End 走的是"逻辑行"(两个换行之间),不是整个缓冲区。 + * 粘贴进来一段多行文本后,按 Home 应该回到当前那一行的行首, + * 而不是跳到整块的最开头 —— 后者是加多行支持时留下的毛病。 */ +void linebuf_home(LineBuf *lb) +{ + size_t i = lb->pos; + while (i > 0 && lb->buf[i - 1] != '\n') i--; + lb->pos = i; +} + +void linebuf_end(LineBuf *lb) +{ + size_t i = lb->pos; + while (i < lb->len && lb->buf[i] != '\n') i++; + lb->pos = i; +} + +void linebuf_kill_to_end(LineBuf *lb) +{ + lb->len = lb->pos; + lb_terminate(lb); +} + +void linebuf_kill_to_start(LineBuf *lb) +{ + if (lb->pos == 0) return; + memmove(lb->buf, lb->buf + lb->pos, lb->len - lb->pos); + lb->len -= lb->pos; + lb->pos = 0; + lb_terminate(lb); +} + +void linebuf_kill_word(LineBuf *lb) +{ + size_t end = lb->pos; + size_t i = end; + while (i > 0 && lb->buf[i - 1] == ' ') i--; + while (i > 0 && lb->buf[i - 1] != ' ') i--; + if (i == end) return; + memmove(lb->buf + i, lb->buf + end, lb->len - end); + lb->len -= end - i; + lb->pos = i; + lb_terminate(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) { + g_hist_cap = g_hist_cap ? g_hist_cap * 2 : 32; + g_hist = xrealloc(g_hist, g_hist_cap * sizeof(char *)); + } + g_hist[g_hist_len++] = line; +} + +int line_history_add(const char *line) +{ + if (!line || !*line) return 0; + /* 与上一条重复就不记 —— 和 bash 的 ignoredups 一个意思 */ + if (g_hist_len > 0 && strcmp(g_hist[g_hist_len - 1], line) == 0) return 0; + hist_push_owned(xstrdup(line)); + return 1; +} + +void line_history_clear(void) +{ + for (size_t i = 0; i < g_hist_len; i++) free(g_hist[i]); + free(g_hist); + g_hist = NULL; + g_hist_len = g_hist_cap = 0; +} + +size_t line_history_len(void) { return g_hist_len; } + +const char *line_history_get(size_t i) +{ + return i < g_hist_len ? g_hist[i] : NULL; +} + +/* ------------------------------------------------------------------ */ +/* 输入历史持久化 */ +/* ------------------------------------------------------------------ */ + +/* 一条历史在文件里占一行。用户输入可能含换行(行尾反斜杠续行), + * 也可能含反斜杠,所以这两个字符必须转义,否则读回来就变了样。 */ +static void hist_encode(const char *s, StrBuf *out) +{ + for (const unsigned char *p = (const unsigned char *)s; *p; p++) { + if (*p == '\\') sb_puts(out, "\\\\"); + else if (*p == '\n') sb_puts(out, "\\n"); + else sb_putc(out, (char)*p); + } +} + +/* 转义的反向。遇到不认识的组合就原样保留,宁可展示得怪一点, + * 也不要把用户的东西吃掉。 */ +static char *hist_decode(const char *s) +{ + StrBuf out; + sb_init(&out); + for (const char *p = s; *p; p++) { + if (*p != '\\') { sb_putc(&out, *p); continue; } + p++; + if (*p == '\\') sb_putc(&out, '\\'); + else if (*p == 'n') sb_putc(&out, '\n'); + else if (*p == '\0') { sb_putc(&out, '\\'); break; } + else { sb_putc(&out, '\\'); sb_putc(&out, *p); } + } + return sb_detach(&out); +} + +/* 把内存里最新的 n 条重写回文件(先写临时文件再 rename, + * 免得写到一半被杀掉就把历史弄没了)。 */ +static void hist_rewrite(const char *path, size_t n) +{ + if (g_hist_len < n) n = g_hist_len; + size_t start = g_hist_len - n; + + char *tmp = xasprintf("%s.tmp", path); + FILE *f = fopen(tmp, "w"); + if (!f) { free(tmp); return; } + + int ok = 1; + for (size_t i = start; i < g_hist_len && ok; i++) { + StrBuf sb; + sb_init(&sb); + hist_encode(g_hist[i], &sb); + if (sb.data && fwrite(sb.data, 1, sb.len, f) != sb.len) ok = 0; + if (fputc('\n', f) == EOF) ok = 0; + sb_free(&sb); + } + if (fclose(f) != 0) ok = 0; + if (ok) { + chmod(tmp, 0600); /* 历史里可能有密钥,别给同机其他用户看 */ + if (rename(tmp, path) != 0) unlink(tmp); + } else { + unlink(tmp); + } + free(tmp); +} + +int line_history_load(const char *path, size_t keep) +{ + if (!path || !*path) return 0; + FILE *f = fopen(path, "r"); + if (!f) return errno == ENOENT ? 0 : -1; + if (keep == 0) keep = 1; + + /* 只留最新的 keep 条,所以用环形缓冲边读边丢 —— 再大的历史文件 + * 也只占 keep 条的内存。 */ + char **ring = xcalloc(keep, sizeof(char *)); + size_t total = 0; + char *line = NULL; + size_t cap = 0; + ssize_t len; + int rc = 0; + + while ((len = getline(&line, &cap, f)) >= 0) { + while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) + line[--len] = '\0'; + size_t slot = total % keep; + free(ring[slot]); + ring[slot] = hist_decode(line); + total++; + } + if (ferror(f)) rc = -1; + free(line); + fclose(f); + + size_t count = total < keep ? total : keep; + for (size_t i = total - count; i < total; i++) { + size_t slot = i % keep; + hist_push_owned(ring[slot]); + ring[slot] = NULL; + } + for (size_t i = 0; i < keep; i++) free(ring[i]); + free(ring); + + /* 文件比上限长就地截短。不这么做的话它会一直长下去。 */ + if (rc == 0 && total > keep) hist_rewrite(path, keep); + return rc; +} + +int line_history_append(const char *path, const char *line) +{ + if (!path || !*path || !line || !*line) return 0; + + /* 0600:历史里可能有密钥,不给同机其他用户读的机会 */ + int fd = open(path, O_WRONLY | O_APPEND | O_CREAT, 0600); + if (fd < 0) return -1; + FILE *f = fdopen(fd, "a"); + if (!f) { close(fd); return -1; } + + StrBuf sb; + sb_init(&sb); + hist_encode(line, &sb); + + int rc = 0; + if (sb.data && fwrite(sb.data, 1, sb.len, f) != sb.len) rc = -1; + if (fputc('\n', f) == EOF) rc = -1; + /* 立刻 flush:另一个 ligent 实例要能马上翻到这条 */ + if (fflush(f) != 0) rc = -1; + if (fclose(f) != 0) rc = -1; + sb_free(&sb); + return rc; +} + +/* ------------------------------------------------------------------ */ +/* 终端交互 */ +/* ------------------------------------------------------------------ */ + +enum { + K_EOF = 0, K_ENTER, K_INTERRUPT, K_ESC, K_CHAR, K_PASTE_START, K_PASTE_END, + K_UP, K_DOWN, K_LEFT, K_RIGHT, K_HOME, K_END, K_DELETE, + K_BACKSPACE, K_KILL_EOL, K_KILL_BOL, K_KILL_WORD, K_IGNORE +}; + +static int term_cols(void) +{ + struct winsize ws; + if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0) + return ws.ws_col; + const char *c = getenv("COLUMNS"); + if (c && *c) { + int n = atoi(c); + if (n > 0) return n; + } + return 80; +} + +static int tty_raw(struct termios *saved) +{ + if (!isatty(STDIN_FILENO)) return -1; + if (tcgetattr(STDIN_FILENO, saved) != 0) return -1; + + struct termios raw = *saved; + raw.c_iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON); + /* 刻意保留 OPOST:\n 仍要能正常换行,否则输出会阶梯状错位 */ + raw.c_cflag |= CS8; + raw.c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG); + raw.c_cc[VMIN] = 1; + raw.c_cc[VTIME] = 0; + if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) != 0) return -1; + return 0; +} + +static void tty_restore(const struct termios *saved) +{ + tcsetattr(STDIN_FILENO, TCSAFLUSH, saved); +} + +static int classify(unsigned char c) +{ + switch (c) { + case 0x0a: case 0x0d: return K_ENTER; + case 0x03: return K_INTERRUPT; + case 0x04: return K_EOF; + case 0x01: return K_HOME; + case 0x05: return K_END; + case 0x02: return K_LEFT; + case 0x06: return K_RIGHT; + case 0x0b: return K_KILL_EOL; + case 0x15: return K_KILL_BOL; + case 0x17: return K_KILL_WORD; + case 0x7f: case 0x08: return K_BACKSPACE; + default: return K_IGNORE; /* 其它控制键忽略,避免污染缓冲区 */ + } +} + +/* 读一个按键。 + * 普通可打印字节通过 *out_ch 返回并返回 K_CHAR; + * 其余返回功能键码。转义序列若在 40ms 内没有后续字节,按"单独按 ESC"处理。 */ +static int read_key(unsigned char *out_ch) +{ + unsigned char c; + for (;;) { + ssize_t r = read(STDIN_FILENO, &c, 1); + if (r == 1) break; + if (r < 0 && errno == EINTR) { + if (g_ligent_interrupt) return K_INTERRUPT; + continue; + } + return K_EOF; + } + + if (c != 0x1b) { + if (c < 0x20 || c == 0x7f) return classify(c); + *out_ch = c; + return K_CHAR; + } + + unsigned char seq[8]; + size_t n = 0; + while (n < sizeof seq) { + struct pollfd pfd; + pfd.fd = STDIN_FILENO; + pfd.events = POLLIN; + pfd.revents = 0; + if (poll(&pfd, 1, 40) <= 0) break; /* 40ms 内没有后续 => 单独按了 ESC */ + if (read(STDIN_FILENO, &seq[n], 1) != 1) break; + n++; + if (n >= 2 && seq[n - 1] >= '@' && seq[n - 1] <= '~') break; /* CSI 终结符 */ + if (seq[0] != '[' && seq[0] != 'O') break; /* 不是 CSI/SS3 */ + } + if (n == 0) return K_ESC; + + unsigned char final = seq[n - 1]; + if (seq[0] == '[' || seq[0] == 'O') { + switch (final) { + case 'A': return K_UP; + case 'B': return K_DOWN; + case 'C': return K_RIGHT; + case 'D': return K_LEFT; + case 'H': return K_HOME; + case 'F': return K_END; + case '~': { + int num = 0; + for (size_t i = 1; i + 1 < n; i++) + if (seq[i] >= '0' && seq[i] <= '9') num = num * 10 + (seq[i] - '0'); + if (num == 1 || num == 7) return K_HOME; + if (num == 4 || num == 8) return K_END; + if (num == 3) return K_DELETE; + if (num == 200) return K_PASTE_START; /* bracketed paste 开始 */ + if (num == 201) return K_PASTE_END; + return K_IGNORE; + } + default: return K_IGNORE; + } + } + return K_IGNORE; +} + +typedef struct { + const char *prompt; + LineBuf *lb; + int drawn_row; /* 光标当前位于输入区的第几行,用于回到起点 */ +} Editor; + +/* 跳过一段 ANSI 转义序列,返回跨过的字节数(调用时保证 s[0] == ESC)。 + * 提示符带颜色时这些字节一个格子都不占,必须跳过,否则光标位置会整体偏移。 + * 提示符是我们自己拼的、很短,所以这里只认 CSI / OSC 两种最常见的形态。 */ +static size_t ansi_skip(const char *s, size_t n) +{ + size_t i = 1; + if (i < n && s[i] == '[') { /* CSI:一直吃到终结字节 */ + i++; + while (i < n && !(s[i] >= '@' && s[i] <= '~')) i++; + if (i < n) i++; + return i; + } + if (i < n && s[i] == ']') { /* OSC:到 BEL 或 ST 结束 */ + i++; + while (i < n) { + if (s[i] == '\a') { i++; break; } + if (s[i] == 0x1b && i + 1 < n && s[i + 1] == '\\') { i += 2; break; } + i++; + } + return i; + } + return i; /* 光秃秃的 ESC 按零宽处理 */ +} + +/* 从给定起点"走"过 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; ) { + unsigned char c = (unsigned char)s[i]; + if (c == '\n') { (*row)++; *col = 0; i++; continue; } + if (c == '\r') { *col = 0; i++; continue; } + if (c == 0x1b) { i += ansi_skip(s + i, n - i); continue; } + size_t adv = 1; + unsigned cp = utf8_decode(s + i, n - i, &adv); + int w = cp_width(cp); + if (w <= 0) w = 1; /* 控制字符按占 1 列算 */ + if (*col + w > cols) { (*row)++; *col = 0; } + *col += w; + i += adv; + } +} + +/* 打印完之后,终端光标视觉上停在第几行第几列。 + * 关键:字符正好填满一行时终端处于"待换行"状态,光标仍停在本行末列。 */ +static void end_pos(const char *prompt, const char *buf, size_t len, + int cols, int *row, int *col) +{ + *row = 0; + *col = 0; + advance_pos(prompt, strlen(prompt), cols, row, col); + advance_pos(buf, len, cols, row, col); + if (*col >= cols) *col = cols - 1; + if (*col < 0) *col = 0; +} + +/* 光标应当停在哪里(下一个字符会被写到的位置) */ +static void want_pos(const char *prompt, const char *buf, size_t pos, + int cols, int *row, int *col) +{ + *row = 0; + *col = 0; + advance_pos(prompt, strlen(prompt), cols, row, col); + advance_pos(buf, pos, cols, row, col); + if (*col >= cols) { (*row)++; *col = 0; } +} + +/* 输入行整行着色:提示符和你敲进去的字都是绿的,和模型的输出区分开。 + * 颜色由这里统一加,调用方传进来的 prompt 保持纯文本 —— 否则提示符自己 + * 带一次 reset,后面输入的正文就掉回默认色了。 */ +#define C_INPUT "\033[32m" +#define C_INPUT_OFF "\033[0m" + +static int g_input_color = 0; +static const char *g_input_ink = C_INPUT; + +void line_edit_set_color(int enabled) +{ + g_input_color = enabled ? 1 : 0; +} + +void line_edit_set_ink(const char *ansi) +{ + g_input_ink = ansi ? ansi : C_INPUT; +} + +/* 重绘整个输入区并把光标放回正确位置。 + * 缓冲区可能包含换行(粘贴多行),也可能超出终端宽度自动折行。 */ +static void refresh(Editor *e) +{ + int cols = term_cols(); + if (cols < 2) cols = 80; + + int erow, ecol, trow, tcol; + end_pos(e->prompt, e->lb->buf, e->lb->len, cols, &erow, &ecol); + want_pos(e->prompt, e->lb->buf, e->lb->pos, cols, &trow, &tcol); + + putchar('\r'); + if (e->drawn_row > 0) printf("\033[%dA", e->drawn_row); + fputs("\033[J", stdout); /* 清到屏幕末尾 */ + if (g_input_color) fputs(g_input_ink, stdout); + fputs(e->prompt, stdout); + if (e->lb->len) fwrite(e->lb->buf, 1, e->lb->len, stdout); + if (g_input_color) fputs(C_INPUT_OFF, stdout); + + /* 从"打印结束处"移到目标位置:先回列 0,再上下行,再右移 */ + int up = erow - trow; + putchar('\r'); + if (up > 0) printf("\033[%dA", up); + else if (up < 0) printf("\033[%dB", -up); + if (tcol > 0) printf("\033[%dC", tcol); + fflush(stdout); + + e->drawn_row = trow; +} + +/* 把粘贴进来的内容清洗成"编辑器能正确渲染"的文本。 + * + * 为什么必须清洗:read_paste 收下的是终端给的原始字节,而缓冲区会被 + * 逐字节回显到终端上。里面只要有控制字符,两边的算盘就对不上: + * - \t 终端按制表位展开(下一个 8 的倍数),编辑器却只当 1 列, + * 粘一段带缩进的代码,光标就从此偏掉,后面每敲一个字都在错位置上; + * - ESC 会被终端当成转义序列真的执行掉 —— 粘贴内容能改颜色、清屏; + * - \r 把终端光标拉回行首,屏幕当场错位; + * - NUL 会把 C 字符串截断。 + * 处理办法:\n 保留(多行粘贴整段提交靠它),\t 就地展开成空格(这样 + * 缓冲区里不留制表符,宽度怎么算都对),ESC 连同后面整条序列一起丢掉, + * 其余控制字符丢弃。 + * + * start_col 是插入点在屏幕上的列号 —— 制表位要从它开始算,而不是从 0。 */ +static char *sanitize_paste(const char *s, int start_col, int cols) +{ + StrBuf out; + sb_init(&out); + int col = start_col; + size_t n = strlen(s); + + for (size_t i = 0; i < n; ) { + unsigned char c = (unsigned char)s[i]; + if (c == '\n') { sb_putc(&out, '\n'); col = 0; i++; continue; } + if (c == 0x1b) { i += ansi_skip(s + i, n - i); continue; } + if (c == '\t') { + int w = 8 - (col % 8); /* 终端默认制表位是 8 的倍数 */ + for (int k = 0; k < w; k++) sb_putc(&out, ' '); + col += w; + i++; + continue; + } + if (c == '\r' || c < 0x20 || c == 0x7f) { i++; continue; } + size_t adv = 1; + unsigned cp = utf8_decode(s + i, n - i, &adv); + int w = cp_width(cp); + if (w <= 0) w = 1; + col += w; + if (cols > 0 && col > cols) col -= cols; /* 自动折行 */ + sb_append(&out, s + i, adv); + i += adv; + } + return sb_detach(&out); +} + +/* 读取一段 bracketed paste 内容,直到结束标记 \x1b[201~。 + * 返回的文本可能含换行 —— 这正是粘贴多行不再被逐行执行的关键。 */ +static char *read_paste(void) +{ + static const char END[] = "\x1b[201~"; + const size_t endlen = sizeof END - 1; + + StrBuf sb; + sb_init(&sb); + size_t matched = 0; + + for (;;) { + unsigned char c; + ssize_t r = read(STDIN_FILENO, &c, 1); + if (r != 1) { + if (r < 0 && errno == EINTR && !g_ligent_interrupt) continue; + break; /* 中断或读到 EOF */ + } + if (c == (unsigned char)END[matched]) { + if (++matched == endlen) break; /* 正常结束,标记本身不入缓冲区 */ + continue; + } + if (matched > 0) { /* 之前只是部分匹配,吐回去 */ + sb_append(&sb, END, matched); + matched = 0; + } + if (c == (unsigned char)END[0]) { matched = 1; continue; } + sb_putc(&sb, (char)c); + } + if (matched > 0) sb_append(&sb, END, matched); + return sb_detach(&sb); +} + +/* 进入/退出终端状态。bracketed paste 必须成对开关, + * 否则会把用户的终端留在开启状态。 */ +static void tty_enter(const struct termios *saved) +{ + (void)saved; + fputs("\033[?2004h", stdout); /* 开启 bracketed paste */ + fflush(stdout); +} + +static void tty_leave(const struct termios *saved) +{ + fputs("\033[?2004l", stdout); /* 关闭 bracketed paste */ + fflush(stdout); + tty_restore(saved); +} + +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); + if (g_input_color) fputs(C_INPUT_OFF, stdout); + fflush(stdout); + StrBuf sb; + sb_init(&sb); + char buf[4096]; + while (fgets(buf, sizeof buf, stdin)) { + size_t n = strlen(buf); + if (n && buf[n - 1] == '\n') { + sb_append(&sb, buf, n - 1); + return sb_detach(&sb); + } + sb_append(&sb, buf, n); + } + if (sb.len == 0) { sb_free(&sb); return NULL; } + return sb_detach(&sb); + } + + struct termios saved; + if (tty_raw(&saved) != 0) return NULL; + tty_enter(&saved); + + LineBuf lb; + linebuf_init(&lb); + linebuf_set(&lb, ""); + + Editor e; + e.prompt = prompt; + e.lb = &lb; + e.drawn_row = 0; + + size_t hist_idx = line_history_len(); /* == len 表示"正在编辑新行" */ + char *draft = NULL; /* 翻历史前保存的当前输入 */ + char *result = NULL; + int at_eof = 0; + + refresh(&e); + + for (;;) { + unsigned char ch = 0; + int key = read_key(&ch); + int stop = 0; + + 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; + int prow, pcol; + want_pos(e.prompt, lb.buf, lb.pos, pcols, &prow, &pcol); + char *clean = sanitize_paste(pasted, pcol, pcols); + linebuf_insert(&lb, clean, strlen(clean)); + free(clean); + free(pasted); + break; + } + case K_PASTE_END: break; /* 单独出现的结束标记,忽略 */ + case K_ENTER: stop = 1; break; + case K_EOF: + if (lb.len == 0) { at_eof = 1; stop = 1; } + else linebuf_delete(&lb); /* 行非空时 Ctrl-D 删一个字符 */ + break; + case K_INTERRUPT: at_eof = -1; stop = 1; break; + case K_BACKSPACE: linebuf_backspace(&lb); break; + case K_DELETE: linebuf_delete(&lb); break; + case K_LEFT: linebuf_left(&lb); break; + case K_RIGHT: linebuf_right(&lb); break; + case K_HOME: linebuf_home(&lb); break; + case K_END: linebuf_end(&lb); break; + case K_KILL_EOL: linebuf_kill_to_end(&lb); break; + case K_KILL_BOL: linebuf_kill_to_start(&lb); break; + case K_KILL_WORD: linebuf_kill_word(&lb); break; + case K_UP: + if (hist_idx > 0) { + if (hist_idx == line_history_len()) { + free(draft); + draft = xstrdup(lb.buf ? lb.buf : ""); + } + hist_idx--; + linebuf_set(&lb, line_history_get(hist_idx)); + } + break; + case K_DOWN: + if (hist_idx < line_history_len()) { + hist_idx++; + if (hist_idx == line_history_len()) + linebuf_set(&lb, draft ? draft : ""); + else + linebuf_set(&lb, line_history_get(hist_idx)); + } + break; + default: break; /* K_ESC / K_IGNORE */ + } + + if (stop) break; + refresh(&e); + } + + tty_leave(&saved); + free(draft); + + if (at_eof == -1) { + fputs("^C\n", stdout); + linebuf_free(&lb); + if (interrupted) *interrupted = 1; + return NULL; + } + if (at_eof == 1) { + putchar('\n'); + linebuf_free(&lb); + return NULL; + } + putchar('\n'); + result = xstrdup(lb.buf ? lb.buf : ""); + linebuf_free(&lb); + return result; +} diff --git a/src/lineedit.h b/src/lineedit.h new file mode 100644 index 0000000..a9dd8e9 --- /dev/null +++ b/src/lineedit.h @@ -0,0 +1,82 @@ +/* lineedit.h - REPL 的行编辑器 + * + * 为什么需要它:原来的 REPL 用 fgets 直接读,终端停在规范模式, + * 方向键发出的转义序列(\x1b[D 之类)会被当成普通字节读进来, + * 显示成 ^[[D 而不是移动光标。要有行内编辑就必须自己进 raw 模式接管按键。 + * + * 缓冲区与编辑操作是纯逻辑,可以脱离终端单测;只有渲染与按键读取需要 tty。 + */ +#ifndef LIGENT_LINEEDIT_H +#define LIGENT_LINEEDIT_H + +#include + +/* 可编辑缓冲区。pos 是字节偏移,且始终落在字符边界上 */ +typedef struct { + char *buf; + size_t len; + size_t cap; + size_t pos; +} LineBuf; + +void linebuf_init(LineBuf *lb); +void linebuf_free(LineBuf *lb); + +/* 用 text 整体替换内容,光标移到末尾 */ +void linebuf_set(LineBuf *lb, const char *text); +/* 在光标处插入 n 字节,光标随之后移 */ +void linebuf_insert(LineBuf *lb, const char *text, size_t n); + +void linebuf_backspace(LineBuf *lb); /* 删除光标前一个字符 */ +void linebuf_delete(LineBuf *lb); /* 删除光标处的字符 */ +void linebuf_left(LineBuf *lb); /* 光标左移一个字符 */ +void linebuf_right(LineBuf *lb); /* 光标右移一个字符 */ +void linebuf_home(LineBuf *lb); +void linebuf_end(LineBuf *lb); +void linebuf_kill_to_end(LineBuf *lb); /* 删到行尾 */ +void linebuf_kill_to_start(LineBuf *lb); /* 删到行首 */ +void linebuf_kill_word(LineBuf *lb); /* 往回删一个词 */ + +/* 输入行是否着色(整行:提示符 + 你敲进去的字)。 + * 传进来的 prompt 应当是纯文本 —— 颜色由编辑器统一加在两头。 */ +void line_edit_set_color(int enabled); + +/* 换输入行的墨色(ANSI 起手式,例如 "\033[35m")。NULL 恢复默认的绿色。 + * meta 模式靠它和主提示符区分开。 */ +void line_edit_set_ink(const char *ansi); + +/* 读入一行(不含换行符,malloc)。 + * Ctrl-C -> 返回 NULL,*interrupted = 1 + * Ctrl-D 空行 -> 返回 NULL,*interrupted = 0(终止输入) + * stdin 非终端 -> 退化为按行读取 + * 返回的字符串可能为空串(用户直接回车),调用方负责 free。 */ +char *line_edit(const char *prompt, int *interrupted); + +/* 进程内的历史记录(上下方向键用) */ + +/* 追加一条。返回 1 表示真的记下了,0 表示被忽略(空行,或与上一条重复)。 */ +int line_history_add(const char *line); +void line_history_clear(void); +size_t line_history_len(void); +const char *line_history_get(size_t i); + +/* ---- 输入历史的持久化 ---- + * + * 文件位置由调用方决定(ligent 用 <二进制目录>/ligent.history)。 + * 一条历史在文件里占一行,所以条目里的反斜杠和换行会被转义 + * (\ -> \\,换行 -> \n),保证读回来和写出去一模一样。不含反斜杠的 + * 普通命令在文件里与输入完全一致,肉眼可读。 + * + * 时间戳之类的元数据刻意不记:目的只是"上下键能翻到",不是审计。 */ + +/* 从 path 读取历史,追加到当前历史之后。最多保留最新的 keep 条; + * 文件本身更长时会被就地截短(否则会无限增长)。 + * 文件不存在返回 0,读出错返回 -1。 */ +int line_history_load(const char *path, size_t keep); + +/* 把一条输入实时追加到 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 new file mode 100644 index 0000000..580f157 --- /dev/null +++ b/src/llm.c @@ -0,0 +1,584 @@ +/* llm.c - OpenAI 兼容接口:请求构造、SSE 流式解析、tool_calls 增量拼接 */ +#include "llm.h" +#include "config.h" /* THINKING_AUTO / _ON / _OFF */ +#include "http.h" +#include "i18n.h" +#include "util.h" + +#include +#include +#include + +/* ------------------------------------------------------------------ */ +/* URL / 请求体 */ +/* ------------------------------------------------------------------ */ + +char *llm_endpoint_url(const char *base_url) +{ + const char *base = (base_url && *base_url) ? base_url : "https://api.openai.com/v1"; + size_t n = strlen(base); + while (n > 0 && base[n - 1] == '/') n--; + /* 允许用户直接给完整的 completions 地址 */ + if (str_ends_with(base_url, "/chat/completions")) + return xstrndup(base, n); + return xasprintf("%.*s/chat/completions", (int)n, base); +} + +JsonValue *llm_build_body(const LlmConfig *cfg, const JsonValue *messages, + const JsonValue *tools, int stream) +{ + JsonValue *body = json_new_object(); + json_object_set_string(body, "model", cfg->model); + json_object_set(body, "messages", json_clone(messages)); + if (cfg->temperature >= 0) + 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 包装。 */ + if (cfg->thinking != THINKING_AUTO) { + JsonValue *t = json_new_object(); + json_object_set_string(t, "type", + cfg->thinking == THINKING_ON ? "enabled" : "disabled"); + json_object_set(body, "thinking", t); + } + if (cfg->reasoning_effort && *cfg->reasoning_effort) + json_object_set_string(body, "reasoning_effort", cfg->reasoning_effort); + if (tools && json_array_size(tools) > 0) { + json_object_set(body, "tools", json_clone(tools)); + json_object_set_string(body, "tool_choice", "auto"); + } + json_object_set_bool(body, "stream", stream ? 1 : 0); + return body; +} + +/* 单行 SSE 上限。正常的 data: 行只有几十字节; + * 出现超长"行"说明对端根本不是 SSE(例如忽略 stream 直接返回整包 JSON, + * 而 JSON 里没有换行),必须丢弃,否则内存会被撑爆。 */ +#define SSE_MAX_LINE (256u * 1024) +/* 单条回复累积内容上限 */ +#define SSE_MAX_CONTENT (32u * 1024 * 1024) +/* 单个工具调用参数上限。正常人写不出 8MB 的参数, + * 超过基本可以断定是对端异常或恶意,必须截住 */ +#define SSE_MAX_TOOL_ARGS (8u * 1024 * 1024) + +/* ------------------------------------------------------------------ */ +/* 错误响应的可读化 */ +/* ------------------------------------------------------------------ */ + +/* 很多服务端把 message 写成 \uXXXX 转义,直接打印很难看。 + * 这里尝试解析出 error.message,失败则回退到原始文本。 */ +static char *extract_error_message(const char *body) +{ + if (!body || !*body) return NULL; + JsonValue *root = json_parse(body, NULL, 0); + if (!root) return NULL; + char *msg = NULL; + JsonValue *err = json_object_get(root, "error"); + if (err && err->type == JSON_OBJECT) { + const char *m = json_get_string(err, "message"); + if (m && *m) msg = xstrdup(m); + } else if (err && err->type == JSON_STRING) { + msg = xstrdup(err->u.string); + } + if (!msg) { + const char *m = json_get_string(root, "message"); + if (m && *m) msg = xstrdup(m); + } + json_free(root); + return msg; +} + +/* 401/403 时补一句提示。服务端的原文经常把人引向错误的方向: + * OpenRouter 对"格式不对的 key"回的是 "Missing Authentication header", + * 听起来像是请求里没带认证头,实际上带了,只是 key 不属于这家。 */ +static void append_auth_hint(char *errbuf, size_t errlen, + const LlmConfig *cfg, long status) +{ + if (status != 401 && status != 403) return; + + const char *key = cfg->api_key ? cfg->api_key : ""; + size_t n = strlen(key); + char masked[80]; + 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", + tf("llm.mask.partial", "head", head, + "n", i18n_num((long long)n), NULL)); + } + + size_t cur = strlen(errbuf); + if (cur + 4 >= errlen) return; + snprintf(errbuf + cur, errlen - cur, "%s", + tf("llm.err.auth_hint", "masked", masked, "url", + cfg->base_url && *cfg->base_url ? cfg->base_url + : t("llm.val.no_base_url"), + NULL)); +} + +static void set_error_with_detail(char *errbuf, size_t errlen, + const char *prefix, const char *body) +{ + snprintf(errbuf, errlen, "%s", prefix); + if (!body || !*body) return; + size_t cur = strlen(errbuf); + if (cur + 4 >= errlen) return; + char *msg = extract_error_message(body); + if (msg) { + snprintf(errbuf + cur, errlen - cur, " — %s", msg); + free(msg); + } else { + snprintf(errbuf + cur, errlen - cur, " — %.400s", body); + } +} + +/* ------------------------------------------------------------------ */ +/* 流式累积器 */ +/* ------------------------------------------------------------------ */ + +typedef struct { + char *id; + char *name; + StrBuf args; + int started; +} ToolAcc; + +typedef struct { + StrBuf raw; /* 原始字节,用于“服务端不支持流式”时的回退 */ + StrBuf content; + StrBuf reasoning; /* reasoning_content 累积(思考过程,不显示) */ + ToolAcc *accs; + size_t acc_count; + size_t acc_cap; + char *finish_reason; + LlmDeltaFn on_delta; + void *ud; + LlmThinkFn on_think; + void *think_ud; + int got_event; + int overflow; /* 内容超过上限 */ + int raw_truncated; + char *error; /* 服务端返回的 error.message */ + JsonValue *usage; + StrBuf linebuf; +} SseCtx; + +static ToolAcc *acc_at(SseCtx *c, size_t index) +{ + if (index >= c->acc_cap) { + size_t cap = c->acc_cap ? c->acc_cap : 4; + while (cap <= index) cap *= 2; + c->accs = xrealloc(c->accs, cap * sizeof(ToolAcc)); + memset(c->accs + c->acc_cap, 0, (cap - c->acc_cap) * sizeof(ToolAcc)); + c->acc_cap = cap; + } + ToolAcc *a = &c->accs[index]; + if (!a->started) { + a->started = 1; + sb_init(&a->args); + if (index + 1 > c->acc_count) c->acc_count = index + 1; + } + return a; +} + +static void sse_ctx_free(SseCtx *c) +{ + sb_free(&c->raw); + sb_free(&c->content); + sb_free(&c->reasoning); + sb_free(&c->linebuf); + for (size_t i = 0; i < c->acc_count; i++) { + if (!c->accs[i].started) continue; + free(c->accs[i].id); + free(c->accs[i].name); + sb_free(&c->accs[i].args); + } + free(c->accs); + free(c->finish_reason); + free(c->error); + json_free(c->usage); +} + +/* 处理一个 SSE data 负载(不含 "data:" 前缀) */ +static void handle_payload(SseCtx *c, const char *payload, size_t len) +{ + if (len == 0) return; + if (len == 6 && memcmp(payload, "[DONE]", 6) == 0) return; + + char *js = xstrndup(payload, len); + char err[256]; + JsonValue *root = json_parse(js, err, sizeof err); + free(js); + if (!root) return; + c->got_event = 1; + + JsonValue *errv = json_object_get(root, "error"); + if (errv && errv->type == JSON_OBJECT) { + const char *m = json_get_string(errv, "message"); + if (m && !c->error) c->error = xstrdup(m); + json_free(root); + return; + } + JsonValue *usage = json_object_get(root, "usage"); + if (usage && usage->type == JSON_OBJECT && !c->usage) + c->usage = json_clone(usage); + + JsonValue *choices = json_object_get(root, "choices"); + JsonValue *ch0 = json_array_get(choices, 0); + if (!ch0) { json_free(root); return; } + + const char *fr = json_get_string(ch0, "finish_reason"); + if (fr && *fr && !c->finish_reason) c->finish_reason = xstrdup(fr); + + /* 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; } + + const char *piece = json_get_string(d, "content"); + if (piece && *piece) { + if (c->content.len + strlen(piece) > SSE_MAX_CONTENT) { + c->overflow = 1; + } else { + sb_puts(&c->content, piece); + if (c->on_delta) c->on_delta(c->ud, piece); + } + } + + /* 思考过程。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) { + if (c->reasoning.len + strlen(think) > SSE_MAX_CONTENT) { + c->overflow = 1; + } else { + sb_puts(&c->reasoning, think); + /* 不往正文回调里塞 —— 思维链不该被渲染。单独走一路, + * 让上层知道"模型开始推理了"。 */ + if (c->on_think) c->on_think(c->think_ud, think); + } + } + + JsonValue *tcs = json_object_get(d, "tool_calls"); + if (tcs && tcs->type == JSON_ARRAY) { + for (size_t i = 0; i < json_array_size(tcs); i++) { + JsonValue *tc = json_array_get(tcs, i); + if (!tc) continue; + size_t idx = (size_t)json_get_number(tc, "index", (double)i); + ToolAcc *a = acc_at(c, idx); + const char *id = json_get_string(tc, "id"); + if (id && *id && !a->id) a->id = xstrdup(id); + JsonValue *fn = json_object_get(tc, "function"); + if (fn && fn->type == JSON_OBJECT) { + const char *name = json_get_string(fn, "name"); + 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; + } + } + const char *args = json_get_string(fn, "arguments"); + if (args) { + if (a->args.len + strlen(args) > SSE_MAX_TOOL_ARGS) + c->overflow = 1; + else + sb_puts(&a->args, args); + } + } + } + } + json_free(root); +} + +/* 按行切分,逐条处理 "data:" */ +static void feed(SseCtx *c, const char *data, size_t len) +{ + sb_append(&c->linebuf, data, len); + char *buf = c->linebuf.data; + size_t start = 0; + for (size_t i = 0; i < c->linebuf.len; i++) { + if (buf[i] != '\n') continue; + size_t line_len = i - start; + if (line_len > 0 && buf[start + line_len - 1] == '\r') line_len--; + const char *line = buf + start; + if (line_len > 5 && memcmp(line, "data:", 5) == 0) { + const char *p = line + 5; + size_t n = line_len - 5; + while (n > 0 && (*p == ' ' || *p == '\t')) { p++; n--; } + handle_payload(c, p, n); + } + start = i + 1; + } + if (start > 0) { + memmove(buf, buf + start, c->linebuf.len - start); + c->linebuf.len -= start; + buf[c->linebuf.len] = '\0'; + } + + /* 残行过长:丢弃,保证内存有界 */ + if (c->linebuf.len > SSE_MAX_LINE) sb_clear(&c->linebuf); +} + +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->raw.len < 65536) { /* 仅保留开头用于回退判断 */ + size_t take = n < 65536 - c->raw.len ? n : 65536 - c->raw.len; + sb_append(&c->raw, ptr, take); + if (take < n) c->raw_truncated = 1; + } else { + c->raw_truncated = 1; + } + feed(c, ptr, n); + if (c->overflow) return 0; + return n; +} + +/* ------------------------------------------------------------------ */ +/* 从整包 JSON 响应里抽出 assistant 消息 */ +/* ------------------------------------------------------------------ */ + +static LlmResponse *from_full_response(JsonValue *root, size_t raw_len) +{ + (void)raw_len; + JsonValue *errv = json_object_get(root, "error"); + if (errv && errv->type == JSON_OBJECT) return NULL; + + JsonValue *ch0 = json_array_get(json_object_get(root, "choices"), 0); + if (!ch0) return NULL; + JsonValue *msg = json_object_get(ch0, "message"); + if (!msg || msg->type != JSON_OBJECT) return NULL; + + LlmResponse *r = xcalloc(1, sizeof(LlmResponse)); + r->message = json_clone(msg); + JsonValue *u = json_object_get(root, "usage"); + if (u && u->type == JSON_OBJECT) r->usage = json_clone(u); + const char *fr = json_get_string(ch0, "finish_reason"); + if (fr) r->finish_reason = xstrdup(fr); + return r; +} + +/* 把累积结构整理成 LlmResponse */ +static LlmResponse *from_sse(SseCtx *c) +{ + LlmResponse *r = xcalloc(1, sizeof(LlmResponse)); + r->message = json_new_object(); + json_object_set_string(r->message, "role", "assistant"); + + int has_tools = 0; + for (size_t i = 0; i < c->acc_count; i++) + if (c->accs[i].started) { has_tools = 1; break; } + + if (c->content.len > 0 || !has_tools) + json_object_set_string(r->message, "content", c->content.data ? c->content.data : ""); + else + json_object_set_null(r->message, "content"); + + /* 思考过程跟着 assistant 消息一起进历史。用户看不到它,但它必须原样回传: + * 只要请求里带 tools,服务端就要求把历史里的 reasoning_content 全带上, + * 否则下一轮直接 400。 */ + if (c->reasoning.len > 0) + json_object_set_string(r->message, "reasoning_content", + c->reasoning.data ? c->reasoning.data : ""); + + if (has_tools) { + JsonValue *arr = json_new_array(); + for (size_t i = 0; i < c->acc_count; i++) { + ToolAcc *a = &c->accs[i]; + if (!a->started) continue; + JsonValue *tc = json_new_object(); + if (a->id) { + json_object_set_string(tc, "id", a->id); + } else { + char *gen = xasprintf("call_%zu", i); + json_object_set_string(tc, "id", gen); + free(gen); + } + json_object_set_string(tc, "type", "function"); + JsonValue *fn = json_new_object(); + json_object_set_string(fn, "name", a->name ? a->name : ""); + json_object_set_string(fn, "arguments", + a->args.data ? a->args.data : "{}"); + json_object_set(tc, "function", fn); + json_array_push(arr, tc); + } + json_object_set(r->message, "tool_calls", arr); + } + + if (c->finish_reason) r->finish_reason = xstrdup(c->finish_reason); + if (c->usage) r->usage = json_clone(c->usage); + return r; +} + +/* ------------------------------------------------------------------ */ +/* 主入口 */ +/* ------------------------------------------------------------------ */ + +LlmResponse *llm_chat(const LlmConfig *cfg, const JsonValue *messages, + const JsonValue *tools, + LlmDeltaFn on_delta, void *ud, + LlmThinkFn on_think, void *think_ud, + char *errbuf, size_t errlen) +{ + static char errsink[1]; + if (!errbuf || !errlen) { errbuf = errsink; errlen = 1; } + errbuf[0] = '\0'; + if (!cfg->api_key || !*cfg->api_key) { + snprintf(errbuf, errlen, "%s", t("llm.err.no_api_key")); + return NULL; + } + char *url = llm_endpoint_url(cfg->base_url); + JsonValue *body = llm_build_body(cfg, messages, tools, cfg->stream); + char *payload = json_dump(body); + json_free(body); + + if (cfg->verbose) + fprintf(stderr, "[ligent] POST %s\n[ligent] body: %.2000s\n", url, payload); + + LlmResponse *result = NULL; + char http_err[512]; + long status = 0; + + if (cfg->stream) { + SseCtx ctx; + memset(&ctx, 0, sizeof ctx); + sb_init(&ctx.raw); + sb_init(&ctx.content); + sb_init(&ctx.reasoning); + sb_init(&ctx.linebuf); + ctx.on_delta = on_delta; + ctx.ud = ud; + ctx.on_think = on_think; + ctx.think_ud = think_ud; + + char *errbody = NULL; + size_t delivered = 0; + HttpRetry rt = HTTP_RETRY_DEFAULT; + rt.max_retries = cfg->retries; + int rc = http_post_stream(url, cfg->api_key, payload, cfg->timeout, + cfg->verbose, &rt, sse_write_cb, &ctx, + &errbody, &status, NULL, &delivered, + http_err, sizeof http_err); + if (g_ligent_interrupt) { + result = xcalloc(1, sizeof(LlmResponse)); + result->interrupted = 1; + result->message = json_new_object(); + json_object_set_string(result->message, "role", "assistant"); + json_object_set_string(result->message, "content", + ctx.content.data ? ctx.content.data : ""); + if (ctx.reasoning.len > 0) + json_object_set_string(result->message, "reasoning_content", + ctx.reasoning.data ? ctx.reasoning.data : ""); + } else if (ctx.overflow) { + snprintf(errbuf, errlen, "%s", + tf("llm.err.overflow", + "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); + } else if (rc != 0) { + if (errbody && *errbody) { + set_error_with_detail(errbuf, errlen, http_err, errbody); + } else if (ctx.error) { + size_t cur = (size_t)snprintf(errbuf, errlen, "%s", http_err); + if (cur + 4 < errlen) + snprintf(errbuf + cur, errlen - cur, " — %s", ctx.error); + } else { + snprintf(errbuf, errlen, "%s", http_err); + } + append_auth_hint(errbuf, errlen, cfg, status); + } else if (ctx.error) { + snprintf(errbuf, errlen, "%s", + tf("llm.err.server", "error", ctx.error, NULL)); + } else if (ctx.got_event) { + result = from_sse(&ctx); + } else { + /* 服务端忽略了 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) { + result = from_full_response(root, 0); + json_free(root); + } + free(raw); + if (!result && !errbuf[0]) { + if (status >= 400) + snprintf(errbuf, errlen, "HTTP %ld", status); + else + snprintf(errbuf, errlen, "%s", t("llm.err.unparsable_response")); + } + } + free(errbody); + sse_ctx_free(&ctx); + } else { + char *resp = NULL; + HttpRetry rt = HTTP_RETRY_DEFAULT; + rt.max_retries = cfg->retries; + int rc = http_post(url, cfg->api_key, payload, cfg->timeout, + cfg->verbose, &rt, &resp, &status, NULL, + http_err, sizeof http_err); + if (rc != 0) { + if (resp && *resp) + set_error_with_detail(errbuf, errlen, http_err, resp); + else + snprintf(errbuf, errlen, "%s", http_err); + append_auth_hint(errbuf, errlen, cfg, status); + } else { + JsonValue *root = json_parse_ui(resp ? resp : "", + http_err, sizeof http_err); + if (root) { + JsonValue *errv = json_object_get(root, "error"); + if (errv && errv->type == JSON_OBJECT) { + const char *m = json_get_string(errv, "message"); + snprintf(errbuf, errlen, "%s", + tf("llm.err.server", "error", + m ? m : t("llm.val.unknown"), NULL)); + } else { + result = from_full_response(root, 0); + if (!result) + snprintf(errbuf, errlen, "%s", t("llm.err.no_choices")); + } + json_free(root); + } else { + char head[201]; + snprintf(head, sizeof head, "%.200s", http_err); + snprintf(errbuf, errlen, "%s", + tf("llm.err.bad_json", "error", head, NULL)); + } + } + free(resp); + } + + free(payload); + free(url); + return result; +} + +void llm_response_free(LlmResponse *r) +{ + if (!r) return; + json_free(r->message); + json_free(r->usage); + free(r->finish_reason); + free(r->incomplete_reason); + free(r); +} diff --git a/src/llm.h b/src/llm.h new file mode 100644 index 0000000..c5dd465 --- /dev/null +++ b/src/llm.h @@ -0,0 +1,60 @@ +/* llm.h - OpenAI 兼容的 Chat Completions 客户端 */ +#ifndef LIGENT_LLM_H +#define LIGENT_LLM_H + +#include "json.h" +#include + +typedef struct { + char *base_url; /* 例如 https://api.deepseek.com/v1 */ + char *api_key; + char *model; + double temperature; /* < 0 表示不发送该字段 */ + long max_tokens; /* <= 0 表示不发送 */ + int thinking; /* config.h 的 THINKING_*;AUTO 表示不发 thinking 字段 */ + const char *reasoning_effort; /* NULL/空 表示不发送 */ + int stream; /* 1 = SSE 流式 */ + int verbose; + long timeout; /* 秒,<=0 用默认 */ + int retries; /* 瞬态失败的额外重试次数,0 = 不重试 */ +} LlmConfig; + +typedef struct { + JsonValue *message; /* assistant 消息对象 {role, content, tool_calls?} */ + JsonValue *usage; /* 可能为 NULL */ + char *finish_reason; /* 可能为 NULL */ + int interrupted; + int incomplete; /* 传输中断,内容是部分的 */ + char *incomplete_reason; +} LlmResponse; + +/* 流式正文增量回调。只有 delta.content 会触发它 —— 正文是"给用户看的", + * 上层据此停止等待动画、开始渲染。 */ +typedef void (*LlmDeltaFn)(void *ud, const char *text); + +/* 流式思维链增量回调。只有 reasoning_content(或部分实现的 reasoning)会触发它。 + * 思维链本身不显示,这个回调的用途是告诉上层"模型真的开始推理了", + * 好把等待动画从"等待响应"切成"思考中"。 */ +typedef void (*LlmThinkFn)(void *ud, const char *text); + +/* 拼出 /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。 + * + * 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, + LlmThinkFn on_think, void *think_ud, + char *errbuf, size_t errlen); + +void llm_response_free(LlmResponse *r); + +#endif /* LIGENT_LLM_H */ diff --git a/src/main.c b/src/main.c new file mode 100644 index 0000000..aac4369 --- /dev/null +++ b/src/main.c @@ -0,0 +1,1251 @@ +/* main.c - 入口:命令行解析、单次任务模式、交互式 REPL */ +#include "config.h" +#include "agent.h" +#include "tools.h" +#include "llm.h" +#include "util.h" +#include "http.h" +#include "lineedit.h" +#include "meta.h" +#include "doctor.h" +#include "spinner.h" +#include "archive.h" +#include "attach.h" +#include "image.h" +#include "models.h" +#include "confirm.h" +#include "i18n.h" +#include "shell.h" +#include "version.h" + +#include +#include +#include +#include +#include +#include + +/* ------------------------------------------------------------------ */ +/* 信号 */ +/* ------------------------------------------------------------------ */ + +static void on_sigint(int sig) +{ + (void)sig; + g_ligent_interrupt = 1; +} + +static void install_signals(void) +{ + struct sigaction sa; + memset(&sa, 0, sizeof sa); + sa.sa_handler = on_sigint; + sigemptyset(&sa.sa_mask); + sa.sa_flags = 0; /* 不设 SA_RESTART:让 read/fgets 被 EINTR 打断 */ + sigaction(SIGINT, &sa, NULL); + signal(SIGPIPE, SIG_IGN); +} + +/* ------------------------------------------------------------------ */ +/* 帮助 */ +/* ------------------------------------------------------------------ */ + +static void print_usage(FILE *out) +{ + fputs(t("cli.help"), out); +} + +/* ------------------------------------------------------------------ */ +/* 把配置灌进 agent */ +/* ------------------------------------------------------------------ */ + +static void apply_config(Agent *a, LigentConfig *c) +{ + a->llm.base_url = c->base_url; /* 有意借用,配置对象活得和进程一样久 */ + a->llm.api_key = c->api_key; + a->llm.model = c->model; + a->llm.temperature = c->temperature; + a->llm.max_tokens = c->max_tokens; + a->llm.thinking = c->thinking; + a->llm.reasoning_effort = c->reasoning_effort; + a->llm.timeout = c->timeout; + a->llm.stream = c->stream; + a->llm.verbose = 0; + a->max_steps = c->max_steps; + a->use_tools = c->use_tools; + a->llm.retries = c->retries; + a->confirm = c->confirm; + a->tools = c->use_tools ? tools_schema() : NULL; + a->probe_caps = c->probe; +} + +/* ------------------------------------------------------------------ */ +/* 读取全部 stdin */ +/* ------------------------------------------------------------------ */ + +static char *read_stdin_all(void) +{ + StrBuf sb; + sb_init(&sb); + char buf[4096]; + size_t n; + while ((n = fread(buf, 1, sizeof buf, stdin)) > 0) + sb_append(&sb, buf, n); + return sb_detach(&sb); +} + +/* ------------------------------------------------------------------ */ +/* REPL */ +/* ------------------------------------------------------------------ */ + +static void print_banner(const LigentConfig *c, const Agent *a) +{ + char cwd[1024]; + if (!getcwd(cwd, sizeof cwd)) snprintf(cwd, sizeof cwd, "."); + printf("%sligent %s%s %s(model: %s, cwd: %s)%s\n", + a->color ? "\033[1m" : "", ligent_version_string(), a->color ? "\033[0m" : "", + a->color ? "\033[2m" : "", c->model, cwd, + a->color ? "\033[0m" : ""); + printf("%s%s%s\n\n", a->color ? "\033[2m" : "", + t("repl.banner.hint"), a->color ? "\033[0m" : ""); +} + +/* 存档目录跟二进制走,和配置同一个原则 */ +static const char *g_bindir; + +/* 当前对话的谱系:最近一次写出或载入的存档名(不含 .json)。 + * 下次存档就在它后面接一级分支,于是文件名本身就记录了分支关系。 */ +static char *g_lineage; + +static void set_lineage(const char *name) +{ + free(g_lineage); + g_lineage = (name && *name) ? xstrdup(name) : NULL; +} + +/* /back 摘掉的消息,供 /unback 撤销;g_back_len 记录摘完之后的长度, + * 用来判断"之后是不是又开了新对话" */ +static JsonValue *g_back_undo; +static size_t g_back_len; + +static void back_undo_clear(void) +{ + json_free(g_back_undo); + g_back_undo = NULL; + 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 == g_saved_count && a->started_at == g_saved_start) + return; /* 内容没变 */ + + char *path = NULL, *name = NULL; + char err[512]; + /* 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), + c->model, c->base_url, + &path, &name, err, sizeof err) != 0) { + fprintf(stderr, "%s", tf("main.err.auto_save_failed", "error", err, NULL)); + return; + } + g_saved_count = n; + g_saved_start = a->started_at; + /* 临时存档只是"换掉当前对话前留一手",不该成为以后分支的祖先 */ + if (!prefix || !*prefix) { + set_lineage(name); + if (meta) meta_reroot_clear(); /* 新根已经落名,标记用掉了 */ + } + fprintf(stderr, "\033[2m%s\033[0m\n", + tf("main.auto_saved", "why", why, "path", path, + "temp", (prefix && *prefix) ? t("main.auto_saved.temp") : "", NULL)); + free(path); + free(name); +} + +/* 真正落地一次模型切换。cap 缓存是按 base_url+model 索引的,所以下次请求 + * 会自动重新探测新模型的能力,这里不用额外处理。 */ +static void apply_model(Agent *a, LigentConfig *c, const char *name) +{ + if (strcmp(c->model, name) == 0) { + printf("%s", tf("repl.model.same", "model", name, NULL)); + return; + } + free(c->model); + c->model = xstrdup(name); + a->llm.model = c->model; + printf("%s", tf("repl.model.switched", "model", c->model, NULL)); +} + +/* /model 不带参数:拉一次在线列表,打印出来,然后用**普通输入行**问要哪个。 + * + * 刻意不用"光标选择"那种菜单:一是它会用 ANSI 反复重画、把 stdout 搅乱 + * (重定向、日志、粘贴都会跟着遭殃),二是**没法输入列表里没有的东西** —— + * 而"列表里没有"恰恰很常见:内部模型、灰度模型、服务端没列出来的别名。 + * 所以这里是 /import 那一套:列表打在上面,输入行里既可以填序号,也可以 + * 直接写模型名,回车取消。 + * + * 拉不到列表就明说原因,并提示还能直接用 /model <名字> —— 不能因为网络不行 + * 或者服务端不列,就把这条路堵死。 */ +static void cmd_model_pick(Agent *a, LigentConfig *c) +{ + printf("%s", tf("repl.model.fetching", "url", c->base_url, NULL)); + fflush(stdout); + + char err[512]; + size_t n = 0; + char **list = models_fetch(c, &n, a->llm.verbose, err, sizeof err); + if (!list) { + printf("%s", tf("repl.model.list_failed", "error", + err[0] ? err : t("repl.unknown_error"), NULL)); + puts(t("repl.model.list_failed_hint")); + return; + } + + printf("%s", tf("repl.model.list_header", "n", i18n_num((long long)n), NULL)); + for (size_t i = 0; i < n; i++) { + int cur = (strcmp(list[i], c->model) == 0); + printf(t("repl.model.row"), i + 1, list[i], + cur ? t("repl.model.current") : ""); + } + + int intr = 0; + char *line = line_edit(t("repl.model.pick_prompt"), &intr); + if (!line) { + models_free(list, n); + printf("\n%s\n", t("repl.cancelled")); + return; + } + + char *ans = str_trim(line); + if (*ans == '\0') { + puts(t("repl.cancelled")); + free(line); + models_free(list, n); + return; + } + + /* 纯数字按序号理解;其它一律当模型名 —— 这条正是"内部模型"的入口, + * 所以不去列表里校验它,服务端认不认由后端说了算。 */ + char *numend = NULL; + long idx = strtol(ans, &numend, 10); + if (numend != ans && *str_trim(numend) == '\0') { + if (idx < 1 || (size_t)idx > n) { + printf("%s", tf("repl.index_out_of_range", "n", i18n_num(idx), + "max", i18n_num((long long)n), NULL)); + free(line); + models_free(list, n); + return; + } + apply_model(a, c, list[idx - 1]); + } else { + apply_model(a, c, ans); + } + + free(line); + models_free(list, n); +} + +/* /include 遇到图片时才会被问到。这时候才去跑能力探测 —— 纯文本的附带 + * 不该为了"可能出现的图"先花掉两个探测请求。探测结论按 base_url+model + * 缓存,所以一个模型只会真探测一次。 */ +static int include_can_image(void *ud) +{ + Agent *a = ud; + agent_ensure_caps(a); + /* 只有**明确不支持**才拦。未测(探测被关掉或结论不确定)按支持放行, + * 和别处一个原则:宁可让服务端说不行,也不错误地藏掉东西。 */ + return a->caps.vision != CAP_NO; +} + +/* /include [glob|clear] —— 不带参数进交互式挑选 */ +static void cmd_include(Agent *a, const char *arg) +{ + if (strcmp(arg, "clear") == 0) { + attach_clear(); + puts(t("repl.include.cleared")); + return; + } + + size_t skipped = 0; + int added; + if (*arg) { + char err[512]; + added = attach_glob(arg, include_can_image, a, &skipped, err, sizeof err); + if (added < 0) { + printf("%s", tf("repl.include.failed", "error", err, NULL)); + return; + } + } else { + added = attach_pick(NULL, include_can_image, a, &skipped); + } + + if (added > 0) { + printf("%s%s%s\n", + tf("repl.include.added", "n", i18n_num(added), NULL), + skipped ? tf("repl.include.skipped_more", "n", + i18n_num((long long)skipped), NULL) : "", + t("repl.colon")); + attach_print(); + puts(t("repl.include.next_hint")); + } else if (skipped > 0) { + printf("%s", tf("repl.include.none", "n", + i18n_num((long long)skipped), NULL)); + } + /* added == 0 且 skipped == 0 = 用户在交互式界面里取消了,不用多嘴 */ +} + +static void cmd_export(Agent *a, LigentConfig *c) +{ + /* 只有 system 一条说明这段对话还没发生过什么,导出没有意义 */ + if (agent_history_len(a) <= 1) { + puts(t("repl.export.empty")); + return; + } + + char *path = NULL, *name = NULL; + char err[512]; + int meta = meta_reroot_pending(); + if (archive_export(a->messages, g_bindir ? g_bindir : ".", g_lineage, + NULL, meta, a->started_at, time(NULL), + c->model, c->base_url, + &path, &name, err, sizeof err) != 0) { + printf("%s", tf("repl.export.failed", "error", err, NULL)); + return; + } + printf("%s", tf("repl.export.done", + "n", i18n_num((long long)agent_conversation_len(a)), + "lineage", g_lineage ? g_lineage : "", + "arrow", g_lineage ? " -> " : "", "name", name, NULL)); + if (g_lineage && + 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(); /* 新根已经落名,标记用掉了 */ + free(path); + free(name); +} + +/* 按名字找一份存档:支持完整文件名、不含 .json 的名字、以及唯一前缀 */ +static const ArchiveEntry *find_entry(const ArchiveEntry *list, size_t n, + const char *want) +{ + size_t stemlen = strlen(want); + if (str_ends_with(want, ".json")) stemlen -= strlen(".json"); + + const ArchiveEntry *hit = NULL; + for (size_t i = 0; i < n; i++) { + if (strncmp(list[i].name, want, stemlen) == 0) { + if (strlen(list[i].name) == stemlen) return &list[i]; /* 精确匹配 */ + if (!hit) hit = &list[i]; /* 前缀匹配 */ + } + } + return hit; +} + +/* 载入存档:给了名字就直接载入,否则交互式选一份。 + * 只把历史装回去,不重放历史内容。 */ +/* 问一个是非题,(Y/n) 语义:直接回车算"是"。 + * 非终端时没人能回答,按默认值返回 1 —— 和 confirm_tool 一致。 */ +static int ask_yes_no(const char *question) +{ + if (!isatty(STDIN_FILENO)) return 1; + for (;;) { + int intr = 0; + char *line = line_edit(question, &intr); + if (!line) { putchar('\n'); return 1; } /* Ctrl-C / EOF 也按默认值 */ + char *ans = str_trim(line); + int yes = (*ans == '\0' || *ans == 'y' || *ans == 'Y'); + int no = (*ans == 'n' || *ans == 'N'); + free(line); + if (yes) return 1; + if (no) return 0; + puts(t("repl.yes_no.hint")); + } +} + +/* /import 载入后用户可能想看看这段历史到底聊了什么。 + * + * 这里是**完整**转储:不截断正文、不省略工具参数、不砍总量。既然用户 + * 明确回答了 Y,就该看到全部 —— 截一半反而会让人以为历史里就这么点东西。 + * 所以只做排版,不做取舍。 + * + * 唯一"少打印"的地方是没有 content 的消息(工具调用型的 assistant), + * 那种消息本来就没有正文,打出来的是它的 tool_calls。 */ +static void print_history(const Agent *a, const JsonValue *msgs) +{ + const char *dim = a->color ? "\033[2m" : ""; + const char *off = a->color ? "\033[0m" : ""; + const char *grn = a->color ? "\033[32m" : ""; + const char *cyan = a->color ? "\033[36m" : ""; + const char *yel = a->color ? "\033[33m" : ""; + + size_t n = json_array_size(msgs); + + for (size_t i = 0; i < n; i++) { + JsonValue *m = json_array_get(msgs, i); + const char *role = json_get_string(m, "role"); + if (!role) role = "?"; + + const char *label, *color; + if (strcmp(role, "system") == 0) { label = t("repl.role.system"); color = dim; } + else if (strcmp(role, "user") == 0) { label = t("repl.role.user"); color = grn; } + else if (strcmp(role, "assistant") == 0) { label = t("repl.role.assistant"); color = cyan; } + else if (strcmp(role, "tool") == 0) { label = t("repl.role.tool"); color = yel; } + else { label = role; color = off; } + + printf("\n%s-- %zu/%zu %s --%s\n", dim, i + 1, n, label, off); + + /* 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); + } + + const char *content = json_get_string(m, "content"); + if (content && *content) { + fputs(color, stdout); + fputs(content, stdout); + fputs(off, stdout); + if (content[strlen(content) - 1] != '\n') putchar('\n'); + } else if (!json_object_get(m, "tool_calls")) { + printf("%s%s%s\n", dim, t("repl.no_content"), off); + } + + /* 工具调用的参数完整打出来 —— 这常常是整段历史里信息量最大的部分 */ + JsonValue *tcs = json_object_get(m, "tool_calls"); + for (size_t k = 0; k < json_array_size(tcs); k++) { + JsonValue *tc = json_array_get(tcs, k); + JsonValue *fn = json_object_get(tc, "function"); + const char *name = json_get_string(fn, "name"); + const char *args = json_get_string(fn, "arguments"); + const char *id = json_get_string(tc, "id"); + printf("%s%s%s\n", dim, + tf("repl.tool_call", "name", name ? name : "?", + "id", id ? id : "", NULL), off); + if (args && *args) { + printf("%s %s%s\n", dim, args, off); + } + } + } + if (n == 0) printf("%s%s%s\n", dim, t("repl.archive_empty"), off); + putchar('\n'); +} + +static void cmd_import(Agent *a, const char *arg) +{ + ArchiveEntry *list = NULL; + size_t n = archive_list(g_bindir ? g_bindir : ".", &list); + + if (n == 0) { + char *dir = archive_dir(g_bindir ? g_bindir : "."); + printf("%s", tf("repl.import.none", "dir", dir, NULL)); + free(dir); + return; + } + + const ArchiveEntry *pick = NULL; + + if (arg && *arg) { + pick = find_entry(list, n, arg); + if (!pick) { + printf("%s", tf("repl.import.not_found", "name", arg, NULL)); + archive_list_free(list, n); + return; + } + } else { + char *dir = archive_dir(g_bindir ? g_bindir : "."); + printf("%s", tf("repl.import.list_header", "dir", dir, + "n", i18n_num((long long)n), NULL)); + free(dir); + for (size_t i = 0; i < n; i++) { + if (list[i].count < 0) + printf(t("repl.import.row_failed"), i + 1, list[i].name); + else + printf(t("repl.import.row"), i + 1, list[i].name, + list[i].created ? list[i].created : "?", list[i].count, + list[i].model ? list[i].model : ""); + } + + int intr = 0; + char *line = line_edit(t("repl.import.pick_prompt"), &intr); + if (!line) { archive_list_free(list, n); return; } + + char *ans = str_trim(line); + if (*ans == '\0') { + puts(t("repl.cancelled")); + free(line); + archive_list_free(list, n); + return; + } + char *numend = NULL; + long idx = strtol(ans, &numend, 10); + if (numend != ans && *str_trim(numend) == '\0') { + if (idx < 1 || (size_t)idx > n) { + printf("%s", tf("repl.index_out_of_range_plain", + "n", i18n_num(idx), NULL)); + free(line); + archive_list_free(list, n); + return; + } + pick = &list[idx - 1]; + } else { + pick = find_entry(list, n, ans); + if (!pick) { + printf("%s", tf("repl.import.not_found_cancelled", + "name", ans, NULL)); + free(line); + archive_list_free(list, n); + return; + } + } + free(line); + } + + char err[512]; + JsonValue *msgs = archive_load(pick->path, NULL, NULL, NULL, NULL, NULL, + err, sizeof err); + if (!msgs) { + printf("%s", tf("repl.import.load_failed", "error", err, NULL)); + archive_list_free(list, n); + return; + } + char *loaded = xstrdup(pick->name); + agent_replace_history(a, msgs); /* 取得所有权 */ + /* 载入之后再数:换成和 /history 一样的口径(不含 system), + * 否则"已载入 N 条"和"历史消息 M 条"会差 1,看着像出了错 */ + size_t count = agent_conversation_len(a); + /* 输入历史不跟着清:它现在是一份持久的、跨会话的用户资源(像 bash 的 + * HISTFILE),记的是"我在 ligent> 敲过什么",和当前装的是哪段对话无关。 */ + set_lineage(loaded); /* 之后的分支都挂在它下面 */ + printf("%s", tf("repl.import.done", "name", loaded, + "n", i18n_num((long long)count), NULL)); + /* 问一句再决定要不要把这段历史打出来 —— 直接回车就输出(默认 Y), + * 历史很长、不想被刷屏时回 n 即可,条数随时可以用 /history 看。 */ + if (ask_yes_no(t("repl.import.print_ask"))) + print_history(a, a->messages); + free(loaded); + archive_list_free(list, n); +} + +static void repl_help(void) +{ + fputs(t("repl.help"), stdout); +} + +/* 带参数命令的匹配:/name 之后必须是空白或字符串结束。 + * + * 只用前缀匹配是不够的 —— 那样 "/modelxxx" 会被当成 "/model" 并把 xxx + * 当作模型名去切模型,"/thinkfoo" 也会落进 /think 分支。命中返回参数起点 + * (已跳过空白;指进 dup 里,调用方不要 free),不命中返回 NULL。 */ +static char *cmd_arg(char *cmd, const char *name) +{ + size_t n = strlen(name); + if (strncmp(cmd, name, n) != 0) return NULL; + char c = cmd[n]; + if (c != '\0' && c != ' ' && c != '\t') return NULL; + return str_trim(cmd + n); +} + +/* 返回 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 内部,不单独释放 */ + + if (strcmp(cmd, "/exit") == 0 || strcmp(cmd, "/quit") == 0) { + free(dup); + return 1; + } + if (strcmp(cmd, "/help") == 0 || strcmp(cmd, "/h") == 0 || + strcmp(cmd, "/?") == 0) { + repl_help(); + } 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() 只有秒级 + * 精度 —— 同一秒里重置、又聊出同样多条消息,退出时就会被当成 + * "内容没变"而跳过存档。 */ + g_saved_count = 0; + g_saved_start = 0; + set_lineage(NULL); /* 新对话另起一个根 */ + back_undo_clear(); /* 旧的回退记录不再适用 */ + puts(t("repl.reset.done")); + } else if ((arg = cmd_arg(cmd, "/model")) != NULL) { + if (*arg) apply_model(a, c, arg); + else cmd_model_pick(a, c); + } else if ((arg = cmd_arg(cmd, "/include")) != NULL) { + cmd_include(a, arg); + } else if ((arg = cmd_arg(cmd, "/system")) != NULL) { + if (*arg) { + free(c->system); + c->system = xstrdup(arg); + agent_reset(a); + attach_clear(); /* 重新开始,暂存的附件也一并丢掉 */ + agent_add_system(a, c->system); + puts(t("repl.system.updated")); + } else { + printf("%s\n%s\n", t("repl.system.current"), + c->system ? c->system : t("repl.system.builtin")); + } + } else if ((arg = cmd_arg(cmd, "/think")) != NULL) { + if (*arg) { + int mode; + if (config_parse_thinking(arg, &mode) == 0) { + c->thinking = mode; + a->llm.thinking = mode; + printf(t("repl.think.mode"), config_thinking_name(mode)); + } else if (config_effort_valid(arg)) { + free(c->reasoning_effort); + c->reasoning_effort = xstrdup(arg); + a->llm.reasoning_effort = c->reasoning_effort; + printf("%s", tf("repl.think.effort_set", "level", arg, NULL)); + } else { + printf("%s", tf("repl.think.bad", "value", arg, + "list", config_effort_list(), NULL)); + } + } else { + printf(t("repl.think.mode"), config_thinking_name(c->thinking)); + printf(t("repl.think.effort"), + (c->reasoning_effort && *c->reasoning_effort) + ? c->reasoning_effort : t("cfg.val.server_default")); + } + } else if (strcmp(cmd, "/meta") == 0) { + /* 进 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_undo_clear(); + g_saved_count = 0; + g_saved_start = 0; + } + } else if (strcmp(cmd, "/cls") == 0) { + /* 只清屏幕,不碰对话历史 —— 和 /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 的注释。 */ + agent_probe_now(a); + } else if (strcmp(cmd, "/config") == 0) { + config_dump(c); + } else if ((arg = cmd_arg(cmd, "/cd")) != NULL) { + if (*arg) { + if (chdir(arg) != 0) perror(t("repl.cd_failed")); + else { + char cwd[1024]; + if (getcwd(cwd, sizeof cwd)) + printf("%s", tf("repl.cd.current", "dir", cwd, NULL)); + } + } else { + char cwd[1024]; + if (getcwd(cwd, sizeof cwd)) printf("%s\n", cwd); + } + } else if (strcmp(cmd, "/usage") == 0) { + if (!a->usage_reported) { + puts(t("repl.usage.none")); + } else { + printf("%s", t("repl.usage.header")); + printf(t("repl.usage.calls"), a->api_calls); + printf(t("repl.usage.prompt"), a->tok_prompt); + printf(t("repl.usage.completion"), a->tok_completion); + printf(t("repl.usage.total"), a->tok_total); + printf(t("repl.usage.last"), a->tok_last_prompt); + printf(t("repl.usage.history"), agent_conversation_len(a)); + } + } else if (strcmp(cmd, "/yolo") == 0) { + int on = !confirm_enabled(); + confirm_set_enabled(on); + a->confirm = on; + printf("%s", tf("repl.yolo.set", "state", + on ? t("repl.yolo.on") : t("repl.yolo.off"), NULL)); + } else if (strcmp(cmd, "/back") == 0) { + JsonValue *removed = agent_take_last_round(a); + if (!removed) { + puts(t("repl.back.none")); + } else { + size_t rn = json_array_size(removed); + if (!g_back_undo) g_back_undo = json_new_array(); + 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); /* 比对用,保持数组真实长度 */ + printf("%s", tf("repl.back.done", "n", i18n_num((long long)rn), + "left", i18n_num((long long)agent_conversation_len(a)), + NULL)); + } + } else if (strcmp(cmd, "/unback") == 0) { + size_t n = g_back_undo ? json_array_size(g_back_undo) : 0; + if (n == 0) { + puts(t("repl.unback.none")); + } else if (agent_history_len(a) != g_back_len) { + puts(t("repl.unback.new_chat")); + } else { + for (size_t i = 0; i < n; i++) + json_array_push(a->messages, + json_clone(json_array_get(g_back_undo, i))); + printf("%s", tf("repl.unback.done", "n", i18n_num((long long)n), + "total", i18n_num((long long)agent_conversation_len(a)), + NULL)); + back_undo_clear(); + } + } else if ((arg = cmd_arg(cmd, "/shell")) != NULL) { + if (*arg) { + /* 带参数:直接执行它,不进入交互式 shell */ + JsonValue *args = json_new_object(); + json_object_set_string(args, "command", arg); + int is_err = 0; + char *r = tool_execute(TOOL_RUN_COMMAND, args, &is_err); + json_free(args); + puts(r); + char *msg = xasprintf("我在 shell 里执行了:%s\n\n结果是:\n%s", + arg, r); + agent_add_user(a, msg); + free(msg); + free(r); + puts(t("repl.shell.added_hint")); + } else { + puts(t("repl.shell.enter")); + char err[256]; + int code = 0; + char *rec = shell_interactive(&code, err, sizeof err); + if (!rec) { + printf("%s", tf("repl.shell.start_failed", "error", err, NULL)); + } else { + if (rec[0]) { + char *msg = xasprintf( + "我在交互式 shell 里做了以下操作(shell 退出码 %d):\n\n" + "```console\n%s\n```\n", code, rec); + agent_add_user(a, msg); + printf("\n%s\n", + tf("repl.shell.recorded", "n", + i18n_num((long long)strlen(rec)), NULL)); + free(msg); + } else { + printf("\n%s\n", t("repl.shell.nothing")); + } + free(rec); + } + } + } else if (strcmp(cmd, "/export") == 0) { + cmd_export(a, c); + } else if ((arg = cmd_arg(cmd, "/import")) != NULL) { + auto_save(a, c, t("repl.why.before_import"), ARCHIVE_TMP_PREFIX); + cmd_import(a, arg); + back_undo_clear(); + } else if ((arg = cmd_arg(cmd, "/log")) != NULL) { + if (*arg == '\0') { + printf("%s", tf("repl.log.status", "n", + i18n_num((long long)http_log_size()), + "trunc", http_log_truncated() + ? t("repl.log.truncated") : "", NULL)); + } else if (strcmp(arg, "clear") == 0 || strcmp(arg, "reset") == 0) { + http_log_clear(); + puts(t("repl.log.cleared")); + } else { + size_t n = 0; + char err[256]; + if (http_log_save(arg, &n, err, sizeof err) != 0) + printf("%s", tf("repl.log.save_failed", "error", err, NULL)); + else if (n == 0) + printf("%s", tf("repl.log.empty", "file", arg, NULL)); + else + printf("%s", tf("repl.log.saved", "n", + i18n_num((long long)n), "file", arg, NULL)); + } + } else if (strcmp(cmd, "/history") == 0) { + /* 一行摘要 + 完整对话。 + * + * 摘要那行报的是"不含 system"的口径(和 /usage、/back、/export 一致), + * 而下面列出来的是**含** system 的全部消息 —— 两个数不一样,所以必须 + * 说清楚,否则用户会以为打印漏了或者多了一条。 + * + * 为什么要连 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", + i18n_num((long long)agent_history_len(a)), NULL)); + print_history(a, a->messages); + } else { + printf("%s", tf("repl.unknown_command", "cmd", cmd, NULL)); + } + free(dup); + return 0; +} + +/* 输入历史最多保留多少条。和 bash 一样有上限,否则文件会一直长下去; + * 只保留最新的,旧的在下次启动时被就地截掉。 */ +#define LIGENT_HISTORY_KEEP 1000 + +/* 输入历史文件;NULL 表示禁用(--no-history)。 */ +static char *g_hist_path = NULL; + +/* 提示符保持纯文本:绿色由行编辑器统一加在整个输入行(提示符 + 你敲的字)上。 + * 在这里塞颜色的话,提示符自带的 reset 会让后面的输入掉回默认色。 */ +#define PROMPT_MAIN "ligent> " +#define PROMPT_CONT " ... " + +/* 读一条完整输入。支持行尾反斜杠续行:每段都由行编辑器独立编辑, + * 因此方向键、Home/End、历史在续行的每一段里都可用。 */ +static char *read_prompt_line(const char *prompt, int *interrupted) +{ + StrBuf sb; + sb_init(&sb); + *interrupted = 0; + int first = 1; + + for (;;) { + int seg_int = 0; + char *seg = line_edit(first ? prompt : PROMPT_CONT, &seg_int); + first = 0; + + if (!seg) { + if (seg_int) { + g_ligent_interrupt = 0; + *interrupted = 1; + sb_free(&sb); + return NULL; + } + /* EOF:缓冲区里还有内容就把它交出去,否则终止 */ + if (sb.len > 0) { + if (sb.data[sb.len - 1] != '\n') sb_putc(&sb, '\n'); + return sb_detach(&sb); + } + sb_free(&sb); + return NULL; + } + + /* 整行空且不是续行状态 => 当作空输入返回 */ + if (!*seg && sb.len == 0) { + free(seg); + sb_puts(&sb, "\n"); + return sb_detach(&sb); + } + + size_t n = strlen(seg); + int cont = 0; + if (n > 0 && seg[n - 1] == '\\') { seg[n - 1] = '\0'; cont = 1; } + + sb_puts(&sb, seg); + free(seg); + if (!cont) { + sb_putc(&sb, '\n'); + /* 历史记的是"这一整条输入":多行续行算一条,上下键能整条召回。 + * 续行的中间段不单独记。这里借尾部那个换行临时当结束符。 */ + if (sb.len > 1) { + sb.data[sb.len - 1] = '\0'; + if (line_history_add(sb.data)) + line_history_append(g_hist_path, sb.data); + sb.data[sb.len - 1] = '\n'; + } + break; + } + sb_putc(&sb, '\n'); + } + return sb_detach(&sb); +} + +static int run_repl(Agent *a, LigentConfig *c) +{ + g_bindir = c->config_dir; + /* 图片备份也要落在二进制旁边。这个函数一度**从来没有被调用过**, + * image_bindir() 于是退化成 ".",备份跟着当前工作目录跑 —— /cd 到别的 + * 目录再带图,备份就写到那儿去了,之后从别处发消息会因为找不到备份而 + * 把图降级掉。测试一直从二进制所在目录跑,"." 恰好等于正确值,所以 + * 一直没暴露。 */ + image_set_bindir(c->config_dir); + print_banner(c, a); + for (;;) { + int interrupted = 0; + char *line = read_prompt_line(PROMPT_MAIN, &interrupted); + if (!line) { + if (interrupted) continue; + break; /* EOF */ + } + char *trimmed = str_trim(line); + if (*trimmed == '\0') { free(line); continue; } + + if (*trimmed == '/') { + int quit = handle_command(a, c, trimmed); + free(line); + if (quit) break; + continue; + } + + /* 暂存的附件跟着这条消息一起发出去 */ + agent_add_user_parts(a, trimmed, attach_take_parts()); + free(line); + + int rc = agent_run(a); + g_ligent_interrupt = 0; + if (rc < 0) + fprintf(stderr, "%s", t("repl.turn_failed")); + putchar('\n'); + } + auto_save(a, c, t("repl.why.exit"), NULL); + puts(t("repl.bye")); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* main */ +/* ------------------------------------------------------------------ */ + +int main(int argc, char **argv) +{ + LigentConfig cfg; + const char *explicit_config = NULL; + char err[512]; + + /* 界面语言:--lang > LIGENT_LANG > LC_ALL/LC_MESSAGES/LANG > 英文。 + * 先粗扫一遍 argv,好让 --help 和 --lang 的先后顺序不影响结果 —— + * 解析循环一遇到 --help 就直接返回,那之后再写 --lang 就来不及了。 */ + { + int lang = -1; + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--lang") == 0 && i + 1 < argc) { + lang = i18n_parse_lang(argv[i + 1]); + break; + } + if (strncmp(argv[i], "--lang=", 7) == 0) { + lang = i18n_parse_lang(argv[i] + 7); + break; + } + } + i18n_init(lang); /* -1 = 按环境探测 */ + } + + /* 先用一个临时结构收集命令行覆盖项 */ + struct { + const char *model, *base_url, *api_key, *system, *prompt_text; + int have_temperature, have_max_tokens, have_max_steps, have_timeout; + int have_retries; + int thinking, thinking_set; + const char *effort; + const char *history; + int history_set, history_off; + double temperature; + long max_tokens, timeout; + int max_steps; + int retries; + int stream_set, stream_val; + int probe_set, probe_val; + int confirm_set, confirm_val; + int autosave_set, autosave_val; + int doctor; + int offline; + int tools_set, tools_val; + int verbose, quiet; + int color_set, color_val; + int print_config; + StrBuf prompt; + } cli; + memset(&cli, 0, sizeof cli); + sb_init(&cli.prompt); + + for (int i = 1; i < argc; i++) { + const char *arg = argv[i]; + const char *val = NULL; + char optbuf[64]; + + if (arg[0] == '-' && arg[1] == '-') { + const char *eq = strchr(arg, '='); + if (eq) { + size_t n = (size_t)(eq - arg); + if (n >= sizeof optbuf) n = sizeof optbuf - 1; + memcpy(optbuf, arg, n); + optbuf[n] = '\0'; + arg = optbuf; + val = eq + 1; + } + } + +#define NEED_VAL() \ + do { \ + if (!val) { \ + if (i + 1 >= argc) { \ + fprintf(stderr, "%s", tf("cli.err.missing_value", "option", arg, NULL)); \ + return 2; \ + } \ + val = argv[++i]; \ + } \ + } while (0) + + if (strcmp(arg, "-h") == 0 || strcmp(arg, "--help") == 0) { + print_usage(stdout); + return 0; + } else if (strcmp(arg, "-V") == 0 || strcmp(arg, "--version") == 0) { + printf("ligent %s\n", ligent_version_full()); + return 0; + } else if (strcmp(arg, "-m") == 0 || strcmp(arg, "--model") == 0) { + NEED_VAL(); cli.model = val; + } else if (strcmp(arg, "-u") == 0 || strcmp(arg, "--base-url") == 0) { + NEED_VAL(); cli.base_url = val; + } else if (strcmp(arg, "-k") == 0 || strcmp(arg, "--api-key") == 0) { + NEED_VAL(); cli.api_key = val; + } else if (strcmp(arg, "-c") == 0 || strcmp(arg, "--config") == 0) { + NEED_VAL(); explicit_config = val; + } else if (strcmp(arg, "--history") == 0) { + NEED_VAL(); cli.history = val; cli.history_set = 1; cli.history_off = 0; + } else if (strcmp(arg, "--no-history") == 0) { + cli.history_set = 1; cli.history_off = 1; + } else if (strcmp(arg, "-s") == 0 || strcmp(arg, "--system") == 0) { + NEED_VAL(); cli.system = val; + } else if (strcmp(arg, "-t") == 0 || strcmp(arg, "--temperature") == 0) { + NEED_VAL(); cli.temperature = atof(val); cli.have_temperature = 1; + } else if (strcmp(arg, "--max-tokens") == 0) { + NEED_VAL(); cli.max_tokens = atol(val); cli.have_max_tokens = 1; + } else if (strcmp(arg, "--thinking") == 0 || strcmp(arg, "--think") == 0) { + NEED_VAL(); + if (config_parse_thinking(val, &cli.thinking) != 0) { + fprintf(stderr, "%s", tf("cli.err.bad_thinking", "value", val, NULL)); + return 2; + } + cli.thinking_set = 1; + } else if (strcmp(arg, "--effort") == 0 || strcmp(arg, "--reasoning-effort") == 0) { + NEED_VAL(); + if (!config_effort_valid(val)) { + fprintf(stderr, "%s", tf("cli.err.bad_effort", "list", + config_effort_list(), "value", val, NULL)); + return 2; + } + cli.effort = val; + } else if (strcmp(arg, "--max-steps") == 0) { + NEED_VAL(); cli.max_steps = atoi(val); cli.have_max_steps = 1; + } else if (strcmp(arg, "--timeout") == 0) { + NEED_VAL(); cli.timeout = atol(val); cli.have_timeout = 1; + } else if (strcmp(arg, "--retries") == 0) { + NEED_VAL(); cli.retries = atoi(val); cli.have_retries = 1; + } else if (strcmp(arg, "--no-stream") == 0) { + cli.stream_set = 1; cli.stream_val = 0; + } else if (strcmp(arg, "--stream") == 0) { + cli.stream_set = 1; cli.stream_val = 1; + } else if (strcmp(arg, "doctor") == 0 && !cli.doctor && cli.prompt.len == 0) { + cli.doctor = 1; /* 子命令:ligent doctor */ + } else if (strcmp(arg, "--offline") == 0) { + cli.offline = 1; + } else if (strcmp(arg, "--no-confirm") == 0) { + cli.confirm_set = 1; cli.confirm_val = 0; + } else if (strcmp(arg, "--confirm") == 0) { + cli.confirm_set = 1; cli.confirm_val = 1; + } else if (strcmp(arg, "--no-auto-save") == 0) { + cli.autosave_set = 1; cli.autosave_val = 0; + } else if (strcmp(arg, "--auto-save") == 0) { + cli.autosave_set = 1; cli.autosave_val = 1; + } else if (strcmp(arg, "--no-probe") == 0) { + cli.probe_set = 1; cli.probe_val = 0; + } else if (strcmp(arg, "--probe") == 0) { + cli.probe_set = 1; cli.probe_val = 1; + } else if (strcmp(arg, "--no-tools") == 0) { + cli.tools_set = 1; cli.tools_val = 0; + } else if (strcmp(arg, "--tools") == 0) { + cli.tools_set = 1; cli.tools_val = 1; + } else if (strcmp(arg, "-q") == 0 || strcmp(arg, "--quiet") == 0) { + cli.quiet = 1; + } else if (strcmp(arg, "-v") == 0 || strcmp(arg, "--verbose") == 0) { + cli.verbose = 1; + } else if (strcmp(arg, "--color") == 0) { + cli.color_set = 1; cli.color_val = 1; + } else if (strcmp(arg, "--no-color") == 0) { + cli.color_set = 1; cli.color_val = 0; + } else if (strcmp(arg, "--lang") == 0) { + NEED_VAL(); + int l = i18n_parse_lang(val); + if (l < 0 && strcmp(val, "auto") != 0) { + fprintf(stderr, "%s", tf("cli.err.bad_lang", "value", val, NULL)); + return 2; + } + i18n_init(l); /* "auto" 也是 -1:交给环境探测 */ + } else if (strcmp(arg, "--print-config") == 0) { + cli.print_config = 1; + } else if (strcmp(arg, "--") == 0) { + for (int j = i + 1; j < argc; j++) { + if (cli.prompt.len) sb_putc(&cli.prompt, ' '); + sb_puts(&cli.prompt, argv[j]); + } + break; + } else if (arg[0] == '-' && arg[1] != '\0') { + fprintf(stderr, "%s", tf("cli.err.unknown_option", "option", arg, NULL)); + return 2; + } else { + if (cli.prompt.len) sb_putc(&cli.prompt, ' '); + sb_puts(&cli.prompt, argv[i]); + } +#undef NEED_VAL + } + + /* 配置:默认值 → 文件 → 环境变量 → 命令行 */ + /* doctor 要能在"配置本身坏了"的时候跑起来 —— 那正是最需要它的时刻 */ + char cfg_error[512]; + cfg_error[0] = '\0'; + if (config_init(&cfg, explicit_config, argv[0], err, sizeof err) != 0) { + if (!cli.doctor) { + fprintf(stderr, "%s", tf("cli.err.config", "error", err, NULL)); + return 2; + } + snprintf(cfg_error, sizeof cfg_error, "%s", err); + } + if (cli.model) { free(cfg.model); cfg.model = xstrdup(cli.model); } + if (cli.base_url) { free(cfg.base_url); cfg.base_url = xstrdup(cli.base_url); } + if (cli.api_key) { free(cfg.api_key); cfg.api_key = xstrdup(cli.api_key); + cfg.api_key_from_cli = 1; } + if (cli.system && *cli.system) { free(cfg.system); cfg.system = xstrdup(cli.system); } + if (cli.have_temperature) cfg.temperature = cli.temperature; + if (cli.have_max_tokens) cfg.max_tokens = cli.max_tokens; + if (cli.thinking_set) cfg.thinking = cli.thinking; + if (cli.effort) { + free(cfg.reasoning_effort); + cfg.reasoning_effort = xstrdup(cli.effort); + } + if (cli.have_timeout) cfg.timeout = cli.timeout; + if (cli.have_max_steps) cfg.max_steps = cli.max_steps; + if (cli.have_retries) cfg.retries = cli.retries; + if (cli.stream_set) cfg.stream = cli.stream_val; + if (cli.tools_set) cfg.use_tools = cli.tools_val; + if (cli.probe_set) cfg.probe = cli.probe_val; + if (cli.confirm_set) cfg.confirm = cli.confirm_val; + if (cli.autosave_set) cfg.auto_save = cli.autosave_val; + + /* doctor 要在"缺 key 直接退出"之前跑 —— 没配好正是它要诊断的场景 */ + if (cli.doctor) { + int rc = doctor_run(&cfg, argv[0], !cli.offline, cfg_error); + config_free(&cfg); + sb_free(&cli.prompt); + return rc == 0 ? 0 : 1; + } + + if (cli.print_config) { + config_dump(&cfg); + config_free(&cfg); + sb_free(&cli.prompt); + return 0; + } + + /* 没有 key 时尽早报错,而不是等第一次请求失败 */ + if (!cfg.api_key || !*cfg.api_key) { + fprintf(stderr, "%s", t("cli.err.no_api_key")); + config_free(&cfg); + sb_free(&cli.prompt); + return 2; + } + + if (http_global_init() != 0) { + fprintf(stderr, "%s", t("cli.err.curl_init")); + config_free(&cfg); + sb_free(&cli.prompt); + return 1; + } + + Agent agent; + agent_init(&agent); + apply_config(&agent, &cfg); + agent.llm.verbose = cli.verbose; + agent.show_tool_output = !cli.quiet; + confirm_init(cfg.confirm); + g_auto_save_on = cfg.auto_save; + if (cli.color_set) agent_set_color(&agent, cli.color_val); + else agent_set_color(&agent, isatty(fileno(stdout)) ? 1 : 0); + line_edit_set_color(agent.color); /* 输入行跟正文用同一个配色开关 */ + + if (cfg.system) { + agent_add_system(&agent, cfg.system); + } else { + char *sp = agent_default_system_prompt(); + agent_add_system(&agent, sp); + free(sp); + } + + /* 只在 stderr 是终端时转 —— 重定向/管道不该收到动画 */ + spinner_init(isatty(fileno(stderr))); + + install_signals(); + + int exit_code = 0; + + g_bindir = cfg.config_dir; + image_set_bindir(cfg.config_dir); /* 理由见 run_repl 里那段注释 */ + + /* 输入历史文件:命令行 > 环境变量 > <二进制目录>/ligent.history。 + * 和配置一样,默认只放二进制旁边,不碰用户级/系统级目录。 */ + { + const char *env = getenv("LIGENT_HISTORY"); + if (cli.history_off) { + g_hist_path = NULL; + } else if (cli.history_set && cli.history) { + g_hist_path = xstrdup(cli.history); + } else if (env && *env) { + g_hist_path = xstrdup(env); + } else { + g_hist_path = xasprintf("%s/ligent.history", cfg.config_dir); + } + /* 读不回来不是错误:第一次跑本来就没有这个文件,只读的目录也不该 + * 拦住 REPL。加载失败静默降级成"这次没有历史"。 */ + if (g_hist_path) line_history_load(g_hist_path, LIGENT_HISTORY_KEEP); + } + + if (cli.prompt.len > 0) { + /* 单次任务模式 */ + agent_add_user(&agent, cli.prompt.data); + int rc = agent_run(&agent); + exit_code = rc == 0 ? 0 : 1; + auto_save(&agent, &cfg, t("repl.why.exit"), NULL); + } else if (!isatty(fileno(stdin))) { + /* 管道输入:把 stdin 当任务 */ + char *text = read_stdin_all(); + char *trimmed = str_trim(text); + if (*trimmed) { + agent_add_user(&agent, trimmed); + int rc = agent_run(&agent); + exit_code = rc == 0 ? 0 : 1; + auto_save(&agent, &cfg, t("repl.why.exit"), NULL); + } else { + print_usage(stderr); + exit_code = 2; + } + free(text); + } else { + exit_code = run_repl(&agent, &cfg); + } + + agent_free(&agent); + config_free(&cfg); + sb_free(&cli.prompt); + http_log_free(); + free(g_lineage); + back_undo_clear(); + http_global_cleanup(); + return exit_code; +} diff --git a/src/markdown.c b/src/markdown.c new file mode 100644 index 0000000..acd6d2f --- /dev/null +++ b/src/markdown.c @@ -0,0 +1,792 @@ +/* markdown.c - 流式 Markdown 渲染状态机 + * + * 核心约束:SSE 的分片位置是任意的,一个标记(`**`、行首的 `#`、`[`) + * 完全可能被切成两片。因此遇到"可能是标记前缀"的尾部时不能立即输出, + * 必须把这段字节留在缓冲区里等下一个分片凑齐后再判定。 + */ +#include "markdown.h" +#include "util.h" + +#include +#include +#include + +/* 样式定义(硬编码 SGR,不依赖 terminfo) + * + * **只用 16 色范围内的码**:前景 30-37,加粗/暗淡/斜体用 1/2/3。 + * 不用 90-97 那一段(aixterm 的"亮色")—— 它虽然到处都是,但严格讲不属于 + * 最窄的 16 色 ANSI,而这段代码是硬编码 SGR、不做终端能力探测的(连 terminfo + * 都不查)。既然不探测,就该待在**所有终端都认**的那一档里。 + * + * 原来链接用的是亮蓝(aixterm 的 94),是这里唯一一个越界的码。改成 + * "\033[4;36m"(下划线 + 青):链接在终端里的传统表示本来就是下划线, + * 而且青色和行内代码、列表符号同色系,视觉上"这些是标记不是正文"是一致的。 + * 更重要的是下划线是**属性**(SGR 4),比颜色更可靠 —— 单色终端、色盲用户、 + * 以及把颜色重映射过的主题里,下划线都还在。 + * + * (这里不写出那个越界码的字面量,免得以后 grep `\[9[0-7]` 做审计时被注释 + * 里的这一行报成假阳性。) */ +#define MD_RESET "\033[0m" +#define MD_BOLD "\033[1m" +#define MD_DIM "\033[2m" +#define MD_ITALIC "\033[3m" +#define MD_CODE "\033[36m" /* 行内代码:青 */ +#define MD_BULLET "\033[36m" +#define MD_LINK "\033[4;36m" /* 链接文字:下划线 + 青 */ +#define MD_H1 "\033[1;36m" /* 粗体青 */ +#define MD_H2 "\033[1;35m" /* 粗体品红 */ +#define MD_H3 "\033[1;34m" /* 粗体蓝 */ +#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 { + int enabled; + FILE *out; + StrBuf in; /* 跨分片的残留数据 */ + + int line_start; /* 下一个字符是否位于行首(块级语法只在行首生效) */ + int line_style; /* 本行处于行级样式(标题)中,换行时要复位 */ + int last_ch; /* 上一个已消费的输入字节,用于 _ 和 [ 的边界判断 */ + + int in_fence; /* 位于围栏代码块内 */ + char fence_char; + int fence_len; + + int in_bold; + int in_italic; + int in_code; /* 行内代码 */ + int in_bracket; /* 位于 [链接文字] 内 */ + int bracket_len; + + int in_table; /* 正在累积一个表格 */ + StrBuf table; /* 表格原始行 */ + int table_rows; + + /* 置位后表示"不会再有新数据",所有挂起都必须就地解开。 + * 这是"输出与分片方式无关"的关键:挂起只允许发生在还有后续数据时, + * 到 EOF 一律按固定规则判定,决策因此只依赖字节流本身。 */ + int eof; +}; + +/* ------------------------------------------------------------------ */ +/* 输出原语 */ +/* ------------------------------------------------------------------ */ + +static void put(MdRenderer *r, const char *s, size_t n) +{ + if (n) fwrite(s, 1, n, r->out); +} + +static void putc_str(MdRenderer *r, const char *s) { fputs(s, r->out); } + +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 || + r->in_bracket || r->line_style) { + style(r, MD_RESET); + r->in_bold = r->in_italic = r->in_code = 0; + r->in_bracket = 0; + r->bracket_len = 0; + r->line_style = 0; + } +} + +/* 显示宽度相关工具已移到 util.c(行编辑器也要用) */ + +/* ------------------------------------------------------------------ */ +/* 表格:解析 */ +/* ------------------------------------------------------------------ */ + +typedef struct { + char **cell; + size_t n; +} TblRow; + +typedef enum { ALIGN_LEFT, ALIGN_CENTER, ALIGN_RIGHT } TblAlign; + +static void row_free(TblRow *row) +{ + for (size_t i = 0; i < row->n; i++) free(row->cell[i]); + free(row->cell); + row->cell = NULL; + row->n = 0; +} + +/* 按 '|' 拆分一行,去掉首尾空白与首尾空单元格 */ +static void row_split(const char *line, size_t n, TblRow *out) +{ + out->cell = NULL; + out->n = 0; + size_t cap = 0; + + while (n > 0 && (line[0] == ' ' || line[0] == '\t')) { line++; n--; } + while (n > 0 && (line[n - 1] == ' ' || line[n - 1] == '\t')) n--; + if (n > 0 && line[0] == '|') { line++; n--; } + if (n > 0 && line[n - 1] == '|') n--; + + size_t start = 0; + for (size_t i = 0; i <= n; i++) { + if (i != n && line[i] != '|') continue; + const char *c = line + start; + size_t cl = i - start; + while (cl > 0 && (c[0] == ' ' || c[0] == '\t')) { c++; cl--; } + while (cl > 0 && (c[cl - 1] == ' ' || c[cl - 1] == '\t')) cl--; + if (out->n == cap) { + cap = cap ? cap * 2 : 8; + out->cell = xrealloc(out->cell, cap * sizeof(char *)); + } + out->cell[out->n++] = xstrndup(c, cl); + start = i + 1; + } +} + +/* 分隔单元格::?-+:? */ +static int is_delim_cell(const char *c, size_t n) +{ + size_t i = 0, dashes = 0; + if (i < n && c[i] == ':') i++; + while (i < n && c[i] == '-') { i++; dashes++; } + if (dashes == 0) return 0; + if (i < n && c[i] == ':') i++; + return i == n; +} + +/* 表头行 + 分隔行是否构成合法表格: + * 两者单元格数必须相同,且第二行每格都是 :?-+:? + * (这条校验同时挡掉了 "cat a | grep b" 跟着一行 "--------" 的误判) */ +static int table_head_ok(const char *h, size_t hn, const char *d, size_t dn) +{ + TblRow hrow, drow; + row_split(h, hn, &hrow); + row_split(d, dn, &drow); + int ok = (hrow.n > 0) && (hrow.n == drow.n); + for (size_t i = 0; ok && i < drow.n; i++) + if (!is_delim_cell(drow.cell[i], strlen(drow.cell[i]))) ok = 0; + row_free(&hrow); + row_free(&drow); + return ok; +} + +static int count_pipes(const char *s, size_t n) +{ + int c = 0; + for (size_t i = 0; i < n; i++) + if (s[i] == '|') c++; + return c; +} + +/* ------------------------------------------------------------------ */ +/* 表格:渲染 */ +/* ------------------------------------------------------------------ */ + +static void emit_padded(MdRenderer *r, const char *cell, size_t width, TblAlign al) +{ + size_t w = display_width(cell, strlen(cell)); + size_t pad = width > w ? width - w : 0; + size_t left = 0, right = pad; + if (al == ALIGN_CENTER) { left = pad / 2; right = pad - left; } + else if (al == ALIGN_RIGHT) { left = pad; right = 0; } + + for (size_t i = 0; i < left; i++) putc_str(r, " "); + putc_str(r, cell); + for (size_t i = 0; i < right; i++) putc_str(r, " "); +} + +static void emit_rule(MdRenderer *r, const size_t *w, size_t ncols, + const char *left, const char *mid, const char *right) +{ + style(r, MD_DIM); + putc_str(r, left); + for (size_t c = 0; c < ncols; c++) { + for (size_t k = 0; k < w[c] + 2; k++) putc_str(r, "─"); + if (c + 1 < ncols) putc_str(r, mid); + } + putc_str(r, right); + style(r, MD_RESET); + putc_str(r, "\n"); +} + +static void emit_row(MdRenderer *r, const TblRow *row, const size_t *w, + const TblAlign *al, size_t ncols, int header) +{ + style(r, MD_DIM); + putc_str(r, "│"); + for (size_t c = 0; c < ncols; c++) { + putc_str(r, " "); + style(r, MD_RESET); + if (header) style(r, MD_BOLD); + emit_padded(r, c < row->n ? row->cell[c] : "", w[c], al[c]); + style(r, MD_RESET); + style(r, MD_DIM); + putc_str(r, " │"); + } + style(r, MD_RESET); + putc_str(r, "\n"); +} + +/* 把 r->table 里累积的原始行渲染成带边框的表格 */ +static void table_flush(MdRenderer *r) +{ + r->in_table = 0; + r->table_rows = 0; + if (r->table.len == 0) return; + + TblRow *rows = NULL; + size_t nrows = 0, cap = 0; + char *data = r->table.data; + size_t start = 0; + for (size_t i = 0; i <= r->table.len; i++) { + if (i != r->table.len && data[i] != '\n') continue; + size_t ln = i - start; + if (ln > 0) { + if (nrows == cap) { + cap = cap ? cap * 2 : 8; + rows = xrealloc(rows, cap * sizeof(TblRow)); + } + row_split(data + start, ln, &rows[nrows]); + nrows++; + } + start = i + 1; + } + r->table.len = 0; + + 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, " | "); + putc_str(r, rows[i].cell[c]); + } + putc_str(r, "\n"); + } + for (size_t i = 0; i < nrows; i++) row_free(&rows[i]); + free(rows); + return; + } + + size_t ncols = 0; + for (size_t i = 0; i < nrows; i++) + if (rows[i].n > ncols) ncols = rows[i].n; + if (ncols == 0) { free(rows); return; } + + /* 对齐方式来自分隔行(第 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++) { + const char *s = rows[1].cell[c]; + size_t n = strlen(s); + int l = (n > 0 && s[0] == ':'); + int rr = (n > 0 && s[n - 1] == ':'); + if (l && rr) al[c] = ALIGN_CENTER; + else if (rr) al[c] = ALIGN_RIGHT; + } + + /* 列宽由表头与所有数据行决定(跳过第 1 行的分隔行) */ + size_t *w = xcalloc(ncols, sizeof(size_t)); + for (size_t i = 0; i < nrows; i++) { + if (i == 1) continue; + for (size_t c = 0; c < rows[i].n && c < ncols; c++) { + size_t cw = display_width(rows[i].cell[c], strlen(rows[i].cell[c])); + if (cw > w[c]) w[c] = cw; + } + } + for (size_t c = 0; c < ncols; c++) if (w[c] == 0) w[c] = 1; + + emit_rule(r, w, ncols, "┌", "┬", "┐"); + emit_row(r, &rows[0], w, al, ncols, 1); + emit_rule(r, w, ncols, "├", "┼", "┤"); + for (size_t i = 2; i < nrows; i++) + emit_row(r, &rows[i], w, al, ncols, 0); + emit_rule(r, w, ncols, "└", "┴", "┘"); + + for (size_t i = 0; i < nrows; i++) row_free(&rows[i]); + free(rows); + free(w); + free(al); +} + +/* ------------------------------------------------------------------ */ +/* 块级语法:只在行首调用 */ +/* 返回消耗的字节数;0 表示"不是块级语法,按普通字符处理"; + * MD_NEED_MORE 表示需要更多数据才能判定 */ +/* ------------------------------------------------------------------ */ + +static size_t scan_line(const char *s, size_t len, size_t i, int *has_nl) +{ + size_t j = i; + while (j < len && s[j] != '\n') j++; + *has_nl = (j < len); + return j - i; +} + +/* 规则表:从上往下依次尝试。 + * 关键约定:每条规则只有在"确实消费了字节"时才 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; + while (i + run < len && s[i + run] == r->fence_char) run++; + if (i + run >= len && !r->eof) return MD_NEED_MORE; + if ((int)run < r->fence_len) return 0; + + 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; /* 围栏后还有内容,不是闭合行 */ + + size_t emit = j - i; + put(r, s + i, emit); + if (j < len) { put(r, "\n", 1); emit++; } + r->in_fence = 0; + r->line_start = 1; + return emit; + } + + /* ---- 表格续行 ---- */ + if (r->in_table) { + int has_nl; + size_t ln = scan_line(s, len, i, &has_nl); + if (!has_nl && !r->eof) return MD_NEED_MORE; + if (ln > 0 && count_pipes(s + i, ln) >= 1) { + sb_append(&r->table, s + i, ln); + sb_putc(&r->table, '\n'); + r->table_rows++; + if (r->table_rows >= MD_MAX_TABLE_ROWS || + r->table.len > MD_MAX_PENDING) { + table_flush(r); + } + return ln + (has_nl ? 1 : 0); + } + table_flush(r); + /* 表格到此结束。注意不 return:当前行可能是个标题/列表, + * 要继续往下走让它被正常渲染。 */ + } + + /* ---- 标题 # ~ ###### ---- */ + if (s[i] == '#') { + size_t run = 0; + while (i + run < len && s[i + run] == '#') run++; + if (i + run >= len && !r->eof) { + if (run <= 6) return MD_NEED_MORE; + /* run > 6:确定不是标题,继续往下判断 */ + } else if (run >= 1 && run <= 6 && + (s[i + run] == ' ' || s[i + run] == '\t')) { + static const char *codes[6] = { + MD_H1, MD_H2, MD_H3, MD_H4, MD_H4, MD_H4 + }; + style(r, codes[run - 1]); + put(r, s + i, run); + put(r, s + i + run, 1); + r->line_start = 0; + r->line_style = 1; + return run + 1; + } + } + + /* ---- 引用 > ---- */ + if (s[i] == '>') { + if (i + 1 >= len && !r->eof) return MD_NEED_MORE; + size_t n = (i + 1 < len && s[i + 1] == ' ') ? 2 : 1; + style(r, MD_DIM); + putc_str(r, "│ "); + style(r, MD_RESET); + r->line_start = 0; + return n; + } + + /* ---- 围栏代码块开启 ---- */ + if (s[i] == '`') { + size_t run = 0; + while (i + run < len && s[i + run] == '`') run++; + if (i + run >= len && !r->eof) return MD_NEED_MORE; + if (run >= 3) { + size_t j = i + run; + while (j < len && s[j] != '\n') j++; + int has_nl = (j < len); + r->in_fence = 1; + r->fence_char = '`'; + r->fence_len = (int)run; + put(r, s + i, j - i); + if (has_nl) { put(r, "\n", 1); r->line_start = 1; } + else { r->line_start = 0; } + return (j - i) + (has_nl ? 1 : 0); + } + /* 少于 3 个反引号:交给行内代码逻辑 */ + } + + /* ---- 无序列表 / 分隔线 ---- */ + if (s[i] == '-' || s[i] == '*' || s[i] == '+') { + if (i + 1 >= len) { + if (!r->eof) return MD_NEED_MORE; /* 可能是 "- " 列表或 "---" */ + /* EOF 且只剩这一个字符:当字面量 */ + } else { + if (s[i + 1] == ' ') { + /* 保留模型输出的原始标记字符并上色,不替换成 • */ + style(r, MD_BULLET); + put(r, s + i, 1); + style(r, MD_RESET); + put(r, s + i + 1, 1); + r->line_start = 0; + return 2; + } + if (s[i] == '-') { + size_t run = 0; + while (i + run < len && s[i + run] == '-') run++; + if (i + run >= len && !r->eof) return MD_NEED_MORE; + if (run >= 3) { + 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') { + style(r, MD_DIM); + put(r, s + i, run); + style(r, MD_RESET); + if (j < len) put(r, "\n", 1); + r->line_start = 1; + return (j - i) + (j < len ? 1 : 0); + } + } + } + } + } + + /* ---- 表格起始:表头行 + 分隔行。放在最后,因为它需要看两行 ---- */ + { + 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; + return MD_NEED_MORE; + } + size_t j = i + ln1 + (nl1 ? 1 : 0); + if (j >= len) { + if (r->eof) return 0; /* 没有第二行,必然不是表格 */ + return MD_NEED_MORE; + } + int nl2; + size_t ln2 = scan_line(s, len, j, &nl2); + if (!nl2 && !r->eof) { + if (ln2 > MD_MAX_TABLE_LINE) return 0; + return MD_NEED_MORE; + } + if (table_head_ok(s + i, ln1, s + j, ln2)) { + sb_append(&r->table, s + i, ln1); + sb_putc(&r->table, '\n'); + sb_append(&r->table, s + j, ln2); + sb_putc(&r->table, '\n'); + r->in_table = 1; + r->table_rows = 2; + return (j + ln2 + (nl2 ? 1 : 0)) - i; + } + } + } + + return 0; +} + +/* ------------------------------------------------------------------ */ +/* 主循环 */ +/* ------------------------------------------------------------------ */ + +static int is_word_char(int c) +{ + /* 只把 ASCII 词字符算作"词内",非 ASCII(中文等)不算。 + * 这样 "中文[链接](url)" 能高亮,而 "arr[i]" 不会。 */ + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || c == '_'; +} + +static void feed_enabled(MdRenderer *r, const char *text, size_t len) +{ + sb_append(&r->in, text, len); + + size_t i = 0; + while (i < r->in.len) { + const char *s = r->in.data; + size_t remain = r->in.len - i; + + if (r->line_start && !r->in_code) { + size_t consumed = try_block(r, i); + if (consumed == MD_NEED_MORE) break; + if (consumed > 0) { i += consumed; continue; } + } + + char c = s[i]; + + /* ---- 换行 ---- */ + if (c == '\n') { + close_line_styles(r); + put(r, "\n", 1); + r->line_start = 1; + i++; + continue; + } + + /* ---- 围栏代码块内部:逐字原样输出 ---- */ + if (r->in_fence) { + put(r, s + i, 1); + r->line_start = 0; + i++; + continue; + } + + /* ---- 行内代码内部:只有反引号有意义,内容不做任何解析 ---- */ + if (r->in_code) { + if (c == '`') { + style(r, MD_DIM); + put(r, "`", 1); + style(r, MD_RESET); + r->in_code = 0; + } else { + put(r, s + i, 1); + } + r->line_start = 0; + i++; + continue; + } + + /* ---- 粗体 / 斜体 ---- */ + if (c == '*') { + if (remain >= 2 && s[i + 1] == '*') { + if (r->in_bold) { style(r, MD_RESET); r->in_bold = 0; } + else { style(r, MD_BOLD); r->in_bold = 1; } + r->line_start = 0; + i += 2; + continue; + } + if (remain < 2) { + /* 末尾的单个 '*':可能是 '**' 的前半个,必须等下一个分片。 + * 只有到 EOF 才能确定后面没有第二个 '*',此时按固定规则解开: + * 正在斜体就当作收尾,否则当字面量。 */ + if (!r->eof) break; + if (r->in_italic) { + style(r, MD_RESET); + r->in_italic = 0; + } else { + put(r, "*", 1); + } + r->line_start = 0; + i++; + continue; + } + if (r->in_italic) { + style(r, MD_RESET); + r->in_italic = 0; + r->line_start = 0; + i++; + continue; + } + char next = s[i + 1]; + if (next != ' ' && next != '\t' && next != '\n') { + style(r, MD_ITALIC); + r->in_italic = 1; + } else { + put(r, "*", 1); + } + r->line_start = 0; + i++; + continue; + } + + /* ---- _斜体_:按 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:没有后一个字符,当字面量 */ + r->line_start = 0; + i++; + continue; + } + int next = (unsigned char)s[i + 1]; + + /* 与另一个下划线相邻时一律当字面量: + * 这样 Python 的 __init__、__main__ 不会被拆成斜体 */ + if (prev == '_' || next == '_') { + put(r, "_", 1); + r->line_start = 0; + i++; + continue; + } + + if (r->in_italic) { + if (prev != ' ' && prev != '\t' && !is_word_char(next)) { + style(r, MD_RESET); + r->in_italic = 0; + } else { + put(r, "_", 1); + } + } else if (!is_word_char(prev) && next != ' ' && + next != '\t' && next != '\n') { + style(r, MD_ITALIC); + r->in_italic = 1; + } else { + put(r, "_", 1); + } + r->line_start = 0; + i++; + continue; + } + + /* ---- [链接文字]:蓝色高亮,其余部分(含 (url))原样保留 ---- */ + if (c == '[') { + if (remain < 2) { + if (!r->eof) break; /* 需要看后一个字符 */ + put(r, "[", 1); /* EOF:当字面量 */ + r->line_start = 0; + i++; + continue; + } + int prev = (i > 0) ? (unsigned char)s[i - 1] : r->last_ch; + int next = (unsigned char)s[i + 1]; + if (!is_word_char(prev) && next != ' ' && next != '\t' && next != '\n') { + style(r, MD_LINK); + put(r, "[", 1); + r->in_bracket = 1; + r->bracket_len = 1; + } else { + put(r, "[", 1); + } + r->line_start = 0; + i++; + continue; + } + + if (c == ']' && r->in_bracket) { + put(r, "]", 1); + style(r, MD_RESET); + r->in_bracket = 0; + r->bracket_len = 0; + r->line_start = 0; + i++; + continue; + } + + /* ---- 行内代码开始 ---- */ + if (c == '`') { + style(r, MD_DIM); + put(r, "`", 1); + style(r, MD_RESET); + style(r, MD_CODE); + r->in_code = 1; + r->line_start = 0; + i++; + continue; + } + + put(r, s + i, 1); + r->line_start = 0; + i++; + } + + 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); + r->in.len = 0; + r->line_start = 0; + } +} + +void md_feed(MdRenderer *r, const char *text) +{ + if (!r || !text) return; + size_t len = strlen(text); + if (!r->enabled) { + if (len) fwrite(text, 1, len, r->out); + return; + } + feed_enabled(r, text, len); +} + +void md_finish(MdRenderer *r) +{ + if (!r) return; + if (r->enabled) { + /* 关键:置 eof 后把残留重新过一遍状态机,让所有挂起按固定规则解开。 + * 这样"一次性喂"与"分片喂"在结尾处的结果必然一致。 */ + r->eof = 1; + feed_enabled(r, "", 0); + if (r->in_table) table_flush(r); + if (r->in.len > 0) { /* 兜底:正常不该走到 */ + close_line_styles(r); + put(r, r->in.data, r->in.len); + r->in.len = 0; + } + close_line_styles(r); + r->in_fence = 0; + r->in_table = 0; + r->eof = 0; + } + fflush(r->out); +} + +/* ------------------------------------------------------------------ */ +/* 生命周期 */ +/* ------------------------------------------------------------------ */ + +MdRenderer *md_new(int enabled) +{ + MdRenderer *r = xcalloc(1, sizeof *r); + r->enabled = enabled ? 1 : 0; + r->out = stdout; + sb_init(&r->in); + sb_init(&r->table); + r->line_start = 1; + return r; +} + +void md_set_enabled(MdRenderer *r, int enabled) +{ + if (r) r->enabled = enabled ? 1 : 0; +} + +void md_set_out(MdRenderer *r, FILE *out) +{ + if (r && out) r->out = out; +} + +void md_free(MdRenderer *r) +{ + if (!r) return; + sb_free(&r->in); + sb_free(&r->table); + free(r); +} diff --git a/src/markdown.h b/src/markdown.h new file mode 100644 index 0000000..91d2669 --- /dev/null +++ b/src/markdown.h @@ -0,0 +1,37 @@ +/* markdown.h - 面向终端输出的流式 Markdown 渲染器 + * + * 设计目标:在"纯行式 REPL"的前提下把常见 Markdown 变成 ANSI 样式, + * 且必须能处理任意分片(SSE 的一小片文本可能把一个标记切成两半)。 + * + * 渲染: + * 块级 # ~ ###### 标题、> 引用、- * + 无序列表、--- 分隔线、| 表格 + * 行内 **粗体**、*斜体* / _斜体_、`行内代码`、[链接文字](蓝色高亮) + * + * 不渲染(原样保留,一个字节都不丢): + * 围栏代码块(含 ``` 标记本身)、图片 ![alt](src)、链接的 (url) 部分、 + * 行内代码的内容 + */ +#ifndef LIGENT_MARKDOWN_H +#define LIGENT_MARKDOWN_H + +#include +#include + +typedef struct MdRenderer MdRenderer; + +/* enabled = 0 时完全透传,逐字节不改写内容(供管道 / --no-color 使用) */ +MdRenderer *md_new(int enabled); +void md_set_enabled(MdRenderer *r, int enabled); + +/* 输出目标,默认 stdout(测试时指向内存流) */ +void md_set_out(MdRenderer *r, FILE *out); + +/* 喂入一段文本(NUL 结尾)。内部保持状态,可被任意切分 */ +void md_feed(MdRenderer *r, const char *text); + +/* 一条消息结束:冲刷残留、闭合未闭合的样式 */ +void md_finish(MdRenderer *r); + +void md_free(MdRenderer *r); + +#endif /* LIGENT_MARKDOWN_H */ diff --git a/src/meta.c b/src/meta.c new file mode 100644 index 0000000..6acf29d --- /dev/null +++ b/src/meta.c @@ -0,0 +1,857 @@ +/* meta.c - /meta:用模型辅助编辑对话历史 + * + * 结构上是一个"第二个 Agent":它有自己的 messages(meta 自己的会话)、 + * 自己的 tools、自己的 system 提示词,但共用主对话的 LLM 配置。 + * agent_run() 一行不改就能跑它 —— 工具分发已经从硬编码改成了函数指针。 + * + * 三条硬约束(决定了下面所有工具的形状): + * 1. assistant 带 tool_calls 时,后面必须紧跟一一对应的 tool 消息; + * 2. system 必须在最前,且不归 meta 管(那是 /system 的事); + * 3. 改完之后,历史必须仍是一段合法的对话。 + * 所以"删单条消息"只对 user 和"不带 tool_calls 的 assistant"开放, + * 结构消息一律拒绝,让模型改用 delete_rounds。 + */ +#include "meta.h" +#include "i18n.h" +#include "confirm.h" +#include "lineedit.h" +#include "llm.h" +#include "spinner.h" +#include "tools.h" /* LIGENT_TOOL_OUTPUT_LIMIT */ +#include "util.h" + +#include +#include +#include + +/* meta 模型的 system 提示词。措辞由用户逐字确定,不要"顺手润色"。 */ +static const char *META_SYSTEM_PROMPT = +"你是对话历史的元编辑器。你的工作对象是用户和一段已经存在的AI助手的对话记录。\n" +"你不是这段对话的参与者,不要把它当成自己的对话往下接。\n" +"\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" +"\n" +"## 红线\n" +"- 带 tool_calls 的 assistant 消息和紧随其后的 tool 结果是不可拆的整体, 否则会造成400错误。\n" +"- 不要为了“整齐”重排顺序。\n" +"\n" +"## 拿不准就问\n" +"如果删掉某个点会让后面的回答失去上下文,把这个后果指出来,让用户决定。\n" +"不要猜。\n" +"\n" +"你的修改应当也遵循原对话中助手的风格, 但你对用户的交互应当一如既往地专业可靠\n" +"\n" +"用中文回复(除非用户用了别的语言)。"; + +/* 快照栈:每次改动前压一份,供 /undo 和 undo_last_edit 回退 */ +#define META_UNDO_MAX 10 + +/* 单条消息在目录/明细里的预览长度 */ +#define META_PREVIEW 60 + +typedef struct { + Agent *target; /* 被编辑的主对话 */ + JsonValue *snap[META_UNDO_MAX]; /* 快照栈(环形) */ + size_t snap_n; /* 已压入总数 */ + int edited; /* 本次会话是否成功改过 */ + size_t ops; /* 成功改动的次数 */ +} MetaCtx; + +/* 工具分发签名里没有上下文参数,只能借文件级指针。 + * 同一时刻只允许一个 meta 会话(下面用 in_session 兜住)。 */ +static MetaCtx *g_ctx; + +int meta_tools_attach(Agent *target) +{ + if (g_ctx) return -1; + g_ctx = xcalloc(1, sizeof(MetaCtx)); + g_ctx->target = target; + return 0; +} + +void meta_tools_detach(void) +{ + if (!g_ctx) return; + for (size_t i = 0; i < META_UNDO_MAX; i++) json_free(g_ctx->snap[i]); + free(g_ctx); + g_ctx = NULL; +} + +size_t meta_tools_ops(void) { return g_ctx ? g_ctx->ops : 0; } + + +/* "这段历史被 meta 动过"的标记。跨 meta 会话保留,直到下一次真正落盘。 */ +static int g_edited_ever; + +int meta_reroot_pending(void) { return g_edited_ever; } +void meta_reroot_clear(void) { g_edited_ever = 0; } + +/* ------------------------------------------------------------------ */ +/* 历史视图:把消息按"轮"切开 */ +/* ------------------------------------------------------------------ */ + +static const char *role_of(const JsonValue *m) +{ + const char *r = json_get_string(m, "role"); + return r ? r : ""; +} + +static int has_tool_calls(const JsonValue *m) +{ + JsonValue *tc = json_object_get(m, "tool_calls"); + return tc && tc->type == JSON_ARRAY && json_array_size(tc) > 0; +} + +int meta_deletable(const JsonValue *messages, size_t index) +{ + if (!messages || index >= json_array_size(messages)) return 0; + JsonValue *m = json_array_get(messages, index); + const char *r = role_of(m); + if (strcmp(r, "system") == 0) return 0; /* system 不归 meta 管 */ + if (strcmp(r, "tool") == 0) return 0; /* 工具结果离不开它的调用 */ + if (strcmp(r, "assistant") == 0 && has_tool_calls(m)) return 0; + return 1; /* user / 普通 assistant */ +} + +int meta_rounds_build(const JsonValue *messages, MetaRounds *out) +{ + memset(out, 0, sizeof *out); + if (!messages || messages->type != JSON_ARRAY) return 0; + size_t n = json_array_size(messages); + if (n == 0) return 0; + out->head = (strcmp(role_of(json_array_get(messages, 0)), "system") == 0) ? 1 : 0; + if (out->head >= n) return 0; /* 只有一条 system:没有可编辑的东西 */ + + size_t cap = 8; + out->starts = xmalloc(cap * sizeof(size_t)); + out->starts[0] = out->head; + out->count = 1; + + /* 轮起点必须用 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) { + cap *= 2; + out->starts = xrealloc(out->starts, cap * sizeof(size_t)); + } + out->starts[out->count++] = i; + } + return 1; +} + +void meta_rounds_free(MetaRounds *r) +{ + free(r->starts); + r->starts = NULL; + r->count = 0; +} + +/* 第 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; + return (n == r->count) ? total : r->starts[n]; +} + +/* ------------------------------------------------------------------ */ +/* 快照 */ +/* ------------------------------------------------------------------ */ + +static void snap_push(MetaCtx *c) +{ + JsonValue *copy = json_clone(c->target->messages); + 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; + } + c->snap_n++; +} + +/* 回退一份快照。成功返回 1 */ +static int snap_pop(MetaCtx *c) +{ + if (c->snap_n == 0) return 0; + size_t idx = (c->snap_n - 1) % META_UNDO_MAX; + JsonValue *old = c->snap[idx]; + c->snap[idx] = NULL; + c->snap_n--; + json_free(c->target->messages); + c->target->messages = old; + return 1; +} + +/* ------------------------------------------------------------------ */ +/* 小工具 */ +/* ------------------------------------------------------------------ */ + +static char *preview_of(const char *s, size_t max) +{ + StrBuf sb; + sb_init(&sb); + size_t n = 0; + for (const char *p = s ? s : ""; *p && n < max; p++, n++) { + char ch = (*p == '\n' || *p == '\r' || *p == '\t') ? ' ' : *p; + sb_putc(&sb, ch); + } + if (s && s[n]) sb_puts(&sb, "..."); + return sb_detach(&sb); +} + +/* 角色的中文短标签,给目录用 */ +static const char *role_short(const JsonValue *m) +{ + const char *r = role_of(m); + if (strcmp(r, "user") == 0) return "user"; + if (strcmp(r, "assistant") == 0) return has_tool_calls(m) ? "assistant(tool_calls)" : "assistant"; + if (strcmp(r, "tool") == 0) return "tool"; + if (strcmp(r, "system") == 0) return "system"; + return r; +} + +/* 一条消息的字节数(content + 工具参数),用于"省了多少"的汇报 */ +static size_t msg_bytes(const JsonValue *m) +{ + size_t n = 0; + const char *c = json_get_string(m, "content"); + if (c) n += strlen(c); + JsonValue *tc = json_object_get(m, "tool_calls"); + for (size_t i = 0; i < json_array_size(tc); i++) { + JsonValue *fn = json_object_get(json_array_get(tc, i), "function"); + const char *a = json_get_string(fn, "arguments"); + if (a) n += strlen(a); + } + return n; +} + +static size_t history_bytes(const JsonValue *messages) +{ + size_t n = 0; + for (size_t i = 0; i < json_array_size(messages); i++) + n += msg_bytes(json_array_get(messages, i)); + return n; +} + +/* 取一个整数参数,缺失或类型不对时把 *ok 置 0 */ +static long arg_long(const JsonValue *args, const char *key, int *ok) +{ + JsonValue *v = json_object_get(args, key); + if (!v || v->type != JSON_NUMBER) { *ok = 0; return 0; } + *ok = 1; + return (long)v->u.number; +} + +/* 把 (from,to) 两个参数解析成闭区间;缺省时用整段。 + * 返回 0 表示参数不合法。 */ +static int arg_range(const JsonValue *args, long lo, long hi, + long *from, long *to) +{ + int ok; + JsonValue *f = json_object_get(args, "from"); + JsonValue *t = json_object_get(args, "to"); + *from = f ? arg_long(args, "from", &ok) : lo; + if (f && !ok) return 0; + *to = t ? arg_long(args, "to", &ok) : hi; + if (t && !ok) return 0; + if (*from < lo) *from = lo; + if (*to > hi) *to = hi; + return *from <= *to; +} + +/* 改动之后的统一收尾:打标记、清掉和旧历史绑定的状态 */ +static void after_edit(MetaCtx *c) +{ + c->edited = 1; + g_edited_ever = 1; + c->ops++; +} + +/* ------------------------------------------------------------------ */ +/* 工具实现 */ +/* ------------------------------------------------------------------ */ + +static char *m_list_rounds(const JsonValue *args, int *is_error) +{ + MetaCtx *c = g_ctx; + JsonValue *msgs = c->target->messages; + size_t total = json_array_size(msgs); + MetaRounds r; + if (!meta_rounds_build(msgs, &r)) { + *is_error = 1; + 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 开始。"); + } + + StrBuf sb; + sb_init(&sb); + char *head = xasprintf("共 %zu 轮,%zu 条消息,%zu 字节。\n" + "(system 不在其中,也不可编辑)\n\n", + r.count, total, history_bytes(msgs)); + sb_puts(&sb, head); + free(head); + + for (long n = from; n <= to; n++) { + size_t s = r.starts[n - 1]; + size_t e = round_end(&r, (size_t)n, total); + size_t bytes = 0; + for (size_t i = s; i < e; i++) bytes += msg_bytes(json_array_get(msgs, i)); + JsonValue *first = json_array_get(msgs, s); + char *pv = preview_of(json_get_string(first, "content"), META_PREVIEW); + char *line = xasprintf("轮 %ld #%zu..#%zu %zu 条 %zu 字节 %s: %s\n", + n, s, e - 1, e - s, bytes, role_short(first), pv); + sb_puts(&sb, line); + free(line); + free(pv); + } + meta_rounds_free(&r); + return sb_detach(&sb); +} + +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("历史是空的。"); } + long from, to; + if (!arg_range(args, 0, (long)total - 1, &from, &to)) { + *is_error = 1; + return xstrdup("from/to 不合法:消息编号是 #N,从 0 开始。"); + } + + StrBuf sb; + sb_init(&sb); + for (long i = from; i <= to; i++) { + 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) ? "" : " 〔结构消息,不可单删〕"); + sb_puts(&sb, head); + free(head); + if (content && *content) { + sb_puts(&sb, content); + if (content[strlen(content) - 1] != '\n') sb_putc(&sb, '\n'); + } else if (!has_tool_calls(m)) { + sb_puts(&sb, "(空)\n"); + } + JsonValue *tc = json_object_get(m, "tool_calls"); + for (size_t k = 0; k < json_array_size(tc); k++) { + JsonValue *fn = json_object_get(json_array_get(tc, k), "function"); + const char *name = json_get_string(fn, "name"); + const char *a = json_get_string(fn, "arguments"); + char *l = xasprintf("[调用 %s] %s\n", name ? name : "?", + a ? a : "{}"); + sb_puts(&sb, l); + free(l); + } + if (strcmp(role_of(m), "tool") == 0) { + const char *id = json_get_string(m, "tool_call_id"); + char *l = xasprintf("(tool_call_id=%s)\n", id ? id : ""); + sb_puts(&sb, l); + free(l); + } + } + /* 输出上限和普通工具一致,免得一次读太多把 meta 自己的上下文撑爆 */ + if (sb.len > LIGENT_TOOL_OUTPUT_LIMIT) { + sb.len = LIGENT_TOOL_OUTPUT_LIMIT; + sb.data[sb.len] = '\0'; + sb_puts(&sb, "\n...(已截断,用更小的范围再读)\n"); + } + return sb_detach(&sb); +} + +static char *m_replace_message(const JsonValue *args, int *is_error) +{ + MetaCtx *c = g_ctx; + JsonValue *msgs = c->target->messages; + size_t total = json_array_size(msgs); + + JsonValue *iv = json_object_get(args, "index"); + const char *content = json_get_string(args, "content"); + if (!iv || iv->type != JSON_NUMBER) { + *is_error = 1; + return xstrdup("缺少 index(消息编号 #N)。"); + } + if (!content) { + *is_error = 1; + 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); + } + 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 改。"); + } + if (strcmp(role, "tool") == 0) { + *is_error = 1; + return xstrdup("这是 tool 结果,改它会让模型以为工具返回了别的东西。" + "如果内容有问题,应该改的是产生它的那次调用。"); + } + /* 结构本身不动:只换 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)); +} + +static char *m_delete_messages(const JsonValue *args, int *is_error) +{ + MetaCtx *c = g_ctx; + JsonValue *msgs = c->target->messages; + size_t total = json_array_size(msgs); + + 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])。"); + } + + /* 先整体校验再动手:宁可一条不改,也不要改一半 */ + size_t n = json_array_size(arr); + size_t *idx = xmalloc(n * sizeof(size_t)); + for (size_t k = 0; k < n; k++) { + JsonValue *v = json_array_get(arr, k); + if (!v || v->type != JSON_NUMBER) { + free(idx); + *is_error = 1; + 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, + total ? total - 1 : 0); + } + if (!meta_deletable(msgs, (size_t)i)) { + free(idx); + *is_error = 1; + return xasprintf( + "#%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; } + size_t uniq = 0; + for (size_t a = 0; a < n; a++) + if (a == 0 || idx[a] != idx[a - 1]) idx[uniq++] = idx[a]; + + size_t before = history_bytes(msgs); + snap_push(c); + JsonValue **items = msgs->u.array.items; + for (size_t a = 0; a < uniq; a++) { + size_t i = idx[a]; + json_free(items[i]); + memmove(items + i, items + i + 1, (msgs->u.array.count - i - 1) * sizeof(JsonValue *)); + msgs->u.array.count--; + } + free(idx); + after_edit(c); + + size_t after = history_bytes(msgs); + return xasprintf("已删除 %zu 条消息:%zu -> %zu 字节(-%zu)。\n" + "注意:如果删掉的是某轮里的提问,那轮剩下的回答现在没有" + "对应的提问了,必要时把整轮一起删掉。", + uniq, before, after, before - after); +} + +static char *m_delete_rounds(const JsonValue *args, int *is_error) +{ + MetaCtx *c = g_ctx; + JsonValue *msgs = c->target->messages; + size_t total = json_array_size(msgs); + MetaRounds r; + if (!meta_rounds_build(msgs, &r)) { + *is_error = 1; + 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 开始。"); + } + if (from == 1 && to == (long)r.count) { + meta_rounds_free(&r); + *is_error = 1; + return xstrdup("这样会把整段历史删光。如果确实想清空,让用户敲 /reset。"); + } + + size_t s = r.starts[from - 1]; + size_t e = round_end(&r, (size_t)to, total); + size_t cnt = e - s; + size_t before = history_bytes(msgs); + meta_rounds_free(&r); + + snap_push(c); + JsonValue **items = msgs->u.array.items; + for (size_t i = 0; i < cnt; i++) json_free(items[s + i]); + memmove(items + s, items + e, (msgs->u.array.count - e) * sizeof(JsonValue *)); + msgs->u.array.count -= cnt; + after_edit(c); + + size_t after = history_bytes(msgs); + return xasprintf("已删除轮 %ld..%ld(%zu 条消息):%zu -> %zu 字节(-%zu)。", + from, to, cnt, before, after, before - after); +} + +static char *m_insert_message(const JsonValue *args, int *is_error) +{ + MetaCtx *c = g_ctx; + JsonValue *msgs = c->target->messages; + size_t total = json_array_size(msgs); + + 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 (strcmp(role, "assistant") != 0 && strcmp(role, "user") != 0) { + *is_error = 1; + 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); + } + + snap_push(c); + JsonValue *m = json_new_object(); + json_object_set_string(m, "role", role); + json_object_set_string(m, "content", content); + 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)); +} + +static char *m_undo(const JsonValue *args, int *is_error) +{ + (void)args; + MetaCtx *c = g_ctx; + if (!snap_pop(c)) { + *is_error = 1; + return xstrdup("没有可撤销的改动了。"); + } + g_edited_ever = 1; /* 撤销本身也是"历史被动过",仍要另起一根 */ + return xstrdup("已撤销上一次改动。"); +} + +/* ------------------------------------------------------------------ */ +/* 工具表 */ +/* ------------------------------------------------------------------ */ + +typedef struct { + const char *name; + const char *desc; + const char *params; /* JSON Schema 片段 */ + int mutating; + char *(*fn)(const JsonValue *args, int *is_error); +} MetaTool; + +static const MetaTool TOOLS[] = { + { "list_rounds", + "看这份对话的目录:每轮一行(轮号、消息编号范围、条数、字节数、开头预览)。" + "动手前先看它。", + "{\"type\":\"object\",\"properties\":{" + "\"from\":{\"type\":\"number\",\"description\":\"起始轮号(从 1 开始),默认 1\"}," + "\"to\":{\"type\":\"number\",\"description\":\"结束轮号,默认最后一轮\"}}}", + 0, m_list_rounds }, + + { "read_messages", + "读指定消息编号范围的完整内容(含工具调用参数)。编号是 #N,从 0 开始," + "和 list_rounds 显示的一致。", + "{\"type\":\"object\",\"properties\":{" + "\"from\":{\"type\":\"number\",\"description\":\"起始消息编号 #N\"}," + "\"to\":{\"type\":\"number\",\"description\":\"结束消息编号 #N\"}}," + "\"required\":[\"from\",\"to\"]}", + 0, m_read_messages }, + + { "replace_message", + "改写某一条消息的内容。只换文字,消息的角色和工具调用结构都不动。" + "这是唯一能修改 assistant 说过的话的办法。", + "{\"type\":\"object\",\"properties\":{" + "\"index\":{\"type\":\"number\",\"description\":\"消息编号 #N\"}," + "\"content\":{\"type\":\"string\",\"description\":\"新的内容\"}}," + "\"required\":[\"index\",\"content\"]}", + 1, m_replace_message }, + + { "delete_messages", + "删掉若干条消息。只能删 user 和不带 tool_calls 的 assistant;" + "结构消息(带 tool_calls 的 assistant、tool 结果)会被拒绝," + "那种情况请用 delete_rounds。", + "{\"type\":\"object\",\"properties\":{" + "\"indices\":{\"type\":\"array\",\"items\":{\"type\":\"number\"}," + "\"description\":\"要删除的消息编号 #N 列表\"}}," + "\"required\":[\"indices\"]}", + 1, m_delete_messages }, + + { "delete_rounds", + "删掉连续的若干整轮(含轮内所有工具往返)。要清掉一次走不通的探索,用这个。", + "{\"type\":\"object\",\"properties\":{" + "\"from\":{\"type\":\"number\",\"description\":\"起始轮号(从 1 开始)\"}," + "\"to\":{\"type\":\"number\",\"description\":\"结束轮号\"}}," + "\"required\":[\"from\",\"to\"]}", + 1, m_delete_rounds }, + + { "insert_message", + "在某条消息之后插入一条新消息。用来补一句迟到的约束或澄清。" + "role 只能是 user 或 assistant。", + "{\"type\":\"object\",\"properties\":{" + "\"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 }, +}; + +#define TOOL_COUNT (sizeof TOOLS / sizeof TOOLS[0]) + +static JsonValue *meta_tools_schema(void) +{ + JsonValue *arr = json_new_array(); + for (size_t i = 0; i < TOOL_COUNT; i++) { + JsonValue *t = json_new_object(); + json_object_set_string(t, "type", "function"); + JsonValue *fn = json_new_object(); + json_object_set_string(fn, "name", TOOLS[i].name); + json_object_set_string(fn, "description", TOOLS[i].desc); + char jerr[256]; + JsonValue *params = json_parse(TOOLS[i].params, jerr, sizeof jerr); + json_object_set(fn, "parameters", params ? params : json_new_object()); + json_object_set(t, "function", fn); + json_array_push(arr, t); + } + return arr; +} + +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 会话已经结束。"); } + 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); +} + +int meta_tools_mutating(const char *name) +{ + for (size_t i = 0; i < TOOL_COUNT; i++) + if (strcmp(TOOLS[i].name, name) == 0) return TOOLS[i].mutating; + return 0; +} + +/* 让确认提示说人话:meta 工具的参数字段名和普通工具完全不同, + * 内置描述要么打不出来、要么会把整段消息内容刷屏。 */ +static char *meta_describe(const char *name, const JsonValue *args) +{ + char *msg = NULL; + int ok; + + if (strcmp(name, "list_rounds") == 0 || strcmp(name, "read_messages") == 0) + return NULL; /* 只读工具根本不会走到确认 */ + + if (strcmp(name, "delete_messages") == 0) { + JsonValue *arr = json_object_get(args, "indices"); + StrBuf sb; + sb_init(&sb); + sb_puts(&sb, " "); + for (size_t i = 0; i < json_array_size(arr); i++) { + char *one = xasprintf("#%ld ", (long)json_number_value(json_array_get(arr, i), -1)); + sb_puts(&sb, one); + free(one); + } + sb_puts(&sb, "\n"); + msg = sb_detach(&sb); + } else if (strcmp(name, "delete_rounds") == 0) { + long f = arg_long(args, "from", &ok), t = arg_long(args, "to", &ok); + msg = xstrdup(tf("meta.desc.delete_rounds", "from", i18n_num(f), + "to", i18n_num(t), NULL)); + } else if (strcmp(name, "replace_message") == 0) { + long i = arg_long(args, "index", &ok); + const char *content = json_get_string(args, "content"); + char *pv = preview_of(content, 120); + msg = xstrdup(tf("meta.desc.replace", "index", i18n_num(i), + "preview", pv, NULL)); + free(pv); + } else if (strcmp(name, "insert_message") == 0) { + long a = arg_long(args, "after", &ok); + const char *role = json_get_string(args, "role"); + const char *content = json_get_string(args, "content"); + char *pv = preview_of(content, 120); + msg = xstrdup(tf("meta.desc.insert", "after", i18n_num(a), + "role", role ? role : "?", "preview", pv, NULL)); + free(pv); + } else if (strcmp(name, "undo_last_edit") == 0) { + msg = xstrdup(t("meta.desc.undo")); + } + return msg; +} + +/* ------------------------------------------------------------------ */ +/* meta 自己的 REPL */ +/* ------------------------------------------------------------------ */ + +static void meta_help(void) +{ + fputs(t("meta.help"), stdout); +} + +/* /diff:把当前历史按消息列出来(只读,不走模型) */ +static void meta_diff(Agent *target) +{ + size_t total = json_array_size(target->messages); + printf("%s", tf("meta.diff.header", "n", i18n_num((long long)total), + "bytes", i18n_num((long long)history_bytes(target->messages)), + NULL)); + for (size_t i = 0; i < total; i++) { + JsonValue *m = json_array_get(target->messages, i); + const char *content = json_get_string(m, "content"); + char *pv = preview_of(content && *content ? content + : t("meta.diff.no_text"), 70); + printf(" #%-3zu %-22s %6zu %s %s%s\n", i, role_short(m), + msg_bytes(m), t("meta.unit.bytes"), pv, + meta_deletable(target->messages, i) + ? "" : t("meta.diff.structural")); + free(pv); + } +} + +int meta_session(Agent *target, LigentConfig *cfg) +{ + (void)cfg; + if (meta_tools_attach(target) != 0) { + puts(t("meta.already_in")); + return -1; + } + + Agent m; + agent_init(&m); + m.llm = target->llm; /* 有意借用:同一个后端、同一个模型 */ + m.llm.stream = target->llm.stream; + m.color = target->color; + m.show_tool_output = 1; + m.confirm = target->confirm; + m.max_steps = 12; + m.tools = meta_tools_schema(); + m.exec_tool = meta_tools_call; + m.is_mutating = meta_tools_mutating; + agent_set_color(&m, target->color); + + char *sp = xstrdup(META_SYSTEM_PROMPT); + agent_add_system(&m, sp); + free(sp); + + /* 确认提示改说人话:meta 工具的参数字段和普通工具完全不同, + * 内置描述在那儿要么打不出来、要么会把整段消息内容刷屏。 */ + confirm_set_describer(meta_describe); + line_edit_set_ink("\033[35m"); /* 品红:和主提示符(绿)分开 */ + + puts(t("meta.enter")); + puts(t("meta.enter.hint")); + + for (;;) { + int intr = 0; + char *line = line_edit("meta> ", &intr); + if (!line) { + if (intr) continue; /* Ctrl-C 丢掉当前行 */ + break; /* Ctrl-D 退出 */ + } + char *ans = str_trim(line); + if (*ans == '\0') { free(line); continue; } + + if (strcmp(ans, "/exit") == 0 || strcmp(ans, "/quit") == 0) { free(line); break; } + if (strcmp(ans, "/help") == 0 || strcmp(ans, "/h") == 0) { meta_help(); free(line); continue; } + if (strcmp(ans, "/diff") == 0) { meta_diff(target); free(line); continue; } + if (strcmp(ans, "/undo") == 0) { + if (snap_pop(g_ctx)) { g_edited_ever = 1; puts(t("meta.undo.done")); } + else puts(t("meta.undo.none")); + free(line); + continue; + } + if (*ans == '/') { + printf("%s", tf("meta.unknown_cmd", "cmd", ans, NULL)); + free(line); + continue; + } + + char *msg = xstrdup(ans); + free(line); + agent_add_user(&m, msg); + free(msg); + spinner_start(t("spinner.waiting")); + int r = agent_run(&m); + spinner_stop(); + putchar('\n'); + if (r < 0) fprintf(stderr, "%s", t("meta.turn_failed")); + } + + size_t ops = meta_tools_ops(); + int edited = (ops > 0); + + line_edit_set_ink(NULL); /* 还回主提示符的绿色 */ + confirm_set_describer(NULL); + agent_free(&m); /* m.tools 由它一起释放,别再 free 一次 */ + meta_tools_detach(); + + printf("%s", t("meta.exit")); + if (ops > 0) + printf("%s", tf("meta.exit.edits", "n", i18n_num((long long)ops), NULL)); + else + printf("%s", t("meta.exit.no_edits")); + printf("%s", tf("meta.exit.usage", "calls", i18n_num(m.api_calls), + "in", i18n_num(m.tok_prompt), + "out", i18n_num(m.tok_completion), NULL)); + if (ops > 0) + puts(t("meta.exit.lineage")); + return edited ? 1 : 0; +} diff --git a/src/meta.h b/src/meta.h new file mode 100644 index 0000000..440cda6 --- /dev/null +++ b/src/meta.h @@ -0,0 +1,53 @@ +/* meta.h - /meta:用模型辅助编辑对话历史 + * + * 和主对话的区别:主模型是来"干活"的,meta 模型是来"改历史"的。 + * 它不是那场对话的参与者,而是拿用户指令去删改那段记录的编辑者。 + * + * 两边工具集完全隔离:meta 会话的 Agent 用的是 meta_tools_schema() 和 + * meta_exec_tool(),主模型那边是 tools.c 那套。主模型就算瞎编一个 + * delete_rounds 也只会拿到"未知工具"。 + */ +#ifndef LIGENT_META_H +#define LIGENT_META_H + +#include "agent.h" +#include "config.h" + +/* 被 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 撤销栈、让退出时必定重新存档)。 */ +int meta_session(Agent *target, LigentConfig *cfg); + +/* 供单测用:把一段历史按轮切开。返回 0 表示"没有可编辑的轮次" + * (空历史,或只有一条 system)。 + * + * 轮的定义与主循环**共用同一个判据**(agent_opens_round):一轮从一条带文字的 + * user 消息开始,到下一条这样的消息之前;read_image 补的那条"只有图片附件"的 + * user 消息不算新的一轮。head 是第一条可编辑消息的下标(跳过 system)。 */ +typedef struct { + size_t *starts; /* 每轮第一条消息的全局下标 */ + size_t count; + size_t head; /* 第一条可编辑消息的下标(跳过 system) */ +} MetaRounds; + +int meta_rounds_build(const JsonValue *messages, MetaRounds *out); +void meta_rounds_free(MetaRounds *r); + +/* 供单测用:这条消息能不能被单独删掉(false = 结构消息,牵连 tool_calls 配对) */ +int meta_deletable(const JsonValue *messages, size_t index); + +/* ---- 工具分发 ---- + * 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); +size_t meta_tools_ops(void); /* 成功改动次数 */ + +#endif /* LIGENT_META_H */ diff --git a/src/models.c b/src/models.c new file mode 100644 index 0000000..d12b67b --- /dev/null +++ b/src/models.c @@ -0,0 +1,139 @@ +/* models.c - GET {base_url}/models 与响应体解析 */ +#include "models.h" +#include "http.h" +#include "i18n.h" +#include "json.h" +#include "util.h" + +#include +#include +#include + +#define MODELS_MAX 512 /* 再多就不是给人挑的了,多半是服务端出了什么问题 */ + +static int already_have(char **list, size_t n, const char *id) +{ + for (size_t i = 0; i < n; i++) + if (strcmp(list[i], id) == 0) return 1; + return 0; +} + +char **models_parse(const char *json, size_t *out_n) +{ + if (out_n) *out_n = 0; + if (!json || !*json) return NULL; + + JsonValue *root = json_parse(json, NULL, 0); + if (!root) return NULL; + + /* 标准形状是 {"data": [...]};也见过直接回一个裸数组的兼容实现 */ + const JsonValue *arr = NULL; + if (root->type == JSON_ARRAY) { + arr = root; + } else { + JsonValue *data = json_object_get(root, "data"); + if (data && data->type == JSON_ARRAY) arr = data; + } + if (!arr) { json_free(root); return NULL; } + + size_t cap = json_array_size(arr) + 1; + if (cap > MODELS_MAX) cap = MODELS_MAX; + char **list = xcalloc(cap ? cap : 1, sizeof *list); + size_t n = 0; + + for (size_t i = 0; i < json_array_size(arr) && n < cap; i++) { + const JsonValue *item = json_array_get(arr, i); + /* 元素可能是字符串本身,也可能是 {"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"); + + if (!id || !*id) continue; + if (already_have(list, n, id)) continue; /* 有些服务端会重复列 */ + list[n++] = xstrdup(id); + } + + json_free(root); + if (n == 0) { free(list); return NULL; } + + /* 保序:服务端给的顺序是它自己的偏好,我们不去替它排序 */ + if (out_n) *out_n = n; + return list; +} + +int models_is_empty_list(const char *json) +{ + if (!json || !*json) return 0; + JsonValue *root = json_parse(json, NULL, 0); + if (!root) return 0; + + const JsonValue *arr = NULL; + if (root->type == JSON_ARRAY) { + arr = root; + } else { + JsonValue *data = json_object_get(root, "data"); + if (data && data->type == JSON_ARRAY) arr = data; + } + int empty = (arr && json_array_size((JsonValue *)arr) == 0); + json_free(root); + return empty; +} + +void models_free(char **list, size_t n) +{ + if (!list) return; + for (size_t i = 0; i < n; i++) free(list[i]); + free(list); +} + +char **models_fetch(const LigentConfig *cfg, size_t *out_n, + int verbose, char *err, size_t errlen) +{ + if (out_n) *out_n = 0; + if (err && errlen) err[0] = '\0'; + + const char *bu = (cfg && cfg->base_url) ? cfg->base_url : ""; + if (!*bu) { + if (err && errlen) snprintf(err, errlen, "%s", t("models.err.no_base_url")); + return NULL; + } + size_t bl = strlen(bu); + while (bl > 0 && bu[bl - 1] == '/') bl--; + char *url = xasprintf("%.*s/models", (int)bl, bu); + + 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); + free(url); + + char **list = NULL; + if (rc != 0) { + if (err && errlen) + snprintf(err, errlen, "%s", buf[0] ? buf : t("models.err.request_failed")); + } else { + list = models_parse(body, out_n); + if (!list && err && errlen) { + if (models_is_empty_list(body)) + snprintf(err, errlen, "%s", + tf("models.err.empty_list", "status", i18n_num(status), + NULL)); + else if (body && *body) { + /* 只回显开头一段:服务端偶尔回一大坨 HTML,全塞进错误里没法读 */ + char head[121]; + snprintf(head, sizeof head, "%.120s", body); + snprintf(err, errlen, "%s", + tf("models.err.unparsable", "status", i18n_num(status), + "body", head, NULL)); + } else + snprintf(err, errlen, "%s", + tf("models.err.empty_body", "status", i18n_num(status), + NULL)); + } + } + free(body); + return list; +} diff --git a/src/models.h b/src/models.h new file mode 100644 index 0000000..b10bf2d --- /dev/null +++ b/src/models.h @@ -0,0 +1,37 @@ +/* models.h - 拉取并解析服务端的模型列表(GET {base_url}/models) + * + * 只为了 /model 不带参数时的那个交互式选择列表。OpenAI 兼容的响应体长这样: + * + * {"object": "list", "data": [{"id": "deepseek-chat", ...}, ...]} + * + * 我们只取 id。别的字段(object / owned_by / created)对挑模型没有帮助, + * 各家还都不一样 —— 取了反而要跟着人家的变化改。 + */ +#ifndef LIGENT_MODELS_H +#define LIGENT_MODELS_H + +#include "config.h" + +#include + +/* 从 /models 的响应体里取模型名。保序、去重,返回 malloc 的字符串数组 + * (元素也是 malloc),*out_n 回填个数。解析不出任何模型名时返回 NULL。 + * + * 兼容两种形状:{"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 细节。 */ +char **models_fetch(const LigentConfig *cfg, size_t *out_n, + int verbose, char *err, size_t errlen); + +/* 释放 models_parse / models_fetch 返回的数组 */ +void models_free(char **list, size_t n); + +#endif /* LIGENT_MODELS_H */ diff --git a/src/priv.c b/src/priv.c new file mode 100644 index 0000000..5ad9155 --- /dev/null +++ b/src/priv.c @@ -0,0 +1,56 @@ +/* priv.c - 提权:经 sudo 执行单条命令 */ +#include "priv.h" +#include "util.h" + +#include +#include +#include +#include +#include +#include + +int priv_have_tty(void) +{ + int fd = open("/dev/tty", O_RDWR); + if (fd < 0) return 0; + close(fd); + return 1; +} + +char **priv_sudo_argv(const char *user, char *const argv[]) +{ + size_t n = 0; + while (argv[n]) n++; + const char *u = (user && *user) ? user : "root"; + + /* 刻意不带 -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"; + w[2] = (char *)u; + w[3] = (char *)"--"; + for (size_t i = 0; i < n; i++) w[4 + i] = argv[i]; + return w; +} + +void priv_explain(int rc, const char *out, char *err, size_t errlen) +{ + if (!err || !errlen) return; + const char *o = out ? out : ""; + + if (rc == 127 && strstr(o, "无法执行")) + snprintf(err, errlen, "这台机器上没有 sudo,无法提权"); + else if (strstr(o, "no tty present") || strstr(o, "a terminal is required")) + snprintf(err, errlen, "提权需要终端来输入密码,当前没有可用的控制终端"); + else if (strstr(o, "incorrect password") || strstr(o, "Sorry, try again") || + strstr(o, "认证失败") || strstr(o, "密码不正确")) + snprintf(err, errlen, "sudo 认证没有通过(密码不对,或者你取消了)"); + else if (strstr(o, "not in the sudoers") || strstr(o, "不在 sudoers")) + snprintf(err, errlen, "当前用户没有 sudo 权限"); + else + snprintf(err, errlen, "%s", *o ? o : "提权命令执行失败"); +} diff --git a/src/priv.h b/src/priv.h new file mode 100644 index 0000000..b095300 --- /dev/null +++ b/src/priv.h @@ -0,0 +1,42 @@ +/* priv.h - 提权:经 sudo 执行单条命令 + * + * 先说清楚这个模块**不做**什么,以及为什么: + * + * PAM 认证不等于获得权限。su 之所以能变 root,是因为 /usr/bin/su 这个文件 + * 本身是 setuid root —— 内核先把 root 给它,它再验密码、验不过就降权。普通 + * 进程调 pam_authenticate() 只能得到"密码对不对",uid 一位都不变。要让 ligent + * 内部认证后真的变 root,唯一的路是把它编译成 setuid root;而它的功能是 + * "让模型执行任意命令",setuid root 的它等于把 root 远程执行做成一等功能。 + * 所以不走那条路。 + * + * 也不做"授权提示"。**密码本身就是授权**:模型敲不出来,你也不可能手滑敲出来。 + * 再加一道 y/N 只会是多余的摩擦,还会给出一个虚假的安全模型 —— 好像把关的是 + * ligent 的提示,其实真正把关的是 sudo。所以这里只做两件事: + * + * 1. 把命令包成 `sudo -u <用户> -- ` 交出去(**不经 shell**, + * 所以路径/命令里的引号、换行、`;` 注入不进 root 那条命令里); + * 2. 需要密码时,**让终端留在 sudo 手上**,由它自己问、自己存 —— ligent + * 不读、不存、不转发密码。 + * + * 唯一由 ligent 把守的是"别卡死":没有控制终端就**根本不去试**,直接说清楚。 + * 否则就会起一个在后台等一个没人能看见的密码提示的进程。 + */ +#ifndef LIGENT_PRIV_H +#define LIGENT_PRIV_H + +#include + +/* 有可用的控制终端吗?判据是能不能打开 /dev/tty,**不是** isatty(0): + * `echo x | ligent ...` 时 stdin 是管道,但终端还在,sudo 照样问得出来。 */ +int priv_have_tty(void); + +/* 把 argv 包成 `sudo -u -- <原 argv>`(user 为空则 "root")。 + * 故意**不带 -n**:需要密码时就让 sudo 自己去问。 + * 返回 malloc 的数组,元素是借用的指针 —— 调用方 free 数组本身即可。 */ +char **priv_sudo_argv(const char *user, char *const argv[]); + +/* sudo 失败时把它的原话换成一句能照做的中文(写进 err)。 + * 认不出来就原样透传 —— 宁可给原文,也不要编一句可能不对的解释。 */ +void priv_explain(int rc, const char *out, char *err, size_t errlen); + +#endif /* LIGENT_PRIV_H */ diff --git a/src/shell.c b/src/shell.c new file mode 100644 index 0000000..cfbb07c --- /dev/null +++ b/src/shell.c @@ -0,0 +1,304 @@ +/* shell.c - 交互式 shell 会话(pty 双向搬运) */ +#include "shell.h" +#include "i18n.h" +#include "util.h" +#include "http.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* bash → ash → dash → sh。 + * + * 刻意不看 $SHELL:用户环境里的 $SHELL 可能是 xonsh 之类交互式外壳, + * 在容器/受限环境里经常起不来,反而把 /shell 弄坏。返回的路径一定存在。 */ +static const char *pick_shell(void) +{ + static const char *cands[] = { + "/bin/bash", "/bin/ash", "/bin/dash", "/bin/sh", NULL + }; + for (size_t i = 0; cands[i]; i++) + if (access(cands[i], X_OK) == 0) return cands[i]; + return "/bin/sh"; +} + +/* 把终端输出整理成"能读的记录": + * 去掉 ANSI 转义、把 CRLF 归一成 LF、丢掉退格与其它控制字符,并处理全屏程序。 + * 不清理的话模型看到的是一堆 ^[[32m 之类的噪音。 + * + * 状态必须跨 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 这类全屏程序) */ + size_t space_run; /* 还没输出的连续空格 */ + char csi[40]; + size_t csi_len; +} CleanCtx; + +/* 全屏程序(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; } + size_t k = cx->space_run <= 8 ? cx->space_run : 1; + for (size_t i = 0; i < k && out->len < LIGENT_SHELL_MAX; i++) sb_putc(out, ' '); + cx->space_run = 0; +} + +static void clean_putc(CleanCtx *cx, StrBuf *out, unsigned char c) +{ + if (cx->in_alt || out->len >= LIGENT_SHELL_MAX) return; + if (c == ' ') { cx->space_run++; return; } + clean_flush_spaces(cx, out); + if (out->len < LIGENT_SHELL_MAX) sb_putc(out, (char)c); +} + +/* 备用屏幕的开启序列有 ?1049(vim/less)、?1047 和 ?47(老程序)几种写法, + * 结束用的是同样的编号加 'l'。 */ +static int csi_alt_screen(const CleanCtx *cx, int *entering) +{ + if (cx->csi_len == 0) return 0; + char last = cx->csi[cx->csi_len - 1]; + if (last != 'h' && last != 'l') return 0; + if (!strstr(cx->csi, "?1049") && !strstr(cx->csi, "?1047") && + !strstr(cx->csi, "?47")) + return 0; + *entering = (last == 'h'); + return 1; +} + +static void append_clean(CleanCtx *cx, StrBuf *out, const char *data, size_t n) +{ + for (size_t i = 0; i < n; i++) { + unsigned char c = (unsigned char)data[i]; + + switch (cx->state) { + case CLEAN_ESC: /* ESC 后面看是什么序列 */ + if (c == '[') { cx->state = CLEAN_CSI; cx->csi_len = 0; } + else if (c == ']') cx->state = CLEAN_OSC; + else cx->state = CLEAN_NORM; + continue; + + case CLEAN_CSI: + if (cx->csi_len < sizeof cx->csi - 1) cx->csi[cx->csi_len++] = (char)c; + if (c >= 0x40 && c <= 0x7e) { /* 终结字节,序列到此为止 */ + int entering = 0; + cx->csi[cx->csi_len] = '\0'; + if (csi_alt_screen(cx, &entering)) { + if (entering && !cx->in_alt) { + clean_flush_spaces(cx, out); + cx->in_alt = 1; + if (out->len < LIGENT_SHELL_MAX) + sb_puts(out, "\n[全屏程序运行中,画面内容已省略]\n"); + } else if (!entering && cx->in_alt) { + cx->in_alt = 0; + cx->space_run = 0; + if (out->len < LIGENT_SHELL_MAX) + sb_puts(out, "[全屏程序结束]\n"); + } + } + cx->state = CLEAN_NORM; + } + continue; + + case CLEAN_OSC: /* OSC 到 BEL 或 ST 结束 */ + if (c == '\a') cx->state = CLEAN_NORM; + else if (c == 0x1b) cx->state = CLEAN_OSC_ESC; + continue; + + case CLEAN_OSC_ESC: + cx->state = (c == '\\') ? CLEAN_NORM : CLEAN_OSC; + continue; + + default: + break; + } + + if (c == 0x1b) { cx->state = CLEAN_ESC; continue; } + if (c == '\r') continue; /* CRLF 归一成 LF;孤立的 CR 丢掉 */ + if (c == '\b') continue; /* 退格在记录里没有意义 */ + if (c < 0x09 || (c > 0x0d && c < 0x20)) continue; + clean_putc(cx, out, c); + } +} + +static int tty_raw(struct termios *saved) +{ + if (!isatty(STDIN_FILENO)) return -1; + if (tcgetattr(STDIN_FILENO, saved) != 0) return -1; + struct termios raw = *saved; + raw.c_iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON); + raw.c_cflag |= CS8; + raw.c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG); + raw.c_cc[VMIN] = 1; + raw.c_cc[VTIME] = 0; + return tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) == 0 ? 0 : -1; +} + +char *shell_interactive(int *out_code, char *errbuf, size_t errlen) +{ + if (out_code) *out_code = 0; + + int master = posix_openpt(O_RDWR | O_NOCTTY); + if (master < 0) { + snprintf(errbuf, errlen, "%s", + tf("shell.err.no_pty", "error", strerror(errno), NULL)); + return NULL; + } + if (grantpt(master) != 0 || unlockpt(master) != 0) { + snprintf(errbuf, errlen, "%s", + tf("shell.err.pty_init", "error", strerror(errno), NULL)); + close(master); + return NULL; + } + char *slave_name = ptsname(master); + if (!slave_name) { + snprintf(errbuf, errlen, "%s", t("shell.err.no_slave_name")); + close(master); + return NULL; + } + char slave_path[256]; + snprintf(slave_path, sizeof slave_path, "%s", slave_name); + + struct winsize ws; + if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0) + ioctl(master, TIOCSWINSZ, &ws); + + /* 全屏程序(nano/vim/less/top)靠"备用屏幕"切换来还原退出前的画面, + * 但它们只在 TERM 声称支持时才这么做:TERM 为空或 dumb 时 nano 会退化成 + * 逐行重绘,几百个空格把记录淹掉,也没法整段忽略。 + * + * 而 ligent 自己的 REPL 就在用颜色、光标定位和括号粘贴 —— 这个终端至少是 + * ANSI 兼容的,TERM=dumb 属于谎报。所以只在 TERM 缺失或为 dumb 时补一个 + * xterm-256color;用户显式设了别的值就原样传下去,不擅自改。 */ + char term_saved[64]; + int term_restore = 0, term_was_set = 0; + { + const char *t = getenv("TERM"); + if (!t || !*t || strcmp(t, "dumb") == 0) { + if (t) { + snprintf(term_saved, sizeof term_saved, "%s", t); + term_was_set = 1; + } + setenv("TERM", "xterm-256color", 1); + term_restore = 1; + } + } + + pid_t pid = fork(); + /* 子进程已经继承了改过的 TERM,父进程立刻还原,免得影响 ligent 自己。 + * 必须显式判断 pid != 0 —— 子进程也会从 fork() 返回并往下走。 */ + if (pid != 0 && term_restore) { + if (term_was_set) setenv("TERM", term_saved, 1); + else unsetenv("TERM"); + } + if (pid < 0) { + snprintf(errbuf, errlen, "%s", + tf("shell.err.fork", "error", strerror(errno), NULL)); + close(master); + return NULL; + } + if (pid == 0) { + setsid(); + int slave = open(slave_path, O_RDWR); + if (slave < 0) _exit(127); + ioctl(slave, TIOCSCTTY, 0); /* 让这个 pty 成为控制终端 */ + dup2(slave, STDIN_FILENO); + dup2(slave, STDOUT_FILENO); + dup2(slave, STDERR_FILENO); + if (slave > 2) close(slave); + close(master); + const char *sh = pick_shell(); + execl(sh, sh, "-i", (char *)NULL); + execl("/bin/sh", "sh", (char *)NULL); + _exit(127); + } + + /* ---- 父进程:把终端切到原始模式,双向搬运 ---- */ + struct termios saved; + int raw = (tty_raw(&saved) == 0); + + StrBuf rec; + sb_init(&rec); + CleanCtx clean; + memset(&clean, 0, sizeof clean); + + int child_done = 0, eof = 0, status = 0; + + while (!(child_done && eof)) { + if (!child_done) { + pid_t w = waitpid(pid, &status, WNOHANG); + if (w == pid) child_done = 1; + } + if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0) + ioctl(master, TIOCSWINSZ, &ws); + + struct pollfd fds[2]; + fds[0].fd = STDIN_FILENO; + fds[0].events = POLLIN; + fds[0].revents = 0; + fds[1].fd = master; + fds[1].events = POLLIN; + fds[1].revents = 0; + + int pr = poll(fds, eof ? 1 : 2, 100); + if (pr < 0) { + if (errno == EINTR) continue; + break; + } + + if (!eof && (fds[1].revents & (POLLIN | POLLHUP | POLLERR))) { + char buf[4096]; + ssize_t r = read(master, buf, sizeof buf); + if (r > 0) { + fwrite(buf, 1, (size_t)r, stdout); + fflush(stdout); + append_clean(&clean, &rec, buf, (size_t)r); + } else if (r == 0 || (r < 0 && errno == EIO)) { + eof = 1; /* 从设备关闭:Linux 上会得到 EIO */ + } else if (r < 0 && errno != EAGAIN && errno != EINTR) { + eof = 1; + } + } + if (fds[0].revents & POLLIN) { + char buf[4096]; + ssize_t r = read(STDIN_FILENO, buf, sizeof buf); + if (r > 0) { + if (write(master, buf, (size_t)r) < 0 && errno == EIO) eof = 1; + } + } + } + + if (!child_done) { + kill(-pid, SIGKILL); + while (waitpid(pid, &status, 0) < 0 && errno == EINTR) { } + } + close(master); + if (raw) tcsetattr(STDIN_FILENO, TCSAFLUSH, &saved); + + int code = -1; + if (WIFEXITED(status)) code = WEXITSTATUS(status); + else if (WIFSIGNALED(status)) code = 128 + WTERMSIG(status); + if (out_code) *out_code = code; + + clean_flush_spaces(&clean, &rec); /* 收尾:还压在手里的空格 */ + /* 全屏程序被 Ctrl-C/SIGKILL 干掉时会来不及发退出序列,补一个收尾标记, + * 免得记录里留下一个没有配对的"开始"。 */ + if (clean.in_alt && rec.len < LIGENT_SHELL_MAX) + sb_puts(&rec, "[全屏程序结束]\n"); + return sb_detach(&rec); +} diff --git a/src/shell.h b/src/shell.h new file mode 100644 index 0000000..602d59a --- /dev/null +++ b/src/shell.h @@ -0,0 +1,20 @@ +/* shell.h - 交互式 shell 会话 */ +#ifndef LIGENT_SHELL_H +#define LIGENT_SHELL_H + +#include + +/* 起一个交互式 shell,用户在里面随便敲;退出时把整段会话录制下来。 + * + * 用伪终端(pty)做双向搬运:子进程以为自己接在真终端上,所以行编辑、 + * 颜色、Ctrl-C、作业控制都照常工作;我们在外面旁听并把输出记下来。 + * + * 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 new file mode 100644 index 0000000..b0f7b6b --- /dev/null +++ b/src/spinner.c @@ -0,0 +1,165 @@ +/* spinner.c - "思考中"指示器实现 */ +#include "spinner.h" +#include "i18n.h" + +#include +#include +#include +#include +#include +#include + +#define DIM "\033[2m" +#define OFF "\033[0m" + +static pthread_t g_thread; +static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t g_cond; +static pthread_once_t g_cond_once = PTHREAD_ONCE_INIT; +static volatile int g_running; +static int g_started; +static int g_enabled; +static char g_label[64]; +/* 当前这段等待的起点。换标签时重置,于是秒数反映的是"这个阶段等了多久": + * 先看到"等待响应 (1s)",切成"思考中"后重新从 0 数起。 */ +static struct timespec g_t0; + +/* 条件变量必须显式指定用 CLOCK_MONOTONIC。 + * + * pthread_cond_timedwait 默认按 CLOCK_REALTIME 解释超时,而我们算出来的 + * 是 CLOCK_MONOTONIC 的绝对时间。开机时长(几十万秒)永远小于 epoch 时间 + * (十几亿秒),于是"超时时刻"永远被当成已经过去 —— timedwait 立刻返回, + * worker 变成疯狂重绘的忙循环。 */ +static void init_cond(void) +{ + pthread_condattr_t attr; + pthread_condattr_init(&attr); + pthread_condattr_setclock(&attr, CLOCK_MONOTONIC); + pthread_cond_init(&g_cond, &attr); + pthread_condattr_destroy(&attr); +} + +static void *worker(void *arg) +{ + (void)arg; + static const char *dots[] = { ".", "..", "...", "...." }; + int i = 0; + struct timespec next; + clock_gettime(CLOCK_MONOTONIC, &next); + + for (;;) { + struct timespec now; + long elapsed; + + pthread_mutex_lock(&g_lock); + if (!g_running) { pthread_mutex_unlock(&g_lock); break; } + + clock_gettime(CLOCK_MONOTONIC, &now); + 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 秒就把秒数亮出来 */ + if (elapsed >= 5) fprintf(stderr, " %s(%lds)%s", DIM, elapsed, OFF); + fflush(stderr); + i = (i + 1) % 4; + + /* 睡到下一帧。这里必须用条件变量定时等,不能 nanosleep: + * spinner_stop() 要 pthread_join,nanosleep 的话就得干等这一觉 + * 睡满 —— 每次收尾白搭最多 250ms,而这笔账直接加在用户感知的 + * 延迟上(每轮模型往返、每个工具来回都要付一次)。 */ + next.tv_nsec += 250000000L; + if (next.tv_nsec >= 1000000000L) { + next.tv_sec++; + next.tv_nsec -= 1000000000L; + } + pthread_cond_timedwait(&g_cond, &g_lock, &next); + pthread_mutex_unlock(&g_lock); + } + return NULL; +} + +void spinner_init(int enabled) +{ + g_enabled = enabled ? 1 : 0; +} + +/* 调用方必须已经持有锁。标签真的变了才重置计时, + * 否则每来一片思维链都重置,"思考中 (12s)"永远停在 0。 */ +static void set_label_locked(const char *label) +{ + const char *want = (label && *label) ? label : t("spinner.thinking"); + if (strcmp(g_label, want) == 0) return; + snprintf(g_label, sizeof g_label, "%s", want); + clock_gettime(CLOCK_MONOTONIC, &g_t0); +} + +void spinner_start(const char *label) +{ + if (!g_enabled) return; + pthread_once(&g_cond_once, init_cond); /* 保证 g_cond 用的是单调时钟 */ + pthread_mutex_lock(&g_lock); + if (!g_started) { + /* 先把标签算成**一份副本**再写进 g_label:label 有可能就是 g_label 自己 + * —— 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 自己 */ + g_running = 1; + g_started = 1; + if (pthread_create(&g_thread, NULL, worker, NULL) != 0) { + g_started = 0; + g_running = 0; + } + } + pthread_mutex_unlock(&g_lock); +} + +void spinner_set_label(const char *label) +{ + if (!g_enabled) return; + pthread_mutex_lock(&g_lock); + set_label_locked(label); + pthread_mutex_unlock(&g_lock); +} + +void spinner_stop(void) +{ + if (!g_enabled) return; + pthread_mutex_lock(&g_lock); + if (!g_started) { pthread_mutex_unlock(&g_lock); return; } + g_running = 0; + pthread_cond_signal(&g_cond); /* 立刻叫醒 worker,别等它睡满一帧 */ + pthread_mutex_unlock(&g_lock); + + pthread_join(g_thread, NULL); + + pthread_mutex_lock(&g_lock); + g_started = 0; + fputs("\r\033[K", stderr); /* 把动画那一行擦干净 */ + fflush(stderr); + pthread_mutex_unlock(&g_lock); +} + +void spinner_notice(const char *fmt, ...) +{ + int was_on; + pthread_mutex_lock(&g_lock); + was_on = g_started; + pthread_mutex_unlock(&g_lock); + + if (was_on) spinner_stop(); + + va_list ap; + va_start(ap, fmt); + vfprintf(stderr, fmt, ap); + va_end(ap); + fputc('\n', stderr); + fflush(stderr); + + if (was_on) spinner_start(g_label); +} diff --git a/src/spinner.h b/src/spinner.h new file mode 100644 index 0000000..25ac2d5 --- /dev/null +++ b/src/spinner.h @@ -0,0 +1,37 @@ +/* spinner.h - 等待指示器 + * + * 存在的意义:模型不输出思考过程是好事,但等待期间屏幕上什么都没有, + * 和"卡死了"看起来一模一样。一个灰色的动画能把这个歧义消掉。 + * + * 分两段显示,因为这两种等待的性质不一样: + * 刚发出请求、还没收到任何东西 → "等待响应"(卡在网络或排队) + * 开始收到思维链 → "思考中"(模型确实在推理) + * 一收到正文就停 —— 那时已经有东西可看了。 + * + * 输出走 stderr,且只在 stderr 是终端时才启用,所以不会污染管道输出。 + */ +#ifndef LIGENT_SPINNER_H +#define LIGENT_SPINNER_H + +#include "util.h" /* LIGENT_PRINTF */ + +/* 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 new file mode 100644 index 0000000..c8f81aa --- /dev/null +++ b/src/tools.c @@ -0,0 +1,1300 @@ +/* tools.c - 工具实现:shell、文件读写、编辑、列目录、搜索 + * + * 设计取向:这是"全权执行"模式,不做沙箱限制;但仍对输出做截断, + * 避免一次命令把上下文撑爆。 + */ +#include "tools.h" +#include "util.h" +#include "i18n.h" +#include "http.h" +#include "image.h" +#include "priv.h" +#include "llm.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* ------------------------------------------------------------------ */ +/* 小工具 */ +/* ------------------------------------------------------------------ */ + +static void append_capped(StrBuf *sb, const char *data, size_t n, int *truncated) +{ + if (*truncated) return; + if (sb->len + n > LIGENT_TOOL_OUTPUT_LIMIT) { + size_t take = sb->len < LIGENT_TOOL_OUTPUT_LIMIT + ? LIGENT_TOOL_OUTPUT_LIMIT - sb->len : 0; + sb_append(sb, data, take); + *truncated = 1; + return; + } + sb_append(sb, data, n); +} + +static int64_t now_ms(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; +} + +static const char *arg_str(const JsonValue *args, const char *key) +{ + return json_string_value(json_object_get(args, key)); +} + +LIGENT_PRINTF(1, 2) static char *err_result(const char *fmt, ...) +{ + va_list ap; + va_start(ap, fmt); + char *body = xvasprintf(fmt, ap); + va_end(ap); + char *out = xasprintf("错误:%s", body); + free(body); + return out; +} + +/* ---- 二进制判定 ---- + * + * 判据(出现 NUL 字节)与探测窗口(前 8 KB)都在 util.h 里,由所有处理文本的 + * 地方共用一份 —— 以前这里和 attach.c 各写一份、窗口还不一样,于是同一个文件 + * 会出现"/include 说它是二进制、read_file 说它能读"的矛盾说法。 + * edit_file 是唯一的例外,它检查**全文件**(见那里的说明)。 */ + +/* 文件看起来是二进制吗?是则返回 1 并把第一个 NUL 的偏移写进 *at。 + * + * 打不开或读不出来时返回 0 —— 让真正做这件事的调用方去报"读不到", + * 否则同一次失败会同时冒出两条互相竞争的错误信息。 */ +static int file_is_binary(const char *path, long *at) +{ + FILE *f = fopen(path, "rb"); + if (!f) return 0; + char probe[LIGENT_BIN_PROBE]; + size_t n = fread(probe, 1, sizeof probe, f); + fclose(f); + return looks_binary(probe, n, at); +} + +/* 统一的拒绝说法:先摆事实(第几字节有 NUL),再说这个工具为什么只收文本、 + * 以及该改用什么。只说"不行"等于让模型原地打转。 */ +static char *binary_err(const char *path, long at, const char *why) +{ + return err_result("%s 看起来是二进制文件(第 %ld 字节处有 NUL 字节)。%s", + path, at + 1, why); +} + +/* ------------------------------------------------------------------ */ +/* run_command */ +/* ------------------------------------------------------------------ */ + +#define CMD_DEFAULT_TIMEOUT 60 +#define CMD_MAX_TIMEOUT 600 + +static char *tool_run_command(const JsonValue *args, int *is_error) +{ + REQUIRE_ERR_PTR(is_error); + const char *cmd = arg_str(args, "command"); + if (!cmd || !*cmd) { + *is_error = 1; + return xstrdup("错误:缺少必填参数 command"); + } + double t = json_get_number(args, "timeout", CMD_DEFAULT_TIMEOUT); + if (t <= 0) t = CMD_DEFAULT_TIMEOUT; + if (t > CMD_MAX_TIMEOUT) t = CMD_MAX_TIMEOUT; + int64_t timeout_ms = (int64_t)(t * 1000); + + /* elevated:以 root 执行这一条命令。 + * + * 不做额外的"授权提示" —— 密码本身就是授权:模型敲不出来,你也不可能 + * 手滑敲出来。确认提示(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," + "或者换一个不需要提权的做法。"); + } + + int fds[2]; + if (pipe(fds) != 0) + { *is_error = 1; return err_result("pipe 失败:%s", strerror(errno)); } + + pid_t pid = fork(); + if (pid < 0) { + close(fds[0]); close(fds[1]); + *is_error = 1; + return err_result("fork 失败:%s", strerror(errno)); + } + if (pid == 0) { + /* 非提权:独立进程组,超时时可以整组杀掉。 + * 提权:**不 setsid** —— sudo 是打开 /dev/tty 问密码的,摘掉控制终端 + * 它就永远问不出来(这正是上一版认证必然失败的成因)。 + * 代价是提权这条没法整组杀,超时只杀直接子进程。 */ + if (!elevated) setsid(); + int devnull = open("/dev/null", O_RDONLY); + if (devnull >= 0) { dup2(devnull, STDIN_FILENO); close(devnull); } + dup2(fds[1], STDOUT_FILENO); + dup2(fds[1], STDERR_FILENO); + close(fds[0]); + close(fds[1]); + /* 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); + _exit(127); + } + + close(fds[1]); + int fl = fcntl(fds[0], F_GETFL, 0); + if (fl >= 0) fcntl(fds[0], F_SETFL, fl | O_NONBLOCK); + + StrBuf out; + sb_init(&out); + int truncated = 0; + int timed_out = 0; + int interrupted = 0; + int64_t t0 = now_ms(); + int eof = 0; + + while (!eof) { + if (g_ligent_interrupt) { + interrupted = 1; + kill(-pid, SIGKILL); + break; + } + int64_t elapsed = now_ms() - t0; + if (elapsed >= timeout_ms) { + timed_out = 1; + kill(-pid, SIGKILL); + break; + } + int64_t left = timeout_ms - elapsed; + int poll_ms = left > 200 ? 200 : (int)left; + + struct pollfd pfd; + pfd.fd = fds[0]; + pfd.events = POLLIN; + pfd.revents = 0; + int pr = poll(&pfd, 1, poll_ms); + if (pr < 0) { if (errno == EINTR) continue; break; } + if (pr == 0) continue; + + for (;;) { + char buf[8192]; + ssize_t n = read(fds[0], buf, sizeof buf); + if (n > 0) { append_capped(&out, buf, (size_t)n, &truncated); continue; } + if (n == 0) { eof = 1; break; } + if (errno == EINTR) continue; + if (errno == EAGAIN || errno == EWOULDBLOCK) break; + eof = 1; + break; + } + } + close(fds[0]); + + int status = 0; + if (timed_out || interrupted) + kill(elevated ? pid : -pid, SIGKILL); + while (waitpid(pid, &status, 0) < 0 && errno == EINTR) { /* retry */ } + + int exit_code = -1; + int by_signal = 0; + if (WIFEXITED(status)) exit_code = WEXITSTATUS(status); + else if (WIFSIGNALED(status)) { exit_code = 128 + WTERMSIG(status); by_signal = 1; } + + StrBuf result; + sb_init(&result); + if (out.len > 0) sb_append(&result, out.data, out.len); + if (truncated) + 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不要反复重试同一个提权命令;" + "如果需要权限才能继续,说明你需要用户亲自做什么。"); + } + if (result.len > 0 && result.data[result.len - 1] != '\n') + sb_putc(&result, '\n'); + if (interrupted) { + sb_puts(&result, "[已中断]"); + *is_error = 1; + } else if (timed_out) { + sb_printf(&result, "[超时 %d 秒,进程组已被终止]", (int)t); + *is_error = 1; + } else if (by_signal) { + sb_printf(&result, "[被信号 %d 终止]", exit_code - 128); + *is_error = 1; + } else { + sb_printf(&result, "[exit code: %d]", exit_code); + if (exit_code != 0) *is_error = 1; + } + + sb_free(&out); + return sb_detach(&result); +} + +/* ------------------------------------------------------------------ */ +/* read_file */ +/* ------------------------------------------------------------------ */ + +static char *tool_read_file(const JsonValue *args, int *is_error) +{ + REQUIRE_ERR_PTR(is_error); + const char *path = arg_str(args, "path"); + if (!path || !*path) { *is_error = 1; return xstrdup("错误:缺少必填参数 path"); } + + double off = json_get_number(args, "offset", 1); + double lim = json_get_number(args, "limit", 2000); + long offset = off > 0 ? (long)off : 1; + long limit = lim > 0 ? (long)lim : 2000; + if (limit > 20000) limit = 20000; + + FILE *f = fopen(path, "rb"); + if (!f) { *is_error = 1; return err_result("无法打开 %s:%s", path, strerror(errno)); } + + /* 二进制文件按文本读出来是乱码,而且会在 NUL 处被截断 —— 直接拒绝。 + * 文件已经打开了,就地探一遍前 8 KB(窗口与判据见 util.h),不必再开一次。 */ + { + char probe[LIGENT_BIN_PROBE]; + size_t pn = fread(probe, 1, sizeof probe, f); + rewind(f); + long at = -1; + if (looks_binary(probe, pn, &at)) { + fclose(f); + *is_error = 1; + return binary_err(path, at, + "read_file 只按文本读(读出来会是乱码,还会在 NUL 处被截断)。" + "看二进制内容用 run_command(file / xxd / strings / od);" + "看图片用 read_image。"); + } + } + + /* 按 '\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 的行号去 + * 改,要么改错地方、要么报越界 —— 而它没有任何办法察觉。 + * + * 单行的字节数靠"上一块是不是以换行结尾"(in_line)来跟踪,所以仍然**不** + * 需要把整个文件读进内存:一行再长也只占 line[] 这么多,超出输出上限就如实 + * 说明这一行读不出来,而不是把它伪装成若干行。 */ + StrBuf sb; + sb_init(&sb); + char line[65536]; + long lineno = 0; /* 已读到的行号(只在真正换行时递增) */ + long emitted = 0; /* 已输出的行数 */ + long partial_line = 0; /* 这一行只给了开头(它自己超过输出上限) */ + long empty_line = 0; /* 这一行一个字节都没能进去 */ + size_t line_bytes = 0; /* 当前这一行已经进了缓冲多少字节 */ + int cut_by_bytes = 0; /* 截断的原因是字节上限,而不是行数上限 */ + int truncated = 0; + int in_line = 0; /* 上一块不以 '\n' 结尾 => 这一块还是同一行 */ + + while (fgets(line, sizeof line, f)) { + size_t n = strlen(line); + int has_nl = (n > 0 && line[n - 1] == '\n'); + + if (!in_line) { /* 新的一行从这里开始 */ + lineno++; + line_bytes = 0; + if (lineno < offset) { in_line = !has_nl; continue; } + if (emitted >= limit) { truncated = 1; break; } + char prefix[32]; + int pl = snprintf(prefix, sizeof prefix, "%6ld\t", lineno); + if (sb.len + (size_t)pl >= LIGENT_TOOL_OUTPUT_LIMIT) { + if (emitted == 0) empty_line = lineno; + cut_by_bytes = 1; + truncated = 1; + break; + } + sb_append(&sb, prefix, (size_t)pl); + emitted++; + } + in_line = !has_nl; + + if (sb.len + n > LIGENT_TOOL_OUTPUT_LIMIT) { + /* 撞的是**字节**上限,而且撞在"正在写的这一行"上(前面那些行都已经 + * 完整给出)。分两种:这一行连一个字节都没进去(前缀已写、内容为 0), + * 还是只给了它开头。两种情况都要如实说,不能让调用方以为拿到的是 + * 一份完整的行 —— 也不能把剩下的部分伪装成"下一行"。 */ + cut_by_bytes = 1; + if (line_bytes == 0) { + empty_line = lineno; + sb_clear(&sb); + emitted = 0; + } else { + partial_line = lineno; + } + truncated = 1; + break; + } + sb_append(&sb, line, n); + line_bytes += n; + /* 文件最后一行没有换行符时补一个,否则行号前缀会跟下一行粘连 */ + if (!has_nl) sb_putc(&sb, '\n'); + } + fclose(f); + + if (lineno == 0) { + sb_free(&sb); + return xstrdup("(空文件)"); + } + if (emitted == 0) { + sb_free(&sb); + if (empty_line > 0) + return xasprintf( + "(第 %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); + } + if (truncated) { + if (cut_by_bytes && partial_line > 0) + /* 撞的是字节上限:给出的是**半行**,所以不能再说"从下一行继续" */ + sb_printf(&sb, + "... [第 %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); + } + return sb_detach(&sb); +} + +/* ------------------------------------------------------------------ */ +/* write_file */ +/* ------------------------------------------------------------------ */ + +static char *tool_write_file(const JsonValue *args, int *is_error) +{ + REQUIRE_ERR_PTR(is_error); + const char *path = arg_str(args, "path"); + JsonValue *cv = json_object_get(args, "content"); + if (!path || !*path) { *is_error = 1; return xstrdup("错误:缺少必填参数 path"); } + if (!cv || cv->type != JSON_STRING) { + *is_error = 1; + return xstrdup("错误:缺少必填参数 content(必须是字符串)"); + } + const char *content = cv->u.string; + + /* 自动创建父目录 */ + char *dir = xstrdup(path); + char *slash = strrchr(dir, '/'); + if (slash && slash != dir) { + *slash = '\0'; + mkdir_p(dir); + } + free(dir); + + /* 别拿文本去整体覆盖一个二进制文件:那等于把原内容毁掉,而模型多半没 + * 意识到(它只是想"写个文件")。要真的换掉它,先删或用 run_command。 */ + { + long at = 0; + if (file_is_binary(path, &at)) { + *is_error = 1; + return binary_err(path, at, + "write_file 只写文本,整体覆盖它等于把原来的内容毁掉。" + "确实要换掉这个文件,先用 run_command 删掉它,或者换一个路径。"); + } + } + + /* 落盘前再看一眼中断标志。确认环节已经会拦下 Ctrl-C,这里是纵深防御: + * "任何改盘的动作之前都检查中断"应当是一条无例外的约定, + * 而不是依赖"恰好上一层拦住了"。 */ + if (g_ligent_interrupt) { + *is_error = 1; + return xstrdup("[已中断] 用户中断了这次操作,文件没有被改动。"); + } + + FILE *f = fopen(path, "wb"); + if (!f) { *is_error = 1; return err_result("无法写入 %s:%s", path, strerror(errno)); } + size_t n = strlen(content); + size_t written = fwrite(content, 1, n, f); + if (fclose(f) != 0 && written == n) written = (size_t)-1; + + if (written != n) { + *is_error = 1; + return err_result("写入 %s 失败:%s", path, strerror(errno)); + } + return xasprintf("已写入 %zu 字节到 %s", n, path); +} + +/* ------------------------------------------------------------------ */ +/* edit_file */ +/* ------------------------------------------------------------------ */ +/* + * 替换受**行号集合**制约:old_string 的**起点**必须落在 line 圈住的行里,且在 + * 这些行里只有一处匹配。 + * + * line 是**集合型,而且可以整个省略**:省略 = 不设行范围,等于**全文件替换所有 + * 匹配**(就是编辑器里那个"全局替换")。给了它就是集合型 —— 单个整数(等价于 + * 只有一个元素的集合)或整数数组都收,`"line": 12` 和 `"line": [12]` 是同一件事。 + * 集合里的行号就是 read_file 打出来的那些(1 起)。 + * + * 为什么是集合而不是单个行号:模型看到的常是一整块内容("第 10~13 行是这段 + * 函数"),但决定命中与否的是 old_string 的**起点**落在哪一行;只给一个行号时 + * 它得先数清第一行到底是 10 还是 11,数错一次就白烧一轮。给了集合之后,照 + * read_file 的显示把覆盖到的那几行都填进来就行。集合只是**搜索范围**,不是 + * "每行都改一遍":真改动的仍是唯一那一处(replace_all 或省略 line 时才动全文件)。 + * + * 为什么不再只靠"全文唯一"来定位:行号是模型唯一的空间坐标(read_file 给的), + * 而"全文唯一"要求整个文件里恰好只有一处 —— 文件一长这个条件经常不成立 + * (同一个 `return 0;` 到处都是),模型只能不断加长上下文去凑唯一性;凑不成 + * 就只能放弃,或者赌一把改错地方。把行号钉住之后,"改哪一处"由坐标决定, + * 不再是个概率问题;old_string 也不必再为"唯一"而写得很长。 + * + * 省略 line 是**故意留着**的逃生门:有些改动本来就该全文件生效(比如统一改个 + * 名字),但不给行号就没法声称"我知道改的是哪一处" —— 回执里也会直说这是全文件 + * 替换。要"改一处"仍得给行号,模型没法靠省略来回避看文件。 + * + * replace_all 仍在,含义不变:改文件里**所有**匹配。但 line 集合必须圈住其中 + * 真实的一处 —— 这等于强制模型先看过文件,而不是凭印象做全文替换。 + */ + +/* 文件按 read_file 的口径有多少行:末尾的换行不算新的一行。 + * (read_file 用 fgets 读,末尾那个"空行"不会产生输出,两边口径必须一致, + * 否则 read_file 显示的行号和这里的越界判断会互相矛盾。) */ +static long count_lines(const char *s, size_t len) +{ + long n = 0; + for (size_t i = 0; i < len; i++) + if (s[i] == '\n') n++; + if (len > 0 && s[len - 1] != '\n') n++; + return n; +} + +/* 第 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; + size_t i = 0; + for (long line = 1; line < n; line++) { + const char *nl = memchr(s + i, '\n', len - i); + if (!nl) return 0; + i = (size_t)(nl - s) + 1; + } + *start = i; + const char *nl = memchr(s + i, '\n', len - i); + *end = nl ? (size_t)(nl - s) : len; + return 1; +} + +/* line 集合里最多几个行号。它是用来圈住 old_string 覆盖的那几行的, + * 给到几十个以上就说明模型在瞎猜行号 —— 那时该做的是重新 read_file 看一遍。 */ +#define EDIT_LINE_MAX 64 + +static int cmp_long(const void *a, const void *b) +{ + long x = *(const long *)a, y = *(const long *)b; + return x < y ? -1 : (x > y ? 1 : 0); +} + +/* 把 line 参数解析成升序去重后的行号数组:写进 out(容量 cap),返回元素个数 + * (>=1)。失败返回 -1,并把给模型看的说法写进 *err(调用方 free)。 + * + * 两种写法都收:整数(历史写法)和整数数组(集合)。所以老调用和老录制里的 + * `"line": 41` 仍然照常工作,不必区分。 + * + * 去重不是洁癖:同一个行号处理两遍,同一处匹配会被数成两处, + * 于是"行内只有一处"的判断会莫名其妙地失败。 */ +static long parse_line_set(const JsonValue *lv, long *out, size_t cap, char **err) +{ + *err = NULL; + int is_arr = (lv->type == JSON_ARRAY); + size_t n = is_arr ? json_array_size(lv) : 1; + + if (is_arr && n == 0) { + *err = xstrdup("错误:line 是空数组 —— 要么至少给一个行号(read_file " + "打出来的那个,从 1 开始),要么把 line 整个省略" + "(省略 = 全文件替换所有匹配)。"); + return -1; + } + if (n > cap) { + *err = xasprintf("错误:line 里有 %zu 个行号,一次最多 %zu 个。" + "集合是用来圈住 old_string 覆盖的那几行的 —— " + "要改的地方太多,先 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 会当场报出来)。 */ + 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); + else + *err = xasprintf("错误:line=%g 不合法 —— 要的是 read_file " + "打出来的 >=1 的整数行号。", d); + return -1; + } + out[i] = (long)d; + } + + qsort(out, n, sizeof *out, cmp_long); + size_t k = 0; + for (size_t i = 0; i < n; i++) + if (k == 0 || out[i] != out[k - 1]) out[k++] = out[i]; + return (long)k; +} + +/* 把行号集合渲染成 "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; + buf[0] = '\0'; + for (long i = 0; i < n; i++) { + if (i >= 8) { snprintf(buf + off, cap - off, "…"); break; } + int w = snprintf(buf + off, cap - off, "%s%ld", i ? "、" : "", v[i]); + if (w < 0 || (size_t)w >= cap - off) break; + off += (size_t)w; + } +} + +static char *tool_edit_file(const JsonValue *args, int *is_error) +{ + REQUIRE_ERR_PTR(is_error); + const char *path = arg_str(args, "path"); + const char *old_s = arg_str(args, "old_string"); + JsonValue *nv = json_object_get(args, "new_string"); + JsonValue *lv = json_object_get(args, "line"); + if (!path || !*path) { *is_error = 1; return xstrdup("错误:缺少必填参数 path"); } + if (!old_s) { *is_error = 1; return xstrdup("错误:缺少必填参数 old_string"); } + if (!nv || nv->type != JSON_STRING) { + *is_error = 1; + return xstrdup("错误:缺少必填参数 new_string(必须是字符串)"); + } + /* 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 整个省略。"); + } + 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 时集合是空的, + * 下面所有"按行"的检查都跳过,直接当全文件替换。 */ + long lines[EDIT_LINE_MAX]; + long nlines = 0; + char req[128]; + req[0] = '\0'; + if (!global) { + char *perr = NULL; + nlines = parse_line_set(lv, lines, EDIT_LINE_MAX, &perr); + if (nlines < 0) { *is_error = 1; return perr; } + /* 报错与回执里要说清"是哪几行":模型据此判断行号有没有被理解对 */ + lines_text(lines, nlines, req, sizeof req); + } + + /* 先看大小再读:edit_file 需要把整个文件放进内存, + * 对超大文件直接拒绝,避免把内存吃干。 + * 用 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)做定点修改,或先拆分文件", + path, (long long)st.st_size, (unsigned)LIGENT_MAX_EDIT_FILE); + } + + size_t flen = 0; + 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 都会让后面的内容 + * 被当成结束 —— 改下去就是把原文件毁掉。 */ + { + 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 这类)。"); + } + } + + /* 上界检查:下界(>=1)和写法在 parse_line_set 里已经查过,这里要文件总行数。 + * 省略 line 时没有行号可查。 */ + if (!global) { + long total_lines = count_lines(src, flen); + for (long i = 0; i < nlines; i++) { + if (lines[i] <= total_lines) continue; + free(src); + *is_error = 1; + if (total_lines == 0) + 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]); + } + } + + size_t olen = strlen(old_s); + /* 搜索范围:给了集合就是集合圈住的那几行 —— 只认起点落在其中某一行上的匹配 + * (old_string 可以跨行),集合已去重所以同一处不会被数两遍;省略 line 就是 + * 整个文件,那时命中几处换几处(见下面的替换分支)。 */ + size_t hits = 0; + const char *at = NULL; + if (global) { + for (const char *p = src; ; ) { + const char *hit = strstr(p, old_s); + if (!hit) break; + if (hits == 0) at = hit; + hits++; + p = hit + olen; + } + } else { + for (long i = 0; i < nlines; i++) { + size_t ls = 0, le = 0; + 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; + if (hits == 0) at = hit; + hits++; + p = hit + olen; + } + } + } + + if (hits == 0 && global) { + /* 省略 line 时没有"给的哪几行"可说,只说整个文件里没有 */ + free(src); + *is_error = 1; + return err_result("%s 整个文件里没有 old_string(注意缩进与换行必须逐字符" + "一致)。要给行范围就填 line:整数或整数数组。", path); + } + + if (hits == 0) { + /* 顺手说清它到底在哪几行 —— 不然模型只能一遍遍猜行号,白烧轮次 */ + StrBuf w; + sb_init(&w); + const char *q = src, *scan = src; + long ln = 1, found = 0; + while ((q = strstr(q, old_s)) != NULL) { + while (scan < q) { if (*scan == '\n') ln++; scan++; } + if (found < 4) sb_printf(&w, "%s%ld", found ? "、" : "", ln); + found++; + q += olen; + } + char *where = sb_detach(&w); + free(src); + *is_error = 1; + char *msg; + if (found == 0) + msg = err_result("%s 第 %s 行没有 old_string,整个文件里也没有" + "(注意缩进与换行必须逐字符一致)。", path, req); + else if (found <= 4) + msg = err_result("%s 第 %s 行没有 old_string,它在第 %s 行。" + "要改那里,就把那一行的行号填进 line。", + path, req, where); + else + msg = err_result("%s 第 %s 行没有 old_string。它在第 %s 行等共 %ld 处," + "要改哪一处就把那一行的行号填进 line。", + path, req, where, found); + free(where); + return msg; + } + + if (hits > 1 && !replace_all && !global) { + free(src); + *is_error = 1; + return err_result("%s 第 %s 行里有 %zu 处 old_string,定不下来是哪一处。" + "把 old_string 写长一点(带上这几行里的上下文)," + "或者设 replace_all=true 把文件里所有匹配都换掉。", + path, req, hits); + } + + StrBuf out; + sb_init(&out); + long replaced = 0; + if (replace_all || global) { + const char *p = src; + for (;;) { + const char *hit = strstr(p, old_s); + if (!hit) { sb_puts(&out, p); break; } + sb_append(&out, p, (size_t)(hit - p)); + sb_puts(&out, new_s); + p = hit + olen; + replaced++; + } + } else { + /* 只替换集合里的那一处(hits==1,上面已经确认过) */ + size_t off = (size_t)(at - src); + sb_append(&out, src, off); + sb_puts(&out, new_s); + sb_append(&out, src + off + olen, flen - off - olen); + replaced = 1; + } + free(src); + + if (g_ligent_interrupt) { /* 同 write_file:改盘前一律先看中断 */ + sb_free(&out); + *is_error = 1; + return xstrdup("[已中断] 用户中断了这次操作,文件没有被改动。"); + } + + FILE *f = fopen(path, "wb"); + if (!f) { + sb_free(&out); + *is_error = 1; + return err_result("无法写入 %s:%s", path, strerror(errno)); + } + size_t w = fwrite(out.data, 1, out.len, f); + int wok = (w == out.len); + if (fclose(f) != 0) wok = 0; + size_t total = out.len; + sb_free(&out); + if (!wok) { + *is_error = 1; + return err_result("写入 %s 失败", path); + } + /* 回执里必须带上行号:模型据此确认"改的就是我以为的那一处"。省略 line 是 + * 全文件替换,回执也得直说 —— 否则它会以为只改了一处。 */ + if (global) + 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 字节)", + path, req, total); +} + +/* ------------------------------------------------------------------ */ +/* list_dir */ +/* ------------------------------------------------------------------ */ + +static int cmp_names(const void *a, const void *b) +{ + return strcmp(*(const char *const *)a, *(const char *const *)b); +} + +static char *tool_list_dir(const JsonValue *args, int *is_error) +{ + REQUIRE_ERR_PTR(is_error); + const char *path = arg_str(args, "path"); + if (!path || !*path) path = "."; + + DIR *d = opendir(path); + if (!d) { *is_error = 1; return err_result("无法打开目录 %s:%s", path, strerror(errno)); } + + char **list = NULL; + size_t count = 0, cap = 0; + + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue; + if (count == cap) { + cap = cap ? cap * 2 : 64; + list = xrealloc(list, cap * sizeof(char *)); + } + list[count++] = xstrdup(de->d_name); + } + closedir(d); + + /* 空目录时 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); + for (size_t i = 0; i < count; i++) { + char full[PATH_MAX]; + snprintf(full, sizeof full, "%s/%s", path, list[i]); + struct stat st; + if (lstat(full, &st) == 0) { + if (S_ISDIR(st.st_mode)) + sb_printf(&out, " %s/\n", list[i]); + else if (S_ISLNK(st.st_mode)) + sb_printf(&out, " %s -> (符号链接)\n", list[i]); + else + sb_printf(&out, " %s (%lld 字节)\n", list[i], (long long)st.st_size); + } else { + sb_printf(&out, " %s\n", list[i]); + } + free(list[i]); + } + free(list); + return sb_detach(&out); +} + +/* read_image:把本地图片交给模型看。 + * + * 协议里图片只有两条路:外部 URL,或者 base64 内联。ligent 不替用户去网上抓图, + * 所以只收本地文件、产出 base64 内联。 + * + * 图片字节不进历史:这里只把图片存成本地备份,历史里挂一个对备份的引用, + * 真正构造请求时才读出来编码(见 image.c 的 image_expand_for_request)。 + * + * 尺寸在**本地**就判掉。理由是图片会留在历史里:一次超限不是一次失败, + * 而是从此每一轮都超限。与其发出去等一个 400,不如当场告诉模型"太大了", + * 并且**不备份** —— 一张永远发不出去的图,留着备份没有意义。 + * + * 工具结果只能是字符串(协议要求),所以图片挂在"待发附件"里,由 + * agent_run 补一条 user 消息带上去 —— 图片只能出现在 user 消息中。 */ +static char *tool_read_image(const JsonValue *args, int *is_error) +{ + REQUIRE_ERR_PTR(is_error); + const char *path = arg_str(args, "path"); + if (!path || !*path) { *is_error = 1; return xstrdup("错误:缺少必填参数 path"); } + + size_t len = 0; + const char *mime = NULL; + char *why = NULL; + char *backup = image_backup_from_path(path, &len, &mime, &why); + if (!backup) { + *is_error = 1; + return why ? why : xstrdup("读不了这张图片"); + } + + image_pending_add(backup); + + char *msg = xasprintf("已读入图片 %s(%s,%zu 字节,base64 内联后约 %.1f MiB)。" + "对话历史里只有对它的引用,不发图片数据;" + "本机备份 %s,发请求时展开成 base64。", + path, mime, len, + (double)base64_encoded_len(len) / 1048576.0, backup); + free(backup); + return msg; +} + +/* ------------------------------------------------------------------ */ +/* 工具表:所有工具的**唯一真相** */ +/* ------------------------------------------------------------------ */ +/* + * 命名约定(改名前先读这里;README §5 有同一份,两处必须同步) + * + * 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。同一个概念在两个工具里必须同名。 + * + * 这张表同时生成四样东西:请求里的 schema(tools_schema)、执行分发 + * (tool_execute)、要不要走确认(tool_mutating)、/tools 的输出 + * (tools_print_list)。所以**改名只需要改 .name 一列** —— 以前这四处各有 + * 一份 strcmp 链,改名必定漏一处,而且漏了不报错(只是静默地少认一个名字)。 + * + * 两条约束写在这里,因为从表里看不出来: + * - needs_vision == 1 的工具会在"探测到模型不收图片"时被摘掉 + * (agent.c 的 tools_for_request 会调 tools_drop)。 + * + * 顺序 = 请求里工具表的顺序,也是 /tools 的打印顺序。改顺序不影响行为, + * 但会让 tests/fixtures 里录制的请求对不上,所以顺序变动要连着 fixtures 一起改。 + * + * 为什么**没有**搜索内容的工具(曾经有过 search_files,后来改名 grep,最后删了): + * 模型用 run_command 里的真 grep 就行 —— `grep -rn` 支持正则、文件名过滤、 + * 上下文行、大小写、多模式,我们这个包装每一样都要自己重写一遍,还得跟着 + * 需求慢慢长(第一个版本就是"搜内容"却叫 search_files,让人以为按文件名搜)。 + * 少一个非必需的工具,工具表更短、模型的误用面更小。 + */ + +typedef struct { + const char *name; + const char *type; /* JSON-Schema 类型:string / integer / boolean; + * 数组写成 "元素类型[]"(如 "integer[]"), + * 交给 tools_schema 展开成 {type:array, items:{...}} */ + const char *desc; + int required; +} ToolParam; + +/* 参数表以它结尾 —— 比单独维护一个"参数个数"少一处可以写错的地方 */ +#define PARAM_END { NULL, NULL, NULL, 0 } + +typedef struct { + const char *name; + const char *desc; /* 给模型看的完整说明 */ + 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); +} ToolDef; + +static const ToolParam P_RUN_COMMAND[] = { + { "command", "string", + "要执行的 shell 命令(由 /bin/sh -c 执行,支持管道与重定向)", 1 }, + { "elevated", "boolean", + "置 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 }, + PARAM_END +}; + +static const ToolParam P_WRITE_FILE[] = { + { "path", "string", + "文件路径;父目录不存在会自动创建。**已经存在的二进制文件会被拒绝覆盖**", 1 }, + { "content", "string", "要写入的完整文件内容", 1 }, + PARAM_END +}; + +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", "string", + "被替换的原文,必须与文件内容逐字符一致(含缩进),且**在 line 圈住的行里**" + "只有一处匹配(匹配可以跨行,起点落在集合里即可)", 1 }, + { "new_string", "string", "替换后的新文本", 1 }, + { "replace_all", "boolean", + "为 true 时替换**全文件**里所有匹配(line 仍必须圈住其中真实的一处)," + "默认 false(只替换 line 圈住的那一处)", 0 }, + PARAM_END +}; + +static const ToolParam P_LIST_DIR[] = { + { "path", "string", "目录路径,默认当前目录", 0 }, + PARAM_END +}; + +static const ToolParam P_READ_IMAGE[] = { + { "path", "string", + "本地图片路径。支持 PNG / JPEG / GIF / WebP(按文件内容判断,不看扩展名)", 1 }, + PARAM_END +}; + +static const ToolDef TOOLS[] = { + { + TOOL_RUN_COMMAND, + "在工作目录中执行 shell 命令,返回合并后的 stdout/stderr 与退出码。" + "适合编译、运行测试、git 操作等。", + "tools.ui.run_command", + P_RUN_COMMAND, 1, 0, tool_run_command + }, + { + TOOL_READ_FILE, + "读取文本文件内容,返回带行号的文本;行号可以直接喂给 edit_file 的 line。" + "大文件请用 offset/limit 分段读取。" + "**二进制文件会被拒绝**(它只处理文本)。", + "tools.ui.read_file", + P_READ_FILE, 0, 0, tool_read_file + }, + { + TOOL_WRITE_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。", + "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 去读二进制。" + "图片超过内联体积上限时会被直接拒绝,这时请让用户先缩小或压缩。", + "tools.ui.read_image", + P_READ_IMAGE, 0, 1, tool_read_image + }, +}; + +#define TOOL_COUNT (sizeof TOOLS / sizeof TOOLS[0]) + +/* 按名字找定义,找不到返回 NULL。 + * + * **故意不保留旧名别名**:schema 里只发新名,dispatch 再认旧名只会掩盖 + * "调用方没跟着改"这件事(旧存档、用户自写的提示词、测试夹具都一样)。 + * 名字即协议,认不出来的就明确报"未知工具",比静默兼容更容易发现漏改。 */ +static const ToolDef *find_tool(const char *name) +{ + if (!name || !*name) return NULL; + for (size_t i = 0; i < TOOL_COUNT; i++) + if (strcmp(TOOLS[i].name, name) == 0) return &TOOLS[i]; + return NULL; +} + +/* 从表生成 chat/completions 的 tools 数组(调用者释放) */ +JsonValue *tools_schema(void) +{ + JsonValue *tools = json_new_array(); + + for (size_t i = 0; i < TOOL_COUNT; i++) { + const ToolDef *d = &TOOLS[i]; + + JsonValue *t = json_new_object(); + json_object_set_string(t, "type", "function"); + + JsonValue *fn = json_new_object(); + json_object_set_string(fn, "name", d->name); + json_object_set_string(fn, "description", d->desc); + + JsonValue *params = json_new_object(); + json_object_set_string(params, "type", "object"); + JsonValue *props = json_new_object(); + JsonValue *req = json_new_array(); + for (const ToolParam *p = d->params; p && p->name; p++) { + JsonValue *one = json_new_object(); + /* "整数[]" 这种写法表示数组:展开成 {"type":"array","items":{"type":"整数"}}。 + * 后缀只认结尾的 "[]",元素类型照抄前面那段。 */ + const char *br = strstr(p->type, "[]"); + if (br && br[2] == '\0') { + char elem[16]; + snprintf(elem, sizeof elem, "%.*s", (int)(br - p->type), p->type); + JsonValue *items = json_new_object(); + json_object_set_string(items, "type", elem); + json_object_set_string(one, "type", "array"); + json_object_set(one, "items", items); + } else { + json_object_set_string(one, "type", p->type); + } + json_object_set_string(one, "description", p->desc); + json_object_set(props, p->name, one); + if (p->required) json_array_push(req, json_new_string(p->name)); + } + json_object_set(params, "properties", props); + json_object_set(params, "required", req); + json_object_set(fn, "parameters", params); + + json_object_set(t, "function", fn); + json_array_push(tools, t); + } + return tools; +} + +void tools_drop(JsonValue *tools, const char *name) +{ + if (!tools || tools->type != JSON_ARRAY || !name) return; + for (size_t i = 0; i < json_array_size(tools); i++) { + JsonValue *fn = json_object_get(json_array_get(tools, i), "function"); + const char *n = fn ? json_get_string(fn, "name") : NULL; + if (n && strcmp(n, name) == 0) { + json_array_remove(tools, i); + return; + } + } +} + +/* hide_image:探测到模型不收图片时为 1 —— /tools 不该列一个已经藏起来的工具, + * 否则用户看到的是谎话。列表本身从工具表生成,所以它和真正发出去的工具表 + * 天然一致(以前这里是手写的一串 puts,就漏过一个工具)。 */ +void tools_print_list(int hide_image) +{ + puts(t("tools.ui.header")); + for (size_t i = 0; i < TOOL_COUNT; i++) { + const ToolDef *d = &TOOLS[i]; + if (hide_image && d->needs_vision) { + printf("%s\n", tf("tools.ui.hidden", "name", d->name, NULL)); + continue; + } + /* 文案在 i18n 表里(键就在上面那一列):/tools 是纯给人看的东西, + * 而表里的 desc 是发给模型的,两者本来就该分开 */ + printf(" %-18s%s\n", d->name, t(d->ui_key)); + } +} + +int tool_mutating(const char *name) +{ + const ToolDef *d = find_tool(name); + return d ? d->mutating : 0; +} + +/* 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"); + int is_arr = (lv && lv->type == JSON_ARRAY); + size_t n = is_arr ? json_array_size(lv) : (lv ? 1 : 0); + size_t off = 0, wrote = 0; + out[0] = '\0'; + for (size_t i = 0; i < n; i++) { + JsonValue *e = is_arr ? json_array_get(lv, i) : lv; + if (e->type != JSON_NUMBER) continue; + if (wrote >= 8) { snprintf(out + off, cap - off, "…"); break; } + int w = snprintf(out + off, cap - off, "%s%ld", + wrote ? ", " : "", (long)e->u.number); + if (w < 0 || (size_t)w >= cap - off) break; + off += (size_t)w; + wrote++; + } +} + +/* ---- edit_file 的新旧对照(带行号)---- + * + * 行号取自 line 参数:替换就发生在那里,所以两侧的起始行号相同 —— 新内容落在 + * 旧内容原来的位置上。line 省略(全文件替换)时没有确定的起点,就**不标**行号: + * 编一个出来比不标更糟。 + */ +#define DIFF_MAX_LINES 8 +#define DIFF_MAX_COLS 200 +#define DIFF_RED "\033[31m" +#define DIFF_GREEN "\033[32m" +#define DIFF_DIM "\033[2m" +#define DIFF_OFF "\033[0m" + +/* 起始行号:line 是集合型,取最小的那个;没给 line 则返回 0(= 不标行号) */ +static long diff_base_line(const JsonValue *args) +{ + JsonValue *lv = json_object_get(args, "line"); + if (!lv) return 0; + if (lv->type == JSON_NUMBER) return (long)lv->u.number; + if (lv->type != JSON_ARRAY) return 0; + long base = 0; + for (size_t i = 0; i < json_array_size(lv); i++) { + JsonValue *e = json_array_get(lv, i); + if (!e || e->type != JSON_NUMBER) continue; + long v = (long)e->u.number; + if (base == 0 || v < base) base = v; + } + return base; +} + +static void diff_side(FILE *out, const char *prefix, const char *sign, + const char *color, int use_color, const char *text, long base) +{ + int total = 0, shown = 0; + long lineno = base; + for (const char *p = text; ; ) { + const char *nl = strchr(p, '\n'); + size_t n = nl ? (size_t)(nl - p) : strlen(p); + total++; + if (shown < DIFF_MAX_LINES) { + size_t w = n > DIFF_MAX_COLS ? DIFF_MAX_COLS : n; + if (base > 0) + fprintf(out, "%s%s%s%s %4ld %.*s%s\n", prefix, + use_color ? color : "", sign, use_color ? DIFF_OFF : "", + lineno, (int)w, p, n > w ? "…" : ""); + else + fprintf(out, "%s%s%s%s %.*s%s\n", prefix, + use_color ? color : "", sign, use_color ? DIFF_OFF : "", + (int)w, p, n > w ? "…" : ""); + shown++; + } + lineno++; + if (!nl || !nl[1]) break; /* 末尾那个换行不算多一行 */ + p = nl + 1; + } + if (total > shown) + fprintf(out, "%s%s%s%s\n", prefix, use_color ? DIFF_DIM : "", + tf("tool.diff.more", "n", i18n_num(total - shown), NULL), + use_color ? DIFF_OFF : ""); +} + +int tool_render_diff(FILE *out, const JsonValue *args, + const char *prefix, int use_color) +{ + if (!out || !args) return 0; + const char *old_s = json_string_value(json_object_get(args, "old_string")); + const char *new_s = json_string_value(json_object_get(args, "new_string")); + if (!old_s || !new_s) return 0; + + long base = diff_base_line(args); + diff_side(out, prefix, "-", DIFF_RED, use_color, old_s, base); + diff_side(out, prefix, "+", DIFF_GREEN, use_color, new_s, base); + return 1; +} + +char *tool_execute(const char *name, const JsonValue *args, int *is_error) +{ + if (is_error) *is_error = 0; + if (!name) return xstrdup("错误:工具名为空"); + if (!args || args->type != JSON_OBJECT) { + if (is_error) *is_error = 1; + return xstrdup("错误:工具参数必须是 JSON 对象"); + } + + const ToolDef *d = find_tool(name); + if (!d) { + if (is_error) *is_error = 1; + return err_result("未知工具 \"%s\"", name); + } + /* fn == NULL 是一道守卫:表里目前没有这种工具,但留着这条检查 —— + * 将来若加一个"由上层执行"的工具,模型瞎编参数时也能得到明确回答, + * 而不是静默什么都不干。 */ + if (!d->fn) { + if (is_error) *is_error = 1; + return err_result("工具 \"%s\" 不能在这里直接执行(它只在主对话里可用," + "由 agent.c 处理)", name); + } + return d->fn(args, is_error); +} diff --git a/src/tools.h b/src/tools.h new file mode 100644 index 0000000..c59b805 --- /dev/null +++ b/src/tools.h @@ -0,0 +1,74 @@ +/* tools.h - 提供给模型的工具(function calling) */ +#ifndef LIGENT_TOOLS_H +#define LIGENT_TOOLS_H + +#include "json.h" + +#include /* FILE:tool_render_diff 要往 stdout / stderr 写 */ + +/* ---- 工具名 ---- + * + * 名字是协议的一部分:它进 schema、进执行分发、进 /tools 的输出。所以**名字 + * 只在这里定义一次**,别处一律用宏 —— 改名时漏一处不会编译报错,只会静默地 + * 少认一个工具。 + * + * 命名约定(动宾结构、动词白名单、副作用可读、长度上限……)见 tools.c 顶部的 + * 工具表注释,用户向的说明在 README §5,三处必须同步。 */ +#define TOOL_RUN_COMMAND "run_command" +#define TOOL_READ_FILE "read_file" +#define TOOL_WRITE_FILE "write_file" +#define TOOL_EDIT_FILE "edit_file" +#define TOOL_LIST_DIR "list_dir" +#define TOOL_READ_IMAGE "read_image" + +/* 返回 tools 数组,用于 chat/completions 请求(调用者释放)。 + * 内容由 tools.c 的工具表生成,不在这里维护。 */ +JsonValue *tools_schema(void); + +/* 从工具表里摘掉一个工具(按 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 多条噪声会把真正的问题淹掉)。 */ +#define REQUIRE_ERR_PTR(p) do { assert((p) != NULL); } while (0) + +/* 执行一个工具调用。返回 malloc 的结果文本;*is_error 置 1 表示失败 */ +char *tool_execute(const char *name, const JsonValue *args, int *is_error); + +/* 该工具是否会改动磁盘或执行命令。 + * 只读工具(read_file / list_dir)返回 0。 + * 这份"哪些工具会写"的知识属于工具层,确认策略由上层决定。 */ +int tool_mutating(const char *name); + +/* 工具的简短列表,用于 /tools 命令 */ +void tools_print_list(int hide_image); + +/* 把 edit_file 的 line 参数(整数或整数数组)渲染成 "1122, 1123"(超过 8 个就 + * 省略尾部)。一个合法行号都没有(也就是没给 line)时留空串,调用方据此走 + * "全文件"那条文案。确认提示与调用摘要共用这一处。 */ +void tool_lines_label(const JsonValue *args, char *out, size_t cap); + +/* 把 edit_file 的旧/新文本渲染成**带行号的 diff**("- 旧 / + 新"),写进 out。 + * + * 行号取自 line 参数 —— 替换就发生在那里,两侧起始行号相同(新内容落在原来的 + * 位置上);line 省略(全文件替换)时没有确定的起始行号,就不标 —— 编一个比 + * 不标更糟。每侧最多 8 行,其余折成一句"还有 N 行"。 + * + * 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 能处理的文件大小上限。它要把整个文件读进内存 */ +#define LIGENT_MAX_EDIT_FILE (32u * 1024 * 1024) + +#endif /* LIGENT_TOOLS_H */ diff --git a/src/util.c b/src/util.c new file mode 100644 index 0000000..151dbe7 --- /dev/null +++ b/src/util.c @@ -0,0 +1,457 @@ +/* util.c - 基础工具实现 */ +#include "util.h" +#include "i18n.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +/* ------------------------------------------------------------------ */ +/* 内存 */ +/* ------------------------------------------------------------------ */ + +static void oom(void) +{ + fputs(t("util.oom"), stderr); + exit(1); +} + +void *xmalloc(size_t n) +{ + void *p = malloc(n ? n : 1); + if (!p) oom(); + return p; +} + +void *xcalloc(size_t n, size_t size) +{ + void *p = calloc(n ? n : 1, size ? size : 1); + if (!p) oom(); + return p; +} + +void *xrealloc(void *p, size_t n) +{ + void *q = realloc(p, n ? n : 1); + if (!q) oom(); + return q; +} + +char *xstrdup(const char *s) +{ + if (!s) return NULL; + size_t n = strlen(s); + char *p = xmalloc(n + 1); + memcpy(p, s, n + 1); + return p; +} + +char *xstrndup(const char *s, size_t n) +{ + size_t len = 0; + while (len < n && s[len]) len++; + char *p = xmalloc(len + 1); + memcpy(p, s, len); + p[len] = '\0'; + return p; +} + +char *xvasprintf(const char *fmt, va_list ap) +{ + if (!fmt) fmt = ""; + va_list ap2; + va_copy(ap2, ap); + int n = vsnprintf(NULL, 0, fmt, ap); + if (n < 0) { va_end(ap2); return xstrdup(""); } + char *buf = xmalloc((size_t)n + 1); + vsnprintf(buf, (size_t)n + 1, fmt, ap2); + va_end(ap2); + return buf; +} + +char *xasprintf(const char *fmt, ...) +{ + va_list ap; + va_start(ap, fmt); + char *s = xvasprintf(fmt, ap); + va_end(ap); + return s; +} + +/* ------------------------------------------------------------------ */ +/* StrBuf */ +/* ------------------------------------------------------------------ */ + +void sb_init(StrBuf *sb) +{ + sb->data = NULL; + sb->len = 0; + sb->cap = 0; +} + +void sb_free(StrBuf *sb) +{ + if (!sb) return; + free(sb->data); + sb->data = NULL; + sb->len = sb->cap = 0; +} + +void sb_clear(StrBuf *sb) +{ + sb->len = 0; + if (sb->data) sb->data[0] = '\0'; +} + +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 失败即退出), + * 不变量坏掉也只会退化成一次多余的重分配。 */ + 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; + sb->data = xrealloc(sb->data, cap); + sb->cap = cap; +} + +void sb_append(StrBuf *sb, const char *data, size_t len) +{ + if (!data || len == 0) { + sb_reserve(sb, 0); + if (sb->data) sb->data[sb->len] = '\0'; + return; + } + sb_reserve(sb, len); + memcpy(sb->data + sb->len, data, len); + sb->len += len; + sb->data[sb->len] = '\0'; +} + +void sb_puts(StrBuf *sb, const char *s) +{ + if (s) sb_append(sb, s, strlen(s)); +} + +void sb_putc(StrBuf *sb, char c) +{ + sb_reserve(sb, 1); + sb->data[sb->len++] = c; + sb->data[sb->len] = '\0'; +} + +void sb_printf(StrBuf *sb, const char *fmt, ...) +{ + va_list ap; + va_start(ap, fmt); + char *s = xvasprintf(fmt, ap); + va_end(ap); + sb_puts(sb, s); + free(s); +} + +char *sb_detach(StrBuf *sb) +{ + if (!sb->data) { + char *p = xmalloc(1); + p[0] = '\0'; + return p; + } + char *p = sb->data; + sb->data = NULL; + sb->len = sb->cap = 0; + return p; +} + +/* ------------------------------------------------------------------ */ +/* 字符串工具 */ +/* ------------------------------------------------------------------ */ + +int str_starts_with(const char *s, const char *prefix) +{ + if (!s || !prefix) return 0; + return strncmp(s, prefix, strlen(prefix)) == 0; +} + +int str_ends_with(const char *s, const char *suffix) +{ + if (!s || !suffix) return 0; + size_t ls = strlen(s), lf = strlen(suffix); + if (lf > ls) return 0; + return memcmp(s + ls - lf, suffix, lf) == 0; +} + +char *str_trim(char *s) +{ + if (!s) return s; + char *p = s; + while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') p++; + if (p != s) memmove(s, p, strlen(p) + 1); + size_t n = strlen(s); + while (n > 0) { + char c = s[n - 1]; + if (c == ' ' || c == '\t' || c == '\r' || c == '\n') s[--n] = '\0'; + else break; + } + return s; +} + +char *read_entire_file(const char *path, size_t *out_len) +{ + FILE *f = fopen(path, "rb"); + if (!f) return NULL; + StrBuf sb; + sb_init(&sb); + char buf[8192]; + size_t n; + while ((n = fread(buf, 1, sizeof buf, f)) > 0) + sb_append(&sb, buf, n); + /* fread 返回 0 既可能是 EOF,也可能是读错误。分不清的话就会把**截断的 + * 内容**当成功返回 —— 对图片尤其糟:会拿半张图去备份、去上传。 */ + int bad = ferror(f); + fclose(f); + if (bad) { sb_free(&sb); return NULL; } + if (out_len) *out_len = sb.len; + return sb_detach(&sb); +} + +int mkdir_p(const char *path) +{ + if (!path || !*path) return -1; + char *tmp = xstrdup(path); + size_t len = strlen(tmp); + /* 去掉结尾的 '/' */ + while (len > 1 && tmp[len - 1] == '/') tmp[--len] = '\0'; + + for (char *p = tmp + 1; *p; p++) { + if (*p == '/') { + *p = '\0'; + if (mkdir(tmp, 0777) != 0 && errno != EEXIST) { + free(tmp); + return -1; + } + *p = '/'; + } + } + int rc = 0; + if (mkdir(tmp, 0777) != 0 && errno != EEXIST) rc = -1; + free(tmp); + return rc; +} + +unsigned utf8_decode(const char *s, size_t len, size_t *adv) +{ + const unsigned char *p = (const unsigned char *)s; + unsigned char c = p[0]; + if (c < 0x80) { *adv = 1; return c; } + if ((c & 0xE0) == 0xC0 && len >= 2) { + *adv = 2; + return ((unsigned)(c & 0x1F) << 6) | (p[1] & 0x3F); + } + if ((c & 0xF0) == 0xE0 && len >= 3) { + *adv = 3; + return ((unsigned)(c & 0x0F) << 12) | ((unsigned)(p[1] & 0x3F) << 6) | + (p[2] & 0x3F); + } + if ((c & 0xF8) == 0xF0 && len >= 4) { + *adv = 4; + return ((unsigned)(c & 0x07) << 18) | ((unsigned)(p[1] & 0x3F) << 12) | + ((unsigned)(p[2] & 0x3F) << 6) | (p[3] & 0x3F); + } + *adv = 1; + return c; +} + +size_t utf8_seq_len(const char *s, size_t len) +{ + if (len == 0) return 0; + const unsigned char *p = (const unsigned char *)s; + unsigned char c = p[0]; + if (c < 0x80) return 1; + + size_t n; + unsigned cp; + 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 不可能是首字节 */ + + 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。 */ + if (n == 2 && cp < 0x80) return 0; + if (n == 3 && cp < 0x800) return 0; + if (n == 4 && cp < 0x10000) return 0; + if (cp >= 0xD800 && cp <= 0xDFFF) return 0; + if (cp > 0x10FFFF) return 0; + return n; +} + +/* 近似 wcwidth:东亚宽字符返回 2,覆盖常用 CJK 区间 */ +int cp_width(unsigned cp) +{ + if (cp < 32) return 0; + if (cp < 0x1100) return 1; + if ((cp >= 0x1100 && cp <= 0x115F) || /* 谚文字母 */ + (cp >= 0x2E80 && cp <= 0x303E) || /* CJK 部首、康熙部首、CJK 标点 */ + (cp >= 0x3041 && cp <= 0x33FF) || /* 假名、注音、谚文兼容、CJK 兼容 */ + (cp >= 0x3400 && cp <= 0x4DBF) || /* CJK 扩展 A */ + (cp >= 0x4E00 && cp <= 0x9FFF) || /* CJK 基本区 */ + (cp >= 0xA000 && cp <= 0xA4CF) || /* 彝文 */ + (cp >= 0xAC00 && cp <= 0xD7A3) || /* 谚文音节 */ + (cp >= 0xF900 && cp <= 0xFAFF) || /* CJK 兼容表意文字 */ + (cp >= 0xFE30 && cp <= 0xFE4F) || /* CJK 兼容形式 */ + (cp >= 0xFF00 && cp <= 0xFF60) || /* 全角字符 */ + (cp >= 0xFFE0 && cp <= 0xFFE6) || + (cp >= 0x20000 && cp <= 0x3FFFD)) /* CJK 扩展 B 及以上 */ + return 2; + return 1; +} + +size_t display_width(const char *s, size_t n) +{ + size_t w = 0, i = 0; + while (i < n) { + size_t adv = 1; + unsigned cp = utf8_decode(s + i, n - i, &adv); + w += (size_t)cp_width(cp); + i += adv; + } + return w; +} + + +size_t utf8_next(const char *s, size_t n, size_t i) +{ + if (i >= n) return n; + size_t adv = 1; + utf8_decode(s + i, n - i, &adv); + return i + adv > n ? n : i + adv; +} + +size_t utf8_prev(const char *s, size_t n, size_t i) +{ + if (i == 0 || i > n) return 0; + size_t j = i - 1; + while (j > 0 && ((unsigned char)s[j] & 0xC0) == 0x80) j--; + return j; +} + +char *expand_env(const char *src) +{ + if (!src) return NULL; + StrBuf out; + sb_init(&out); + const char *p = src; + while (*p) { + if (p[0] == '$' && p[1] == '{') { + const char *end = strchr(p + 2, '}'); + if (end) { + char *name = xstrndup(p + 2, (size_t)(end - (p + 2))); + const char *val = getenv(name); + if (val) sb_puts(&out, val); + free(name); + p = end + 1; + continue; + } + } + sb_putc(&out, *p++); + } + return sb_detach(&out); +} + +/* ------------------------------------------------------------------ */ +/* 文本 / 二进制的判据 */ +/* ------------------------------------------------------------------ */ + +long buf_nul_at(const char *data, size_t n) +{ + const char *p = memchr(data, '\0', n); + return p ? (long)(p - data) : -1; +} + +int looks_binary(const char *data, size_t n, long *at) +{ + if (!data) return 0; + if (n > LIGENT_BIN_PROBE) n = LIGENT_BIN_PROBE; + long off = buf_nul_at(data, n); + if (off >= 0) { if (at) *at = off; return 1; } + return 0; +} + +/* ------------------------------------------------------------------ */ +/* 随机名 */ +/* ------------------------------------------------------------------ */ + +void random_alnum(char *out, size_t n) +{ + static const char alpha[] = "abcdefghijklmnopqrstuvwxyz0123456789"; + unsigned char buf[64]; + size_t got = 0; + + if (n > sizeof buf) n = sizeof buf; + FILE *f = fopen("/dev/urandom", "rb"); + if (f) { + got = fread(buf, 1, n, f); + fclose(f); + } + if (got < n) { + /* 没有 urandom(或读短了):时间 + pid 播种,够避免撞名即可 */ + unsigned seed = (unsigned)time(NULL) ^ ((unsigned)getpid() << 16); + for (size_t i = got; i < n; i++) { + seed = seed * 1103515245u + 12345u; + buf[i] = (unsigned char)(seed >> 16); + } + } + for (size_t i = 0; i < n; i++) out[i] = alpha[buf[i] % (sizeof alpha - 1)]; + out[n] = '\0'; +} + +/* ------------------------------------------------------------------ */ +/* base64 */ +/* ------------------------------------------------------------------ */ + +size_t base64_encoded_len(size_t n) +{ + /* 每 3 字节出 4 个字符,不足的补 '='。先算长度再决定要不要编码: + * 一张 24 MiB 的图编码后是 32 MiB,正好卡在内联上限上。 */ + return ((n + 2) / 3) * 4; +} + +char *base64_encode(const unsigned char *data, size_t n) +{ + static const char tbl[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + if (!data && n > 0) return NULL; + + size_t out_len = base64_encoded_len(n); + char *out = xmalloc(out_len + 1); + + size_t o = 0; + for (size_t i = 0; i < n; i += 3) { + size_t rem = n - i; /* 1..3 */ + unsigned v = (unsigned)data[i] << 16; + if (rem > 1) v |= (unsigned)data[i + 1] << 8; + if (rem > 2) v |= (unsigned)data[i + 2]; + + out[o++] = tbl[(v >> 18) & 0x3F]; + out[o++] = tbl[(v >> 12) & 0x3F]; + out[o++] = rem > 1 ? tbl[(v >> 6) & 0x3F] : '='; + out[o++] = rem > 2 ? tbl[v & 0x3F] : '='; + } + out[o] = '\0'; + return out; +} diff --git a/src/util.h b/src/util.h new file mode 100644 index 0000000..aa78278 --- /dev/null +++ b/src/util.h @@ -0,0 +1,111 @@ +/* util.h - 基础工具:内存分配、动态字符串缓冲 */ +#ifndef LIGENT_UTIL_H +#define LIGENT_UTIL_H + +#include +#include + +/* 让我们自己的 printf 风格函数也受编译器检查。 + * + * 之前全项目 __attribute__ 出现 0 次 —— 于是 xasprintf / sb_printf / + * 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))) +#else +# 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); +char *xstrdup(const char *s); +char *xstrndup(const char *s, size_t n); +char *xasprintf(const char *fmt, ...) LIGENT_PRINTF(1, 2); +char *xvasprintf(const char *fmt, va_list ap) LIGENT_PRINTF(1, 0); + +/* ---- 动态字符串缓冲 ---- */ +typedef struct { + char *data; + size_t len; + size_t cap; +} StrBuf; + +void sb_init(StrBuf *sb); +void sb_free(StrBuf *sb); +void sb_clear(StrBuf *sb); +void sb_reserve(StrBuf *sb, size_t extra); +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 重置为空 */ +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 */ +char *read_entire_file(const char *path, size_t *out_len); +/* 递归创建目录(类似 mkdir -p);成功返回 0 */ +int mkdir_p(const char *path); +/* 把 src 中所有 ${VAR} 展开为环境变量(未定义则展开为空串) */ +char *expand_env(const char *src); + +/* ---- 文本 / 二进制的判据(唯一定义处) ---- + * + * 判据只有一条:**出现 NUL 字节**。文本文件里不该有它,而 UTF-16、压缩包、 + * 可执行文件、稀疏文件这类都会中招。 + * + * 探测窗口是 8 KB —— 读类工具没必要为了拒绝一个 10 GB 的文件把它整个读一遍。 + * 判据和窗口必须由所有"处理文本"的地方共用:以前 tools.c 和 attach.c 各写了 + * 一份,窗口还不一样(一个看前 8 KB、一个看全文件),于是同一个文件会出现 + * "/include 说它是二进制、read_file 说它能读"这种自相矛盾的说法。 + * + * 唯一的例外是 edit_file 的**全文件**检查:它在内存里按 C 字符串处理, + * 任何位置的 NUL 都会让结果从那里被截断,所以它不能只看开头。 */ +#define LIGENT_BIN_PROBE (8 * 1024) + +/* 返回第一个 NUL 的偏移;没有则返回 -1 */ +long buf_nul_at(const char *data, size_t n); +/* 这段字节看起来是二进制吗?*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 播种的伪随机兜底 —— 它只用来避免文件名撞车,不是密钥。 + * 存档名与图片备份名共用这一处(以前各写了一份,连退化种子都各写一遍)。 */ +void random_alnum(char *out, size_t n); + +/* ---- base64(内联图片用) ---- */ + +/* 编码后的长度,不实际编码 —— 用来"先算尺寸,再决定值不值得编码" */ +size_t base64_encoded_len(size_t n); +/* 标准 base64 编码(RFC 4648,带 = 补位,不换行),返回 malloc 的字符串 */ +char *base64_encode(const unsigned char *data, size_t n); + +/* ---- UTF-8 与终端显示宽度 ---- */ + +/* 解码一个码点,*adv 为消耗的字节数(输入非法时退化为 1 字节)。 + * 注意它是**宽松**的:不校验续字节格式、过长编码、代理区,只按首字节定长度。 + * 想判断"这段字节是不是合法 UTF-8"要用下面的 utf8_seq_len。 */ +unsigned utf8_decode(const char *s, size_t len, size_t *adv); +/* s 处是否是一个**合法** UTF-8 序列:合法返回序列长度(1-4),非法/被截断返回 0。 + * 比 utf8_decode 严格 —— 它另外拒绝:续字节格式不对、过长编码、UTF-16 代理区 + * (U+D800..U+DFFF)、以及超出 U+10FFFF 的编码。这些在 JSON 里同样是非法的, + * 所以序列化前必须按这个判据过一遍。 */ +size_t utf8_seq_len(const char *s, size_t len); +/* 近似 wcwidth:东亚宽字符返回 2,控制字符返回 0 */ +int cp_width(unsigned cp); +/* 从 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); +/* 整段文本占用的终端列数 */ +size_t display_width(const char *s, size_t n); + +#endif /* LIGENT_UTIL_H */ diff --git a/src/version.c b/src/version.c new file mode 100644 index 0000000..17a804a --- /dev/null +++ b/src/version.c @@ -0,0 +1,202 @@ +/* version.c - 版本号、编译器、平台的拼接与呈现 */ +#include "version.h" + +#include +#include + +/* 长度账(最坏情况都要放得下,否则 gcc 会报 -Wformat-truncation, + * 而那个警告是对的 —— 截断的版本串比报错更糟): + * 主体 2026.09.22-abcdef7.dirty 25 + * 分支 LIGENT_BRANCH_MAX 40(见下,截断) + * 时间 2026-09-22 04:32:50 19 + * 编译器 GCC 16.2.1 20260810 20 + * 平台 linux/x86_64 14 + * 字面 " () [] on " 和空格 12 + * --- + * 130 + * + * 下面这些尺寸是**按组成部分算出来的**,不是拍的。上一版我直接写 320 然后靠 + * 人肉估计"应该够",gcc 立刻用 -Wformat-truncation 指出它证明不了够 —— + * 因为 where[] 和 buf_short[] 的尺寸加起来就已经超过 320 了。让编译器能自己 + * 算出"装得下",比加大数字再赌一把可靠。 */ +#define LIGENT_BRANCH_MAX 40 +/* 编译器串和平台串的缓冲尺寸(在 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 */ +#define LIGENT_BRANCH_BUF (LIGENT_BRANCH_MAX * 4 + 8) +/* where = branch + ", " + date(10) + " " + time(8) */ +#define LIGENT_WHERE_BUF (LIGENT_BRANCH_BUF + 32) +/* full = 主体 + " (" + where + ") [" + 编译器 + "] on " + 平台 */ +#define LIGENT_FULL_BUF (LIGENT_VERBODY_MAX + LIGENT_WHERE_BUF + \ + LIGENT_COMPILER_MAX + LIGENT_PLATFORM_MAX + 16) + +static char buf_short[LIGENT_VERBODY_MAX]; +static char buf_full[LIGENT_FULL_BUF]; + +/* ------------------------------------------------------------------ */ +/* 编译器标识 */ +/* ------------------------------------------------------------------ */ +/* + * 顺序有讲究:clang 也定义 __GNUC__(它假装自己是 gcc 4.2),所以 clang 必须 + * 排在 GCC 前面判断。tcc 不定义 __GNUC__,放哪都行,但放在一起更清楚。 + * + * tcc 只给一个整数 __TINYC__(0.9.28 -> 928,也就是去掉小数点当十进制读)。 + * 拼出来是 "tcc 0.9.28",比原样输出一个 928 有用。 + */ +static const char *compiler_name(void) +{ +#if defined(__clang__) + return "Clang"; +#elif defined(__TINYC__) + return "tcc"; +#elif defined(__GNUC__) + return "GCC"; +#else + return "unknown"; +#endif +} + +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 更可控。 */ + 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。 + * 所以主版本要 /1000 而不是 /100: + * 928 / 1000 = 0 <- 对 + * 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); + return buf; +#elif defined(__GNUC__) + return __VERSION__; /* gcc: "16.2.1 20260810" */ +#else + return "unknown"; +#endif +} + +/* ------------------------------------------------------------------ */ +/* 目标平台 */ +/* ------------------------------------------------------------------ */ +static const char *os_name(void) +{ +#if defined(__linux__) + return "linux"; +#elif defined(__CYGWIN__) + return "cygwin"; +#elif defined(_WIN32) + return "windows"; +#elif defined(__APPLE__) + return "darwin"; +#elif defined(__FreeBSD__) + return "freebsd"; +#elif defined(__OpenBSD__) + return "openbsd"; +#elif defined(__NetBSD__) + return "netbsd"; +#else + return "unknown"; +#endif +} + +static const char *arch_name(void) +{ +#if defined(__x86_64__) || defined(_M_X64) + return "x86_64"; +#elif defined(__i386__) || defined(_M_IX86) + return "i386"; +#elif defined(__aarch64__) || defined(_M_ARM64) + return "aarch64"; +#elif defined(__arm__) + return "arm"; +#elif defined(__loongarch__) + return "loongarch64"; +#elif defined(__riscv) && (__riscv_xlen == 64) + return "riscv64"; +#else + return "unknown"; +#endif +} + +const char *ligent_compiler_string(void) +{ + static char buf[LIGENT_COMPILER_MAX]; + snprintf(buf, sizeof buf, "%s %s", compiler_name(), compiler_version()); + return buf; +} + +const char *ligent_platform_string(void) +{ + static char buf[LIGENT_PLATFORM_MAX]; + snprintf(buf, sizeof buf, "%s/%s", os_name(), arch_name()); + return buf; +} + +/* ------------------------------------------------------------------ */ +/* 版本号 */ +/* ------------------------------------------------------------------ */ + + +const char *ligent_version_string(void) +{ + /* 格式:-[.dirty] + * hash 为 "unknown" 时直接省掉它 */ + if (strcmp(LIGENT_GIT_HASH, "unknown") == 0) + snprintf(buf_short, sizeof buf_short, "%s", LIGENT_VERSION); + else + snprintf(buf_short, sizeof buf_short, "%s-%s", LIGENT_VERSION, + LIGENT_GIT_HASH); + return buf_short; +} + +/* 截断分支名以免分支名超限溢出 */ +static void branch_display(char *out, size_t n) +{ + size_t len = strlen(LIGENT_GIT_BRANCH); + if (len <= LIGENT_BRANCH_MAX) { + snprintf(out, n, "%s", LIGENT_GIT_BRANCH); + return; + } + snprintf(out, n, "%.*s...", LIGENT_BRANCH_MAX, LIGENT_GIT_BRANCH); +} + +const char *ligent_version_full(void) +{ + const char *v = ligent_version_string(); + + /* 括号里:分支 + 构建时间。这两个是"这份二进制从哪来的",排查时都要。 + * 分支在 detached HEAD(CI 上常见)时是 unknown,那也照实说。 */ + char branch[LIGENT_BRANCH_BUF]; + branch_display(branch, sizeof branch); + + char where[LIGENT_WHERE_BUF]; + if (strcmp(LIGENT_BUILD_TIME, "unknown") == 0) + snprintf(where, sizeof where, "%s, %s", branch, LIGENT_BUILD_DATE); + else + snprintf(where, sizeof where, "%s, %s %s", branch, + LIGENT_BUILD_DATE, LIGENT_BUILD_TIME); + + /* 整体: + * ligent 2026.09.22-209538e (master, 2026-09-22 04:32:04) + * [GCC 16.2.1 20260810] on linux/x86_64 */ + snprintf(buf_full, sizeof buf_full, "%s (%s) [%s] on %s", + v, where, ligent_compiler_string(), ligent_platform_string()); + return buf_full; +} diff --git a/src/version.h b/src/version.h new file mode 100644 index 0000000..ef9cd64 --- /dev/null +++ b/src/version.h @@ -0,0 +1,79 @@ +/* version.h - 版本号与构建信息 + * + * 版本号由**构建系统注入**,不写死在源码里: + * + * 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) + * + * 编译器与目标平台**不走注入**,在 version.c 里用编译期宏判断(见下): + * 那两项是"这份代码被什么编出来、给谁跑的",编译器自己最清楚,让构建脚本去猜 + * 反而多一个出错点。 + * + * 为什么不让源码当"唯一真相":那样每发一次版就要改一次源码、多一个提交,而且 + * 从 tarball 还是从 git 编出来的版本号会不一致。让构建期算出来、编进去,源码 + * 只留一个**回退默认值**(给"没有构建系统"的场景,比如有人直接拿 gcc 编 + * src 下的所有 .c),这是 dunst/curl 这类项目的通行做法。 + * + * 为什么是 CalVer 而不是 SemVer:ligent 是个终端工具,不是被别人链接的库。 + * CalVer 表达的是"这是哪天的东西",对一个还在快速演进的工具比"第几个大版本" + * 有用得多 —— 用户看到 2026.02.14 立刻知道新旧,看到 1.4.2 还得去查。真到了 + * 需要表达"破坏性变更"的时候,git 历史和 README 比版本号数字更能说清楚。 + */ +#ifndef LIGENT_VERSION_H +#define LIGENT_VERSION_H + +/* 回退值:只在没有通过 -D 注入时生效(直接拿 gcc 编、或从没有 .git 的 + * tarball 编)。改这个值等于"手动打了一个版本",正常发布流程不该动它。 */ +#ifndef LIGENT_VERSION +# define LIGENT_VERSION "0.0.0-unknown" +#endif + +#ifndef LIGENT_GIT_HASH +# define LIGENT_GIT_HASH "unknown" +#endif + +#ifndef LIGENT_GIT_BRANCH +# define LIGENT_GIT_BRANCH "unknown" +#endif + +#ifndef LIGENT_BUILD_DATE +# define LIGENT_BUILD_DATE "unknown" +#endif + +#ifndef LIGENT_BUILD_TIME +# define LIGENT_BUILD_TIME "unknown" +#endif + +/* ------------------------------------------------------------------ */ +/* 编译器与平台:用编译期宏判断,不靠构建脚本传 */ +/* ------------------------------------------------------------------ */ +/* + * 为什么要区分这么多分支:__VERSION__ 是 gcc/clang 的约定,**tcc 没有这个宏** + * (它只有 __TINYC__ 加一个数字),而 tcc 是这个项目明确支持的编译器之一 + * (make CC=tcc 是过全套测试的)。所以 tcc 那条腿得单独拼。 + * + * 平台同理用 __linux__ / __APPLE__ 这些宏,**不用 uname()**:宏描述的是 + * "这份二进制是给哪个平台编的",uname() 描述的是"现在跑在哪" —— 静态产物 + * 会被拷到别的机器上跑,交叉编译时两者也不一致。版本串里该记的是前者。 + */ + +/* 编译器标识,例如 "GCC 16.2.1 20260810" / "Clang 22.1.8" / "tcc 0.9.28" */ +const char *ligent_compiler_string(void); + +/* 目标平台,例如 "linux/x86_64" */ +const char *ligent_platform_string(void); + +/* 版本号主体:-,例如 2026.09.22-209538e + * hash 未知时只有 CalVer。 */ +const char *ligent_version_string(void); + +/* 完整一行,给 --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 new file mode 100755 index 0000000..0549eb1 --- /dev/null +++ b/tests/confirm_check.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""confirm_check.py - 确认提示上的每一种回答都必须被正确处理 + +重点是 Ctrl-C。 + +`confirm_tool` 用 fgets 读输入,而 SIGINT 会让 fgets 以 EINTR 失败、返回 +NULL;代码把 NULL 当成"没人能回答,按默认值放行"。于是**在确认提示 +上按 Ctrl-C 等于批准这次操作**。run_command 侥幸自己查了中断标志才没执行, +write_file / edit_file 没查,文件照写。 + +这个脚本把三种回答都跑一遍,直接看磁盘上有没有留下东西: + Ctrl-C -> 必须拒绝 + n -> 必须拒绝 + y -> 必须执行(否则就是"修过头"了) + +用法: + confirm_check.py +""" +import os +import pty +import select +import sys +import time + +# 探针文件放在 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), +# 写死整串会在改提示文案时把测试弄坏。 +MARK = "继续?" +PROMPT_READY = b"\x1b[?2004h" + + +def run_case(binary, base, answer, wait_confirm=6.0): + """跑一轮:触发 write_file → 等确认提示出现 → 回答 → 看文件在不在。""" + try: + os.unlink(PROBE) + except OSError: + pass + + os.environ["LIGENT_API_KEY"] = "sk-test" + pid, fd = pty.fork() + if pid == 0: + os.execv(binary, [binary, "--base-url", base, "--no-color", + "--confirm", "--no-auto-save", "--no-history"]) + os._exit(127) + + out = bytearray() + + def pump(seconds, idle=0.3): + """读到"安静"或超时为止""" + end = time.time() + seconds + last = time.time() + while time.time() < end: + r, _, _ = select.select([fd], [], [], 0.02) + if r: + try: + d = os.read(fd, 65536) + except OSError: + return False + if not d: + return False + out.extend(d) + last = time.time() + elif time.time() - last > idle: + return True + return True + + def wait_for(text, seconds): + """一直读到 text 出现(或超时)。按标记等,不靠 sleep 猜。""" + end = time.time() + seconds + while time.time() < end: + if text.encode("utf-8") in bytes(out): + return True + r, _, _ = select.select([fd], [], [], 0.05) + if r: + try: + d = os.read(fd, 65536) + except OSError: + return False + if not d: + return False + out.extend(d) + return text.encode("utf-8") in bytes(out) + + pump(1.2) # 横幅 + 第一个提示符 + os.write(fd, "WRITE_TEST 写个文件\n".encode()) + got_prompt = wait_for(MARK, wait_confirm) + if got_prompt: + os.write(fd, answer) + pump(1.5) + os.write(fd, b"\x03") + pump(0.3) + try: + os.close(fd) + except OSError: + pass + try: + os.waitpid(pid, 0) + except OSError: + pass + return got_prompt, os.path.exists(PROBE) + + +def run_turn_scope(binary, base): + """Always / neVer 的作用域必须是"这一轮",不是整个会话。 + + 一轮里让模型连写两个文件:第一次问、答 Always,第二次不该再问。 + 然后开新的一轮(下一条用户消息),必须**重新问** —— 会话级的开关是 + /yolo,不是这个。 + + 全程按标记同步("继续?"出现 / 回到提示符),不靠 sleep 猜时间。 + """ + for p in (PROBE1, PROBE2): + try: + os.unlink(p) + except OSError: + pass + + os.environ["LIGENT_API_KEY"] = "sk-test" + pid, fd = pty.fork() + if pid == 0: + os.execv(binary, [binary, "--base-url", base, "--no-color", + "--confirm", "--no-auto-save", "--no-history"]) + os._exit(127) + + out = bytearray() + state = {"ready": 0, "tail": b""} + READY = b"\x1b[?2004h" # 行编辑器每次回到提示符都会发它 + + def note(chunk): + buf = state["tail"] + chunk + state["ready"] += buf.count(READY) + state["tail"] = buf[-(len(READY) - 1):] + + def pump(seconds, idle=0.3): + end = time.time() + seconds + last = time.time() + while time.time() < end: + r, _, _ = select.select([fd], [], [], 0.02) + if r: + try: + d = os.read(fd, 65536) + except OSError: + return + if not d: + return + out.extend(d) + note(d) + last = time.time() + elif time.time() - last > idle: + return + + def wait_for(pred, seconds): + end = time.time() + seconds + while time.time() < end: + if pred(): + return True + r, _, _ = select.select([fd], [], [], 0.03) + if r: + try: + d = os.read(fd, 65536) + except OSError: + return False + if not d: + return False + out.extend(d) + note(d) + return pred() + + def has_prompt(): + return "继续?".encode("utf-8") in bytes(out) + + def prompt_count(): + return bytes(out).count("继续?".encode("utf-8")) + + pump(1.2) + + # ---- 第一轮:连写两个 ---- + base_ready = state["ready"] + 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()) + got_again = wait_for(lambda: prompt_count() > before, 8.0) + second_prompts = prompt_count() - before + os.write(fd, b"N\n") + pump(1.0) + + os.write(fd, b"\x03") + pump(0.3) + try: + os.close(fd) + except OSError: + pass + try: + os.waitpid(pid, 0) + except OSError: + pass + + for p in (PROBE1, PROBE2): + try: + os.unlink(p) + except OSError: + pass + return first_turn_prompts, both_written, got_again, second_prompts + + +def main(): + if len(sys.argv) < 3: + print("用法: confirm_check.py <二进制> ") + return 2 + binary, base = sys.argv[1], sys.argv[2] + os.makedirs(os.path.dirname(PROBE), exist_ok=True) + + fails = 0 + checks = 0 + + def check(name, ok, detail=""): + nonlocal fails, checks + checks += 1 + if ok: + print(" ✓ %s" % name) + else: + fails += 1 + print(" ✗ %s%s" % (name, (" " + detail) if detail else "")) + + # 三种回答:(说明, 发送的字节, 期望文件是否被写) + cases = [ + ("在确认提示上按 Ctrl-C", b"\x03", False), + ("答 n", b"n\n", False), + ("答 y", b"y\n", True), + ] + + for label, answer, expect_written in cases: + got, written = run_case(binary, base, answer) + check("%s:等到了确认提示" % label, got, "没等到确认提示") + check("%s:文件%s" % (label, "被写入" if expect_written else "没被写入"), + written == expect_written, + "实际 %s" % ("写了" if written else "没写")) + + # 作用域:Always 只活这一轮 + n1, both, again, n2 = run_turn_scope(binary, base) + check("Always:同一轮里只问一次", n1 == 1, "实际问了 %d 次" % n1) + check("Always:同轮的第二次改动被放行", both, "第二个文件没写") + check("新一轮会重新询问(作用域仅限本轮)", again and n2 >= 1, + "新一轮没有询问") + + for p in (PROBE, PROBE1, PROBE2): + try: + os.unlink(p) + except OSError: + pass + # 这里**不要** rmtree("build/tmp"):那是 e2e 各段共用的临时目录,删掉它 + # 会连带清掉别人放进去的东西(我就因此丢过两次日志),而它自己的三个文件 + # 上面已经删干净了。测试辅助脚本只该收拾自己造的东西。 + + print("\n%d 项检查,%d 项失败" % (checks, fails)) + return 1 if fails else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/fixtures/error.log b/tests/fixtures/error.log new file mode 100644 index 0000000..daa86c8 --- /dev/null +++ b/tests/fixtures/error.log @@ -0,0 +1,20 @@ +# 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} +----- 响应头 ----- +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 new file mode 100644 index 0000000..535acce --- /dev/null +++ b/tests/fixtures/session.log @@ -0,0 +1,76 @@ +# 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} +----- 响应头 ----- +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}]} + +data: {"choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "function": {"arguments": "{\"command\": \"echo"}}]}, "finish_reason": null}]} + +data: {"choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "function": {"arguments": " hello-from-tool\"}"}}]}, "finish_reason": null}]} + +data: {"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}]} + +data: [DONE] + + +----- 结束: HTTP 200 ----- + +===== 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} +----- 响应头 ----- +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}]} + +data: {"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}} + +data: [DONE] + + +----- 结束: HTTP 200 ----- + +===== 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} +----- 响应头 ----- +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}]} + +data: {"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}} + +data: [DONE] + + +----- 结束: HTTP 200 ----- + + + + + diff --git a/tests/fuzz.c b/tests/fuzz.c new file mode 100644 index 0000000..31188c4 --- /dev/null +++ b/tests/fuzz.c @@ -0,0 +1,345 @@ +/* fuzz.c - 对抗性模糊测试:JSON 解析器 + Markdown 渲染器 + * + * 这两个模块是"解析不可信输入"的地方(服务端响应、模型输出), + * 也是最可能出崩溃/越界/泄漏的地方。所以用随机输入去砸它们。 + * + * 检查的不变量: + * JSON + * 1. 任何输入都不能崩溃、不能泄漏(靠 ASan/UBSan/LeakSanitizer) + * 2. 解析成功 => dump 再 parse 再 dump,两次 dump 必须一致(幂等) + * 3. 随机生成的合法 JSON 必须解析成功并完整往返 + * Markdown + * 4. 任何输入都不能崩溃、不能泄漏 + * 5. 分片不变性:一次性喂 与 任意分片喂 的输出必须逐字节一致 + * 6. 不含任何元字符的输入必须原样输出 + * + * 用法: fuzz [迭代次数] [随机种子] + */ +#include "json.h" +#include "markdown.h" +#include "util.h" + +#include +#include +#include +#include + +static uint64_t RNG; + +static uint32_t rnd(void) +{ + RNG ^= RNG << 13; + RNG ^= RNG >> 7; + RNG ^= RNG << 17; + return (uint32_t)(RNG >> 32); +} + +static uint32_t rnd_below(uint32_t n) { return n ? rnd() % n : 0; } + +/* ------------------------------------------------------------------ */ +/* JSON */ +/* ------------------------------------------------------------------ */ + +static const char *STR_POOL[] = { + "", "a", "hello", "中文", "😀", "line\nbreak", "tab\there", "quote\"x", + "back\\slash", "ctrl\x01\x1f", "surrogate\u00e9", "aaaaaaaaaaaaaaaaaaaa", + "/", "\x7f", "\xc3\xa9\xe4\xb8\xad", +}; + +static void gen_json_string(StrBuf *sb) +{ + const char *s = STR_POOL[rnd_below(sizeof STR_POOL / sizeof STR_POOL[0])]; + char *q = json_quote_string(s); + sb_puts(sb, q); + free(q); +} + +/* 递归生成合法 JSON */ +static void gen_json(StrBuf *sb, int depth) +{ + uint32_t kind = rnd_below(depth > 3 ? 4 : 6); + switch (kind) { + case 0: sb_puts(sb, "null"); break; + case 1: sb_puts(sb, rnd() & 1 ? "true" : "false"); break; + case 2: { + char buf[64]; + int mode = (int)rnd_below(3); + if (mode == 0) snprintf(buf, sizeof buf, "%d", (int)rnd() - 1000); + else if (mode == 1) snprintf(buf, sizeof buf, "%.6g", (double)rnd() / 7.0); + else snprintf(buf, sizeof buf, "%de%d", (int)rnd_below(1000), (int)rnd_below(20) - 10); + sb_puts(sb, buf); + break; + } + case 3: gen_json_string(sb); break; + case 4: { + sb_putc(sb, '['); + uint32_t n = rnd_below(5); + for (uint32_t i = 0; i < n; i++) { + if (i) sb_putc(sb, ','); + gen_json(sb, depth + 1); + } + sb_putc(sb, ']'); + break; + } + default: { + sb_putc(sb, '{'); + uint32_t n = rnd_below(5); + for (uint32_t i = 0; i < n; i++) { + if (i) sb_putc(sb, ','); + gen_json_string(sb); + sb_putc(sb, ':'); + gen_json(sb, depth + 1); + } + sb_putc(sb, '}'); + break; + } + } +} + +/* 返回 1 表示这一轮失败 */ +static int fuzz_json_once(char *err, size_t errlen) +{ + StrBuf sb; + sb_init(&sb); + + int mutate = (int)rnd_below(2); + if (mutate) { + /* 变异模式:先造合法 JSON,再随机破坏 */ + gen_json(&sb, 0); + size_t n = sb.len; + if (n == 0) { sb_free(&sb); return 0; } + /* 每次突变都必须基于"当前"长度。早期版本用了突变前记下的旧长度, + * 截断之后再按下标写就会覆盖掉结尾的 '\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) */ + sb.data[rnd_below((uint32_t)sb.len)] = (char)(rnd() & 0xFF); + } else if (op == 1) { /* 截断 */ + sb.len = rnd_below((uint32_t)sb.len); + } else { /* 插入一个字节 */ + if (sb.len < 4096) { + size_t pos = rnd_below((uint32_t)sb.len + 1); + sb_reserve(&sb, 1); + memmove(sb.data + pos + 1, sb.data + pos, sb.len - pos + 1); + sb.data[pos] = (char)"{}[]\",:0123456789.eE+-truefalsn"[rnd_below(30)]; + sb.len++; + } + } + } + /* 无论怎么突变,结尾符必须是好的 */ + 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 元字符,提高触发率 */ + static const char meta[] = "{}[]\",:0123456789.eE+-truefalsn \t\n\\/u"; + char c = (rnd() & 3) ? meta[rnd_below(sizeof meta - 1)] + : (char)(rnd() & 0xFF); + sb_putc(&sb, c); + } + } + + const char *input = sb.data ? sb.data : ""; + size_t inlen = sb.len; + + /* 输入里不能有 NUL,否则不是合法的 C 字符串测试对象 */ + for (size_t i = 0; i < inlen; i++) { + if (input[i] == '\0') { + sb_free(&sb); + return 0; + } + } + + /* 交给解析器的必须是真正 NUL 结尾的缓冲区 */ + if (strlen(input) != inlen) { + snprintf(err, errlen, "测试器自身出错:缓冲区未正确结尾"); + sb_free(&sb); + return 1; + } + + JsonValue *v = json_parse(input, err, errlen); + if (v) { + char *d1 = json_dump(v); + char *d2 = NULL; + JsonValue *v2 = json_parse(d1, err, errlen); + if (!v2) { + snprintf(err, errlen, "解析成功的 JSON 却无法重新解析:%.120s", d1); + free(d1); + json_free(v); + sb_free(&sb); + return 1; + } + d2 = json_dump(v2); + if (strcmp(d1, d2) != 0) { + snprintf(err, errlen, "dump 不幂等:\n 输入 %.200s\n 一次 %.200s\n 二次 %.200s", + input, d1, d2); + free(d1); free(d2); + json_free(v); json_free(v2); + sb_free(&sb); + return 1; + } + free(d1); free(d2); + json_free(v2); + json_free(v); + } + sb_free(&sb); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* Markdown */ +/* ------------------------------------------------------------------ */ + +#define MD_RENDER_LIMIT (1u << 20) + +/* 渲染到内存;chunk==0 表示一次性 */ +static char *md_render(const char *src, size_t len, size_t chunk) +{ + char *buf = NULL; + size_t size = 0; + FILE *f = open_memstream(&buf, &size); + if (!f) return NULL; + + MdRenderer *r = md_new(1); + md_set_out(r, f); + if (chunk == 0) { + md_feed(r, src); + } else { + for (size_t i = 0; i < len; i += chunk) { + size_t n = len - i < chunk ? len - i : chunk; + char *tmp = xstrndup(src + i, n); + md_feed(r, tmp); + free(tmp); + } + } + md_finish(r); + md_free(r); + fclose(f); + return buf; +} + +static int markdown_metachar(int c) +{ + /* 这些字符会触发渲染器改写输出;其余字符必须原样通过 */ + return c == '*' || c == '_' || c == '`' || c == '[' || c == ']' || + c == '#' || c == '>' || c == '-' || c == '+' || c == '|' || + c == '\n' || c == '\r'; +} + +static int fuzz_md_once(char *err, size_t errlen) +{ + StrBuf sb; + sb_init(&sb); + + static const char alpha[] = + "#*_-+>`[]|\\ \tabc123.:/~\n\r\u4e2d\U0001f600\"'()=;,\xc3\xa9"; + size_t alen = sizeof alpha - 1; + + uint32_t n = rnd_below(400); + int pure = (int)rnd_below(4) == 0; /* 1/4 概率生成"无元字符"的干净文本 */ + for (uint32_t i = 0; i < n; i++) { + char c; + if (pure) { + do { + c = alpha[rnd_below((uint32_t)alen)]; + } while (markdown_metachar((unsigned char)c) || c == '\0'); + } else { + uint32_t mode = rnd_below(10); + if (mode < 7) c = alpha[rnd_below((uint32_t)alen)]; + else if (mode < 9) c = (char)(0x20 + rnd_below(0x5F)); + else c = (char)(rnd() & 0xFF); /* 偶尔来点非法 UTF-8 */ + } + if (c == '\0') c = 'x'; + sb_putc(&sb, c); + } + + const char *src = sb.data ? sb.data : ""; + size_t len = sb.len; + if (len == 0) { sb_free(&sb); return 0; } + + char *whole = md_render(src, len, 0); + if (!whole) { sb_free(&sb); return 0; } + if (strlen(whole) > MD_RENDER_LIMIT * 4) { + snprintf(err, errlen, "输出异常膨胀:输入 %zu 字节 => %zu 字节", + len, strlen(whole)); + free(whole); sb_free(&sb); + return 1; + } + + /* 分片不变性 */ + size_t chunks[4]; + for (int k = 0; k < 4; k++) chunks[k] = 1 + rnd_below(8); + for (int k = 0; k < 4; k++) { + char *part = md_render(src, len, chunks[k]); + if (!part) continue; + if (strcmp(whole, part) != 0) { + snprintf(err, errlen, + "分片不变性被破坏(%zu 字节/片)", + chunks[k]); + /* 落盘,方便用 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); + return 1; + } + free(part); + } + + /* 无元字符 => 必须逐字节原样 */ + if (pure) { + if (strcmp(whole, src) != 0) { + snprintf(err, errlen, "纯净文本被改写了:\n 输入 %.300s\n 输出 %.300s", + src, whole); + free(whole); sb_free(&sb); + return 1; + } + } + + free(whole); + sb_free(&sb); + return 0; +} + +/* ------------------------------------------------------------------ */ + +int main(int argc, char **argv) +{ + long iters = (argc > 1) ? strtol(argv[1], NULL, 10) : 3000; + RNG = (argc > 2) ? strtoull(argv[2], NULL, 10) : 0x9E3779B97F4A7C15ULL; + if (!RNG) RNG = 1; + + char err[512]; + long json_fail = 0, md_fail = 0; + + printf("模糊测试:JSON %ld 轮 + Markdown %ld 轮(种子 %llu)\n", + iters, iters, (unsigned long long)RNG); + + for (long i = 0; i < iters; i++) { + err[0] = '\0'; + if (fuzz_json_once(err, sizeof err)) { + printf(" ✗ JSON 第 %ld 轮失败:%s\n", i, err); + if (++json_fail >= 3) break; + } + } + printf(" JSON %ld 轮,%ld 次失败\n", iters, json_fail); + + for (long i = 0; i < iters; i++) { + err[0] = '\0'; + if (fuzz_md_once(err, sizeof err)) { + printf(" ✗ Markdown 第 %ld 轮失败:%s\n", i, err); + if (++md_fail >= 3) break; + } + } + 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"); + return 0; +} diff --git a/tests/mock_server.py b/tests/mock_server.py new file mode 100644 index 0000000..574630a --- /dev/null +++ b/tests/mock_server.py @@ -0,0 +1,1008 @@ +#!/usr/bin/env python3 +"""mock_server.py - 最小的 OpenAI 兼容服务端,用于端到端测试 ligent。 + +不依赖任何第三方包。行为完全由请求内容决定,保持确定性: + + - 用户消息含 ERROR_TEST -> 返回 HTTP 400 + error 对象 + - 尚未有 tool 结果 -> 流式返回一个 tool_call(run_command echo ...) + - 用户消息含 twice 且只执行过一次工具 -> 再返回第二个 tool_call + - 用户消息含 MANYSTEPS_TEST -> 连要 4 次工具往返才收工 + - 其余 -> 流式/非流式返回最终文本,内容里回显最近一次工具输出 +""" +import json +import os +import sys +import time +from http.server import BaseHTTPRequestHandler, HTTPServer + + +STATE = {} # 按消息文本隔离的重试计数 + + +def sse(wfile, obj): + wfile.write(("data: " + json.dumps(obj, ensure_ascii=False) + "\n\n").encode("utf-8")) + wfile.flush() + + +def pairing_error(msgs): + """服务端的硬要求:带 tool_calls 的 assistant 后面必须**紧跟**与每个 + tool_call_id 一一对应的 tool 消息,中间不能夹别的东西。 + + 真实服务端就是这么回 400 的,mock 也照做 —— 这样任何会写坏历史的回归 + (比如把图片附件插在两条 tool 消息中间、或者中断时丢下没回应的调用) + 都会在 e2e 里当场炸出来,而不是等到用户真拿它去连服务端才发现。 + 返回错误文案,没问题返回 None。""" + i, n = 0, len(msgs) + while i < n: + m = msgs[i] + if m.get("role") == "assistant" and m.get("tool_calls"): + want = [tc.get("id") for tc in m["tool_calls"]] + got, j = [], i + 1 + while j < n and msgs[j].get("role") == "tool": + got.append(msgs[j].get("tool_call_id")) + j += 1 + for w in want: + if w not in got: + return ("An assistant message with 'tool_calls' must be " + "followed by tool messages responding to each " + "'tool_call_id'. (insufficient tool messages " + "following tool_calls message)") + i += 1 + return None + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.0" + + def log_message(self, *args): + pass + + # -- 工具调用(流式增量) -- + def emit_tool_call(self, index, call_id, name, arguments): + # 故意把 arguments 切成多片,检验客户端的增量拼接 + half = len(arguments) // 2 + sse(self.wfile, {"choices": [{"index": 0, "delta": { + "role": "assistant", "content": ""}, "finish_reason": None}]}) + sse(self.wfile, {"choices": [{"index": 0, "delta": {"tool_calls": [ + {"index": index, "id": call_id, "type": "function", + "function": {"name": name, "arguments": ""}}]}, "finish_reason": None}]}) + for part in (arguments[:half], arguments[half:]): + if not part: + continue + sse(self.wfile, {"choices": [{"index": 0, "delta": {"tool_calls": [ + {"index": index, "function": {"arguments": part}}]}, + "finish_reason": None}]}) + sse(self.wfile, {"choices": [{"index": 0, "delta": {}, + "finish_reason": "tool_calls"}]}) + + def emit_text(self, text): + for piece in (text[: len(text) // 2], text[len(text) // 2:]): + if not piece: + continue + sse(self.wfile, {"choices": [{"index": 0, "delta": {"content": piece}, + "finish_reason": None}]}) + sse(self.wfile, {"choices": [{"index": 0, "delta": {}, + "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, + "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") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def send_text(self, text, stream, model): + """按流式/非流式把一段最终文本发回去。""" + if not stream: + payload = json.dumps({"choices": [{"index": 0, + "message": {"role": "assistant", + "content": text}, + "finish_reason": "stop"}], + "model": model}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.emit_text(text) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + + def do_GET(self): + # 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") + self.send_response(200 if ok else 401) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", "2") + self.end_headers() + self.wfile.write(b"{}") + return + + # /model 不带参数时拉模型列表。 + # 故意混进一个重复项和一个缺 id 的条目 —— 客户端要能去重、跳过坏条目。 + # MODEL_LIST=bare 时改回裸数组,MODEL_LIST=empty 时不返回任何模型, + # 两种兼容/异常形状都由 e2e 覆盖。 + if "/models" in self.path: + # 模式从**文件**读,不从环境变量读:mock 是脚本开头起好的, + # 给客户端进程设的 env 它根本看不见(第一版就是这么假通过的)。 + mode = "" + mode_file = os.environ.get("MODELS_MODE_FILE", "") + if mode_file: + try: + with open(mode_file, encoding="utf-8") as f: + mode = f.read().strip() + except OSError: + mode = "" + if mode == "empty": + payload = json.dumps({"object": "list", "data": []}).encode() + elif mode == "bare": + payload = json.dumps(["alpha-model", "beta-model"]).encode() + elif mode == "garbage": + payload = b"not json at all" + else: + # 默认列表里**故意包含当前模型**(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"}, + {"id": "beta-model", "object": "model", "owned_by": "mock"}, + {"id": "alpha-model", "object": "model", "owned_by": "mock"}, + {"object": "model", "owned_by": "mock"}, + ]}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return + + if self.path.startswith("/redirect"): + self.send_response(307) + self.send_header("Location", "https://example.invalid/v1/chat/completions") + self.send_header("Content-Length", "0") + self.end_headers() + return + body = b"{}" + self.send_response(404) # 端点根路径本来就没有东西 + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self): + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length) + + # ---- UTF-8 严格校验:真服务端就是这么拒的 ---- + # 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 什么都没说明,而且换个实现就可能漏掉,所以显式判。) + try: + raw.decode("utf-8") + except UnicodeDecodeError as exc: + self.reject(400, "Failed to parse the request body as JSON: " + "invalid unicode code point at byte %d" % exc.start) + return + + # ---- 假的 Files API ---- + # 只做够用的部分:认 multipart 里有没有图片内容,发一个递增的 file_id。 + try: + body = json.loads(raw or b"{}") + except Exception: + self.send_error(400) + return + + messages = body.get("messages", []) + model = body.get("model", "?") + stream = bool(body.get("stream")) + + # 把 user 消息拍平成纯文本再匹配。带附件的消息 content 是**块数组** + # (/include、read_image 的附件都是),不拍平的话 first_user 会是个 + # list,`"标记" in list` 永远不成立 —— 分支会莫名其妙地匹配不上。 + def user_text(m): + c = m.get("content") + if isinstance(c, str): + return c + if isinstance(c, list): + return " ".join(p.get("text", "") for p in c + if isinstance(p, dict) and p.get("type") == "text") + return "" + + users = [user_text(m) for m in messages if m.get("role") == "user"] + first_user = users[0] if users else "" + last_user = users[-1] if users else "" + tool_msgs = [m for m in messages if m.get("role") == "tool"] + tools_sent = body.get("tools") + + # ---- 结构校验:和真服务端一样严 ---- + perr = pairing_error(messages) + if perr: + self.reject(400, perr) + return + + # ---- 能力探测序列 ---- + # ligent 会在第一次真实请求之前发几个试探请求(基线 / 图片 / tools), + # 它们会被真实处理,所以这里必须排在所有标记分支之前。 + # 辨认靠形状:刚好一条 user 消息,内容是 "1",或者带一张 1x1 的图。 + def looks_like_probe(msgs): + if len(msgs) != 1 or msgs[0].get("role") != "user": + return False + c = msgs[0].get("content") + if c == "1": + return True + if isinstance(c, list): + for p in c: + if not isinstance(p, dict) or p.get("type") != "image_url": + continue + u = (p.get("image_url") or {}).get("url", "") + if "iVBORw0KGgo" in u: + return True + return False + + if looks_like_probe(messages): + has_image = isinstance(messages[0].get("content"), list) + # 用模型名模拟"不支持某能力"的服务端:400 是明确的拒绝信号 + if "no-vision" in model and has_image: + return self.reject(400, "unsupported content type: image_url") + if "no-tools" in model and tools_sent: + return self.reject(400, "unsupported parameter: tools") + if "broken-probe" in model: + return self.reject(401, "invalid api key") + self.send_text("1", stream, model) + return + + # 提权:让模型发一个带 elevated 的 run_command + if "ELEVATED_TEST" in first_user: + if not tool_msgs: + args = json.dumps({"command": "echo 提权后的输出", "elevated": True}) + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.emit_tool_call(0, "call_elev", "run_command", args) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + return + got = "" + for m in messages: + if m.get("role") == "tool": + got = m.get("content") or "" + self.send_text("ELEV_SAW[%s]" % got.replace("\n", " | "), stream, model) + return + + # /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 是纯字符串,那也算"一个文本块" + if isinstance(c, str): + texts += 1 + continue + if not isinstance(c, list): + continue + for p in c: + if not isinstance(p, dict): + continue + if p.get("type") == "text": + texts += 1 + t = p.get("text", "") + for line in t.split("\n"): + if line.startswith("===== "): + 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 [])] + self.send_text("TOOLS[%s]" % ",".join(n for n in names if n), + stream, model) + return + + # 权限/参数校验:客户端必须把 tools 传上来 + assert tools_sent, "请求里没有 tools 字段" + + if "ERROR_TEST" in first_user: + payload = json.dumps({"error": {"message": "mock 模拟的服务端错误", + "type": "invalid_request_error"}}).encode() + self.send_response(400) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return + + # 消息条数超过阈值就报"上下文超限",用于验证客户端的自动裁剪。 + # 客户端丢掉最老的一轮后条数会降下来,于是下一轮就能成功。 + if "TOOLONG_TEST" in first_user: + if len(messages) > 3: + payload = json.dumps({"error": { + "message": "This model's maximum context length is 8192 tokens. " + "However, your messages resulted in 9000 tokens. " + "Please reduce the length of the messages.", + "code": "context_length_exceeded"}}).encode() + self.send_response(400) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return + text = "当前历史 %d 条消息,正常回复" % len(messages) + payload = json.dumps({"choices": [{"index": 0, + "message": {"role": "assistant", + "content": text}, + "finish_reason": "stop"}]}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return + + # 把收到的用户消息原样回显,用于验证"一次请求里到底装了什么" + if "ECHO_TEST" in first_user: + text = "ECHO[" + first_user.replace("\n", "\\n") + "]" + payload = json.dumps({"choices": [{"index": 0, + "message": {"role": "assistant", + "content": text}, + "finish_reason": "stop"}]}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return + + # 复刻 OpenRouter 对"key 格式不对"的回应:头其实带了, + # 但文案说的是 "Missing Authentication header",容易把人引偏 + if "AUTHFAIL_TEST" in first_user: + payload = json.dumps({"error": {"message": "Missing Authentication header", + "code": 401}}).encode() + self.send_response(401) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return + + # 重定向:用于验证客户端不静默跟随(跨源跟随会丢掉 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") + self.send_header("Content-Length", "0") + self.end_headers() + return + + # 前几次返回 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() + self.send_response(503) + self.send_header("Content-Type", "application/json") + self.send_header("Retry-After", "1") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return + text = "重试后成功(共尝试 %d 次)" % STATE["retry"] + payload = json.dumps({"choices": [{"index": 0, + "message": {"role": "assistant", + "content": text}, + "finish_reason": "stop"}]}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return + + # 永远 503,用于验证重试次数耗尽后正确报错 + if "ALWAYS503_TEST" in first_user: + payload = json.dumps({"error": {"message": "mock 持续不可用"}}).encode() + self.send_response(503) + self.send_header("Content-Type", "application/json") + self.send_header("Retry-After", "1") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return + + # 超过客户端响应体上限的超大响应:客户端应当主动中止而不是被撑爆 + if "HUGE_TEST" in first_user: + total = 40 * 1024 * 1024 + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(total)) + self.end_headers() + chunk = b'{"pad":"' + b'x' * (1024 * 1024 - 12) + b'"}' + try: + for _ in range(40): + self.wfile.write(chunk) + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError, OSError): + pass # 客户端主动断开是预期行为 + return + + # 畸形 SSE:各种不合法的片段,客户端必须不崩溃且给出合理结果 + if "GARBAGE_TEST" in first_user: + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.end_headers() + lines = [ + "data: {\n\n", + "data: not json at all\n\n", + "data: [DONE]\n\n", + 'data: {"choices":[]}\n\n', + 'data: {"choices":[{"delta":{"content":"ok"}}]}\n\n', + "\xe6\xb3\xa8\xe9\x87\x8a\xe8\xa1\x8c\n\n", # 非 ASCII 裸行 + "data:\n\n", + "\x01\x02\xff\xfe\n\n", + ] + for line in lines: + self.wfile.write(line.encode("utf-8", "surrogateescape")) + self.wfile.flush() + return + + # 传输中途断掉:声明了 Content-Length 却提前关闭连接 + if "TRUNCATE_TEST" in first_user: + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Content-Length", "100000") + self.end_headers() + self.wfile.write(b"data: {\"choices\":[{\"delta\":{\"content\":\"\xe5\x8d\x8a\xe9\x80\x94\xe5\xb4\xa9\xe6\xba\x83\"}}]}\n\n") + self.wfile.flush() + self.close_connection = True + return + + # 固定的 Markdown 回复,用于验证流式渲染 + if "MARKDOWN_TEST" in first_user: + md = ( + "# 一级标题\n" + "## 二级标题\n" + "\n" + "这是 **粗体** 和 *斜体* 和 _也是斜体_ 和 `code` 混排。\n" + "参考 [官方文档](https://example.com/doc) 与 arr[i]。\n" + "\n" + "- 列表甲\n- 列表乙\n" + "\n" + "| 名称 | 数量 | 说明 |\n" + "|:-----|-----:|:----:|\n" + "| 苹果 | 3 | 常见 |\n" + "| 香蕉 | 12 | 较长 |\n" + "\n" + "```c\n" + "int x = 1; // **不应加粗** 也不高亮 [方括号]\n" + "```\n" + ) + if not stream: + payload = json.dumps({"choices": [{"index": 0, + "message": {"role": "assistant", + "content": md}, + "finish_reason": "stop"}], + "model": model}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + # 故意按 1 字符一片下发,最大化"标记被切断"的概率 + for ch in md: + sse(self.wfile, {"choices": [{"index": 0, + "delta": {"content": ch}, + "finish_reason": None}]}) + sse(self.wfile, {"choices": [{"index": 0, "delta": {}, + "finish_reason": "stop"}]}) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + return + + # 思考模式:验证 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": "先想一下要不要调用工具。", + "tool_calls": [{"id": "call_1", "type": "function", + "function": { + "name": "run_command", + "arguments": '{"command": "echo think-ok"}'}}]} + payload = json.dumps({"choices": [{"index": 0, "message": msg, + "finish_reason": "tool_calls"}], + "model": model}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + # 思考过程分片下发:客户端必须累积,而不是只留最后一片 + for part in ("先想一下", "要不要调用工具。"): + sse(self.wfile, {"choices": [{"index": 0, + "delta": {"reasoning_content": part}, + "finish_reason": None}]}) + sse(self.wfile, {"choices": [{"index": 0, "delta": {"tool_calls": [ + {"index": 0, "id": "call_1", "type": "function", + "function": {"name": "run_command", + "arguments": '{"command": "echo think-ok"}'}}]}, + "finish_reason": None}]}) + sse(self.wfile, {"choices": [{"index": 0, "delta": {}, + "finish_reason": "tool_calls"}]}) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + return + + assistants = [m for m in messages if m.get("role") == "assistant"] + got = "".join(m.get("reasoning_content", "") for m in assistants) + th = body.get("thinking") + th = th.get("type") if isinstance(th, dict) else "none" + self.send_text("THINK[echo=%s thinking=%s effort=%s]" % ( + "yes" if "要不要调用工具" in got else "NO", th, + body.get("reasoning_effort") or "none"), stream, model) + return + + # 慢速响应:先静默一段(网络/排队),再吐思维链,再静默一段, + # 最后给正文。用来验证等待动画的两段标签:"等待响应" → "思考中"。 + # 名字刻意不含 "THINK_TEST" —— 上面那些标记都是按子串匹配的, + # 叫 SLOWTHINK_TEST 会被 THINK_TEST 那个分支先截胡。 + if "SLOWRESP_TEST" in first_user: + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + time.sleep(0.8) # 这期间一个字节都没发 + sse(self.wfile, {"choices": [{"index": 0, + "delta": {"reasoning_content": "先想一下。"}, + "finish_reason": None}]}) + time.sleep(0.8) # 这期间模型在推理 + self.send_text("答案", stream=True, model=model) + return + + # meta 模式:第一轮让 meta 模型调 delete_rounds 删掉第 2 轮, + # 第二轮收工。用来端到端验证 /meta 的完整链路。 + if "META_TEST" in first_user: + if not tool_msgs: + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + import json as _j + sse(self.wfile, {"choices": [{"index": 0, "delta": {"tool_calls": [ + {"index": 0, "id": "meta_1", "type": "function", + "function": {"name": "delete_rounds", + "arguments": _j.dumps({"from": 2, "to": 2})}}]}, + "finish_reason": None}]}) + sse(self.wfile, {"choices": [{"index": 0, "delta": {}, + "finish_reason": "tool_calls"}]}) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + return + self.send_text("已删除第 2 轮。", stream, model) + return + + # 按"最后一条用户消息"触发:上面那些模式都看 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", + "content": "第二轮的"}, ensure_ascii=False) + if not stream: + msg = {"role": "assistant", "content": None, "tool_calls": [ + {"id": "call_t2", "type": "function", + "function": {"name": "write_file", "arguments": args}}]} + payload = json.dumps({"choices": [{"index": 0, "message": msg, + "finish_reason": "tool_calls"}], + "model": model}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.emit_tool_call(0, "call_t2", "write_file", args) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + return + self.send_text("第二轮写完了", stream, model) + return + + # 发出一个 write_file 调用:用来验证"在确认提示上按 Ctrl-C"到底 + # 会不会把文件写下去 + if "WRITE_TEST" in first_user: + if not tool_msgs: + args = json.dumps({"path": "build/tmp/confirm_probe.txt", + "content": "应该被拒绝的内容"}, ensure_ascii=False) + if not stream: + msg = {"role": "assistant", "content": None, "tool_calls": [ + {"id": "call_w", "type": "function", + "function": {"name": "write_file", "arguments": args}}]} + payload = json.dumps({"choices": [{"index": 0, "message": msg, + "finish_reason": "tool_calls"}], + "model": model}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.emit_tool_call(0, "call_w", "write_file", args) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + return + self.send_text("写完了", stream, model) + return + + # 读一个**含非法 UTF-8 字节**的文件。工具输出里出现坏字节的来源很日常: + # 某条命令在非 UTF-8 locale 下打出来的路径、被截断的多字节字符、二进制 + # 文件里混的一小段文本。这段字节会进对话历史,然后被序列化进请求体 —— + # 如果序列化器原样吐出,服务端就会 400,而且之后每次请求都 400。 + # 这个分支的作用就是让那条路径在 e2e 里真的被走一遍。 + if "UTF8_TEST" in first_user: + if not tool_msgs: + args = json.dumps({"path": "build/tmp/bad_utf8.txt"}) + if not stream: + msg = {"role": "assistant", "content": None, "tool_calls": [ + {"id": "call_u8", "type": "function", + "function": {"name": "read_file", "arguments": args}}]} + payload = json.dumps({"choices": [{"index": 0, "message": msg, + "finish_reason": "tool_calls"}], + "model": model}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.emit_tool_call(0, "call_u8", "read_file", args) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + return + # 第二轮:能走到这里就说明请求体过了上面的 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) + return + + # 一轮里连写两个文件:用来验证 Always/neVer 的作用域就是"这一轮" + # (名字刻意不含 "WRITE_TEST",免得被上面那个分支先截胡) + if "WRITE2_TEST" in first_user: + n = len(tool_msgs) + if n < 2: + args = json.dumps({"path": "build/tmp/confirm_probe%d.txt" % (n + 1), + "content": "第 %d 个" % (n + 1)}) + call_id = "call_w%d" % n + if not stream: + msg = {"role": "assistant", "content": None, "tool_calls": [ + {"id": call_id, "type": "function", + "function": {"name": "write_file", "arguments": args}}]} + payload = json.dumps({"choices": [{"index": 0, "message": msg, + "finish_reason": "tool_calls"}], + "model": model}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.emit_tool_call(0, call_id, "write_file", args) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + return + self.send_text("两个都写完了", stream, model) + return + + # 让模型去读一个"扩展名是 .png、内容不是图片"的文件, + # 验证 read_image 按内容判断、并明确拒绝 + # 两种"读不了":内容不是图片、以及大得内联不进去。 + # 注意: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 + else "build/tmp/notanimage.png") + args = json.dumps({"path": path}) + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.emit_tool_call(0, "call_bad", "read_image", args) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + return + self.send_text("读不了就算了", stream, model) + return + + # 一条 assistant 里发**两个**工具调用,第一个是 read_image。 + # 这是"图片附件插在两条 tool 消息中间"那个 bug 的最小复现形状: + # 只要附件没被攒到最后一起推,上面的结构校验就会回 400。 + # 注意用 first_user:图片附件本身也是一条 user 消息,而且排在最后, + # 所以第二轮请求的 last_user 是那个附件(一个 list),不是这条标记。 + if "IMAGE_PAIR_TEST" in first_user: + if not tool_msgs: + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.emit_tool_call(0, "call_img", "read_image", + json.dumps({"path": "build/tmp/dot.png"})) + self.emit_tool_call(1, "call_cmd", "run_command", + json.dumps({"command": "echo pair-ok"})) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + return + found = [] + for m in messages: + c = m.get("content") + if isinstance(c, list): + for part in c: + if isinstance(part, dict) and part.get("type") == "image_url": + found.append("img") + self.send_text("PAIR[img=%d tool=%d]" + % (len(found), len(tool_msgs)), stream, model) + return + + # 图片:先让模型调一次 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"}) + if not stream: + msg = {"role": "assistant", "content": None, "tool_calls": [ + {"id": "call_img", "type": "function", + "function": {"name": "read_image", "arguments": args}}]} + payload = json.dumps({"choices": [{"index": 0, "message": msg, + "finish_reason": "tool_calls"}], + "model": model}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.emit_tool_call(0, "call_img", "read_image", args) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + return + + # 第二轮:请求里应该已经带上内联图片了。 + # 报文形状:{"type":"image_url","image_url":{"url":"data:image/png;base64,..."}} + found, notes = [], [] + for m in messages: + c = m.get("content") + if isinstance(c, list): + for part in c: + if not isinstance(part, dict): + continue + if part.get("type") == "image_url": + url = (part.get("image_url") or {}).get("url", "") + if url.startswith("data:"): + 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:第一次故意说服务端不收这张图, + # 客户端应该把图片从历史里摘掉再试(而不是每轮都失败) + if "IMAGE_REJECT_TEST" in last_user and not STATE.get("told_once"): + STATE["told_once"] = 1 + payload = json.dumps({"error": { + "message": "image rejected: unsupported image variant"}}).encode() + self.send_response(400) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return + reply = "IMAGE[%s]" % (",".join(found) if found else "none") + if notes: + reply += " NOTE[" + "|".join(notes) + "]" + self.send_text(reply, stream, model) + return + + # 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 行要折叠" + 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"}) + if not stream: + msg = {"role": "assistant", "content": None, "tool_calls": [ + {"id": "call_edit", "type": "function", + "function": {"name": "edit_file", "arguments": args}}]} + payload = json.dumps({"choices": [{"index": 0, "message": msg, + "finish_reason": "tool_calls"}], + "model": model}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.emit_tool_call(0, "call_edit", "edit_file", args) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + return + + # 多步(4 次工具往返才收工):用来钉住"往返次数默认不限"。 + # 默认配置下必须跑得完;显式设了正数上限时要停在那个上限上并如实报出。 + if "MANYSTEPS_TEST" in first_user: + done = len(tool_msgs) + if done < 4: + args = json.dumps({"command": "echo step-%d" % (done + 1)}) + if not stream: + msg = {"role": "assistant", "content": None, "tool_calls": [ + {"id": "call_ms%d" % (done + 1), "type": "function", + "function": {"name": "run_command", "arguments": args}}]} + payload = json.dumps({"choices": [{"index": 0, "message": msg, + "finish_reason": "tool_calls"}], + "model": model}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.emit_tool_call(0, "call_ms%d" % (done + 1), "run_command", args) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + return + self.send_text("STEPS_DONE[%d]" % done, stream, model) + return + + want_tools = "twice" in first_user + need = 2 if want_tools else 1 + + if len(tool_msgs) < need: + if not stream: + args = json.dumps({"command": "echo hello-from-tool"}, ensure_ascii=False) + msg = {"role": "assistant", "content": None, "tool_calls": [ + {"id": "call_1", "type": "function", + "function": {"name": "run_command", "arguments": args}}]} + payload = json.dumps({"choices": [{"index": 0, "message": msg, + "finish_reason": "tool_calls"}], + "model": model}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return + + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + if len(tool_msgs) == 0: + self.emit_tool_call(0, "call_1", "run_command", + '{"command": "echo hello-from-tool"}') + else: + self.emit_tool_call(0, "call_2", "run_command", + '{"command": "echo second-call"}') + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + return + + last = tool_msgs[-1].get("content", "") + text = "FINAL: " + last.strip().replace("\n", " / ") + + if not stream: + payload = json.dumps({"choices": [{"index": 0, + "message": {"role": "assistant", + "content": text}, + "finish_reason": "stop"}], + "model": model}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return + + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.emit_text(text) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + + +def main(): + port = int(sys.argv[1]) if len(sys.argv) > 1 else 8731 + server = HTTPServer(("127.0.0.1", port), Handler) + print("mock server on %d" % port, flush=True) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/tests/reduce.c b/tests/reduce.c new file mode 100644 index 0000000..9462804 --- /dev/null +++ b/tests/reduce.c @@ -0,0 +1,122 @@ +/* reduce.c - 把 fuzz 找到的失败输入最小化到最短复现样例 + * + * 判定(oracle):同一段输入,"一次性喂" 与 "逐字节喂" 的渲染结果不一致。 + * 用法: build/reduce build/md_fail.bin + */ +#include "markdown.h" +#include "util.h" +#include +#include +#include + +/* 注意:md_feed 只接受 NUL 结尾的字符串,而最小化过程中的缓冲区 + * 是"长度受限"的,所以必须先做一份带结尾符的副本。 */ +static char *render(const char *src, size_t len, size_t chunk) +{ + char *text = xmalloc(len + 1); + memcpy(text, src, len); + text[len] = '\0'; + + char *buf = NULL; + size_t size = 0; + FILE *f = open_memstream(&buf, &size); + if (!f) { free(text); return NULL; } + MdRenderer *r = md_new(1); + md_set_out(r, f); + if (chunk == 0) { + md_feed(r, text); + } else { + for (size_t i = 0; i < len; i += chunk) { + size_t n = len - i < chunk ? len - i : chunk; + char *t = xstrndup(text + i, n); + md_feed(r, t); + free(t); + } + } + md_finish(r); + md_free(r); + fclose(f); + free(text); + return buf; +} + +/* 只要"一次性"与"逐字节"不一致就算失败 */ +static int fails(const char *src, size_t len) +{ + if (len == 0) return 0; + /* 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); + int bad = (a && b && strcmp(a, b) != 0); + if (bad) { + /* 确认真的不是"空输出 vs 空输出"这种假阳性 */ + if (strcmp(a, b) == 0) bad = 0; + } + free(a); free(b); + return bad; +} + +static void show(const char *label, const char *buf, size_t len) +{ + printf("%s (%zu 字节): ", label, len); + for (size_t i = 0; i < len; i++) { + unsigned char c = (unsigned char)buf[i]; + if (c == '\n') printf("\\n"); + else if (c == '\t') printf("\\t"); + else if (c == 0x1b) printf(""); + else if (c >= 0x20 && c < 0x7f) putchar(c); + else printf("\\x%02x", c); + } + putchar('\n'); +} + +int main(int argc, char **argv) +{ + if (argc < 2) { fputs("用法: reduce <失败输入文件>\n", stderr); return 2; } + size_t len = 0; + char *buf = read_entire_file(argv[1], &len); + if (!buf) { perror("读取失败"); return 2; } + if (!fails(buf, len)) { puts("该输入当前不复现失败"); free(buf); return 1; } + + puts("== 原始失败输入 =="); + show(" ", buf, len); + { + char *a = render(buf, len, 0), *b = render(buf, len, 1); + show(" 一次性", a, strlen(a)); + show(" 逐字节", b, strlen(b)); + free(a); free(b); + } + + /* 贪心块删除 */ + size_t gran = len / 2; + while (gran >= 1) { + size_t i = 0; + while (i + gran <= len) { + char *save = xmalloc(gran); + memcpy(save, buf + i, gran); + memmove(buf + i, buf + i + gran, len - i - gran); + if (fails(buf, len - gran)) { + len -= gran; + } else { + memmove(buf + i + gran, buf + i, len - i - gran); + memcpy(buf + i, save, gran); + i += gran; + } + free(save); + } + if (gran == 1) break; + gran /= 2; + } + + printf("\n== 最小复现样例 (%zu 字节) ==\n", len); + show(" ", buf, len); + { + char *a = render(buf, len, 0), *b = render(buf, len, 1); + show(" 一次性", a, strlen(a)); + show(" 逐字节", b, strlen(b)); + free(a); free(b); + } + free(buf); + return 0; +} diff --git a/tests/repl_drive.py b/tests/repl_drive.py new file mode 100644 index 0000000..6648485 --- /dev/null +++ b/tests/repl_drive.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""repl_drive.py - 用 pty 驱动 ligent 的 REPL,跑多轮对话并打印全部输出。 + +交互式行为(多轮上下文、/命令、行编辑)没法用管道测 —— 管道模式会把 +整段 stdin 当成一个任务。所以这里开一个伪终端,逐条发送提示词。 + +用法: + repl_drive.py --bin ./ligent --base-url URL --prompt "第一轮" --prompt "第二轮" +""" +import argparse +import os +import pty +import re +import select +import sys +import time + + +def drive(binary, args, prompts, wait_per_prompt=2.0, quiet_after=1.5, + raw_keys=None): + pid, fd = pty.fork() + if pid == 0: + os.execv(binary, [binary] + args) + os._exit(127) + + out = bytearray() + # 行编辑器每次回到提示符都会打开括号粘贴,所以这个序列就是"轮到用户输入了" + # 的信号。用它做同步点,比"等输出安静下来"可靠也快得多。 + PROMPT_READY = b"\x1b[?2004h" + ready_seen = 0 + tail = b"" + + def note(chunk): + # 标记可能被 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),或者输出彻底安静下来。 + + 必须按标记等,不能只按安静等:等待动画每 250ms 画一帧,pty 永远 + 不会安静超过 idle,于是每次都要白等满 timeout —— 而 mock 其实几 + 毫秒就答复了。这是整个 e2e 从 126 秒掉下来的关键。 + """ + want = ready_seen + 1 if until_ready else None + deadline = time.time() + timeout + last = time.time() + while time.time() < deadline: + r, _, _ = select.select([fd], [], [], 0.02) + if r: + try: + chunk = os.read(fd, 65536) + except OSError: + return False + if not chunk: + return False + out.extend(chunk) + note(chunk) + last = time.time() + if want is not None and ready_seen >= want: + return True + elif time.time() - last > idle: + return True + return True + + 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) + os.write(fd, b"/exit\n") + drain(0.8) + try: + os.close(fd) + except OSError: + pass + try: + os.waitpid(pid, 0) + except ChildProcessError: + pass + return re.sub(r"\x1b\[[0-9;]*m", "", out.decode("utf-8", "replace")) + + for p in prompts: + os.write(fd, (p + "\n").encode("utf-8")) + if not drain(wait_per_prompt, until_ready=True): + break + os.write(fd, b"/exit\n") + drain(0.8) + + try: + os.close(fd) + except OSError: + pass + try: + os.waitpid(pid, 0) + except ChildProcessError: + pass + + text = out.decode("utf-8", "replace") + return re.sub(r"\x1b\[[0-9;]*m", "", text) # 去掉 ANSI 样式 + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--bin", default="./ligent") + ap.add_argument("--base-url", required=True) + ap.add_argument("--api-key", default="test") + ap.add_argument("--prompt", action="append", default=[]) + 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 转义),按顺序发送") + a = ap.parse_args() + + # --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: + raw = [(k.encode("utf-8").decode("unicode_escape").encode("latin-1"), 0.25) + for k in a.keys] + sys.stdout.write(drive(a.bin, args, [], a.wait, raw_keys=raw)) + else: + sys.stdout.write(drive(a.bin, args, a.prompt, a.wait)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/repl_edit_check.py b/tests/repl_edit_check.py new file mode 100755 index 0000000..a6070f0 --- /dev/null +++ b/tests/repl_edit_check.py @@ -0,0 +1,502 @@ +#!/usr/bin/env python3 +"""repl_edit_check.py - 验证 REPL 的行编辑行为(方向键、Home/End、退格、历史) + +只抓字节流是不够的:光标移动是靠转义序列实现的,必须把输出回放到一个 +终端模型里才能知道"屏幕上最终长什么样"。所以这里带一个极简终端模拟器, +只实现本编辑器用到的那几条控制序列。 +""" +import os +import pty +import re +import select +import sys +import tempfile +import time + +COLS = 80 +ROWS = 200 + + +class Term: + """极简终端模型:只实现 \\r \\n \\b、CSI 光标移动、CSI J/K 清屏""" + + def __init__(self, cols=COLS, rows=ROWS): + self.cols, self.rows = cols, rows + self.grid = [[' '] * cols for _ in range(rows)] + self.row = self.col = 0 + + def _clear_from_cursor(self): + for c in range(self.col, self.cols): + self.grid[self.row][c] = ' ' + for r in range(self.row + 1, self.rows): + for c in range(self.cols): + self.grid[r][c] = ' ' + + def feed(self, data): + i, n = 0, len(data) + while i < n: + ch = data[i] + if ch == '\x1b': + j = i + 1 + if j < n and data[j] in '[O': + j += 1 + start = j + # CSI 的参数可以包含 ? < > ! 等私有标记,一律扫到终结符为止 + while j < n and not ('@' <= data[j] <= '~'): + j += 1 + params = data[start:j] + final = data[j] if j < n else '' + num = int(params) if params.isdigit() else 1 + if final == 'A': + self.row = max(0, self.row - num) + elif final == 'B': + self.row = min(self.rows - 1, self.row + num) + elif final == 'C': + self.col = min(self.cols - 1, self.col + num) + elif final == 'D': + self.col = max(0, self.col - num) + elif final == 'J': + self._clear_from_cursor() + elif final == 'K': + for c in range(self.col, self.cols): + self.grid[self.row][c] = ' ' + i = j + 1 + continue + i += 1 + continue + if ch == '\r': + self.col = 0 + elif ch == '\n': + self.row = min(self.rows - 1, self.row + 1) + self.col = 0 + elif ch == '\b': + self.col = max(0, self.col - 1) + elif ch == '\x07': + pass + elif ch == '\t': + self.col = min(self.cols - 1, (self.col // 8 + 1) * 8) + else: + self.grid[self.row][self.col] = ch + self.col += 1 + if self.col >= self.cols: + self.col = 0 + self.row = min(self.rows - 1, self.row + 1) + i += 1 + + def lines(self): + return [''.join(r).rstrip() for r in self.grid] + + def nonempty(self): + ls = self.lines() + last = max((i for i, l in enumerate(ls) if l), default=-1) + return ls[:last + 1] + + def last_prompt_line(self): + for line in reversed(self.nonempty()): + if line.startswith('ligent> '): + return line + return '' + + +def to_bytes(s): + # 把源码里写的十六进制转义与真实非 ASCII 字符一起转成字节。 + # 不能直接用 unicode_escape:一条串里既有转义又有中文时,latin-1 那步会报错。 + if isinstance(s, bytes): + return s + out = bytearray() + i = 0 + while i < len(s): + if s[i] == '\\' and i + 1 < len(s): + nxt = s[i + 1] + if nxt == 'x' and i + 4 <= len(s): + try: + out.append(int(s[i + 2:i + 4], 16)) + i += 4 + continue + except ValueError: + pass + simple = {'n': 10, 'r': 13, 't': 9, '\\': 92} + if nxt in simple: + out.append(simple[nxt]) + i += 2 + continue + out.extend(s[i].encode('utf-8')) + i += 1 + return bytes(out) + + +def run(binary, args, keys, keyset_settle=0.10, settle=0.45, idle=0.03): + pid, fd = pty.fork() + if pid == 0: + os.environ['TERM'] = 'xterm-256color' + os.execv(binary, [binary] + args) + os._exit(127) + + out = bytearray() + # 行编辑器每次回到提示符都会打开括号粘贴 —— 用它当"可以开始敲了"的信号 + PROMPT_READY = b"\x1b[?2004h" + ready_seen = 0 + tail = b"" + + def note(chunk): + nonlocal tail, ready_seen + buf = tail + chunk + ready_seen += buf.count(PROMPT_READY) + tail = buf[-(len(PROMPT_READY) - 1):] + + def drain(timeout, idle=idle, until_ready=False): + """安静下来就返回,不把整个 timeout 睡满。 + + idle 取得很小(0.03s)是有依据的:行编辑器每次按键都在一个 fflush + 里把整屏重绘写完,所以一次 read 之后基本就没了。而提交输入触发请求 + 时,等待动画每 250ms 一帧,永远不会安静,于是照样等到 timeout —— + 那条路径的行为和以前一致,不会因为 idle 调小而抢跑。 + """ + want = ready_seen + 1 if until_ready else None + end = time.time() + timeout + last = time.time() + while time.time() < end: + r, _, _ = select.select([fd], [], [], 0.02) + if r: + try: + d = os.read(fd, 65536) + except OSError: + return + if not d: + return + out.extend(d) + note(d) + last = time.time() + if want is not None and ready_seen >= want: + return + elif time.time() - last > idle: + return + + drain(settle, until_ready=True) + for k in keys: + os.write(fd, to_bytes(k)) + drain(keyset_settle) + drain(settle) + + # 刻意不发 Ctrl-C(它会在光标处打出 ^C 标记并覆盖两个字), + # 直接关掉 pty:最后那次重绘就是缓冲区的真值 + try: + os.close(fd) + except OSError: + pass + try: + os.waitpid(pid, 0) + except ChildProcessError: + pass + return bytes(out) + + +def wait_ready(fd, timeout=3.0): + """等到行编辑器回到提示符(它会打开括号粘贴)。 + + 裸 pty 会话里不要用 time.sleep 猜时间:猜短了会 flaky,猜长了每处都在 + 白等。进程退出(EOF)也算等到。""" + READY = b"\x1b[?2004h" + end = time.time() + timeout + tail = b"" + while time.time() < end: + r, _, _ = select.select([fd], [], [], 0.02) + if not r: + continue + try: + d = os.read(fd, 65536) + except OSError: + return False + if not d: + return False + buf = tail + d + if READY in buf: + return True + tail = buf[-(len(READY) - 1):] + return False + + +LEFT, RIGHT = '\\x1b[D', '\\x1b[C' +UP, DOWN = '\\x1b[A', '\\x1b[B' +HOME_END = ('\\x1b[H', '\\x1b[F') +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 显式打开。 + args = ['--no-color', '--no-confirm', '--no-auto-save', '--api-key', 'test', + '--base-url', base or 'http://127.0.0.1:1/v1'] + + # 被驱动的实例会读写输入历史。整轮都指到临时文件:既不往用户真实的 + # ligent.history 里写测试垃圾,方向键用例的结果也不会取决于开发者机器 + # 上碰巧有什么历史。 + scratch = os.path.join(tempfile.gettempdir(), "ligent_edit_hist_%d" % os.getpid()) + os.environ["LIGENT_HISTORY"] = scratch + + cases = [] + # 方向键左移后插入:光标必须落在中间 + cases.append(("左方向键移动光标后插入", + ['hello world'] + [LEFT] * 5 + ['X'], + 'ligent> hello Xworld')) + cases.append(("右方向键回到末尾", + ['hello world'] + [LEFT] * 5 + [RIGHT, RIGHT, 'X'], + 'ligent> hello woXrld')) + cases.append(("Home 回到行首后插入", + ['world', '\\x01', 'hello '], + 'ligent> hello world')) + cases.append(("CSI Home 序列(\\x1b[H)", + ['world', HOME_END[0], 'hello '], + 'ligent> hello world')) + cases.append(("End 序列(\\x1b[F)回到末尾", + ['world', HOME_END[0], 'hello ', HOME_END[1], '!'], + 'ligent> hello world!')) + cases.append(("行首再左移不越界", + ['abc', HOME_END[0], LEFT, LEFT, 'Z'], + 'ligent> Zabc')) + cases.append(("退格删除", + ['helloX', BS], + 'ligent> hello')) + cases.append(("中间退格删的是光标前的字符", + ['abcd', LEFT, LEFT, BS], + 'ligent> acd')) + cases.append(("Delete 键删的是光标处的字符", + ['abcd', LEFT, LEFT, '\\x1b[3~'], + 'ligent> abd')) + cases.append(("Ctrl-K 删到行尾", + ['abcd', LEFT, LEFT, '\\x0b'], + 'ligent> ab')) + cases.append(("Ctrl-U 删到行首", + ['abcd', LEFT, LEFT, '\\x15'], + 'ligent> cd')) + PASTE = '\\x1b[200~' + PASTE_END = '\\x1b[201~' + cases.append(("粘贴多行:整段插入,不逐行提交", + [PASTE + 'line1\nline2\nline3' + PASTE_END], + ['ligent> line1', 'line2', 'line3'])) + cases.append(("粘贴后可继续编辑", + [PASTE + 'aaa\nbbb' + PASTE_END, 'X'], + ['ligent> aaa', 'bbbX'])) + cases.append(("粘贴内容里含中文", + [PASTE + '\u4e2d\u6587\n\u7b2c\u4e8c\u884c' + PASTE_END], + ['ligent> 中文', '第二行'])) + cases.append(("粘贴多行后 Home 只回到本行行首", + [PASTE + 'aaa\nbbb\nccc' + PASTE_END, '\\x01', 'X'], + ['ligent> aaa', 'bbb', 'Xccc'])) + cases.append(("粘贴多行后 End 只到本行行尾", + [PASTE + 'aaa\nbbb\nccc' + PASTE_END, '\\x01', '\\x05', 'Y'], + ['ligent> aaa', 'bbb', 'cccY'])) + cases.append(("中文按字符移动光标", + ['中文abc'] + [LEFT] * 3 + ['X'], + 'ligent> 中文Xabc')) + + # 粘贴进来的原始字节里可能夹着控制字符。read_paste 是原样收下的(不走 + # classify),所以必须清洗:终端按制表位展开 \t,编辑器只当 1 列; + # ESC 会被终端真的执行掉;\r 把光标拉回行首。 + cases.append(("粘贴里的制表符按制表位展开", + [PASTE + 'a\\tb' + PASTE_END, 'X'], + 'ligent> ' + 'a' + ' ' * 7 + 'bX')) + cases.append(("制表符展开后光标位置正确(左移再插入)", + [PASTE + 'a\\tb' + PASTE_END, LEFT, 'X'], + 'ligent> ' + 'a' + ' ' * 7 + 'Xb')) + cases.append(("粘贴里的 ESC 序列被丢弃,不让它执行", + [PASTE + 'before\\x1b[2Jafter' + PASTE_END], + 'ligent> beforeafter')) + cases.append(("粘贴里的 CRLF 归一成 LF", + [PASTE + 'a\\r\\nb' + PASTE_END], + ['ligent> a', 'b'])) + + fail = 0 + for name, keys, want in cases: + raw = run(binary, args, keys) + term = Term() + term.feed(raw.decode('utf-8', 'replace')) + if isinstance(want, list): + tail = term.nonempty()[-len(want):] + got = '\n'.join(tail) + ok = (tail == want) + want = '\n'.join(want) + else: + got = term.last_prompt_line() + ok = (got == want) + # 旧实现会把方向键回显成字面量 ^[[D + literal_escapes = '^[[' in raw.decode('utf-8', 'replace') + if not ok or literal_escapes: + fail += 1 + print(" ✗ %s" % name) + print(" 期望: %r" % want) + print(" 实际: %r" % got) + if literal_escapes: + print(" 输出里出现了字面量转义文本 ^[[(方向键没被识别)") + else: + print(" ✓ %s" % name) + + # 提示符带颜色时,那串转义序列不能占列宽 —— 光标定位必须和不着色时一致。 + # 上面的用例都带 --no-color,所以这里单独补一组彩色的。 + color_args = [a for a in args if a != '--no-color'] + ['--color'] + color_cases = [ + ("彩色提示符:左移后插入", + ['hello world'] + [LEFT] * 5 + ['X'], 'ligent> hello Xworld'), + ("彩色提示符:Home 后插入", + ['world', '\\x01', 'hello '], 'ligent> hello world'), + ("彩色提示符:End 后追加", + ['abc', '\\x05', '!'], 'ligent> abc!'), + ("彩色提示符:中文按字符移动", + ['中文abc'] + [LEFT] * 3 + ['X'], 'ligent> 中文Xabc'), + ("彩色提示符:退格", + ['abcd', '\\x7f'], 'ligent> abc'), + ] + for name, keys, want in color_cases: + raw = run(binary, color_args, keys) + term = Term() + term.feed(raw.decode('utf-8', 'replace')) + got = term.last_prompt_line() + if got == want: + print(" ✓ %s" % name) + else: + fail += 1 + print(" ✗ %s" % name) + print(" 期望: %r" % want) + print(" 实际: %r" % got) + + # 上面那些用例只比屏幕内容,而颜色在终端模型里是被剥掉的 —— 包没包住 + # 输入的字看不出来。所以这里直接查字节:绿色区间必须一直延续到用户敲的 + # 内容之后。只包提示符的话,提示符自带的 reset 会让输入掉回默认色。 + raw = run(binary, color_args, ['greenme']) + txt = raw.decode('utf-8', 'replace') + start = txt.rfind('\x1b[32m') + tail = txt[start:] if start >= 0 else '' + end = tail.find('\x1b[0m') + inside = tail[:end] if start >= 0 and end >= 0 else '' + if 'greenme' in inside: + print(" ✓ 输入的字也被包在绿色里") + else: + fail += 1 + print(" ✗ 输入的字也被包在绿色里") + print(" 绿色区间: %r" % inside) + + # 粘贴多行后提交:必须作为"一条"消息发出去(回显里能看到内嵌换行) + if base: + raw = run(binary, args, + [PASTE + 'ECHO_TEST 第一行\n第二行\n第三行' + PASTE_END, '\\r'], + keyset_settle=1.2, settle=0.8, idle=0.5) + term = Term() + term.feed(raw.decode('utf-8', 'replace')) + joined = '\n'.join(term.nonempty()) + if 'ECHO[ECHO_TEST 第一行\\n第二行\\n第三行]' in joined: + print(" ✓ 粘贴的多行作为一条消息提交") + else: + fail += 1 + print(" ✗ 粘贴的多行作为一条消息提交") + print(" 屏幕尾部: %r" % term.nonempty()[-6:]) + + # 上下方向键取历史:需要真的提交一行,所以只有在给了服务端时才测 + if base: + raw = run(binary, args, ['recalldemo', '\\r'], keyset_settle=0.8, + settle=0.6, idle=0.5) + term = Term() + term.feed(raw.decode('utf-8', 'replace')) + raw2 = run(binary, args, ['firstline', '\\r', '\\x1b[A'], + keyset_settle=0.8, settle=0.6, idle=0.5) + term2 = Term() + term2.feed(raw2.decode('utf-8', 'replace')) + lines = [l for l in term2.nonempty() if l.startswith('ligent> ')] + if lines and 'firstline' in lines[-1]: + print(" ✓ 上方向键取回上一条输入") + else: + fail += 1 + print(" ✗ 上方向键取回上一条输入") + print(" 提示符行: %r" % lines[-3:]) + cases.append(("history", [], "ligent> firstline")) + + # 历史要跨进程:上一个实例敲过的,新实例按上方向键就能翻到。 + # 用独立的 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' + os.execv(binary, [binary] + args) + os._exit(127) + wait_ready(fd) # 等横幅画完、回到提示符 + os.write(fd, b'live_append\r') + wait_ready(fd) # 等这条提交并落盘 + content = "" + if os.path.exists(hist): + with open(hist, encoding='utf-8') as fh: + content = fh.read() + if 'live_append' in content: + print(" ✓ 提交后立刻落盘(进程未退出)") + else: + fail += 1 + print(" ✗ 提交后立刻落盘(进程未退出)") + print(" 文件内容: %r" % content) + # 继续敲两条再正常退出,供下一个实例翻 + for k in ('persist_one\r', '/think high\r'): + os.write(fd, k.encode()) + wait_ready(fd) + os.write(fd, b'/exit\r') + wait_ready(fd) # 退出后这里会读到 EOF + try: + os.close(fd) + except OSError: + pass + try: + os.waitpid(pid, 0) + except OSError: + pass + + # 文件里依次是 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() + term3.feed(raw3.decode('utf-8', 'replace')) + lines3 = [l for l in term3.nonempty() if l.startswith('ligent> ')] + if lines3 and 'persist_one' in lines3[-1]: + print(" ✓ 新实例能翻到上一个实例的历史") + else: + fail += 1 + print(" ✗ 新实例能翻到上一个实例的历史") + print(" 提示符行: %r" % lines3[-3:]) + cases.append(("history-persist", [], "ligent> persist_one")) + finally: + for p in (hist, hist + ".tmp"): + if os.path.exists(p): + os.unlink(p) + os.environ["LIGENT_HISTORY"] = scratch # 还给整轮共用的那个 + + # Ctrl-C 应当丢弃当前行并回到新提示符 + raw = run(binary, args, ['garbage', CTRLC, 'typed', CTRLC], idle=0.5) + term = Term() + term.feed(raw.decode('utf-8', 'replace')) + prompts = [l for l in term.nonempty() if l.startswith('ligent> ')] + # 期望: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: + fail += 1 + print(" ✗ Ctrl-C 丢弃当前行并开新行") + print(" 提示符行: %r" % prompts[-3:]) + else: + print(" ✓ Ctrl-C 丢弃当前行并开新行") + + total = len(cases) + len(color_cases) + 2 + print() + print("通过 %d 项,失败 %d 项" % (total - fail, fail)) + for p in (scratch, scratch + ".tmp"): + if os.path.exists(p): + os.unlink(p) + os.environ.pop("LIGENT_HISTORY", None) + return 1 if fail else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/replay.py b/tests/replay.py new file mode 100644 index 0000000..10a153f --- /dev/null +++ b/tests/replay.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +"""replay.py - 把 /log 录下来的真实会话当成测试夹具回放。 + +为什么需要它:ligent 的协议适配一直只在自建的 mock 服务端上验证过,而 mock +是我们自己写的 —— 它接受什么,完全取决于**我们以为**服务端会接受什么。真实 +服务端的差异是最大的未知数。 + +/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 打印差异。这是这个工具最有用 +的一半:它把"今天的客户端还在发同样的报文形状"变成一条会失败的断言。 + +注意回放时要给 ligent 加 --no-probe:能力探测会插进额外的请求,而那几轮 +不在录制里。录制时也建议关掉(LIGENT_PROBE=0),两边一致。 +""" +import argparse +import json +import socket +import sys +import threading + +# ---------- 解析 /log 导出 ---------- + + +def parse_log(text): + """把一份 /log 导出切成若干次 HTTP 往返。 + + 格式(见 http.c 的记录点): + ===== ===== + 请求: POST + ----- 请求体 (N 字节) ----- + + ----- 响应头 ----- +
+ <空行> + <响应原文> + ----- 结束: HTTP ----- + + 日志是**滚动缓冲**,开头那一段可能是被截断的,所以要容忍缺标记 —— 跳过 + 解析不出来的块,而不是整个失败。 + """ + lines = text.split("\n") + out = [] + i = 0 + while i < len(lines): + if not (lines[i].startswith("===== ") and lines[i].endswith(" =====")): + i += 1 + continue + stamp = lines[i][6:-6] + i += 1 + ex = {"stamp": stamp, "url": None, "method": None, + "req": b"", "status": None, "headers": [], "resp": b""} + + # 请求行 + if i < len(lines) and lines[i].startswith("请求: "): + parts = lines[i][len("请求: "):].split(" ", 1) + ex["method"] = parts[0] + 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 + blob = "" + while i < len(lines) and len(blob.encode()) < n: + blob += (lines[i] + "\n") + i += 1 + ex["req"] = blob.encode()[:n] + # 上面多读了一行就把多余的还回去(blob 里包含了尾部换行) + consumed = len(blob.encode()) + if consumed > n: + # 把这一行重新交回处理(只可能多出换行) + pass + + # 响应头 + if i < len(lines) and lines[i] == "----- 响应头 -----": + i += 1 + while i < len(lines) and lines[i] != "": + ex["headers"].append(lines[i]) + i += 1 + i += 1 # 跳过空行 + + # 响应原文:直到"结束"行 + start = i + while i < len(lines) and not lines[i].startswith("----- 结束: "): + i += 1 + ex["resp"] = ("\n".join(lines[start:i])).encode() + + if i < len(lines) and lines[i].startswith("----- 结束: "): + tail = lines[i] + try: + ex["status"] = int(tail.split("HTTP ")[1].split()[0]) + except (IndexError, ValueError): + 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")) + except Exception as e: + return {"error": "不是合法 JSON: %s" % e} + msgs = body.get("messages") or [] + tools = body.get("tools") or [] + names = [] + for t in tools: + n = ((t or {}).get("function") or {}).get("name") + if n: + names.append(n) + return { + "keys": sorted(body.keys()), + "roles": [m.get("role") for m in msgs], + "tools": sorted(names), + } + + +# ---------- 离线服务端 ---------- + + +class ReplayServer: + def __init__(self, exchanges, strict, loop): + self.ex = exchanges + self.strict = strict + self.loop = loop + self.pos = 0 + self.mismatch = 0 + self.lock = threading.Lock() + + def next_exchange(self): + with self.lock: + if self.pos >= len(self.ex): + if self.loop: + self.pos = 0 + else: + return None + e = self.ex[self.pos] + self.pos += 1 + return e + + def handle(self, method, path, req_bytes): + e = self.next_exchange() + if e is None: + sys.stderr.write( + "replay: 录制里没有更多响应了(已经回了 %d 个)。\n" + " 客户端比录制时多发了请求 —— 多半是多了能力探测(回放时请加 " + "--no-probe)或者客户端行为变了。\n" % len(self.ex)) + return 500, {}, b'{"error":{"message":"replay: out of recorded responses"}}' + + if self.strict: + want = shape_of(e["req"]) + got = shape_of(req_bytes) + if want != got: + self.mismatch += 1 + sys.stderr.write( + "replay: 第 %d 个请求的**结构**和录制时不一致:\n" + " 录制时: %s\n" + " 现在 : %s\n" + % (self.pos, json.dumps(want, ensure_ascii=False), + json.dumps(got, ensure_ascii=False))) + return (400, {"Content-Type": "application/json"}, + json.dumps({"error": { + "message": "replay strict: request shape changed"}} + ).encode()) + + ctype = "application/json" + for h in e["headers"]: + if h.lower().startswith("content-type:"): + ctype = h.split(":", 1)[1].strip() + hdrs = {"Content-Type": ctype} + return e["status"], hdrs, e["resp"] + + +def serve(exchanges, port, strict, loop): + srv = ReplayServer(exchanges, strict, loop) + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + 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) + + while True: + conn, _ = s.accept() + threading.Thread(target=handle_conn, args=(conn, srv), + daemon=True).start() + + +def handle_conn(conn, srv): + try: + f = conn.makefile("rb") + req_line = f.readline() + if not req_line: + return + parts = req_line.decode("latin-1").split() + method = parts[0] if parts else "GET" + path = parts[1] if len(parts) > 1 else "/" + + clen = 0 + while True: + line = f.readline() + if not line or line in (b"\r\n", b"\n"): + break + if line.lower().startswith(b"content-length:"): + clen = int(line.split(b":", 1)[1].strip()) + body = f.read(clen) if clen > 0 else b"" + + status, hdrs, resp = srv.handle(method, path, body) + head = "HTTP/1.1 %d %s\r\n" % (status, "OK" if status == 200 else "Error") + for k, v in hdrs.items(): + head += "%s: %s\r\n" % (k, v) + head += "Content-Length: %d\r\nConnection: close\r\n\r\n" % len(resp) + conn.sendall(head.encode() + resp) + except Exception as e: # noqa: BLE001 - 服务端不该因一个连接死掉 + sys.stderr.write("replay: 连接处理出错:%s\n" % e) + finally: + try: + conn.close() + except OSError: + pass + + +# ---------- 入口 ---------- + + +def main(): + ap = argparse.ArgumentParser(description="回放 /log 录下来的真实会话") + ap.add_argument("file") + ap.add_argument("--summary", action="store_true", help="只打印这份录制里有什么") + 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") + ap.add_argument("--loop", action="store_true", help="响应用完后从头再来") + a = ap.parse_args() + + text = open(a.file, encoding="utf-8", errors="replace").read() + ex = parse_log(text) + + if a.summary or a.check: + print("%s:%d 次往返" % (a.file, len(ex))) + for i, e in enumerate(ex): + r = e["resp"] + kind = "SSE" if r.lstrip().startswith(b"data:") else "JSON" + ntool = r.count(b'"tool_calls"') + print(" #%d HTTP %s %-28s 请求 %5d 字节 → 响应 %6d 字节 %s%s" + % (i + 1, e["status"], (e["url"] or "").split("/")[-1], + len(e["req"]), len(r), kind, + " 含 tool_calls" if ntool else "")) + if a.check: + bad = [i + 1 for i, e in enumerate(ex) if not e["req"] or e["status"] is None] + if bad: + print("解析不完整的往返: %s" % bad, file=sys.stderr) + return 1 + print("解析自检通过") + return 0 + + if a.serve: + if not ex: + print("这份录制里没有可回放的往返", file=sys.stderr) + return 1 + serve(ex, a.serve, a.strict, a.loop) + return 0 + + ap.error("要么 --summary/--check,要么 --serve PORT") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/run_e2e.sh b/tests/run_e2e.sh new file mode 100755 index 0000000..9749034 --- /dev/null +++ b/tests/run_e2e.sh @@ -0,0 +1,1544 @@ +#!/usr/bin/env bash +# run_e2e.sh - 端到端测试:用本地 mock 服务端跑通 ligent 的完整链路 +set -u + +# 把脚本自己的 stdin 断掉。 +# +# 这不是洁癖:ligent 在写操作前会问 (Y/n),而它在 stdin 不是终端时默认放行。 +# 于是"直接调用 $BIN"的用例在 CI 里一路通过,在开发者的终端里却会停下来 +# 等人敲回车 —— 同一份脚本,两种行为。交互用例都走 pty 驱动脚本,不靠这里 +# 的 stdin,所以断开它不会误伤任何东西。 +exec /dev/null 2>&1 & +SERVER_PID=$! +trap 'kill $SERVER_PID 2>/dev/null; wait $SERVER_PID 2>/dev/null' EXIT + +# 等服务端起来 +for _ in $(seq 1 50); do + if python3 -c " +import socket,sys +s=socket.socket() +s.settimeout(0.2) +sys.exit(0 if s.connect_ex(('127.0.0.1',$port))==0 else 1)"; then + break + fi + sleep 0.1 +done + +BASE="http://127.0.0.1:$port/v1" +PORT="$port" + +# 输入历史落到 build/ 里,别在仓库根目录留下 ligent.history。 +# 需要验证历史本身的用例会自己指定 LIGENT_HISTORY。 +export LIGENT_HISTORY="$ROOT/build/e2e.history" +rm -f "$LIGENT_HISTORY" "$LIGENT_HISTORY.tmp" + +# 默认关掉能力探测:它会在每个会话的第一次真实请求前插进一串试探请求, +# 而下面几十段用例都是按"请求序列"写的,会被它整体推后一位。 +# 探测本身由第 51 段专门覆盖(那段自己把这个变量打开)。 +export LIGENT_PROBE=0 + +# 界面语言锁成中文:下面这些断言写的是中文文案,而开发者的 LANG 或 CI 的 +# C locale 会让同一份脚本在两种语言下各红一半。英文路径由第 59 段专门覆盖。 +export LIGENT_LANG=zh + +# 配置隔离。ligent 读的是"二进制旁边那份 ligent.json",而被测二进制就在仓库根 +# —— 于是开发者自己那份(改过 temperature、reasoning_effort……)会混进请求体, +# 让"默认请求长什么样"这类断言时绿时红:同一份代码,换个人的机器结论就不同。 +# 测试得自证,所以这里指向一份空配置;要测配置加载的用例自己带 -c。 +export LIGENT_CONFIG="$ROOT/build/tmp/e2e-empty.json" +printf '{}\n' > "$LIGENT_CONFIG" + +# 存档目录(<二进制目录>/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 + else + echo "✗ 移不动 $ARCHIVE_DIR —— 为免误删,测试不跑。先手动移开它。" >&2 + exit 1 + fi +fi + +check() { # check <描述> <条件结果 0/1> [输出] + local desc="$1" rc="$2" out="${3:-}" + if [ "$rc" -eq 0 ]; then + PASS=$((PASS + 1)) + echo " ✓ $desc" + else + FAIL=$((FAIL + 1)) + echo " ✗ $desc" + echo "----- 实际输出 -----" + echo "$out" + echo "--------------------" + fi +} + +# contains <子串> <文本> +contains() { case "$2" in *"$1"*) return 0;; *) return 1;; esac; } + +echo "== 1. 单次任务 + 流式 + 一次工具调用 ==" +OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "please run it" 2>&1)" +echo "$OUT" | sed 's/^/ /' +contains "hello-from-tool" "$OUT"; check "工具被真实执行并回显" $? +contains "FINAL:" "$OUT"; check "拿到最终回答" $? +contains "run_command" "$OUT"; check "展示了工具调用" $? + +echo +echo "== 2. 非流式模式 ==" +OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color --no-stream "do it" 2>&1)" +contains "FINAL:" "$OUT"; check "--no-stream 正常工作" $? +contains "hello-from-tool" "$OUT"; check "非流式下工具结果正确" $? + +echo +echo "== 3. 多步工具调用 ==" +OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "do it twice" 2>&1)" +echo "$OUT" | sed 's/^/ /' +contains "second-call" "$OUT"; check "第二次工具调用被执行" $? +contains "FINAL:" "$OUT"; check "多步之后拿到最终回答" $? + +echo +echo "== 4. 服务端错误处理 ==" +OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "ERROR_TEST" 2>&1)" +RC=$? +contains "mock 模拟的服务端错误" "$OUT"; check "错误信息透传给用户" $? +[ "$RC" -ne 0 ]; check "出错时退出码非 0" $? + +echo +echo "== 5. 缺少 API key ==" +OUT="$(env -u LIGENT_API_KEY -u DEEPSEEK_API_KEY -u OPENAI_API_KEY \ + "$BIN" --base-url "$BASE" --no-confirm --no-auto-save "hi" 2>&1)" +contains "未配置 API key" "$OUT"; check "缺 key 时给出清晰提示" $? + +echo +echo "== 6. 管道输入模式 ==" +OUT="$(echo "piped task" | "$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color 2>&1)" +contains "FINAL:" "$OUT"; check "从 stdin 读取任务" $? + +echo +echo "== 7. Markdown 流式渲染(mock 按 1 字符一片下发) ==" +OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --color "MARKDOWN_TEST" 2>&1)" +# 去掉 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 +printf '%s' "$OUT" | grep -q $'\033\[1m' && r=0 || r=1 +check "粗体渲染" $r +printf '%s' "$OUT" | grep -qF '**粗体**' && r=1 || r=0 +check "正文里的粗体标记 ** 已被移除" $r +printf '%s' "$OUT" | grep -qF '粗体' && r=0 || r=1 +check "文本内容保留" $r +printf '%s' "$OUT" | grep -qF '**不应加粗**' && r=0 || r=1 +check "代码块内的 ** 原样保留" $r +printf '%s' "$OUT" | grep -qF $'\033[36m-' && r=0 || r=1 +check "列表标记 - 被着色" $r +printf '%s' "$PLAIN" | grep -qF -- '- 列表甲' && r=0 || r=1 +check "列表保留原始 - 字符" $r +printf '%s' "$OUT" | grep -qF '•' && r=1 || r=0 +check "不再出现非 ASCII 圆点" $r +printf '%s' "$OUT" | grep -qF '┌' && r=0 || r=1 +check "表格渲染出边框" $r +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。 +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 +check "链接 URL 原样保留" $r +printf '%s' "$OUT" | grep -qF 'arr[i]' && r=0 || r=1 +check "数组下标不被当成链接" $r + +echo +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 +printf '%s' "$OUT" | grep -qF '# 一级标题' && r=0 || r=1 +check "--no-color 时保留原始 # 标记" $r +printf '%s' "$OUT" | grep -q $'\033\[' && r=1 || r=0 +check "--no-color 时不含任何 ANSI 转义" $r + +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 "第二次失败后继续重试" $? + +echo +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 +check "--retries 0 时不做任何重试" $r +contains "503" "$OUT" "$OUT"; check "--retries 0 时直接报出 503" $? +[ "$RC" -ne 0 ]; check "--retries 0 失败时退出码非 0" $? + +echo +echo "== 11. 重试耗尽后必须给出可读错误 ==" +OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color --retries 1 "ALWAYS503_TEST" 2>&1)" +contains "已重试 1 次" "$OUT"; check "错误信息说明重试了几次" $? +contains "mock 持续不可用" "$OUT"; check "服务端的说明被透传" $? + +echo +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 "已输出内容时不重试(避免重复输出)" $? + +echo +echo "== 13. 畸形 SSE 不能导致崩溃 ==" +OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "GARBAGE_TEST" 2>&1)" +RC=$? +contains "ok" "$OUT"; check "夹杂的合法片段仍被解析" $? +[ "$RC" -eq 0 ]; check "畸形输入下正常退出" $? + +echo +echo "== 14. 超大响应必须被主动中止 ==" +OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "HUGE_TEST" 2>&1)" +RC=$? +contains "超过" "$OUT"; check "明确报出超过响应体上限" $? +[ "$RC" -ne 0 ]; check "超限时退出码非 0" $? +OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color --no-stream "HUGE_TEST" 2>&1)" +contains "超过" "$OUT"; check "非流式路径同样有上限" $? + +echo +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)" +contains "第一轮" "$OUT"; check "第一轮正常" $? +contains "maximum context length" "$OUT"; check "超限时报出服务端的原始错误" $? +contains "已丢弃最老" "$OUT" && r=1 || r=0 +check "不再自动丢弃历史" $r +# 两轮 user + 第一轮的 assistant = 3 条:第二轮报错没有留下残缺消息,也没删东西 +contains "历史消息 3 条" "$OUT" +check "历史条数原样(system 不计)" $? + +echo +echo "== 16. 自动裁剪的整套接口都已移除 ==" +OUT="$("$BIN" --no-auto-trim "x" 2>&1)"; r=$? +[ "$r" -eq 2 ]; check "--no-auto-trim 被当成未知选项拒绝" $? +contains "未知选项" "$OUT"; check "拒绝时说清是未知选项" $? +OUT="$("$BIN" --auto-trim "x" 2>&1)"; r=$? +[ "$r" -eq 2 ]; check "--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 不会拦住启动(未知键忽略)" $? +contains "auto_trim" "$OUT" && r=1 || r=0 +check "--print-config 里不再出现 auto_trim" $r + +echo +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 "明确报出发生重定向" $? +contains "base_url" "$OUT"; check "告诉用户该改哪个配置项" $? +contains "example.invalid" "$OUT"; check "报出重定向目标地址" $? +[ "$RC" -ne 0 ]; check "重定向时退出码非 0" $? + +echo +echo "== 18. --verbose 不得泄露 API key ==" +# 必须有 --no-confirm:这一轮会触发工具调用,缺了它就会在真终端上问 (Y/n), +# 整个套件停在那里等人敲回车。脚本开头的 exec &1)" +contains "VERBOSE-LEAK-CHECK" "$OUT" && r=1 || r=0 +check "-v 输出里不含完整密钥" $r +contains "已隐藏" "$OUT"; check "-v 显示脱敏后的认证行" $? +contains "Send header" "$OUT"; check "-v 仍然保留协议细节" $? + +echo +echo "== 19. 401 必须给出可操作的提示 ==" +OUT="$("$BIN" --api-key "sk-0f114e3dac8249f0a36fd8fd5c517360" --base-url "$BASE" \ + --no-color --no-confirm --no-auto-save --retries 0 "AUTHFAIL_TEST" 2>&1)" +contains "Missing Authentication header" "$OUT"; check "服务端原文被透传" $? +contains "不同服务商的密钥互不通用" "$OUT"; check "指出 key 与服务商不匹配" $? +contains "sk-or-" "$OUT"; check "点明 OpenRouter 的 key 前缀" $? +contains "sk-0f1" "$OUT"; check "显示实际发出的 key 前缀(已打码)" $? +contains "0f114e3dac8249f0a36fd8fd5c517360" "$OUT" && r=1 || r=0 +check "不回显完整密钥" $r + +echo +echo "== 20. 空 system 配置不应下发空消息 ==" +printf '{"model":"deepseek-flash","system":""}\n' > build/empty_system.json +OUT="$("$BIN" --config build/empty_system.json --print-config 2>&1)" +contains "system : (内置默认提示词)" "$OUT"; check "空 system 回退到内置默认" $? +rm -f build/empty_system.json + +echo +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]} +[ "$RC" -eq 0 ]; check "行编辑器行为全部正确" $? + +echo +echo "== 23. /log 导出收发记录 ==" +LOG="build/e2e_session.log" +rm -f "$LOG" +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 字节"这句话(行编辑器会重绘,所以要按关键词找) +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 "结束: HTTP 200"; check "记录了结束状态" $? +printf '%s' "$(cat "$LOG" 2>/dev/null)" | grep -q "Authorization"; r=$? +[ "$r" -ne 0 ]; check "日志里不含认证头" $? + +# 再导出一次应是追加 +L1="$(wc -l < "$LOG")" +OUT2="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ + --prompt "再来一次" --prompt "/log $LOG" --wait 1.2 2>&1)" +L2="$(wc -l < "$LOG")" +[ "$L2" -gt "$L1" ]; check "多次导出是追加而不是覆盖" $? + +# /log clear +OUT3="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ + --prompt "hi" --prompt "/log clear" --prompt "/log" --wait 1.2 2>&1)" +contains "收发记录已清空" "$OUT3"; check "/log clear 清空缓存" $? +rm -f "$LOG" + +echo +echo "== 30. /export 导出对话存档 ==" +ARCHIVE_DIR="$(dirname "$BIN")/ligent.archive" +rm -rf "$ARCHIVE_DIR" +OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ + --prompt "记住暗号是紫水晶" --prompt "/export" --wait 1.3 2>&1)" +contains "已导出" "$OUT"; check "导出成功并报告条数" $? +OUT2="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ + --prompt "另一个话题" --prompt "/export" --wait 1.3 2>&1)" +contains "已导出" "$OUT2"; check "再次导出成功" $? + +# 空对话不该产生存档 +OUT3="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ + --prompt "/export" --wait 1.2 2>&1)" +contains "还没有内容" "$OUT3"; check "空对话拒绝导出" $? + +COUNT="$(ls "$ARCHIVE_DIR"/*.json 2>/dev/null | wc -l)" +[ "$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") +d = json.load(open(files[0], encoding="utf-8")) +ok = (d.get("format") == "ligent.archive" and "messages" in d + and d.get("name") and "parent" 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)" $? + +echo +echo "== 30b. 谱系命名:分支看得见 ==" +AROOT="$(ls "$ARCHIVE_DIR" | sed 's/\.json$//' | head -1)" +OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ + --extra=--auto-save --prompt "/import $AROOT" --prompt "n" --prompt "分支甲" \ + --prompt "/export" --prompt "/exit" --wait 1.3 2>&1)" +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 "第二次仍从根分出(兄弟分支)" $? + +BRANCHES="$(ls "$ARCHIVE_DIR" | sed 's/\.json$//' | grep -c "^${AROOT}_")" +[ "$BRANCHES" -eq 2 ]; check "根下有两个兄弟分支" $? +for b in $(ls "$ARCHIVE_DIR" | sed 's/\.json$//' | grep "^${AROOT}_"); do + printf '%s' "$b" | grep -qE "^${AROOT}_[a-z0-9]{3}$" || BRANCHES=0 +done +[ "$BRANCHES" -eq 2 ]; check "分支名形如 <父>_<3位>" $? + +# 再从分支往下分一级 +B1="$(ls "$ARCHIVE_DIR" | sed 's/\.json$//' | grep "^${AROOT}_" | head -1)" +OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ + --extra=--auto-save --prompt "/import $B1" --prompt "n" --prompt "更深一层" \ + --prompt "/export" --prompt "/exit" --wait 1.3 2>&1)" +contains "$B1 ->" "$OUT"; check "从分支再分出一级" $? +ls "$ARCHIVE_DIR" | sed 's/\.json$//' | grep -qE "^${B1}_[a-z0-9]{3}$" +check "孙分支名形如 <父>_<3位>_<3位>" $? + +echo +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)" $? +printf '%s' "$OUT" | grep -q "紫水晶" && r=1 || r=0 +check "回答 n 时不打印历史内容" $r + +# 回答 y 时把历史逐条打出来。这里按名字指定含暗号那一份 —— 选择器的 +# 序号顺序由 archive_list 的排序决定,不该在测试里赌。 +SECRET="$(grep -l 紫水晶 "$ARCHIVE_DIR"/*.json 2>/dev/null | head -1 \ + | xargs -r basename | sed 's/\.json$//')" +[ -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 提示词也展开,不是只报个体积 +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 +[ "$r" -eq 0 ]; check "没有任何截断标记" $? + +# 直接回车等于 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)" $? + +echo +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" \ + --wait 1.2 2>&1)" +# 清屏序列必须真的发出去 +printf '%s' "$OUT" | grep -q $'\033\[2J' ; check "发出清屏转义序列" $? +# 前后条数一致 = 对话没被动过 +N1="$(printf '%s' "$OUT" | tr '\r' '\n' | grep -oE '历史消息 [0-9]+ 条' | head -1)" +N2="$(printf '%s' "$OUT" | tr '\r' '\n' | grep -oE '历史消息 [0-9]+ 条' | tail -1)" +[ -n "$N1" ] && [ "$N1" = "$N2" ]; check "/cls 前后历史条数不变($N1)" $? +# /cls 和 /clear 是两回事:/clear 是 /reset 的别名 +contains "已清空对话历史" "$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ + --prompt "你好" --prompt "/clear" --wait 1.2 2>&1)" +check "/clear 仍然清空对话(没被 /cls 抢走)" $? + +echo +echo "== 32. /import 取消与非法序号 ==" +OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ + --prompt "/import" --prompt "" --wait 1.2 2>&1)" +contains "已取消" "$OUT"; check "直接回车取消" $? +OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ + --prompt "/import" --prompt "99" --wait 1.2 2>&1)" +contains "超出范围" "$OUT"; check "非法序号被拒绝" $? +rm -rf "$ARCHIVE_DIR" + +echo +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)" +contains "即将执行" "$OUT"; check "执行前说明了要做什么" $? +contains "命令: echo hello-from-tool" "$OUT"; check "展示了具体命令" $? +contains "继续?(Y/N/Always/neVer)" "$OUT"; check "提示列出四种回答" $? +contains "用户拒绝" "$OUT"; check "拒绝后反馈给模型而不是中断本轮" $? + +OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ + --extra=--confirm --extra=--no-auto-save \ + --prompt "跑一下" --prompt "" --prompt "/exit" --wait 1.2 2>&1)" +contains "hello-from-tool" "$OUT"; check "直接回车即默认允许" $? + +OUT="$("$BIN" --api-key test --base-url "$BASE" --no-color --no-confirm \ + --no-auto-save "跑一下" 2>&1)" +contains "hello-from-tool" "$OUT"; check "--no-confirm 时直接执行" $? +printf '%s' "$OUT" | grep -q "继续?" && r=1 || r=0 +check "--no-confirm 时不再询问" $r + +echo +echo "== 34. 自动存档 ==" +ARCHIVE_DIR="$(dirname "$BIN")/ligent.archive" +rm -rf "$ARCHIVE_DIR" +"$BIN" --api-key test --base-url "$BASE" --no-color --no-confirm "跑一下" >/dev/null 2>&1 +N1="$(ls "$ARCHIVE_DIR"/*.json 2>/dev/null | wc -l)" +[ "$N1" -eq 1 ]; check "单次任务结束后自动存档" $? + +"$BIN" --api-key test --base-url "$BASE" --no-color --no-confirm --no-auto-save "跑一下" >/dev/null 2>&1 +N2="$(ls "$ARCHIVE_DIR"/*.json 2>/dev/null | wc -l)" +[ "$N2" -eq "$N1" ]; check "--no-auto-save 时不存档" $? + +# 只有 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 "没有内容时不留下空存档" $? + +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 "退出时自动存档" $? + +# 重置之后若没有任何内容,退出时不该留下空存档 +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 +AFTER="$(ls "$ARCHIVE_DIR"/*.json 2>/dev/null | wc -l)" +[ "$BEFORE" -eq "$AFTER" ]; check "空对话退出时不留存档" $? +rm -rf "$ARCHIVE_DIR" + +echo +echo "== 35. /import 前的保底存档带 tmp_ 前缀 ==" +rm -rf "$ARCHIVE_DIR" +# 先建一个根 +python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" --extra=--auto-save \ + --prompt "开场" --prompt "/export" --prompt "/exit" --wait 1.2 >/dev/null 2>&1 +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)" +contains "〔临时〕" "$OUT"; check "保底存档被标记为临时" $? +ls "$ARCHIVE_DIR" | grep -qE "^tmp_[a-z0-9]{8}\.json$" +check "临时存档名为 tmp_<8位>" $? + +# 临时存档不该成为分支的祖先 +printf '%s' "$OUT" | grep -qE "${R2} -> ${R2}_[a-z0-9]{3}" +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(带参数直接执行) ==" +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 提示词)" $? + +echo +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)" +contains "进入交互式 shell" "$OUT"; check "进入交互式 shell" $? +contains "交互录制" "$OUT"; check "shell 里的命令真的跑了" $? +contains "已把" "$OUT"; check "报告录制的字节数" $? +contains "不自动提问" "$OUT"; check "说明只是记入上下文、不自动提问" $? +contains "历史消息 1 条" "$OUT"; check "会话记入上下文(不含 system)" $? + +echo +echo "== 37b. /shell(全屏程序与重绘空格的记录清洗) ==" +OUT="$(python3 -u tests/shell_screen_check.py "$BIN" "$BASE" 2>&1)" +echo "$OUT" | sed 's/^/ /' +contains "0 项失败" "$OUT"; check "全屏程序记录清洗" $? + +echo +echo "== 38. /usage ==" +OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ + --prompt "随便问问" --prompt "/usage" --wait 1.3 2>&1)" +contains "token 用量" "$OUT"; check "/usage 输出用量" $? +contains "API 调用" "$OUT"; check "报告调用次数" $? +contains "输入 token" "$OUT"; check "报告输入 token" $? +contains "最近一次输入" "$OUT"; check "报告当前上下文大小" $? + +echo +echo "== 39. /back 与 /unback ==" +OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ + --prompt "第一轮" --prompt "第二轮" --prompt "/history" \ + --prompt "/back" --prompt "/history" --prompt "/unback" \ + --prompt "/history" --wait 1.3 2>&1)" +contains "已删除最近一轮" "$OUT"; check "/back 删除最近一轮" $? +contains "已恢复" "$OUT"; check "/unback 恢复" $? + +# /back 之后又开了新对话则不能撤销 +OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ + --prompt "第一轮" --prompt "/back" --prompt "新的话题" \ + --prompt "/unback" --wait 1.3 2>&1)" +contains "无法撤销" "$OUT"; check "开了新对话后 /unback 拒绝" $? + +# 没有可撤销的东西 +OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ + --prompt "/unback" --prompt "/back" --wait 1.2 2>&1)" +contains "没有可撤销的操作" "$OUT"; check "空历史时 /unback 提示" $? +contains "没有可以回退" "$OUT"; check "空历史时 /back 提示" $? + +echo +echo "== 40. /yolo 临时切换确认 ==" +OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ + --extra=--confirm --extra=--no-auto-save \ + --prompt "/yolo" --prompt "跑一下" --prompt "/yolo" --prompt "/exit" --wait 1.3 2>&1)" +contains "确认已关闭" "$OUT"; check "/yolo 关闭确认" $? +contains "确认已开启" "$OUT"; check "/yolo 再次开启" $? +printf '%s' "$OUT" | tr '\r' '\n' | grep -q "继续?" && r=1 || r=0 +check "关闭确认期间不再询问" $r + +echo +echo "== 41. 思考模式与级别 ==" +# 默认:不发 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 消息回传 +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(它走的是另一条解析分支) +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 "非流式路径也回传思考内容" $? + +# 非法值必须在命令行就被拦下 +OUT="$("$BIN" --api-key test --base-url "$BASE" --thinking 乱写 --no-confirm "x" 2>&1)"; r=$? +[ "$r" -ne 0 ]; check "--thinking 非法值被拒绝" $? +contains "on / off / auto" "$OUT"; check "非法值给出可选范围" $? +OUT="$("$BIN" --api-key test --base-url "$BASE" --effort 超级高 --no-confirm "x" 2>&1)"; r=$? +[ "$r" -ne 0 ]; check "--effort 非法值被拒绝" $? + +echo +echo "== 42. /think 运行时切换 ==" +OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ + --extra=--no-confirm --extra=--no-auto-save \ + --prompt "/think" --prompt "/think high" --prompt "/think" \ + --prompt "/think off" --prompt "/think 乱写" --prompt "/think auto" \ + --wait 0.6 2>&1)" +contains "由服务端决定" "$OUT"; check "/think 无参数显示当前设置" $? +contains "思考级别已设为 high" "$OUT"; check "/think LEVEL 设置级别" $? +contains "思考级别:high" "$OUT"; check "级别被记住" $? +contains "关闭(发 thinking.type=disabled)" "$OUT"; check "/think off 关闭思考" $? +contains "无法识别" "$OUT"; check "/think 非法值给出提示" $? + +echo +echo "== 43. 等待动画的两段标签 ==" +OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ + --extra=--no-confirm --extra=--no-auto-save --extra=--thinking --extra=on \ + --prompt "SLOWRESP_TEST 慢一点" --wait 3.2 2>&1)" +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 + +echo +echo "== 44. /meta 元编辑对话历史 ==" +ARCHIVE_DIR="$(dirname "$BIN")/ligent.archive" +rm -rf "$ARCHIVE_DIR" + +# 全都在同一个进程里做完:谱系(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 "第三轮" \ + --prompt "/export" \ + --prompt "/meta" --prompt "META_TEST 删掉第 2 轮" \ + --prompt "/diff" --prompt "/exit" \ + --prompt "/history" --prompt "/export" --prompt "/exit" --wait 1.3 2>&1)" +contains "已导出 8 条消息" "$OUT" +check "建好三段对话并导出(8 条对话消息,不含 system)" $? +contains "已进入 meta 模式" "$OUT"; check "能进入 meta 模式" $? +contains "meta>" "$OUT"; check "提示符换成 meta>" $? +contains "已删除轮 2..2" "$OUT"; check "meta 模型真的改了历史" $? +contains "当前历史 7 条消息" "$OUT"; check "/diff 反映改动后的条数" $? +contains "历史消息 6 条" "$OUT"; check "主对话上下文确实变了" $? +contains "没有改动" "$OUT" && r=1 || r=0 +[ "$r" -eq 0 ]; check "退出时报告本次不是空跑" $? +contains "另起一根" "$OUT"; check "提示会重新存根" $? + +# 新根必须叫 <原名>_m<3位>,并且 parent 记空 +AROOT="$(ls "$ARCHIVE_DIR" | sed 's/\.json$//' | grep -E '^[a-z0-9]{8}$' | head -1)" +[ -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)" +python3 - "$ARCHIVE_DIR/$META_NAME.json" <<'PYEOF' +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 为空)" $? +python3 - "$ARCHIVE_DIR/$META_NAME.json" <<'PYEOF' +import json, sys +d = json.load(open(sys.argv[1], encoding="utf-8")) +msgs = d.get("messages", []) +# 结构必须仍然自洽:带 tool_calls 的 assistant 后面必须紧跟 tool +ok = True +for i, m in enumerate(msgs): + if m.get("role") == "assistant" and m.get("tool_calls"): + if i + 1 >= len(msgs) or msgs[i + 1].get("role") != "tool": + ok = False +sys.exit(0 if ok else 1) +PYEOF +check "删改之后工具调用配对仍然完整" $? + +# meta 模式里的未知命令不该被当成给模型的指令 +# 单引号很关键:双引号里 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)" $? +rm -rf "$ARCHIVE_DIR" + +echo +echo "== 45. 确认提示上的每种回答 ==" +# 重点:在确认提示上按 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。 +OUT="$("$BIN" doctor --api-key test --base-url "$BASE" 2>&1)"; 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 +# 一律说"密钥被拒绝" —— 把用户引到一个根本不存在的问题上。 +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 "密钥正确时如实说接受" $? +OUT="$(env -u LIGENT_API_KEY "$BIN" doctor --api-key bad-key --base-url "$AUTHED" 2>&1)" +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 "不是网络问题" "$OUT"; check "并说明这跟网络无关" $? + +# 404 也不能被说成"密钥没问题":请求根本没走到认证 +OUT="$(env -u LIGENT_API_KEY "$BIN" doctor --api-key good-key \ + --base-url "http://127.0.0.1:$PORT/nope" 2>&1)" +contains "这个地址不存在" "$OUT"; check "404 报地址不对" $? +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)" $? +contains "重定向" "$OUT"; check "识别出端点跳转" $? +contains "example.invalid" "$OUT"; check "报出跳转目标" $? +contains "不自动跟随重定向" "$OUT"; check "解释了为什么不跟随" $? + +echo +echo "== 48. read_image:图片以 base64 内联发出,历史里只留备份引用 ==" +# 自己造测试图:一张最小的合法 PNG,和一个"扩展名是 .png、内容不是图片"的文件, +# 以及一张故意超过内联上限的大图。不能依赖 build/ 里已有的东西 —— +# make clean 会把它整个删掉。 +mkdir -p build/tmp +python3 - <<'PYEOF' +import struct, zlib +def chunk(t, d): + c = t + d + return struct.pack(">I", len(d)) + c + struct.pack(">I", zlib.crc32(c) & 0xffffffff) +png = (b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(b"\x00\xff\x00\x00")) + + 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 上限就会触发体检 +with open("build/tmp/huge.png", "wb") as f: + f.write(png) + f.truncate(25 * 1024 * 1024) +PYEOF +PNG="build/tmp/dot.png" +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_TEST 看一下 $PNG" --prompt "/history" \ + --prompt "/export" --prompt "/exit" --wait 1.6 2>&1)" +contains "已读入图片" "$OUT"; check "read_image 读入成功" $? +contains "base64" "$OUT"; check "工具结果说明了会以 base64 内联" $? + +# 关键:模型在下一轮**真的看到了**内联图片(图片只能出现在 user 消息里, +# 而 read_image 是工具、结果只能是字符串,所以中间补了一条附件消息) +contains "IMAGE[data:image/png:" "$OUT"; check "模型收到了内联 base64 图片" $? +contains "历史消息 5 条" "$OUT" +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 份)" $? +python3 - "$ARCHIVE_DIR" <<'PYEOF' +import glob, os, sys +names = [os.path.basename(p) for p in glob.glob(sys.argv[1] + "/images/*.png")] +ok = names and all(len(n[:-4].split("_")[-1]) == 4 for n in names) +sys.exit(0 if ok else 1) +PYEOF +check "备份名形如 <时间戳>_<4位>." $? + +# 这是这一版最要紧的不变量:**归档里不能有图片字节**。 +# 请求里发的是 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]" 这种对收到内容的描述。真正该消失的是那串载荷。 +payload = base64.b64encode(open(png_path, "rb").read()).decode() +if payload in raw: + print("归档里出现了图片 base64 载荷") + sys.exit(1) +if "data:image/png;base64," in raw: + print("归档里出现了 data URL") + sys.exit(1) + +# 同时要确认引用确实在 —— 别把"没存图"和"压根没存"搞混 +refs = [] +def walk(o): + if isinstance(o, dict): + if o.get("type") == "ligent_image": + refs.append(o.get("backup")) + for v in o.values(): + walk(v) + elif isinstance(o, list): + for v in o: + walk(v) + +walk(json.loads(raw)) +ok = len(refs) == 1 and (refs[0] or "").endswith(".png") +if not ok: + print("归档里的引用不对:", refs) +sys.exit(0 if ok else 1) +PYEOF +check "归档里只有对备份的引用、没有图片字节" $? +rm -rf "$ARCHIVE_DIR" + +echo +echo "== 49. read_image:服务端拒图时把图片摘掉重试,而不是每轮都失败 ==" +rm -rf "$ARCHIVE_DIR" +# 三轮必须在**同一个** 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 先看一张图" \ + --prompt "IMAGE_TEST IMAGE_REJECT_TEST 再看一次" \ + --prompt "IMAGE_TEST 第三轮" \ + --prompt "/exit" --wait 2.4 2>&1)" +contains "服务端拒绝了请求里的图片" "$OUT"; check "识别出是图片被拒" $? +contains "从对话里移除后重试" "$OUT"; check "说明了是移除后重试" $? +# 重试那一轮请求里就不该再有图片了 +contains "IMAGE[none]" "$OUT"; check "重试时请求里确实没有图片了" $? +# 而且模型**真的收到了**那句话(mock 把 user 文本回显出来了) +contains "不符合 API 所需的格式标准" "$OUT"; check "模型收到了"请规范化图片"的说明" $? +contains "请规范化图片" "$OUT"; check "说明里给了下一步动作" $? + +# 降级是**就地**的:同一会话的下一轮也不该再有图片, +# 否则每轮都会重新踩同一个 4xx,用户只能 /reset。 +LAST_IMG="$(printf '%s\n' "$OUT" | grep -o 'IMAGE\[[^]]*\]' | tail -1)" +[ "$LAST_IMG" = "IMAGE[none]" ]; check "下一轮也是干净的($LAST_IMG)" $? + +# 备份不该因为被拒而多出来(被拒的是服务端,图片本身是好的,仍只有 1 份) +N="$(ls "$ARCHIVE_DIR"/images/*.png 2>/dev/null | wc -l)" +[ "$N" -eq 1 ]; check "备份没有被重复写($N 份)" $? +rm -rf "$ARCHIVE_DIR" + +echo "== 50. read_image:不认识的格式、以及过大的图,都要在本地拒绝 ==" +rm -rf "$ARCHIVE_DIR" +OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ + --extra=--no-confirm --extra=--no-auto-save --extra=--no-history \ + --prompt "IMAGE_BAD_TEST 读一个假图片" --prompt "/exit" --wait 1.3 2>&1)" +contains "不认识的图片格式" "$OUT"; check "按内容判断并拒绝(扩展名是 .png 也没用)" $? + +# 过大的图:本地当场拒,不备份、不发请求 +OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ + --extra=--no-confirm --extra=--no-auto-save --extra=--no-history \ + --prompt "IMAGE_OVERSIZE_TEST 读一张大图" --prompt "/exit" --wait 1.5 2>&1)" +contains "图片太大" "$OUT"; check "过大的图在本地就被拒掉" $? +contains "内联上限" "$OUT"; check "说明了上限是多少" $? +N="$(ls "$ARCHIVE_DIR"/images/*.png 2>/dev/null | wc -l)" +[ "$N" -eq 0 ]; check "被拒的图不备份($N 份)" $? +rm -rf "$ARCHIVE_DIR" + +echo "== 51. 能力探测序列:按模型能力决定暴露哪些工具 ==" +# 这一段自己把 LIGENT_PROBE 打开(脚本全局是关的,理由见文件开头)。 +PROBE="LIGENT_PROBE=1" +run_probe() { # run_probe <模型名> <额外参数...> + local model="$1"; shift + 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="$model" "$@" 2>&1 +} + +# --- 正常模型:探测跑过,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 "日志里也说明工具调用能力" $? + +# --- 模型明确不收图片:read_image 必须从工具表里消失 --- +OUT="$(run_probe no-vision-model --prompt "TOOLS_ECHO_TEST 看看工具表" \ + --prompt "/tools" --prompt "/exit" --wait 2.0)" +contains "多模态(图片输入)不支持(已隐藏 read_image)" "$OUT" +check "如实说明了图片能力与因此做的动作" $? +contains "TOOLS[" "$OUT"; check "隐藏之后请求仍能正常发出" $? +if printf '%s\n' "$OUT" | grep -q 'TOOLS\[[^]]*read_image'; then + check "read_image 不该出现在工具表里" 1 +else + check "read_image 确实没发给服务端" 0 +fi +printf '%s\n' "$OUT" | grep -q 'TOOLS\[[^]]*run_command' +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" +check "如实说明了工具调用能力与动作" $? +contains "TOOLS[]" "$OUT"; check "工具表确实是空的" $? + +# --- 探测结论不确定:一律按"支持"处理,宁可多暴露 --- +# broken-probe 模型只在**探测请求**上回 401,真实请求照常。 +OUT="$(run_probe broken-probe-model --prompt "TOOLS_ECHO_TEST 看看工具表" --prompt "/exit" --wait 2.0)" +contains "多模态(图片输入)未测" "$OUT"; check "探不出来时如实说明是未测" $? +contains "没测出来的一律按「支持」处理" "$OUT"; check "说明按支持处理" $? +printf '%s\n' "$OUT" | grep -q 'TOOLS\[[^]]*read_image' +check "结论不确定时不藏工具(宁可多暴露)" $? + +# --- 关掉探测:一个试探请求都不发,工具表保持完整 --- +OUT="$(env LIGENT_PROBE=0 python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ + --extra=--no-confirm --extra=--no-auto-save --extra=--no-history \ + --prompt "TOOLS_ECHO_TEST 看看工具表" --prompt "/exit" --wait 1.6 2>&1)" +if printf '%s\n' "$OUT" | grep -q '检测模型能力\|模型能力:'; then + check "--no-probe 时既不该发探测请求,也不该有结论日志" 1 +else + 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 + local a=() + for line in "$@"; do a+=(--prompt "$line"); done + python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ + --extra=--no-confirm --extra=--no-auto-save --extra=--no-history \ + --prompt "/model" "${a[@]}" --prompt "/exit" --wait 1.4 2>&1 +} + +# --- 列表本身:编号、当前模型被标出、去重与坏条目被跳过 --- +OUT="$(run_model "2")" +contains "正在获取模型列表($BASE/models)" "$OUT"; check "用的是标准的 /models 路径" $? +contains "服务端列出了 3 个模型" "$OUT" +check "去重 + 跳过坏条目后剩 3 个(mock 塞了重复项和一个缺 id 的)" $? +contains "1) deepseek-flash <- 当前" "$OUT"; check "当前模型被标出来" $? +contains "2) alpha-model" "$OUT"; check "列表带编号" $? +contains "选择序号,或直接输入模型名" "$OUT"; check "输入行的提示说清了两种输入" $? + +# --- 按序号选 --- +contains "模型已切换为 alpha-model" "$OUT"; check "填序号能选中对应模型" $? + +# --- 直接写一个**列表里没有**的名字:这是内部模型的入口,必须允许 --- +OUT="$(run_model "internal-model-v9")" +contains "模型已切换为 internal-model-v9" "$OUT" +check "能直接写列表里没有的模型名(内部模型走这条)" $? +if printf '%s\n' "$OUT" | grep -q "找不到"; then + check "不该因为不在列表里就报错" 1 +else + check "不因为不在列表里就拒绝" 0 +fi + +# --- 直接回车 = 取消 --- +OUT="$(run_model "")" +contains "已取消" "$OUT"; check "空输入即取消" $? +if printf '%s\n' "$OUT" | grep -q "模型已切换"; then + check "取消之后不该切换" 1 +else + check "取消之后什么都没改" 0 +fi + +# --- 序号越界:报错并取消,不能当成模型名去用 --- +OUT="$(run_model "9")" +contains "序号 9 超出范围" "$OUT"; check "序号越界要报出来" $? +if printf '%s\n' "$OUT" | grep -q "模型已切换"; then + check "越界之后不该切换" 1 +else + check "越界之后没有切换" 0 +fi + +# --- 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)" +contains "已取消" "$OUT"; check "Ctrl-C 也能取消" $? + +# --- /model NAME 这条老路不能被新功能挡住 --- +OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ + --extra=--no-confirm --extra=--no-auto-save --extra=--no-history \ + --prompt "/model gamma-model" --prompt "/exit" --wait 1.2 2>&1)" +contains "模型已切换为 gamma-model" "$OUT"; check "/model NAME 仍然直接生效" $? +if printf '%s\n' "$OUT" | grep -q "正在获取模型列表"; then + check "带参数时不该去拉列表" 1 +else + check "带参数时不去拉列表" 0 +fi + +# --- 服务端返回空列表:说清楚,并提示还能直接指定名字 --- +printf 'empty' > build/tmp/models_mode +OUT="$(run_model "1")" +rm -f build/tmp/models_mode +contains "拿不到模型列表" "$OUT"; check "空列表时如实报错" $? +contains "空的模型列表" "$OUT"; check "说清是空列表而不是网络故障" $? +contains "/model <名字>" "$OUT"; check "提示还能直接指定名字" $? + +# --- 服务端返回的不是 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 +contains "服务端列出了 2 个模型" "$OUT"; check "裸数组形状也能解析" $? +contains "模型已切换为 beta-model" "$OUT"; check "裸数组里也能按序号选" $? + +echo "== 53. 一条 assistant 里两个工具调用 + 图片:附件不能插在 tool 消息中间 ==" +# 回归用例。曾经的写法是"每推完一条 tool 消息就把图片附件推出去",于是 +# assistant 带两个调用、第一个是 read_image 时,历史变成 +# assistant(tool_calls) / tool(读图) / user(附件) / tool(另一个) +# 中间那条 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。 +# +# 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 \ + --prompt "IMAGE_PAIR_TEST 同时读图并跑个命令" \ + --prompt "/exit" --wait 2.0 2>&1)" +contains "pair-ok" "$OUT"; check "两个工具调用都被执行" $? +contains "PAIR[img=1 tool=2]" "$OUT" +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 +fi +rm -rf "$ARCHIVE_DIR" + +# 顺带验一下修复路径本身:mock 校验会让坏历史当场 400,所以这里换个角度 —— +# 直接看 agent_repair_tool_pairing 的单元测试(test_agent)。 + +echo "== 54. /back 报的条数不含 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 "/history 也报 0 条(与 /back 口径一致)" $? +contains "历史回到 4 条" "$OUT"; check "/unback 之后回到 4 条" $? +contains "不含 system" "$OUT"; check "说清楚这个口径不含 system 提示词" $? + +echo "== 55. /include:把文件附带进下一条消息 ==" +mkdir -p build/tmp/inc/sub +printf 'int main(void){return 0;}\n' > build/tmp/inc/a.c +printf 'hello text\n' > build/tmp/inc/b.txt +printf 'deep\n' > build/tmp/inc/sub/c.c +printf 'this is not an image' > build/tmp/inc/fake.png +python3 - <<'PYEOF' +import struct, zlib +def chunk(t, d): + c = t + d + return struct.pack(">I", len(d)) + c + struct.pack(">I", zlib.crc32(c) & 0xffffffff) +png = (b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(b"\x00\xff\x00\x00")) + + chunk(b"IEND", b"")) +open("build/tmp/inc/pic.png", "wb").write(png) +PYEOF +rm -rf "$ARCHIVE_DIR" +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 +} + +# --- glob 参数:命中几个就附带几个 --- +OUT="$(inc --prompt "/include *.c" --prompt "INCLUDE_TEST 看看" --prompt "/exit")" +contains "已附带 1 个文件" "$OUT"; check "glob 展开并暂存" $? +contains "INC[text=2 img=0 files=a.c]" "$OUT" +check "模型收到了用户的话 + 一个文件(2 个文本块)" $? +contains "下一条消息会带上它们" "$OUT"; check "说明了附带会在下一条消息生效" $? + +# --- 多个 glob / 多次附带会累加 --- +OUT="$(inc --prompt "/include *.c" --prompt "/include *.txt" \ + --prompt "INCLUDE_TEST 两个" --prompt "/exit")" +contains "INC[text=3 img=0" "$OUT"; check "两次附带的文件都发出去了" $? + +# --- 图片:存备份、以 ligent_image 引用参与请求 --- +OUT="$(inc --prompt "/include pic.png" --prompt "INCLUDE_TEST 看图" --prompt "/export" --prompt "/exit")" +contains "〔图片〕" "$OUT"; check "识别出是图片并标注" $? +contains "INC[text=1 img=1" "$OUT"; check "图片以 image_url 内联发出" $? +# 备份必须落在**二进制所在目录**,不是当前工作目录 —— 这一条是有来历的: +# image_set_bindir() 曾经从来没被调用过,于是备份跟着 cwd 走,/cd 到别处 +# 带图就会写到那儿,之后从别处发消息会因为找不到备份把图降级掉。 +# 测试一直从二进制所在目录跑,"." 恰好等于正确值,所以一直没暴露。 +ls "$ARCHIVE_DIR"/images/*.png >/dev/null 2>&1 +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 同一条不变量) +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() +if base64.b64encode(open(sys.argv[2], "rb").read()).decode() in raw: + sys.exit(1) +sys.exit(0 if "ligent_image" in raw else 1) +PYEOF +check "归档里只有引用、没有图片字节" $? +rm -rf "$ARCHIVE_DIR" + +# --- 内容不是图片的 .png 按文本附带(认内容不认扩展名) --- +OUT="$(inc --prompt "/include fake.png" --prompt "INCLUDE_TEST 假的" --prompt "/exit")" +contains "INC[text=2 img=0 files=fake.png]" "$OUT" +check "按内容判断:不是图片就当文本带" $? + +# --- 二进制被跳过 --- +printf '\x00\x01\x02binary' > build/tmp/inc/bin.dat +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 排序后),进去再回车结束; +# 然后回头选一个文件。 +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 "选中后有回执" $? + +# --- clear --- +OUT="$(inc --prompt "/include *.c" --prompt "/include clear" \ + --prompt "INCLUDE_TEST 清空后" --prompt "/exit")" +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 \ + --prompt "/cd build/tmp/inc" \ + --prompt "/include a.c" --prompt "/include pic.png" \ + --prompt "INCLUDE_TEST 混合" --prompt "/exit" --wait 1.8 2>&1)" +contains "当前模型不支持图片输入" "$OUT"; check "如实说明为什么跳过图片" $? +contains "INC[text=2 img=0 files=a.c]" "$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=probe-model \ + --prompt "/cd build/tmp/inc" \ + --prompt "/include pic.png" --prompt "INCLUDE_TEST 看图" \ + --prompt "/exit" --wait 1.8 2>&1)" +contains "INC[text=1 img=1" "$OUT"; check "支持视觉时图片正常附带" $? +rm -rf "$ARCHIVE_DIR" + +echo "== 56. run_command elevated:经 sudo 提权执行单条命令 ==" +# 全程用**替身 sudo**:不需要真权限,也永远不会碰到谁的真实密码。 +# 它把 argv 记下来,于是"ligent 到底怎么调 sudo 的"能断言到参数级。 +FAKEBIN="$ROOT/build/tmp/fakebin" +mkdir -p "$FAKEBIN" +cat > "$FAKEBIN/sudo" <<'SUDOEOF' +#!/bin/sh +printf '%s\n' "$*" >> "$FAKE_SUDO_LOG" +[ -n "$FAKE_SUDO_REJECT" ] && { echo "Sorry, try again." >&2; exit 1; } +case "$1" in -u) shift 2;; esac +[ "$1" = "--" ] && shift +exec "$@" +SUDOEOF +chmod +x "$FAKEBIN/sudo" +SULOG="$ROOT/build/tmp/elev_argv.log" +mkdir -p "$ROOT/build/tmp" + +elev() { # elev <额外环境变量...> -- + rm -f "$SULOG" + PATH="$FAKEBIN:$PATH" FAKE_SUDO_LOG="$SULOG" "$@" 2>&1 +} + +# --- 模型请求提权:命令经 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 "ELEV_SAW[提权后的输出" "$OUT"; check "输出回填给了模型" $? +grep -q '^-u root -- /bin/sh -c echo 提权后的输出$' "$SULOG" +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 能问出来" $? + +# --- 确认提示必须先把要跑的东西摆出来 --- +OUT="$(elev python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \ + --extra=--confirm --extra=--no-auto-save --extra=--no-history \ + --prompt "ELEVATED_TEST 提权跑个命令" --prompt "y" --prompt "/exit" --wait 1.8)" +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 "明确叫它别反复重试" $? + +# --- 没有控制终端时:直接拒绝,绝不发起一个等不到输入的进程 --- +# 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)" $? + # 而且它必须是**立刻**失败,不能卡在那儿等密码 + contains "没有可用的控制终端" "$OUT" || true +else + echo " (跳过:没有 setsid)" +fi + +echo "== 57. 离线回放:把 /log 录下来的真实会话当夹具 ==" +# 这是 /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 +contains "解析自检通过" "$(cat build/tmp/replay_check.txt)"; check "回放器能解析录制" $? +contains "含 tool_calls" "$(cat build/tmp/replay_check.txt)" +check "识别出录制里那个被切碎的 tool_calls" $? + +RLOG="$ROOT/build/tmp/replay_server.log" +replay_serve() { # replay_serve <端口> <夹具> [--strict] + : > "$RLOG" + python3 tests/replay.py --serve "$1" "$2" "${@:3}" >>"$RLOG" 2>&1 & + RPID=$! + for _ in $(seq 1 40); do + python3 -c " +import socket,sys +s=socket.socket(); s.settimeout(0.2) +sys.exit(0 if s.connect_ex(('127.0.0.1',$1))==0 else 1)" && break + sleep 0.1 + done +} + +# --- 用离线服务端跑**真的** ligent:它应当沿着录制里的路径走完 --- +replay_serve 8899 "$FIX" --strict +OUT="$("$BIN" --api-key test --base-url "http://127.0.0.1:8899/v1" --no-color \ + --no-confirm --no-auto-save --no-history --no-probe "please run it" 2>&1)" +RC=$? +kill $RPID 2>/dev/null; wait $RPID 2>/dev/null +[ "$RC" -eq 0 ]; check "在录制上跑完一轮(退出码 $RC)" $? +contains "hello-from-tool" "$OUT"; check "工具调用从被切碎的 SSE 里正确重组" $? +contains "FINAL:" "$OUT"; check "拿到了录制里的最终回答" $? +# strict:请求的**结构**必须和录制时一致 —— 这才是"报文形状没变"的断言 +if grep -q "结构" "$RLOG"; then + check "请求结构与录制一致" 1 + grep -A2 "结构" "$RLOG" | head -4 +else + check "请求结构与录制一致(strict 没报差异)" 0 +fi + +# --- 错误体:录下来的 400 也要能被原样解析出可读信息 --- +replay_serve 8898 "$ROOT/tests/fixtures/error.log" --strict +OUT="$("$BIN" --api-key test --base-url "http://127.0.0.1:8898/v1" --no-color \ + --no-confirm --no-auto-save --no-history --no-probe --retries 0 \ + "ERROR_TEST 制造一个服务端错误" 2>&1)" +RC=$? +kill $RPID 2>/dev/null; wait $RPID 2>/dev/null +[ "$RC" -ne 0 ]; check "录下来的 400 仍然被判为失败(退出码 $RC)" $? +contains "mock 模拟的服务端错误" "$OUT"; check "错误体从真实字节里解析出来" $? + +# --- 请求比录制多时,回放器要明确报出来(而不是静静回错东西)--- +replay_serve 8897 "$ROOT/tests/fixtures/error.log" --strict +# 录制里只有 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 \ + "再来一次" >/dev/null 2>&1 +done +kill $RPID 2>/dev/null; wait $RPID 2>/dev/null +contains "没有更多响应" "$(cat "$RLOG")" +check "录制用完后明确报出来(而不是静静回错东西)" $? + +echo "== 58. 工具输出里混进非法 UTF-8:不能让之后每个请求都被拒 ==" +# 坏字节的来源很日常:某条命令在非 UTF-8 locale 下打出来的路径、被截断的多 +# 字节字符、二进制文件里混的一小段。它会跟着工具结果进对话历史,再被序列化 +# 进请求体 —— 序列化器若原样吐出,DeepSeek 回 +# 400 ... invalid unicode code point +# 而且因为坏数据已经在历史里了,**之后每一次请求都会失败**,除非丢掉整轮对话。 +# 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)" +echo "$OUT" | sed 's/^/ /' +case "$OUT" in + *"invalid unicode code point"*) rc=1;; # 服务端把请求拒了 + *"替换字符=0"*) rc=1;; # 一个都没替换 —— 坏字节原样发出去了 + *) rc=0;; +esac +check "请求体是合法 UTF-8:服务端没拒,且坏字节被换成了 U+FFFD" $rc +contains "读完了" "$OUT"; check "这一轮正常跑完" $? + +echo "== 59. 界面语言:--lang / LIGENT_LANG / LC_* ==" +# 前面几十段都跑在顶层锁死的 zh 下(见文件开头的 export)。这一段专门走英文, +# 因为"翻译漏了"和"翻译错了"只有真的把每条路径跑一遍才看得出来。 +OUT="$(LIGENT_LANG=en "$BIN" --help 2>&1)" +contains "Usage:" "$OUT"; check "--help 出英文" $? +contains "--lang LANG" "$OUT"; check "帮助里列出 --lang" $? +contains "ligent doctor" "$OUT"; check "英文帮助里有子命令" $? + +OUT="$("$BIN" --lang en --nonsense 2>&1)"; r=$? +[ "$r" -eq 2 ]; check "英文下用法错误仍是退出码 2" $? +contains "unknown option" "$OUT"; check "错误信息是英文" $? + +OUT="$("$BIN" --lang zh --nonsense 2>&1)" +contains "未知选项" "$OUT"; check "--lang zh 覆盖环境变量" $? + +# 语言也要能在配置里看见 —— 否则"为什么它是英文"要猜 +OUT="$(LIGENT_LANG=en "$BIN" --lang=en --print-config 2>&1)" +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)" $? + +# 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)回退英文" $? + +OUT="$("$BIN" --lang 乱写 "x" 2>&1)"; r=$? +[ "$r" -eq 2 ]; check "非法 --lang 被拒绝" $? +contains "只接受 auto / en / zh" "$OUT"; check "拒绝时说清可选值" $? + +# doctor 是最长的一段界面文案,分节括号也要跟着语言走 +OUT="$(LIGENT_LANG=en "$BIN" doctor --offline 2>&1)" +# 注意:方括号在 case 模式里是字符类,所以这里只断言括号里的文字 +contains "binary & config location" "$OUT"; check "英文分节标题" $? +contains "Bottom line:" "$OUT"; check "英文结论行" $? +OUT="$(LIGENT_LANG=zh "$BIN" doctor --offline 2>&1)" +contains "【二进制与配置位置】" "$OUT"; check "中文分节用【】" $? +contains "结论:" "$OUT"; check "中文结论行" $? + +echo +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 = 不限是这条接口的约定) +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 + check "到顶后不应该再继续跑完" 1 "$OUT" +else + check "到顶后不应该再继续跑完" 0 +fi + +# 配置文件里写 max_steps 同样生效 +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 让人猜 +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:既刷屏,又看不出到底要改哪里。现在摘要只有 +# "哪个文件、哪几行"(路径带引号、行号用方括号),改动内容用带行号的 -/+ 两段摆开。 +mkprobe() { + python3 -c " +with open('$ROOT/build/tmp/edit_probe.txt', 'w') as f: + f.write('head\n' + ''.join('OLD%d\n' % i for i in range(1, 13)) + 'tail\n') +" +} + +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)" $? +contains " │ + 2 NEW" "$OUT" +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 +check "参数 JSON 一个字节都不上屏" $r +contains "DONE[" "$OUT"; check "工具确实执行了" $? + +echo +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 \ + --prompt "EDITTEST_OK" --prompt "" --wait 1.2 2>&1)" +contains '修改: "build/tmp/edit_probe.txt" [2]' "$OUT" +check "确认提示里给出路径与行号" $? +contains "- 2 OLD1" "$OUT"; check "旧内容带行号" $? +contains "- 3 OLD2" "$OUT"; check "行号跟着走" $? +contains "+ 2 NEW" "$OUT"; check "新内容带行号" $? +contains "继续?(Y/N/Always/neVer)" "$OUT"; check "仍然问四种回答" $? +ORDER_OUT="$OUT" python3 - <<'PY' +import os +t = os.environ["ORDER_OUT"] +i_diff = t.find("- 2 OLD1") +i_ask = t.find("继续?") +raise SystemExit(0 if 0 <= i_diff < i_ask else 1) +PY +check "顺序:先摆 diff、再提问" $? +contains "替换:" "$OUT" && r=1 || r=0 +check "不再出现「替换: 旧 -> 新」那一行" $r +# 确认过的改动不该在执行后再摆一遍 diff(否则像是要改两次) +contains "已在 build/tmp/edit_probe.txt 第 2 行替换 1 处" "$OUT" +check "确认之后只给一句回执,不重复 diff" $? + +echo +echo "== 63. edit_file 失败时:同样只有摘要 + 错误 ==" +mkprobe +OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "EDITTEST_FAIL" 2>&1)" +contains '{/} edit_file "build/tmp/edit_probe.txt" [999]' "$OUT" +check "失败时也先给一句人话摘要" $? +contains "line=999 越界" "$OUT"; check "错误信息指向具体行号" $? +contains '"old_string"' "$OUT" && r=1 || r=0 +check "失败时同样不抖 JSON" $r + +echo +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 次" $? +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)" +contains "/probe" "$OUT"; check "--help 的 REPL 命令列表里也有" $? + +echo +echo "== 65. /history:一行摘要 + 完整对话 ==" +# 摘要行报的是"不含 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 "run_command" "$OUT"; check "工具调用与结果一并列出" $? +contains "tool_call_id=" "$OUT"; check "标出工具结果回应的是哪次调用" $? + +echo +echo "通过 $PASS 项,失败 $FAIL 项" +[ "$FAIL" -eq 0 ] diff --git a/tests/shell_screen_check.py b/tests/shell_screen_check.py new file mode 100644 index 0000000..226ec69 --- /dev/null +++ b/tests/shell_screen_check.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""shell_screen_check.py - 验证 /shell 记录里对全屏程序的处理 + +交互式 shell 的原始输出里混着转义序列、重绘空格和全屏程序的整屏画面。 +不清洗的话模型看到的就是几百个空格加一串光标定位;清过头又会把命令和 +状态行一起吃掉。这里用 printf 造出确定的输入来钉住行为: + + 1. 备用屏幕(vim/less/top 走这条路)——画面必须省略,只留首尾标记; + 2. 成片空格(nano/top 靠空格重绘)——必须压掉,但短缩进要原样保留; + 3. 大输出里夹着的备用屏幕——跨 read 也要能认出来。 + +用法: + shell_screen_check.py +""" +import glob +import json +import os +import pty +import re +import select +import shutil +import sys +import time + +ALT_TEXT = "备用屏幕里的机密内容" +NORMAL_TEXT = "正常输出" + + +def drive(binary, base, commands, wait=1.6, term="xterm-256color"): + """在 pty 里跑 ligent,进 /shell 执行 commands,导出后返回记录文本。 + + term=None 表示不设 TERM,用来观察退化行为。""" + saved = os.environ.copy() + os.environ["LIGENT_API_KEY"] = "sk-test" + # 没有 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) + + pid, fd = pty.fork() + if pid == 0: + os.execv(binary, [binary, "--base-url", base, "--no-confirm", "--auto-save"]) + os._exit(127) + + # 行编辑器每次回到提示符都会打开括号粘贴 —— 用它当"轮到输入了"的信号。 + # 只按"输出安静"等是不行的:等待动画每 250ms 画一帧,pty 永远不会安静 + # 超过 idle,于是每次都要白等满整个额度。 + PROMPT_READY = b"\x1b[?2004h" + ready_seen = 0 + tail = b"" + + def note(chunk): + nonlocal tail, ready_seen + buf = tail + chunk + ready_seen += buf.count(PROMPT_READY) + tail = buf[-(len(PROMPT_READY) - 1):] + + def drain(seconds, idle=0.4, until_ready=False): + want = ready_seen + 1 if until_ready else None + end = time.time() + seconds + last = time.time() + while time.time() < end: + r, _, _ = select.select([fd], [], [], 0.02) + if r: + try: + chunk = os.read(fd, 65536) + except OSError: + return + if not chunk: + return + note(chunk) + last = time.time() + if want is not None and ready_seen >= want: + return + elif time.time() - last > idle: + return + + try: + drain(1.5, until_ready=True) # 等横幅画完、回到提示符 + for line in ["/shell"] + commands + ["exit", "/export", "/exit"]: + os.write(fd, line.encode("utf-8") + b"\n") + drain(wait, until_ready=True) + finally: + for fn in (lambda: os.close(fd), lambda: os.waitpid(pid, 0)): + try: + fn() + except OSError: + pass + os.environ.clear() + os.environ.update(saved) + + bindir = os.path.dirname(os.path.abspath(binary)) + files = sorted(glob.glob(os.path.join(bindir, "ligent.archive", "*.json"))) + if not files: + return None + with open(files[0], encoding="utf-8") as fh: + data = json.load(fh) + users = [m["content"] for m in data["messages"] if m["role"] == "user"] + return users[-1] if users else "" + + +def between_markers(rec, start="[全屏程序运行中,画面内容已省略]", end="[全屏程序结束]"): + """取出"全屏程序"首尾标记之间的内容。 + + 命令回显必然把输入里的字面文本也录进去,所以不能直接断言"机密文本不在 + 记录里"——只能断言它没出现在真正被省略的那段区间内。""" + parts = rec.split(start) + if len(parts) < 2: + return None + return parts[1].split(end)[0] + + +def main(): + if len(sys.argv) < 3: + print("用法: shell_screen_check.py <二进制> ") + return 2 + binary, base = sys.argv[1], sys.argv[2] + bindir = os.path.dirname(os.path.abspath(binary)) + archive = os.path.join(bindir, "ligent.archive") + + fails = 0 + total = 0 + + def check(name, ok, detail=""): + nonlocal fails, total + total += 1 + if ok: + print(" ✓ %s" % name) + else: + fails += 1 + print(" ✗ %s%s" % (name, (" " + detail) if detail else "")) + + # ---- 1. 备用屏幕:画面省略,只留首尾标记 ---- + print("备用屏幕(vim/less 路径)") + rec = drive(binary, base, [ + r"printf '\033[?1049h" + ALT_TEXT + r"\033[?1049l'", + "echo " + NORMAL_TEXT, + ]) + if rec is None: + check("拿到记录", False, "没有生成归档") + return 1 + check("拿到记录", True) + body = between_markers(rec) + check("首尾标记成对出现", body is not None, "没有找到完整的一对标记") + check("全屏区间内没有画面内容", body is not None and ALT_TEXT not in body, + "机密内容漏出来了") + check("留下开始标记", "全屏程序运行中" in rec) + check("留下结束标记", "全屏程序结束" in rec) + check("标记之外的输出还在", NORMAL_TEXT in rec) + check("记录里没有裸转义符", "\x1b" not in rec) + + # ---- 2. 成片空格:压掉,但缩进保留 ---- + print("成片空格(nano/top 重绘路径)") + rec = drive(binary, base, [ + r"printf 'A'; printf '%0.s ' $(seq 1 200); printf 'B\n'", + r"printf ' 缩进四个空格\n'", + ]) + if rec is None: + check("拿到记录", False, "没有生成归档") + return 1 + check("拿到记录", True) + check("200 个空格被压缩", not re.search(r" {9,}", rec), "还留着成片空白") + check("压缩后仍保留 A 和 B", "A" in rec and "B" in rec) + check("短缩进原样保留", " 缩进四个空格" in rec, "缩进被误伤") + blank = sum(1 for ch in rec if ch in " \n\t") / max(1, len(rec)) + check("空白占比低于 40%", blank < 0.40, "实际 %.0f%%" % (100 * blank)) + + # ---- 3. 大输出里夹着的备用屏幕 ---- + print("大输出(跨 read)里的备用屏幕") + rec = drive(binary, base, [ + "head -c 8000 /dev/zero | tr '\\0' 'x'; printf '\\033[?1049h" + + ALT_TEXT + "\\033[?1049l'", + ]) + if rec is None: + check("拿到记录", False, "没有生成归档") + return 1 + check("拿到记录", True) + body = between_markers(rec) + check("跨块后仍认得出备用屏幕", body is not None and ALT_TEXT not in body, + "切断后漏了画面") + check("跨块标记成对出现", body is not None) + + # ---- 4. 同一个会话里进两次全屏,标记必须成对 ---- + print("连续两次进入全屏") + rec = drive(binary, base, [ + r"printf '\033[?1049hAAA\033[?1049l'; printf '\033[?1049hBBB\033[?1049l'", + ]) + if rec is None: + check("拿到记录", False, "没有生成归档") + return 1 + check("拿到记录", True) + check("两次开始标记", rec.count("[全屏程序运行中,画面内容已省略]") == 2, + "实际 %d 个" % rec.count("[全屏程序运行中,画面内容已省略]")) + check("两次结束标记", rec.count("[全屏程序结束]") == 2, + "实际 %d 个" % rec.count("[全屏程序结束]")) + + # ---- 5. TERM 退化时补一个像样的值 ---- + # TERM=dumb 时 nano 会放弃备用屏幕、改成逐行重绘,整段忽略就失效了。 + # 而 ligent 自己的 REPL 就在用光标定位,说明终端并非真的 dumb。 + for bad in ("dumb", None): + label = "TERM=%s" % (bad if bad else "(未设置)") + print("退化的 %s" % label) + rec = drive(binary, base, ["echo TERM_IS=$TERM"], term=bad) + if rec is None: + check("拿到记录", False, "没有生成归档") + continue + check("%s 时补上可用 TERM" % label, "TERM_IS=xterm-256color" in rec, + "实际记录:%s" % rec.strip().replace("\n", " | ")[-80:]) + + shutil.rmtree(archive, ignore_errors=True) + print("\n%d 项检查,%d 项失败" % (total, fails)) + return 1 if fails else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_agent.c b/tests/test_agent.c new file mode 100644 index 0000000..ca86634 --- /dev/null +++ b/tests/test_agent.c @@ -0,0 +1,433 @@ +/* test_agent.c - agent 上下文管理(不涉及网络) */ +#include "agent.h" +#include "json.h" +#include "util.h" + +#include +#include +#include + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ + } while (0) + +static size_t count_role(const Agent *a, const char *role) +{ + size_t n = 0; + for (size_t i = 0; i < json_array_size(a->messages); i++) { + const char *r = json_get_string(json_array_get(a->messages, i), "role"); + if (r && strcmp(r, role) == 0) n++; + } + return n; +} + +/* ------------------------------------------------------------------ */ +/* 上下文超限的自动恢复 */ +/* ------------------------------------------------------------------ */ + +static void add_msg(Agent *a, const char *role, const char *content) +{ + JsonValue *m = json_new_object(); + json_object_set_string(m, "role", role); + json_object_set_string(m, "content", content); + json_array_push(a->messages, m); +} + +/* 造一条 assistant 带 tool_calls 的消息 */ +static void add_assistant_tool_call(Agent *a, const char *id, const char *name) +{ + JsonValue *m = json_new_object(); + json_object_set_string(m, "role", "assistant"); + json_object_set_null(m, "content"); + JsonValue *tcs = json_new_array(); + JsonValue *tc = json_new_object(); + json_object_set_string(tc, "id", id); + json_object_set_string(tc, "type", "function"); + JsonValue *fn = json_new_object(); + json_object_set_string(fn, "name", name); + json_object_set_string(fn, "arguments", "{}"); + json_object_set(tc, "function", fn); + json_array_push(tcs, tc); + json_object_set(m, "tool_calls", tcs); + json_array_push(a->messages, m); +} + +static void add_tool_result(Agent *a, const char *id, const char *content) +{ + JsonValue *m = json_new_object(); + json_object_set_string(m, "role", "tool"); + json_object_set_string(m, "tool_call_id", id); + json_object_set_string(m, "content", 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 —— 所以每次动过历史之后都要查。 */ +static void check_pairing(Agent *a) +{ + size_t n = json_array_size(a->messages); + for (size_t i = 0; i < n; i++) { + JsonValue *m = json_array_get(a->messages, i); + JsonValue *tcs = json_object_get(m, "tool_calls"); + if (!tcs) continue; + const char *id = json_get_string(json_array_get(tcs, 0), "id"); + CHECK(i + 1 < n, "tool_calls 之后必须有消息"); + if (i + 1 < n) { + 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); + } + } +} + +/* 一轮一轮摘下去(/back 走的就是这条路),历史在每一步都必须仍然自洽。 + * + * 这里原来测的是"上下文超限自动裁剪"(agent_drop_oldest_round)—— 那套机制 + * 已经**整个删掉**:它的判据只是错误文案里的关键词,却会不可逆地删掉最老的 + * 几轮对话。现在超限就是如实报错、历史一个字节都不动,怎么处理由用户决定 + * (/back 摘一轮、/reset 重来)。但"摘掉一整轮之后 tool 配对不能散"这条 + * 不变量与由谁触发无关,所以留着继续测。 */ +static void test_take_rounds_keeps_pairing(void) +{ + puts("逐轮摘掉之后历史仍然自洽"); + Agent a; + agent_init(&a); + agent_add_system(&a, "SYS"); + for (int i = 1; i <= 4; i++) { + char buf[32]; + snprintf(buf, sizeof buf, "U%d", i); + add_msg(&a, "user", buf); + add_assistant_tool_call(&a, "call_x", "read_file"); + add_tool_result(&a, "call_x", "R"); + add_msg(&a, "assistant", "A"); + } + + int rounds = 0; + for (;;) { + JsonValue *r = agent_take_last_round(&a); + if (!r) break; + json_free(r); + rounds++; + check_pairing(&a); /* 每摘一轮都查一遍配对 */ + } + 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 用)"); + Agent a; + agent_init(&a); + agent_add_system(&a, "SYS"); + add_msg(&a, "user", "U1"); + add_assistant_tool_call(&a, "call_1", "run_command"); + add_tool_result(&a, "call_1", "R1"); + add_msg(&a, "assistant", "A1"); + add_msg(&a, "user", "U2"); + add_msg(&a, "assistant", "A2"); + + 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", + r ? json_array_size(r) : 0); + CHECK(agent_history_len(&a) == 5, "摘完应剩 5 条,实际 %zu", + agent_history_len(&a)); + char *h = dump_history(&a); + CHECK(strstr(h, "U2") == NULL, "U2 应被摘掉"); + CHECK(strstr(h, "U1") != NULL, "U1 应保留"); + free(h); + json_free(r); + + /* 关键:工具调用与结果必须整组一起走 */ + r = agent_take_last_round(&a); + CHECK(r && json_array_size(r) == 4, "第一轮应 4 条,实际 %zu", + r ? json_array_size(r) : 0); + 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); + + /* 只剩 system 时无可摘 */ + r = agent_take_last_round(&a); + CHECK(r == NULL, "没有用户轮次时应返回 NULL"); + json_free(r); + CHECK(agent_history_len(&a) == 1, "历史应只剩 system"); + agent_free(&a); + + /* 没有 user 消息的历史 */ + Agent b; + agent_init(&b); + add_msg(&b, "assistant", "只有助手"); + CHECK(agent_take_last_round(&b) == NULL, "没有 user 时不应摘任何东西"); + CHECK(agent_history_len(&b) == 1, "历史不应被改动"); + agent_free(&b); +} + +/* ------------------------------------------------------------------ */ +/* 结构体检:assistant(tool_calls) 与 tool 消息的配对 */ +/* ------------------------------------------------------------------ */ + +/* 造一条 assistant,带 count 个工具调用(id 依次是 c0, c1, ...) */ +static JsonValue *mk_assistant(size_t count) +{ + JsonValue *m = json_new_object(); + json_object_set_string(m, "role", "assistant"); + JsonValue *tcs = json_new_array(); + for (size_t i = 0; i < count; i++) { + char id[16]; + snprintf(id, sizeof id, "c%zu", i); + JsonValue *tc = json_new_object(); + json_object_set_string(tc, "id", id); + json_object_set_string(tc, "type", "function"); + JsonValue *fn = json_new_object(); + json_object_set_string(fn, "name", "run_command"); + json_object_set_string(fn, "arguments", "{}"); + json_object_set(tc, "function", fn); + json_array_push(tcs, tc); + } + json_object_set(m, "tool_calls", tcs); + return m; +} + +static JsonValue *mk_tool(const char *id) +{ + JsonValue *m = json_new_object(); + json_object_set_string(m, "role", "tool"); + json_object_set_string(m, "tool_call_id", id); + json_object_set_string(m, "content", "ok"); + return m; +} + +static JsonValue *mk_plain(const char *role, const char *text) +{ + JsonValue *m = json_new_object(); + json_object_set_string(m, "role", role); + json_object_set_string(m, "content", text); + return m; +} + +/* 数一数某个 assistant 之后紧跟的 tool 消息覆盖了几个 id */ +static const char *nth_role(const JsonValue *msgs, size_t i) +{ + return json_get_string(json_array_get((JsonValue *)msgs, i), "role"); +} + +static int pairing_ok(const JsonValue *msgs) +{ + size_t n = json_array_size((JsonValue *)msgs); + for (size_t i = 0; i < n; i++) { + JsonValue *m = json_array_get((JsonValue *)msgs, i); + const char *r = json_get_string(m, "role"); + if (!r || strcmp(r, "assistant") != 0) continue; + JsonValue *tcs = json_object_get(m, "tool_calls"); + if (!tcs || json_array_size(tcs) == 0) continue; + for (size_t k = 0; k < json_array_size(tcs); k++) { + const char *id = json_get_string(json_array_get(tcs, k), "id"); + size_t j = i + 1; + int found = 0; + for (; j < n && strcmp(nth_role(msgs, j), "tool") == 0; j++) + if (strcmp(json_get_string(json_array_get((JsonValue *)msgs, j), + "tool_call_id"), id) == 0) found = 1; + if (!found) return 0; + } + } + return 1; +} + +static void test_repair_pairing(void) +{ + int fixed = 0; + + puts("结构体检:本来没问题就不该动它"); + JsonValue *ok = json_new_array(); + json_array_push(ok, mk_plain("user", "跑一下")); + json_array_push(ok, mk_assistant(2)); + json_array_push(ok, mk_tool("c0")); + json_array_push(ok, mk_tool("c1")); + json_array_push(ok, mk_plain("assistant", "做完了")); + CHECK(pairing_ok(ok), "构造出来的历史本身应是合法的"); + CHECK(agent_repair_tool_pairing(ok, &fixed) == NULL, + "合法历史不该被重建(NULL = 什么都没改)"); + CHECK(fixed == 0, "合法历史的修复处数应为 0"); + json_free(ok); + + puts("缺 tool 消息:补齐,且补在原位"); + /* 复现真实报错:assistant 带两个调用,中间夹了一条 user(图省事直接 + * 模仿当年那个 bug 的形状),第二个调用没人回应 */ + JsonValue *bad = json_new_array(); + json_array_push(bad, mk_plain("user", "看看图")); + json_array_push(bad, mk_assistant(2)); + json_array_push(bad, mk_tool("c0")); + json_array_push(bad, mk_plain("user", "图片附件")); + json_array_push(bad, mk_tool("c1")); + fixed = 0; + JsonValue *r = agent_repair_tool_pairing(bad, &fixed); + CHECK(r != NULL, "对不上时应该返回重建好的数组"); + if (r) { + CHECK(pairing_ok(r), "修完之后配对必须成立"); + /* 两处:给 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 段里,而不是被塞到最后 */ + 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)"); + json_free(r); + } + json_free(bad); + + puts("末尾悬空:assistant 之后再没有消息"); + JsonValue *tail = json_new_array(); + json_array_push(tail, mk_plain("user", "跑一下")); + 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); + json_free(r); + json_free(tail); + + 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"); + json_free(r2); + json_free(orphan); + + puts("空输入与边界"); + fixed = 7; + CHECK(agent_repair_tool_pairing(NULL, &fixed) == NULL && fixed == 0, "NULL 输入"); + JsonValue *empty = json_new_array(); + CHECK(agent_repair_tool_pairing(empty, &fixed) == NULL, "空历史没什么可修"); + json_free(empty); + /* out_fixed 可以为 NULL */ + JsonValue *b2 = json_new_array(); + json_array_push(b2, mk_assistant(1)); + JsonValue *r3 = agent_repair_tool_pairing(b2, NULL); + json_free(r3); + json_free(b2); + CHECK(1, "out_fixed 为 NULL 时不崩"); +} + +/* ------------------------------------------------------------------ */ +/* 面向用户的历史口径:不含 system */ +/* ------------------------------------------------------------------ */ + +static void test_conversation_len(void) +{ + 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 条,但对话是空的 */ + agent_add_system(&a, "你是助手"); + CHECK(agent_history_len(&a) == 1, "只有 system 时数组长度 1"); + CHECK(agent_conversation_len(&a) == 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 的历史(比如裸构造的)不该被误减 */ + Agent b; + agent_init(&b); + add_msg(&b, "user", "hi"); + CHECK(agent_history_len(&b) == 1 && agent_conversation_len(&b) == 1, + "没有 system 时两个口径一致"); + + /* reset 之后回到"只有 system" */ + agent_reset(&a); + CHECK(agent_history_len(&a) == 1 && agent_conversation_len(&a) == 0, + "reset 之后数组剩 system、对话为空"); + + agent_free(&a); + agent_free(&b); +} + +int main(void) +{ + Agent a; + agent_init(&a); + CHECK(a.messages && a.messages->type == JSON_ARRAY, "messages 应为数组"); + + agent_add_system(&a, "S1"); + CHECK(agent_history_len(&a) == 1, "应只有 1 条 system"); + CHECK(count_role(&a, "system") == 1, "system 计数"); + + agent_add_user(&a, "U1"); + CHECK(agent_history_len(&a) == 2, "应 2 条"); + + /* 关键不变量:重复设置 system 应替换而不是追加 */ + agent_add_system(&a, "S2"); + 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 条"); + CHECK(strcmp(json_get_string(json_array_get(a.messages, 1), "content"), "U1") == 0, + "原有对话应保留"); + + agent_add_user(&a, "U2"); + CHECK(agent_history_len(&a) == 3, "应 3 条"); + + agent_reset(&a); + 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, + "reset 保留的是最新 system"); + + /* 历史里没有 system 时 reset 应得到空历史 */ + Agent b; + agent_init(&b); + agent_add_user(&b, "only user"); + agent_reset(&b); + CHECK(agent_history_len(&b) == 0, "无 system 时 reset 应为空,实际 %zu", + agent_history_len(&b)); + + agent_free(&a); + agent_free(&b); + test_take_rounds_keeps_pairing(); + test_take_last_round(); + test_repair_pairing(); + test_conversation_len(); + printf("\n%d 项检查,%d 项失败\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_archive.c b/tests/test_archive.c new file mode 100644 index 0000000..62e66fa --- /dev/null +++ b/tests/test_archive.c @@ -0,0 +1,178 @@ +/* test_archive.c - 存档命名规则与读写往返(不需要网络) */ +#include "archive.h" +#include "util.h" + +#include +#include +#include +#include +#include + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ + } while (0) + +static int is_alnum_lower(const char *s, size_t n) +{ + for (size_t i = 0; i < n; i++) + if (!((s[i] >= 'a' && s[i] <= 'z') || (s[i] >= '0' && s[i] <= '9'))) + return 0; + return 1; +} + +int main(void) +{ + char tmpl[] = "/tmp/ligent_arch_XXXXXX"; + char *dir = mkdtemp(tmpl); + if (!dir) { perror("mkdtemp"); return 2; } + char err[256]; + + puts("根名:8 位小写字母数字"); + char *n1 = NULL; + CHECK(archive_next_name(dir, NULL, NULL, 0, &n1, err, sizeof err) == 0, "生成失败: %s", err); + CHECK(n1 && strlen(n1) == ARCHIVE_ROOT_LEN, "根名应为 %d 位,实际 %s", + ARCHIVE_ROOT_LEN, n1 ? n1 : "(null)"); + CHECK(n1 && is_alnum_lower(n1, strlen(n1)), "根名字符集应为 [a-z0-9]:%s", n1); + free(n1); + + /* 空字符串与 NULL 等价:都表示"没有来源" */ + char *n2 = NULL; + CHECK(archive_next_name(dir, "", NULL, 0, &n2, err, sizeof err) == 0, "空 lineage 应可用"); + CHECK(n2 && strlen(n2) == ARCHIVE_ROOT_LEN, "空 lineage 也应给出根名"); + free(n2); + + puts("分支名:来源 + _ + 3 位"); + char *b = NULL; + CHECK(archive_next_name(dir, "a1b2c3d4", NULL, 0, &b, err, sizeof err) == 0, + "分支生成失败: %s", err); + CHECK(b && strncmp(b, "a1b2c3d4_", 9) == 0, "应以来源加下划线开头:%s", b); + CHECK(b && strlen(b) == 8 + 1 + ARCHIVE_BRANCH_LEN, "分支长度不对:%s", b); + CHECK(b && is_alnum_lower(b + 9, ARCHIVE_BRANCH_LEN), "后缀字符集不对:%s", b); + free(b); + + /* 多次生成应当不同(随机性) */ + char *x1 = NULL, *x2 = NULL; + archive_next_name(dir, "a1b2c3d4", NULL, 0, &x1, err, sizeof err); + archive_next_name(dir, "a1b2c3d4", NULL, 0, &x2, err, sizeof err); + CHECK(x1 && x2 && strcmp(x1, x2) != 0, "两次生成不应相同:%s / %s", x1, x2); + free(x1); free(x2); + + puts("长度上限:接不下就重新起根"); + /* 边界:来源长度 + 1 + 3 + ".json" <= 240 */ + size_t maxlineage = ARCHIVE_NAME_MAX - 1 - ARCHIVE_BRANCH_LEN - strlen(".json"); + char *ok = xmalloc(maxlineage + 1); + memset(ok, 'a', maxlineage); + ok[maxlineage] = '\0'; + + char *edge = NULL; + CHECK(archive_next_name(dir, ok, NULL, 0, &edge, err, sizeof err) == 0, "边界生成失败"); + CHECK(edge && strncmp(edge, "aaa", 3) == 0, "刚好装得下时应继续接分支"); + CHECK(edge && strlen(edge) + strlen(".json") <= ARCHIVE_NAME_MAX, + "完整文件名不应超上限:%zu", edge ? strlen(edge) + 5 : 0); + free(edge); + + /* 再多一位就装不下了 */ + char *too = xmalloc(maxlineage + 2); + memset(too, 'a', maxlineage + 1); + too[maxlineage + 1] = '\0'; + 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 位)", + ARCHIVE_ROOT_LEN, fresh ? fresh : "(null)", fresh ? strlen(fresh) : 0); + free(fresh); + puts("前缀:临时存档"); + char *tp = NULL; + CHECK(archive_next_name(dir, "a1b2c3d4", "tmp_", 0, &tp, err, sizeof err) == 0, + "带前缀生成失败"); + CHECK(tp && strncmp(tp, "tmp_a1b2c3d4_", 13) == 0, + "前缀应在最前面、其后才是分支名:%s", tp ? tp : "(null)"); + free(tp); + + char *tr = NULL; + CHECK(archive_next_name(dir, NULL, "tmp_", 0, &tr, err, sizeof err) == 0, "带前缀建根失败"); + CHECK(tr && strncmp(tr, "tmp_", 4) == 0 && + strlen(tr) == 4 + ARCHIVE_ROOT_LEN, + "新根也应带前缀:%s", tr ? tr : "(null)"); + free(tr); + + /* 前缀要算进长度上限:原本刚好装得下的,加了 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 位)", + edgep ? edgep : "(null)", edgep ? strlen(edgep) : 0); + free(edgep); + + free(ok); free(too); + + puts("导出与载入往返"); + JsonValue *msgs = json_new_array(); + JsonValue *m = json_new_object(); + json_object_set_string(m, "role", "user"); + json_object_set_string(m, "content", "你好,暗号是紫水晶"); + json_array_push(msgs, m); + + char *path = NULL, *name = NULL; + time_t t = 1700000000; + CHECK(archive_export(msgs, dir, "parentname", NULL, 0, t, t + 5, + "test-model", "http://example/v1", + &path, &name, err, sizeof err) == 0, + "导出失败: %s", err); + CHECK(name && strncmp(name, "parentname_", 11) == 0, + "导出名应挂在来源下:%s", name ? name : "(null)"); + CHECK(path && access(path, R_OK) == 0, "文件应存在:%s", path ? path : ""); + + char *created = NULL, *saved = NULL, *model = NULL, *iname = NULL, *iparent = NULL; + JsonValue *back = archive_load(path, &created, &saved, &model, &iname, &iparent, + err, sizeof err); + CHECK(back != NULL, "载入失败: %s", err); + CHECK(back && json_array_size(back) == 1, "消息条数应为 1"); + CHECK(iname && name && strcmp(iname, name) == 0, "内容里的 name 应与文件名一致"); + CHECK(iparent && strcmp(iparent, "parentname") == 0, + "内容里的 parent 应记下来源:%s", iparent ? iparent : "(null)"); + CHECK(model && strcmp(model, "test-model") == 0, "model 应往返"); + CHECK(created && strstr(created, "2023-") != NULL, + "created 应可读:%s", created ? created : "(null)"); + /* saved 是"这份存档写出时的时间",与 created(这段对话的开始时间)是两回事 */ + CHECK(saved && strstr(saved, "2023-") != NULL, + "saved 应可读:%s", saved ? saved : "(null)"); + + json_free(back); json_free(msgs); + free(created); free(saved); free(model); free(iname); free(iparent); + + puts("列举"); + ArchiveEntry *list = NULL; + size_t n = archive_list(dir, &list); + 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].created != NULL, "时间应从内容里读出来"); + } + archive_list_free(list, n); + + /* 非 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); + archive_list_free(list, n); + unlink(jd); free(jd); + + unlink(path); free(path); free(name); + rmdir(dir); + printf("\n%d 项检查,%d 项失败\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_attach.c b/tests/test_attach.c new file mode 100644 index 0000000..841801d --- /dev/null +++ b/tests/test_attach.c @@ -0,0 +1,177 @@ +/* test_attach.c - /include 的暂存、glob 展开与体积/类型判断 + * + * 交互式挑选(attach_pick)要真终端才能测,由 e2e 第 55 段覆盖; + * 这里测的是不需要人操作的那一半。 + */ +#include "attach.h" +#include "image.h" +#include "json.h" +#include "util.h" + +#include +#include +#include +#include +#include + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ + } while (0) + +static void write_file(const char *path, const void *data, size_t n) +{ + FILE *f = fopen(path, "wb"); + if (!f) { perror(path); exit(2); } + if (n && fwrite(data, 1, n, f) != n) { perror("fwrite"); exit(2); } + fclose(f); +} + +static const unsigned char PNG[] = { + 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 'p', 'a', 'y', 'l', 'o', 'a', 'd' +}; + +/* 数一数暂存里某类块有几个 */ +static size_t count_type(const JsonValue *parts, const char *type) +{ + if (!parts) return 0; + size_t n = 0; + for (size_t i = 0; i < json_array_size((JsonValue *)parts); i++) { + const char *t = json_get_string(json_array_get((JsonValue *)parts, i), "type"); + if (t && strcmp(t, type) == 0) n++; + } + return n; +} + +/* 记一下回调被问了几次 —— 它只在遇到图片时该被问到。 + * 答案和计数放在一起,免得"计数版只实现了一种答案"这种测试自己的错。 */ +static int ask_count; +static int ask_answer; +static int counting_ask(void *ud) { (void)ud; ask_count++; return ask_answer; } + +int main(void) +{ + char tmpl[] = "/tmp/ligent_attach_XXXXXX"; + char *dir = mkdtemp(tmpl); + if (!dir) { perror("mkdtemp"); return 2; } + + /* 备份目录也指到临时目录,别污染仓库 */ + image_set_bindir(dir); + + char *f_c = xasprintf("%s/a.c", dir); + char *f_txt = xasprintf("%s/b.txt", dir); + char *f_bin = xasprintf("%s/bin.dat", dir); + char *f_png = xasprintf("%s/pic.png", dir); + char *f_big = xasprintf("%s/big.txt", dir); + + write_file(f_c, "int main(void){return 0;}\n", 26); + write_file(f_txt, "hello\n", 6); + write_file(f_bin, "\x00\x01\x02binary", 9); + write_file(f_png, PNG, sizeof PNG); + { + /* 超过单个文件上限(256 KB) */ + size_t n = ATTACH_MAX_FILE + 1024; + char *big = xmalloc(n); + memset(big, 'x', n); + write_file(f_big, big, n); + free(big); + } + + puts("glob 展开:命中几个就暂存几个"); + attach_clear(); + size_t skipped = 0; + char err[256]; + int added = attach_glob(f_c, NULL, NULL, &skipped, err, sizeof err); + CHECK(added == 1 && attach_count() == 1, "命中一个文件"); + CHECK(skipped == 0, "没有跳过任何东西"); + + JsonValue *parts = attach_take_parts(); + CHECK(parts && json_array_size(parts) == 1, "取到 1 个块"); + JsonValue *p0 = parts ? json_array_get(parts, 0) : NULL; + CHECK(p0 && strcmp(json_get_string(p0, "type"), "text") == 0, + "文本文件应当是 text 块"); + const char *txt = p0 ? json_get_string(p0, "text") : NULL; + 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 也是"); + attach_glob(f_c, NULL, NULL, &skipped, err, sizeof err); + CHECK(attach_count() == 1, "再附带一次"); + attach_clear(); + CHECK(attach_count() == 0, "attach_clear 清空"); + CHECK(attach_take_parts() == NULL, "清空之后取不到东西"); + + puts("二进制文件跳过"); + attach_clear(); + added = attach_glob(f_bin, NULL, NULL, &skipped, err, sizeof err); + 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); + + puts("图片:认内容不认扩展名,并走备份引用"); + attach_clear(); + added = attach_glob(f_png, NULL, NULL, &skipped, err, sizeof err); + CHECK(added == 1, "png 被收下"); + CHECK(attach_has_image(), "暂存里有图片"); + parts = attach_take_parts(); + CHECK(count_type(parts, "ligent_image") == 1, "应当是一个 ligent_image 引用块"); + json_free(parts); + + 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, "纯文本不该去问'能不能收图'(问了一次就是白探测)"); + 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); + + puts("匹配不到 / 参数非法"); + attach_clear(); + added = attach_glob("/tmp/绝对不存在的模式*.zzz", NULL, NULL, &skipped, err, sizeof err); + CHECK(added == -1 && err[0], "匹配不到返回 -1 并给出原因:%s", err); + added = attach_glob(NULL, NULL, NULL, &skipped, err, sizeof err); + CHECK(added == -1, "NULL 模式返回 -1"); + added = attach_glob("", NULL, NULL, &skipped, err, sizeof err); + CHECK(added == -1, "空模式返回 -1"); + + puts("多个匹配会累加"); + 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(skipped == 1, "big.txt 被跳过"); + free(pat); + attach_clear(); + + free(f_c); free(f_txt); free(f_bin); free(f_png); free(f_big); + image_set_bindir("."); + char *cmd = xasprintf("rm -rf %s", tmpl); + if (system(cmd) != 0) { } + free(cmd); + + printf("\n%d 项检查,%d 项失败\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_capability.c b/tests/test_capability.c new file mode 100644 index 0000000..0a538c5 --- /dev/null +++ b/tests/test_capability.c @@ -0,0 +1,92 @@ +/* test_capability.c - 能力探测的纯逻辑部分 + * + * 探测序列本身(基线 -> 合并 -> 拆开)要发 HTTP,属于端到端范畴, + * 由 e2e 第 51 段对着 mock 覆盖:正常模型 / 不收图片 / 不收 tools / + * 探测失败(401)/ --no-probe 五种走法都在那儿验。 + * + * 这里只测不需要网络的:名字映射、关闭探测时的语义、缓存查询。 + * 其中"关闭探测必须给未测而不是不支持"那条是回归测试 —— 那句话看着像废话, + * 但 CAP_NO 恰好是 0,而 Agent 是 memset 出来的,忘了显式初始化就会一声不响地 + * 把所有工具摘掉。这个 bug 真的发生过一次。 + */ +#include "capability.h" +#include "llm.h" + +#include +#include + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ + } while (0) + +int main(void) +{ + /* CAP_NO 必须是 0 —— Agent 是 memset 出来的,整个 capability 模块的 + * 设计都建立在"零值不能是某个真实结论"之上。这条断言是那个 bug 的护栏。 */ + CHECK(CAP_NO == 0, "CAP_NO 应为 0(零值即'不支持'是事故的根源)"); + CHECK(CAP_UNKNOWN != CAP_NO, "未知与不支持必须是两个不同的值"); + CHECK(CAP_UNKNOWN != CAP_YES, "未知与支持必须是两个不同的值"); + + puts("名字映射"); + 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 的值都该落到"未测",而不是碰巧落到某一个 */ + CHECK(strcmp(cap_name(42), "未测") == 0, "越界值应落到未测"); + + puts("关掉探测:一律未测,绝不冒充结论"); + /* 用可变数组而不是字面量:LlmConfig 的这些字段是 char *(真要释放的), + * 而 -Wwrite-strings 下 "字面量" 是 const char * —— 直接赋值会被警告。 */ + char model[] = "some-model"; + char base_url[] = "http://127.0.0.1:1/v1"; + LlmConfig cfg; + memset(&cfg, 0, sizeof cfg); + cfg.model = model; + cfg.base_url = base_url; + cfg.timeout = 1; + + cap_cache_clear(); + + Capabilities 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.conclusive == 0, "禁用时不该声称有结论"); + + /* 关键回归:禁用时的结果拿去决定工具表,必须"全都留着" */ + CHECK(c.vision != CAP_NO, "禁用时不能表现为不支持(否则会藏掉 read_image)"); + CHECK(c.tools != CAP_NO, "禁用时不能表现为不支持(否则会摘掉整张工具表)"); + + puts("缓存查询"); + Capabilities got; + memset(&got, 0xAB, sizeof got); + CHECK(cap_cached(&cfg, &got) == 0, "没测过时缓存应未命中"); + /* 关掉探测不会写入缓存 */ + CHECK(cap_cached(&cfg, NULL) == 0, "禁用不该往缓存里写东西"); + + cap_cache_clear(); + CHECK(cap_cached(&cfg, &got) == 0, "清空之后仍应未命中"); + + /* 空指针不该崩 */ + cap_get(NULL, 0, &c, 0, NULL, 0); + CHECK(c.vision == CAP_UNKNOWN, "cfg 为空也要给未测"); + cap_get(&cfg, 0, NULL, 0, NULL, 0); + cap_cache_clear(); + CHECK(1, "空指针路径没有崩"); + + printf("\n%d 项检查,%d 项失败\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_config.c b/tests/test_config.c new file mode 100644 index 0000000..7f7e7e4 --- /dev/null +++ b/tests/test_config.c @@ -0,0 +1,498 @@ +/* test_config.c - 配置默认值 / 环境变量 / 配置文件覆盖 */ +#include "config.h" +#include "util.h" +#include "i18n.h" + +#include +#include +#include +#include +#include +#include + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ + } while (0) + +static void unset_all(void) +{ + const char *vars[] = { + "LIGENT_API_KEY", "DEEPSEEK_API_KEY", "OPENAI_API_KEY", + "LIGENT_BASE_URL", "LIGENT_MODEL", "LIGENT_SYSTEM", + "LIGENT_TEMPERATURE", "LIGENT_MAX_TOKENS", "LIGENT_TIMEOUT", + "LIGENT_MAX_STEPS", "LIGENT_STREAM", "LIGENT_TOOLS", + "OPENAI_BASE_URL", "LIGENT_CONFIG", + }; + for (size_t i = 0; i < sizeof vars / sizeof vars[0]; i++) + unsetenv(vars[i]); +} + +static void test_defaults(void) +{ + puts("默认值"); + unset_all(); + LigentConfig c; + config_defaults(&c); + CHECK(strcmp(c.model, "deepseek-flash") == 0, + "默认模型应为 deepseek-flash,实际 %s", c.model); + CHECK(strcmp(c.base_url, "https://api.deepseek.com/v1") == 0, + "默认 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); + config_free(&c); +} + +static void test_env_key(void) +{ + puts("环境变量取 key"); + unset_all(); + LigentConfig c; + config_defaults(&c); + + /* 没有任何 key */ + config_apply_env(&c); + CHECK(c.api_key[0] == '\0', "无环境变量时 key 应为空"); + + /* 只设 DEEPSEEK_API_KEY:默认就应读它 */ + 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); + + /* 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); + + /* OPENAI_API_KEY 只在端点确实是 OpenAI 时才兜底 —— + * 把 OpenAI 的密钥发给 DeepSeek 的端点是没有意义的 */ + unset_all(); + setenv("OPENAI_API_KEY", "sk-openai", 1); + /* 模拟"配置文件没写 api_key":必须先清掉上一步留下的值 */ + free(c.api_key); + c.api_key = xstrdup(""); + config_apply_env(&c); + CHECK(c.api_key[0] == '\0', "DeepSeek 端点不应兜底到 OPENAI_API_KEY"); + + free(c.base_url); + 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); + + unset_all(); + config_free(&c); +} + +static void test_env_others(void) +{ + puts("环境变量其他项"); + unset_all(); + LigentConfig c; + config_defaults(&c); + setenv("LIGENT_MODEL", "my-model", 1); + setenv("LIGENT_MAX_STEPS", "7", 1); + setenv("LIGENT_STREAM", "0", 1); + setenv("LIGENT_TOOLS", "false", 1); + setenv("LIGENT_THINKING", "off", 1); + setenv("LIGENT_REASONING_EFFORT", "max", 1); + config_apply_env(&c); + CHECK(strcmp(c.model, "my-model") == 0, "LIGENT_MODEL 应生效"); + CHECK(c.max_steps == 7, "LIGENT_MAX_STEPS 应生效"); + CHECK(c.stream == 0, "LIGENT_STREAM=0 应关闭流式"); + CHECK(c.use_tools == 0, "LIGENT_TOOLS=false 应关闭工具"); + CHECK(c.thinking == THINKING_OFF, "LIGENT_THINKING=off 应关闭思考"); + CHECK(c.reasoning_effort && strcmp(c.reasoning_effort, "max") == 0, + "LIGENT_REASONING_EFFORT=max 应生效"); + unset_all(); + config_free(&c); + + /* 环境变量是临时开关:写错了应当被忽略,而不是把程序拦下来 */ + config_defaults(&c); + setenv("LIGENT_THINKING", "随便写的", 1); + setenv("LIGENT_REASONING_EFFORT", "超高", 1); + config_apply_env(&c); + CHECK(c.thinking == THINKING_AUTO, "无法识别的 LIGENT_THINKING 应被忽略"); + CHECK(c.reasoning_effort == NULL, "无法识别的级别应被忽略"); + unset_all(); + config_free(&c); +} + +static void test_thinking_parse(void) +{ + puts("思考模式 / 级别解析"); + int t = 123; + CHECK(config_parse_thinking("auto", &t) == 0 && t == THINKING_AUTO, "auto"); + CHECK(config_parse_thinking("", &t) == 0 && t == THINKING_AUTO, "空串 = auto"); + CHECK(config_parse_thinking("on", &t) == 0 && t == THINKING_ON, "on"); + CHECK(config_parse_thinking("true", &t) == 0 && t == THINKING_ON, "true"); + CHECK(config_parse_thinking("enabled", &t) == 0 && t == THINKING_ON, "enabled"); + CHECK(config_parse_thinking("off", &t) == 0 && t == THINKING_OFF, "off"); + CHECK(config_parse_thinking("false", &t) == 0 && t == THINKING_OFF, "false"); + CHECK(config_parse_thinking("disabled", &t) == 0 && t == THINKING_OFF, "disabled"); + CHECK(config_parse_thinking("maybe", &t) != 0, "无法识别的值应报错"); + CHECK(config_parse_thinking(NULL, &t) != 0, "NULL 应报错"); + + static const char *const good[] = { + "minimal", "low", "medium", "high", "xhigh", "max", "ultra", NULL + }; + for (size_t i = 0; good[i]; i++) + CHECK(config_effort_valid(good[i]), "%s 应是合法级别", good[i]); + CHECK(!config_effort_valid("huge"), "huge 不是合法级别"); + CHECK(!config_effort_valid(""), "空串不是合法级别"); + CHECK(!config_effort_valid(NULL), "NULL 不是合法级别"); + CHECK(strstr(config_effort_list(), "ultra") != NULL, "展示串应含全部级别"); + CHECK(config_thinking_name(THINKING_AUTO) != NULL, "THINKING_AUTO 有说明"); + CHECK(config_thinking_name(THINKING_ON) != NULL, "THINKING_ON 有说明"); + CHECK(config_thinking_name(THINKING_OFF) != NULL, "THINKING_OFF 有说明"); +} + +static void test_file_thinking(void) +{ + puts("配置文件里的 thinking / reasoning_effort"); + char err[256]; + LigentConfig c; + + /* 布尔写法 */ + char t1[] = "/tmp/ligent_think_XXXXXX"; + int fd = mkstemp(t1); + if (fd < 0) { CHECK(0, "mkstemp 失败"); return; } + const char *j1 = "{\"thinking\":false,\"reasoning_effort\":\"medium\"}"; + ssize_t w = write(fd, j1, strlen(j1)); + close(fd); + CHECK(w == (ssize_t)strlen(j1), "写临时配置失败"); + config_defaults(&c); + CHECK(config_load_file(&c, t1, err, sizeof err) == 0, "加载失败: %s", err); + CHECK(c.thinking == THINKING_OFF, "thinking:false 应为关闭"); + CHECK(c.reasoning_effort && strcmp(c.reasoning_effort, "medium") == 0, + "reasoning_effort 应被读入"); + config_free(&c); + unlink(t1); + + /* 字符串写法 */ + char t2[] = "/tmp/ligent_think_XXXXXX"; + fd = mkstemp(t2); + if (fd >= 0) { + const char *j2 = "{\"thinking\":\"on\"}"; + w = write(fd, j2, strlen(j2)); + close(fd); + CHECK(w == (ssize_t)strlen(j2), "写临时配置失败"); + config_defaults(&c); + CHECK(config_load_file(&c, t2, err, sizeof err) == 0, "加载失败: %s", err); + CHECK(c.thinking == THINKING_ON, "thinking:\"on\" 应为开启"); + config_free(&c); + unlink(t2); + } + + /* 写错了必须报错,不能静默忽略 —— 配置文件是长期存在的,静默会让人困惑 */ + char t3[] = "/tmp/ligent_think_XXXXXX"; + fd = mkstemp(t3); + if (fd >= 0) { + const char *j3 = "{\"thinking\":\"大概吧\"}"; + w = write(fd, j3, strlen(j3)); + close(fd); + CHECK(w == (ssize_t)strlen(j3), "写临时配置失败"); + config_defaults(&c); + CHECK(config_load_file(&c, t3, err, sizeof err) != 0, + "非法的 thinking 应加载失败"); + config_free(&c); + unlink(t3); + } + + char t4[] = "/tmp/ligent_think_XXXXXX"; + fd = mkstemp(t4); + if (fd >= 0) { + const char *j4 = "{\"reasoning_effort\":\"超级高\"}"; + w = write(fd, j4, strlen(j4)); + close(fd); + CHECK(w == (ssize_t)strlen(j4), "写临时配置失败"); + config_defaults(&c); + CHECK(config_load_file(&c, t4, err, sizeof err) != 0, + "非法的 reasoning_effort 应加载失败"); + config_free(&c); + unlink(t4); + } +} + +static void test_file_override(void) +{ + puts("配置文件覆盖 + ${VAR} 展开"); + unset_all(); + setenv("MY_SECRET", "sk-from-env", 1); + + char tmpl[] = "/tmp/ligent_cfg_XXXXXX"; + int fd = mkstemp(tmpl); + CHECK(fd >= 0, "mkstemp 失败"); + if (fd < 0) return; + const char *json = + "{\"base_url\":\"http://localhost:11434/v1\"," + "\"api_key\":\"${MY_SECRET}\"," + "\"model\":\"qwen2.5:14b\"," + "\"temperature\":0.3," + "\"max_tokens\":4096," + "\"max_steps\":9," + "\"stream\":false," + "\"tools\":false}"; + ssize_t w = write(fd, json, strlen(json)); + close(fd); + CHECK(w == (ssize_t)strlen(json), "写临时配置失败"); + + LigentConfig c; + config_defaults(&c); + char err[256]; + int rc = config_load_file(&c, tmpl, err, sizeof err); + CHECK(rc == 0, "加载配置失败: %s", err); + 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); + CHECK(c.temperature > 0.29 && c.temperature < 0.31, "temperature 覆盖"); + CHECK(c.max_tokens == 4096, "max_tokens 覆盖"); + CHECK(c.max_steps == 9, "max_steps 覆盖"); + CHECK(c.stream == 0, "stream 覆盖"); + CHECK(c.use_tools == 0, "tools 覆盖"); + CHECK(c.config_path && strcmp(c.config_path, tmpl) == 0, "记录配置文件路径"); + + /* 坏配置应报错而不是静默通过。 + * + * 解析器的报错跟着界面语言走(json_parse_ui),所以下面这段断语先把语言 + * 钉住,再顺手把英文那份也验一遍 —— 位置信息两种语言都得有。 */ + char tmpl2[] = "/tmp/ligent_bad_XXXXXX"; + int fd2 = mkstemp(tmpl2); + if (fd2 >= 0) { + const char *bad = "{ not json "; + ssize_t w2 = write(fd2, bad, strlen(bad)); + (void)w2; + close(fd2); + LigentConfig c2; + i18n_set_lang(LIGENT_LANG_ZH); + config_defaults(&c2); + 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); + + LigentConfig c3; + i18n_set_lang(LIGENT_LANG_EN); + config_defaults(&c3); + CHECK(config_load_file(&c3, tmpl2, err, sizeof err) != 0 && + strstr(err, "line") != NULL, + "英文界面下报错也带位置(且是英文): %s", err); + config_free(&c3); + i18n_set_lang(LIGENT_LANG_ZH); + unlink(tmpl2); + } + + config_free(&c); + unlink(tmpl); + unset_all(); +} + +/* 配置只认二进制所在目录:不做用户级、系统级,也不做当前目录查找 */ +static void test_binary_dir_lookup(void) +{ + puts("配置位置 = 二进制所在目录"); + char err[256]; + + char *dir = config_binary_dir(NULL); + char *canon = config_default_path(NULL); + CHECK(dir && *dir, "应能解析出二进制所在目录"); + CHECK(canon && str_ends_with(canon, "/ligent.json"), + "规范路径应以 /ligent.json 结尾: %s", canon ? canon : "(null)"); + CHECK(canon && dir && str_starts_with(canon, dir), + "规范路径应位于二进制目录下: %s / %s", canon, dir); + + /* 当前目录放一份 ligent.json,它绝不能被加载 */ + char tmpl[] = "/tmp/ligent_cwd_XXXXXX"; + char *tmp = mkdtemp(tmpl); + if (!tmp) { free(dir); free(canon); return; } + + char *fake = xasprintf("%s/ligent.json", tmp); + FILE *f = fopen(fake, "w"); + if (f) { fputs("{\"model\":\"FROM-CWD-ONLY\"}", f); fclose(f); } + + char *saved = getcwd(NULL, 0); + int loaded_from_cwd = 0; + char *got_dir = NULL; + + if (chdir(tmp) == 0) { + LigentConfig c; + config_init(&c, NULL, NULL, err, sizeof err); + loaded_from_cwd = (c.model && strcmp(c.model, "FROM-CWD-ONLY") == 0); + got_dir = c.config_dir ? xstrdup(c.config_dir) : NULL; + config_free(&c); + if (saved) chdir(saved); + } + + CHECK(!loaded_from_cwd, "不应加载当前工作目录下的 ligent.json"); + CHECK(got_dir && dir && strcmp(got_dir, dir) == 0, + "config_dir 应是二进制目录,实际 %s", got_dir ? got_dir : "(null)"); + + free(got_dir); + free(saved); + unlink(fake); /* 先删文件再释放路径字符串 */ + free(fake); + rmdir(tmp); + free(dir); + free(canon); +} + +/* 写一个临时配置文件并加载 */ +static int load_temp_config(LigentConfig *c, const char *json) +{ + char tmpl[] = "/tmp/ligent_prec_XXXXXX"; + int fd = mkstemp(tmpl); + if (fd < 0) return -1; + ssize_t w = write(fd, json, strlen(json)); + close(fd); + if (w != (ssize_t)strlen(json)) { unlink(tmpl); return -1; } + char err[256]; + int rc = config_load_file(c, tmpl, err, sizeof err); + unlink(tmpl); + return rc; +} + +/* 厂商专属的环境变量不能越权覆盖配置文件里写好的密钥。 + * 这正是用户踩到的坑:配置写了 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 => 它就是最终值,环境变量顶不掉 */ + LigentConfig c; + config_defaults(&c); + CHECK(load_temp_config(&c, + "{\"base_url\":\"https://openrouter.ai/api/v1\"," + "\"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); + config_free(&c); + + /* 2) 配置文件没写 key + 端点就是 DeepSeek => 用 DEEPSEEK_API_KEY 兜底 */ + config_defaults(&c); + CHECK(load_temp_config(&c, "{\"base_url\":\"https://api.deepseek.com/v1\"}") == 0, + "加载配置失败"); + config_apply_env(&c); + CHECK(strcmp(c.api_key, "sk-leftover-in-shell") == 0, + "DeepSeek 端点应能用 DEEPSEEK_API_KEY 兜底,实际 %s", c.api_key); + config_free(&c); + + /* 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); + config_free(&c); + + /* 4) LIGENT_API_KEY 是显式覆盖,任何时候都最高 */ + setenv("LIGENT_API_KEY", "sk-explicit", 1); + config_defaults(&c); + CHECK(load_temp_config(&c, + "{\"base_url\":\"https://openrouter.ai/api/v1\"," + "\"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); + config_free(&c); + + unset_all(); +} + +/* 软链接:配置目录要跟着"用户实际调用那个文件"走。 + * 否则把 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; } + self[n] = '\0'; + + char tmpl[] = "/tmp/ligent_link_XXXXXX"; + char *dir = mkdtemp(tmpl); + if (!dir) { puts(" (mkdtemp 失败,跳过)"); return; } + + char *link = xasprintf("%s/ligent", dir); + if (symlink(self, link) != 0) { + 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)"); + free(got); + + /* 路径里的 . 应被规范化,但最后一段仍不解析 */ + char *dotted = xasprintf("%s/./ligent", dir); + got = config_binary_dir(dotted); + CHECK(got && strcmp(got, dir) == 0, + "带 . 的路径应规范化,实际 %s", got ? got : "(null)"); + free(got); free(dotted); + + /* argv[0] 只是个名字时经 $PATH 找到的仍应是软链接本身 */ + char *saved = getenv("PATH") ? xstrdup(getenv("PATH")) : NULL; + char *newpath = xasprintf("%s:%s", dir, saved ? saved : ""); + setenv("PATH", newpath, 1); + got = config_binary_dir("ligent"); + CHECK(got && strcmp(got, dir) == 0, + "$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)"); + free(got); free(selfdir); + + unlink(link); + free(link); + rmdir(dir); +} + +int main(void) +{ + test_defaults(); + test_env_key(); + test_env_others(); + test_thinking_parse(); + test_file_thinking(); + test_file_override(); + test_binary_dir_lookup(); + test_env_key_precedence(); + test_symlink_dir(); + printf("\n%d 项检查,%d 项失败\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_http.c b/tests/test_http.c new file mode 100644 index 0000000..6fd67c1 --- /dev/null +++ b/tests/test_http.c @@ -0,0 +1,112 @@ +/* test_http.c - HTTP 层纯逻辑测试(不联网) */ +#include "http.h" +#include "util.h" + +#include +#include +#include + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ + } while (0) + +int main(void) +{ + puts("重试判定:只重试服务端明确报错的情况"); + + /* 应当重试 */ + long retryable[] = { 408, 409, 429, 500, 502, 503, 504 }; + for (size_t i = 0; i < sizeof retryable / sizeof retryable[0]; i++) + 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++) + CHECK(http_status_retryable(permanent[i]) == 0, + "HTTP %ld 不应重试", permanent[i]); + + /* 默认策略与上限的合理性 */ + HttpRetry def = HTTP_RETRY_DEFAULT; + HttpRetry none = HTTP_RETRY_NONE; + CHECK(def.max_retries > 0, "默认应当会重试"); + CHECK(none.max_retries == 0, "HTTP_RETRY_NONE 应当不重试"); + CHECK(HTTP_MAX_BODY >= 1024 * 1024, "响应体上限不该小到影响正常使用"); + CHECK(HTTP_MAX_ERRBODY < HTTP_MAX_BODY, "错误体上限应远小于正文上限"); + + /* ---- 调试输出的密钥脱敏 ---- */ + puts("--verbose 输出脱敏"); + { + const char *key = "sk-0f114e3dac8249f0a36fd8fd5c517360"; + char *buf = NULL; + size_t sz = 0; + FILE *f = open_memstream(&buf, &sz); + + /* 这条正是曾经漏出去的: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 是整块传来的,不是逐行) */ + const char *hdrs = "POST /v1/chat/completions HTTP/2\r\n" + "Authorization: Bearer sk-0f114e3dac8249f0a36fd8fd5c517360\r\n" + "Content-Type: application/json\r\n"; + http_write_redacted(f, hdrs, strlen(hdrs), key); + + /* 其它内容不能被改动 */ + const char *plain = "== Info: TLSv1.3 (IN), TLS handshake, Finished (20):\n"; + http_write_redacted(f, plain, strlen(plain), key); + + /* 没有密钥时原样输出 */ + http_write_redacted(f, "no key here\n", 12, ""); + + fclose(f); + CHECK(strstr(buf, key) == NULL, "输出里不应残留完整密钥"); + CHECK(strstr(buf, "<已隐藏>") != NULL, "应替换为占位符"); + CHECK(strstr(buf, "[HTTP/2]") != NULL, "HTTP/2 调试行本身应保留"); + CHECK(strstr(buf, "Authorization: Bearer <已隐藏>") != NULL, + "请求头里的密钥应脱敏: %.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 期间就段错误。 + * + * 这里故意用一个格式非法的 URL:libcurl 会在真正联网之前就拒绝它, + * 所以本测试仍然不碰网络。而 strlen(NULL) 发生在设置 POSTFIELDS 期间、 + * curl_easy_perform 之前,也就是说旧代码在这里必崩 —— 这个断言对旧 + * 代码是有效的回归守卫,不是走过场。 */ + static const HttpRetry no_retry = HTTP_RETRY_NONE; + char errbuf[256] = ""; + int attempts = 0; + long status = 0; + + http_global_init(); + int rc = http_post(":::", "sk-test", NULL, 5, 0, &no_retry, + NULL, &status, &attempts, errbuf, sizeof errbuf); + http_global_cleanup(); + + CHECK(rc == -1, "非法 URL 应当返回 -1(实际返回 %d)", rc); + CHECK(errbuf[0] != '\0', "失败时必须给出错误说明"); + } + + printf("\n%d 项检查,%d 项失败\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_i18n.c b/tests/test_i18n.c new file mode 100644 index 0000000..d88e53f --- /dev/null +++ b/tests/test_i18n.c @@ -0,0 +1,219 @@ +/* test_i18n.c - 界面文案表 + * + * 这张表是"每种语言一条"。它最容易出的错不是逻辑,而是漏:加了键忘了翻, + * 或者译文里少了个占位符({path} 写成 {paths})—— 用户看到的就是一句缺字的话。 + * 所以这里的检查全部是机械的:遍历表,逐条比对。 + * + * 文案本身好不好不在这里管;"英文界面是不是每句都有着落"才是。 + */ +#include "i18n.h" + +#include +#include +#include + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ + } while (0) + +/* 收一份占位符名单({name} 里的 name),按出现顺序放进 out */ +static int placeholders(const char *s, char (*out)[32], int max) +{ + int n = 0; + for (const char *p = s; *p; p++) { + if (*p != '{') continue; + const char *e = strchr(p + 1, '}'); + if (!e) break; + size_t len = (size_t)(e - p - 1); + if (len == 0 || len >= 32 || n >= max) break; + memcpy(out[n], p + 1, len); + out[n][len] = '\0'; + n++; + p = e; + } + return n; +} + +/* 收一份 printf 转换符名单(%zu、%c、%.20s …),原样带走标志/宽度/长度。 + * json.* 那几条是 printf 模板而不是 {name},两种语言只要转换符顺序或类型 + * 对不上,snprintf 就会读错参数 —— 比少字更糟,得由机器盯住。 */ +static int printf_specs(const char *s, char (*out)[16], int max) +{ + int n = 0; + for (const char *p = s; p && *p; p++) { + if (*p != '%' || n >= max) continue; + const char *q = p + 1; + while (*q && strchr("-+ #0", *q)) q++; /* 标志 */ + while (*q >= '0' && *q <= '9') q++; /* 宽度 */ + if (*q == '.') { q++; while (*q >= '0' && *q <= '9') q++; } + while (*q == 'h' || *q == 'l' || *q == 'L' || + *q == 'z' || *q == 'j' || *q == 't') q++; /* 长度修饰 */ + if (strchr("diouxXcspfgeE", *q)) { + size_t len = (size_t)(q + 1 - p); + if (len < 16) { + memcpy(out[n], p, len); + out[n][len] = '\0'; + n++; + } + } + if (*q) p = q; + } + return n; +} + +/* 有没有中日韩字符(含全角标点:〔〕())。英文那一列里出现它,基本就是 + * "照抄了中文"或者"美化用的框线忘了换" —— 用户看到的是英文句子中间卡着一对 + * 中文括号。顺带把"英文栏里混进日文假名"这种复制粘贴也一并挡住。 */ +static int has_cjk(const char *s) +{ + for (const unsigned char *p = (const unsigned char *)s; p && *p; p++) { + if (p[0] >= 0xE3 && p[0] <= 0xEF && p[1] >= 0x80 && p[2] >= 0x80) { + unsigned cp = (unsigned)((p[0] & 0x0Fu) << 12) + | (unsigned)((p[1] & 0x3Fu) << 6) + | (unsigned)(p[2] & 0x3Fu); + if ((cp >= 0x3000 && cp <= 0x30FF) || /* 标点 / 假名 */ + (cp >= 0x4E00 && cp <= 0x9FFF) || /* 汉字 */ + (cp >= 0xFF00 && cp <= 0xFFEF)) /* 全角 */ + return 1; + } + } + return 0; +} + +int main(void) +{ + size_t n = 0; + const LigentMsg *tab = i18n_table(&n); + CHECK(tab != NULL && n > 0, "文案表不能是空的"); + + puts("每条两种语言都齐、key 不重复、占位符对得上"); + for (size_t i = 0; i < n; i++) { + const LigentMsg *m = &tab[i]; + CHECK(m->key && *m->key, "第 %zu 条没有 key", i); + if (!m->key || !*m->key) continue; + CHECK(m->en && *m->en, "%s 缺英文", m->key); + CHECK(m->zh && *m->zh, "%s 缺中文", m->key); + CHECK(!has_cjk(m->en ? m->en : ""), "%s 的英文里混进了中日韩字符", + m->key); + + /* key 唯一性:比较是 O(n²),但每个 key 只记一条检查 —— 写成循环里 + * CHECK 的话,check-docs 数出来的"单元项数"就没意义了。 */ + int dup = 0; + for (size_t j = i + 1; j < n; j++) + if (strcmp(tab[j].key, m->key) == 0) dup = 1; + CHECK(!dup, "key 重复:%s", m->key); + + 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)", + m->key, ne, nz); + for (int a = 0; a < ne && a < nz; a++) { + int found = 0; + for (int b = 0; b < nz; b++) + if (strcmp(pe[a], pz[b]) == 0) found = 1; + CHECK(found, "%s:英文的 {%s} 在中文里没有对应", m->key, pe[a]); + } + /* 译文里多出一个原文没有的占位符,同样会在运行时漏字 */ + for (int a = 0; a < nz && a < ne; a++) { + int found = 0; + for (int b = 0; b < ne; b++) + if (strcmp(pz[a], pe[b]) == 0) found = 1; + CHECK(found, "%s:中文的 {%s} 在英文里没有对应", m->key, pz[a]); + } + } + + /* 表里有一部分键是**当 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); + for (int a = 0; a < ce && a < cz; a++) + CHECK(strcmp(se[a], sz[a]) == 0, + "%s:第 %d 个转换符不同(en %s / zh %s)", + m->key, a + 1, se[a], sz[a]); + } + + puts("查表、切换语言、漏键可见"); + i18n_set_lang(LIGENT_LANG_ZH); + CHECK(strcmp(t("cli.err.unknown_option"), + i18n_lookup(LIGENT_LANG_ZH, "cli.err.unknown_option")) == 0, + "t() 应取当前语言的那条"); + CHECK(i18n_lang() == LIGENT_LANG_ZH, "语言状态"); + CHECK(strstr(t("cli.err.unknown_option"), "未知") != NULL, "中文那条"); + i18n_set_lang(LIGENT_LANG_EN); + 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")); + + puts("占位符替换按名字走"); + i18n_set_lang(LIGENT_LANG_EN); + const char *r = tf("cli.err.unknown_option", "option", "--nope", NULL); + 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); + CHECK(strstr(two, "DeepSeek") && strstr(two, "OpenAI"), "两个值都要在"); + CHECK(strstr(two, "{") == NULL, "不该有没填上的占位符:%s", two); + + puts("语言名解析"); + CHECK(i18n_parse_lang("zh") == LIGENT_LANG_ZH, "zh"); + CHECK(i18n_parse_lang("zh_CN.UTF-8") == LIGENT_LANG_ZH, "zh_CN.UTF-8"); + CHECK(i18n_parse_lang("zh-Hans") == LIGENT_LANG_ZH, "zh-Hans"); + CHECK(i18n_parse_lang("en_US") == LIGENT_LANG_EN, "en_US"); + CHECK(i18n_parse_lang("C") == LIGENT_LANG_EN, "C 就是英文"); + CHECK(i18n_parse_lang("POSIX") == LIGENT_LANG_EN, "POSIX 也是英文"); + CHECK(i18n_parse_lang("fr_FR") < 0, "认不出来的返回 -1"); + CHECK(i18n_parse_lang("") < 0, "空串返回 -1"); + CHECK(i18n_parse_lang(NULL) < 0, "NULL 返回 -1"); + + puts("环境探测:LIGENT_LANG > LC_ALL > LC_MESSAGES > LANG,认不出/没设 -> 英文"); + unsetenv("LIGENT_LANG"); + unsetenv("LC_ALL"); + unsetenv("LC_MESSAGES"); + unsetenv("LANG"); + CHECK(i18n_lang_from_env() == LIGENT_LANG_EN, "什么都没设 -> 英文"); + setenv("LANG", "zh_CN.UTF-8", 1); + CHECK(i18n_lang_from_env() == LIGENT_LANG_ZH, "LANG=zh_CN -> 中文"); + setenv("LC_MESSAGES", "en_US.UTF-8", 1); + CHECK(i18n_lang_from_env() == LIGENT_LANG_EN, "LC_MESSAGES 优先于 LANG"); + setenv("LC_ALL", "zh_TW", 1); + CHECK(i18n_lang_from_env() == LIGENT_LANG_ZH, "LC_ALL 优先于 LC_MESSAGES"); + setenv("LIGENT_LANG", "en", 1); + CHECK(i18n_lang_from_env() == LIGENT_LANG_EN, "LIGENT_LANG 优先级最高"); + setenv("LIGENT_LANG", "fr", 1); + CHECK(i18n_lang_from_env() == LIGENT_LANG_EN, "LIGENT_LANG 认不出时回退英文"); + unsetenv("LIGENT_LANG"); + unsetenv("LC_ALL"); + unsetenv("LC_MESSAGES"); + unsetenv("LANG"); + CHECK(i18n_lang_from_env() == LIGENT_LANG_EN, "清干净之后仍是英文"); + + printf("\n%d 项检查,%d 项失败\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_image.c b/tests/test_image.c new file mode 100644 index 0000000..6b5d5fa --- /dev/null +++ b/tests/test_image.c @@ -0,0 +1,318 @@ +/* test_image.c - 图片格式嗅探、本地备份、base64、请求前展开 */ +#include "image.h" +#include "util.h" + +#include +#include +#include +#include +#include + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ + } while (0) + +static const char PNG[] = "\x89PNG\r\n\x1a\n rest"; +static const char JPEG[] = "\xff\xd8\xff\xe0 rest"; +static const char GIF87[] = "GIF87a rest"; +static const char GIF89[] = "GIF89a rest"; +static const char WEBP[] = "RIFF\x00\x00\x00\x00WEBPVP8 "; + +static void test_sniff(void) +{ + puts("按文件内容认格式(不看扩展名)"); + const char *ext = NULL, *mime = NULL; + + CHECK(image_sniff((const unsigned char *)PNG, 14, &ext, &mime) == 0 && + strcmp(ext, "png") == 0 && strcmp(mime, "image/png") == 0, "PNG"); + CHECK(image_sniff((const unsigned char *)JPEG, 7, &ext, &mime) == 0 && + strcmp(ext, "jpg") == 0 && strcmp(mime, "image/jpeg") == 0, "JPEG"); + CHECK(image_sniff((const unsigned char *)GIF87, 10, &ext, &mime) == 0 && + strcmp(ext, "gif") == 0, "GIF87a"); + CHECK(image_sniff((const unsigned char *)GIF89, 10, &ext, &mime) == 0 && + strcmp(ext, "gif") == 0, "GIF89a"); + 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, + "截断的 PNG 头不该被认出来"); + CHECK(image_sniff((const unsigned char *)"RIFFxxxxNOPE", 12, &ext, &mime) != 0, + "RIFF 但不是 WEBP 不该被认成 WebP"); + CHECK(image_sniff(NULL, 0, &ext, &mime) != 0, "NULL 不该崩"); +} + +static void test_backup(void) +{ + puts("本地备份:命名、内容、不重复"); + char tmpl[] = "/tmp/ligent_img_XXXXXX"; + char *dir = mkdtemp(tmpl); + CHECK(dir != NULL, "建临时目录失败"); + if (!dir) return; + + const unsigned char data[] = "\x89PNG\r\n\x1a\n payload"; + size_t n = sizeof data - 1; + + char *b1 = image_backup_save(dir, data, n, "png"); + CHECK(b1 != NULL, "备份应成功"); + if (!b1) { return; } + + /* 名字形如 <上传时间戳>_<4位随机>. */ + CHECK(strlen(b1) > 5, "名字不该太短:%s", b1); + char *dot = strrchr(b1, '.'); + CHECK(dot && strcmp(dot, ".png") == 0, "扩展名应从嗅探结果来:%s", b1); + CHECK(dot - b1 >= 7, "时间戳 + 下划线 + 4 位随机:%s", b1); + CHECK(b1[dot - b1 - 5] == '_', "下划线位置应在随机后缀前:%s", b1); + + char *p1 = image_backup_path(dir, b1); + size_t len = 0; + char *back = read_entire_file(p1, &len); + 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)"); + free(b2); + + free(b1); + /* 清场 */ + char *cmd = xasprintf("rm -rf %s", dir); + if (system(cmd) != 0) { /* 清理失败不影响断言 */ } + free(cmd); +} + +static void test_base64(void) +{ + puts("base64:长度、补位、往返"); + /* 用几组长度覆盖三种补位情况:3n、3n+1、3n+2 */ + struct { const char *in; const char *want; } cases[] = { + { "", "" }, + { "f", "Zg==" }, + { "fo", "Zm8=" }, + { "foo", "Zm9v" }, + { "foob", "Zm9vYg==" }, + { "fooba", "Zm9vYmE=" }, + { "foobar", "Zm9vYmFy" }, + }; + for (size_t i = 0; i < sizeof cases / sizeof cases[0]; i++) { + 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, + got ? got : "(null)"); + CHECK(base64_encoded_len(n) == strlen(cases[i].want), + "长度函数应与实际一致(%zu)", n); + free(got); + } + /* 二进制字节(0x00 / 0xFF 这类不能被当字符串处理) */ + const unsigned char bin[] = { 0x00, 0xFF, 0x10, 0x80 }; + char *b = base64_encode(bin, sizeof bin); + 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"; + size_t m = sizeof magic - 1; + if (n < m) return 0; + memcpy(out, magic, m); + for (size_t i = m; i < n; i++) out[i] = (unsigned char)(i & 0xFF); + return n; +} + +/* 把一段备份引用塞进一条 user 消息,模拟 read_image 之后的历史 */ +static const char *ref_message_type = "ligent_image"; + +static JsonValue *history_with_ref(const char *backup) +{ + JsonValue *msgs = json_new_array(); + + JsonValue *sys = json_new_object(); + json_object_set_string(sys, "role", "system"); + json_object_set_string(sys, "content", "你是助手"); + json_array_push(msgs, sys); + + JsonValue *part = json_new_object(); + json_object_set_string(part, "type", ref_message_type); + json_object_set_string(part, "backup", backup); + JsonValue *content = json_new_array(); + json_array_push(content, part); + + JsonValue *um = json_new_object(); + json_object_set_string(um, "role", "user"); + json_object_set(um, "content", content); + json_array_push(msgs, um); + return msgs; +} + +static void test_expand(void) +{ + puts("请求前展开:引用 -> image_url + base64"); + char tmpl[] = "/tmp/ligent_imgx_XXXXXX"; + char *dir = mkdtemp(tmpl); + CHECK(dir != NULL, "建临时目录失败"); + if (!dir) return; + image_set_bindir(dir); + + unsigned char png[256]; + size_t n = fake_png(png, sizeof png); + char *backup = image_backup_save(dir, png, n, "png"); + CHECK(backup != NULL, "备份应成功"); + if (!backup) { image_set_bindir("."); return; } + + /* --- 正常展开 --- */ + JsonValue *msgs = history_with_ref(backup); + int degraded = -1; + JsonValue *wire = image_expand_for_request(msgs, °raded); + + CHECK(wire && json_array_size(wire) == 2, "副本消息条数应与原历史一致"); + CHECK(degraded == 0, "正常展开不该有降级"); + + JsonValue *wu = json_array_get(wire, 1); + JsonValue *wc = json_object_get(wu, "content"); + CHECK(wc && wc->type == JSON_ARRAY && json_array_size(wc) == 1, + "附件消息应有一个 content 块"); + JsonValue *wp = json_array_get(wc, 0); + CHECK(strcmp(json_get_string(wp, "type"), "image_url") == 0, + "发送形态的类型应是 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)"); + + /* 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"); + CHECK(strstr(orig, backup) != NULL, "原历史里应记着备份名"); + free(orig); + json_free(wire); + + /* --- 备份没了:就地降级 --- */ + char *bpath = image_backup_path(dir, backup); + CHECK(unlink(bpath) == 0, "删掉备份文件"); + free(bpath); + + degraded = 0; + wire = image_expand_for_request(msgs, °raded); + CHECK(degraded == 1, "读不到备份应降级 1 处"); + JsonValue *wc2 = json_object_get(json_array_get(wire, 1), "content"); + JsonValue *wp2 = json_array_get(wc2, 0); + CHECK(strcmp(json_get_string(wp2, "type"), "text") == 0, + "发送形态应降级成 text 块"); + const char *dt = json_get_string(wp2, "text"); + 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 兜底的判据 */ + CHECK(image_has_refs(msgs) == 0, "降级后历史里不该还有图片引用"); + json_free(msgs); + + /* --- 4xx 兜底:image_degrade_all --- */ + 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(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)"); + CHECK(t2 && strstr(t2, "请规范化图片") != NULL, "应给出下一步动作"); + CHECK(t2 && strchr(t2, '\n') == NULL, "说明不该带换行"); + json_free(m2); + + /* 没有引用时不该动历史 */ + JsonValue *plain = json_new_array(); + JsonValue *pm = json_new_object(); + json_object_set_string(pm, "role", "user"); + json_object_set_string(pm, "content", "你好"); + json_array_push(plain, pm); + CHECK(image_has_refs(plain) == 0, "纯文本历史不该报有引用"); + CHECK(image_degrade_all(plain) == 0, "纯文本历史不该被改"); + JsonValue *pw = image_expand_for_request(plain, NULL); + CHECK(pw && json_array_size(pw) == 1, "纯文本历史应能原样复制"); + json_free(pw); + json_free(plain); + + free(backup); + image_set_bindir("."); + char *cmd = xasprintf("rm -rf %s", tmpl); + if (system(cmd) != 0) { } + free(cmd); +} + +static void test_pending(void) +{ + puts("待发附件:取走即清空"); + image_pending_take(); /* 先清干净 */ + + image_pending_add("1700000000_aaaa.png"); + image_pending_add("1700000001_bbbb.png"); + JsonValue *arr = image_pending_take(); + CHECK(arr && json_array_size(arr) == 2, "应取到 2 个块"); + 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, + "不该再有 file_id 这种协议字段"); + CHECK(strcmp(json_get_string(json_array_get(arr, 1), "backup"), + "1700000001_bbbb.png") == 0, "第二个备份名"); + } + json_free(arr); + + CHECK(image_pending_take() == NULL, "取走之后应该空了"); + image_pending_add(NULL); + image_pending_add(""); + CHECK(image_pending_take() == NULL, "空备份名不该被记下"); +} + +int main(void) +{ + test_sniff(); + test_backup(); + test_base64(); + test_expand(); + test_pending(); + printf("\n%d 项检查,%d 项失败\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_json.c b/tests/test_json.c new file mode 100644 index 0000000..b728350 --- /dev/null +++ b/tests/test_json.c @@ -0,0 +1,315 @@ +/* test_json.c - JSON 解析器/序列化器 单元测试 */ +#include "json.h" +#include "util.h" + +#include +#include +#include + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ + } while (0) + +static void test_basic(void) +{ + puts("test_basic"); + char err[256]; + JsonValue *v = json_parse("{\"a\": 1, \"b\": [true, false, null], \"c\": \"hi\"}", + err, sizeof err); + CHECK(v != NULL, "解析失败: %s", err); + if (!v) return; + CHECK(json_get_number(v, "a", -1) == 1, "a 应为 1"); + CHECK(json_array_size(json_object_get(v, "b")) == 3, "b 应有 3 个元素"); + CHECK(json_bool_value(json_array_get(json_object_get(v, "b"), 0), 0) == 1, + "b[0] 应为 true"); + CHECK(json_array_get(json_object_get(v, "b"), 2)->type == JSON_NULL, + "b[2] 应为 null"); + CHECK(strcmp(json_get_string(v, "c"), "hi") == 0, "c 应为 hi"); + + char *dump = json_dump(v); + JsonValue *v2 = json_parse(dump, err, sizeof err); + CHECK(v2 != NULL, "往返解析失败: %s", err); + char *dump2 = json_dump(v2); + CHECK(strcmp(dump, dump2) == 0, "往返不一致:\n %s\n %s", dump, dump2); + free(dump); free(dump2); + json_free(v); json_free(v2); +} + +static void test_escapes(void) +{ + puts("test_escapes"); + char err[256]; + JsonValue *v = json_parse("\"a\\nb\\t\\\"c\\\\d\\u4e2d\\u6587\\uD83D\\uDE00\"", + err, sizeof err); + CHECK(v != NULL, "解析失败: %s", err); + if (!v) return; + const char *s = json_string_value(v); + CHECK(strcmp(s, "a\nb\t\"c\\d中文😀") == 0, "转义解码错误: [%s]", s); + + char *q = json_quote_string(s); + JsonValue *v2 = json_parse(q, err, sizeof err); + CHECK(v2 != NULL, "转义往返失败: %s / %s", q, err); + CHECK(v2 && strcmp(json_string_value(v2), s) == 0, "转义往返内容不一致"); + free(q); + json_free(v); json_free(v2); +} + +static void test_numbers(void) +{ + puts("test_numbers"); + char err[256]; + JsonValue *v = json_parse("[0, -1, 3.5, 1e3, -2.5e-2, 123456789012]", err, sizeof err); + CHECK(v != NULL, "解析失败: %s", err); + if (!v) return; + CHECK(json_number_value(json_array_get(v, 0), -1) == 0, "[0]"); + CHECK(json_number_value(json_array_get(v, 1), 0) == -1, "[1]"); + CHECK(json_number_value(json_array_get(v, 2), 0) == 3.5, "[2]"); + CHECK(json_number_value(json_array_get(v, 3), 0) == 1000, "[3]"); + CHECK(json_number_value(json_array_get(v, 4), 0) == -0.025, "[4]"); + CHECK(json_number_value(json_array_get(v, 5), 0) == 123456789012.0, "[5]"); + json_free(v); + + /* 严格按 RFC 8259 扫描数字:这些以前全都被接受 —— strtod 会把 "1e" 读成 1、 + * "5." 读成 5,于是一个手写错的数字会被静默当成另一个数用下去(配置文件、 + * 存档都是手写的,用户只会在很久之后从一个不相干的现象里发现它)。 */ + const char *bad_nums[] = { + "01", "-01", "5.", ".5", "+1", "1e", "1e+", "1e-", "1.2e", "-", "1_000", + }; + for (size_t i = 0; i < sizeof bad_nums / sizeof bad_nums[0]; i++) { + JsonValue *b = json_parse(bad_nums[i], err, sizeof err); + CHECK(b == NULL, "非法数字应被拒绝: [%s]", bad_nums[i]); + json_free(b); + } + /* 边界上合法的写法不能被误伤 */ + const char *ok_nums[] = { + "0", "-0", "0.0", "10", "-1.5", "1e3", "1E3", "1e+3", "1e-3", "0.1e10", + "123456789012345678901234567890", "1e308", + }; + for (size_t i = 0; i < sizeof ok_nums / sizeof ok_nums[0]; i++) { + JsonValue *g = json_parse(ok_nums[i], err, sizeof err); + CHECK(g != NULL && g->type == JSON_NUMBER, + "合法数字被拒: [%s] (%s)", ok_nums[i], err); + json_free(g); + } +} + +static void test_invalid(void) +{ + puts("test_invalid"); + char err[256]; + const char *bad[] = { + "{", "}", "[1,]", "{\"a\":}", "\"unterminated", "tru", "{\"a\" 1}", + "[1 2]", "01abc", "", " ", "{\"a\":1}}", "[1,2] extra", + }; + for (size_t i = 0; i < sizeof bad / sizeof bad[0]; i++) { + JsonValue *v = json_parse(bad[i], err, sizeof err); + CHECK(v == NULL, "应当解析失败但成功了: [%s]", bad[i]); + json_free(v); + } + /* 空字符串是合法 JSON 字符串 */ + JsonValue *e = json_parse("\"\"", err, sizeof err); + CHECK(e != NULL && strcmp(json_string_value(e), "") == 0, "空字符串应为合法"); + json_free(e); +} + +static void test_builders(void) +{ + puts("test_builders"); + JsonValue *o = json_new_object(); + json_object_set_string(o, "model", "gpt-x"); + json_object_set_number(o, "n", 3); + json_object_set_bool(o, "ok", 1); + JsonValue *arr = json_new_array(); + json_array_push(arr, json_new_string("a")); + json_array_push(arr, json_new_string("b")); + json_object_set(o, "items", arr); + json_object_set_string(o, "model", "gpt-y"); /* 覆盖 */ + + char *s = json_dump(o); + CHECK(strcmp(s, "{\"model\":\"gpt-y\",\"n\":3,\"ok\":true,\"items\":[\"a\",\"b\"]}") == 0, + "构造结果不对: %s", s); + free(s); + + /* clone 独立性 */ + JsonValue *c = json_clone(o); + json_object_set_string(o, "model", "changed"); + CHECK(strcmp(json_get_string(c, "model"), "gpt-y") == 0, "clone 应相互独立"); + json_object_remove(o, "model"); + CHECK(json_object_get(o, "model") == NULL, "remove 应删除键"); + json_free(o); json_free(c); +} + +static void test_nested_depth(void) +{ + puts("test_nested_depth"); + char err[256]; + size_t n = 300; + char *deep = xmalloc(n * 2 + 1); + for (size_t i = 0; i < n; i++) deep[i] = '['; + for (size_t i = 0; i < n; i++) deep[n + i] = ']'; + deep[n * 2] = '\0'; + JsonValue *v = json_parse(deep, err, sizeof err); + CHECK(v == NULL, "超深嵌套应被拒绝"); + free(deep); + + JsonValue *ok = json_parse("[[[1]]]", err, sizeof err); + CHECK(ok != NULL, "正常嵌套应成功"); + json_free(ok); +} + +/* 报错必须能定位:只有"语法错了"而不知道错在哪,等于没有报错 */ +static void test_error_location(void) +{ + puts("test_error_location"); + char err[256]; + JsonValue *v; + + /* 尾随逗号:行号必须指向真正出问题的那一行 */ + v = json_parse("{\n \"a\": 1,\n}\n", err, sizeof err); + CHECK(v == NULL, "尾随逗号应报错"); + CHECK(strstr(err, "第 3 行") != NULL, "应报出行号: %s", err); + CHECK(strstr(err, "期望对象的键") != NULL, "应说明期望什么: %s", err); + CHECK(strstr(err, "}") != NULL, "应回显出错处原文: %s", err); + json_free(v); + + /* 未闭合的数组:位置应指向输入结束处 */ + v = json_parse("[1, 2\n", err, sizeof err); + CHECK(v == NULL, "未闭合数组应报错"); + CHECK(strstr(err, "第 2 行") != NULL, "未闭合应指向结尾: %s", err); + CHECK(strstr(err, "结束") != NULL, "应说明输入在此结束: %s", err); + json_free(v); + + /* 缺冒号 */ + v = json_parse("{\"a\" 1}", err, sizeof err); + CHECK(v == NULL, "缺冒号应报错"); + CHECK(strstr(err, "第 1 行") && strstr(err, "':'") != NULL, "缺冒号: %s", err); + json_free(v); + + /* 尾部多余内容 */ + v = json_parse("{\"a\": 1} extra", err, sizeof err); + CHECK(v == NULL, "尾部多余内容应报错"); + CHECK(strstr(err, "多余") != NULL, "应说明是多余内容: %s", err); + json_free(v); + + /* 非法字符要被回显出来 */ + v = json_parse("{\n \"a\": @\n}", err, sizeof err); + CHECK(v == NULL, "非法字符应报错"); + CHECK(strstr(err, "@") != NULL, "应回显非法字符: %s", err); + CHECK(strstr(err, "第 2 行") != NULL, "行号应为第 2 行: %s", err); + json_free(v); + + /* 控制字符不能被原样吐到终端上 */ + v = json_parse("{\"a\": \x01\x02}", err, sizeof err); + CHECK(v == NULL, "控制字符应报错"); + CHECK(strchr(err, '\x01') == NULL, "错误信息里不应含原始控制字符"); + json_free(v); + + /* 合法输入不受影响 */ + v = json_parse("{\"a\": [1, {\"b\": null}]}", err, sizeof err); + CHECK(v != NULL, "合法输入不应报错: %s", err); + json_free(v); +} + +/* 序列化出去的东西必须是**合法 UTF-8**。 + * + * 服务端会因为请求体里有非法 UTF-8 直接回 + * 400 ... invalid unicode code point + * 而那段坏数据一旦进了对话历史,之后每次请求都会失败 —— 除非丢掉整轮对话。 + * 坏字节的来源很日常:某条命令在非 UTF-8 locale 下打出来的路径、被截断的多 + * 字节字符、二进制垃圾。所以这条不能只靠"在入口清理",必须在序列化器这层兜住: + * 出口是唯一能保证"发出去的永远合法"的地方。 + * + * 这个校验函数故意用 utf8_seq_len 自己走一遍,而不是信任 dump 的实现 —— + * 断言的是**产物**的性质,不是实现的自述。 */ +static int utf8_all_valid(const char *s) +{ + size_t n = strlen(s), i = 0; + while (i < n) { + size_t adv = utf8_seq_len(s + i, n - i); + if (adv == 0) return 0; + i += adv; + } + return 1; +} + +static void test_dump_utf8_safety(void) +{ + puts("test_dump_utf8_safety"); + + /* utf8_seq_len:合法序列返回长度 */ + CHECK(utf8_seq_len("\xe4\xb8\xad", 3) == 3, "合法 CJK 应是 3 字节"); + CHECK(utf8_seq_len("A", 1) == 1, "ASCII 应是 1 字节"); + CHECK(utf8_seq_len("\xf0\x9f\x98\x80", 4) == 4, "emoji 应是 4 字节"); + CHECK(utf8_seq_len("\xc2\x80", 2) == 2, "U+0080 的 2 字节编码应合法"); + + /* 非法的一律返回 0 */ + CHECK(utf8_seq_len("\x80", 1) == 0, "孤立续字节应非法"); + CHECK(utf8_seq_len("\xe4\xb8", 2) == 0, "被截断的序列应非法"); + CHECK(utf8_seq_len("\xc0\xaf", 2) == 0, "过长编码应非法"); + CHECK(utf8_seq_len("\xed\xa0\x80", 3) == 0, "UTF-16 代理区应非法"); + 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 */ + static const char *bad[] = { + "a\x80" "b", /* 孤立续字节 */ + "a\xe4\xb8" "b", /* 截断的 3 字节序列 */ + "a\xc0\xaf" "b", /* 过长编码(历史上被用来夹带 '/') */ + "a\xed\xa0\x80" "b", /* UTF-16 代理区 */ + "a\xf5\x80\x80\x80" "b", /* 超出 U+10FFFF */ + "\xff\xfe", /* 二进制垃圾 */ + "\xe4\xb8\xad\x80\xe6", /* 合法中文里夹一个坏字节 */ + }; + for (size_t k = 0; k < sizeof bad / sizeof bad[0]; k++) { + JsonValue *o = json_new_object(); + 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); + if (back) { + const char *got = json_get_string(back, "content"); + CHECK(got && strstr(got, "\xef\xbf\xbd") != NULL, + "第 %zu 个输出里应出现 U+FFFD 替换字符", k); + json_free(back); + } + free(d); + json_free(o); + } + + /* 合法的多字节内容必须**原样保留** —— 别把好数据一起误伤 */ + JsonValue *o = json_new_object(); + json_object_set_string(o, "content", "中文 😀 tail"); + char *d = json_dump(o); + CHECK(strstr(d, "中文") != NULL, "合法中文不该被改动: %s", d); + CHECK(strstr(d, "\xf0\x9f\x98\x80") != NULL, "合法 emoji 不该被改动: %s", d); + CHECK(strstr(d, "\xef\xbf\xbd") == NULL, "合法输入不该出现替换字符: %s", d); + free(d); + json_free(o); +} + +int main(void) +{ + test_basic(); + test_escapes(); + test_numbers(); + test_invalid(); + test_builders(); + test_nested_depth(); + test_error_location(); + test_dump_utf8_safety(); + printf("\n%d 项检查,%d 项失败\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_lineedit.c b/tests/test_lineedit.c new file mode 100644 index 0000000..9097f36 --- /dev/null +++ b/tests/test_lineedit.c @@ -0,0 +1,279 @@ +/* test_lineedit.c - 行编辑器的缓冲区逻辑与历史记录(不需要终端) */ +#include "lineedit.h" +#include "util.h" + +#include +#include +#include +#include + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ + } while (0) + +#define BUFEQ(lb, want) \ + CHECK(strcmp((lb).buf, (want)) == 0 && (lb).len == strlen(want), \ + "内容应为 [%s],实际 [%s]", want, (lb).buf) + +/* 读一个文本文件的前 n 字节到 buf(用于检查落盘格式) */ +static void slurp(const char *path, char *buf, size_t buflen) +{ + buf[0] = '\0'; + FILE *f = fopen(path, "r"); + if (!f) return; + size_t n = fread(buf, 1, buflen - 1, f); + buf[n] = '\0'; + fclose(f); +} + +static void test_history_file(void) +{ + puts("输入历史的持久化"); + char path[] = "/tmp/ligent_hist_XXXXXX"; + int fd = mkstemp(path); + CHECK(fd >= 0, "mkstemp 失败"); + if (fd < 0) return; + close(fd); + unlink(path); /* 故意从"文件不存在"开始 */ + + line_history_clear(); + CHECK(line_history_load(path, 100) == 0, "文件不存在不算错误"); + CHECK(line_history_len() == 0, "没有文件时历史应为空"); + 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, "与上一条重复应被忽略"); + CHECK(line_history_add("/think high") == 1, "不同内容应记下"); + line_history_append(path, "/think high"); + + char raw[512]; + slurp(path, raw, sizeof raw); + CHECK(strcmp(raw, "echo hello\n/think high\n") == 0, + "普通命令应原样一行,实际 %s", raw); + + line_history_clear(); + CHECK(line_history_load(path, 100) == 0, "加载应成功"); + 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(); + + /* 反斜杠和换行必须能原样往返:一条历史在文件里只占一行 */ + unlink(path); + line_history_add("echo C:\\Users\\me"); + line_history_append(path, "echo C:\\Users\\me"); + line_history_add("第一行\n第二行"); /* 反斜杠续行拼出来的多行输入 */ + line_history_append(path, "第一行\n第二行"); + line_history_add("中文历史"); + line_history_append(path, "中文历史"); + + slurp(path, raw, sizeof raw); + CHECK(strchr(raw, '\n') != NULL && strstr(raw, "\n\n") == NULL, + "每条只占一行,实际 %s", raw); + int newlines = 0; + for (const char *p = raw; *p; p++) if (*p == '\n') 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(strcmp(line_history_get(0), "echo C:\\Users\\me") == 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)"); + CHECK(strcmp(line_history_get(2), "中文历史") == 0, "中文应原样还原"); + line_history_clear(); + + /* 超过上限:只留最新的 keep 条,并且文件被就地截短 */ + unlink(path); + for (int i = 0; i < 10; i++) { + char *s = xasprintf("cmd-%d", i); + line_history_add(s); + line_history_append(path, s); + free(s); + } + 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); + + line_history_clear(); + line_history_load(path, 4); + 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]", + line_history_get(0) ? line_history_get(0) : "(null)"); + CHECK(line_history_get(3) && strcmp(line_history_get(3), "cmd-9") == 0, + "最后一条应是 cmd-9"); + CHECK(line_history_get(4) == NULL, "第 5 条应不存在"); + + slurp(path, raw, sizeof raw); + lines = 0; + for (const char *p = raw; *p; p++) if (*p == '\n') 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()); + line_history_clear(); + + unlink(path); +} + +int main(void) +{ + puts("插入与删除"); + LineBuf lb; + linebuf_init(&lb); + linebuf_set(&lb, ""); + BUFEQ(lb, ""); + + linebuf_insert(&lb, "hello", 5); + BUFEQ(lb, "hello"); + CHECK(lb.pos == 5, "插入后光标应在末尾,实际 %zu", lb.pos); + + /* 在中间插入 */ + linebuf_home(&lb); + CHECK(lb.pos == 0, "Home 应回到行首"); + linebuf_right(&lb); + linebuf_right(&lb); + linebuf_insert(&lb, "XY", 2); + BUFEQ(lb, "heXYllo"); + CHECK(lb.pos == 4, "插入后光标应后移,实际 %zu", lb.pos); + + /* 退格删的是光标前的字符 */ + linebuf_backspace(&lb); + BUFEQ(lb, "heXllo"); + + /* Delete 删的是光标处的字符 */ + linebuf_home(&lb); + linebuf_delete(&lb); + BUFEQ(lb, "eXllo"); + linebuf_end(&lb); + linebuf_delete(&lb); /* 末尾 Delete 应无动作 */ + BUFEQ(lb, "eXllo"); + linebuf_free(&lb); + + puts("UTF-8:按字符而非字节移动"); + linebuf_init(&lb); + linebuf_set(&lb, "中文abc"); /* 中(3) 文(3) a b c = 9 字节 */ + CHECK(lb.len == 9, "应为 9 字节,实际 %zu", lb.len); + linebuf_left(&lb); + CHECK(lb.pos == 8, "左移一格应为 8,实际 %zu", lb.pos); + linebuf_left(&lb); + linebuf_left(&lb); + linebuf_left(&lb); + CHECK(lb.pos == 3, "再左移三格应停在第二个汉字之后,实际 %zu", lb.pos); + linebuf_left(&lb); + linebuf_left(&lb); + CHECK(lb.pos == 0, "应能移到行首,实际 %zu", lb.pos); + linebuf_left(&lb); + CHECK(lb.pos == 0, "行首再左移不应越界"); + + /* 退格必须整字删除,不能留下半个字符 */ + linebuf_end(&lb); + linebuf_backspace(&lb); + BUFEQ(lb, "中文ab"); + linebuf_backspace(&lb); + BUFEQ(lb, "中文a"); + linebuf_backspace(&lb); + BUFEQ(lb, "中文"); + linebuf_backspace(&lb); + BUFEQ(lb, "中"); + linebuf_backspace(&lb); + BUFEQ(lb, ""); + linebuf_backspace(&lb); + BUFEQ(lb, ""); + + /* 中间删除多字节字符 */ + linebuf_set(&lb, "a中b"); + linebuf_home(&lb); + linebuf_right(&lb); /* 光标到 'a' 之后 */ + linebuf_delete(&lb); /* 删掉 '中' */ + BUFEQ(lb, "ab"); + CHECK(lb.pos == 1, "删除后光标不动,实际 %zu", lb.pos); + linebuf_free(&lb); + + puts("Home / End 走的是逻辑行,不是整个缓冲区"); + linebuf_init(&lb); + linebuf_set(&lb, "one two three"); + linebuf_home(&lb); + CHECK(lb.pos == 0, "单行时 Home 到行首"); + linebuf_end(&lb); + CHECK(lb.pos == lb.len, "单行时 End 到行尾"); + + /* 多行(粘贴进来的那种):Home/End 只在本行内走 */ + linebuf_set(&lb, "aaa\nbbb\nccc"); /* 长度 11 */ + CHECK(lb.len == 11, "应为 11 字节,实际 %zu", lb.len); + linebuf_home(&lb); + CHECK(lb.pos == 8, "末尾按 Home 应到最后一行的行首(8),实际 %zu", lb.pos); + linebuf_end(&lb); + 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); + linebuf_end(&lb); + CHECK(lb.pos == 7, "中间行按 End 应到该行行尾(7),实际 %zu", lb.pos); + + lb.pos = 4; /* 正好在行首 */ + linebuf_home(&lb); + CHECK(lb.pos == 4, "已在行首时 Home 不应跳到整个缓冲区开头"); + lb.pos = 7; /* 正好在行尾(换行符前) */ + linebuf_end(&lb); + CHECK(lb.pos == 7, "已在行尾时 End 不应跳过换行符"); + + linebuf_set(&lb, "one two three"); + lb.pos = 7; /* "one two" 之后 */ + linebuf_kill_to_end(&lb); + BUFEQ(lb, "one two"); + + linebuf_set(&lb, "one two three"); + lb.pos = 8; /* "one two " 之后 */ + linebuf_kill_to_start(&lb); + BUFEQ(lb, "three"); + CHECK(lb.pos == 0, "删到行首后光标应在行首"); + + linebuf_set(&lb, "one two three"); + linebuf_kill_word(&lb); /* 从末尾回删一个词 */ + BUFEQ(lb, "one two "); + linebuf_kill_word(&lb); /* 先跳过空格再删词 */ + BUFEQ(lb, "one "); + linebuf_free(&lb); + + puts("历史记录"); + line_history_clear(); + CHECK(line_history_len() == 0, "初始应为空"); + line_history_add("first"); + line_history_add("second"); + line_history_add("second"); /* 与上一条重复,应忽略 */ + line_history_add(""); /* 空行不入历史 */ + 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"); + line_history_clear(); + CHECK(line_history_len() == 0, "清空后应为空"); + line_history_add("中文历史"); + CHECK(strcmp(line_history_get(0), "中文历史") == 0, "应支持非 ASCII"); + line_history_clear(); + + test_history_file(); + + printf("\n%d 项检查,%d 项失败\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_markdown.c b/tests/test_markdown.c new file mode 100644 index 0000000..90cfe94 --- /dev/null +++ b/tests/test_markdown.c @@ -0,0 +1,522 @@ +/* test_markdown.c - 流式 Markdown 渲染器测试 + * + * 最关键的不变量:把同一段文本任意切分后逐片喂入,输出必须与一次性喂入 + * 完全一致。SSE 的分片位置是随机的,这条不成立就会漏出"半个星号"。 + */ +#include "markdown.h" +#include "util.h" + +#include +#include +#include + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ + } while (0) + +/* 去掉 ANSI 转义,得到"肉眼可见"的文本 */ +static char *strip_ansi(const char *s) +{ + StrBuf out; + sb_init(&out); + for (const char *p = s; *p; ) { + if (*p == 0x1b && p[1] == '[') { + p += 2; + while (*p && !(*p >= '@' && *p <= '~')) p++; + if (*p) p++; + continue; + } + sb_putc(&out, *p++); + } + return sb_detach(&out); +} + +static void show_escaped(const char *s) +{ + for (const unsigned char *p = (const unsigned char *)s; *p; p++) { + if (*p == 0x1b) printf(""); + else if (*p == '\n') printf("\\n"); + else putchar(*p); + } +} + +/* 渲染到内存流;chunk == 0 表示一次性喂入 */ +static char *do_render(const char *input, int enabled, size_t chunk) +{ + char *buf = NULL; + size_t size = 0; + FILE *f = open_memstream(&buf, &size); + if (!f) return NULL; + MdRenderer *r = md_new(enabled); + md_set_out(r, f); + + if (chunk == 0) { + md_feed(r, input); + } else { + size_t len = strlen(input); + for (size_t i = 0; i < len; i += chunk) { + size_t n = len - i < chunk ? len - i : chunk; + char tmp[64]; + if (n >= sizeof tmp) n = sizeof tmp - 1; + memcpy(tmp, input + i, n); + tmp[n] = '\0'; + md_feed(r, tmp); + } + } + md_finish(r); + md_free(r); + fclose(f); + return buf; +} + +static char *render(const char *in, int enabled) +{ + return do_render(in, enabled, 0); +} + +/* ------------------------------------------------------------------ */ + +static void test_split_invariance(void) +{ + puts("分片不变性(一次性 / 3 字节 / 逐字节 必须一致)"); + static const char *samples[] = { + "**粗体**\n", + "普通 *斜体* 文本\n", + "这是 `code` 片段\n", + "# 一级标题\n## 二级标题\n", + "```c\nint main(void) { return 0; }\n```\n", + "- 项目一\n- 项目二\n", + "> 引用内容\n", + "2 * 3 * 4 = 24\n", + "**a** 和 *b* 和 `c`\n", + "---\n", + "----\n", + "-x\n", + "--x\n", + "混合 **粗** 与 `代` 与 *斜* 一行\n", + "# 标题里有 **粗体**\n", + "```\n**这不是粗体**\n```\n", + "```\n没有闭合的代码块\n", + "未闭合 **粗体\n下一行\n", + "*a\nb*\n", + "**\n", + "***\n", + "\n\n**\n\n", + "行内 `代码` 跨分片被切断\n", + "| a | b |\n|---|---|\n| 1 | 22 |\n", + "| 名称 | 值 |\n|---|---|\n| 苹果 | 3 |\n| 香蕉 | 12 |\n", + "| 左 | 中 | 右 |\n|:---|:---:| ---:|\n| a | b | c |\n", + "见 [文档](https://example.com) 说明\n", + "这是 _斜体_ 与 foo_bar_baz\n", + "```\n# 不是标题\n[x](y)\n```\n", + "ls -la | grep foo\n", + "a | b\nc | d\n", + /* 以下三个是模糊测试找到并最小化出来的历史缺陷, + * 每个都曾让"一次性喂"与"分片喂"结果不一致,留作回归见证 */ + "1|`", /* 表格探测挂起整行 -> 行内代码被漏渲染 */ + "- |", /* try_block 提前 return 跳过了列表规则 */ + "s|\n-", /* 表格判断依赖分片边界 */ + "| a |\n|---|\n", /* 表头+分隔行后紧跟 EOF */ + "| a |\n|---|", /* 末尾无换行的表格 */ + "| a | b |\n|---|---|\n| 1 | 2 |\n- 列表\n", /* 表格后紧跟列表 */ + "| a |\n| b |\n| c |\n", /* 有续行但缺分隔行 */ + }; + for (size_t k = 0; k < sizeof samples / sizeof samples[0]; k++) { + char *whole = render(samples[k], 1); + char *by1 = do_render(samples[k], 1, 1); + char *by3 = do_render(samples[k], 1, 3); + int ok = whole && by1 && by3 && + strcmp(whole, by1) == 0 && strcmp(whole, by3) == 0; + checks++; + if (!ok) { + failures++; + printf(" ✗ 分片不一致: [%s]\n", samples[k]); + printf(" 一次性: "); show_escaped(whole ? whole : "(null)"); printf("\n"); + printf(" 逐字节: "); show_escaped(by1 ? by1 : "(null)"); printf("\n"); + printf(" 3 字节: "); show_escaped(by3 ? by3 : "(null)"); printf("\n"); + } + free(whole); free(by1); free(by3); + } +} + +static void test_headings(void) +{ + puts("标题"); + char *o = render("# 一\n## 二\n### 三\n#### 四\n", 1); + CHECK(strstr(o, "\033[1;36m# 一\033[0m") != NULL, "H1 应为粗体青"); + CHECK(strstr(o, "\033[1;35m## 二\033[0m") != NULL, "H2 应为粗体品红"); + CHECK(strstr(o, "\033[1;34m### 三") != NULL, "H3 应为粗体蓝"); + CHECK(strstr(o, "\033[1m#### 四") != NULL, "H4 应为粗体"); + char *vis = strip_ansi(o); + CHECK(strcmp(vis, "# 一\n## 二\n### 三\n#### 四\n") == 0, + "可见文本应保留 # 标记: [%s]", vis); + free(vis); free(o); + + /* 不是标题的情况必须原样保留 */ + o = render("#没有空格\n", 1); + CHECK(strcmp(o, "#没有空格\n") == 0, "无空格不应识别为标题: [%s]", o); + free(o); + + o = render("####### 七个\n", 1); + CHECK(strstr(o, "####### 七个") != NULL, "7 个 # 不应识别为标题: [%s]", o); + free(o); + + o = render("a # b\n", 1); + CHECK(strcmp(o, "a # b\n") == 0, "行中 # 应原样: [%s]", o); + free(o); +} + +static void test_bold_italic(void) +{ + puts("粗体 / 斜体"); + char *o = render("**粗**\n", 1); + CHECK(strstr(o, "\033[1m粗\033[0m") != NULL, "粗体样式: [%s]", o); + char *vis = strip_ansi(o); + CHECK(strcmp(vis, "粗\n") == 0, "粗体标记应被吃掉: [%s]", vis); + free(vis); free(o); + + o = render("*斜*\n", 1); + CHECK(strstr(o, "\033[3m斜\033[0m") != NULL, "斜体样式: [%s]", o); + vis = strip_ansi(o); + CHECK(strcmp(vis, "斜\n") == 0, "斜体标记应被吃掉: [%s]", vis); + free(vis); free(o); + + /* 字面星号:乘法 */ + o = render("2 * 3 * 4\n", 1); + CHECK(strcmp(o, "2 * 3 * 4\n") == 0, "乘法星号应原样输出: [%s]", o); + free(o); + + /* snake_case 不能被误伤(这是不实现 _斜体_ 的原因) */ + o = render("foo_bar_baz\n", 1); + CHECK(strcmp(o, "foo_bar_baz\n") == 0, "下划线标识符不应被改动: [%s]", o); + free(o); + + /* 样式不应跨行泄漏 */ + o = render("*a\nb\n", 1); + CHECK(strstr(o, "\033[0m\n") != NULL, "行尾应复位样式: [%s]", o); + free(o); + + /* 嵌套:粗体里的斜体 */ + o = render("**粗 *斜* 粗**\n", 1); + vis = strip_ansi(o); + CHECK(strcmp(vis, "粗 斜 粗\n") == 0, "嵌套可见文本: [%s]", vis); + free(vis); free(o); +} + +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, + "行内代码应保留反引号: [%s]", vis); + CHECK(strstr(o, "\033[36m") != NULL, "内容应着色: [%s]", o); + free(vis); free(o); + + /* 围栏代码块内部不做行内解析 */ + o = render("```\n**原样**\n```\n", 1); + vis = strip_ansi(o); + CHECK(strcmp(vis, "```\n**原样**\n```\n") == 0, + "代码块内不应渲染粗体: [%s]", vis); + free(vis); free(o); + + /* 带语言标识 */ + o = render("```c\nx;\n```\n", 1); + vis = strip_ansi(o); + CHECK(strcmp(vis, "```c\nx;\n```\n") == 0, + "语言标识与内容应完整保留: [%s]", vis); + free(vis); free(o); + + /* 未闭合的代码块:不能吞掉后面的内容 */ + o = render("```\ncode\n", 1); + vis = strip_ansi(o); + CHECK(strcmp(vis, "```\ncode\n") == 0, "未闭合代码块: [%s]", vis); + free(vis); free(o); +} + +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); + CHECK(strstr(o, "\033[36m-\033[0m") != NULL, "列表标记 - 应着色: [%s]", o); + CHECK(strstr(o, "•") == NULL, "不应出现非 ASCII 圆点: [%s]", o); + free(vis); free(o); + + o = render("* 甲\n+ 乙\n", 1); + vis = strip_ansi(o); + CHECK(strcmp(vis, "* 甲\n+ 乙\n") == 0, "列表应保留原始 * 和 + : [%s]", vis); + CHECK(strstr(o, "\033[36m*\033[0m") != NULL, "列表标记 * 应着色: [%s]", o); + CHECK(strstr(o, "\033[36m+\033[0m") != NULL, "列表标记 + 应着色: [%s]", o); + free(vis); free(o); + + /* 表格不受影响:仍然是框线字符 */ + o = render("| a |\n|---|\n| 1 |\n", 1); + vis = strip_ansi(o); + CHECK(strstr(vis, "┌") && strstr(vis, "│") && strstr(vis, "└"), + "表格仍用框线字符: [%s]", vis); + free(vis); free(o); + + o = render("> 引用\n", 1); + vis = strip_ansi(o); + CHECK(strcmp(vis, "│ 引用\n") == 0, "引用: [%s]", vis); + free(vis); free(o); + + o = render("---\n", 1); + vis = strip_ansi(o); + CHECK(strcmp(vis, "---\n") == 0, "分隔线可见文本: [%s]", vis); + CHECK(strstr(o, "\033[2m") != NULL, "分隔线应暗色: [%s]", o); + free(vis); free(o); + + /* 非分隔线、非列表应原样 */ + o = render("--x\n", 1); + CHECK(strcmp(o, "--x\n") == 0, "非分隔线应原样: [%s]", o); + free(o); +} + +static void test_passthrough(void) +{ + puts("关闭渲染时逐字节透传"); + static const char *samples[] = { + "# 标题 **粗体** `code`\n", + "- 列表\n```\ncode\n```\n", + "\033 转义 中文 😀\n", + "没有换行的最后一行", + "", + }; + for (size_t k = 0; k < sizeof samples / sizeof samples[0]; k++) { + char *whole = render(samples[k], 0); + char *by1 = do_render(samples[k], 0, 1); + CHECK(strcmp(whole, samples[k]) == 0, + "应完全透传: 期望 [%s] 实际 [%s]", samples[k], whole); + CHECK(strcmp(by1, samples[k]) == 0, "分片透传也应一致"); + free(whole); free(by1); + } +} + +static void test_fence_verbatim(void) +{ + puts("围栏代码块:全部不渲染"); + /* 纯代码块:输出里不应出现任何 ANSI 转义 */ + char *o = render("```c\nint x = 1; // **不是粗体**\n```\n", 1); + CHECK(strstr(o, "\033") == NULL, + "代码块内不应有任何 ANSI 转义: [%s]", o); + CHECK(strcmp(o, "```c\nint x = 1; // **不是粗体**\n```\n") == 0, + "代码块应逐字节原样: [%s]", o); + free(o); + + /* 代码块里的 # - > [ 都不能被当成语法 */ + o = render("```\n# 不是标题\n- 不是列表\n> 不是引用\n[x](y)\n```\n", 1); + CHECK(strstr(o, "\033") == NULL, "代码块内不应有转义: [%s]", o); + CHECK(strstr(o, "# 不是标题") && strstr(o, "[x](y)") && + strstr(o, "- 不是列表") && strstr(o, "> 不是引用"), + "代码块内容应完整: [%s]", o); + free(o); + + /* 代码块结束之后,普通文本要恢复渲染 */ + o = render("```\nx\n```\n**加粗**\n", 1); + CHECK(strstr(o, "\033[1m加粗\033[0m") != NULL, + "代码块之后应恢复渲染: [%s]", o); + free(o); +} + +static void test_inline_code_protected(void) +{ + puts("行内代码内容不受行内语法影响"); + char *o = render("形如 `a**b**c` 的写法\n", 1); + CHECK(strstr(o, "\033[1m") == NULL, + "行内代码内不应渲染粗体: [%s]", o); + char *vis = strip_ansi(o); + CHECK(strcmp(vis, "形如 `a**b**c` 的写法\n") == 0, + "行内代码可见文本应原样: [%s]", vis); + free(vis); free(o); + + o = render("`[不是链接](x)` 和 `_不是斜体_`\n", 1); + CHECK(strstr(o, "\033[94m") == NULL, "行内代码内不应高亮 []: [%s]", o); + CHECK(strstr(o, "\033[3m") == NULL, "行内代码内不应渲染斜体: [%s]", o); + vis = strip_ansi(o); + CHECK(strcmp(vis, "`[不是链接](x)` 和 `_不是斜体_`\n") == 0, + "可见文本应原样: [%s]", vis); + free(vis); free(o); +} + +static void test_underscore_italic(void) +{ + puts("_斜体_ 与词内下划线保护"); + char *o = render("这是 _斜体_ 文字\n", 1); + CHECK(strstr(o, "\033[3m斜体\033[0m") != NULL, "_斜体_ 应渲染: [%s]", o); + char *vis = strip_ansi(o); + CHECK(strcmp(vis, "这是 斜体 文字\n") == 0, "可见文本: [%s]", vis); + free(vis); free(o); + + /* 中文紧邻下划线也应生效(非 ASCII 不算词字符) */ + o = render("中文_斜体_中文\n", 1); + CHECK(strstr(o, "\033[3m斜体") != NULL, "中文紧邻应生效: [%s]", o); + free(o); + + /* 关键保护:下划线标识符不能被当成斜体 */ + o = render("foo_bar_baz 和 __init__ 和 a_b_c\n", 1); + CHECK(strstr(o, "\033[3m") == NULL, + "下划线标识符不应被渲染为斜体: [%s]", o); + CHECK(strcmp(o, "foo_bar_baz 和 __init__ 和 a_b_c\n") == 0, + "标识符应逐字节保留: [%s]", o); + free(o); +} + +static void test_link_bracket(void) +{ + puts("链接:[] 下划线+青色高亮,其余原样"); + char *o = render("见 [文档](https://example.com/a) 说明\n", 1); + /* 这里是下划线 + 青(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); + free(vis); free(o); + + /* 图片也是 [...],同样只高亮方括号部分,整段保留 */ + o = render("![图](a.png)\n", 1); + vis = strip_ansi(o); + CHECK(strcmp(vis, "![图](a.png)\n") == 0, "图片应保留原文: [%s]", vis); + free(vis); free(o); + + /* 下标写法不应被当成链接 */ + o = render("arr[i] = br[j]\n", 1); + CHECK(strstr(o, "\033[94m") == NULL, "下标不应高亮: [%s]", o); + CHECK(strcmp(o, "arr[i] = br[j]\n") == 0, "下标应原样: [%s]", o); + free(o); +} + +static void test_tables(void) +{ + puts("表格"); + /* ASCII 表格:逐字节比对完整边框输出 */ + char *o = render("| a | b |\n|---|---|\n| 1 | 22 |\n", 1); + char *vis = strip_ansi(o); + const char *want = + "┌───┬────┐\n" + "│ a │ b │\n" + "├───┼────┤\n" + "│ 1 │ 22 │\n" + "└───┴────┘\n"; + CHECK(strcmp(vis, want) == 0, "ASCII 表格:\n期望:\n%s\n实际:\n%s", want, vis); + CHECK(strstr(o, "\033[1m") != NULL, "表头应加粗: [%s]", o); + free(vis); free(o); + + /* 中文列宽按 2 列计算,边框必须对齐 */ + o = render("| 名称 | 值 |\n|---|---|\n| 苹果 | 3 |\n", 1); + vis = strip_ansi(o); + const char *want2 = + "┌──────┬────┐\n" + "│ 名称 │ 值 │\n" + "├──────┼────┤\n" + "│ 苹果 │ 3 │\n" + "└──────┴────┘\n"; + CHECK(strcmp(vis, want2) == 0, "中文表格:\n期望:\n%s\n实际:\n%s", want2, vis); + free(vis); free(o); + + /* 对齐标记 */ + o = render("| 左 | 中 | 右 |\n|:---|:---:|---:|\n| a | b | c |\n", 1); + vis = strip_ansi(o); + const char *want3 = + "┌────┬────┬────┐\n" + "│ 左 │ 中 │ 右 │\n" + "├────┼────┼────┤\n" + "│ a │ b │ c │\n" + "└────┴────┴────┘\n"; + CHECK(strcmp(vis, want3) == 0, "对齐表格:\n期望:\n%s\n实际:\n%s", want3, vis); + free(vis); free(o); + + /* 顶格必须写 '|'。不带首竖线的写法按普通文本处理 —— + * 这是为保证"输出与分片方式无关"而做的取舍: + * 判断必须在行的第 0 个字节就能做出,否则分片落在竖线之前时, + * 行的前半段已被当作普通文本输出,结果会随分片方式而变。 */ + o = render("a | b\n--- | ---\n1 | 2\n", 1); + vis = strip_ansi(o); + CHECK(strcmp(vis, "a | b\n--- | ---\n1 | 2\n") == 0, + "无首竖线应原样输出: [%s]", vis); + free(vis); free(o); + + /* 首竖线前有缩进同理:不认 */ + o = render(" | a |\n |---|\n", 1); + vis = strip_ansi(o); + CHECK(strstr(vis, "┌") == NULL, "缩进的表格不识别: [%s]", vis); + free(vis); free(o); + + /* 误判防护:普通文本里出现竖线 + 下一行是短横线,不能当成表格 */ + o = render("运行 ls |\n-\n", 1); + vis = strip_ansi(o); + CHECK(strstr(vis, "┌") == NULL, "竖线+短横线不应误判成表格: [%s]", vis); + free(vis); free(o); + + /* 单列极简表 */ + o = render("| x |\n|---|\n| 1 |\n", 1); + vis = strip_ansi(o); + CHECK(strstr(vis, "┌───┐") != NULL, "单列表格: [%s]", vis); + free(vis); free(o); + + /* 没有分隔行就不是表格 */ + o = render("a | b\nc | d\n", 1); + CHECK(strcmp(o, "a | b\nc | d\n") == 0, "无分隔行不应成表: [%s]", o); + free(o); + + /* 单竖线的普通命令行不能被误判为表格 */ + o = render("ls -la | grep foo\n", 1); + 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, + "表格后应恢复普通渲染: [%s]", vis); + free(vis); free(o); +} + +static void test_long_line_bounded(void) +{ + puts("超长待处理缓冲应有界"); + /* 一个没有换行的超长 fence 信息行:渲染器不能无限缓存 */ + StrBuf sb; + sb_init(&sb); + sb_puts(&sb, "```"); + 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); + free(o); + sb_free(&sb); +} + +int main(void) +{ + test_split_invariance(); + test_headings(); + test_bold_italic(); + test_code(); + test_blocks(); + test_fence_verbatim(); + test_inline_code_protected(); + test_underscore_italic(); + test_link_bracket(); + test_tables(); + test_passthrough(); + test_long_line_bounded(); + printf("\n%d 项检查,%d 项失败\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_meta.c b/tests/test_meta.c new file mode 100644 index 0000000..dfb3fee --- /dev/null +++ b/tests/test_meta.c @@ -0,0 +1,387 @@ +/* test_meta.c - meta 历史编辑:轮次切分、结构安全、各工具的行为 */ +#include "meta.h" +#include "json.h" +#include "util.h" +#include "agent.h" + +#include +#include +#include + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ + } while (0) + +/* 造一条消息 */ +static JsonValue *msg(const char *role, const char *content) +{ + JsonValue *m = json_new_object(); + json_object_set_string(m, "role", role); + json_object_set_string(m, "content", content); + return m; +} + +/* assistant 带一个 tool_call */ +static JsonValue *assistant_call(const char *id, const char *name, const char *args) +{ + JsonValue *m = json_new_object(); + json_object_set_string(m, "role", "assistant"); + json_object_set_string(m, "content", ""); + JsonValue *arr = json_new_array(); + JsonValue *tc = json_new_object(); + json_object_set_string(tc, "id", id); + json_object_set_string(tc, "type", "function"); + JsonValue *fn = json_new_object(); + json_object_set_string(fn, "name", name); + json_object_set_string(fn, "arguments", args); + json_object_set(tc, "function", fn); + json_array_push(arr, tc); + json_object_set(m, "tool_calls", arr); + return m; +} + +/* 造一段典型历史: + * #0 system + * #1 user A ← 轮 1 + * #2 assistant(tool_calls) + * #3 tool + * #4 assistant 回答 + * #5 user B ← 轮 2 + * #6 assistant 回答 + * #7 user C ← 轮 3 + * #8 assistant 回答 + */ +static JsonValue *sample(void) +{ + JsonValue *a = json_new_array(); + json_array_push(a, msg("system", "你是助手")); + json_array_push(a, msg("user", "帮我用 Python 写")); + json_array_push(a, assistant_call("call_1", "run_command", "{\"command\":\"ls\"}")); + 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("user", "加个注释")); + json_array_push(a, msg("assistant", "加好了")); + return a; +} + +static void test_rounds(void) +{ + puts("轮次切分"); + 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]); + meta_rounds_free(&r); + + /* 没有 system 也要能切 */ + JsonValue *n = json_new_array(); + 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 轮,从头开始"); + meta_rounds_free(&r); + json_free(n); + + /* 只有 system:没有可编辑的东西 */ + JsonValue *only = json_new_array(); + json_array_push(only, msg("system", "s")); + CHECK(meta_rounds_build(only, &r) == 0, "只有 system 时不该切出轮次"); + meta_rounds_free(&r); + json_free(only); + + /* read_image 补的那条"只有图片附件、没有文字"的 user 消息**不算新的一轮** —— + * 判据必须与主循环(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("tool", "结果")); + { + JsonValue *att = json_new_object(); + json_object_set_string(att, "role", "user"); + JsonValue *c = json_new_array(); + JsonValue *blk = json_new_object(); + json_object_set_string(blk, "type", "image_url"); + json_array_push(c, blk); + json_object_set(att, "content", c); + json_array_push(withimg, att); + } + json_array_push(withimg, msg("assistant", "看到图了")); + CHECK(meta_rounds_build(withimg, &r) == 1, "带附件的历史也应该能切"); + CHECK(r.count == 1, "只有附件的那条 user 不该算新的一轮,实际 %zu 轮", r.count); + meta_rounds_free(&r); + json_free(withimg); + + json_free(m); +} + +static void test_deletable(void) +{ + puts("结构安全:哪些消息能单独删"); + JsonValue *m = sample(); + CHECK(meta_deletable(m, 0) == 0, "system 不可删"); + CHECK(meta_deletable(m, 1) == 1, "user 可删"); + CHECK(meta_deletable(m, 2) == 0, "带 tool_calls 的 assistant 不可删"); + CHECK(meta_deletable(m, 3) == 0, "tool 结果不可删"); + CHECK(meta_deletable(m, 4) == 1, "普通 assistant 可删"); + CHECK(meta_deletable(m, 99) == 0, "越界不可删"); + json_free(m); +} + +/* ---- 工具驱动 ---- */ + +static char *call(Agent *a, const char *name, const char *json) +{ + char err[256]; + JsonValue *args = json_parse(json, err, sizeof err); + if (!args) { printf(" (测试自身出错:%s)\n", err); return xstrdup(""); } + int is_err = 0; + char *r = meta_tools_call(name, args, &is_err); + json_free(args); + return r; +} + +/* 把一段历史挂上,跑 body,再摘下来 */ +#define WITH_TOOLS(a) \ + do { \ + CHECK(meta_tools_attach(a) == 0, "attach 应成功"); \ + CHECK(meta_tools_attach(a) != 0, "重复 attach 应被拒绝"); \ + } while (0) + +static void test_replace(void) +{ + puts("replace_message"); + Agent a; agent_init(&a); + json_free(a.messages); a.messages = sample(); + meta_tools_attach(&a); + + int err = 0; + char *r = call(&a, "replace_message", "{\"index\":1,\"content\":\"帮我用 C 写\"}"); + CHECK(err == 0 && r && strstr(r, "已改写") != NULL, "应改写成功:%s", r ? r : ""); + JsonValue *m1 = json_array_get(a.messages, 1); + CHECK(strcmp(json_get_string(m1, "content"), "帮我用 C 写") == 0, "内容应被替换"); + CHECK(strcmp(json_get_string(m1, "role"), "user") == 0, "角色不该变"); + free(r); + + /* 改 assistant 说的话 —— 这是 meta 存在的理由 */ + r = call(&a, "replace_message", "{\"index\":4,\"content\":\"改好了\"}"); + CHECK(r && strstr(r, "已改写") != NULL, "应能改写 assistant 消息"); + CHECK(strcmp(json_get_string(json_array_get(a.messages, 4), "content"), "改好了") == 0, + "assistant 内容应被替换"); + free(r); + + /* tool 结果和 system 都不该被动 */ + r = call(&a, "replace_message", "{\"index\":3,\"content\":\"x\"}"); + CHECK(r && strstr(r, "tool 结果") != NULL, "改 tool 结果应被拒绝:%s", r ? r : ""); + free(r); + r = call(&a, "replace_message", "{\"index\":0,\"content\":\"x\"}"); + CHECK(r && strstr(r, "system") != NULL, "改 system 应被拒绝"); + free(r); + + /* 越界 */ + r = call(&a, "replace_message", "{\"index\":99,\"content\":\"x\"}"); + CHECK(r && strstr(r, "越界") != NULL, "越界应报错"); + free(r); + + meta_tools_detach(); + agent_free(&a); +} + +static void test_delete_safety(void) +{ + puts("delete_messages 的结构安全"); + Agent a; agent_init(&a); + json_free(a.messages); a.messages = sample(); + meta_tools_attach(&a); + + size_t before = json_array_size(a.messages); + char *r = call(&a, "delete_messages", "{\"indices\":[2]}"); + CHECK(r && strstr(r, "不可拆") != NULL, "带 tool_calls 的 assistant 应被拒绝"); + CHECK(json_array_size(a.messages) == before, "被拒绝时不该改动任何东西"); + free(r); + + r = call(&a, "delete_messages", "{\"indices\":[3]}"); + CHECK(r && strstr(r, "不可拆") != NULL, "tool 结果应被拒绝"); + 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,并给上下文提示 */ + r = call(&a, "delete_messages", "{\"indices\":[1]}"); + CHECK(r && strstr(r, "已删除 1 条") != NULL, "合法删除应成功:%s", r ? r : ""); + CHECK(strstr(r, "没有对应的提问") != NULL, "删掉提问后应提示后续回答失去上下文"); + CHECK(json_array_size(a.messages) == before - 1, "条数应减一"); + free(r); + + /* 多选 + 越界 */ + r = call(&a, "delete_messages", "{\"indices\":[0]}"); + CHECK(r && strstr(r, "不可拆") != NULL, "system 也在结构消息之列"); + free(r); + + meta_tools_detach(); + agent_free(&a); +} + +static void test_delete_rounds(void) +{ + puts("delete_rounds"); + Agent a; agent_init(&a); + json_free(a.messages); a.messages = sample(); + meta_tools_attach(&a); + + size_t before = json_array_size(a.messages); + char *r = call(&a, "delete_rounds", "{\"from\":2,\"to\":2}"); + CHECK(r && strstr(r, "已删除轮 2..2") != NULL, "应删掉第 2 轮:%s", r ? r : ""); + CHECK(json_array_size(a.messages) == before - 2, "第 2 轮是 2 条消息"); + /* 剩下的结构必须还是自洽的:#2 的 tool_calls 后面仍跟着 tool */ + CHECK(meta_deletable(a.messages, 2) == 0, "#2 仍应是结构消息"); + CHECK(strcmp(json_get_string(json_array_get(a.messages, 3), "role"), "tool") == 0, + "tool 结果应仍紧跟它的调用"); + free(r); + + /* 整段删光要被拒 */ + r = call(&a, "delete_rounds", "{\"from\":1,\"to\":2}"); + CHECK(r && strstr(r, "/reset") != NULL, "删光整段应被拒绝并建议 /reset"); + free(r); + + /* 越界 */ + r = call(&a, "delete_rounds", "{\"from\":99,\"to\":99}"); + CHECK(r && (strstr(r, "不合法") != NULL), "越界轮号应报错:%s", r ? r : ""); + free(r); + + meta_tools_detach(); + agent_free(&a); +} + +static void test_insert_and_undo(void) +{ + puts("insert_message 与 undo"); + Agent a; agent_init(&a); + json_free(a.messages); a.messages = sample(); + meta_tools_attach(&a); + + size_t before = json_array_size(a.messages); + char *r = call(&a, "insert_message", + "{\"after\":8,\"role\":\"user\",\"content\":\"这个项目必须用 C99\"}"); + CHECK(r && strstr(r, "插入一条 user") != NULL, "应插入成功:%s", r ? r : ""); + CHECK(json_array_size(a.messages) == before + 1, "条数应加一"); + CHECK(strcmp(json_get_string(json_array_get(a.messages, 9), "content"), + "这个项目必须用 C99") == 0, "应插在 #8 之后"); + free(r); + + /* tool 角色不能凭空插 —— 没有配对的 tool_calls */ + r = call(&a, "insert_message", "{\"after\":0,\"role\":\"tool\",\"content\":\"x\"}"); + CHECK(r && strstr(r, "role 只能是") != NULL, "插 tool 消息应被拒绝"); + free(r); + + /* 撤销应回到插入前 */ + 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, "撤销后条数应还原"); + free(r); + + /* 没有更多可撤销的了 */ + r = call(&a, "undo_last_edit", "{}"); + CHECK(r && strstr(r, "没有可撤销") != NULL, "栈空时应明确说没有"); + free(r); + + meta_tools_detach(); + agent_free(&a); +} + +static void test_read_tools(void) +{ + puts("list_rounds / read_messages(只读)"); + Agent a; agent_init(&a); + json_free(a.messages); a.messages = sample(); + meta_tools_attach(&a); + + char *r = call(&a, "list_rounds", "{}"); + CHECK(r && strstr(r, "共 3 轮") != NULL, "应报告 3 轮:%s", r ? r : ""); + CHECK(r && strstr(r, "轮 1") != NULL && strstr(r, "轮 3") != NULL, "应列出每轮"); + CHECK(r && strstr(r, "Python") != NULL, "应带开头预览"); + free(r); + + r = call(&a, "list_rounds", "{\"from\":2,\"to\":2}"); + CHECK(r && strstr(r, "轮 2") != NULL && strstr(r, "轮 3") == NULL, "应只列第 2 轮"); + free(r); + + 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, "结构消息应有标注"); + free(r); + + CHECK(meta_tools_mutating("list_rounds") == 0, "list_rounds 不是写操作"); + CHECK(meta_tools_mutating("read_messages") == 0, "read_messages 不是写操作"); + CHECK(meta_tools_mutating("delete_rounds") == 1, "delete_rounds 是写操作"); + CHECK(meta_tools_mutating("replace_message") == 1, "replace_message 是写操作"); + CHECK(meta_tools_mutating("不存在的工具") == 0, "未知工具不该被当成写操作"); + + r = call(&a, "delete_rounds_but_typo", "{}"); + CHECK(r && strstr(r, "未知工具") != NULL, "未知工具名应被拒绝"); + free(r); + + meta_tools_detach(); + agent_free(&a); +} + +static void test_reroot_flag(void) +{ + puts("重新存根标记"); + Agent a; agent_init(&a); + json_free(a.messages); a.messages = sample(); + meta_reroot_clear(); + CHECK(meta_reroot_pending() == 0, "初始不该有标记"); + + meta_tools_attach(&a); + char *r = call(&a, "list_rounds", "{}"); /* 只读,不该打标记 */ + free(r); + CHECK(meta_reroot_pending() == 0, "只读操作不该打标记"); + + r = call(&a, "replace_message", "{\"index\":1,\"content\":\"x\"}"); + free(r); + CHECK(meta_reroot_pending() == 1, "改动之后应打上标记"); + + meta_tools_detach(); + agent_free(&a); + meta_reroot_clear(); +} + +int main(void) +{ + test_rounds(); + test_deletable(); + test_read_tools(); + test_replace(); + test_delete_safety(); + test_delete_rounds(); + test_insert_and_undo(); + test_reroot_flag(); + printf("\n%d 项检查,%d 项失败\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_models.c b/tests/test_models.c new file mode 100644 index 0000000..74ca673 --- /dev/null +++ b/tests/test_models.c @@ -0,0 +1,133 @@ +/* test_models.c - /models 响应体的解析 + * + * 只测纯解析。拉取(GET {base_url}/models)要走网络,由 e2e 第 52 段对着 + * mock 覆盖,那里还顺带验了交互式选择本身(方向键、确认、取消)。 + * + * 为什么要容忍这么多种形状:OpenAI 兼容是个很大的家庭,/models 的返回花样 + * 比文档里写的多 —— 见过裸数组、见过重复列同一模型、见过元素里没有 id。 + * 解析器要么跳过,要么去重,但**不能**因此整个失败:列表里少一个模型远好过 + * 一个都选不了。 + */ +#include "models.h" + +#include +#include + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ + } while (0) + +static void test_standard(void) +{ + puts("标准形状:{\"data\":[{\"id\":...}]}"); + const char *json = + "{\"object\":\"list\",\"data\":[" + "{\"id\":\"alpha\",\"object\":\"model\",\"owned_by\":\"x\"}," + "{\"id\":\"beta\",\"object\":\"model\"}" + "]}"; + size_t n = 0; + char **l = models_parse(json, &n); + CHECK(l && n == 2, "应解析出 2 个,实际 %zu", n); + if (l && n == 2) { + CHECK(strcmp(l[0], "alpha") == 0, "顺序应保持服务端给的(第一个 alpha)"); + CHECK(strcmp(l[1], "beta") == 0, "第二个 beta"); + } + models_free(l, n); +} + +static void test_dedupe_and_skip(void) +{ + puts("重复项要去重,缺 id 的要跳过"); + const char *json = + "{\"data\":[" + "{\"id\":\"a\"},{\"id\":\"a\"},{\"object\":\"model\"}," + "{\"id\":\"\"},{\"id\":\"b\"},{\"id\":42}" + "]}"; + size_t n = 0; + char **l = models_parse(json, &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"); + } + models_free(l, n); +} + +static void test_bare_array(void) +{ + puts("兼容裸数组(字符串 / 对象都认)"); + size_t n = 0; + char **l = models_parse("[\"alpha\",\"beta\"]", &n); + CHECK(l && n == 2 && strcmp(l[1], "beta") == 0, "字符串数组应能解析"); + models_free(l, n); + + n = 0; + l = models_parse("[{\"id\":\"gamma\"}]", &n); + CHECK(l && n == 1 && strcmp(l[0], "gamma") == 0, "对象数组应能解析"); + models_free(l, n); +} + +static void test_bad_input(void) +{ + puts("拿不到模型时返回 NULL,而不是空数组或崩溃"); + size_t n = 123; + CHECK(models_parse(NULL, &n) == NULL && n == 0, "NULL 输入"); + n = 123; + CHECK(models_parse("", &n) == NULL && n == 0, "空串"); + n = 123; + CHECK(models_parse("not json", &n) == NULL && n == 0, + "不是 JSON"); + n = 123; + CHECK(models_parse("{\"data\":[]}", &n) == NULL && n == 0, "空列表"); + n = 123; + CHECK(models_parse("{\"object\":\"list\"}", &n) == NULL && n == 0, + "没有 data 字段"); + n = 123; + CHECK(models_parse("{\"data\":\"nope\"}", &n) == NULL && n == 0, + "data 不是数组"); + n = 123; + CHECK(models_parse("{\"data\":[{\"object\":\"model\"}]}", &n) == NULL && n == 0, + "全是坏条目"); + + /* out_n 也可为 NULL(调用方只关心数组) */ + char **l = models_parse("{\"data\":[{\"id\":\"x\"}]}", NULL); + CHECK(l != NULL, "out_n 为 NULL 也要能工作"); + models_free(l, 1); + + puts("空列表要与\"解析不了\"分开"); + CHECK(models_is_empty_list("{\"object\":\"list\",\"data\":[]}") == 1, + "空的 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, + "有模型就不是空列表"); + CHECK(models_is_empty_list(NULL) == 0, "NULL 不算空列表"); + CHECK(models_is_empty_list("") == 0, "空串不算空列表"); + + /* 释放 NULL 不该崩 */ + models_free(NULL, 0); + CHECK(1, "释放 NULL 没崩"); +} + +int main(void) +{ + test_standard(); + test_dedupe_and_skip(); + test_bare_array(); + test_bad_input(); + printf("\n%d 项检查,%d 项失败\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_priv.c b/tests/test_priv.c new file mode 100644 index 0000000..9df35bd --- /dev/null +++ b/tests/test_priv.c @@ -0,0 +1,118 @@ +/* test_priv.c - 提权模块里那几条"不能搞错"的不变量 + * + * 这个模块摔过一跤:上一版把认证路径也 setsid + 接管道,于是 sudo 拿不到 + * 控制终端,真机上认证 100% 失败(而替身 sudo 无条件成功,给了个假绿)。 + * 所以这里把两条硬不变量钉住: + * + * 1. **提权调用里不能有 -n**。带 -n 就等于"不许提问",需要密码时直接失败, + * 而用户要的恰恰是"提权要有身份验证"。 + * 2. **没有控制终端时能识别出来**。识别不出来就会起一个等不到输入的进程。 + */ +#include "priv.h" +#include "util.h" + +#include +#include +#include +#include +#include + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ + } while (0) + +static int argv_has(char *const *a, const char *want) +{ + for (size_t i = 0; a[i]; i++) + if (strcmp(a[i], want) == 0) return 1; + return 0; +} + +int main(void) +{ + puts("包装成 sudo -u <用户> -- "); + /* 显式转换,和 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[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 原样接在后面,不被拆开"); + CHECK(w[7] == NULL, "数组要 NULL 结尾"); + /* 命门外的那条不变量 */ + CHECK(!argv_has(w, "-n"), + "**不能带 -n**:带 -n 就不许提问,需要密码时直接失败 —— " + "而提权的要点就是让 sudo 能问密码"); + CHECK(!argv_has(w, "-S"), + "也不能带 -S:那要求把密码从 stdin 喂进去,等于让 ligent 经手密码"); + free(w); + + puts("用户名为空时按 root"); + w = priv_sudo_argv(NULL, plain); + CHECK(strcmp(w[2], "root") == 0, "空用户应落到 root,实际 %s", w[2]); + free(w); + w = priv_sudo_argv("", plain); + CHECK(strcmp(w[2], "root") == 0, "空串也应落到 root"); + free(w); + + puts("sudo 的失败原话要换成能照做的中文"); + 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); + priv_explain(1, "Sorry, try again.", err, sizeof 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); + /* 认不出来就原样透传:宁可给原文,也不编一句可能不对的解释 */ + priv_explain(1, "some brand new message", err, sizeof err); + CHECK(strstr(err, "some brand new message") != NULL, + "认不出来要透传原文,实际 %s", err); + priv_explain(127, "无法执行 sudo:No such file or directory", err, sizeof err); + CHECK(strstr(err, "没有 sudo") != NULL, "没有 sudo 时要说清楚,实际 %s", err); + + puts("没有控制终端时能识别出来"); + int fds[2]; + if (pipe(fds) != 0) { perror("pipe"); return 2; } + pid_t pid = fork(); + if (pid < 0) { perror("fork"); return 2; } + if (pid == 0) { + close(fds[0]); + int rc = -1; + if (setsid() >= 0) { /* 摘掉控制终端 */ + rc = priv_have_tty(); /* 必须是 0 */ + } + ssize_t wr = write(fds[1], &rc, sizeof rc); + (void)wr; + close(fds[1]); + _exit(0); + } + close(fds[1]); + int got = -1; + ssize_t rd = read(fds[0], &got, sizeof got); + close(fds[0]); + while (waitpid(pid, NULL, 0) < 0) { } + CHECK(rd == (ssize_t)sizeof got && got == 0, + "setsid() 之后不该认为有终端(识别不出来就会卡住)"); + + printf("\n%d 项检查,%d 项失败\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_spinner.c b/tests/test_spinner.c new file mode 100644 index 0000000..3226f91 --- /dev/null +++ b/tests/test_spinner.c @@ -0,0 +1,229 @@ +/* test_spinner.c - 等待动画的两段式标签 + * + * 动画写的是 stderr,所以这里把 stderr 挪到一个临时文件,驱动几轮之后 + * 再把内容读回来断言。spinner_init(1) 是显式开关,测试里不依赖 isatty。 + */ +#include "spinner.h" + +#include +#include +#include +#include +#include + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ + } while (0) + +/* 把 stderr 接到一个临时文件上,返回文件描述符;*saved 收原来的 stderr。 + * 用完必须调用 capture_end 还原,否则后面的失败信息也会被吞掉。 */ +static int capture_begin(char *path, size_t pathlen, int *saved) +{ + snprintf(path, pathlen, "/tmp/ligent_spin_XXXXXX"); + int fd = mkstemp(path); + if (fd < 0) return -1; + *saved = dup(STDERR_FILENO); + if (*saved < 0) { close(fd); return -1; } + dup2(fd, STDERR_FILENO); + return fd; +} + +static void capture_end(int fd, int saved, char *buf, size_t buflen) +{ + fflush(stderr); + dup2(saved, STDERR_FILENO); + close(saved); + lseek(fd, 0, SEEK_SET); + ssize_t n = read(fd, buf, buflen - 1); + buf[n > 0 ? (size_t)n : 0] = '\0'; + close(fd); +} + +static void test_two_phase(void) +{ + puts("两段式标签:等待响应 → 思考中"); + char path[64]; + int saved = -1; + int fd = capture_begin(path, sizeof path, &saved); + CHECK(fd >= 0, "无法重定向 stderr"); + if (fd < 0) return; + + spinner_init(1); + spinner_start("等待响应"); + usleep(400 * 1000); + /* 每来一片思维链都会调一次:第一次生效,后面几次都是同一标签 */ + spinner_set_label("思考中"); + usleep(120 * 1000); + spinner_set_label("思考中"); + usleep(120 * 1000); + spinner_set_label("思考中"); + usleep(200 * 1000); + spinner_stop(); + + char buf[8192]; + capture_end(fd, saved, buf, sizeof buf); + unlink(path); + + 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(strstr(buf, "\r\033[K") != NULL, "每帧应先回车清行再打印"); + /* 停在"思考中"之后就不该再有"等待响应"冒出来 */ + if (b) CHECK(strstr(b, "等待响应") == NULL, "换标签后不应回退到\"等待响应\""); +} + +static void test_idempotent(void) +{ + puts("未开始/未启用时的行为"); + char path[64]; + int saved = -1; + int fd = capture_begin(path, sizeof path, &saved); + CHECK(fd >= 0, "无法重定向 stderr"); + if (fd < 0) return; + + /* 没 start 就 set:只记标签,不该打印任何东西,也不该崩 */ + spinner_init(1); + spinner_set_label("思考中"); + usleep(50 * 1000); + /* 没 start 就 stop:幂等 */ + spinner_stop(); + /* start 两次:第二次忽略,不该起第二个线程 */ + spinner_start("等待响应"); + spinner_start("思考中"); + usleep(300 * 1000); + spinner_stop(); + spinner_stop(); + + char buf[8192]; + capture_end(fd, saved, buf, sizeof buf); + unlink(path); + CHECK(strstr(buf, "等待响应") != NULL, "start 后应开始转"); + CHECK(strstr(buf, "思考中") == NULL, + "已在转时再次 start 应被忽略,标签不该被改掉"); + + /* enabled = 0 时全部是空操作:连重定向都不需要 */ + spinner_init(0); + spinner_start("等待响应"); + spinner_set_label("思考中"); + spinner_stop(); + CHECK(1, "关闭状态下调用不崩溃"); +} + +static double now_sec(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9; +} + +/* spinner_stop 要 pthread_join worker。如果 worker 是 nanosleep 一整帧, + * 这里就得干等最多 250ms —— 而这笔账会加在每一轮模型往返、每一次工具 + * 来回的用户感知延迟上。 */ +static void test_stop_is_prompt(void) +{ + puts("stop 不该等满一帧(250ms)"); + char path[64]; + int saved = -1; + int fd = capture_begin(path, sizeof path, &saved); + CHECK(fd >= 0, "无法重定向 stderr"); + if (fd < 0) return; + + spinner_init(1); + double worst = 0.0; + for (int i = 0; i < 5; i++) { + double t0 = now_sec(); + spinner_start("等待响应"); + spinner_stop(); + double dt = now_sec() - t0; + if (dt > worst) worst = dt; + } + + char buf[4096]; + capture_end(fd, saved, buf, sizeof buf); + unlink(path); + CHECK(worst < 0.10, "stop 应立即返回,最慢一次 %.3fs(帧间隔是 0.25s)", worst); +} + +/* 帧率必须受 250ms 限制。曾经踩过一次:pthread_cond_timedwait 默认按 + * CLOCK_REALTIME 解释超时,而传进去的是 CLOCK_MONOTONIC 的绝对时间 + * (开机时长永远小于 epoch 时间),于是超时永远"已经过了" —— worker + * 变成疯狂重绘的忙循环,把 pty 刷屏。 */ +static void test_frame_rate_is_bounded(void) +{ + puts("帧率受 250ms 限制,不能狂刷"); + char path[64]; + int saved = -1; + int fd = capture_begin(path, sizeof path, &saved); + CHECK(fd >= 0, "无法重定向 stderr"); + if (fd < 0) return; + + spinner_init(1); + spinner_start("等待响应"); + usleep(600 * 1000); /* 0.6 秒 ≈ 2~3 帧 */ + spinner_stop(); + + char buf[65536]; + capture_end(fd, saved, buf, sizeof buf); + unlink(path); + + 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); +} + +/* spinner_notice 会先停、打印一条提示、再**接着转**。恢复时它传的是 g_label + * 自己 —— 而 spinner_start 曾经先执行 g_label[0] = '\0' 再读那个 label,于是 + * 标签被自己清空、退化成默认的"思考中"。症状:每次 HTTP 重试退避之后,屏幕上 + * 的"等待响应"都会变成"思考中",而这两段等待的含义恰恰是它要区分的。 */ +static void test_notice_keeps_label(void) +{ + puts("notice 之后标签保持不变"); + char path[64]; + int saved = -1; + int fd = capture_begin(path, sizeof path, &saved); + CHECK(fd >= 0, "无法重定向 stderr"); + if (fd < 0) return; + + spinner_init(1); + spinner_start("自定义标签A"); + usleep(300 * 1000); /* 让它画至少一帧 */ + spinner_notice("一条插进来的提示"); + usleep(300 * 1000); + spinner_stop(); + + char buf[8192]; + capture_end(fd, saved, buf, sizeof buf); + unlink(path); + + const char *noticed = strstr(buf, "一条插进来的提示"); + CHECK(noticed != NULL, "提示本身应该打出来,实际 [%.200s]", buf); + /* 提示之后剩下的帧里必须还是原来那个标签 */ + if (noticed) + CHECK(strstr(noticed, "自定义标签A") != NULL, + "notice 之后标签被改写了(应保持\"自定义标签A\"):\n%.300s", + noticed); +} + +int main(void) +{ + test_two_phase(); + test_idempotent(); + test_stop_is_prompt(); + test_frame_rate_is_bounded(); + test_notice_keeps_label(); + printf("\n%d 项检查,%d 项失败\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/test_tools.c b/tests/test_tools.c new file mode 100644 index 0000000..921a1df --- /dev/null +++ b/tests/test_tools.c @@ -0,0 +1,597 @@ +/* test_tools.c - 工具层测试 */ +#include "tools.h" +#include "image.h" +#include "json.h" +#include "util.h" + +#include +#include +#include +#include +#include + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ + } while (0) + +static char *call(const char *name, const char *args_json, int *is_error) +{ + char err[256]; + JsonValue *args = json_parse(args_json, err, sizeof err); + if (!args) { + printf(" 测试用例 args JSON 非法: %s\n", err); + exit(2); + } + char *r = tool_execute(name, args, is_error); + json_free(args); + return r; +} + +int main(void) +{ + char tmpl[] = "/tmp/ligent_test_XXXXXX"; + char *dir = mkdtemp(tmpl); + if (!dir) { perror("mkdtemp"); return 2; } + if (chdir(dir) != 0) { perror("chdir"); return 2; } + + /* ---- 工具 schema 合法性 ---- */ + puts("tools_schema"); + JsonValue *schema = tools_schema(); + 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); + CHECK(strcmp(json_get_string(t, "type"), "function") == 0, "type"); + JsonValue *fn = json_object_get(t, "function"); + CHECK(json_get_string(fn, "name") != NULL, "缺 name"); + JsonValue *params = json_object_get(fn, "parameters"); + CHECK(json_get_string(params, "type") != NULL, "缺 parameters.type"); + CHECK(json_object_get(params, "properties") != NULL, "缺 properties"); + CHECK(json_object_get(params, "required") != NULL, "缺 required"); + } + { + int has_image = 0; + for (size_t i = 0; i < json_array_size(schema); i++) { + JsonValue *fn = json_object_get(json_array_get(schema, i), "function"); + const char *nm = json_get_string(fn, "name"); + if (nm && strcmp(nm, "read_image") == 0) has_image = 1; + } + CHECK(has_image, "schema 里应有 read_image"); + } + { + /* line 是集合型参数:schema 里必须是 array(元素 integer), + * 否则模型根本不会想到可以给数组 —— 参数表的 "integer[]" 写法 + * 只有 schema 展开对了才作数 */ + JsonValue *props = NULL, *reqd = NULL; + for (size_t i = 0; i < json_array_size(schema); i++) { + JsonValue *fn = json_object_get(json_array_get(schema, i), "function"); + const char *nm = json_get_string(fn, "name"); + if (nm && strcmp(nm, "edit_file") == 0) { + JsonValue *par = json_object_get(fn, "parameters"); + props = json_object_get(par, "properties"); + reqd = json_object_get(par, "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"); + + 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", + 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", + it ? it : "(缺)"); + } + + /* 序列化一遍确保可发给服务端 */ + char *schema_json = json_dump(schema); + JsonValue *re = json_parse(schema_json, NULL, 0); + CHECK(re != NULL, "schema 无法被自身解析器往返"); + free(schema_json); + json_free(re); + json_free(schema); + + /* ---- run_command ---- */ + puts("run_command"); + int err = 0; + char *r = call("run_command", "{\"command\":\"echo hello\"}", &err); + CHECK(err == 0, "exit 0 不应算错误: %s", r); + CHECK(strstr(r, "hello") != NULL, "应含 hello: %s", r); + CHECK(strstr(r, "[exit code: 0]") != NULL, "应含退出码: %s", r); + free(r); + + r = call("run_command", "{\"command\":\"exit 3\"}", &err); + CHECK(err == 1, "非零退出应标记为错误"); + CHECK(strstr(r, "[exit code: 3]") != NULL, "应报告退出码 3: %s", r); + free(r); + + r = call("run_command", "{\"command\":\"echo err >&2; exit 1\"}", &err); + CHECK(strstr(r, "err") != NULL, "stderr 应被合并捕获: %s", r); + free(r); + + /* 超时 */ + r = call("run_command", "{\"command\":\"sleep 5\",\"timeout\":1}", &err); + CHECK(err == 1 && strstr(r, "超时") != NULL, "应超时: %s", r); + free(r); + + r = call("run_command", "{}", &err); + CHECK(err == 1 && strstr(r, "command") != NULL, "缺参数应报错: %s", r); + free(r); + + /* ---- write_file / read_file ---- */ + puts("write_file / read_file"); + r = call("write_file", + "{\"path\":\"sub/dir/a.txt\",\"content\":\"line1\\nline2\\nline3\\n\"}", &err); + CHECK(err == 0, "写入应成功: %s", r); + CHECK(access("sub/dir/a.txt", R_OK) == 0, "父目录应被自动创建"); + free(r); + + r = call("read_file", "{\"path\":\"sub/dir/a.txt\"}", &err); + CHECK(err == 0, "读取应成功: %s", r); + CHECK(strstr(r, "line1") && strstr(r, "line3"), "内容应完整: %s", r); + CHECK(strstr(r, "1\t") != NULL, "应带行号: %s", r); + free(r); + + r = call("read_file", "{\"path\":\"sub/dir/a.txt\",\"offset\":2,\"limit\":1}", &err); + CHECK(strstr(r, "line2") && !strstr(r, "line1") && !strstr(r, "line3"), + "offset/limit 应生效: %s", r); + free(r); + + r = call("read_file", "{\"path\":\"does_not_exist\"}", &err); + CHECK(err == 1, "读不存在的文件应报错: %s", r); + free(r); + + /* ---- edit_file:替换由行号钉住 ---- */ + puts("edit_file"); + r = call("edit_file", + "{\"path\":\"sub/dir/a.txt\",\"line\":2,\"old_string\":\"line2\"," + "\"new_string\":\"LINE-TWO\"}", &err); + CHECK(err == 0, "按行号替换应成功: %s", r); + CHECK(strstr(r, "第 2 行") != NULL, "回执里应带上行号: %s", r); + free(r); + r = call("read_file", "{\"path\":\"sub/dir/a.txt\"}", &err); + CHECK(strstr(r, "LINE-TWO") != NULL, "替换结果应落盘: %s", r); + free(r); + + /* 省略 line 不再是错误:那是"全文件替换"这条逃生门(见下面那组用例) */ + + /* 行号对不上:必须报出它**真正在哪一行**,否则模型只能反复猜 */ + r = call("edit_file", + "{\"path\":\"sub/dir/a.txt\",\"line\":3,\"old_string\":\"LINE-TWO\"," + "\"new_string\":\"x\"}", &err); + CHECK(err == 1, "行号不对应报错: %s", r); + CHECK(strstr(r, "第 2 行") != NULL, "应指出它真正的行号: %s", r); + free(r); + + /* 越界行号:要连文件总行数一起说清楚 */ + r = call("edit_file", + "{\"path\":\"sub/dir/a.txt\",\"line\":99,\"old_string\":\"x\"," + "\"new_string\":\"y\"}", &err); + CHECK(err == 1 && strstr(r, "只有 3 行") != NULL, + "越界行号应报错并给出总行数: %s", r); + 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); + free(r); + + /* 同一行里出现两处 → 仍然拒绝(行内也要定得下来) */ + r = call("write_file", "{\"path\":\"dup.txt\",\"content\":\"foo foo\\n\"}", &err); + CHECK(err == 0, "写入 dup.txt: %s", r); + free(r); + r = call("edit_file", + "{\"path\":\"dup.txt\",\"line\":1,\"old_string\":\"foo\",\"new_string\":\"bar\"}", + &err); + CHECK(err == 1 && strstr(r, "第 1 行里有 2 处") != NULL, + "同一行里多匹配应拒绝: %s", r); + free(r); + + /* replace_all 仍然换全文件,但 line 必须指向其中真实的一处 */ + r = call("edit_file", + "{\"path\":\"dup.txt\",\"line\":1,\"old_string\":\"foo\",\"new_string\":\"bar\"," + "\"replace_all\":true}", &err); + CHECK(err == 0, "replace_all 应成功: %s", r); + free(r); + r = call("read_file", "{\"path\":\"dup.txt\"}", &err); + CHECK(strstr(r, "bar bar") != NULL && !strstr(r, "foo"), + "replace_all 应换掉文件里全部匹配: %s", r); + free(r); + r = call("edit_file", + "{\"path\":\"dup.txt\",\"line\":7,\"old_string\":\"bar\",\"new_string\":\"x\"," + "\"replace_all\":true}", &err); + CHECK(err == 1, "replace_all 也必须锚定在真实的一行上: %s", r); + free(r); + + /* ---- edit_file:line 是集合型 ---- + * 集合是**搜索范围**(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); + CHECK(err == 0, "集合圈住的替换应成功: %s", r); + CHECK(strstr(r, "第 1、2 行") != NULL, "回执里应带上整个集合: %s", r); + free(r); + r = call("read_file", "{\"path\":\"set.txt\"}", &err); + CHECK(strstr(r, "AAA") != NULL && !strstr(r, "aaa"), "替换应落盘: %s", r); + free(r); + + /* 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); + 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); + r = call("edit_file", + "{\"path\":\"two.txt\",\"line\":[1,3],\"old_string\":\"foo\"," + "\"new_string\":\"bar\"}", &err); + CHECK(err == 1 && strstr(r, "第 1、3 行里有 2 处") != NULL, + "集合里多处匹配应拒绝: %s", r); + free(r); + + /* 重复的行号要去重:不然同一处匹配会被数成两处 */ + r = call("write_file", "{\"path\":\"dup3.txt\",\"content\":\"foo\\n\"}", &err); + CHECK(err == 0, "写入 dup3.txt: %s", r); + free(r); + r = call("edit_file", + "{\"path\":\"dup3.txt\",\"line\":[1,1],\"old_string\":\"foo\"," + "\"new_string\":\"bar\"}", &err); + CHECK(err == 0, "重复行号应去重,而不是当成两处: %s", r); + free(r); + + /* 集合里有一行越界 → 和单个行号时一样,报出文件总行数 */ + r = call("edit_file", + "{\"path\":\"set.txt\",\"line\":[1,99],\"old_string\":\"x\"," + "\"new_string\":\"y\"}", &err); + CHECK(err == 1 && strstr(r, "只有 4 行") != NULL, + "集合里的越界行号应报错并给出总行数: %s", r); + free(r); + + /* 写法本身不对的三种:空数组、非整数元素、一次给太多 */ + r = call("edit_file", + "{\"path\":\"set.txt\",\"line\":[],\"old_string\":\"x\"," + "\"new_string\":\"y\"}", &err); + CHECK(err == 1 && strstr(r, "空数组") != NULL, "空数组应报错: %s", r); + CHECK(strstr(r, "省略") != NULL, "空数组的报错要提示可以整个省略 line: %s", r); + free(r); + + r = call("edit_file", + "{\"path\":\"set.txt\",\"line\":[1,1.5],\"old_string\":\"x\"," + "\"new_string\":\"y\"}", &err); + CHECK(err == 1 && strstr(r, "第 2 个") != NULL, + "非整数元素应报错并指出是第几个: %s", r); + free(r); + + /* ---- 省略 line = 全文件替换所有匹配 ---- + * 这是 range 那样的集合参数该有的默认值:不给范围就是不设范围。 */ + r = call("write_file", "{\"path\":\"all.txt\",\"content\":\"k\\nx\\nk\\n\"}", &err); + CHECK(err == 0, "写入 all.txt: %s", r); + free(r); + r = call("edit_file", + "{\"path\":\"all.txt\",\"old_string\":\"k\",\"new_string\":\"K\"}", &err); + CHECK(err == 0, "省略 line 应做全文件替换: %s", r); + CHECK(strstr(r, "全文件替换") != NULL && strstr(r, "2 处") != NULL, + "回执要说清是全文件替换、换了几处: %s", r); + free(r); + r = call("read_file", "{\"path\":\"all.txt\"}", &err); + CHECK(strstr(r, "K") != NULL && !strstr(r, "k"), "两处都该被换掉: %s", r); + free(r); + + /* 省略 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 给错了类型(不是"想全局"):仍然当场报错,并告诉它可以省略 */ + r = call("edit_file", + "{\"path\":\"all.txt\",\"line\":\"1\",\"old_string\":\"K\"," + "\"new_string\":\"z\"}", &err); + CHECK(err == 1 && strstr(r, "整数或整数数组") != NULL, "line 类型错应报错: %s", r); + CHECK(strstr(r, "整个省略") != NULL, "报错要指出可以省略 line: %s", r); + free(r); + + { + /* 65 个行号:超过一次能圈住的范围,要明说 */ + char big[600]; + size_t o = (size_t)snprintf(big, sizeof big, "{\"path\":\"set.txt\",\"line\":["); + for (int i = 1; i <= 65; i++) + o += (size_t)snprintf(big + o, sizeof big - o, "%s%d", i > 1 ? "," : "", i); + snprintf(big + o, sizeof big - o, "],\"old_string\":\"x\",\"new_string\":\"y\"}"); + r = call("edit_file", big, &err); + CHECK(err == 1 && strstr(r, "一次最多") != NULL, "行号过多应报错: %s", r); + free(r); + } + + /* ---- list_dir ---- */ + puts("list_dir"); + r = call("list_dir", "{\"path\":\".\"}", &err); + CHECK(err == 0 && strstr(r, "sub/") != NULL, "应列出子目录: %s", r); + free(r); + + r = call("list_dir", "{\"path\":\"nope\"}", &err); + CHECK(err == 1, "不存在的目录应报错: %s", r); + free(r); + + /* 这两个工具都已经删掉:搜索交给模型自己用 run_command 里的真 grep, + * 子代理(spawn_subagent)整个功能也去掉了。留着断言是为了钉住 + * "不要再冒出一个半吊子的替代品"。 */ + r = call("grep", "{\"pattern\":\"x\",\"path\":\".\"}", &err); + CHECK(err == 1 && strstr(r, "未知工具") != NULL, "不该再有 grep 工具: %s", r); + free(r); + r = call("spawn_subagent", "{}", &err); + CHECK(err == 1 && strstr(r, "未知工具") != NULL, "不该再有子代理工具: %s", r); + free(r); + + /* ---- 超大文件不能把内存吃干 ---- */ + puts("超大文件防护"); + { + /* 稀疏文件,创建几乎不耗时;关键是客户端要在读之前就发现它太大。 + * 开头 16 KB 写成 'x':这样它是"文本"(探测的前 8 KB 里没有 NUL), + * 否则连 read_file 都会先被二进制检测拦掉 —— 这个用例就测不到 + * "分段读大文件"了。 */ + FILE *big = fopen("huge.txt", "wb"); + CHECK(big != NULL, "创建测试文件失败"); + if (big) { + char head[16 * 1024]; + memset(head, 'x', sizeof head); + fwrite(head, 1, sizeof head, big); + fseek(big, 40L * 1024 * 1024, SEEK_SET); + fputc('x', big); + fclose(big); + } + r = call("edit_file", + "{\"path\":\"huge.txt\",\"line\":1,\"old_string\":\"x\",\"new_string\":\"y\"}", + &err); + CHECK(err == 1, "超大文件的 edit_file 应被拒绝: %.120s", r); + CHECK(strstr(r, "上限") != NULL, "应说明是超上限: %.120s", r); + free(r); + + /* 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"); + CHECK(bf != NULL, "创建二进制测试文件失败"); + if (bf) { fwrite("PNG\0\1\2\3", 1, 8, bf); fclose(bf); } + } + + r = call("read_file", "{\"path\":\"bin.dat\"}", &err); + CHECK(err == 1, "read_file 读二进制应被拒绝: %s", r); + CHECK(strstr(r, "二进制") != NULL && strstr(r, "第 4 字节") != NULL, + "拒绝理由要指出 NUL 在哪一字节: %s", r); + free(r); + + r = call("edit_file", + "{\"path\":\"bin.dat\",\"line\":1,\"old_string\":\"PNG\",\"new_string\":\"JPG\"}", + &err); + CHECK(err == 1 && strstr(r, "二进制") != NULL, "edit_file 改二进制应被拒绝: %s", r); + free(r); + + r = call("write_file", "{\"path\":\"bin.dat\",\"content\":\"text\"}", &err); + CHECK(err == 1 && strstr(r, "二进制") != NULL, + "write_file 覆盖二进制应被拒绝: %s", r); + free(r); + + /* 被拒绝的三次都不能动到文件本身 —— 拒绝的意义就在这里 */ + { + FILE *bf = fopen("bin.dat", "rb"); + char keep[16]; + memset(keep, 0, sizeof keep); + size_t kn = bf ? fread(keep, 1, sizeof keep, bf) : 0; + if (bf) fclose(bf); + CHECK(kn == 8 && memcmp(keep, "PNG\0\1\2\3", 8) == 0, + "被拒绝的读/写不能改动二进制文件的字节"); + } + + /* 确认提示里要能看出改的是哪一行 */ + r = call("write_file", "{\"path\":\"confirm.txt\",\"content\":\"aaa\\nbbb\\n\"}", &err); + free(r); + r = call("edit_file", + "{\"path\":\"confirm.txt\",\"line\":2,\"old_string\":\"bbb\",\"new_string\":\"ccc\"}", + &err); + 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("没有这个工具") == 0, "未知工具按只读处理"); + CHECK(tool_mutating(NULL) == 0, "空名字不应崩"); + + /* ---- read_image:只收真的能内联进去的图 ---- + * + * 两条拒绝路径是新的:格式不认识、体积超限。它们都必须**不备份** —— + * 一张交不出去的图,留备份只会让 ligent.archive 变成垃圾场。 */ + puts("read_image:本地就把不合格的图拦掉"); + { + char btmp[] = "/tmp/ligent_tools_img_XXXXXX"; + char *bdir = mkdtemp(btmp); + CHECK(bdir != NULL, "建备份临时目录失败"); + if (bdir) { + image_set_bindir(bdir); + image_pending_take(); /* 清干净,免得上一段残留 */ + + /* 1) 内容是文本、扩展名骗人 */ + FILE *f = fopen("notanimage.png", "wb"); + if (f) { fputs("这不是图片", f); fclose(f); } + r = call("read_image", "{\"path\":\"notanimage.png\"}", &err); + CHECK(err == 1 && strstr(r, "不认识") != NULL, + "非图片内容应被拒: %s", r); + CHECK(strstr(r, "按文件内容判断") != NULL, "应说明是看内容判断的"); + free(r); + + /* 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) { + fwrite("\x89PNG\r\n\x1a\n", 1, 8, f); + char *pad = xcalloc(1, 65536); + size_t left = raw_needed - 8; + while (left > 0) { + size_t chunk = left > 65536 ? 65536 : left; + if (fwrite(pad, 1, chunk, f) != chunk) break; + left -= chunk; + } + free(pad); + fclose(f); + } + r = call("read_image", "{\"path\":\"huge.png\"}", &err); + CHECK(err == 1 && strstr(r, "太大") != NULL, + "超限的图应被当场拒掉: %.120s", r); + CHECK(strstr(r, "内联上限") != NULL, "应说清上限是多少"); + free(r); + + /* 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); + CHECK(err == 0 && strstr(r, "已读入图片") != NULL, + "合法图片应成功: %s", r); + CHECK(strstr(r, "base64") != NULL, "应说明会以 base64 内联"); + free(r); + + JsonValue *att = image_pending_take(); + CHECK(att && json_array_size(att) == 1, "应挂上 1 个待发附件"); + if (att && json_array_size(att) == 1) + CHECK(strcmp(json_get_string(json_array_get(att, 0), "type"), + "ligent_image") == 0, "附件应是内部引用块"); + json_free(att); + + /* 只有成功那一张留了备份 —— 两条拒绝路径都不该留下东西 */ + char *ls = xasprintf("ls %s/ligent.archive/images/*.png | wc -l", bdir); + FILE *p = popen(ls, "r"); + int cnt = -1; + if (p) { if (fscanf(p, "%d", &cnt) != 1) cnt = -1; pclose(p); } + free(ls); + CHECK(cnt == 1, "只该留 1 份备份(成功那张),实际 %d", cnt); + + image_set_bindir("."); + } + } + + /* ---- 超长单行:read_file 报出的行号必须与 edit_file 的口径一致 ---- + * + * read_file 以前用 fgets + 固定 64 KB 缓冲,一行比缓冲长时会被切成"多行"、 + * 每块各带一个行号。于是模型按 read_file 看到的行号去 edit_file 改,要么改错 + * 地方、要么报越界 —— 而它没有任何办法察觉。工具描述和 README 都写着 + * "read_file 打出来的行号可以直接喂给 edit_file 的 line",这里把它钉住。 */ + puts("超长单行的行号口径"); + { + FILE *lf = fopen("longline.txt", "wb"); + for (int i = 0; i < 70000; i++) fputc('a', lf); /* 跨过 64 KB 那块缓冲 */ + fputc('\n', lf); + fputs("second line\nthird line\n", lf); + fclose(lf); + + r = call("read_file", "{\"path\":\"longline.txt\"}", &err); + 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); + CHECK(strstr(r, " 3\tthird line") != NULL, "第 3 行应是 third line"); + CHECK(strstr(r, " 4\t") == NULL, "不应该出现第 4 行"); + free(r); + + /* 拿 read_file 报出的行号去 edit_file 改,必须命中同一处 */ + r = call("edit_file", + "{\"path\":\"longline.txt\",\"line\":3," + "\"old_string\":\"third line\",\"new_string\":\"THIRD LINE\"}", &err); + CHECK(err == 0, "用 read_file 的行号改第 3 行失败: %s", r); + CHECK(strstr(r, "第 3 行替换 1 处") != NULL, "回执应说清改的是第 3 行: %s", r); + free(r); + char *after = read_entire_file("longline.txt", NULL); + 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); + fputs("tail\n", hf); + fclose(hf); + r = call("read_file", "{\"path\":\"hugeline.txt\"}", &err); + CHECK(strstr(r, "第 1 行本身就超过") != NULL, + "应说清是这一行超过上限:%.160s", r); + CHECK(strstr(r, " 2\t") == NULL, "不能把剩下的半个行伪装成第 2 行"); + free(r); + remove("hugeline.txt"); + remove("longline.txt"); + } + + /* ---- 空目录:qsort 的第一个参数会拿到 NULL ---- */ + puts("list_dir 空目录"); + if (mkdir("emptydir", 0755) != 0) { /* 可能已存在 */ } + r = call("list_dir", "{\"path\":\"emptydir\"}", &err); + CHECK(err == 0 && strstr(r, "0 个条目") != NULL, + "空目录应报 0 个条目,实际 %s", r); + free(r); + rmdir("emptydir"); + + /* ---- 未知工具 ---- */ + r = call("no_such_tool", "{}", &err); + CHECK(err == 1 && strstr(r, "未知工具") != NULL, "未知工具应报错: %s", r); + free(r); + + /* ---- 输出截断 ---- */ + puts("output truncation"); + 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)); + CHECK(strstr(r, "截断") != NULL, "应提示已截断"); + free(r); + + 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 new file mode 100755 index 0000000..5a47c2f --- /dev/null +++ b/tools/build-static-deps.sh @@ -0,0 +1,363 @@ +#!/bin/sh +# build-static-deps.sh - 造一份"musl 工具链编出来的静态 libcurl",给 make static 用。 +# +# 为什么需要它:发行版的 libcurl.a 是针对 glibc 编的,跟 musl 混link会得到一堆 +# "undefined reference to __libc_start_main"。所以必须自己用 musl 工具链把 +# libcurl 和它的依赖(zlib、OpenSSL)编一遍。 +# +# 产物在 build/static-prefix/,之后 `make static` 就能链了。 +# +# 用法: +# ./tools/build-static-deps.sh # 用默认版本 +# ZLIB=1.3.1 OPENSSL=3.0.15 CURL=8.11.1 ./tools/build-static-deps.sh +# 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 写的)、以及 +# **能联网**(要下载三个源码包)。构建大约几分钟。 +set -eu + +ZLIB_VER=${ZLIB:-1.3.1} +OPENSSL_VER=${OPENSSL:-3.0.15} +CURL_VER=${CURL:-8.11.1} + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +PREFIX=${PREFIX:-$ROOT/build/static-prefix} +SRC=${SRC:-$ROOT/build/static-src} +JOBS=${JOBS:-$( (nproc 2>/dev/null) || echo 2 )} + +# 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 " Artix/Arch: sudo pacman -S musl # 提供 musl-gcc" >&2 + echo " 或指定: CC=x86_64-linux-musl-gcc $0" >&2 + exit 1 + fi +fi + +need() { + 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 +need tar + +# ---------------------------------------------------------------- 架构 +# OpenSSL 的 Configure 要一个明确的 target 名,它**不做**自动探测。以前这里 +# 写死 linux-x86_64,于是在 aarch64/riscv64 上会去编 x86 的汇编,直接失败 —— +# 而这个脚本的产物恰恰是"拷到别的机器也能跑"的静态件。 +# 认不出来时**不猜**:猜错的后果是编出一堆看着像成功、其实不对的东西。 +OPENSSL_TARGET=${OPENSSL_TARGET:-} +if [ -z "$OPENSSL_TARGET" ]; then + case "$(uname -m)" in + x86_64|amd64) OPENSSL_TARGET=linux-x86_64 ;; + i?86) OPENSSL_TARGET=linux-elf ;; + aarch64|arm64) OPENSSL_TARGET=linux-aarch64 ;; + armv7l|armv6l) OPENSSL_TARGET=linux-armv4 ;; + riscv64) OPENSSL_TARGET=linux64-riscv64 ;; + ppc64le) OPENSSL_TARGET=linux-ppc64le ;; + s390x) OPENSSL_TARGET=linux64-s390x ;; + loongarch64) OPENSSL_TARGET=linux64-loongarch64 ;; # 3.1+ 才有 + *) + echo "认不出这台机器的架构($(uname -m)),而 OpenSSL 的 Configure" >&2 + echo "需要一个明确的 target 名。请显式指定,例如:" >&2 + echo " OPENSSL_TARGET=linux-aarch64 $0" >&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,于是 +# relocation R_X86_64_32 against ... can not be used when making a PIE object +# 而且 zlib 的 configure 探测 vsnprintf 也会因此失败,悄悄退化成不安全的 +# vsprintf 版本(实测踩过)。 +# 体积优先。静态产物是"拷过去就能跑"的发布件,不是拿来调试的,所以这里的 +# 取舍和普通构建相反 —— 目标是越小越好。三组开关要一起上才有意义: +# -Os 别做以体积换速度的展开/内联 +# -ffunction-sections 依赖库也按函数分段,这样最后链接时 +# -fdata-sections --gc-sections 才**丢得掉**没被引用的部分 +# -fno-*-unwind-tables C 代码不需要 C++ 异常和栈回溯,.eh_frame 全省 +# 实测(连同 Makefile 那侧的 strip 等一起):6.24 MB → 3.29 MB。 +# 其中这一半的功劳很大:libcurl+OpenSSL 的 .text 从 3.45 MB 掉到 2.41 MB —— +# 静态归档里本来就有一大堆我们永远走不到的算法和协议实现。 +# 想回到调试友好(-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 就死在这一步: +# fatal error: linux/mman.h: No such file or directory +# 用 -idirafter:musl 自己的头优先,/usr/include 只在 musl 没有时兜底。 +# 直接写 -I 会让 glibc 的头盖过 musl 的,那是另一个坑。 +UAPI_INC="-idirafter /usr/include" + +# ---- configure 参数(抽出来是为了进 WANT,见下面的 stamp 机制)---- +# +# 以前这些参数直接写在各自的 configure 调用处,于是"改了参数"这件事**只有人 +# 记得把 BUILD_DEFS 加一**才能被发现;忘了就继续用旧参数编出来的库,症状是 +# "我明明加了个 --disable-x,怎么产物一点没变"。 + +# zlib 只要 --static:我们要的是 libz.a +ZLIB_CONFIGURE_ARGS="--static" + +# OpenSSL:no-shared 只出 .a;no-tests 少编一堆测试程序(省几分钟); +# no-legacy 去掉已淘汰的算法(体积)。 +OPENSSL_CONFIGURE_ARGS="no-shared no-tests no-legacy" + +# curl:ligent 只用 chat/completions 一个端点,所以只要能发 HTTPS 的那部分。 +# --disable-* FTP/邮件/字典/… 这些协议编进来纯属占地 +# --without-* 静态链接下每个"可选增强"都会再拖一串依赖进来(libpsl、idn2、 +# brotli、zstd、nghttp2/3…),而 ligent 一个都不用 +# --with-ca-* 把 CA 路径编进去 —— 静态件跑到别的机器上时得知道去哪找根证书 +CURL_CONFIGURE_ARGS=" +--disable-shared --enable-static +--with-openssl=$PREFIX --with-zlib=$PREFIX +--disable-ldap --disable-ldaps --disable-rtsp --disable-dict +--disable-telnet --disable-tftp --disable-pop3 --disable-imap +--disable-smtp --disable-gopher --disable-mqtt --disable-manual +--without-libpsl --without-libidn2 --without-brotli --without-zstd +--without-nghttp2 --without-ngtcp2 --without-nghttp3 +--with-ca-bundle=/etc/ssl/certs/ca-certificates.crt +--with-ca-path=/etc/ssl/certs" + +mkdir -p "$SRC" "$PREFIX" + +# 编译选项变了就得把依赖整个重建。否则下面每个守卫都会看到 libz.a 已存在而 +# 直接跳过,于是你一直用着旧选项编出来的库,而且**看不出来** —— 症状是 +# "我明明加了 -Os,怎么产物一点没小"。 +# 这和 Makefile 里"对象目录按编译器/插桩分标签"是同一个坑的两处:make 和这个 +# 脚本都只看文件在不在,看不见命令行变了。 +STAMP="$PREFIX/.build-flags" +# BUILD_DEFS:配方版本号,留给"改了脚本逻辑、但 WANT 里的文本一个字都没变"的 +# 改动(比如换了一种绕坑的处理方式)。凡是会改变产物内容的改动都要加一。 +# +# 注意它现在**不再是唯一的触发源**:编译选项、三个版本号、OpenSSL target、 +# 每个库的 configure 参数都直接进了下面的 WANT,改它们会自动触发重建,不需要 +# 谁记得来这里加一 —— 以前 configure 参数不在 WANT 里,全靠这个手写数字兜着, +# 而人是会忘的(忘了就是"一直用旧参数编出来的库,看不出来")。 +# 已有的历史值:1 = 加了体积优先的 CFLAGS;2 = configure 期间藏起 .pc 文件; +# 3 = 架构与 configure 参数进指纹(本版)。 +BUILD_DEFS=3 + +# 多行参数折叠成一行:好塞进戳文件,也好肉眼比对 +flatten() { + printf '%s' "$1" | tr -s '[:space:]' ' ' | sed 's/^ *//; s/ *$//' +} + +WANT="defs=$BUILD_DEFS | CC=$CC | zlib=$ZLIB_VER openssl=$OPENSSL_VER curl=$CURL_VER \ + | target=$OPENSSL_TARGET | cflags=$CFLAGS_COMMON | uapi=$UAPI_INC \ + | zlib:[$(flatten "$ZLIB_CONFIGURE_ARGS")] \ + | openssl:[$(flatten "$OPENSSL_CONFIGURE_ARGS")] \ + | curl:[$(flatten "$CURL_CONFIGURE_ARGS")]" +if [ ! -f "$STAMP" ] && [ -f "$PREFIX/lib/libz.a" ]; then + # 有库却没有选项记录 —— 那是**旧版脚本**编的,不知道用的什么选项。 + # 这一条不能省:没有它,下面的判断会认为"stamp 不存在 = 不用重建", + # 然后我们就给旧库写上一个声称是新选项的 stamp,把它洗白成"新的"。 + echo "== 这个前缀没有编译选项记录(旧版脚本编的),重建以确认选项一致" + rm -rf "$PREFIX" + mkdir -p "$PREFIX" +elif [ -f "$STAMP" ] && [ "$(cat "$STAMP" 2>/dev/null)" != "$WANT" ]; then + echo "== 编译选项变了,重建依赖" + echo " 原: $(cat "$STAMP")" + echo " 新: $WANT" + rm -rf "$PREFIX" + mkdir -p "$PREFIX" +fi +printf '%s' "$WANT" > "$STAMP" + +# ---- 被打断时把藏起来的 .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 + mv "$PCHIDE" "$PCDIR" + fi +} +trap 'restore_pc' EXIT INT TERM HUP + +# fetch <输出文件> —— 多个地址依次试。 +# 各家下载站的目录结构会变(zlib 就把旧版本挪到了 /fossils/),写死一个地址 +# 迟早会 404,而那时的报错是 curl 的一句 "error: 404",看不出该改哪。 +fetch() { + out=$1; shift + [ -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 " 这个地址不行,换下一个" + rm -f "$out.part" + done + echo " 下载 $(basename "$out") 失败:所有候选地址都不可用。" >&2 + echo " 要么是没网,要么是版本号写错了。可以指定版本重来,例如:" >&2 + echo " ZLIB=1.3.1 OPENSSL=3.5.0 CURL=8.11.1 $0" >&2 + exit 1 +} + +echo "== 目标前缀: $PREFIX" +echo "== 工具链 : $CC ($($CC --version 2>&1 | head -1))" +echo + +# ---------------------------------------------------------------- zlib +if [ ! -f "$PREFIX/lib/libz.a" ]; then + echo "== 编 zlib $ZLIB_VER" + fetch "$SRC/zlib-$ZLIB_VER.tar.gz" \ + "https://zlib.net/zlib-$ZLIB_VER.tar.gz" \ + "https://zlib.net/fossils/zlib-$ZLIB_VER.tar.gz" \ + "https://github.com/madler/zlib/releases/download/v$ZLIB_VER/zlib-$ZLIB_VER.tar.gz" + rm -rf "$SRC/zlib-$ZLIB_VER" + tar -xzf "$SRC/zlib-$ZLIB_VER.tar.gz" -C "$SRC" + ( cd "$SRC/zlib-$ZLIB_VER" && + CC="$CC" CFLAGS="$CFLAGS_COMMON" \ + ./configure $ZLIB_CONFIGURE_ARGS --prefix="$PREFIX" && + make -j"$JOBS" libz.a && make install ) +else + echo "== zlib 已就绪" +fi + +# ------------------------------------------------------------- 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(这一步最慢)" + 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" + rm -rf "$SRC/openssl-$OPENSSL_VER" + tar -xzf "$SRC/openssl-$OPENSSL_VER.tar.gz" -C "$SRC" + ( cd "$SRC/openssl-$OPENSSL_VER" && + CC="$CC" ./Configure "$OPENSSL_TARGET" $OPENSSL_CONFIGURE_ARGS \ + --prefix="$PREFIX" --openssldir="$PREFIX/ssl" --libdir=lib \ + $CFLAGS_COMMON "$UAPI_INC" && + make -j"$JOBS" && make install_sw ) +else + echo "== OpenSSL 已就绪" +fi + +# ---------------------------------------------------------------- curl +if [ ! -x "$PREFIX/bin/curl-config" ]; then + echo + echo "== 编 curl $CURL_VER" + fetch "$SRC/curl-$CURL_VER.tar.gz" \ + "https://curl.se/download/curl-$CURL_VER.tar.gz" \ + "https://github.com/curl/curl/releases/download/curl-$CURL_VER/curl-$CURL_VER.tar.gz" + rm -rf "$SRC/curl-$CURL_VER" + tar -xzf "$SRC/curl-$CURL_VER.tar.gz" -C "$SRC" + + # ---- configure 期间把 .pc 文件藏起来,别让它走 pkg-config ---- + # + # curl 的 configure 是这么挑 OpenSSL 的(--with-openssl 给的是自定义前缀): + # OPENSSL_PCDIR="$OPT_OPENSSL/lib/pkgconfig" + # if test -f "$OPENSSL_PCDIR/openssl.pc"; then PKGTEST="yes" # 用 pkg-config + # ... + # if test "$PKGTEST" != "yes"; then + # SSL_LDFLAGS="-L$PREFIX_OPENSSL/lib" # 直接给目录,路径原样 + # + # 走 pkg-config 那条路会出事:configure 开头就 `LC_ALL=C; export LC_ALL` + # (autoconf 的标准开头,脚本外面设 LC_ALL 压不住它),而 pkg-config 在 + # C locale 下用 isprint() 判断字节要不要转义 —— 每个 >= 0x80 的字节都被判成 + # "不可打印",于是它给路径里每个非 ASCII 字节前面加个反斜杠: + # -L/…/开发/… -> -L/…/\开\发/… + # \ 后面跟 E5 不是合法续字节,所以那串是**非法 UTF-8**。curl 拿到就写进 + # curl-config、libcurl.pc 和一堆 Makefile(实测 26 个文件)。 + # + # 后果不只是难看:之后 `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 # 上一次被打断留下的隐藏目录,先放回去 + if [ -d "$PCDIR" ]; then mv "$PCDIR" "$PCHIDE"; fi + + rc=0 + # shellcheck disable=SC2086 # configure 参数就该按空白摊开成多个词 + ( cd "$SRC/curl-$CURL_VER" && + CC="$CC" CFLAGS="$CFLAGS_COMMON" \ + CPPFLAGS="-I$PREFIX/include $UAPI_INC" LDFLAGS="-L$PREFIX/lib" \ + ./configure --prefix="$PREFIX" $CURL_CONFIGURE_ARGS \ + && make -j"$JOBS" && make install ) || rc=$? + + restore_pc + if [ "$rc" -ne 0 ]; then + echo "curl 构建失败(退出码 $rc)" >&2 + exit "$rc" + fi +else + echo "== curl 已就绪" +fi + +# ------------------------------------------------------------ 自检 +# 光看文件在不在不够 —— 要真的**链一个用 curl 的静态程序**,才能证明这份 +# 前缀是可用的(静态链接最常栽在依赖顺序和缺库上,而那只在链接时暴露)。 +echo +echo "== 自检:编一个最小程序并静态链接" +CHECK="$SRC/check.c" +cat > "$CHECK" <<'EOF' +#include +int main(void) { + CURL *c = curl_easy_init(); + if (!c) return 1; + curl_easy_setopt(c, CURLOPT_URL, "https://example.com/"); + curl_easy_cleanup(c); + return 0; +} +EOF +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 " 静态链接成功,且确实不依赖动态库" +else + 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 就跳过。 +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 " 删掉这个前缀重跑本脚本:rm -rf \"$PREFIX\"" >&2 + exit 1 + fi +fi + +echo +echo "依赖就绪:$PREFIX" +echo "接下来: make static" diff --git a/tools/check-colors.sh b/tools/check-colors.sh new file mode 100755 index 0000000..d08fb30 --- /dev/null +++ b/tools/check-colors.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# check-colors.sh - 审计所有 ANSI 颜色码,确保都在 16 色内 +# +# 为什么要单独一个脚本:颜色越界这件事**看代码看不出来** —— \033[94m 在人眼里 +# 也是"一个蓝色",只有拿尺子量才知道它属于 aixterm 的亮色段,不在最窄的 +# 16 色 ANSI 里。而这个项目是硬编码 SGR、不查 terminfo 的,既然不探测终端 +# 能力,就该待在所有终端都认的那一档。 +# +# 允许的 SGR 参数: +# 0 重置 +# 1/2/3/4 加粗/暗淡/斜体/下划线(属性,不是颜色) +# 30-37 标准前景 8 色 +# 39 默认前景色 +# 不允许:90-97(亮色)、38;5/48;5(256 色)、38;2/48;2(真彩) +set -eu + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +cd "$ROOT" + +# 只扫源码,不扫文档 —— README 里要举反例讲这件事,出现在那里是对的 +bad=$(grep -rhoE '\\033\[[0-9;]+m' src/*.c src/*.h 2>/dev/null \ + | grep -oE '[0-9;]+' \ + | tr ';' '\n' \ + | sort -n -u \ + | awk '$1 != 0 && $1 != 1 && $1 != 2 && $1 != 3 && $1 != 4 \ + && $1 != 39 && !($1 >= 30 && $1 <= 37) { print }' || true) + +if [ -n "$bad" ]; then + echo "发现 16 色范围外的 SGR 参数:" >&2 + printf ' %s\n' $bad >&2 + echo "" >&2 + echo "允许的:0 1 2 3 4 30-37 39" >&2 + echo "越界的:90-97(aixterm 亮色)、38;5/48;5(256 色)、38;2/48;2(真彩)" >&2 + 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 色内。" diff --git a/tools/get-version.sh b/tools/get-version.sh new file mode 100755 index 0000000..fc6ed21 --- /dev/null +++ b/tools/get-version.sh @@ -0,0 +1,126 @@ +#!/bin/sh +# get-version.sh - 算出版本号,供 Makefile 用 -D 注入 +# +# 输出六行(顺序固定,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. 本脚本算出的版本串(仅供人看/调试) +# +# 版本方案:**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 的粒度是"天",同一天可能发好几次;而且用户手上 +# 的二进制未必来自 tag(可能自己改了源码)。带上哈希,报 bug 时能精确定位。 +# +# 分隔符用 "-" 而不是 git describe 传统的 "+g":我们只用 git,不存在"区分是 +# 哪种 SCM"的需求(那正是 g 前缀存在的理由),而 2026.09.22-1a2b3c4 少两个 +# 符号、念起来也顺。 +# +# 取版本号的优先级(从高到低): +# 1. 环境变量 LIGENT_VERSION_OVERRIDE —— 给 CI 用,tag 打得不对时能手动指定 +# 2. git tag 里最新的 CalVer 形态的 tag(v2026.02.14 或 2026.02.14) +# 3. 源码目录的 mtime(从 tarball 解出来时没有 .git,用文件的日期当版本) +# +# 任何一步失败都不算致命:退回一个明确的默认值,绝不让构建挂掉 —— +# 版本号不对只是难看,构建挂掉是真的不能用。 +set -eu + +ROOT=$(cd "$(dirname "$0")/.." && pwd) + +# ------------------------------------------------------- 构建日期与时间 +# SOURCE_DATE_EPOCH 是"可复现构建"的标准约定:设了它就用它当构建时间, +# 这样同一个源码在不同时间编出来的二进制是**逐字节相同**的。 +# 不认这个约定的话,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 \ + || echo unknown) + BUILD_TIME=$(date -u -d "@$SOURCE_DATE_EPOCH" +%H:%M:%S 2>/dev/null \ + || date -u -r "$SOURCE_DATE_EPOCH" +%H:%M:%S 2>/dev/null \ + || echo unknown) +else + BUILD_DATE=$(date -u +%Y-%m-%d 2>/dev/null || echo unknown) + BUILD_TIME=$(date -u +%H:%M:%S 2>/dev/null || echo unknown) +fi + +# ---------------------------------------------------------------- 主版本号 +if [ -n "${LIGENT_VERSION_OVERRIDE:-}" ]; then + CALVER=$LIGENT_VERSION_OVERRIDE +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=$(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 有用。 + if [ -z "$CALVER" ]; then + CALVER=$(date -u -r "$ROOT/src/main.c" +%Y.%m.%d 2>/dev/null || true) + fi + [ -n "$CALVER" ] || CALVER=0.0.0-unknown +fi + +# ---------------------------------------------------------------- git 哈希 +# 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 + # 未跟踪的新文件也算脏 —— 它可能正是被编译进去的那个源文件 + HASH="$HASH.dirty" + fi + fi +fi +[ -n "$HASH" ] || HASH=unknown + +# ---------------------------------------------------------------- 分支名 +# 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 的分支。 + if [ -n "$b" ] && [ "$b" != "HEAD" ]; then + BRANCH=$b + fi +fi +[ -n "$BRANCH" ] || BRANCH=unknown + +printf '%s\n' "$CALVER" +printf '%s\n' "$HASH" +printf '%s\n' "$BRANCH" +printf '%s\n' "$BUILD_DATE" +printf '%s\n' "$BUILD_TIME" +# 第 6 行只是给人看的完整串。哈希未知时**不要**拼出 "…-unknown" —— +# 那既丑又会被误读成一个真实的哈希。src/version.c 的拼接遵循同一规则, +# 两处必须一致,否则这个"调试输出"会误导人。 +if [ "$HASH" = unknown ]; then + printf '%s\n' "$CALVER" +else + printf '%s-%s\n' "$CALVER" "$HASH" +fi diff --git a/tools/md_demo.c b/tools/md_demo.c new file mode 100644 index 0000000..c261650 --- /dev/null +++ b/tools/md_demo.c @@ -0,0 +1,305 @@ +/* md_demo.c - Markdown 渲染效果演示 + * + * 一个独立的小程序:把内置示例(或你指定的文件 / stdin)喂进 ligent 的 + * 流式 Markdown 渲染器,直观看到每种语法的渲染结果。 + * + * 编译: make md_demo + * 运行: ./build/md_demo + */ +#include "markdown.h" +#include "util.h" + +#include +#include +#include +#include +#include + +/* ------------------------------------------------------------------ */ +/* 内置示例:把支持的语法和边界情况都覆盖一遍 */ +/* ------------------------------------------------------------------ */ + +static const char *SAMPLE = +"# ligent 渲染演示\n" +"\n" +"## 行内样式\n" +"\n" +"这是 **粗体**、*斜体*、_下划线斜体_ 和 `行内代码` 的混排效果。\n" +"标识符不会被误伤:foo_bar_baz、__init__、arr[i]、a_b_c 都保持原样。\n" +"链接只高亮方括号:[官方文档](https://example.com/doc) 后面还有普通文字。\n" +"图片保留原文:![架构图](./arch.png)\n" +"\n" +"## 列表与引用\n" +"\n" +"- 减号列表项\n" +"- 第二项\n" +"* 星号也能当列表\n" +"+ 加号也可以\n" +"\n" +"> 这是一段引用,前缀是暗色的竖线。\n" +"> 引用可以有多行。\n" +"\n" +"---\n" +"\n" +"## 代码块(整块不渲染)\n" +"\n" +"```c\n" +"#include \n" +"int main(void) {\n" +" // **这里不会加粗**,[方括号] 也不会高亮\n" +" printf(\"hello %s\\n\", \"world\");\n" +" return 0;\n" +"}\n" +"```\n" +"\n" +"行内代码同理:`a ** b` 里的星号不会被当成粗体标记。\n" +"\n" +"## 表格\n" +"\n" +"| 语言 | 类型系统 | 典型用途 | 份额 |\n" +"|:-----|:--------:|----------|-----:|\n" +"| C | 静态弱类型 | 系统编程 | 11.4% |\n" +"| Rust | 静态强类型 | 系统编程 | 3.2% |\n" +"| Python | 动态强类型 | 脚本 / AI | 23.1% |\n" +"\n" +"| 单列表格 |\n" +"|---|\n" +"| 也能渲染 |\n" +"\n" +"## 边界情况\n" +"\n" +"乘法星号原样保留:2 * 3 * 4 = 24\n" +"管道命令不会被误判成表格:ls -la | grep foo\n" +"未闭合的 **粗体标记 会在行尾被复位\n" +"#井号后没有空格就不是标题\n" +"####### 七个井号也不是标题\n" +"\n"; + +/* ------------------------------------------------------------------ */ +/* 渲染到内存(用于 --compare 自检) */ +/* ------------------------------------------------------------------ */ + +static char *render_to_mem(const char *src, int enabled, size_t chunk) +{ + char *buf = NULL; + size_t size = 0; + FILE *f = open_memstream(&buf, &size); + if (!f) return NULL; + + MdRenderer *r = md_new(enabled); + md_set_out(r, f); + + if (chunk == 0) { + md_feed(r, src); + } else { + size_t len = strlen(src); + for (size_t i = 0; i < len; i += chunk) { + size_t n = len - i < chunk ? len - i : chunk; + char *tmp = xstrndup(src + i, n); + md_feed(r, tmp); + free(tmp); + } + } + md_finish(r); + md_free(r); + fclose(f); + return buf; +} + +/* ------------------------------------------------------------------ */ +/* 渲染到终端(可带分片与延时,模拟 SSE 流式) */ +/* ------------------------------------------------------------------ */ + +static void render_to_tty(const char *src, int enabled, + size_t chunk, long delay_ms) +{ + MdRenderer *r = md_new(enabled); + md_set_out(r, stdout); + + size_t len = strlen(src); + if (chunk == 0) chunk = len ? len : 1; + + struct timespec ts; + ts.tv_sec = delay_ms / 1000; + ts.tv_nsec = (delay_ms % 1000) * 1000000L; + + for (size_t i = 0; i < len; i += chunk) { + size_t n = len - i < chunk ? len - i : chunk; + char *tmp = xstrndup(src + i, n); + md_feed(r, tmp); + free(tmp); + fflush(stdout); + if (delay_ms > 0) nanosleep(&ts, NULL); + } + md_finish(r); + md_free(r); +} + +/* ------------------------------------------------------------------ */ +/* 参数 */ +/* ------------------------------------------------------------------ */ + +static void usage(FILE *out) +{ + fputs( +"md_demo - 演示 ligent 的流式 Markdown 渲染效果\n" +"\n" +"用法: md_demo [选项] [文件...]\n" +"\n" +"不指定文件时使用内置示例;文件名为 - 或 stdin 不是终端时从标准输入读取。\n" +"\n" +"选项:\n" +" -p, --plain 关闭渲染,输出原始 Markdown(对照用)\n" +" --color 强制开启渲染(默认仅在输出到终端时开启)\n" +" -c, --chunk N 按每 N 字节一片喂入(默认 0 = 一次性喂完)\n" +" -d, --delay MS 每片之间延时毫秒(默认 0)\n" +" -s, --stream 流式预设:等价于 -c 5 -d 12\n" +" --compare 自检:把同一段文本按多种分片方式渲染,比对结果是否一致\n" +" -h, --help 显示本帮助\n" +"\n" +"例子:\n" +" md_demo 看渲染效果\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", + out); +} + +static char *read_stream(FILE *f) +{ + StrBuf sb; + sb_init(&sb); + char buf[8192]; + size_t n; + while ((n = fread(buf, 1, sizeof buf, f)) > 0) + sb_append(&sb, buf, n); + return sb_detach(&sb); +} + +int main(int argc, char **argv) +{ + int plain = 0, force_color = 0, compare = 0; + long delay_ms = 0; + size_t chunk = 0; + const char *file = NULL; + + for (int i = 1; i < argc; i++) { + const char *a = argv[i]; + if (strcmp(a, "-h") == 0 || strcmp(a, "--help") == 0) { + usage(stdout); + return 0; + } else if (strcmp(a, "-p") == 0 || strcmp(a, "--plain") == 0) { + plain = 1; + } else if (strcmp(a, "--color") == 0) { + force_color = 1; + } else if (strcmp(a, "-c") == 0 || strcmp(a, "--chunk") == 0) { + if (++i >= argc) { fputs("缺少 -c 的参数\n", stderr); return 2; } + chunk = (size_t)strtoul(argv[i], NULL, 10); + } else if (strcmp(a, "-d") == 0 || strcmp(a, "--delay") == 0) { + if (++i >= argc) { fputs("缺少 -d 的参数\n", stderr); return 2; } + delay_ms = strtol(argv[i], NULL, 10); + } else if (strcmp(a, "-s") == 0 || strcmp(a, "--stream") == 0) { + chunk = 5; + delay_ms = 12; + } else if (strcmp(a, "--compare") == 0) { + compare = 1; + } else if (a[0] == '-' && a[1] != '\0' && strcmp(a, "-") != 0) { + fprintf(stderr, "未知选项 %s(--help 查看用法)\n", a); + return 2; + } else { + file = a; + } + } + + /* 取输入 */ + char *src = NULL; + const char *source_name = "内置示例"; + if (file && strcmp(file, "-") != 0) { + size_t len = 0; + src = read_entire_file(file, &len); + if (!src) { + fprintf(stderr, "无法读取文件 %s\n", file); + return 1; + } + source_name = file; + } else if (file || !isatty(fileno(stdin))) { + src = read_stream(stdin); + source_name = "标准输入"; + if (src[0] == '\0') { + /* stdin 是空管道(例如被 CI / make 调用)时退回内置示例 */ + free(src); + src = xstrdup(SAMPLE); + source_name = "内置示例(stdin 为空)"; + } + } else { + src = xstrdup(SAMPLE); + } + + int enabled = plain ? 0 : (force_color ? 1 : (isatty(fileno(stdout)) ? 1 : 0)); + + if (compare) { + /* 分片不变性自检:不同分片大小必须得到完全相同的结果 */ + 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 }; + int all_ok = 1; + for (size_t k = 0; k < sizeof sizes / sizeof sizes[0]; k++) { + char *part = render_to_mem(src, 1, sizes[k]); + int ok = part && whole && strcmp(whole, part) == 0; + printf(" %-5zu 字节/片 %s\n", sizes[k], ok ? "一致 ✓" : "不一致 ✗"); + if (!ok) all_ok = 0; + free(part); + } + /* 随机分片(模拟真实 SSE 的抖动) */ + srand(12345); + char *buf = NULL; + size_t bsize = 0; + FILE *f = open_memstream(&buf, &bsize); + MdRenderer *r = md_new(1); + md_set_out(r, f); + size_t len = strlen(src), i = 0; + while (i < len) { + size_t n = (size_t)(rand() % 11) + 1; + if (i + n > len) n = len - i; + char *tmp = xstrndup(src + i, n); + md_feed(r, tmp); + free(tmp); + i += n; + } + md_finish(r); + md_free(r); + fclose(f); + int ok = buf && whole && strcmp(whole, buf) == 0; + printf(" %-5s %s\n", "随机", ok ? "一致 ✓" : "不一致 ✗"); + if (!ok) all_ok = 0; + free(buf); + free(whole); + + printf("\n结论:%s\n", all_ok ? "全部一致,标记跨分片处理正确。" + : "存在不一致,渲染器有 bug。"); + free(src); + return all_ok ? 0 : 1; + } + + if (isatty(fileno(stdout))) { + fprintf(stderr, "── 输入:%s 渲染:%s%s ──\n\n", + source_name, enabled ? "开" : "关", + chunk ? "(分片模式)" : ""); + } + + render_to_tty(src, enabled, chunk, delay_ms); + + if (isatty(fileno(stdout))) { + fprintf(stderr, "\n── 演示结束 ──\n"); + if (enabled && chunk == 0) + fprintf(stderr, "试试 `md_demo -s` 看流式分片下标记被切断时的表现," + "或 `md_demo -p` 对照原始 Markdown。\n"); + } + + free(src); + return 0; +}