From e54390a9f8d6207b46ae69ff1bb9923fa2bc6fe2 Mon Sep 17 00:00:00 2001 From: Wang Zhiyu Date: Sat, 3 Oct 2026 14:13:21 +0800 Subject: [PATCH] init --- .dockerignore | 34 + .gitignore | 15 + Dockerfile | 135 +++ LICENSE | 21 + Makefile | 583 ++++++++++ README.md | 37 + README.zh.md | 2142 +++++++++++++++++++++++++++++++++++ README2.md | 2142 +++++++++++++++++++++++++++++++++++ ligent.example.json | 24 + src/agent.c | 915 +++++++++++++++ src/agent.h | 107 ++ src/archive.c | 305 +++++ src/archive.h | 81 ++ src/attach.c | 473 ++++++++ src/attach.h | 64 ++ src/capability.c | 279 +++++ src/capability.h | 64 ++ src/config.c | 555 +++++++++ src/config.h | 92 ++ src/confirm.c | 168 +++ src/confirm.h | 48 + src/doctor.c | 536 +++++++++ src/doctor.h | 17 + src/http.c | 729 ++++++++++++ src/http.h | 98 ++ src/i18n.c | 1325 ++++++++++++++++++++++ src/i18n.h | 62 + src/image.c | 385 +++++++ src/image.h | 108 ++ src/json.c | 789 +++++++++++++ src/json.h | 97 ++ src/lineedit.c | 781 +++++++++++++ src/lineedit.h | 82 ++ src/llm.c | 584 ++++++++++ src/llm.h | 60 + src/main.c | 1251 ++++++++++++++++++++ src/markdown.c | 792 +++++++++++++ src/markdown.h | 37 + src/meta.c | 857 ++++++++++++++ src/meta.h | 53 + src/models.c | 139 +++ src/models.h | 37 + src/priv.c | 56 + src/priv.h | 42 + src/shell.c | 304 +++++ src/shell.h | 20 + src/spinner.c | 165 +++ src/spinner.h | 37 + src/tools.c | 1300 +++++++++++++++++++++ src/tools.h | 74 ++ src/util.c | 457 ++++++++ src/util.h | 111 ++ src/version.c | 202 ++++ src/version.h | 79 ++ tests/confirm_check.py | 275 +++++ tests/fixtures/error.log | 20 + tests/fixtures/session.log | 76 ++ tests/fuzz.c | 345 ++++++ tests/mock_server.py | 1008 +++++++++++++++++ tests/reduce.c | 122 ++ tests/repl_drive.py | 132 +++ tests/repl_edit_check.py | 502 ++++++++ tests/replay.py | 289 +++++ tests/run_e2e.sh | 1544 +++++++++++++++++++++++++ tests/shell_screen_check.py | 221 ++++ tests/test_agent.c | 433 +++++++ tests/test_archive.c | 178 +++ tests/test_attach.c | 177 +++ tests/test_capability.c | 92 ++ tests/test_config.c | 498 ++++++++ tests/test_http.c | 112 ++ tests/test_i18n.c | 219 ++++ tests/test_image.c | 318 ++++++ tests/test_json.c | 315 ++++++ tests/test_lineedit.c | 279 +++++ tests/test_markdown.c | 522 +++++++++ tests/test_meta.c | 387 +++++++ tests/test_models.c | 133 +++ tests/test_priv.c | 118 ++ tests/test_spinner.c | 229 ++++ tests/test_tools.c | 597 ++++++++++ tools/build-static-deps.sh | 363 ++++++ tools/check-colors.sh | 38 + tools/get-version.sh | 126 +++ tools/md_demo.c | 305 +++++ 85 files changed, 29923 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 README.zh.md create mode 100644 README2.md create mode 100644 ligent.example.json create mode 100644 src/agent.c create mode 100644 src/agent.h create mode 100644 src/archive.c create mode 100644 src/archive.h create mode 100644 src/attach.c create mode 100644 src/attach.h create mode 100644 src/capability.c create mode 100644 src/capability.h create mode 100644 src/config.c create mode 100644 src/config.h create mode 100644 src/confirm.c create mode 100644 src/confirm.h create mode 100644 src/doctor.c create mode 100644 src/doctor.h create mode 100644 src/http.c create mode 100644 src/http.h create mode 100644 src/i18n.c create mode 100644 src/i18n.h create mode 100644 src/image.c create mode 100644 src/image.h create mode 100644 src/json.c create mode 100644 src/json.h create mode 100644 src/lineedit.c create mode 100644 src/lineedit.h create mode 100644 src/llm.c create mode 100644 src/llm.h create mode 100644 src/main.c create mode 100644 src/markdown.c create mode 100644 src/markdown.h create mode 100644 src/meta.c create mode 100644 src/meta.h create mode 100644 src/models.c create mode 100644 src/models.h create mode 100644 src/priv.c create mode 100644 src/priv.h create mode 100644 src/shell.c create mode 100644 src/shell.h create mode 100644 src/spinner.c create mode 100644 src/spinner.h create mode 100644 src/tools.c create mode 100644 src/tools.h create mode 100644 src/util.c create mode 100644 src/util.h create mode 100644 src/version.c create mode 100644 src/version.h create mode 100755 tests/confirm_check.py create mode 100644 tests/fixtures/error.log create mode 100644 tests/fixtures/session.log create mode 100644 tests/fuzz.c create mode 100644 tests/mock_server.py create mode 100644 tests/reduce.c create mode 100644 tests/repl_drive.py create mode 100755 tests/repl_edit_check.py create mode 100644 tests/replay.py create mode 100755 tests/run_e2e.sh create mode 100644 tests/shell_screen_check.py create mode 100644 tests/test_agent.c create mode 100644 tests/test_archive.c create mode 100644 tests/test_attach.c create mode 100644 tests/test_capability.c create mode 100644 tests/test_config.c create mode 100644 tests/test_http.c create mode 100644 tests/test_i18n.c create mode 100644 tests/test_image.c create mode 100644 tests/test_json.c create mode 100644 tests/test_lineedit.c create mode 100644 tests/test_markdown.c create mode 100644 tests/test_meta.c create mode 100644 tests/test_models.c create mode 100644 tests/test_priv.c create mode 100644 tests/test_spinner.c create mode 100644 tests/test_tools.c create mode 100755 tools/build-static-deps.sh create mode 100755 tools/check-colors.sh create mode 100755 tools/get-version.sh create mode 100644 tools/md_demo.c 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; +}