chore: 规范标点
This commit is contained in:
1 parent
15e79887fa
commit
3dd2231c70
81 files changed
+1751
-1751
No files matched your search
+2
-2
@@ -749,7 +749,7 @@ int agent_run(Agent *a)
|
||||
/* "这一轮"的定义就在这儿: 一条用户消息引发的一整串工具调用.
|
||||
* 确认提示上的 Always/neVer 只活到这里, 下一条消息进来时复位. */
|
||||
confirm_reset_turn();
|
||||
int img_dropped = 0; /* 图片被服务端拒了只降级一次, 免得来回打转 */
|
||||
int img_dropped = 0; /* 图片被服务端拒了只降级一次, 以免来回打转 */
|
||||
|
||||
agent_ensure_caps(a);
|
||||
|
||||
@@ -800,7 +800,7 @@ int agent_run(Agent *a)
|
||||
tf("agent.img.rejected", "n", i18n_num(n), NULL),
|
||||
col(a, C_OFF));
|
||||
/* 模型那边只收到一句能照做的中文; 原始报错留在这里,
|
||||
* 免得"为什么被拒"这个信息彻底消失 */
|
||||
* 以免"为什么被拒"这个信息彻底消失 */
|
||||
fprintf(stderr, "%s%s%s\n", col(a, C_DIM),
|
||||
tf("agent.img.server_said", "error", err, NULL),
|
||||
col(a, C_OFF));
|
||||
|
||||
+8
-8
@@ -1,4 +1,4 @@
|
||||
/* agent.h - agent 主循环:多轮对话 + 工具调用 */
|
||||
/* agent.h - agent 主循环: 多轮对话 + 工具调用 */
|
||||
#ifndef LIGENT_AGENT_H
|
||||
#define LIGENT_AGENT_H
|
||||
|
||||
@@ -21,7 +21,7 @@ struct Agent {
|
||||
int show_tool_output;
|
||||
int confirm; /* 写操作与命令执行前是否询问 */
|
||||
int probe_caps; /* 首个请求前跑一次能力探测序列 */
|
||||
Capabilities caps; /* 探测结果:决定要不要暴露 read_image / 工具表 */
|
||||
Capabilities caps; /* 探测结果: 决定要不要暴露 read_image / 工具表 */
|
||||
|
||||
/* 工具分发. 默认指向 tools.c 里那套; meta 会话换成自己的,
|
||||
* 于是两边的工具集天然隔离 —— 主模型看不到也调不到 meta 的工具,
|
||||
@@ -51,7 +51,7 @@ char *agent_default_system_prompt(void);
|
||||
void agent_add_system(Agent *a, const char *prompt);
|
||||
void agent_add_user(Agent *a, const char *text);
|
||||
|
||||
/* 加一条 user 消息:文本 + 若干内容块 (parts 所有权转移, 可为 NULL).
|
||||
/* 加一条 user 消息: 文本 + 若干内容块 (parts 所有权转移, 可为 NULL).
|
||||
* /include 用它把文件和图片跟用户这一句话一起发出去. */
|
||||
void agent_add_user_parts(Agent *a, const char *text, JsonValue *parts);
|
||||
|
||||
@@ -59,7 +59,7 @@ void agent_add_user_parts(Agent *a, const char *text, JsonValue *parts);
|
||||
* 探测被关掉时什么都不做. */
|
||||
void agent_ensure_caps(Agent *a);
|
||||
|
||||
/* 手动探测一次:**无视** --no-probe 与缓存, 重新测 (REPL 的 /probe 用它).
|
||||
/* 手动探测一次: **无视** --no-probe 与缓存, 重新测 (REPL 的 /probe 用它).
|
||||
* 与自动探测的唯一区别就是这两点 —— 打出来的结论是同一份. */
|
||||
void agent_probe_now(Agent *a);
|
||||
|
||||
@@ -74,7 +74,7 @@ void agent_reset(Agent *a);
|
||||
* 返回 0 正常结束, -1 出错, 1 被用户中断. */
|
||||
int agent_run(Agent *a);
|
||||
|
||||
/* 结构体检:保证 assistant(tool_calls) 后面紧跟对应的 tool 消息.
|
||||
/* 结构体检: 保证 assistant(tool_calls) 后面紧跟对应的 tool 消息.
|
||||
* 没问题返回 NULL; 修过则返回重建好的数组 (调用方负责替换旧的).
|
||||
* *out_fixed 回填补/删的处数. */
|
||||
JsonValue *agent_repair_tool_pairing(const JsonValue *messages, int *out_fixed);
|
||||
@@ -82,7 +82,7 @@ JsonValue *agent_repair_tool_pairing(const JsonValue *messages, int *out_fixed);
|
||||
/* 当前历史的消息条数 (含 system 提示词, 内部比对用) */
|
||||
size_t agent_history_len(const Agent *a);
|
||||
|
||||
/* 面向用户的"历史消息数":不含 system 提示词 */
|
||||
/* 面向用户的"历史消息数": 不含 system 提示词 */
|
||||
size_t agent_conversation_len(const Agent *a);
|
||||
|
||||
/* 用 given 整体替换历史 (取得所有权). 存档里的 system 消息会被丢掉,
|
||||
@@ -92,13 +92,13 @@ void agent_replace_history(Agent *a, JsonValue *messages);
|
||||
|
||||
/* 摘掉最近一整轮 (最后一条 user 消息及其之后的一切), 返回被摘掉的消息数组,
|
||||
* 没有可摘的则返回 NULL. 调用者负责释放.
|
||||
* 用于 /back:模型调用的工具与结果必须整组一起走, 不能只删中间几条. */
|
||||
* 用于 /back: 模型调用的工具与结果必须整组一起走, 不能只删中间几条. */
|
||||
JsonValue *agent_take_last_round(Agent *a);
|
||||
|
||||
/* 服务端回了 4xx (图片引用失效的重传就靠它触发) */
|
||||
int agent_is_http_4xx(const char *err);
|
||||
|
||||
/* 这条 user 消息是否开启新的一轮. 判据见 agent.c:**只有图片附件、没有文字的**
|
||||
/* 这条 user 消息是否开启新的一轮. 判据见 agent.c: **只有图片附件、没有文字的**
|
||||
* user 消息不算新的一轮 —— 它是 read_image 补上去的, 按它切轮会把紧邻的
|
||||
* assistant(tool_calls) 与 tool 结果拆散 (下一轮请求直接 400).
|
||||
* meta.c 切轮时必须用同一个判据, 否则它报的"轮号"和主循环的口径对不上. */
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ int archive_next_name(const char *bindir, const char *lineage,
|
||||
char suffix[ARCHIVE_META_LEN + 1];
|
||||
random_alnum(suffix, ARCHIVE_BRANCH_LEN);
|
||||
if (meta) {
|
||||
/* _m<3位>:标记 + 随机, 长度 4 与普通分支的 3 区分开 */
|
||||
/* _m<3位>: 标记 + 随机, 长度 4 与普通分支的 3 区分开 */
|
||||
char tail[ARCHIVE_META_LEN + 1];
|
||||
tail[0] = ARCHIVE_META_MARK;
|
||||
memcpy(tail + 1, suffix, ARCHIVE_BRANCH_LEN + 1);
|
||||
|
||||
+7
-7
@@ -1,6 +1,6 @@
|
||||
/* archive.h - 对话存档:导出与载入
|
||||
/* archive.h - 对话存档: 导出与载入
|
||||
*
|
||||
* 命名方式刻意做成"文件名即谱系":
|
||||
* 命名方式刻意做成"文件名即谱系":
|
||||
*
|
||||
* a1b2c3d4 从零开始的一段对话
|
||||
* a1b2c3d4_a9x 从 a1b2c3d4 分出来的分支
|
||||
@@ -10,7 +10,7 @@
|
||||
* 实际文件不同步而出错. 代价是名字会越来越长, 所以超过长度上限时
|
||||
* 重新起一个 8 位随机根 (谱系就此断开, 但至少不会撑爆文件名).
|
||||
*
|
||||
* 存档放在「二进制所在目录」下的 ligent.archive/ 里 —— 和配置同一个原则:
|
||||
* 存档放在「二进制所在目录」下的 ligent.archive/ 里 —— 和配置同一个原则:
|
||||
* 整个程序连同它的数据都待在一个目录里, 拷走即走.
|
||||
*/
|
||||
#ifndef LIGENT_ARCHIVE_H
|
||||
@@ -24,7 +24,7 @@
|
||||
/* 根名与每级分支后缀的长度 */
|
||||
#define ARCHIVE_ROOT_LEN 8
|
||||
#define ARCHIVE_BRANCH_LEN 3
|
||||
/* meta 改过的对话另起一个根, 但名字里留住出处:<原名>_m<3位>.
|
||||
/* meta 改过的对话另起一个根, 但名字里留住出处: <原名>_m<3位>.
|
||||
* 标记是 'm', 所以"下划线后有 4 个字符"只可能是 meta 根,
|
||||
* 而普通分支固定是 3 个 —— 按长度就能分开, 不会歧义. */
|
||||
#define ARCHIVE_META_MARK 'm'
|
||||
@@ -40,11 +40,11 @@ typedef struct {
|
||||
long count; /* 消息条数; -1 表示读取失败 */
|
||||
} ArchiveEntry;
|
||||
|
||||
/* 存档目录:<bindir>/ligent.archive (malloc) */
|
||||
/* 存档目录: <bindir>/ligent.archive (malloc) */
|
||||
char *archive_dir(const char *bindir);
|
||||
|
||||
/* 临时存档的前缀. /import 前那次保底存档用它, 以便和用户主动存下来的分支
|
||||
* 区分开:它只是"换掉当前对话之前先留一手", 不属于分支树的一部分. */
|
||||
* 区分开: 它只是"换掉当前对话之前先留一手", 不属于分支树的一部分. */
|
||||
#define ARCHIVE_TMP_PREFIX "tmp_"
|
||||
|
||||
/* 生成下一个可用的文件名 (不含 .json), 保证在目录里不冲突.
|
||||
@@ -59,7 +59,7 @@ int archive_next_name(const char *bindir, const char *lineage,
|
||||
char *errbuf, size_t errlen);
|
||||
|
||||
/* 导出. lineage 为来源存档名 (NULL/空 = 新根), prefix 见上.
|
||||
* meta=1 表示这份是 meta 编辑过的:名字用 <lineage>_m<3位>, 且 parent 记空.
|
||||
* meta=1 表示这份是 meta 编辑过的: 名字用 <lineage>_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,
|
||||
|
||||
+11
-11
@@ -1,4 +1,4 @@
|
||||
/* attach.c - /include 的实现:暂存、glob 展开、交互式挑选 */
|
||||
/* attach.c - /include 的实现: 暂存、glob 展开、交互式挑选 */
|
||||
#include "attach.h"
|
||||
#include "i18n.h"
|
||||
#include "image.h"
|
||||
@@ -21,7 +21,7 @@ static size_t g_text_bytes; /* 文本累计字节数, 用来卡总量 */
|
||||
|
||||
/* 每个块对应的来源路径, 只用于给用户看.
|
||||
*
|
||||
* 刻意**不**把它塞进块里:那会跟着请求一起发给服务端, 而 content 块里多一个
|
||||
* 刻意**不**把它塞进块里: 那会跟着请求一起发给服务端, 而 content 块里多一个
|
||||
* 自造字段是可以被严格的服务端判 400 的. 显示用的东西就该留在本地. */
|
||||
static char **g_names;
|
||||
static size_t g_names_n, g_names_cap;
|
||||
@@ -100,7 +100,7 @@ void attach_print(void)
|
||||
/* 单个文件的处理 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* 二进制判定 (判据与 8 KB 窗口都在 util.h, 和 read_file 共用一份):
|
||||
/* 二进制判定 (判据与 8 KB 窗口都在 util.h, 和 read_file 共用一份):
|
||||
* 以前这里扫的是**整个文件**而 read_file 只看前 8 KB, 于是同一个文件会出现
|
||||
* "/include 说它是二进制、read_file 说它能读"这种自相矛盾的说法. */
|
||||
|
||||
@@ -132,7 +132,7 @@ static int add_one(const char *path, AttachVisionFn can_image, void *ud,
|
||||
char *err = NULL;
|
||||
char *backup = image_backup_from_path(path, NULL, NULL, &err);
|
||||
if (!backup) {
|
||||
/* 超限/写不了备份:把原话带出去 */
|
||||
/* 超限/写不了备份: 把原话带出去 */
|
||||
static char buf[400];
|
||||
snprintf(buf, sizeof buf, "%s",
|
||||
err ? err : t("attach.err.read_failed"));
|
||||
@@ -171,7 +171,7 @@ static int add_one(const char *path, AttachVisionFn can_image, void *ud,
|
||||
}
|
||||
|
||||
/* 头 + 正文. 头用 ===== 包起来, 模型一眼能看出文件从哪开始.
|
||||
* 文件不以换行结尾时补一个, 免得下一个文件头黏在最后一行后面. */
|
||||
* 文件不以换行结尾时补一个, 以免下一个文件头黏在最后一行后面. */
|
||||
StrBuf sb;
|
||||
sb_init(&sb);
|
||||
sb_printf(&sb, "===== %s (%zu 字节)=====\n", path, len);
|
||||
@@ -206,10 +206,10 @@ int attach_glob(const char *pattern, AttachVisionFn can_image, void *ud,
|
||||
|
||||
glob_t g;
|
||||
memset(&g, 0, sizeof g);
|
||||
/* 认 ~/. **用 #ifdef 包起来**:GLOB_TILDE 是 GNU 扩展, glibc 一定有,
|
||||
/* 认 ~/. **用 #ifdef 包起来**: GLOB_TILDE 是 GNU 扩展, glibc 一定有,
|
||||
* 而 musl 那边不敢打包票 (它挂在编译期 —— 没有这个宏就是编译失败,
|
||||
* 静态 musl 构建会直接挂在这儿). 有就带, 没有就退化成普通路径展开.
|
||||
* 不开 GLOB_BRACE:{a,b} 是 shell 特有写法, 开了会让用户以为别的 shell
|
||||
* 不开 GLOB_BRACE: {a,b} 是 shell 特有写法, 开了会让用户以为别的 shell
|
||||
* 语法也能用. */
|
||||
int gflags = 0;
|
||||
#ifdef GLOB_TILDE
|
||||
@@ -252,7 +252,7 @@ int attach_glob(const char *pattern, AttachVisionFn can_image, void *ud,
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 交互式挑选:逐层进入目录 */
|
||||
/* 交互式挑选: 逐层进入目录 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
#define PICK_MAX_SHOW 200
|
||||
@@ -301,7 +301,7 @@ static void free_list(char **l, size_t n)
|
||||
free(l);
|
||||
}
|
||||
|
||||
/* 相对 dir 解析用户输入:可能是序号、目录名、路径或 glob.
|
||||
/* 相对 dir 解析用户输入: 可能是序号、目录名、路径或 glob.
|
||||
* 返回选中的路径列表 (malloc 数组), *n 回填个数. */
|
||||
static char **resolve_input(const char *dir, const char *input, size_t *n)
|
||||
{
|
||||
@@ -410,7 +410,7 @@ int attach_pick(const char *start, AttachVisionFn can_image, void *ud,
|
||||
char *picked = xstrdup(items[idx - 1]);
|
||||
size_t pl = strlen(picked);
|
||||
if (pl > 0 && picked[pl - 1] == '/') {
|
||||
/* 进目录:去掉尾斜杠 */
|
||||
/* 进目录: 去掉尾斜杠 */
|
||||
picked[pl - 1] = '\0';
|
||||
free(dir);
|
||||
dir = picked;
|
||||
@@ -431,7 +431,7 @@ int attach_pick(const char *start, AttachVisionFn can_image, void *ud,
|
||||
continue;
|
||||
}
|
||||
|
||||
/* 其它:当成 glob / 路径 / 目录名 */
|
||||
/* 其它: 当成 glob / 路径 / 目录名 */
|
||||
size_t rn = 0;
|
||||
char **res = resolve_input(dir, ans, &rn);
|
||||
free(line);
|
||||
|
||||
+6
-6
@@ -1,15 +1,15 @@
|
||||
/* attach.h - /include:把文件附带进下一条消息
|
||||
/* attach.h - /include: 把文件附带进下一条消息
|
||||
*
|
||||
* 设计成"暂存"而不是"立刻发出去":附带文件的意义在于配合一个提问
|
||||
* 设计成"暂存"而不是"立刻发出去": 附带文件的意义在于配合一个提问
|
||||
* ("这几个文件有什么问题"), 所以 /include 先把文件攒起来, 下一条消息
|
||||
* 带上它们一起发. 暂存是进程内状态, 发完就清空.
|
||||
*
|
||||
* 附带的文件在会话里有两种形态:
|
||||
* 附带的文件在会话里有两种形态:
|
||||
* 文本 -> 直接作为 text 块 (就是内容本身)
|
||||
* 图片 -> 存成本机备份, 历史里只放 ligent_image 引用 —— 和 read_image
|
||||
* 完全同一条路, 图片字节不进 JSON, 构造请求时才展开成 base64.
|
||||
*
|
||||
* 图片有额外前提:**模型得能看图**. 所以附带图片前会先用能力探测的结论
|
||||
* 图片有额外前提: **模型得能看图**. 所以附带图片前会先用能力探测的结论
|
||||
* 确认一次, 不支持就跳过并如实说明, 而不是把图塞进去让服务端 400.
|
||||
*/
|
||||
#ifndef LIGENT_ATTACH_H
|
||||
@@ -38,7 +38,7 @@ int attach_has_image(void);
|
||||
|
||||
/* 遇到图片时回调一次, 问"当前模型能收图吗", 返回 0 就跳过这张图.
|
||||
*
|
||||
* 做成回调而不是一个布尔入参:判断"能不能收图"要跑能力探测, 而探测是要发
|
||||
* 做成回调而不是一个布尔入参: 判断"能不能收图"要跑能力探测, 而探测是要发
|
||||
* 请求的. 纯文本的 /include 不该为了一张图都没有的一次附带去探测模型. */
|
||||
typedef int (*AttachVisionFn)(void *ud);
|
||||
|
||||
@@ -49,7 +49,7 @@ typedef int (*AttachVisionFn)(void *ud);
|
||||
int attach_glob(const char *pattern, AttachVisionFn can_image, void *ud,
|
||||
size_t *skipped, char *err, size_t errlen);
|
||||
|
||||
/* 交互式挑选:打印目录列表, 用普通输入行逐层进入 (可递归),
|
||||
/* 交互式挑选: 打印目录列表, 用普通输入行逐层进入 (可递归),
|
||||
* 也能直接敲 glob 或路径. 从 dir 开始 (NULL = 当前目录).
|
||||
* 返回新增的文件数; 用户取消返回 0. */
|
||||
int attach_pick(const char *dir, AttachVisionFn can_image, void *ud,
|
||||
|
||||
+13
-13
@@ -1,4 +1,4 @@
|
||||
/* capability.c - 模型能力探测:基线 -> 合并 -> 单独拆开 */
|
||||
/* capability.c - 模型能力探测: 基线 -> 合并 -> 单独拆开 */
|
||||
#include "capability.h"
|
||||
#include "http.h"
|
||||
#include "i18n.h"
|
||||
@@ -9,13 +9,13 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* 一张 1x1 的 PNG. 探测只需要"一个合法的图片字节流", 越小越好:
|
||||
/* 一张 1x1 的 PNG. 探测只需要"一个合法的图片字节流", 越小越好:
|
||||
* 服务端会把它缩放后按图计费, 尺寸再大也换不来更多信息. */
|
||||
static const char PROBE_PNG_B64[] =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLv"
|
||||
"AAAAAElFTkSuQmCC";
|
||||
|
||||
/* 缓存:按 base_url + model 索引. 同一个进程里换模型会自然重测,
|
||||
/* 缓存: 按 base_url + model 索引. 同一个进程里换模型会自然重测,
|
||||
* 切回来则命中. 条数很少 (人手切的模型个数). */
|
||||
#define CAP_CACHE_MAX 8
|
||||
|
||||
@@ -69,9 +69,9 @@ static char *chat_url(const char *base_url)
|
||||
/* 一个试探请求的结论 */
|
||||
/* 注意别叫 R_OK —— <unistd.h> 里那个是 access() 的权限位, 会撞名 */
|
||||
typedef enum {
|
||||
PROBE_OK = 0, /* 2xx:接受 */
|
||||
PROBE_REJECT = 1, /* 400:明确拒绝 (只有在基线通过时才有意义) */
|
||||
PROBE_UNKNOWN = 2 /* 网络、401/403/404/429/5xx……:什么也说明不了 */
|
||||
PROBE_OK = 0, /* 2xx: 接受 */
|
||||
PROBE_REJECT = 1, /* 400: 明确拒绝 (只有在基线通过时才有意义) */
|
||||
PROBE_UNKNOWN = 2 /* 网络、401/403/404/429/5xx……: 什么也说明不了 */
|
||||
} ProbeResult;
|
||||
|
||||
static ProbeResult probe(const LlmConfig *cfg, const char *url,
|
||||
@@ -82,7 +82,7 @@ static ProbeResult probe(const LlmConfig *cfg, const char *url,
|
||||
JsonValue *body = json_new_object();
|
||||
json_object_set_string(body, "model", cfg->model);
|
||||
json_object_set(body, "messages", json_clone(messages));
|
||||
/* 只让它回一个 token:探测要的是"收不收", 不是回复内容.
|
||||
/* 只让它回一个 token: 探测要的是"收不收", 不是回复内容.
|
||||
* 不设这个的话, 默认开着思考的模型会为了回答"1"生成一大段推理. */
|
||||
json_object_set_number(body, "max_tokens", 1);
|
||||
json_object_set_bool(body, "stream", 0);
|
||||
@@ -108,7 +108,7 @@ static ProbeResult probe(const LlmConfig *cfg, const char *url,
|
||||
long status = 0;
|
||||
char local_err[256];
|
||||
local_err[0] = '\0';
|
||||
/* 探测不重试:它只是一次判断, 服务端抖一下还有兜底 (一律按支持处理),
|
||||
/* 探测不重试: 它只是一次判断, 服务端抖一下还有兜底 (一律按支持处理),
|
||||
* 不值得为此让每个进程启动都多等几个退避周期. */
|
||||
HttpRetry no_retry = HTTP_RETRY_NONE;
|
||||
int rc = http_post(url, cfg->api_key, text, cfg->timeout, verbose,
|
||||
@@ -126,7 +126,7 @@ static ProbeResult probe(const LlmConfig *cfg, const char *url,
|
||||
return PROBE_UNKNOWN;
|
||||
}
|
||||
|
||||
/* 探测用的消息:一句 "1", 可选带一张图 */
|
||||
/* 探测用的消息: 一句 "1", 可选带一张图 */
|
||||
static JsonValue *probe_messages(int with_image)
|
||||
{
|
||||
JsonValue *msgs = json_new_array();
|
||||
@@ -185,20 +185,20 @@ static void run_sequence(const LlmConfig *cfg, Capabilities *out,
|
||||
}
|
||||
out->conclusive = 1;
|
||||
|
||||
/* --- 2. 合并试探:图片 + tools --- */
|
||||
/* --- 2. 合并试探: 图片 + tools --- */
|
||||
JsonValue *both = probe_messages(1);
|
||||
ProbeResult combined = probe(cfg, url, both, 1, verbose, &status, errbuf, errlen);
|
||||
json_free(both);
|
||||
|
||||
if (combined == PROBE_OK) {
|
||||
/* 常见情况:两样都支持, 两个请求收工 */
|
||||
/* 常见情况: 两样都支持, 两个请求收工 */
|
||||
out->vision = out->tools = CAP_YES;
|
||||
if (verbose) fprintf(stderr, "%s", t("capability.note.both_ok"));
|
||||
free(url);
|
||||
return;
|
||||
}
|
||||
|
||||
/* --- 3. 拆开单独试:才知道是哪一个不行 --- */
|
||||
/* --- 3. 拆开单独试: 才知道是哪一个不行 --- */
|
||||
JsonValue *img = probe_messages(1);
|
||||
ProbeResult vr = probe(cfg, url, img, 0, verbose, &status, errbuf, errlen);
|
||||
json_free(img);
|
||||
@@ -247,7 +247,7 @@ int cap_get(const LlmConfig *cfg, int enable, Capabilities *out,
|
||||
const char *base = (cfg && cfg->base_url) ? cfg->base_url : "";
|
||||
|
||||
if (!enable) {
|
||||
/* 探测被关掉:不下任何结论, 一切都按"支持" */
|
||||
/* 探测被关掉: 不下任何结论, 一切都按"支持" */
|
||||
out->vision = out->tools = CAP_UNKNOWN;
|
||||
out->probed = 0;
|
||||
out->conclusive = 0;
|
||||
|
||||
+7
-7
@@ -1,25 +1,25 @@
|
||||
/* capability.h - 模型能力探测 (vision / tools)
|
||||
*
|
||||
* 为什么需要它:OpenAI 兼容层**没有任何查询能力的接口**. DeepSeek 和 MiMo 的
|
||||
* 为什么需要它: OpenAI 兼容层**没有任何查询能力的接口**. DeepSeek 和 MiMo 的
|
||||
* GET /v1/models 都只返回 id / object / owned_by, 没有 capability / modality
|
||||
* 字段. 所以"这个模型收不收图片"只能靠**发一个试探请求**问出来.
|
||||
*
|
||||
* 这里叫"序列"是因为它不是一次请求, 而是有先后的几步:
|
||||
* 这里叫"序列"是因为它不是一次请求, 而是有先后的几步:
|
||||
*
|
||||
* 1. 基线:一句纯文本 + max_tokens=1
|
||||
* 1. 基线: 一句纯文本 + max_tokens=1
|
||||
* └ 失败 → 什么都别下结论 (可能是 key、网络、模型名的问题)
|
||||
* └ 成功 → 说明模型是活的, 后面的否定结论才有意义
|
||||
* 2. 合并试探:图片 + tools 一起发
|
||||
* 2. 合并试探: 图片 + tools 一起发
|
||||
* └ 200 → 两样都支持 (常见情况, 到此为止, 只花了 2 个请求)
|
||||
* └ 非 200 → 拆开单独试, 才知道是哪一个不行
|
||||
* 3. 单独试探:图片、tools 各发一次
|
||||
* 3. 单独试探: 图片、tools 各发一次
|
||||
*
|
||||
* 关键在于**否定结论只在基线成功时才敢下**:单个试探 400 而基线 200, 说明
|
||||
* 关键在于**否定结论只在基线成功时才敢下**: 单个试探 400 而基线 200, 说明
|
||||
* 差别就在那一样东西上. 这样不需要去猜服务端的错误文案 ("不支持图片"、
|
||||
* "invalid content"、"multimodal not supported"…… 各家写法都不一样),
|
||||
* 也就不会因为文案变化而误判.
|
||||
*
|
||||
* 猜不准的时候一律按"支持"处理:多暴露一个工具, 代价是模型可能白调一次
|
||||
* 猜不准的时候一律按"支持"处理: 多暴露一个工具, 代价是模型可能白调一次
|
||||
* (我们还有 4xx 降级兜底); 而错误地藏掉工具, 用户会以为模型看不见图.
|
||||
*
|
||||
* 结果按 base_url + model 缓存, 所以**换模型会自动重新测**, 切回来则命中缓存.
|
||||
|
||||
+12
-12
@@ -30,7 +30,7 @@ void config_defaults(LigentConfig *c)
|
||||
c->reasoning_effort = NULL; /* 不发送 */
|
||||
c->timeout = 300;
|
||||
c->stream = 1;
|
||||
/* max_steps <= 0 = 不限制单轮往返次数 (默认). 这是**默认**值:模型陷进
|
||||
/* max_steps <= 0 = 不限制单轮往返次数 (默认). 这是**默认**值: 模型陷进
|
||||
* "调工具 → 看结果 → 再调" 的循环时不会再被我们自己掐断, 由用户 Ctrl-C
|
||||
* (或模型自己收尾)来结束. 要恢复上限就设个正数. */
|
||||
c->max_steps = 0;
|
||||
@@ -94,7 +94,7 @@ int config_parse_thinking(const char *s, int *out)
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* 配置文件里 thinking 既可以是布尔也可以是字符串:
|
||||
/* 配置文件里 thinking 既可以是布尔也可以是字符串:
|
||||
* `"thinking": true` 比 `"thinking": "on"` 自然, 两种都收. */
|
||||
static int load_thinking(LigentConfig *c, const JsonValue *v,
|
||||
const char *path, char *errbuf, size_t errlen)
|
||||
@@ -115,7 +115,7 @@ static int load_thinking(LigentConfig *c, const JsonValue *v,
|
||||
|
||||
/* ---- 定位二进制所在目录 ----
|
||||
*
|
||||
* 关键点:路径要"跟着用户看到的那个文件走". 如果 ligent 是个软链接,
|
||||
* 关键点: 路径要"跟着用户看到的那个文件走". 如果 ligent 是个软链接,
|
||||
* 用户是把它当 ./bin/ligent 用的, 配置就该在 ./bin/ 旁边, 而不是被
|
||||
* 悄悄挪到源文件所在的目录去.
|
||||
*
|
||||
@@ -185,7 +185,7 @@ static char *dir_of_canonical(const char *path)
|
||||
|
||||
char *config_binary_dir(const char *argv0)
|
||||
{
|
||||
/* 优先 argv[0]:它反映用户实际调用的那个路径 */
|
||||
/* 优先 argv[0]: 它反映用户实际调用的那个路径 */
|
||||
if (argv0 && *argv0) {
|
||||
if (strchr(argv0, '/')) {
|
||||
if (is_self(argv0)) {
|
||||
@@ -193,7 +193,7 @@ char *config_binary_dir(const char *argv0)
|
||||
if (d) return d;
|
||||
}
|
||||
} else {
|
||||
/* 只是个名字:去 $PATH 里找 (返回的仍是软链接本身) */
|
||||
/* 只是个名字: 去 $PATH 里找 (返回的仍是软链接本身) */
|
||||
char *full = search_path(argv0);
|
||||
if (full) {
|
||||
char *d = dir_of_canonical(full);
|
||||
@@ -203,7 +203,7 @@ char *config_binary_dir(const char *argv0)
|
||||
}
|
||||
}
|
||||
|
||||
/* 兜底:问内核可执行文件在哪. 注意这会解析软链接,
|
||||
/* 兜底: 问内核可执行文件在哪. 注意这会解析软链接,
|
||||
* 只在 argv[0] 给不出答案时使用 */
|
||||
char buf[PATH_MAX];
|
||||
ssize_t n = readlink("/proc/self/exe", buf, sizeof buf - 1);
|
||||
@@ -227,7 +227,7 @@ char *config_default_path(const char *argv0)
|
||||
|
||||
/* ---- 查找要加载的配置文件 ---- */
|
||||
|
||||
/* 只认两个来源:命令行的显式指定, 以及二进制目录下的 ligent.json.
|
||||
/* 只认两个来源: 命令行的显式指定, 以及二进制目录下的 ligent.json.
|
||||
* 不做用户级 / 系统级 / 当前工作目录的查找. */
|
||||
static char *find_config_file(const char *explicit_path, const char *argv0)
|
||||
{
|
||||
@@ -279,7 +279,7 @@ int config_load_file(LigentConfig *c, const char *path,
|
||||
free(expanded);
|
||||
}
|
||||
if ((s = json_get_string(root, "model"))) set_str(&c->model, s);
|
||||
/* 空字符串视为"没写":内置提示词里带着工具使用规范,
|
||||
/* 空字符串视为"没写": 内置提示词里带着工具使用规范,
|
||||
* 静默丢掉会让 agent 行为明显变差; 而且下发一条空的 system 消息
|
||||
* 既浪费又没有意义. */
|
||||
if ((s = json_get_string(root, "system"))) {
|
||||
@@ -351,7 +351,7 @@ void config_apply_env(LigentConfig *c)
|
||||
|
||||
/* 厂商专属的环境变量只在"确实在用这家"时才读.
|
||||
*
|
||||
* 否则会出这种事:配置文件里写的是 OpenRouter 的 key,
|
||||
* 否则会出这种事: 配置文件里写的是 OpenRouter 的 key,
|
||||
* 而 shell 里残留的 DEEPSEEK_API_KEY 把它悄悄顶掉,
|
||||
* 结果 A 家的密钥被发给了 B 家 —— 对方只会回一个含糊的 401.
|
||||
* 用户明明配对了却怎么都调不通, 排查方向还会被带偏. */
|
||||
@@ -359,7 +359,7 @@ void config_apply_env(LigentConfig *c)
|
||||
int use_deepseek = strstr(url, "deepseek") != NULL;
|
||||
int use_openai = strstr(url, "api.openai.com") != NULL;
|
||||
|
||||
/* api_key 优先级:
|
||||
/* api_key 优先级:
|
||||
* LIGENT_API_KEY (显式覆盖) > 配置文件 > 对应厂商的环境变量兜底 */
|
||||
const char *key = getenv("LIGENT_API_KEY");
|
||||
if (key && *key) {
|
||||
@@ -395,7 +395,7 @@ void config_apply_env(LigentConfig *c)
|
||||
c->auto_save = !(strcmp(v, "0") == 0 || strcmp(v, "false") == 0);
|
||||
if ((v = getenv("LIGENT_PROBE")) && *v)
|
||||
c->probe = !(strcmp(v, "0") == 0 || strcmp(v, "false") == 0);
|
||||
/* 环境变量里写错了不报错、直接忽略:环境变量是临时开关,
|
||||
/* 环境变量里写错了不报错、直接忽略: 环境变量是临时开关,
|
||||
* 为它把整个程序拦下来太粗暴. 配置文件和命令行则严格校验. */
|
||||
if ((v = getenv("LIGENT_THINKING")) && *v) {
|
||||
int t;
|
||||
@@ -412,7 +412,7 @@ int config_init(LigentConfig *c, const char *explicit_path, const char *argv0,
|
||||
config_defaults(c);
|
||||
if (errbuf && errlen) errbuf[0] = '\0';
|
||||
|
||||
/* 记下二进制目录:配置和以后任何数据都只放这里 */
|
||||
/* 记下二进制目录: 配置和以后任何数据都只放这里 */
|
||||
free(c->config_dir);
|
||||
c->config_dir = config_binary_dir(argv0);
|
||||
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
/* config.h - 配置:内置默认值 < 配置文件 < 环境变量 < 命令行
|
||||
/* config.h - 配置: 内置默认值 < 配置文件 < 环境变量 < 命令行
|
||||
*
|
||||
* 唯一的配置文件位置是**二进制所在目录**下的 ligent.json:
|
||||
* 唯一的配置文件位置是**二进制所在目录**下的 ligent.json:
|
||||
*
|
||||
* /path/to/ligent ← 可执行文件
|
||||
* /path/to/ligent.json ← 配置 (不存在则用内置默认值)
|
||||
@@ -30,7 +30,7 @@ typedef struct {
|
||||
char *system; /* NULL = 使用内置提示词 */
|
||||
double temperature; /* < 0 = 不发送 */
|
||||
long max_tokens; /* <= 0 = 不发送 */
|
||||
int thinking; /* 思考模式:THINKING_AUTO / _OFF / _ON, 见下 */
|
||||
int thinking; /* 思考模式: THINKING_AUTO / _OFF / _ON, 见下 */
|
||||
char *reasoning_effort; /* 思考级别; NULL/空 = 不发送 */
|
||||
long timeout; /* 秒 */
|
||||
int stream;
|
||||
@@ -46,7 +46,7 @@ typedef struct {
|
||||
|
||||
void config_defaults(LigentConfig *c);
|
||||
|
||||
/* 加载配置:先读配置文件, 再用环境变量覆盖. 返回 0 成功.
|
||||
/* 加载配置: 先读配置文件, 再用环境变量覆盖. 返回 0 成功.
|
||||
*
|
||||
* explicit_path 非 NULL 时只读该文件 (命令行 -c 的显式指定);
|
||||
* 为 NULL 时读 <二进制目录>/ligent.json.
|
||||
@@ -80,7 +80,7 @@ const char *config_thinking_name(int thinking);
|
||||
* 优先 /proc/self/exe, 其次 argv0 里的路径, 最后在 $PATH 里查 */
|
||||
char *config_binary_dir(const char *argv0);
|
||||
|
||||
/* 配置文件的规范位置 (malloc):<二进制目录>/ligent.json */
|
||||
/* 配置文件的规范位置 (malloc): <二进制目录>/ligent.json */
|
||||
char *config_default_path(const char *argv0);
|
||||
|
||||
/* 打印当前生效配置 (api_key 打码) */
|
||||
|
||||
+7
-7
@@ -1,7 +1,7 @@
|
||||
/* confirm.c - 写操作确认 */
|
||||
#include "confirm.h"
|
||||
#include "i18n.h"
|
||||
#include "tools.h" /* TOOL_* 宏:工具名只在那里定义一次 */
|
||||
#include "tools.h" /* TOOL_* 宏: 工具名只在那里定义一次 */
|
||||
#include "util.h"
|
||||
|
||||
#include <stdio.h>
|
||||
@@ -69,7 +69,7 @@ static void describe(const char *name, const JsonValue *args)
|
||||
char lines[96];
|
||||
preview(path, 160, buf, sizeof buf);
|
||||
tool_lines_label(args, lines, sizeof lines);
|
||||
/* 行号必须打出来:用户要确认的第一件事就是"改的是哪几行".
|
||||
/* 行号必须打出来: 用户要确认的第一件事就是"改的是哪几行".
|
||||
* 没给 line 就是全文件替换 (工具不拒绝它), 摘要里直说"全文件". */
|
||||
const char *scope = lines[0] ? lines : t("tool.call.all_lines");
|
||||
if (json_get_bool(args, "replace_all", 0))
|
||||
@@ -77,7 +77,7 @@ static void describe(const char *name, const JsonValue *args)
|
||||
fprintf(stderr, " %s %s\"%s\" [%s]%s\n", t("confirm.lbl.edit"),
|
||||
c(BOLD), buf, scope, c(OFF));
|
||||
/* 然后摆出**带行号**的新旧对照, 再问 Y/n —— 用户就是照着它决定放不放行.
|
||||
* 旧版这里打的是"替换 <旧> -> <新>":两边各截 60 字符、压成一行, 看不出
|
||||
* 旧版这里打的是"替换 <旧> -> <新>": 两边各截 60 字符、压成一行, 看不出
|
||||
* 到底动了几行、动的是哪几行, 长一点的改动更是只剩两个省略号. */
|
||||
tool_render_diff(stderr, args, " ", g_color);
|
||||
} else if (path) {
|
||||
@@ -107,7 +107,7 @@ int confirm_tool(const char *name, const JsonValue *args)
|
||||
{
|
||||
if (!g_enabled) return 1;
|
||||
|
||||
/* 本轮已经表过态就不再打扰. Always/neVer 只作用于这一轮:
|
||||
/* 本轮已经表过态就不再打扰. Always/neVer 只作用于这一轮:
|
||||
* 下一条用户消息进来时 agent_run 会把策略复位成 ASK. */
|
||||
if (g_turn == CONFIRM_ALWAYS) return 1;
|
||||
if (g_turn == CONFIRM_NEVER) return 0;
|
||||
@@ -115,12 +115,12 @@ int confirm_tool(const char *name, const JsonValue *args)
|
||||
* 去回一句"未知工具". strcmp(NULL, ...) 会直接崩. */
|
||||
if (!name) return 1;
|
||||
|
||||
/* 非交互输入:没有人能回答, 按默认值 (Y)放行.
|
||||
/* 非交互输入: 没有人能回答, 按默认值 (Y)放行.
|
||||
* 这也是"默认 Y"的自然推论 —— 问不到就等于不反对. */
|
||||
if (!isatty(STDIN_FILENO)) return 1;
|
||||
|
||||
/* 提权命令会在参数里明说 elevated, describe() 已经把它打出来了 ——
|
||||
* 这里不需要额外的身份标注:没有常驻提权状态, 一次命令一次身份. */
|
||||
* 这里不需要额外的身份标注: 没有常驻提权状态, 一次命令一次身份. */
|
||||
fprintf(stderr, "\n%s </> %s %s%s\n", c(YEL), t("confirm.lbl.about"),
|
||||
name ? name : "?", c(OFF));
|
||||
describe(name, args);
|
||||
@@ -133,7 +133,7 @@ int confirm_tool(const char *name, const JsonValue *args)
|
||||
errno = 0;
|
||||
if (!fgets(line, sizeof line, stdin)) {
|
||||
fputc('\n', stderr);
|
||||
/* 这里必须区分两种"读不到":
|
||||
/* 这里必须区分两种"读不到":
|
||||
*
|
||||
* EINTR 用户按了 Ctrl-C. 他是在说"别问了" —— 必须拒绝.
|
||||
* 真 EOF stdin 关了, 没人能回答 —— 才按默认值放行.
|
||||
|
||||
+3
-3
@@ -14,7 +14,7 @@ void confirm_init(int enabled);
|
||||
void confirm_set_enabled(int on);
|
||||
int confirm_enabled(void);
|
||||
|
||||
/* 询问是否允许执行某个会改动东西的工具:显示将要做什么, 然后问 (Y/n).
|
||||
/* 询问是否允许执行某个会改动东西的工具: 显示将要做什么, 然后问 (Y/n).
|
||||
* 返回 1 允许, 0 拒绝. 回车即默认允许.
|
||||
*
|
||||
* 拒绝时返回 0 而不是让整轮失败 —— 上层会把"用户拒绝了"作为工具结果
|
||||
@@ -37,10 +37,10 @@ int confirm_turn_mode(void);
|
||||
|
||||
int confirm_tool(const char *name, const JsonValue *args);
|
||||
|
||||
/* 可选:自定义"这个工具要做什么"的说明.
|
||||
/* 可选: 自定义"这个工具要做什么"的说明.
|
||||
* 返回 malloc 的文本 (每行自带缩进), 返回 NULL 表示回退到内置描述.
|
||||
*
|
||||
* meta 的工具需要它:那些参数里带整段消息内容, 内置描述既打不出来
|
||||
* meta 的工具需要它: 那些参数里带整段消息内容, 内置描述既打不出来
|
||||
* 也不该打出来 —— 得总结成人话 ("压缩 2..6 轮, 6 条 -> 1 条"). */
|
||||
typedef char *(*ConfirmDescriber)(const char *name, const JsonValue *args);
|
||||
void confirm_set_describer(ConfirmDescriber fn);
|
||||
|
||||
+9
-9
@@ -1,11 +1,11 @@
|
||||
/* doctor.c - 环境体检
|
||||
*
|
||||
* 这是把前几轮踩过的坑固化成检查项:
|
||||
* 这是把前几轮踩过的坑固化成检查项:
|
||||
* 1. 配置文件解析失败却只给一句"语法错误" (现在带行列号)
|
||||
* 2. shell 里残留的厂商环境变量顶掉配置文件里的密钥
|
||||
* 3. base_url 重定向导致 Authorization 头被丢掉, 服务端回 401
|
||||
* 4. 密钥与服务商不匹配 (把 DeepSeek 的 key 发给 OpenRouter)
|
||||
* 这些问题的共同点是:报错信息指向的方向是错的, 靠猜要花很久.
|
||||
* 这些问题的共同点是: 报错信息指向的方向是错的, 靠猜要花很久.
|
||||
*/
|
||||
#include "doctor.h"
|
||||
#include "version.h"
|
||||
@@ -40,11 +40,11 @@ static const char *c(const char *code) { return color_on ? code : ""; }
|
||||
|
||||
static void section(const char *name)
|
||||
{
|
||||
/* 括号也随语言走:英文里写【binary】很别扭 */
|
||||
/* 括号也随语言走: 英文里写【binary】很别扭 */
|
||||
printf("\n%s%s%s\n", c(BOLD), tf("fmt.section", "name", name, NULL), c(OFF));
|
||||
}
|
||||
|
||||
/* 按"显示宽度"补齐:中文一个字占 3 字节但只有 2 列,
|
||||
/* 按"显示宽度"补齐: 中文一个字占 3 字节但只有 2 列,
|
||||
* 用 %-16s 会把中文标签那一列全部顶歪 */
|
||||
static void label(const char *s, size_t width)
|
||||
{
|
||||
@@ -155,7 +155,7 @@ static void check_binary(const char *argv0)
|
||||
{
|
||||
section(t("doctor.sec.binary"));
|
||||
|
||||
/* 版本放最前面:报 bug 时第一件要问的就是"你用的是哪个版本".
|
||||
/* 版本放最前面: 报 bug 时第一件要问的就是"你用的是哪个版本".
|
||||
* 带 git hash 才能在对方改过源码的情况下知道到底是哪份代码. */
|
||||
field(t("doctor.lbl.version"), "%s", ligent_version_full());
|
||||
|
||||
@@ -226,7 +226,7 @@ static void check_config_file(const LigentConfig *cfg, const char *argv0,
|
||||
char *text = read_entire_file(cfg->config_path, &len);
|
||||
if (text) v = json_parse(text, NULL, 0);
|
||||
if (v) {
|
||||
/* 这份清单必须与 config.c 的 config_load_file 认识的键一一对应:
|
||||
/* 这份清单必须与 config.c 的 config_load_file 认识的键一一对应:
|
||||
* 它是"配置文件里到底设了哪些键"的唯一依据, 多写一个不存在的键、
|
||||
* 或者漏掉一个真键, 这一行说的就是假话. (以前这里写的是 "log",
|
||||
* 而配置文件根本没有这个键, 同时真有的 "probe" 没有列出来. ) */
|
||||
@@ -371,7 +371,7 @@ static void check_env(const LigentConfig *cfg)
|
||||
}
|
||||
}
|
||||
|
||||
/* 最坑的一种:厂商变量设了, 但这次用的是配置文件里的密钥 */
|
||||
/* 最坑的一种: 厂商变量设了, 但这次用的是配置文件里的密钥 */
|
||||
const char *ds = getenv("DEEPSEEK_API_KEY");
|
||||
const char *ok = getenv("OPENAI_API_KEY");
|
||||
int used_from_env = cfg->api_key_env != NULL;
|
||||
@@ -435,7 +435,7 @@ static void check_network(const LigentConfig *cfg, int verbose)
|
||||
if (!proxy) proxy = getenv("https_proxy");
|
||||
if (!proxy) proxy = getenv("ALL_PROXY");
|
||||
if (!proxy) proxy = getenv("all_proxy");
|
||||
/* 一定要过 "%s":代理 URL 里带 % 是常事 (密码 %40、路径 %2F),
|
||||
/* 一定要过 "%s": 代理 URL 里带 % 是常事 (密码 %40、路径 %2F),
|
||||
* 直接当格式串会读到不存在的参数. */
|
||||
field(t("doctor.lbl.proxy"), "%s",
|
||||
proxy && *proxy ? proxy : t("doctor.val.proxy_unset"));
|
||||
@@ -483,7 +483,7 @@ static void check_network(const LigentConfig *cfg, int verbose)
|
||||
sub("%s", t("doctor.sub.auth_not_network"));
|
||||
}
|
||||
} else if (status == 404) {
|
||||
/* 404 也不能当成"密钥没问题":请求多半在匹配到路由之前就被回绝了,
|
||||
/* 404 也不能当成"密钥没问题": 请求多半在匹配到路由之前就被回绝了,
|
||||
* 这次探测根本没走到认证那一步. 而且 404 本身通常就是 base_url 写错了. */
|
||||
marked(2, t("doctor.lbl.endpoint"), "%s",
|
||||
tf("doctor.val.endpoint_404", "url", url, NULL));
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
#include "config.h"
|
||||
|
||||
/* 把整条链路逐环检查一遍:二进制定位、配置文件、密钥来源与格式、
|
||||
/* 把整条链路逐环检查一遍: 二进制定位、配置文件、密钥来源与格式、
|
||||
* 端点、环境变量干扰、参数合理性、网络连通性与重定向.
|
||||
*
|
||||
* do_network = 0 时跳过联网检查.
|
||||
|
||||
+14
-14
@@ -1,4 +1,4 @@
|
||||
/* http.c - libcurl 封装:同步 POST、流式 POST、瞬态失败重试退避 */
|
||||
/* http.c - libcurl 封装: 同步 POST、流式 POST、瞬态失败重试退避 */
|
||||
#include "http.h"
|
||||
#include "i18n.h"
|
||||
#include "util.h"
|
||||
@@ -19,7 +19,7 @@ volatile sig_atomic_t g_ligent_interrupt = 0;
|
||||
static char errsink[1];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 收发记录:进程内滚动缓存 */
|
||||
/* 收发记录: 进程内滚动缓存 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* 上限. 一场长会话可能产生很多流量, 不能无限涨;
|
||||
@@ -36,7 +36,7 @@ static void log_append(const void *data, size_t n)
|
||||
if (!g_log_init) { sb_init(&g_log); g_log_init = 1; }
|
||||
|
||||
const char *p = data;
|
||||
if (n > LOG_MAX) { /* 单块就超限:只留尾部 */
|
||||
if (n > LOG_MAX) { /* 单块就超限: 只留尾部 */
|
||||
p += n - LOG_MAX;
|
||||
n = LOG_MAX;
|
||||
g_log_truncated = 1;
|
||||
@@ -97,7 +97,7 @@ void http_log_free(void)
|
||||
int http_log_save(const char *path, size_t *out_bytes,
|
||||
char *errbuf, size_t errlen)
|
||||
{
|
||||
/* 和其它 HTTP 入口一个规矩:没有落点也不能空手写字 */
|
||||
/* 和其它 HTTP 入口一个规矩: 没有落点也不能空手写字 */
|
||||
if (!errbuf || !errlen) { errbuf = errsink; errlen = 1; }
|
||||
if (out_bytes) *out_bytes = 0;
|
||||
FILE *f = fopen(path, "a");
|
||||
@@ -156,7 +156,7 @@ int http_status_retryable(long status)
|
||||
}
|
||||
}
|
||||
|
||||
/* 注意这里刻意不含 CURLE_OPERATION_TIMEDOUT:
|
||||
/* 注意这里刻意不含 CURLE_OPERATION_TIMEDOUT:
|
||||
* 请求超时说明服务端很可能已经处理完了, 重发会重复扣费. */
|
||||
static int curl_code_retryable(CURLcode rc)
|
||||
{
|
||||
@@ -238,7 +238,7 @@ static void ctx_free(HttpCtx *c)
|
||||
}
|
||||
|
||||
/* 必须按 curl 的契约返回 size_t —— 返回值不等于 n 会被当成传输失败.
|
||||
* 以前这里写的 int:(int)n 在 x86-64 上碰巧是对的 (写 eax 会清零 rax 高位),
|
||||
* 以前这里写的 int: (int)n 在 x86-64 上碰巧是对的 (写 eax 会清零 rax 高位),
|
||||
* 所以 gcc 不吭声、测试全绿, 只有 clang 借 libcurl 的类型检查把它指了出来.
|
||||
* 换个 ABI, 高 32 位的垃圾值就会被当成「处理了多少字节」. */
|
||||
static size_t header_cb(char *buffer, size_t size, size_t nitems, void *ud)
|
||||
@@ -298,7 +298,7 @@ static size_t write_cb(char *ptr, size_t size, size_t nmemb, void *ud)
|
||||
return r;
|
||||
}
|
||||
|
||||
/* 非流式:带上限累积, 超了就中止传输 */
|
||||
/* 非流式: 带上限累积, 超了就中止传输 */
|
||||
if (c->body.len + n > HTTP_MAX_BODY) {
|
||||
c->overflow = 1;
|
||||
return 0;
|
||||
@@ -384,7 +384,7 @@ static CURLcode attempt(const char *url, const char *api_key, const char *body,
|
||||
/* 必须**在最前面**清空. 下面 curl_easy_init 失败会直接 return, 而调用方
|
||||
* 随后会拿 cerr 去判断 `*cerr ? cerr : curl_easy_strerror(rc)` —— 原先
|
||||
* 这行在两百行之后、日志区段下面, 于是那条提前返回的路径会把一个未初始化
|
||||
* 的栈数组当字符串读. (gcc -fanalyzer 抓到的, 测试跑不出来:curl_easy_init
|
||||
* 的栈数组当字符串读. (gcc -fanalyzer 抓到的, 测试跑不出来: curl_easy_init
|
||||
* 几乎不会失败. ) */
|
||||
if (cerr && cerrlen) cerr[0] = '\0';
|
||||
|
||||
@@ -393,7 +393,7 @@ static CURLcode attempt(const char *url, const char *api_key, const char *body,
|
||||
|
||||
struct curl_slist *headers = make_headers(api_key);
|
||||
|
||||
/* 长度先算好:POSTFIELDSIZE 要用, 而 body 允许为 NULL (GET 探测). */
|
||||
/* 长度先算好: POSTFIELDSIZE 要用, 而 body 允许为 NULL (GET 探测). */
|
||||
size_t blen = body ? strlen(body) : 0;
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||||
@@ -408,7 +408,7 @@ static CURLcode attempt(const char *url, const char *api_key, const char *body,
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE_LARGE, (curl_off_t)blen);
|
||||
}
|
||||
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
|
||||
/* 刻意不跟随重定向:
|
||||
/* 刻意不跟随重定向:
|
||||
* 1) libcurl 跨源重定向时会主动丢掉 Authorization 头 (安全设计),
|
||||
* 结果是 401 "Missing Authentication header" —— 错误指向完全错的方向,
|
||||
* 用户会去查自己的 key, 而真正的问题是 URL 跳转了;
|
||||
@@ -473,7 +473,7 @@ int http_probe(const char *url, const char *api_key, long timeout_s, int verbose
|
||||
ctx.probe = 1;
|
||||
|
||||
char cerr[CURL_ERROR_SIZE];
|
||||
/* 把密钥带上:探测的目的之一就是验证"这把密钥能不能过",
|
||||
/* 把密钥带上: 探测的目的之一就是验证"这把密钥能不能过",
|
||||
* 不带密钥去问, 401 只说明端点要认证, 说明不了密钥好不好. */
|
||||
CURLcode rc = attempt(url, api_key, NULL, timeout_s, verbose, &ctx,
|
||||
cerr, sizeof cerr);
|
||||
@@ -505,7 +505,7 @@ static int should_retry(const HttpRetry *rt, int attempt, CURLcode rc,
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* 重定向单独处理:这几乎总是 base_url 写错了 */
|
||||
/* 重定向单独处理: 这几乎总是 base_url 写错了 */
|
||||
if (rc == CURLE_OK && ctx->status >= 300 && ctx->status < 400) {
|
||||
snprintf(errbuf, errlen, "%s",
|
||||
tf("http.err.redirect", "status", i18n_num(ctx->status),
|
||||
@@ -568,7 +568,7 @@ static void normalize_retry(const HttpRetry *in, HttpRetry *out)
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* POST 与 GET 只差一个标志位, 重试、退避、错误体处理完全一样 ——
|
||||
* 合成一个实现, 免得两条路各修一遍 bug. */
|
||||
* 合成一个实现, 以免两条路各修一遍 bug. */
|
||||
static int http_request(int is_get, const char *url, const char *api_key,
|
||||
const char *body,
|
||||
long timeout_s, int verbose, const HttpRetry *retry,
|
||||
@@ -696,7 +696,7 @@ int http_post_stream(const char *url, const char *api_key, const char *body,
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* 已经吐过数据就不再重试:重发会把用户已经看到的内容再来一遍 */
|
||||
/* 已经吐过数据就不再重试: 重发会把用户已经看到的内容再来一遍 */
|
||||
if (ctx.delivered > 0) {
|
||||
if (rc != CURLE_OK)
|
||||
snprintf(errbuf, errlen, "%s",
|
||||
|
||||
+6
-6
@@ -1,4 +1,4 @@
|
||||
/* http.h - libcurl 的薄封装:同步 POST、流式 POST、瞬态失败重试 */
|
||||
/* http.h - libcurl 的薄封装: 同步 POST、流式 POST、瞬态失败重试 */
|
||||
#ifndef LIGENT_HTTP_H
|
||||
#define LIGENT_HTTP_H
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
/* 全局中断标志 (Ctrl-C 置位, 由 main 安装信号处理器) */
|
||||
extern volatile sig_atomic_t g_ligent_interrupt;
|
||||
|
||||
/* libcurl 流式写回调, 语义同 libcurl CURLOPT_WRITEFUNCTION:
|
||||
/* libcurl 流式写回调, 语义同 libcurl CURLOPT_WRITEFUNCTION:
|
||||
* 返回已处理的字节数; 返回不等于 nmemb*size 的值即中止传输 */
|
||||
typedef size_t (*HttpSink)(char *data, size_t size, size_t nmemb, void *userdata);
|
||||
|
||||
/* 重试策略. 只重试"服务端明确报错"或"连接根本没建立"的情况:
|
||||
/* 重试策略. 只重试"服务端明确报错"或"连接根本没建立"的情况:
|
||||
* 超时 (OPERATION_TIMEDOUT)不重试 —— 服务端可能已经处理完并计费了,
|
||||
* 重发会造成重复扣费. */
|
||||
typedef struct {
|
||||
@@ -54,7 +54,7 @@ int http_status_retryable(long status);
|
||||
|
||||
/* 把 data 里出现的 secret 全部替换成 <已隐藏> 后写入 out.
|
||||
* 用"按密钥值替换"而不是"匹配 Authorization 头", 是因为 libcurl 有多条
|
||||
* 输出通道都会带出密钥:除了 HEADER_OUT 那段, HTTP/2 还会在 TEXT 消息里
|
||||
* 输出通道都会带出密钥: 除了 HEADER_OUT 那段, HTTP/2 还会在 TEXT 消息里
|
||||
* 打一行 "[HTTP/2] [1] [authorization: Bearer sk-...]". */
|
||||
void http_write_redacted(FILE *out, const char *data, size_t size,
|
||||
const char *secret);
|
||||
@@ -75,7 +75,7 @@ int http_get(const char *url, const char *api_key,
|
||||
char **out_body, long *out_status, int *out_attempts,
|
||||
char *errbuf, size_t errlen);
|
||||
|
||||
/* 探测:发一个 GET (不跟随重定向、不带请求体), 用于 doctor 检查
|
||||
/* 探测: 发一个 GET (不跟随重定向、不带请求体), 用于 doctor 检查
|
||||
* 连通性、TLS 与是否发生重定向.
|
||||
* out_location 为 3xx 时的 Location (malloc, 可为 NULL). */
|
||||
/* api_key 为 NULL 表示不带认证发出去. doctor 要拿它区分"端点要求认证但你没配
|
||||
@@ -86,7 +86,7 @@ int http_probe(const char *url, const char *api_key, long timeout_s, int verbose
|
||||
char *errbuf, size_t errlen);
|
||||
|
||||
/* 流式 POST. 2xx 时响应体逐块交给 sink; 非 2xx 时响应体被收集到 out_errbody.
|
||||
* 注意:一旦有数据交给了 sink (见 out_delivered), 就不再重试 ——
|
||||
* 注意: 一旦有数据交给了 sink (见 out_delivered), 就不再重试 ——
|
||||
* 否则会把已经显示过的内容重复输出一遍. */
|
||||
int http_post_stream(const char *url, const char *api_key, const char *body,
|
||||
long timeout_s, int verbose, const HttpRetry *retry,
|
||||
|
||||
+99
-99
@@ -1,7 +1,7 @@
|
||||
/* i18n.c - 界面文案表与查找
|
||||
*
|
||||
* 表里每条两种语言各一份. 条目按 key 排序 (不需要, 但好查).
|
||||
* 测试会遍历这张表:两种语言都不许空, 占位符集合必须一致.
|
||||
* 测试会遍历这张表: 两种语言都不许空, 占位符集合必须一致.
|
||||
*/
|
||||
#include "i18n.h"
|
||||
|
||||
@@ -74,12 +74,12 @@ M("cli.help",
|
||||
|
||||
"ligent - 纯 C 实现的 AI Agent (OpenAI 兼容接口)\n"
|
||||
"\n"
|
||||
"用法:\n"
|
||||
"用法: \n"
|
||||
" ligent [选项] [任务描述...] 带任务描述时一次性执行后退出\n"
|
||||
" ligent [选项] 进入交互式 REPL\n"
|
||||
" echo \"任务\" | ligent 从标准输入读取任务\n"
|
||||
"\n"
|
||||
"选项:\n"
|
||||
"选项: \n"
|
||||
" -m, --model NAME 模型名 (默认 deepseek-flash)\n"
|
||||
" -u, --base-url URL API 基地址 (默认 https://api.deepseek.com/v1)\n"
|
||||
" -k, --api-key KEY API key (默认读 $DEEPSEEK_API_KEY)\n"
|
||||
@@ -89,8 +89,8 @@ M("cli.help",
|
||||
" -s, --system TEXT 覆盖 system 提示词\n"
|
||||
" -t, --temperature N 采样温度\n"
|
||||
" --max-tokens N 单次回复的最大 token 数\n"
|
||||
" --thinking MODE 思考模式:on / off / auto (默认 auto = 由服务端决定)\n"
|
||||
" --effort LEVEL 思考级别:minimal/low/medium/high/xhigh/max/ultra\n"
|
||||
" --thinking MODE 思考模式: on / off / auto (默认 auto = 由服务端决定)\n"
|
||||
" --effort LEVEL 思考级别: minimal/low/medium/high/xhigh/max/ultra\n"
|
||||
" --max-steps N 单轮任务里模型最多往返几次 (默认不限, 0 = 不限)\n"
|
||||
" --timeout N 单次请求超时秒数 (默认 300)\n"
|
||||
" --retries N 瞬态失败 (429/5xx/连接失败)的重试次数 (默认 2, 0 = 不重试)\n"
|
||||
@@ -99,7 +99,7 @@ M("cli.help",
|
||||
" --no-confirm 不再询问写操作与命令执行 (默认会问)\n"
|
||||
" --no-auto-save 退出/重置/导入前不自动存档 (默认会存)\n"
|
||||
" --no-probe 首个请求前不探测模型能力 (不额外发请求)\n"
|
||||
" --lang LANG 界面语言:auto / en / zh (默认 auto)\n"
|
||||
" --lang LANG 界面语言: auto / en / zh (默认 auto)\n"
|
||||
" -q, --quiet 不打印工具的输出内容\n"
|
||||
" -v, --verbose 打印 HTTP 细节与调试信息\n"
|
||||
" --color / --no-color 强制开关彩色输出\n"
|
||||
@@ -107,24 +107,24 @@ M("cli.help",
|
||||
" -h, --help 显示本帮助\n"
|
||||
" -V, --version 显示版本\n"
|
||||
"\n"
|
||||
"配置文件 (JSON):\n"
|
||||
"配置文件 (JSON): \n"
|
||||
" 只读「二进制所在目录」下的 ligent.json, 例如 /usr/local/bin/ligent 对应\n"
|
||||
" /usr/local/bin/ligent.json. 不存在则使用内置默认值. \n"
|
||||
" 不做用户级 (~/.config)和系统级 (/etc)查找, 也不看当前工作目录. \n"
|
||||
" 临时换一份可用 -c PATH 或 $LIGENT_CONFIG. \n"
|
||||
"\n"
|
||||
"配置项:base_url, api_key, model, system, temperature, max_tokens,\n"
|
||||
"配置项: base_url, api_key, model, system, temperature, max_tokens,\n"
|
||||
" thinking, reasoning_effort,\n"
|
||||
" timeout, stream, max_steps, tools, retries,\n"
|
||||
" confirm, auto_save, probe\n"
|
||||
"\n"
|
||||
"REPL 命令:/help /exit /reset /cls /meta /model /system /tools /probe\n"
|
||||
"REPL 命令: /help /exit /reset /cls /meta /model /system /tools /probe\n"
|
||||
" /think /config /cd /history /log /export /import /shell /usage\n"
|
||||
" /include\n"
|
||||
" /back /unback /yolo\n"
|
||||
"\n"
|
||||
"子命令:\n"
|
||||
" ligent doctor [--offline] 体检:配置、密钥、端点、网络逐环检查\n"),
|
||||
"子命令: \n"
|
||||
" ligent doctor [--offline] 体检: 配置、密钥、端点、网络逐环检查\n"),
|
||||
|
||||
M("cli.err.bad_effort",
|
||||
"ligent: --effort accepts only {list} (got \"{value}\")\n",
|
||||
@@ -146,10 +146,10 @@ M("cli.err.no_api_key",
|
||||
" with something like {\"api_key\": \"${DEEPSEEK_API_KEY}\", \"model\": \"deepseek-flash\"}\n"
|
||||
" 3) ligent --api-key sk-xxx \"hi\"\n",
|
||||
"ligent: 未配置 API key. \n"
|
||||
" 方式一:export DEEPSEEK_API_KEY=sk-xxx\n"
|
||||
" 方式二:在二进制所在目录建一个 ligent.json (见 --print-config 显示的路径), \n"
|
||||
" 方式一: export DEEPSEEK_API_KEY=sk-xxx\n"
|
||||
" 方式二: 在二进制所在目录建一个 ligent.json (见 --print-config 显示的路径), \n"
|
||||
" 内容形如 {\"api_key\": \"${DEEPSEEK_API_KEY}\", \"model\": \"deepseek-flash\"}\n"
|
||||
" 方式三:ligent --api-key sk-xxx \"你好\"\n"),
|
||||
" 方式三: ligent --api-key sk-xxx \"你好\"\n"),
|
||||
M("cli.err.unknown_option",
|
||||
"ligent: unknown option {option} (try --help)\n",
|
||||
"ligent: 未知选项 {option} (--help 查看用法)\n"),
|
||||
@@ -189,12 +189,12 @@ M("cfg.thinking.off", "off (sends thinking.type=disabled)",
|
||||
"关闭 (发 thinking.type=disabled)"),
|
||||
M("cfg.thinking.on", "on (sends thinking.type=enabled)",
|
||||
"开启 (发 thinking.type=enabled)"),
|
||||
M("cfg.err.bad_json", "{path}: {error}", "{path}:{error}"),
|
||||
M("cfg.err.bad_json", "{path}: {error}", "{path}: {error}"),
|
||||
M("cfg.err.bad_effort",
|
||||
"{path}: reasoning_effort accepts only {list} (or leave it out)",
|
||||
"{path}:reasoning_effort 只能是 {list} (或留空不发)"),
|
||||
"{path}: reasoning_effort 只能是 {list} (或留空不发)"),
|
||||
M("cfg.err.not_object", "{path}: the top level must be a JSON object ({...})",
|
||||
"{path}:顶层必须是 JSON 对象 ({...})"),
|
||||
"{path}: 顶层必须是 JSON 对象 ({...})"),
|
||||
M("cfg.err.parse_ignored",
|
||||
"ligent: config file could not be parsed; using built-in defaults instead\n"
|
||||
" {error}",
|
||||
@@ -204,7 +204,7 @@ M("cfg.err.read_failed", "cannot read config file {path}",
|
||||
"无法读取配置文件 {path}"),
|
||||
M("cfg.err.thinking_type",
|
||||
"{path}: thinking accepts only true/false or \"on\"/\"off\"/\"auto\"",
|
||||
"{path}:thinking 只能是 true/false, 或 \"on\"/\"off\"/\"auto\""),
|
||||
"{path}: thinking 只能是 true/false, 或 \"on\"/\"off\"/\"auto\""),
|
||||
|
||||
/* ---- 写操作确认 ---- */
|
||||
M("confirm.always", "no more prompts for the rest of this turn.",
|
||||
@@ -225,15 +225,15 @@ M("confirm.prompt", " Continue? (Y/N/Always/neVer) ",
|
||||
" 继续?(Y/N/Always/neVer) "),
|
||||
M("confirm.val.empty", "(empty)", "(空)"),
|
||||
|
||||
/* ---- doctor:体检 ---- */
|
||||
/* 分节标题的括号:中文用【】, 英文用 [] */
|
||||
/* ---- doctor: 体检 ---- */
|
||||
/* 分节标题的括号: 中文用【】, 英文用 [] */
|
||||
M("fmt.section", "[{name}]", "【{name}】"),
|
||||
M("doctor.conclusion.bad", "Bottom line: {problems} problem(s), {warnings} warning(s).",
|
||||
"结论:{problems} 个问题, {warnings} 处提醒. "),
|
||||
"结论: {problems} 个问题, {warnings} 处提醒. "),
|
||||
M("doctor.conclusion.next", "start with the items marked [x].",
|
||||
"先处理标 [x] 的项. "),
|
||||
M("doctor.conclusion.ok", "Bottom line: no problems found.",
|
||||
"结论:没发现问题. "),
|
||||
"结论: 没发现问题. "),
|
||||
M("doctor.env.note.all_proxy", "proxy for all protocols", "通用代理"),
|
||||
M("doctor.env.note.api_key", "explicit override, highest priority",
|
||||
"显式覆盖, 优先级最高"),
|
||||
@@ -289,22 +289,22 @@ M("doctor.sub.404_base_url",
|
||||
"多半是 base_url 写错了 (比如少了 /v1); 先照下面「重定向」那节的方法核对"),
|
||||
M("doctor.sub.404_no_key_info",
|
||||
"this probe says nothing about the key: the request was refused before auth",
|
||||
"这次探测说明不了密钥好不好:请求没走到认证就被回绝了"),
|
||||
"这次探测说明不了密钥好不好: 请求没走到认证就被回绝了"),
|
||||
M("doctor.sub.all_ignored",
|
||||
"the whole file was ignored; this run uses built-in defaults",
|
||||
"整个文件被忽略, 本次运行全部使用内置默认值. "),
|
||||
M("doctor.sub.auth_not_network",
|
||||
"this is not a network problem: it connects, there is just no key",
|
||||
"这不是网络问题:连得上, 只是没带钥匙"),
|
||||
"这不是网络问题: 连得上, 只是没带钥匙"),
|
||||
M("doctor.sub.auth_redirect",
|
||||
"also check whether base_url was redirected: see the redirect section below",
|
||||
"也确认一下 base_url 是不是被跳转过:详见下面「重定向」"),
|
||||
"也确认一下 base_url 是不是被跳转过: 详见下面「重定向」"),
|
||||
M("doctor.sub.max_tokens_huge",
|
||||
"</> far above the usual output cap (mostly <= 65536); may be rejected or truncated",
|
||||
"</> 远超常见模型的输出上限 (多数 <= 65536), 可能被拒或被截断"),
|
||||
M("doctor.sub.redirect_1",
|
||||
"ligent does not follow redirects: a cross-origin hop drops the Authorization header,",
|
||||
"ligent 不自动跟随重定向:跨源跳转会丢掉 Authorization 头, "),
|
||||
"ligent 不自动跟随重定向: 跨源跳转会丢掉 Authorization 头, "),
|
||||
M("doctor.sub.redirect_2",
|
||||
"the server then replies 401 \"Missing Authentication header\", which looks like a missing key,",
|
||||
"服务端只会回 401 \"Missing Authentication header\", 看起来像是密钥没带, "),
|
||||
@@ -330,29 +330,29 @@ M("doctor.val.bad_scheme", "URL must start with http:// or https://",
|
||||
"URL 必须以 http:// 或 https:// 开头"),
|
||||
M("doctor.val.base_url_unset", "(not set)", "(未设置)"),
|
||||
M("doctor.val.chars", "({n} chars)", " ({n} 字符)"),
|
||||
M("doctor.val.connect_failed", "failed: {error}", "失败:{error}"),
|
||||
M("doctor.val.connect_failed", "failed: {error}", "失败: {error}"),
|
||||
M("doctor.val.connect_ok", "reachable, TLS verified (for HTTPS)",
|
||||
"可连接, TLS 校验通过 (HTTPS 时)"),
|
||||
M("doctor.val.empty", "(empty)", "(空)"),
|
||||
M("doctor.val.endpoint_404", "HTTP 404 — no such address: {url}",
|
||||
"HTTP 404 —— 这个地址不存在:{url}"),
|
||||
"HTTP 404 —— 这个地址不存在: {url}"),
|
||||
M("doctor.val.env_ignored",
|
||||
"{var} is set but unused this run (the key came from {source}). "
|
||||
"If that is not what you want, unset it or use LIGENT_API_KEY",
|
||||
"{var} 已设置, 但本次没用上 (密钥来自「{source}」). "
|
||||
"如果这不是你想要的, unset 它或改用 LIGENT_API_KEY"),
|
||||
M("doctor.val.env_set", "set: {value}", "已设置:{value}"),
|
||||
M("doctor.val.env_set", "set: {value}", "已设置: {value}"),
|
||||
M("doctor.val.env_unset", "not set", "未设置"),
|
||||
M("doctor.val.exists_unloadable",
|
||||
"present but not loaded (the top level must be a JSON object)",
|
||||
"存在但加载失败 (顶层必须是 JSON 对象)"),
|
||||
M("doctor.val.exists_unparsable", "present but unparsable: {error}",
|
||||
"存在但无法解析:{error}"),
|
||||
"存在但无法解析: {error}"),
|
||||
M("doctor.val.how_to_set_key",
|
||||
"export DEEPSEEK_API_KEY=... or -k KEY or api_key in the config file",
|
||||
"export DEEPSEEK_API_KEY=... 或 -k KEY 或配置文件 api_key"),
|
||||
M("doctor.val.https", "HTTPS", "HTTPS"),
|
||||
M("doctor.val.load_failed", "load failed: {error}", "加载失败:{error}"),
|
||||
M("doctor.val.load_failed", "load failed: {error}", "加载失败: {error}"),
|
||||
M("doctor.val.location_missing", "(the server sent no Location)",
|
||||
"(服务端未提供 Location)"),
|
||||
M("doctor.val.loaded", "loaded", "已加载"),
|
||||
@@ -401,10 +401,10 @@ M("doctor.val.unknown", "(unknown)", "(未知)"),
|
||||
|
||||
/* ---- 其它 UI ---- */
|
||||
M("main.auto_saved", "auto-archived ({why}){temp}: {path}",
|
||||
"已自动存档 ({why}){temp}:{path}"),
|
||||
"已自动存档 ({why}){temp}: {path}"),
|
||||
M("main.auto_saved.temp", " [temp]", "〔临时〕"),
|
||||
M("main.err.auto_save_failed", "ligent: auto-archive failed: {error}\n",
|
||||
"ligent: 自动存档失败:{error}\n"),
|
||||
"ligent: 自动存档失败: {error}\n"),
|
||||
M("repl.banner.hint", "Type a task to start; /help lists commands, Ctrl-D exits.",
|
||||
"输入任务开始对话, /help 查看命令, Ctrl-D 退出. "),
|
||||
M("repl.bye", "Bye.", "再见. "),
|
||||
@@ -419,16 +419,16 @@ M("repl.cancelled",
|
||||
"已取消. "),
|
||||
M("repl.colon",
|
||||
":",
|
||||
":"),
|
||||
": "),
|
||||
M("repl.export.done",
|
||||
"exported {n} messages: {lineage}{arrow}{name}\n",
|
||||
"已导出 {n} 条消息:{lineage}{arrow}{name}\n"),
|
||||
"已导出 {n} 条消息: {lineage}{arrow}{name}\n"),
|
||||
M("repl.export.empty",
|
||||
"this conversation is still empty, nothing to export.",
|
||||
"当前对话还没有内容, 无需导出. "),
|
||||
M("repl.export.failed",
|
||||
"export failed: {error}\n",
|
||||
"导出失败:{error}\n"),
|
||||
"导出失败: {error}\n"),
|
||||
M("repl.export.name_limit",
|
||||
"(name hit the length cap; the lineage starts a new root here)",
|
||||
" (名字已达长度上限, 谱系在这里重新起头)"),
|
||||
@@ -440,7 +440,7 @@ M("repl.include.cleared",
|
||||
"已清空待附带的文件. "),
|
||||
M("repl.include.failed",
|
||||
"attaching failed: {error}\n",
|
||||
"附带失败:{error}\n"),
|
||||
"附带失败: {error}\n"),
|
||||
M("repl.include.next_hint",
|
||||
"they go out with your next message. /include clear cancels.",
|
||||
"下一条消息会带上它们. 用 /include clear 取消. "),
|
||||
@@ -461,13 +461,13 @@ M("repl.model.fetching",
|
||||
"正在获取模型列表 ({url}/models)...\n"),
|
||||
M("repl.model.list_failed",
|
||||
"could not fetch the model list: {error}\n",
|
||||
"拿不到模型列表:{error}\n"),
|
||||
"拿不到模型列表: {error}\n"),
|
||||
M("repl.model.list_failed_hint",
|
||||
"you can still name one directly: /model NAME (the list is not required).",
|
||||
"可以直接用 /model <名字> 指定, 列表拿不到不影响这条. "),
|
||||
M("repl.model.list_header",
|
||||
"\nthe server lists {n} models:\n\n",
|
||||
"\n服务端列出了 {n} 个模型:\n\n"),
|
||||
"\n服务端列出了 {n} 个模型: \n\n"),
|
||||
M("repl.model.pick_prompt",
|
||||
"\nselect a number, or type a model name (Enter cancels) > ",
|
||||
"\n选择序号, 或直接输入模型名 (回车取消)> "),
|
||||
@@ -476,7 +476,7 @@ M("repl.model.row",
|
||||
" %2zu) %s%s\n"),
|
||||
M("repl.model.same",
|
||||
"model unchanged: {model}\n",
|
||||
"模型未变:{model}\n"),
|
||||
"模型未变: {model}\n"),
|
||||
M("repl.model.switched",
|
||||
"switched to model {model}\n",
|
||||
"模型已切换为 {model}\n"),
|
||||
@@ -512,22 +512,22 @@ M("repl.back.none",
|
||||
"没有可以回退的对话轮次. "),
|
||||
M("repl.cd.current",
|
||||
"current directory: {dir}\n",
|
||||
"当前目录:{dir}\n"),
|
||||
"当前目录: {dir}\n"),
|
||||
M("repl.cd_failed",
|
||||
"cd failed",
|
||||
"cd 失败"),
|
||||
M("repl.help",
|
||||
"/help show this help\n/exit, /quit exit\n/reset clear the conversation (keeps the system prompt)\n/cls clear the screen (history untouched)\n/meta meta mode: edit the conversation with the model's help\n/model [NAME] switch model; with no name, pick from the online list\n/include [GLOB] attach files to the model; no argument = pick interactively, clear cancels\n/system TEXT set a new system prompt and clear the history\n/tools list available tools\n/probe probe model capabilities now (vision/tools); ignores --no-probe and the cache\n/think show thinking mode and effort\n/think on|off|auto switch thinking mode (auto = server decides)\n/think LEVEL set reasoning effort (minimal/low/medium/high/xhigh/max/ultra)\n/config show the effective config\n/cd PATH change working directory\n/history print the whole conversation (every message, tools included)\n/usage show token usage\n/shell [CMD] open an interactive shell, or run a single command\n/back roll back the most recent round\n/unback undo the last /back\n/yolo toggle write confirmation for this session\n/log [FILE] export the wire log (no argument = show size, /log clear = drop it)\n/export save this conversation under ligent.archive/\n/import load one of the archives (asks before printing the history)\n",
|
||||
"/help 显示本帮助\n/exit, /quit 退出\n/reset 清空对话历史 (保留 system 提示词)\n/cls 清屏 (只动屏幕, 不动对话历史)\n/meta 进入 meta 模式:用模型辅助编辑对话历史\n/model [NAME] 切换模型; 不带名字则拉取在线列表交互式选择\n/include [GLOB] 把文件附带给模型; 不带参数则交互式挑选, clear 取消\n/system TEXT 重新设置 system 提示词, 并清空历史\n/tools 列出可用工具\n/probe 现在测一次模型能力 (图片/工具); 无视 --no-probe 与缓存\n/think 查看思考模式与级别\n/think on|off|auto 切换思考模式 (auto = 由服务端决定)\n/think LEVEL 设置思考级别 (minimal/low/medium/high/xhigh/max/ultra)\n/config 显示当前生效配置\n/cd PATH 切换工作目录\n/history 把当前对话完整打印出来 (每条消息, 含工具调用与结果)\n/usage 显示 token 用量\n/shell [命令] 进入交互式 shell, 或直接执行一条命令\n/back 回退最近一轮对话\n/unback 撤销刚才的 /back\n/yolo 临时开关写操作确认\n/log [FILE] 导出收发记录 (无参数看大小, /log clear 清空)\n/export 把当前对话存到 ligent.archive/\n/import 从存档里选一份载入 (默认会问要不要打印历史)\n"),
|
||||
"/help 显示本帮助\n/exit, /quit 退出\n/reset 清空对话历史 (保留 system 提示词)\n/cls 清屏 (只动屏幕, 不动对话历史)\n/meta 进入 meta 模式: 用模型辅助编辑对话历史\n/model [NAME] 切换模型; 不带名字则拉取在线列表交互式选择\n/include [GLOB] 把文件附带给模型; 不带参数则交互式挑选, clear 取消\n/system TEXT 重新设置 system 提示词, 并清空历史\n/tools 列出可用工具\n/probe 现在测一次模型能力 (图片/工具); 无视 --no-probe 与缓存\n/think 查看思考模式与级别\n/think on|off|auto 切换思考模式 (auto = 由服务端决定)\n/think LEVEL 设置思考级别 (minimal/low/medium/high/xhigh/max/ultra)\n/config 显示当前生效配置\n/cd PATH 切换工作目录\n/history 把当前对话完整打印出来 (每条消息, 含工具调用与结果)\n/usage 显示 token 用量\n/shell [命令] 进入交互式 shell, 或直接执行一条命令\n/back 回退最近一轮对话\n/unback 撤销刚才的 /back\n/yolo 临时开关写操作确认\n/log [FILE] 导出收发记录 (无参数看大小, /log clear 清空)\n/export 把当前对话存到 ligent.archive/\n/import 从存档里选一份载入 (默认会问要不要打印历史)\n"),
|
||||
M("repl.import.done",
|
||||
"loaded {name} ({n} messages). Future /export will save as {name}_xxx\n",
|
||||
"已载入 {name} ({n} 条消息). 之后 /export 会存成 {name}_xxx\n"),
|
||||
M("repl.import.list_header",
|
||||
"\n{dir} holds {n} archives:\n\n",
|
||||
"\n{dir} 里有 {n} 份存档:\n\n"),
|
||||
"\n{dir} 里有 {n} 份存档: \n\n"),
|
||||
M("repl.import.load_failed",
|
||||
"loading failed: {error}\n",
|
||||
"载入失败:{error}\n"),
|
||||
"载入失败: {error}\n"),
|
||||
M("repl.import.none",
|
||||
"no archives yet ({dir} is empty). Save one with /export first.\n",
|
||||
"还没有任何存档 ({dir} 是空的). 先用 /export 存一份. \n"),
|
||||
@@ -560,22 +560,22 @@ M("repl.system.builtin",
|
||||
"(内置默认)"),
|
||||
M("repl.system.current",
|
||||
"current system prompt:",
|
||||
"当前 system 提示词:"),
|
||||
"当前 system 提示词: "),
|
||||
M("repl.system.updated",
|
||||
"system prompt updated and history cleared.",
|
||||
"system 提示词已更新, 历史已清空. "),
|
||||
M("repl.think.bad",
|
||||
"not recognised: \"{value}\".\n mode: on / off / auto\n effort: {list}\n",
|
||||
"无法识别 \"{value}\". \n 开关:on / off / auto\n 级别:{list}\n"),
|
||||
"无法识别 \"{value}\". \n 开关: on / off / auto\n 级别: {list}\n"),
|
||||
M("repl.think.effort",
|
||||
"reasoning effort: %s\n",
|
||||
"思考级别:%s\n"),
|
||||
"思考级别: %s\n"),
|
||||
M("repl.think.effort_set",
|
||||
"reasoning effort set to {level} (this session only).\n",
|
||||
"思考级别已设为 {level} (只影响本次会话). \n"),
|
||||
M("repl.think.mode",
|
||||
"thinking mode: %s\n",
|
||||
"思考模式:%s\n"),
|
||||
"思考模式: %s\n"),
|
||||
M("repl.tools.disabled",
|
||||
"tools are disabled (--no-tools).",
|
||||
"工具已禁用 (--no-tools). "),
|
||||
@@ -638,7 +638,7 @@ M("repl.history.count",
|
||||
"历史消息 {n} 条 (不含 system 提示词). \n"),
|
||||
M("repl.history.full",
|
||||
"printing all {total} messages below (system prompt included):\n",
|
||||
"下面完整打印全部 {total} 条消息 (含 system 提示词):\n"),
|
||||
"下面完整打印全部 {total} 条消息 (含 system 提示词): \n"),
|
||||
M("repl.log.cleared",
|
||||
"wire log cleared.",
|
||||
"收发记录已清空. "),
|
||||
@@ -647,7 +647,7 @@ M("repl.log.empty",
|
||||
"还没有任何收发记录, {file} 未写入内容. \n"),
|
||||
M("repl.log.save_failed",
|
||||
"export failed: {error}\n",
|
||||
"导出失败:{error}\n"),
|
||||
"导出失败: {error}\n"),
|
||||
M("repl.log.saved",
|
||||
"appended {n} bytes of wire log to {file}\n",
|
||||
"已把 {n} 字节收发记录追加写入 {file}\n"),
|
||||
@@ -671,21 +671,21 @@ M("repl.shell.recorded",
|
||||
"已把 {n} 字节会话记录加入上下文 (不自动提问). \n"),
|
||||
M("repl.shell.start_failed",
|
||||
"could not start the shell: {error}\n",
|
||||
"启动 shell 失败:{error}\n"),
|
||||
"启动 shell 失败: {error}\n"),
|
||||
M("repl.turn_failed",
|
||||
"this turn failed; the history is kept. Keep asking, or /reset to clear it.\n",
|
||||
"本轮失败, 历史保留, 可继续提问或 /reset 清空. \n"),
|
||||
M("repl.unknown_command",
|
||||
"unknown command: {cmd} (/help lists them)\n",
|
||||
"未知命令:{cmd} (/help 查看帮助)\n"),
|
||||
M("agent.call.elevated", "as root: {cmd}", "以 root:{cmd}"),
|
||||
"未知命令: {cmd} (/help 查看帮助)\n"),
|
||||
M("agent.call.elevated", "as root: {cmd}", "以 root: {cmd}"),
|
||||
M("agent.call.write", "\"{path}\" ({n} bytes)", "\"{path}\" ({n} 字节)"),
|
||||
M("agent.caps.baseline_failed",
|
||||
"the baseline request did not go through",
|
||||
"基线请求未通过"),
|
||||
M("agent.caps.line",
|
||||
"model capabilities: multimodal (image input) {vision} * tool calls {tools}{note}",
|
||||
"模型能力:多模态 (图片输入){vision} * 工具调用 {tools}{note}"),
|
||||
"模型能力: 多模态 (图片输入){vision} * 工具调用 {tools}{note}"),
|
||||
M("agent.caps.no.tools",
|
||||
"no (tool table no longer sent)",
|
||||
"不支持 (不再发送工具表)"),
|
||||
@@ -706,7 +706,7 @@ M("agent.empty_reply",
|
||||
"(模型没有返回任何内容)"),
|
||||
M("agent.error",
|
||||
"error: {error}\n",
|
||||
"错误:{error}\n"),
|
||||
"错误: {error}\n"),
|
||||
M("agent.image.degraded",
|
||||
"{n} image reference(s) could not be expanded (local backup missing or over the size cap); they were downgraded to a text note in place, no retry.",
|
||||
"有 {n} 处图片引用展开不出来 (本机备份缺失或体积超限), 已就地降级成文字说明, 不再重试. "),
|
||||
@@ -715,7 +715,7 @@ M("agent.img.rejected",
|
||||
"服务端拒绝了请求里的图片, 已把 {n} 处图片从对话里移除后重试...\n"),
|
||||
M("agent.img.server_said",
|
||||
"the server said: {error}",
|
||||
"服务端原话:{error}"),
|
||||
"服务端原话: {error}"),
|
||||
M("agent.interrupted",
|
||||
"[interrupted]",
|
||||
"[已中断]"),
|
||||
@@ -730,7 +730,7 @@ M("agent.output.empty",
|
||||
"(无输出)"),
|
||||
M("agent.stream.broken",
|
||||
"</> transfer interrupted; the partial content above is all that arrived: {why}",
|
||||
"</> 传输中断, 以上是已收到的部分内容:{why}"),
|
||||
"</> 传输中断, 以上是已收到的部分内容: {why}"),
|
||||
M("agent.structure.fixed",
|
||||
"the conversation structure was broken (an assistant tool_calls message was not paired with its tool replies); repaired {n} place(s) — without this every later round would be rejected by the server.",
|
||||
"对话结构有问题 (assistant 的 tool_calls 与 tool 消息对不上), 已自动修复 {n} 处 —— 不修的话之后每一轮都会被服务端拒绝. "),
|
||||
@@ -748,10 +748,10 @@ M("attach.err.binary",
|
||||
"看起来是二进制文件"),
|
||||
M("attach.err.glob_failed",
|
||||
"glob failed: {pattern}",
|
||||
"展开失败:{pattern}"),
|
||||
"展开失败: {pattern}"),
|
||||
M("attach.err.no_match",
|
||||
"no files matched: {pattern}",
|
||||
"没有匹配到文件:{pattern}"),
|
||||
"没有匹配到文件: {pattern}"),
|
||||
M("attach.err.no_pattern",
|
||||
"no pattern given",
|
||||
"没有给出模式"),
|
||||
@@ -775,7 +775,7 @@ M("attach.err.unreadable",
|
||||
"读不到"),
|
||||
M("attach.glob.skipped",
|
||||
" skipped {file}: {why}",
|
||||
" 跳过 {file}:{why}"),
|
||||
" 跳过 {file}: {why}"),
|
||||
M("attach.image_mark",
|
||||
" [image]",
|
||||
" 〔图片〕"),
|
||||
@@ -784,7 +784,7 @@ M("attach.pick.cancelled",
|
||||
"已取消选择. "),
|
||||
M("attach.pick.dir",
|
||||
"\ncurrent directory: {dir}\n\n",
|
||||
"\n当前目录:{dir}\n\n"),
|
||||
"\n当前目录: {dir}\n\n"),
|
||||
M("attach.pick.empty_dir",
|
||||
"(empty directory, or no permission to read it)",
|
||||
" (空目录, 或者没有权限读)"),
|
||||
@@ -802,7 +802,7 @@ M("attach.pick.more",
|
||||
" ...还有 {n} 项没列出来\n"),
|
||||
M("attach.pick.no_match",
|
||||
"no match: {input}\n",
|
||||
"没有匹配到:{input}\n"),
|
||||
"没有匹配到: {input}\n"),
|
||||
M("attach.pick.parent",
|
||||
" .) .. go up one level",
|
||||
" .) .. 返回上级"),
|
||||
@@ -814,12 +814,12 @@ M("attach.pick.selected",
|
||||
"已选中 {file}\n"),
|
||||
M("attach.pick.skipped",
|
||||
"skipped {file}: {why}",
|
||||
"跳过 {file}:{why}"),
|
||||
"跳过 {file}: {why}"),
|
||||
|
||||
/* ---- 存档 (archive.c):/export /import 的底层错误 ---- */
|
||||
/* ---- 存档 (archive.c): /export /import 的底层错误 ---- */
|
||||
M("archive.err.mkdir",
|
||||
"cannot create the archive directory {dir}: {error}",
|
||||
"无法创建存档目录 {dir}:{error}"),
|
||||
"无法创建存档目录 {dir}: {error}"),
|
||||
M("archive.err.name_exhausted",
|
||||
"could not come up with a non-clashing file name in 64 tries",
|
||||
"连续 64 次都没能生成不冲突的文件名"),
|
||||
@@ -831,7 +831,7 @@ M("archive.err.not_archive",
|
||||
"{path} 不是 ligent 存档 (缺少 format 标记)"),
|
||||
M("archive.err.not_json",
|
||||
"{path} is not valid JSON: {error}",
|
||||
"{path} 不是合法 JSON:{error}"),
|
||||
"{path} 不是合法 JSON: {error}"),
|
||||
M("archive.err.read_failed",
|
||||
"cannot read {path}",
|
||||
"无法读取 {path}"),
|
||||
@@ -840,7 +840,7 @@ M("archive.err.write_failed",
|
||||
"写入 {path} 时出错"),
|
||||
M("archive.err.write_open",
|
||||
"cannot write {path}: {error}",
|
||||
"无法写入 {path}:{error}"),
|
||||
"无法写入 {path}: {error}"),
|
||||
|
||||
/* ---- 模型列表 (models.c)---- */
|
||||
M("models.err.empty_body",
|
||||
@@ -870,25 +870,25 @@ M("http.err.interrupted",
|
||||
"已中断"),
|
||||
M("http.err.log_open",
|
||||
"cannot write {path}: {error}",
|
||||
"无法写入 {path}:{error}"),
|
||||
"无法写入 {path}: {error}"),
|
||||
M("http.err.log_write",
|
||||
"error while writing {path}",
|
||||
"写入 {path} 时出错"),
|
||||
M("http.err.network",
|
||||
"network error: {error}",
|
||||
"网络错误:{error}"),
|
||||
"网络错误: {error}"),
|
||||
M("http.err.network_partial",
|
||||
"network error: {error} (some content was already shown, not retrying)",
|
||||
"网络错误:{error} (已输出部分内容, 不重试)"),
|
||||
"网络错误: {error} (已输出部分内容, 不重试)"),
|
||||
M("http.err.network_retried",
|
||||
"network error: {error} (after {n} retries)",
|
||||
"网络错误:{error} (已重试 {n} 次)"),
|
||||
"网络错误: {error} (已重试 {n} 次)"),
|
||||
M("http.err.redirect",
|
||||
"HTTP {status} redirects to {location} — point base_url at that address "
|
||||
"instead. (ligent does not follow redirects: a cross-origin hop would drop "
|
||||
"the Authorization header, and the server would only answer 401)",
|
||||
"HTTP {status} 重定向到 {location} —— 请直接把 base_url 改成该地址. "
|
||||
" (ligent 不自动跟随重定向:跨源跳转会丢掉 Authorization 头, "
|
||||
" (ligent 不自动跟随重定向: 跨源跳转会丢掉 Authorization 头, "
|
||||
"服务端只会返回 401)"),
|
||||
M("http.err.status_partial",
|
||||
"HTTP {status} (some content was already shown, not retrying)",
|
||||
@@ -902,7 +902,7 @@ M("http.log.header",
|
||||
M("http.log.truncated",
|
||||
"# note: the in-memory limit of {limit} bytes was reached, "
|
||||
"earlier parts were dropped\n",
|
||||
"# 注意:内存上限 {limit} 字节, 较早的部分已被丢弃\n"),
|
||||
"# 注意: 内存上限 {limit} 字节, 较早的部分已被丢弃\n"),
|
||||
M("http.retry.notice",
|
||||
"ligent: request failed, retrying in {delay} ms (attempt {n})",
|
||||
"ligent: 请求失败, {delay} 毫秒后重试 (第 {n} 次)"),
|
||||
@@ -919,7 +919,7 @@ M("llm.err.auth_hint",
|
||||
"请确认它属于 {url}, 并检查 -k / $DEEPSEEK_API_KEY / ligent.json 里配的是哪一个. "),
|
||||
M("llm.err.bad_json",
|
||||
"the response is not valid JSON: {error}",
|
||||
"响应不是合法 JSON:{error}"),
|
||||
"响应不是合法 JSON: {error}"),
|
||||
M("llm.err.no_api_key",
|
||||
"no API key (set $DEEPSEEK_API_KEY or write one into the config file)",
|
||||
"缺少 API key (设置 $DEEPSEEK_API_KEY 环境变量或写入配置文件)"),
|
||||
@@ -932,7 +932,7 @@ M("llm.err.overflow",
|
||||
"响应内容超过上限 (正文 {content} 字节 / 工具参数 {args} 字节), 已中止"),
|
||||
M("llm.err.server",
|
||||
"server error: {error}",
|
||||
"服务端错误:{error}"),
|
||||
"服务端错误: {error}"),
|
||||
M("llm.err.unparsable_response",
|
||||
"cannot parse the server response (neither streaming nor JSON)",
|
||||
"无法解析服务端响应 (非流式且非 JSON)"),
|
||||
@@ -952,16 +952,16 @@ M("llm.val.unknown",
|
||||
/* ---- /shell (shell.c)---- */
|
||||
M("shell.err.fork",
|
||||
"fork failed: {error}",
|
||||
"fork 失败:{error}"),
|
||||
"fork 失败: {error}"),
|
||||
M("shell.err.no_pty",
|
||||
"cannot create a pty: {error}",
|
||||
"无法创建伪终端:{error}"),
|
||||
"无法创建伪终端: {error}"),
|
||||
M("shell.err.no_slave_name",
|
||||
"cannot get the pty slave device name",
|
||||
"无法取得伪终端从设备名"),
|
||||
M("shell.err.pty_init",
|
||||
"pty initialization failed: {error}",
|
||||
"伪终端初始化失败:{error}"),
|
||||
"伪终端初始化失败: {error}"),
|
||||
|
||||
/* ---- 能力探测 (capability.c)---- */
|
||||
M("capability.note.baseline_failed",
|
||||
@@ -975,11 +975,11 @@ M("capability.note.both_ok",
|
||||
M("capability.note.split",
|
||||
"[capability probe] the combined probe failed; splitting it: "
|
||||
"vision={vision} tools={tools}\n",
|
||||
"[能力探测] 合并试探未通过, 拆开结果:图片={vision} tools={tools}\n"),
|
||||
"[能力探测] 合并试探未通过, 拆开结果: 图片={vision} tools={tools}\n"),
|
||||
|
||||
/* ---- JSON 解析诊断 (json.c):给人看的那份 ----
|
||||
/* ---- JSON 解析诊断 (json.c): 给人看的那份 ----
|
||||
*
|
||||
* 这一组是"两条腿":json_parse() 的报错固定中文 (它会被原样回填给模型,
|
||||
* 这一组是"两条腿": json_parse() 的报错固定中文 (它会被原样回填给模型,
|
||||
* 属于给模型看的信息); json_parse_ui() 的报错跟随界面语言 (配置、存档、
|
||||
* 服务端响应这些给人看的路径). 文案只有这一份, 两种走法各取一列.
|
||||
*
|
||||
@@ -988,10 +988,10 @@ M("capability.note.split",
|
||||
* 机械比对 (见 printf_specs). */
|
||||
M("json.err.at_end",
|
||||
"line %zu, column %zu: %s (input ends here)",
|
||||
"第 %zu 行第 %zu 列:%s (输入在此处结束)"),
|
||||
"第 %zu 行第 %zu 列: %s (输入在此处结束)"),
|
||||
M("json.err.at_got",
|
||||
"line %zu, column %zu: %s, got \"%s\"",
|
||||
"第 %zu 行第 %zu 列:%s, 实际读到 \"%s\""),
|
||||
"第 %zu 行第 %zu 列: %s, 实际读到 \"%s\""),
|
||||
M("json.err.array_unterminated", "array is not closed", "数组未闭合"),
|
||||
M("json.err.bad_number_got", "invalid number '%s'", "非法的数字 '%s'"),
|
||||
M("json.err.empty_input", "input is empty", "输入为空"),
|
||||
@@ -1008,7 +1008,7 @@ M("json.err.too_deep",
|
||||
"JSON 嵌套过深 (超过 %d 层)"),
|
||||
M("json.err.trailing",
|
||||
"extra content after the JSON: '%.20s'",
|
||||
"JSON 末尾存在多余内容:'%.20s'"),
|
||||
"JSON 末尾存在多余内容: '%.20s'"),
|
||||
M("json.err.u_bad_hex",
|
||||
"invalid \\u hex digit '%c'",
|
||||
"非法的 \\u 十六进制字符 '%c'"),
|
||||
@@ -1038,22 +1038,22 @@ M("json.err.want_true",
|
||||
"invalid literal (expected true)",
|
||||
"非法字面量 (期望 true)"),
|
||||
|
||||
/* ---- 工具参数的呈现 (tools.c 的 tool_lines_label / tool_render_diff):
|
||||
/* ---- 工具参数的呈现 (tools.c 的 tool_lines_label / tool_render_diff):
|
||||
* 调用摘要、确认提示、执行后的展示共用同一份 ---- */
|
||||
M("tool.call.all_lines", "whole file", "全文件"),
|
||||
M("tool.call.scope_all", "{scope} (all matches)", "{scope} (所有匹配)"),
|
||||
M("tool.diff.more", "... ({n} more lines)", "... (还有 {n} 行)"),
|
||||
|
||||
/* ---- /tools 的列表 (tools.c):纯给人看的一行 ---- */
|
||||
/* ---- /tools 的列表 (tools.c): 纯给人看的一行 ---- */
|
||||
M("tools.ui.edit_file",
|
||||
"replace inside a set of lines (omit line to replace everywhere)",
|
||||
"按行号集合精确替换 (省略 line = 全文件替换)"),
|
||||
M("tools.ui.header",
|
||||
"available tools:",
|
||||
"可用工具:"),
|
||||
"可用工具: "),
|
||||
M("tools.ui.hidden",
|
||||
" ({name} is hidden: the current model does not accept image input)",
|
||||
" ({name} 已隐藏:当前模型不接受图片输入)"),
|
||||
" ({name} 已隐藏: 当前模型不接受图片输入)"),
|
||||
M("tools.ui.list_dir",
|
||||
"list a directory",
|
||||
"列出目录"),
|
||||
@@ -1076,7 +1076,7 @@ M("util.oom",
|
||||
"ligent: out of memory\n",
|
||||
"ligent: 内存分配失败\n"),
|
||||
|
||||
/* ---- meta 模式 (meta.c):只有给人看的那部分, 工具回执仍是中文 ---- */
|
||||
/* ---- meta 模式 (meta.c): 只有给人看的那部分, 工具回执仍是中文 ---- */
|
||||
M("meta.already_in",
|
||||
"already in meta mode.",
|
||||
"已经在 meta 模式里了. "),
|
||||
@@ -1085,16 +1085,16 @@ M("meta.desc.delete_rounds",
|
||||
" 轮 {from}..{to} 整轮删除\n"),
|
||||
M("meta.desc.insert",
|
||||
" inserting {role} after #{after}: {preview}\n",
|
||||
" 在 #{after} 之后插入 {role}:{preview}\n"),
|
||||
" 在 #{after} 之后插入 {role}: {preview}\n"),
|
||||
M("meta.desc.replace",
|
||||
" rewriting #{index}: {preview}\n",
|
||||
" #{index} 的正文改成:{preview}\n"),
|
||||
" #{index} 的正文改成: {preview}\n"),
|
||||
M("meta.desc.undo",
|
||||
" undo the last edit\n",
|
||||
" 撤销上一次改动\n"),
|
||||
M("meta.diff.header",
|
||||
"current history: {n} message(s), {bytes} bytes:\n",
|
||||
"当前历史 {n} 条消息, {bytes} 字节:\n"),
|
||||
"当前历史 {n} 条消息, {bytes} 字节: \n"),
|
||||
M("meta.diff.no_text",
|
||||
"(no text)",
|
||||
"(无正文)"),
|
||||
@@ -1117,13 +1117,13 @@ M("meta.exit.edits",
|
||||
M("meta.exit.lineage",
|
||||
"this conversation has been edited: the next /export starts a new root, "
|
||||
"named like <name>_m<3 digits>.",
|
||||
"这段对话已被编辑过:下次 /export 会另起一根, 名字形如 <原名>_m<3位>. "),
|
||||
"这段对话已被编辑过: 下次 /export 会另起一根, 名字形如 <原名>_m<3位>. "),
|
||||
M("meta.exit.no_edits",
|
||||
"no edits this time, ",
|
||||
"本次没有改动, "),
|
||||
M("meta.exit.usage",
|
||||
"meta itself used {calls} call(s), {in} in / {out} out tokens.\n",
|
||||
"meta 自身消耗:{calls} 次调用, {in} 输入 / {out} 输出 token. \n"),
|
||||
"meta 自身消耗: {calls} 次调用, {in} 输入 / {out} 输出 token. \n"),
|
||||
M("meta.help",
|
||||
"/exit, /quit leave meta mode, back to the main conversation\n"
|
||||
"/diff show the conversation as it stands (changes nothing)\n"
|
||||
@@ -1140,7 +1140,7 @@ M("meta.help",
|
||||
"/undo 撤销上一次改动\n"
|
||||
"/help 显示本帮助\n"
|
||||
"\n"
|
||||
"其余输入都是给 meta 模型的指令, 例如:\n"
|
||||
"其余输入都是给 meta 模型的指令, 例如: \n"
|
||||
" 把第 3 轮里我说的 "用 Python" 删掉\n"
|
||||
" 第 5 轮助手那个结论是错的, 改掉\n"
|
||||
" 第 2 到 4 轮在绕圈子, 压成一段\n"),
|
||||
@@ -1165,7 +1165,7 @@ M("meta.unknown_cmd",
|
||||
|
||||
#define NMSGS (sizeof MSGS / sizeof MSGS[0])
|
||||
/* tf() 用一圈静态缓冲轮流返回字符串 (这样调用点不必自己 malloc).
|
||||
* 轮转的层数就是**嵌套调用**的上限:`tf(a, "x", tf(b, ...), NULL)` 里的内层
|
||||
* 轮转的层数就是**嵌套调用**的上限: `tf(a, "x", tf(b, ...), NULL)` 里的内层
|
||||
* 返回值必须在外层拼完之前一直有效, 嵌套超过 TF_BUFS 层就会踩坏自己. */
|
||||
#define TF_BUFS 8
|
||||
#define TF_BUFLEN 4096
|
||||
@@ -1205,7 +1205,7 @@ int i18n_parse_lang(const char *name)
|
||||
if (!name || !*name) return -1;
|
||||
char lang[16];
|
||||
size_t k = 0;
|
||||
/* 语言码取到第一个 '_' / '-' / '.' / '@' 之前:"zh_CN.UTF-8" → "zh" */
|
||||
/* 语言码取到第一个 '_' / '-' / '.' / '@' 之前: "zh_CN.UTF-8" → "zh" */
|
||||
for (const char *p = name; *p && k < sizeof lang - 1; p++) {
|
||||
if (*p == '_' || *p == '-' || *p == '.' || *p == '@') break;
|
||||
lang[k++] = (char)tolower((unsigned char)*p);
|
||||
@@ -1225,7 +1225,7 @@ int i18n_lang_from_env(void)
|
||||
if (!v || !*v) continue;
|
||||
int lang = i18n_parse_lang(v);
|
||||
if (lang >= 0) return lang;
|
||||
/* 认不出来的语言码 (fr_FR、ja_JP…):我们只有两种翻译,
|
||||
/* 认不出来的语言码 (fr_FR、ja_JP…): 我们只有两种翻译,
|
||||
* 与其猜, 不如按"没设"处理, 最后落到英文. */
|
||||
return LIGENT_LANG_EN;
|
||||
}
|
||||
@@ -1305,7 +1305,7 @@ const char *tf(const char *key, ...)
|
||||
p = e + 1;
|
||||
goto next;
|
||||
}
|
||||
/* 没给这个占位符的值:原样留着, 方便一眼看出漏了参数 */
|
||||
/* 没给这个占位符的值: 原样留着, 方便一眼看出漏了参数 */
|
||||
if (o + want + 2 < TF_BUFLEN) {
|
||||
out[o++] = '{';
|
||||
memcpy(out + o, p + 1, want);
|
||||
|
||||
+8
-8
@@ -1,10 +1,10 @@
|
||||
/* i18n.h - 界面文案的多语言表
|
||||
*
|
||||
* 只覆盖**给人看**的文案:帮助、错误、诊断、确认提示、spinner 标签.
|
||||
* 只覆盖**给人看**的文案: 帮助、错误、诊断、确认提示、spinner 标签.
|
||||
* 送给模型的那些 (系统提示词、工具描述、工具回执)**不在这里**,
|
||||
* 它们跟界面语言是两件事, 见 README「界面语言」一节.
|
||||
*
|
||||
* 键用点分 ID (`err.unknown_option`)而不是中文原文:这样两种语言的
|
||||
* 键用点分 ID (`err.unknown_option`)而不是中文原文: 这样两种语言的
|
||||
* 条目能机械对齐 —— 少一条、占位符对不上, 测试都会报出来.
|
||||
*/
|
||||
#ifndef LIGENT_I18N_H
|
||||
@@ -24,13 +24,13 @@ typedef struct {
|
||||
void i18n_set_lang(int lang);
|
||||
int i18n_lang(void);
|
||||
|
||||
/* 解析语言名:"zh"、"zh_CN"、"zh-Hans.UTF-8" → ZH;
|
||||
/* 解析语言名: "zh"、"zh_CN"、"zh-Hans.UTF-8" → ZH;
|
||||
* "en"、"C"、"POSIX" → EN;
|
||||
* "auto"、空、认不出来的 → -1 (调用方自行决定回退). */
|
||||
int i18n_parse_lang(const char *name);
|
||||
|
||||
/* 按环境探测:LIGENT_LANG > LC_ALL > LC_MESSAGES > LANG.
|
||||
* 都没有或都认不出来 → EN (POSIX 惯例:C locale 是英文). */
|
||||
/* 按环境探测: LIGENT_LANG > LC_ALL > LC_MESSAGES > LANG.
|
||||
* 都没有或都认不出来 → EN (POSIX 惯例: C locale 是英文). */
|
||||
int i18n_lang_from_env(void);
|
||||
|
||||
/* 初始化. lang 为 -1 时按环境探测. */
|
||||
@@ -40,15 +40,15 @@ void i18n_init(int lang);
|
||||
const char *i18n_lang_name(void);
|
||||
|
||||
/* 查表取当前语言的文案. 未知键返回 "[?key?]" —— 显眼,
|
||||
* 免得漏掉的条目静默变成空白. */
|
||||
* 以免漏掉的条目静默变成空白. */
|
||||
const char *t(const char *key);
|
||||
|
||||
/* 带占位符:模板里写 {name}, 调用时按名字成对给值, 最后传 NULL.
|
||||
/* 带占位符: 模板里写 {name}, 调用时按名字成对给值, 最后传 NULL.
|
||||
* tf("err.unknown_option", "option", arg, NULL)
|
||||
* 按名字匹配 (不是按位置), 所以译文可以调整语序. */
|
||||
const char *tf(const char *key, ...);
|
||||
|
||||
/* 整数转字符串 (静态轮转缓冲), 专供 tf 的参数位置用:
|
||||
/* 整数转字符串 (静态轮转缓冲), 专供 tf 的参数位置用:
|
||||
* tf("k", "n", i18n_num(count), NULL)
|
||||
* 省得每个调用点都开一个 char buf[32]. */
|
||||
const char *i18n_num(long long v);
|
||||
|
||||
+7
-7
@@ -92,7 +92,7 @@ char *image_backup_save(const char *bindir, const unsigned char *data, size_t n,
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 请求前展开:ligent_image -> image_url + base64 */
|
||||
/* 请求前展开: ligent_image -> image_url + base64 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* 内部块类型. 带 ligent_ 前缀是为了万一漏了展开、直接发出去时,
|
||||
@@ -152,7 +152,7 @@ static char *backup_to_data_url(const char *name, size_t *budget, const char **w
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* 体积要先算再编码:编码本身就要吃一份内存, 超限的话没必要做 */
|
||||
/* 体积要先算再编码: 编码本身就要吃一份内存, 超限的话没必要做 */
|
||||
size_t b64len = base64_encoded_len(len);
|
||||
if (b64len > IMAGE_MAX_B64) {
|
||||
free(raw);
|
||||
@@ -220,9 +220,9 @@ JsonValue *image_expand_for_request(JsonValue *messages, int *degraded)
|
||||
continue;
|
||||
}
|
||||
|
||||
/* 展开不出来:把历史里这条坏引用就地换成文字说明,
|
||||
/* 展开不出来: 把历史里这条坏引用就地换成文字说明,
|
||||
* 否则它每轮都会再失败一次. */
|
||||
char *text = xasprintf("[图片 %s 无法交给模型:%s]",
|
||||
char *text = xasprintf("[图片 %s 无法交给模型: %s]",
|
||||
name && *name ? name : "?", why);
|
||||
JsonValue *note = make_note(text);
|
||||
json_array_push(wc, to_wire_part(note));
|
||||
@@ -251,7 +251,7 @@ char *image_backup_from_path(const char *path, size_t *out_len,
|
||||
size_t len = 0;
|
||||
char *raw = read_entire_file(path, &len);
|
||||
if (!raw) {
|
||||
if (out_msg) *out_msg = xasprintf("读不到图片 %s:%s", path, strerror(errno));
|
||||
if (out_msg) *out_msg = xasprintf("读不到图片 %s: %s", path, strerror(errno));
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -259,7 +259,7 @@ char *image_backup_from_path(const char *path, size_t *out_len,
|
||||
if (image_sniff((const unsigned char *)raw, len, &ext, &mime) != 0) {
|
||||
free(raw);
|
||||
if (out_msg) *out_msg = xasprintf(
|
||||
"不认识的图片格式:%s. 支持 PNG / JPEG / GIF / WebP"
|
||||
"不认识的图片格式: %s. 支持 PNG / JPEG / GIF / WebP"
|
||||
" (按文件内容判断, 不看扩展名)", path);
|
||||
return NULL;
|
||||
}
|
||||
@@ -269,7 +269,7 @@ char *image_backup_from_path(const char *path, size_t *out_len,
|
||||
if (b64len > IMAGE_MAX_B64) {
|
||||
free(raw);
|
||||
if (out_msg) *out_msg = xasprintf(
|
||||
"图片太大, 没法交给模型看:%s 有 %.1f MiB, base64 内联后 %.1f MiB, "
|
||||
"图片太大, 没法交给模型看: %s 有 %.1f MiB, base64 内联后 %.1f MiB, "
|
||||
"超过内联上限 %.0f MiB. "
|
||||
" (图片会留在对话历史里, 所以这里当场拒掉, 而不是发出去等报错. "
|
||||
"如果要看大图, 请先缩小尺寸或压缩后再读. )",
|
||||
|
||||
+15
-15
@@ -1,11 +1,11 @@
|
||||
/* image.h - 图片:格式嗅探、本地备份、请求前展开
|
||||
/* image.h - 图片: 格式嗅探、本地备份、请求前展开
|
||||
*
|
||||
* 协议层面图片只有两条路能走:给一个外部 URL, 或者把 base64 内联进请求.
|
||||
* 协议层面图片只有两条路能走: 给一个外部 URL, 或者把 base64 内联进请求.
|
||||
* ligent 不替用户去网上抓图, 所以 read_image 只收本地文件, 产出 base64 内联.
|
||||
*
|
||||
* 但 base64 内联有个直接后果:图片字节会躺在会话历史里, 每轮请求都重发一遍.
|
||||
* 但 base64 内联有个直接后果: 图片字节会躺在会话历史里, 每轮请求都重发一遍.
|
||||
* 归档、/history、/log 全都会跟着膨胀. 所以历史的**存储形态**和**发送形态**
|
||||
* 是分开的:
|
||||
* 是分开的:
|
||||
*
|
||||
* 存进历史 {"type":"ligent_image","backup":"1700000000_a1b2.png"}
|
||||
* 发给服务端 {"type":"image_url","image_url":{"url":"data:image/png;base64,...."}}
|
||||
@@ -14,7 +14,7 @@
|
||||
* 真正构造请求时才读出来编码. 备份目录是 <bindir>/ligent.archive/images/.
|
||||
*
|
||||
* 两个内部块类型都带 ligent_ 前缀, 就是为了万一哪天漏了展开、直接把它发出去,
|
||||
* 服务端报的错能一眼看出是我们的问题, 而不是一个莫名其妙的 400:
|
||||
* 服务端报的错能一眼看出是我们的问题, 而不是一个莫名其妙的 400:
|
||||
* ligent_image 引用一张本地备份
|
||||
* ligent_note 展开失败后的文字说明 (引用读不到了、或体积超了)
|
||||
*/
|
||||
@@ -28,16 +28,16 @@
|
||||
/* ---- 内联图片的上限 ----
|
||||
*
|
||||
* 取各家文档里最严的一档 (ligent 同时对着 DeepSeek 和 MiMo 跑, 两边都发了
|
||||
* 文档里的数字):
|
||||
* DeepSeek:请求体 48 MiB; 单张 (base64 / 外部 URL)32 MiB
|
||||
* MiMo :base64 字符串 50 MB
|
||||
* 文档里的数字):
|
||||
* DeepSeek: 请求体 48 MiB; 单张 (base64 / 外部 URL)32 MiB
|
||||
* MiMo : base64 字符串 50 MB
|
||||
* 于是单张按 32 MiB 的 **base64 字符串** 算 —— 注意是编码后的长度, 一张
|
||||
* 24 MiB 的原图编码后就已经超了. 宁可在这里拒掉, 也别发出去等一个 400:
|
||||
* 24 MiB 的原图编码后就已经超了. 宁可在这里拒掉, 也别发出去等一个 400:
|
||||
* 图片是躺在历史里的, 一次超限会变成每轮都超限.
|
||||
*/
|
||||
#define IMAGE_MAX_B64 (32u * 1024 * 1024)
|
||||
/* 一个请求里所有内联图片的 base64 总量. 48 MiB 的请求体还要装提示词、历史、
|
||||
* 工具描述, 给它们留 4 MiB. 这条是兜底:read_image 一次最多挂 8 张,
|
||||
* 工具描述, 给它们留 4 MiB. 这条是兜底: read_image 一次最多挂 8 张,
|
||||
* 每张都卡在 32 MiB 的话, 加起来早就冲爆请求体了. */
|
||||
#define IMAGE_MAX_TOTAL_B64 (44u * 1024 * 1024)
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
int image_sniff(const unsigned char *data, size_t n,
|
||||
const char **ext, const char **mime);
|
||||
|
||||
/* 备份目录:<bindir>/ligent.archive/images (malloc) */
|
||||
/* 备份目录: <bindir>/ligent.archive/images (malloc) */
|
||||
char *image_backup_dir(const char *bindir);
|
||||
|
||||
/* 备份文件所在目录下的完整路径 (malloc). name 是 image_backup_save 返回的名字 */
|
||||
@@ -62,14 +62,14 @@ char *image_backup_save(const char *bindir, const unsigned char *data, size_t n,
|
||||
void image_set_bindir(const char *dir);
|
||||
const char *image_bindir(void);
|
||||
|
||||
/* 读一个本地图片文件:按内容认格式、判体积、落备份.
|
||||
/* 读一个本地图片文件: 按内容认格式、判体积、落备份.
|
||||
*
|
||||
* 成功返回备份文件名 (malloc), *out_len 回填原始字节数、*out_mime 回填 MIME
|
||||
* (字面量)、*out_msg 置 NULL.
|
||||
* 失败返回 NULL, *out_msg 是一句能直接给模型/用户看的中文说明 (malloc).
|
||||
*
|
||||
* 体积超限的图**不备份** —— 一张发不出去的图, 留着备份没有意义.
|
||||
* read_image 和 /include 都走这一条, 免得两处的判断慢慢长歪. */
|
||||
* read_image 和 /include 都走这一条, 以免两处的判断慢慢长歪. */
|
||||
char *image_backup_from_path(const char *path, size_t *out_len,
|
||||
const char **out_mime, char **out_msg);
|
||||
|
||||
@@ -88,7 +88,7 @@ JsonValue *image_pending_take(void);
|
||||
* (调用者 json_free). 原 messages 不变, 除非有引用展开不出来 —— 那种情况
|
||||
* 会**就地**把它降级成 ligent_note, 把坏引用从历史里清掉.
|
||||
*
|
||||
* 为什么就地降级:那条引用躺在历史里, 不修的话每一轮都会重新失败一次,
|
||||
* 为什么就地降级: 那条引用躺在历史里, 不修的话每一轮都会重新失败一次,
|
||||
* 用户除了 /reset 没有别的路. 这和上下文超限要丢老轮次是同一个道理.
|
||||
*
|
||||
* *degraded (可传 NULL)回填就地降级的处数, 调用方据此提示用户.
|
||||
@@ -101,7 +101,7 @@ int image_has_refs(const JsonValue *messages);
|
||||
|
||||
/* 把历史里所有图片引用就地降级成文字说明 ("不符合 API 所需的格式标准,
|
||||
* 无法上传, 请规范化图片后重试"), 返回处理处数.
|
||||
* 不给理由参数:给模型的话要能直接照做, 服务端的原始英文报错由调用方打到
|
||||
* 不给理由参数: 给模型的话要能直接照做, 服务端的原始英文报错由调用方打到
|
||||
* stderr, 不往上下文里塞. */
|
||||
int image_degrade_all(JsonValue *messages);
|
||||
|
||||
|
||||
+10
-10
@@ -319,7 +319,7 @@ LIGENT_PRINTF(2, 3) static void perr(Parser *ps, const char *fmt, ...)
|
||||
snprintf(ps->err, ps->errlen, jfmt(ps->ui, "json.err.at_end"),
|
||||
line, col, detail);
|
||||
|
||||
/* 最后统一净化:错误信息里不允许出现原始控制字符.
|
||||
/* 最后统一净化: 错误信息里不允许出现原始控制字符.
|
||||
* 像 "意外的字符 '%c'" 这种格式化会直接把控制字符带进消息本体,
|
||||
* 原样打到终端上会把用户的屏幕搞乱. */
|
||||
for (char *q = ps->err; *q; q++) {
|
||||
@@ -434,13 +434,13 @@ static JsonValue *parse_string(Parser *ps)
|
||||
return v;
|
||||
}
|
||||
|
||||
/* 严格按 RFC 8259 扫描一个数字:
|
||||
/* 严格按 RFC 8259 扫描一个数字:
|
||||
* -?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?
|
||||
*
|
||||
* 以前这里是"能吃多少吃多少", 于是 01、5.、1e、1e+ 全都被当成合法数字 ——
|
||||
* 而 strtod("1e") 会老老实实返回 1, 那个残缺的指数就被静默吞掉了.
|
||||
* 配置文件和存档都是手写的, 把写错的数字当成另一个数继续用下去, 用户只会在
|
||||
* 很久之后从一个完全不相干的现象里发现它. 所以语法必须自己严查一遍:
|
||||
* 很久之后从一个完全不相干的现象里发现它. 所以语法必须自己严查一遍:
|
||||
* strtod 只负责最后那步文本 -> double 的转换. */
|
||||
static JsonValue *parse_number(Parser *ps)
|
||||
{
|
||||
@@ -470,12 +470,12 @@ static JsonValue *parse_number(Parser *ps)
|
||||
double d = strtod(tmp, &endp);
|
||||
int all = (endp && *endp == '\0');
|
||||
free(tmp);
|
||||
if (!all) goto bad; /* 语法扫过了 strtod 却不认:不该发生 */
|
||||
if (!all) goto bad; /* 语法扫过了 strtod 却不认: 不该发生 */
|
||||
return json_new_number(d);
|
||||
}
|
||||
|
||||
bad:
|
||||
/* 回退到数字的起点再报错:这样行列号指向"这个数字从哪开始",
|
||||
/* 回退到数字的起点再报错: 这样行列号指向"这个数字从哪开始",
|
||||
* 而不是指向我们自己刚消费掉的那个字节. */
|
||||
{
|
||||
char *tmp = xstrndup(start, (size_t)(ps->p - start));
|
||||
@@ -601,7 +601,7 @@ done:
|
||||
return result;
|
||||
}
|
||||
|
||||
/* ui != 0:报错跟随界面语言; = 0:固定中文 (给模型看的那份).
|
||||
/* ui != 0: 报错跟随界面语言; = 0: 固定中文 (给模型看的那份).
|
||||
* 见上面 jfmt 的说明与 README「界面语言」. */
|
||||
static JsonValue *parse_impl(const char *text, char *errbuf, size_t errlen, int ui)
|
||||
{
|
||||
@@ -666,7 +666,7 @@ static void dump_string(StrBuf *sb, const char *s)
|
||||
if (c < 0x20) { sb_printf(sb, "\\u%04x", c); i++; continue; }
|
||||
if (c < 0x80) { sb_putc(sb, (char)c); i++; continue; }
|
||||
|
||||
/* 非 ASCII 字节:只有构成**合法** UTF-8 序列时才能原样进 JSON 字符串.
|
||||
/* 非 ASCII 字节: 只有构成**合法** UTF-8 序列时才能原样进 JSON 字符串.
|
||||
* 以前这里是无条件 sb_putc, 于是工具输出里一个坏字节 (例如某条命令在
|
||||
* 非 UTF-8 locale 下打出来的路径, 或者被截断的多字节字符)就会让**整个
|
||||
* 请求体**不是合法 UTF-8, 服务端直接回
|
||||
@@ -675,13 +675,13 @@ static void dump_string(StrBuf *sb, const char *s)
|
||||
* 除非 /reset 把整轮对话丢掉.
|
||||
*
|
||||
* 兜在出口而不是只在入口清理, 是因为出口才是能保证"发出去的永远合法"
|
||||
* 的那一层:对话内容还可能来自存档、粘贴、/include 进来的文件……
|
||||
* 的那一层: 对话内容还可能来自存档、粘贴、/include 进来的文件……
|
||||
* 这里统一把非法字节换成 U+FFFD, 既让请求成立, 也让模型看到"这里有个
|
||||
* 无法解码的字符", 而不是收到一段乱码. */
|
||||
size_t n = utf8_seq_len(s + i, len - i);
|
||||
if (n == 0) { sb_puts(sb, "\\ufffd"); i++; continue; }
|
||||
/* U+FFFD 一律写成 \ufffd 转义. 它是上面替换坏字节时用的字符, 如果这里
|
||||
* 让 EF BF BD 原样输出, dump→parse→dump 就不稳定了:第一次是 \ufffd,
|
||||
* 让 EF BF BD 原样输出, dump→parse→dump 就不稳定了: 第一次是 \ufffd,
|
||||
* 解析回来变成真的 U+FFFD, 第二次就原样吐三个字节.
|
||||
* fuzz 的"dump 幂等"这条不变量就是被这个当场抓住的 —— 幂等是有价值的
|
||||
* 性质, 它保证存档反复读写在字节层面也是收敛的. 给这一个字符定死一个
|
||||
@@ -703,7 +703,7 @@ static void dump_number(StrBuf *sb, double n)
|
||||
if (isnan(n) || isinf(n)) { sb_puts(sb, "null"); return; }
|
||||
char buf[64];
|
||||
/* 先判范围, 再转换. double -> long long 在值超出范围时是 UB, 而 && 的
|
||||
* 求值顺序保不住这个转换:原来它写在条件最左边, 服务端回一个 1e300
|
||||
* 求值顺序保不住这个转换: 原来它写在条件最左边, 服务端回一个 1e300
|
||||
* 就会踩到 (clang 的 UBSan 报 float-cast-overflow; gcc 不插桩这条转换,
|
||||
* 所以 make SAN=1 抓不到). */
|
||||
if (fabs(n) < 1e15 && n == (double)(long long)n)
|
||||
|
||||
+3
-3
@@ -39,11 +39,11 @@ struct JsonValue {
|
||||
} u;
|
||||
};
|
||||
|
||||
/* ---- 解析:失败返回 NULL 并写入 errbuf ---- */
|
||||
/* ---- 解析: 失败返回 NULL 并写入 errbuf ---- */
|
||||
JsonValue *json_parse(const char *text, char *errbuf, size_t errlen);
|
||||
|
||||
/* 同上, 但报错跟随界面语言 (给人看的配置/存档/响应报错用这个).
|
||||
* json_parse 的报错固定中文:那些会原样回填给模型 (工具参数解析),
|
||||
* json_parse 的报错固定中文: 那些会原样回填给模型 (工具参数解析),
|
||||
* 属于"给模型看的信息", 不随界面语言变. */
|
||||
JsonValue *json_parse_ui(const char *text, char *errbuf, size_t errlen);
|
||||
|
||||
@@ -85,7 +85,7 @@ void json_object_set_bool(JsonValue *obj, const char *key, int value);
|
||||
void json_object_set_null(JsonValue *obj, const char *key);
|
||||
void json_object_remove(JsonValue *obj, const char *key);
|
||||
|
||||
/* ---- 序列化:返回 malloc 的字符串 ---- */
|
||||
/* ---- 序列化: 返回 malloc 的字符串 ---- */
|
||||
char *json_dump(const JsonValue *v); /* 紧凑格式 */
|
||||
char *json_dump_pretty(const JsonValue *v); /* 2 空格缩进 */
|
||||
/* 把任意 C 字符串转义成 JSON 字符串字面量 (含首尾引号) */
|
||||
|
||||
+12
-12
@@ -216,7 +216,7 @@ static char *hist_decode(const char *s)
|
||||
}
|
||||
|
||||
/* 把内存里最新的 n 条重写回文件 (先写临时文件再 rename,
|
||||
* 免得写到一半被杀掉就把历史弄没了). */
|
||||
* 以免写到一半被杀掉就把历史弄没了). */
|
||||
static void hist_rewrite(const char *path, size_t n)
|
||||
{
|
||||
if (g_hist_len < n) n = g_hist_len;
|
||||
@@ -291,7 +291,7 @@ int line_history_append(const char *path, const char *line)
|
||||
{
|
||||
if (!path || !*path || !line || !*line) return 0;
|
||||
|
||||
/* 0600:历史里可能有密钥, 不给同机其他用户读的机会 */
|
||||
/* 0600: 历史里可能有密钥, 不给同机其他用户读的机会 */
|
||||
int fd = open(path, O_WRONLY | O_APPEND | O_CREAT, 0600);
|
||||
if (fd < 0) return -1;
|
||||
FILE *f = fdopen(fd, "a");
|
||||
@@ -304,7 +304,7 @@ int line_history_append(const char *path, const char *line)
|
||||
int rc = 0;
|
||||
if (sb.data && fwrite(sb.data, 1, sb.len, f) != sb.len) rc = -1;
|
||||
if (fputc('\n', f) == EOF) rc = -1;
|
||||
/* 立刻 flush:另一个 ligent 实例要能马上翻到这条 */
|
||||
/* 立刻 flush: 另一个 ligent 实例要能马上翻到这条 */
|
||||
if (fflush(f) != 0) rc = -1;
|
||||
if (fclose(f) != 0) rc = -1;
|
||||
sb_free(&sb);
|
||||
@@ -341,7 +341,7 @@ static int tty_raw(struct termios *saved)
|
||||
|
||||
struct termios raw = *saved;
|
||||
raw.c_iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON);
|
||||
/* 刻意保留 OPOST:\n 仍要能正常换行, 否则输出会阶梯状错位 */
|
||||
/* 刻意保留 OPOST: \n 仍要能正常换行, 否则输出会阶梯状错位 */
|
||||
raw.c_cflag |= CS8;
|
||||
raw.c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG);
|
||||
raw.c_cc[VMIN] = 1;
|
||||
@@ -448,13 +448,13 @@ typedef struct {
|
||||
static size_t ansi_skip(const char *s, size_t n)
|
||||
{
|
||||
size_t i = 1;
|
||||
if (i < n && s[i] == '[') { /* CSI:一直吃到终结字节 */
|
||||
if (i < n && s[i] == '[') { /* CSI: 一直吃到终结字节 */
|
||||
i++;
|
||||
while (i < n && !(s[i] >= '@' && s[i] <= '~')) i++;
|
||||
if (i < n) i++;
|
||||
return i;
|
||||
}
|
||||
if (i < n && s[i] == ']') { /* OSC:到 BEL 或 ST 结束 */
|
||||
if (i < n && s[i] == ']') { /* OSC: 到 BEL 或 ST 结束 */
|
||||
i++;
|
||||
while (i < n) {
|
||||
if (s[i] == '\a') { i++; break; }
|
||||
@@ -486,7 +486,7 @@ static void advance_pos(const char *s, size_t n, int cols, int *row, int *col)
|
||||
}
|
||||
|
||||
/* 打印完之后, 终端光标视觉上停在第几行第几列.
|
||||
* 关键:字符正好填满一行时终端处于"待换行"状态, 光标仍停在本行末列. */
|
||||
* 关键: 字符正好填满一行时终端处于"待换行"状态, 光标仍停在本行末列. */
|
||||
static void end_pos(const char *prompt, const char *buf, size_t len,
|
||||
int cols, int *row, int *col)
|
||||
{
|
||||
@@ -509,7 +509,7 @@ static void want_pos(const char *prompt, const char *buf, size_t pos,
|
||||
if (*col >= cols) { (*row)++; *col = 0; }
|
||||
}
|
||||
|
||||
/* 输入行整行着色:提示符和你敲进去的字都是绿的, 和模型的输出区分开.
|
||||
/* 输入行整行着色: 提示符和你敲进去的字都是绿的, 和模型的输出区分开.
|
||||
* 颜色由这里统一加, 调用方传进来的 prompt 保持纯文本 —— 否则提示符自己
|
||||
* 带一次 reset, 后面输入的正文就掉回默认色了. */
|
||||
#define C_INPUT "\033[32m"
|
||||
@@ -547,7 +547,7 @@ static void refresh(Editor *e)
|
||||
if (e->lb->len) fwrite(e->lb->buf, 1, e->lb->len, stdout);
|
||||
if (g_input_color) fputs(C_INPUT_OFF, stdout);
|
||||
|
||||
/* 从"打印结束处"移到目标位置:先回列 0, 再上下行, 再右移 */
|
||||
/* 从"打印结束处"移到目标位置: 先回列 0, 再上下行, 再右移 */
|
||||
int up = erow - trow;
|
||||
putchar('\r');
|
||||
if (up > 0) printf("\033[%dA", up);
|
||||
@@ -560,14 +560,14 @@ static void refresh(Editor *e)
|
||||
|
||||
/* 把粘贴进来的内容清洗成"编辑器能正确渲染"的文本.
|
||||
*
|
||||
* 为什么必须清洗:read_paste 收下的是终端给的原始字节, 而缓冲区会被
|
||||
* 逐字节回显到终端上. 里面只要有控制字符, 两边的算盘就对不上:
|
||||
* 为什么必须清洗: read_paste 收下的是终端给的原始字节, 而缓冲区会被
|
||||
* 逐字节回显到终端上. 里面只要有控制字符, 两边的算盘就对不上:
|
||||
* - \t 终端按制表位展开 (下一个 8 的倍数), 编辑器却只当 1 列,
|
||||
* 粘一段带缩进的代码, 光标就从此偏掉, 后面每敲一个字都在错位置上;
|
||||
* - ESC 会被终端当成转义序列真的执行掉 —— 粘贴内容能改颜色、清屏;
|
||||
* - \r 把终端光标拉回行首, 屏幕当场错位;
|
||||
* - NUL 会把 C 字符串截断.
|
||||
* 处理办法:\n 保留 (多行粘贴整段提交靠它), \t 就地展开成空格 (这样
|
||||
* 处理办法: \n 保留 (多行粘贴整段提交靠它), \t 就地展开成空格 (这样
|
||||
* 缓冲区里不留制表符, 宽度怎么算都对), ESC 连同后面整条序列一起丢掉,
|
||||
* 其余控制字符丢弃.
|
||||
*
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
/* lineedit.h - REPL 的行编辑器
|
||||
*
|
||||
* 为什么需要它:原来的 REPL 用 fgets 直接读, 终端停在规范模式,
|
||||
* 为什么需要它: 原来的 REPL 用 fgets 直接读, 终端停在规范模式,
|
||||
* 方向键发出的转义序列 (\x1b[D 之类)会被当成普通字节读进来,
|
||||
* 显示成 ^[[D 而不是移动光标. 要有行内编辑就必须自己进 raw 模式接管按键.
|
||||
*
|
||||
@@ -37,7 +37,7 @@ void linebuf_kill_to_end(LineBuf *lb); /* 删到行尾 */
|
||||
void linebuf_kill_to_start(LineBuf *lb); /* 删到行首 */
|
||||
void linebuf_kill_word(LineBuf *lb); /* 往回删一个词 */
|
||||
|
||||
/* 输入行是否着色 (整行:提示符 + 你敲进去的字).
|
||||
/* 输入行是否着色 (整行: 提示符 + 你敲进去的字).
|
||||
* 传进来的 prompt 应当是纯文本 —— 颜色由编辑器统一加在两头. */
|
||||
void line_edit_set_color(int enabled);
|
||||
|
||||
@@ -67,7 +67,7 @@ const char *line_history_get(size_t i);
|
||||
* (\ -> \\, 换行 -> \n), 保证读回来和写出去一模一样. 不含反斜杠的
|
||||
* 普通命令在文件里与输入完全一致, 肉眼可读.
|
||||
*
|
||||
* 时间戳之类的元数据刻意不记:目的只是"上下键能翻到", 不是审计. */
|
||||
* 时间戳之类的元数据刻意不记: 目的只是"上下键能翻到", 不是审计. */
|
||||
|
||||
/* 从 path 读取历史, 追加到当前历史之后. 最多保留最新的 keep 条;
|
||||
* 文件本身更长时会被就地截短 (否则会无限增长).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* llm.c - OpenAI 兼容接口:请求构造、SSE 流式解析、tool_calls 增量拼接 */
|
||||
/* llm.c - OpenAI 兼容接口: 请求构造、SSE 流式解析、tool_calls 增量拼接 */
|
||||
#include "llm.h"
|
||||
#include "config.h" /* THINKING_AUTO / _ON / _OFF */
|
||||
#include "http.h"
|
||||
@@ -90,7 +90,7 @@ static char *extract_error_message(const char *body)
|
||||
return msg;
|
||||
}
|
||||
|
||||
/* 401/403 时补一句提示. 服务端的原文经常把人引向错误的方向:
|
||||
/* 401/403 时补一句提示. 服务端的原文经常把人引向错误的方向:
|
||||
* OpenRouter 对"格式不对的 key"回的是 "Missing Authentication header",
|
||||
* 听起来像是请求里没带认证头, 实际上带了, 只是 key 不属于这家. */
|
||||
static void append_auth_hint(char *errbuf, size_t errlen,
|
||||
@@ -104,7 +104,7 @@ static void append_auth_hint(char *errbuf, size_t errlen,
|
||||
if (n == 0) {
|
||||
snprintf(masked, sizeof masked, "%s", t("llm.mask.empty"));
|
||||
} else {
|
||||
/* 只露前几个字符:够认出是哪一把, 又不至于把密钥打进日志 */
|
||||
/* 只露前几个字符: 够认出是哪一把, 又不至于把密钥打进日志 */
|
||||
char head[8];
|
||||
snprintf(head, sizeof head, "%.*s", n <= 10 ? 4 : 7, key);
|
||||
snprintf(masked, sizeof masked, "%s",
|
||||
@@ -252,7 +252,7 @@ static void handle_payload(SseCtx *c, const char *payload, size_t len)
|
||||
|
||||
/* 思考过程. DeepSeek 用 reasoning_content, 有些 OpenAI 兼容实现用
|
||||
* reasoning. 只累积、不回调 —— 用户明确要求不显示, 但它必须随
|
||||
* assistant 消息一起回传:请求带 tools 时服务端要求这么做,
|
||||
* assistant 消息一起回传: 请求带 tools 时服务端要求这么做,
|
||||
* 漏了会在下一轮直接 400. */
|
||||
const char *think = json_get_string(d, "reasoning_content");
|
||||
if (!think) think = json_get_string(d, "reasoning");
|
||||
@@ -326,7 +326,7 @@ static void feed(SseCtx *c, const char *data, size_t len)
|
||||
buf[c->linebuf.len] = '\0';
|
||||
}
|
||||
|
||||
/* 残行过长:丢弃, 保证内存有界 */
|
||||
/* 残行过长: 丢弃, 保证内存有界 */
|
||||
if (c->linebuf.len > SSE_MAX_LINE) sb_clear(&c->linebuf);
|
||||
}
|
||||
|
||||
@@ -388,7 +388,7 @@ static LlmResponse *from_sse(SseCtx *c)
|
||||
else
|
||||
json_object_set_null(r->message, "content");
|
||||
|
||||
/* 思考过程跟着 assistant 消息一起进历史. 用户看不到它, 但它必须原样回传:
|
||||
/* 思考过程跟着 assistant 消息一起进历史. 用户看不到它, 但它必须原样回传:
|
||||
* 只要请求里带 tools, 服务端就要求把历史里的 reasoning_content 全带上,
|
||||
* 否则下一轮直接 400. */
|
||||
if (c->reasoning.len > 0)
|
||||
|
||||
+35
-35
@@ -1,4 +1,4 @@
|
||||
/* main.c - 入口:命令行解析、单次任务模式、交互式 REPL */
|
||||
/* main.c - 入口: 命令行解析、单次任务模式、交互式 REPL */
|
||||
#include "config.h"
|
||||
#include "agent.h"
|
||||
#include "tools.h"
|
||||
@@ -41,7 +41,7 @@ static void install_signals(void)
|
||||
memset(&sa, 0, sizeof sa);
|
||||
sa.sa_handler = on_sigint;
|
||||
sigemptyset(&sa.sa_mask);
|
||||
sa.sa_flags = 0; /* 不设 SA_RESTART:让 read/fgets 被 EINTR 打断 */
|
||||
sa.sa_flags = 0; /* 不设 SA_RESTART: 让 read/fgets 被 EINTR 打断 */
|
||||
sigaction(SIGINT, &sa, NULL);
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
}
|
||||
@@ -113,7 +113,7 @@ static void print_banner(const LigentConfig *c, const Agent *a)
|
||||
/* 存档目录跟二进制走, 和配置同一个原则 */
|
||||
static const char *g_bindir;
|
||||
|
||||
/* 当前对话的谱系:最近一次写出或载入的存档名 (不含 .json).
|
||||
/* 当前对话的谱系: 最近一次写出或载入的存档名 (不含 .json).
|
||||
* 下次存档就在它后面接一级分支, 于是文件名本身就记录了分支关系. */
|
||||
static char *g_lineage;
|
||||
|
||||
@@ -153,7 +153,7 @@ static void auto_save(Agent *a, LigentConfig *c, const char *why,
|
||||
|
||||
char *path = NULL, *name = NULL;
|
||||
char err[512];
|
||||
/* meta 改过就另起一根:<原名>_m<3位>, parent 记空 */
|
||||
/* meta 改过就另起一根: <原名>_m<3位>, parent 记空 */
|
||||
int meta = meta_reroot_pending();
|
||||
if (archive_export(a->messages, g_bindir ? g_bindir : ".", g_lineage,
|
||||
prefix, meta, a->started_at, time(NULL),
|
||||
@@ -190,12 +190,12 @@ static void apply_model(Agent *a, LigentConfig *c, const char *name)
|
||||
printf("%s", tf("repl.model.switched", "model", c->model, NULL));
|
||||
}
|
||||
|
||||
/* /model 不带参数:拉一次在线列表, 打印出来, 然后用**普通输入行**问要哪个.
|
||||
/* /model 不带参数: 拉一次在线列表, 打印出来, 然后用**普通输入行**问要哪个.
|
||||
*
|
||||
* 刻意不用"光标选择"那种菜单:一是它会用 ANSI 反复重画、把 stdout 搅乱
|
||||
* 刻意不用"光标选择"那种菜单: 一是它会用 ANSI 反复重画、把 stdout 搅乱
|
||||
* (重定向、日志、粘贴都会跟着遭殃), 二是**没法输入列表里没有的东西** ——
|
||||
* 而"列表里没有"恰恰很常见:内部模型、灰度模型、服务端没列出来的别名.
|
||||
* 所以这里是 /import 那一套:列表打在上面, 输入行里既可以填序号, 也可以
|
||||
* 而"列表里没有"恰恰很常见: 内部模型、灰度模型、服务端没列出来的别名.
|
||||
* 所以这里是 /import 那一套: 列表打在上面, 输入行里既可以填序号, 也可以
|
||||
* 直接写模型名, 回车取消.
|
||||
*
|
||||
* 拉不到列表就明说原因, 并提示还能直接用 /model <名字> —— 不能因为网络不行
|
||||
@@ -267,7 +267,7 @@ static int include_can_image(void *ud)
|
||||
Agent *a = ud;
|
||||
agent_ensure_caps(a);
|
||||
/* 只有**明确不支持**才拦. 未测 (探测被关掉或结论不确定)按支持放行,
|
||||
* 和别处一个原则:宁可让服务端说不行, 也不错误地藏掉东西. */
|
||||
* 和别处一个原则: 宁可让服务端说不行, 也不错误地藏掉东西. */
|
||||
return a->caps.vision != CAP_NO;
|
||||
}
|
||||
|
||||
@@ -344,7 +344,7 @@ static void cmd_export(Agent *a, LigentConfig *c)
|
||||
free(name);
|
||||
}
|
||||
|
||||
/* 按名字找一份存档:支持完整文件名、不含 .json 的名字、以及唯一前缀 */
|
||||
/* 按名字找一份存档: 支持完整文件名、不含 .json 的名字、以及唯一前缀 */
|
||||
static const ArchiveEntry *find_entry(const ArchiveEntry *list, size_t n,
|
||||
const char *want)
|
||||
{
|
||||
@@ -361,9 +361,9 @@ static const ArchiveEntry *find_entry(const ArchiveEntry *list, size_t n,
|
||||
return hit;
|
||||
}
|
||||
|
||||
/* 载入存档:给了名字就直接载入, 否则交互式选一份.
|
||||
/* 载入存档: 给了名字就直接载入, 否则交互式选一份.
|
||||
* 只把历史装回去, 不重放历史内容. */
|
||||
/* 问一个是非题, (Y/n) 语义:直接回车算"是".
|
||||
/* 问一个是非题, (Y/n) 语义: 直接回车算"是".
|
||||
* 非终端时没人能回答, 按默认值返回 1 —— 和 confirm_tool 一致. */
|
||||
static int ask_yes_no(const char *question)
|
||||
{
|
||||
@@ -384,7 +384,7 @@ static int ask_yes_no(const char *question)
|
||||
|
||||
/* /import 载入后用户可能想看看这段历史到底聊了什么.
|
||||
*
|
||||
* 这里是**完整**转储:不截断正文、不省略工具参数、不砍总量. 既然用户
|
||||
* 这里是**完整**转储: 不截断正文、不省略工具参数、不砍总量. 既然用户
|
||||
* 明确回答了 Y, 就该看到全部 —— 截一半反而会让人以为历史里就这么点东西.
|
||||
* 所以只做排版, 不做取舍.
|
||||
*
|
||||
@@ -530,10 +530,10 @@ static void cmd_import(Agent *a, const char *arg)
|
||||
}
|
||||
char *loaded = xstrdup(pick->name);
|
||||
agent_replace_history(a, msgs); /* 取得所有权 */
|
||||
/* 载入之后再数:换成和 /history 一样的口径 (不含 system),
|
||||
/* 载入之后再数: 换成和 /history 一样的口径 (不含 system),
|
||||
* 否则"已载入 N 条"和"历史消息 M 条"会差 1, 看着像出了错 */
|
||||
size_t count = agent_conversation_len(a);
|
||||
/* 输入历史不跟着清:它现在是一份持久的、跨会话的用户资源 (像 bash 的
|
||||
/* 输入历史不跟着清: 它现在是一份持久的、跨会话的用户资源 (像 bash 的
|
||||
* HISTFILE), 记的是"我在 ligent> 敲过什么", 和当前装的是哪段对话无关. */
|
||||
set_lineage(loaded); /* 之后的分支都挂在它下面 */
|
||||
printf("%s", tf("repl.import.done", "name", loaded,
|
||||
@@ -551,7 +551,7 @@ static void repl_help(void)
|
||||
fputs(t("repl.help"), stdout);
|
||||
}
|
||||
|
||||
/* 带参数命令的匹配:/name 之后必须是空白或字符串结束.
|
||||
/* 带参数命令的匹配: /name 之后必须是空白或字符串结束.
|
||||
*
|
||||
* 只用前缀匹配是不够的 —— 那样 "/modelxxx" 会被当成 "/model" 并把 xxx
|
||||
* 当作模型名去切模型, "/thinkfoo" 也会落进 /think 分支. 命中返回参数起点
|
||||
@@ -583,7 +583,7 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line)
|
||||
auto_save(a, c, t("repl.why.before_reset"), NULL);
|
||||
agent_reset(a);
|
||||
/* 刚存的是"上一段"对话, 和新开的这段没关系了, 所以把判重状态清掉.
|
||||
* 不清的话会漏存:判重只看 (条数, started_at), 而 time() 只有秒级
|
||||
* 不清的话会漏存: 判重只看 (条数, started_at), 而 time() 只有秒级
|
||||
* 精度 —— 同一秒里重置、又聊出同样多条消息, 退出时就会被当成
|
||||
* "内容没变"而跳过存档. */
|
||||
g_saved_count = 0;
|
||||
@@ -631,12 +631,12 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line)
|
||||
? c->reasoning_effort : t("cfg.val.server_default"));
|
||||
}
|
||||
} else if (strcmp(cmd, "/meta") == 0) {
|
||||
/* 进 meta 前先留一份保底存档:里面全是不可逆的删改.
|
||||
* 用 tmp_ 前缀, 免得它变成以后分支的祖先. */
|
||||
/* 进 meta 前先留一份保底存档: 里面全是不可逆的删改.
|
||||
* 用 tmp_ 前缀, 以免它变成以后分支的祖先. */
|
||||
auto_save(a, c, t("repl.why.before_meta"), ARCHIVE_TMP_PREFIX);
|
||||
int r = meta_session(a, c);
|
||||
if (r == 1) {
|
||||
/* 历史被改过:/back 的撤销栈基于旧历史, 作废;
|
||||
/* 历史被改过: /back 的撤销栈基于旧历史, 作废;
|
||||
* 存档判重也清掉, 保证退出时一定存下这个新版本. */
|
||||
back_undo_clear();
|
||||
g_saved_count = 0;
|
||||
@@ -723,14 +723,14 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line)
|
||||
}
|
||||
} else if ((arg = cmd_arg(cmd, "/shell")) != NULL) {
|
||||
if (*arg) {
|
||||
/* 带参数:直接执行它, 不进入交互式 shell */
|
||||
/* 带参数: 直接执行它, 不进入交互式 shell */
|
||||
JsonValue *args = json_new_object();
|
||||
json_object_set_string(args, "command", arg);
|
||||
int is_err = 0;
|
||||
char *r = tool_execute(TOOL_RUN_COMMAND, args, &is_err);
|
||||
json_free(args);
|
||||
puts(r);
|
||||
char *msg = xasprintf("我在 shell 里执行了:%s\n\n结果是:\n%s",
|
||||
char *msg = xasprintf("我在 shell 里执行了: %s\n\n结果是: \n%s",
|
||||
arg, r);
|
||||
agent_add_user(a, msg);
|
||||
free(msg);
|
||||
@@ -746,7 +746,7 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line)
|
||||
} else {
|
||||
if (rec[0]) {
|
||||
char *msg = xasprintf(
|
||||
"我在交互式 shell 里做了以下操作 (shell 退出码 %d):\n\n"
|
||||
"我在交互式 shell 里做了以下操作 (shell 退出码 %d): \n\n"
|
||||
"```console\n%s\n```\n", code, rec);
|
||||
agent_add_user(a, msg);
|
||||
printf("\n%s\n",
|
||||
@@ -792,7 +792,7 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line)
|
||||
* 而下面列出来的是**含** system 的全部消息 —— 两个数不一样, 所以必须
|
||||
* 说清楚, 否则用户会以为打印漏了或者多了一条.
|
||||
*
|
||||
* 为什么要连 system 一起列:那是"模型被要求怎么做"的原文, 想核对提示词
|
||||
* 为什么要连 system 一起列: 那是"模型被要求怎么做"的原文, 想核对提示词
|
||||
* 时只有这里能看到全文 (/config 里那行会把多行提示词挤成一团). */
|
||||
printf("%s", tf("repl.history.count", "n",
|
||||
i18n_num((long long)agent_conversation_len(a)), NULL));
|
||||
@@ -813,12 +813,12 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line)
|
||||
/* 输入历史文件; NULL 表示禁用 (--no-history). */
|
||||
static char *g_hist_path = NULL;
|
||||
|
||||
/* 提示符保持纯文本:绿色由行编辑器统一加在整个输入行 (提示符 + 你敲的字)上.
|
||||
/* 提示符保持纯文本: 绿色由行编辑器统一加在整个输入行 (提示符 + 你敲的字)上.
|
||||
* 在这里塞颜色的话, 提示符自带的 reset 会让后面的输入掉回默认色. */
|
||||
#define PROMPT_MAIN "ligent> "
|
||||
#define PROMPT_CONT " ... "
|
||||
|
||||
/* 读一条完整输入. 支持行尾反斜杠续行:每段都由行编辑器独立编辑,
|
||||
/* 读一条完整输入. 支持行尾反斜杠续行: 每段都由行编辑器独立编辑,
|
||||
* 因此方向键、Home/End、历史在续行的每一段里都可用. */
|
||||
static char *read_prompt_line(const char *prompt, int *interrupted)
|
||||
{
|
||||
@@ -839,7 +839,7 @@ static char *read_prompt_line(const char *prompt, int *interrupted)
|
||||
sb_free(&sb);
|
||||
return NULL;
|
||||
}
|
||||
/* EOF:缓冲区里还有内容就把它交出去, 否则终止 */
|
||||
/* EOF: 缓冲区里还有内容就把它交出去, 否则终止 */
|
||||
if (sb.len > 0) {
|
||||
if (sb.data[sb.len - 1] != '\n') sb_putc(&sb, '\n');
|
||||
return sb_detach(&sb);
|
||||
@@ -863,7 +863,7 @@ static char *read_prompt_line(const char *prompt, int *interrupted)
|
||||
free(seg);
|
||||
if (!cont) {
|
||||
sb_putc(&sb, '\n');
|
||||
/* 历史记的是"这一整条输入":多行续行算一条, 上下键能整条召回.
|
||||
/* 历史记的是"这一整条输入": 多行续行算一条, 上下键能整条召回.
|
||||
* 续行的中间段不单独记. 这里借尾部那个换行临时当结束符. */
|
||||
if (sb.len > 1) {
|
||||
sb.data[sb.len - 1] = '\0';
|
||||
@@ -930,7 +930,7 @@ int main(int argc, char **argv)
|
||||
const char *explicit_config = NULL;
|
||||
char err[512];
|
||||
|
||||
/* 界面语言:--lang > LIGENT_LANG > LC_ALL/LC_MESSAGES/LANG > 英文.
|
||||
/* 界面语言: --lang > LIGENT_LANG > LC_ALL/LC_MESSAGES/LANG > 英文.
|
||||
* 先粗扫一遍 argv, 好让 --help 和 --lang 的先后顺序不影响结果 ——
|
||||
* 解析循环一遇到 --help 就直接返回, 那之后再写 --lang 就来不及了. */
|
||||
{
|
||||
@@ -1054,7 +1054,7 @@ int main(int argc, char **argv)
|
||||
} else if (strcmp(arg, "--stream") == 0) {
|
||||
cli.stream_set = 1; cli.stream_val = 1;
|
||||
} else if (strcmp(arg, "doctor") == 0 && !cli.doctor && cli.prompt.len == 0) {
|
||||
cli.doctor = 1; /* 子命令:ligent doctor */
|
||||
cli.doctor = 1; /* 子命令: ligent doctor */
|
||||
} else if (strcmp(arg, "--offline") == 0) {
|
||||
cli.offline = 1;
|
||||
} else if (strcmp(arg, "--no-confirm") == 0) {
|
||||
@@ -1088,7 +1088,7 @@ int main(int argc, char **argv)
|
||||
fprintf(stderr, "%s", tf("cli.err.bad_lang", "value", val, NULL));
|
||||
return 2;
|
||||
}
|
||||
i18n_init(l); /* "auto" 也是 -1:交给环境探测 */
|
||||
i18n_init(l); /* "auto" 也是 -1: 交给环境探测 */
|
||||
} else if (strcmp(arg, "--print-config") == 0) {
|
||||
cli.print_config = 1;
|
||||
} else if (strcmp(arg, "--") == 0) {
|
||||
@@ -1107,7 +1107,7 @@ int main(int argc, char **argv)
|
||||
#undef NEED_VAL
|
||||
}
|
||||
|
||||
/* 配置:默认值 → 文件 → 环境变量 → 命令行 */
|
||||
/* 配置: 默认值 → 文件 → 环境变量 → 命令行 */
|
||||
/* doctor 要能在"配置本身坏了"的时候跑起来 —— 那正是最需要它的时刻 */
|
||||
char cfg_error[512];
|
||||
cfg_error[0] = '\0';
|
||||
@@ -1198,7 +1198,7 @@ int main(int argc, char **argv)
|
||||
g_bindir = cfg.config_dir;
|
||||
image_set_bindir(cfg.config_dir); /* 理由见 run_repl 里那段注释 */
|
||||
|
||||
/* 输入历史文件:命令行 > 环境变量 > <二进制目录>/ligent.history.
|
||||
/* 输入历史文件: 命令行 > 环境变量 > <二进制目录>/ligent.history.
|
||||
* 和配置一样, 默认只放二进制旁边, 不碰用户级/系统级目录. */
|
||||
{
|
||||
const char *env = getenv("LIGENT_HISTORY");
|
||||
@@ -1211,7 +1211,7 @@ int main(int argc, char **argv)
|
||||
} else {
|
||||
g_hist_path = xasprintf("%s/ligent.history", cfg.config_dir);
|
||||
}
|
||||
/* 读不回来不是错误:第一次跑本来就没有这个文件, 只读的目录也不该
|
||||
/* 读不回来不是错误: 第一次跑本来就没有这个文件, 只读的目录也不该
|
||||
* 拦住 REPL. 加载失败静默降级成"这次没有历史". */
|
||||
if (g_hist_path) line_history_load(g_hist_path, LIGENT_HISTORY_KEEP);
|
||||
}
|
||||
@@ -1223,7 +1223,7 @@ int main(int argc, char **argv)
|
||||
exit_code = rc == 0 ? 0 : 1;
|
||||
auto_save(&agent, &cfg, t("repl.why.exit"), NULL);
|
||||
} else if (!isatty(fileno(stdin))) {
|
||||
/* 管道输入:把 stdin 当任务 */
|
||||
/* 管道输入: 把 stdin 当任务 */
|
||||
char *text = read_stdin_all();
|
||||
char *trimmed = str_trim(text);
|
||||
if (*trimmed) {
|
||||
|
||||
+34
-34
@@ -1,6 +1,6 @@
|
||||
/* markdown.c - 流式 Markdown 渲染状态机
|
||||
*
|
||||
* 核心约束:SSE 的分片位置是任意的, 一个标记 (`**`、行首的 `#`、`[`)
|
||||
* 核心约束: SSE 的分片位置是任意的, 一个标记 (`**`、行首的 `#`、`[`)
|
||||
* 完全可能被切成两片. 因此遇到"可能是标记前缀"的尾部时不能立即输出,
|
||||
* 必须把这段字节留在缓冲区里等下一个分片凑齐后再判定.
|
||||
*/
|
||||
@@ -13,26 +13,26 @@
|
||||
|
||||
/* 样式定义 (硬编码 SGR, 不依赖 terminfo)
|
||||
*
|
||||
* **只用 16 色范围内的码**:前景 30-37, 加粗/暗淡/斜体用 1/2/3.
|
||||
* **只用 16 色范围内的码**: 前景 30-37, 加粗/暗淡/斜体用 1/2/3.
|
||||
* 不用 90-97 那一段 (aixterm 的"亮色")—— 它虽然到处都是, 但严格讲不属于
|
||||
* 最窄的 16 色 ANSI, 而这段代码是硬编码 SGR、不做终端能力探测的 (连 terminfo
|
||||
* 都不查). 既然不探测, 就该待在**所有终端都认**的那一档里.
|
||||
*
|
||||
* 原来链接用的是亮蓝 (aixterm 的 94), 是这里唯一一个越界的码. 改成
|
||||
* "\033[4;36m" (下划线 + 青):链接在终端里的传统表示本来就是下划线,
|
||||
* "\033[4;36m" (下划线 + 青): 链接在终端里的传统表示本来就是下划线,
|
||||
* 而且青色和行内代码、列表符号同色系, 视觉上"这些是标记不是正文"是一致的.
|
||||
* 更重要的是下划线是**属性** (SGR 4), 比颜色更可靠 —— 单色终端、色盲用户、
|
||||
* 以及把颜色重映射过的主题里, 下划线都还在.
|
||||
*
|
||||
* (这里不写出那个越界码的字面量, 免得以后 grep `\[9[0-7]` 做审计时被注释
|
||||
* (这里不写出那个越界码的字面量, 以免以后 grep `\[9[0-7]` 做审计时被注释
|
||||
* 里的这一行报成假阳性. ) */
|
||||
#define MD_RESET "\033[0m"
|
||||
#define MD_BOLD "\033[1m"
|
||||
#define MD_DIM "\033[2m"
|
||||
#define MD_ITALIC "\033[3m"
|
||||
#define MD_CODE "\033[36m" /* 行内代码:青 */
|
||||
#define MD_CODE "\033[36m" /* 行内代码: 青 */
|
||||
#define MD_BULLET "\033[36m"
|
||||
#define MD_LINK "\033[4;36m" /* 链接文字:下划线 + 青 */
|
||||
#define MD_LINK "\033[4;36m" /* 链接文字: 下划线 + 青 */
|
||||
#define MD_H1 "\033[1;36m" /* 粗体青 */
|
||||
#define MD_H2 "\033[1;35m" /* 粗体品红 */
|
||||
#define MD_H3 "\033[1;34m" /* 粗体蓝 */
|
||||
@@ -41,7 +41,7 @@
|
||||
/* 等待更多数据 (当前字节可能是一个被切断的标记) */
|
||||
#define MD_NEED_MORE ((size_t)-1)
|
||||
|
||||
/* 待处理缓冲上限:遇到始终等不到结束标记的超长行时直接吐出, 保证内存有界 */
|
||||
/* 待处理缓冲上限: 遇到始终等不到结束标记的超长行时直接吐出, 保证内存有界 */
|
||||
#define MD_MAX_PENDING (64 * 1024)
|
||||
/* 候选表格行超过这个长度就不再当表格等下去, 避免长行被卡住 */
|
||||
#define MD_MAX_TABLE_LINE 4096
|
||||
@@ -72,7 +72,7 @@ struct MdRenderer {
|
||||
int table_rows;
|
||||
|
||||
/* 置位后表示"不会再有新数据", 所有挂起都必须就地解开.
|
||||
* 这是"输出与分片方式无关"的关键:挂起只允许发生在还有后续数据时,
|
||||
* 这是"输出与分片方式无关"的关键: 挂起只允许发生在还有后续数据时,
|
||||
* 到 EOF 一律按固定规则判定, 决策因此只依赖字节流本身. */
|
||||
int eof;
|
||||
};
|
||||
@@ -109,7 +109,7 @@ static void close_line_styles(MdRenderer *r)
|
||||
/* 显示宽度相关工具已移到 util.c (行编辑器也要用) */
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 表格:解析 */
|
||||
/* 表格: 解析 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
typedef struct {
|
||||
@@ -155,7 +155,7 @@ static void row_split(const char *line, size_t n, TblRow *out)
|
||||
}
|
||||
}
|
||||
|
||||
/* 分隔单元格::?-+:? */
|
||||
/* 分隔单元格: :?-+:? */
|
||||
static int is_delim_cell(const char *c, size_t n)
|
||||
{
|
||||
size_t i = 0, dashes = 0;
|
||||
@@ -166,7 +166,7 @@ static int is_delim_cell(const char *c, size_t n)
|
||||
return i == n;
|
||||
}
|
||||
|
||||
/* 表头行 + 分隔行是否构成合法表格:
|
||||
/* 表头行 + 分隔行是否构成合法表格:
|
||||
* 两者单元格数必须相同, 且第二行每格都是 :?-+:?
|
||||
* (这条校验同时挡掉了 "cat a | grep b" 跟着一行 "--------" 的误判) */
|
||||
static int table_head_ok(const char *h, size_t hn, const char *d, size_t dn)
|
||||
@@ -191,7 +191,7 @@ static int count_pipes(const char *s, size_t n)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 表格:渲染 */
|
||||
/* 表格: 渲染 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void emit_padded(MdRenderer *r, const char *cell, size_t width, TblAlign al)
|
||||
@@ -320,7 +320,7 @@ static void table_flush(MdRenderer *r)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 块级语法:只在行首调用 */
|
||||
/* 块级语法: 只在行首调用 */
|
||||
/* 返回消耗的字节数; 0 表示"不是块级语法, 按普通字符处理";
|
||||
* MD_NEED_MORE 表示需要更多数据才能判定 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -333,8 +333,8 @@ static size_t scan_line(const char *s, size_t len, size_t i, int *has_nl)
|
||||
return j - i;
|
||||
}
|
||||
|
||||
/* 规则表:从上往下依次尝试.
|
||||
* 关键约定:每条规则只有在"确实消费了字节"时才 return;
|
||||
/* 规则表: 从上往下依次尝试.
|
||||
* 关键约定: 每条规则只有在"确实消费了字节"时才 return;
|
||||
* 判定为"不适用"时必须继续往下走, 绝不能中途 return 0 ——
|
||||
* 否则会跳过后面所有规则 (早期版本正是因此让表格探测吃掉了列表规则). */
|
||||
static size_t try_block(MdRenderer *r, size_t i)
|
||||
@@ -342,7 +342,7 @@ static size_t try_block(MdRenderer *r, size_t i)
|
||||
const char *s = r->in.data;
|
||||
size_t len = r->in.len;
|
||||
|
||||
/* ---- 围栏代码块内部:只认闭合行, 其余一律当代码内容 ---- */
|
||||
/* ---- 围栏代码块内部: 只认闭合行, 其余一律当代码内容 ---- */
|
||||
if (r->in_fence) {
|
||||
if (s[i] != r->fence_char) return 0;
|
||||
size_t run = 0;
|
||||
@@ -379,7 +379,7 @@ static size_t try_block(MdRenderer *r, size_t i)
|
||||
return ln + (has_nl ? 1 : 0);
|
||||
}
|
||||
table_flush(r);
|
||||
/* 表格到此结束. 注意不 return:当前行可能是个标题/列表,
|
||||
/* 表格到此结束. 注意不 return: 当前行可能是个标题/列表,
|
||||
* 要继续往下走让它被正常渲染. */
|
||||
}
|
||||
|
||||
@@ -389,7 +389,7 @@ static size_t try_block(MdRenderer *r, size_t i)
|
||||
while (i + run < len && s[i + run] == '#') run++;
|
||||
if (i + run >= len && !r->eof) {
|
||||
if (run <= 6) return MD_NEED_MORE;
|
||||
/* run > 6:确定不是标题, 继续往下判断 */
|
||||
/* run > 6: 确定不是标题, 继续往下判断 */
|
||||
} else if (run >= 1 && run <= 6 &&
|
||||
(s[i + run] == ' ' || s[i + run] == '\t')) {
|
||||
static const char *codes[6] = {
|
||||
@@ -432,14 +432,14 @@ static size_t try_block(MdRenderer *r, size_t i)
|
||||
else { r->line_start = 0; }
|
||||
return (j - i) + (has_nl ? 1 : 0);
|
||||
}
|
||||
/* 少于 3 个反引号:交给行内代码逻辑 */
|
||||
/* 少于 3 个反引号: 交给行内代码逻辑 */
|
||||
}
|
||||
|
||||
/* ---- 无序列表 / 分隔线 ---- */
|
||||
if (s[i] == '-' || s[i] == '*' || s[i] == '+') {
|
||||
if (i + 1 >= len) {
|
||||
if (!r->eof) return MD_NEED_MORE; /* 可能是 "- " 列表或 "---" */
|
||||
/* EOF 且只剩这一个字符:当字面量 */
|
||||
/* EOF 且只剩这一个字符: 当字面量 */
|
||||
} else {
|
||||
if (s[i + 1] == ' ') {
|
||||
/* 保留模型输出的原始标记字符并上色, 不替换成 • */
|
||||
@@ -471,13 +471,13 @@ static size_t try_block(MdRenderer *r, size_t i)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 表格起始:表头行 + 分隔行. 放在最后, 因为它需要看两行 ---- */
|
||||
/* ---- 表格起始: 表头行 + 分隔行. 放在最后, 因为它需要看两行 ---- */
|
||||
{
|
||||
int nl1;
|
||||
size_t ln1 = scan_line(s, len, i, &nl1);
|
||||
|
||||
/* 表格必须顶格以 '|' 开头.
|
||||
* 这个判断在行的第 0 个字节就能做出 —— 这是关键:只要"是否挂起"
|
||||
* 这个判断在行的第 0 个字节就能做出 —— 这是关键: 只要"是否挂起"
|
||||
* 依赖行内更靠后的内容 (例如"整行是否含竖线"), 那么当分片边界
|
||||
* 落在竖线之前时, 行的前半段已经被当成普通文本输出了, 再也无法回头,
|
||||
* 结果就会随分片方式而变. */
|
||||
@@ -550,7 +550,7 @@ static void feed_enabled(MdRenderer *r, const char *text, size_t len)
|
||||
continue;
|
||||
}
|
||||
|
||||
/* ---- 围栏代码块内部:逐字原样输出 ---- */
|
||||
/* ---- 围栏代码块内部: 逐字原样输出 ---- */
|
||||
if (r->in_fence) {
|
||||
put(r, s + i, 1);
|
||||
r->line_start = 0;
|
||||
@@ -558,7 +558,7 @@ static void feed_enabled(MdRenderer *r, const char *text, size_t len)
|
||||
continue;
|
||||
}
|
||||
|
||||
/* ---- 行内代码内部:只有反引号有意义, 内容不做任何解析 ---- */
|
||||
/* ---- 行内代码内部: 只有反引号有意义, 内容不做任何解析 ---- */
|
||||
if (r->in_code) {
|
||||
if (c == '`') {
|
||||
style(r, MD_DIM);
|
||||
@@ -583,8 +583,8 @@ static void feed_enabled(MdRenderer *r, const char *text, size_t len)
|
||||
continue;
|
||||
}
|
||||
if (remain < 2) {
|
||||
/* 末尾的单个 '*':可能是 '**' 的前半个, 必须等下一个分片.
|
||||
* 只有到 EOF 才能确定后面没有第二个 '*', 此时按固定规则解开:
|
||||
/* 末尾的单个 '*': 可能是 '**' 的前半个, 必须等下一个分片.
|
||||
* 只有到 EOF 才能确定后面没有第二个 '*', 此时按固定规则解开:
|
||||
* 正在斜体就当作收尾, 否则当字面量. */
|
||||
if (!r->eof) break;
|
||||
if (r->in_italic) {
|
||||
@@ -616,19 +616,19 @@ static void feed_enabled(MdRenderer *r, const char *text, size_t len)
|
||||
continue;
|
||||
}
|
||||
|
||||
/* ---- _斜体_:按 CommonMark 规则禁用词内强调, 避免误伤 foo_bar_baz ---- */
|
||||
/* ---- _斜体_: 按 CommonMark 规则禁用词内强调, 避免误伤 foo_bar_baz ---- */
|
||||
if (c == '_') {
|
||||
int prev = (i > 0) ? (unsigned char)s[i - 1] : r->last_ch;
|
||||
if (i + 1 >= r->in.len) {
|
||||
if (!r->eof) break; /* 需要看后一个字符 */
|
||||
put(r, "_", 1); /* EOF:没有后一个字符, 当字面量 */
|
||||
put(r, "_", 1); /* EOF: 没有后一个字符, 当字面量 */
|
||||
r->line_start = 0;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
int next = (unsigned char)s[i + 1];
|
||||
|
||||
/* 与另一个下划线相邻时一律当字面量:
|
||||
/* 与另一个下划线相邻时一律当字面量:
|
||||
* 这样 Python 的 __init__、__main__ 不会被拆成斜体 */
|
||||
if (prev == '_' || next == '_') {
|
||||
put(r, "_", 1);
|
||||
@@ -656,11 +656,11 @@ static void feed_enabled(MdRenderer *r, const char *text, size_t len)
|
||||
continue;
|
||||
}
|
||||
|
||||
/* ---- [链接文字]:蓝色高亮, 其余部分 (含 (url))原样保留 ---- */
|
||||
/* ---- [链接文字]: 蓝色高亮, 其余部分 (含 (url))原样保留 ---- */
|
||||
if (c == '[') {
|
||||
if (remain < 2) {
|
||||
if (!r->eof) break; /* 需要看后一个字符 */
|
||||
put(r, "[", 1); /* EOF:当字面量 */
|
||||
put(r, "[", 1); /* EOF: 当字面量 */
|
||||
r->line_start = 0;
|
||||
i++;
|
||||
continue;
|
||||
@@ -716,7 +716,7 @@ static void feed_enabled(MdRenderer *r, const char *text, size_t len)
|
||||
r->in.data[r->in.len] = '\0';
|
||||
}
|
||||
|
||||
/* 兜底:等待中的残留过大就原样吐出, 保证内存有界 */
|
||||
/* 兜底: 等待中的残留过大就原样吐出, 保证内存有界 */
|
||||
if (r->in.len > MD_MAX_PENDING) {
|
||||
close_line_styles(r);
|
||||
put(r, r->in.data, r->in.len);
|
||||
@@ -740,12 +740,12 @@ void md_finish(MdRenderer *r)
|
||||
{
|
||||
if (!r) return;
|
||||
if (r->enabled) {
|
||||
/* 关键:置 eof 后把残留重新过一遍状态机, 让所有挂起按固定规则解开.
|
||||
/* 关键: 置 eof 后把残留重新过一遍状态机, 让所有挂起按固定规则解开.
|
||||
* 这样"一次性喂"与"分片喂"在结尾处的结果必然一致. */
|
||||
r->eof = 1;
|
||||
feed_enabled(r, "", 0);
|
||||
if (r->in_table) table_flush(r);
|
||||
if (r->in.len > 0) { /* 兜底:正常不该走到 */
|
||||
if (r->in.len > 0) { /* 兜底: 正常不该走到 */
|
||||
close_line_styles(r);
|
||||
put(r, r->in.data, r->in.len);
|
||||
r->in.len = 0;
|
||||
|
||||
+4
-4
@@ -1,13 +1,13 @@
|
||||
/* markdown.h - 面向终端输出的流式 Markdown 渲染器
|
||||
*
|
||||
* 设计目标:在"纯行式 REPL"的前提下把常见 Markdown 变成 ANSI 样式,
|
||||
* 设计目标: 在"纯行式 REPL"的前提下把常见 Markdown 变成 ANSI 样式,
|
||||
* 且必须能处理任意分片 (SSE 的一小片文本可能把一个标记切成两半).
|
||||
*
|
||||
* 渲染:
|
||||
* 渲染:
|
||||
* 块级 # ~ ###### 标题、> 引用、- * + 无序列表、--- 分隔线、| 表格
|
||||
* 行内 **粗体**、*斜体* / _斜体_、`行内代码`、[链接文字] (蓝色高亮)
|
||||
*
|
||||
* 不渲染 (原样保留, 一个字节都不丢):
|
||||
* 不渲染 (原样保留, 一个字节都不丢):
|
||||
* 围栏代码块 (含 ``` 标记本身)、图片 、链接的 (url) 部分、
|
||||
* 行内代码的内容
|
||||
*/
|
||||
@@ -29,7 +29,7 @@ void md_set_out(MdRenderer *r, FILE *out);
|
||||
/* 喂入一段文本 (NUL 结尾). 内部保持状态, 可被任意切分 */
|
||||
void md_feed(MdRenderer *r, const char *text);
|
||||
|
||||
/* 一条消息结束:冲刷残留、闭合未闭合的样式 */
|
||||
/* 一条消息结束: 冲刷残留、闭合未闭合的样式 */
|
||||
void md_finish(MdRenderer *r);
|
||||
|
||||
void md_free(MdRenderer *r);
|
||||
|
||||
+28
-28
@@ -1,10 +1,10 @@
|
||||
/* meta.c - /meta:用模型辅助编辑对话历史
|
||||
/* meta.c - /meta: 用模型辅助编辑对话历史
|
||||
*
|
||||
* 结构上是一个"第二个 Agent":它有自己的 messages (meta 自己的会话)、
|
||||
* 结构上是一个"第二个 Agent": 它有自己的 messages (meta 自己的会话)、
|
||||
* 自己的 tools、自己的 system 提示词, 但共用主对话的 LLM 配置.
|
||||
* agent_run() 一行不改就能跑它 —— 工具分发已经从硬编码改成了函数指针.
|
||||
*
|
||||
* 三条硬约束 (决定了下面所有工具的形状):
|
||||
* 三条硬约束 (决定了下面所有工具的形状):
|
||||
* 1. assistant 带 tool_calls 时, 后面必须紧跟一一对应的 tool 消息;
|
||||
* 2. system 必须在最前, 且不归 meta 管 (那是 /system 的事);
|
||||
* 3. 改完之后, 历史必须仍是一段合法的对话.
|
||||
@@ -45,7 +45,7 @@ static const char *META_SYSTEM_PROMPT =
|
||||
" 不要顺手把整轮都删了. \n"
|
||||
"3. 用户没要求压缩就不要压缩 —— 压缩会丢证据. \n"
|
||||
"4. 每次改动后消息编号会重排. 要继续改, 先重新 list_rounds. \n"
|
||||
"5. 改完汇报:改了什么、为什么、增减多少. 不要复述历史. \n"
|
||||
"5. 改完汇报: 改了什么、为什么、增减多少. 不要复述历史. \n"
|
||||
"\n"
|
||||
"## 红线\n"
|
||||
"- 带 tool_calls 的 assistant 消息和紧随其后的 tool 结果是不可拆的整体, 否则会造成400错误. \n"
|
||||
@@ -59,7 +59,7 @@ static const char *META_SYSTEM_PROMPT =
|
||||
"\n"
|
||||
"用中文回复 (除非用户用了别的语言). ";
|
||||
|
||||
/* 快照栈:每次改动前压一份, 供 /undo 和 undo_last_edit 回退 */
|
||||
/* 快照栈: 每次改动前压一份, 供 /undo 和 undo_last_edit 回退 */
|
||||
#define META_UNDO_MAX 10
|
||||
|
||||
/* 单条消息在目录/明细里的预览长度 */
|
||||
@@ -103,7 +103,7 @@ int meta_reroot_pending(void) { return g_edited_ever; }
|
||||
void meta_reroot_clear(void) { g_edited_ever = 0; }
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 历史视图:把消息按"轮"切开 */
|
||||
/* 历史视图: 把消息按"轮"切开 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static const char *role_of(const JsonValue *m)
|
||||
@@ -136,7 +136,7 @@ int meta_rounds_build(const JsonValue *messages, MetaRounds *out)
|
||||
size_t n = json_array_size(messages);
|
||||
if (n == 0) return 0;
|
||||
out->head = (strcmp(role_of(json_array_get(messages, 0)), "system") == 0) ? 1 : 0;
|
||||
if (out->head >= n) return 0; /* 只有一条 system:没有可编辑的东西 */
|
||||
if (out->head >= n) return 0; /* 只有一条 system: 没有可编辑的东西 */
|
||||
|
||||
size_t cap = 8;
|
||||
out->starts = xmalloc(cap * sizeof(size_t));
|
||||
@@ -145,7 +145,7 @@ int meta_rounds_build(const JsonValue *messages, MetaRounds *out)
|
||||
|
||||
/* 轮起点必须用 agent_opens_round —— 和主循环 (/back、请求前的结构体检)
|
||||
* 同一个判据. 以前这里是"任何 role == user"都算新的一轮, 于是 read_image
|
||||
* 补的那条"只有图片附件"的 user 消息会被当成一轮的开头:/meta 报出的轮号
|
||||
* 补的那条"只有图片附件"的 user 消息会被当成一轮的开头: /meta 报出的轮号
|
||||
* 和主循环的口径对不上, 模型照着轮号删下去, 删掉的也不是它以为的那一轮. */
|
||||
for (size_t i = out->head + 1; i < n; i++) {
|
||||
if (!agent_opens_round(json_array_get(messages, i))) continue;
|
||||
@@ -182,7 +182,7 @@ static void snap_push(MetaCtx *c)
|
||||
if (c->snap_n < META_UNDO_MAX) {
|
||||
c->snap[c->snap_n] = copy;
|
||||
} else {
|
||||
/* 满了:丢掉最老的一份, 整体前移 */
|
||||
/* 满了: 丢掉最老的一份, 整体前移 */
|
||||
json_free(c->snap[0]);
|
||||
memmove(c->snap, c->snap + 1, (META_UNDO_MAX - 1) * sizeof(JsonValue *));
|
||||
c->snap[META_UNDO_MAX - 1] = copy;
|
||||
@@ -280,7 +280,7 @@ static int arg_range(const JsonValue *args, long lo, long hi,
|
||||
return *from <= *to;
|
||||
}
|
||||
|
||||
/* 改动之后的统一收尾:打标记、清掉和旧历史绑定的状态 */
|
||||
/* 改动之后的统一收尾: 打标记、清掉和旧历史绑定的状态 */
|
||||
static void after_edit(MetaCtx *c)
|
||||
{
|
||||
c->edited = 1;
|
||||
@@ -306,7 +306,7 @@ static char *m_list_rounds(const JsonValue *args, int *is_error)
|
||||
if (!arg_range(args, 1, (long)r.count, &from, &to)) {
|
||||
meta_rounds_free(&r);
|
||||
*is_error = 1;
|
||||
return xstrdup("from/to 不合法:轮次编号从 1 开始. ");
|
||||
return xstrdup("from/to 不合法: 轮次编号从 1 开始. ");
|
||||
}
|
||||
|
||||
StrBuf sb;
|
||||
@@ -343,7 +343,7 @@ static char *m_read_messages(const JsonValue *args, int *is_error)
|
||||
long from, to;
|
||||
if (!arg_range(args, 0, (long)total - 1, &from, &to)) {
|
||||
*is_error = 1;
|
||||
return xstrdup("from/to 不合法:消息编号是 #N, 从 0 开始. ");
|
||||
return xstrdup("from/to 不合法: 消息编号是 #N, 从 0 开始. ");
|
||||
}
|
||||
|
||||
StrBuf sb;
|
||||
@@ -378,7 +378,7 @@ static char *m_read_messages(const JsonValue *args, int *is_error)
|
||||
free(l);
|
||||
}
|
||||
}
|
||||
/* 输出上限和普通工具一致, 免得一次读太多把 meta 自己的上下文撑爆 */
|
||||
/* 输出上限和普通工具一致, 以免一次读太多把 meta 自己的上下文撑爆 */
|
||||
if (sb.len > LIGENT_TOOL_OUTPUT_LIMIT) {
|
||||
sb.len = LIGENT_TOOL_OUTPUT_LIMIT;
|
||||
sb.data[sb.len] = '\0';
|
||||
@@ -419,13 +419,13 @@ static char *m_replace_message(const JsonValue *args, int *is_error)
|
||||
return xstrdup("这是 tool 结果, 改它会让模型以为工具返回了别的东西. "
|
||||
"如果内容有问题, 应该改的是产生它的那次调用. ");
|
||||
}
|
||||
/* 结构本身不动:只换 content, tool_calls 原样保留 */
|
||||
/* 结构本身不动: 只换 content, tool_calls 原样保留 */
|
||||
const char *old = json_get_string(m, "content");
|
||||
size_t oldb = old ? strlen(old) : 0;
|
||||
snap_push(c);
|
||||
json_object_set_string(m, "content", content);
|
||||
after_edit(c);
|
||||
return xasprintf("已改写 #%ld (%s):%zu -> %zu 字节. ", i, role, oldb, strlen(content));
|
||||
return xasprintf("已改写 #%ld (%s): %zu -> %zu 字节. ", i, role, oldb, strlen(content));
|
||||
}
|
||||
|
||||
static char *m_delete_messages(const JsonValue *args, int *is_error)
|
||||
@@ -440,7 +440,7 @@ static char *m_delete_messages(const JsonValue *args, int *is_error)
|
||||
return xstrdup("缺少 indices (消息编号数组, 例如 [3,5]). ");
|
||||
}
|
||||
|
||||
/* 先整体校验再动手:宁可一条不改, 也不要改一半 */
|
||||
/* 先整体校验再动手: 宁可一条不改, 也不要改一半 */
|
||||
size_t n = json_array_size(arr);
|
||||
size_t *idx = xmalloc(n * sizeof(size_t));
|
||||
for (size_t k = 0; k < n; k++) {
|
||||
@@ -468,7 +468,7 @@ static char *m_delete_messages(const JsonValue *args, int *is_error)
|
||||
idx[k] = (size_t)i;
|
||||
}
|
||||
|
||||
/* 去重, 并从大到小删, 免得删一个前面就错位 */
|
||||
/* 去重, 并从大到小删, 以免删一个前面就错位 */
|
||||
for (size_t a = 0; a < n; a++)
|
||||
for (size_t b = a + 1; b < n; b++)
|
||||
if (idx[b] > idx[a]) { size_t t = idx[a]; idx[a] = idx[b]; idx[b] = t; }
|
||||
@@ -489,8 +489,8 @@ static char *m_delete_messages(const JsonValue *args, int *is_error)
|
||||
after_edit(c);
|
||||
|
||||
size_t after = history_bytes(msgs);
|
||||
return xasprintf("已删除 %zu 条消息:%zu -> %zu 字节 (-%zu). \n"
|
||||
"注意:如果删掉的是某轮里的提问, 那轮剩下的回答现在没有"
|
||||
return xasprintf("已删除 %zu 条消息: %zu -> %zu 字节 (-%zu). \n"
|
||||
"注意: 如果删掉的是某轮里的提问, 那轮剩下的回答现在没有"
|
||||
"对应的提问了, 必要时把整轮一起删掉. ",
|
||||
uniq, before, after, before - after);
|
||||
}
|
||||
@@ -509,7 +509,7 @@ static char *m_delete_rounds(const JsonValue *args, int *is_error)
|
||||
if (!arg_range(args, 1, (long)r.count, &from, &to)) {
|
||||
meta_rounds_free(&r);
|
||||
*is_error = 1;
|
||||
return xstrdup("from/to 不合法:轮次编号从 1 开始. ");
|
||||
return xstrdup("from/to 不合法: 轮次编号从 1 开始. ");
|
||||
}
|
||||
if (from == 1 && to == (long)r.count) {
|
||||
meta_rounds_free(&r);
|
||||
@@ -531,7 +531,7 @@ static char *m_delete_rounds(const JsonValue *args, int *is_error)
|
||||
after_edit(c);
|
||||
|
||||
size_t after = history_bytes(msgs);
|
||||
return xasprintf("已删除轮 %ld..%ld (%zu 条消息):%zu -> %zu 字节 (-%zu). ",
|
||||
return xasprintf("已删除轮 %ld..%ld (%zu 条消息): %zu -> %zu 字节 (-%zu). ",
|
||||
from, to, cnt, before, after, before - after);
|
||||
}
|
||||
|
||||
@@ -594,7 +594,7 @@ typedef struct {
|
||||
|
||||
static const MetaTool TOOLS[] = {
|
||||
{ "list_rounds",
|
||||
"看这份对话的目录:每轮一行 (轮号、消息编号范围、条数、字节数、开头预览). "
|
||||
"看这份对话的目录: 每轮一行 (轮号、消息编号范围、条数、字节数、开头预览). "
|
||||
"动手前先看它. ",
|
||||
"{\"type\":\"object\",\"properties\":{"
|
||||
"\"from\":{\"type\":\"number\",\"description\":\"起始轮号 (从 1 开始), 默认 1\"},"
|
||||
@@ -680,7 +680,7 @@ char *meta_tools_call(const char *name, const JsonValue *args, int *is_error)
|
||||
for (size_t i = 0; i < TOOL_COUNT; i++)
|
||||
if (strcmp(TOOLS[i].name, name) == 0) return TOOLS[i].fn(args, is_error);
|
||||
*is_error = 1;
|
||||
return xasprintf("未知工具:%s. 可用的只有列表里那几个. ", name);
|
||||
return xasprintf("未知工具: %s. 可用的只有列表里那几个. ", name);
|
||||
}
|
||||
|
||||
int meta_tools_mutating(const char *name)
|
||||
@@ -690,7 +690,7 @@ int meta_tools_mutating(const char *name)
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* 让确认提示说人话:meta 工具的参数字段名和普通工具完全不同,
|
||||
/* 让确认提示说人话: meta 工具的参数字段名和普通工具完全不同,
|
||||
* 内置描述要么打不出来、要么会把整段消息内容刷屏. */
|
||||
static char *meta_describe(const char *name, const JsonValue *args)
|
||||
{
|
||||
@@ -746,7 +746,7 @@ static void meta_help(void)
|
||||
fputs(t("meta.help"), stdout);
|
||||
}
|
||||
|
||||
/* /diff:把当前历史按消息列出来 (只读, 不走模型) */
|
||||
/* /diff: 把当前历史按消息列出来 (只读, 不走模型) */
|
||||
static void meta_diff(Agent *target)
|
||||
{
|
||||
size_t total = json_array_size(target->messages);
|
||||
@@ -776,7 +776,7 @@ int meta_session(Agent *target, LigentConfig *cfg)
|
||||
|
||||
Agent m;
|
||||
agent_init(&m);
|
||||
m.llm = target->llm; /* 有意借用:同一个后端、同一个模型 */
|
||||
m.llm = target->llm; /* 有意借用: 同一个后端、同一个模型 */
|
||||
m.llm.stream = target->llm.stream;
|
||||
m.color = target->color;
|
||||
m.show_tool_output = 1;
|
||||
@@ -791,10 +791,10 @@ int meta_session(Agent *target, LigentConfig *cfg)
|
||||
agent_add_system(&m, sp);
|
||||
free(sp);
|
||||
|
||||
/* 确认提示改说人话:meta 工具的参数字段和普通工具完全不同,
|
||||
/* 确认提示改说人话: meta 工具的参数字段和普通工具完全不同,
|
||||
* 内置描述在那儿要么打不出来、要么会把整段消息内容刷屏. */
|
||||
confirm_set_describer(meta_describe);
|
||||
line_edit_set_ink("\033[35m"); /* 品红:和主提示符 (绿)分开 */
|
||||
line_edit_set_ink("\033[35m"); /* 品红: 和主提示符 (绿)分开 */
|
||||
|
||||
puts(t("meta.enter"));
|
||||
puts(t("meta.enter.hint"));
|
||||
|
||||
+6
-6
@@ -1,9 +1,9 @@
|
||||
/* meta.h - /meta:用模型辅助编辑对话历史
|
||||
/* meta.h - /meta: 用模型辅助编辑对话历史
|
||||
*
|
||||
* 和主对话的区别:主模型是来"干活"的, meta 模型是来"改历史"的.
|
||||
* 和主对话的区别: 主模型是来"干活"的, meta 模型是来"改历史"的.
|
||||
* 它不是那场对话的参与者, 而是拿用户指令去删改那段记录的编辑者.
|
||||
*
|
||||
* 两边工具集完全隔离:meta 会话的 Agent 用的是 meta_tools_schema() 和
|
||||
* 两边工具集完全隔离: meta 会话的 Agent 用的是 meta_tools_schema() 和
|
||||
* meta_exec_tool(), 主模型那边是 tools.c 那套. 主模型就算瞎编一个
|
||||
* delete_rounds 也只会拿到"未知工具".
|
||||
*/
|
||||
@@ -23,10 +23,10 @@ void meta_reroot_clear(void);
|
||||
* 清掉 /back 撤销栈、让退出时必定重新存档). */
|
||||
int meta_session(Agent *target, LigentConfig *cfg);
|
||||
|
||||
/* 供单测用:把一段历史按轮切开. 返回 0 表示"没有可编辑的轮次"
|
||||
/* 供单测用: 把一段历史按轮切开. 返回 0 表示"没有可编辑的轮次"
|
||||
* (空历史, 或只有一条 system).
|
||||
*
|
||||
* 轮的定义与主循环**共用同一个判据** (agent_opens_round):一轮从一条带文字的
|
||||
* 轮的定义与主循环**共用同一个判据** (agent_opens_round): 一轮从一条带文字的
|
||||
* user 消息开始, 到下一条这样的消息之前; read_image 补的那条"只有图片附件"的
|
||||
* user 消息不算新的一轮. head 是第一条可编辑消息的下标 (跳过 system). */
|
||||
typedef struct {
|
||||
@@ -38,7 +38,7 @@ typedef struct {
|
||||
int meta_rounds_build(const JsonValue *messages, MetaRounds *out);
|
||||
void meta_rounds_free(MetaRounds *r);
|
||||
|
||||
/* 供单测用:这条消息能不能被单独删掉 (false = 结构消息, 牵连 tool_calls 配对) */
|
||||
/* 供单测用: 这条消息能不能被单独删掉 (false = 结构消息, 牵连 tool_calls 配对) */
|
||||
int meta_deletable(const JsonValue *messages, size_t index);
|
||||
|
||||
/* ---- 工具分发 ----
|
||||
|
||||
+2
-2
@@ -56,7 +56,7 @@ char **models_parse(const char *json, size_t *out_n)
|
||||
json_free(root);
|
||||
if (n == 0) { free(list); return NULL; }
|
||||
|
||||
/* 保序:服务端给的顺序是它自己的偏好, 我们不去替它排序 */
|
||||
/* 保序: 服务端给的顺序是它自己的偏好, 我们不去替它排序 */
|
||||
if (out_n) *out_n = n;
|
||||
return list;
|
||||
}
|
||||
@@ -122,7 +122,7 @@ char **models_fetch(const LigentConfig *cfg, size_t *out_n,
|
||||
tf("models.err.empty_list", "status", i18n_num(status),
|
||||
NULL));
|
||||
else if (body && *body) {
|
||||
/* 只回显开头一段:服务端偶尔回一大坨 HTML, 全塞进错误里没法读 */
|
||||
/* 只回显开头一段: 服务端偶尔回一大坨 HTML, 全塞进错误里没法读 */
|
||||
char head[121];
|
||||
snprintf(head, sizeof head, "%.120s", body);
|
||||
snprintf(err, errlen, "%s",
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
/* models.h - 拉取并解析服务端的模型列表 (GET {base_url}/models)
|
||||
*
|
||||
* 只为了 /model 不带参数时的那个交互式选择列表. OpenAI 兼容的响应体长这样:
|
||||
* 只为了 /model 不带参数时的那个交互式选择列表. OpenAI 兼容的响应体长这样:
|
||||
*
|
||||
* {"object": "list", "data": [{"id": "deepseek-chat", ...}, ...]}
|
||||
*
|
||||
@@ -17,7 +17,7 @@
|
||||
/* 从 /models 的响应体里取模型名. 保序、去重, 返回 malloc 的字符串数组
|
||||
* (元素也是 malloc), *out_n 回填个数. 解析不出任何模型名时返回 NULL.
|
||||
*
|
||||
* 兼容两种形状:{"data":[...]} 和裸数组 [...]. 纯函数, 可单测. */
|
||||
* 兼容两种形状: {"data":[...]} 和裸数组 [...]. 纯函数, 可单测. */
|
||||
char **models_parse(const char *json, size_t *out_n);
|
||||
|
||||
/* 响应体是不是"格式没问题、但里面一个模型都没有"?
|
||||
@@ -25,7 +25,7 @@ char **models_parse(const char *json, size_t *out_n);
|
||||
* 这两种情况的下一步动作完全不同, 含糊地说成"拿不到列表"就是误导. */
|
||||
int models_is_empty_list(const char *json);
|
||||
|
||||
/* 打一次网络:GET {base_url}/models, 解析出模型名.
|
||||
/* 打一次网络: GET {base_url}/models, 解析出模型名.
|
||||
* 成功返回数组 (调用方用 models_free 释放), 失败返回 NULL 并把原因写进 err.
|
||||
* verbose 会打印 HTTP 细节. */
|
||||
char **models_fetch(const LigentConfig *cfg, size_t *out_n,
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
/* priv.c - 提权:经 sudo 执行单条命令 */
|
||||
/* priv.c - 提权: 经 sudo 执行单条命令 */
|
||||
#include "priv.h"
|
||||
#include "util.h"
|
||||
|
||||
@@ -23,8 +23,8 @@ char **priv_sudo_argv(const char *user, char *const argv[])
|
||||
while (argv[n]) n++;
|
||||
const char *u = (user && *user) ? user : "root";
|
||||
|
||||
/* 刻意不带 -n:需要密码时就让 sudo 自己问.
|
||||
* 也不带 -S:那要求把密码从 stdin 喂进去, 等于让 ligent 经手密码. */
|
||||
/* 刻意不带 -n: 需要密码时就让 sudo 自己问.
|
||||
* 也不带 -S: 那要求把密码从 stdin 喂进去, 等于让 ligent 经手密码. */
|
||||
/* execvp 的签名要 char *const[], 但这里放进去的都是字面量和调用方的
|
||||
* 指针, **没有任何人会去改写它们** (execvp 本身也不会). 所以这里显式
|
||||
* 转换, 而不是把整条链路都改成非 const —— 那会把 const 正确性一路丢掉. */
|
||||
|
||||
+7
-7
@@ -1,6 +1,6 @@
|
||||
/* priv.h - 提权:经 sudo 执行单条命令
|
||||
/* priv.h - 提权: 经 sudo 执行单条命令
|
||||
*
|
||||
* 先说清楚这个模块**不做**什么, 以及为什么:
|
||||
* 先说清楚这个模块**不做**什么, 以及为什么:
|
||||
*
|
||||
* PAM 认证不等于获得权限. su 之所以能变 root, 是因为 /usr/bin/su 这个文件
|
||||
* 本身是 setuid root —— 内核先把 root 给它, 它再验密码、验不过就降权. 普通
|
||||
@@ -9,16 +9,16 @@
|
||||
* "让模型执行任意命令", setuid root 的它等于把 root 远程执行做成一等功能.
|
||||
* 所以不走那条路.
|
||||
*
|
||||
* 也不做"授权提示". **密码本身就是授权**:模型敲不出来, 你也不可能手滑敲出来.
|
||||
* 也不做"授权提示". **密码本身就是授权**: 模型敲不出来, 你也不可能手滑敲出来.
|
||||
* 再加一道 y/N 只会是多余的摩擦, 还会给出一个虚假的安全模型 —— 好像把关的是
|
||||
* ligent 的提示, 其实真正把关的是 sudo. 所以这里只做两件事:
|
||||
* ligent 的提示, 其实真正把关的是 sudo. 所以这里只做两件事:
|
||||
*
|
||||
* 1. 把命令包成 `sudo -u <用户> -- <argv>` 交出去 (**不经 shell**,
|
||||
* 所以路径/命令里的引号、换行、`;` 注入不进 root 那条命令里);
|
||||
* 2. 需要密码时, **让终端留在 sudo 手上**, 由它自己问、自己存 —— ligent
|
||||
* 不读、不存、不转发密码.
|
||||
*
|
||||
* 唯一由 ligent 把守的是"别卡死":没有控制终端就**根本不去试**, 直接说清楚.
|
||||
* 唯一由 ligent 把守的是"别卡死": 没有控制终端就**根本不去试**, 直接说清楚.
|
||||
* 否则就会起一个在后台等一个没人能看见的密码提示的进程.
|
||||
*/
|
||||
#ifndef LIGENT_PRIV_H
|
||||
@@ -26,12 +26,12 @@
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
/* 有可用的控制终端吗?判据是能不能打开 /dev/tty, **不是** isatty(0):
|
||||
/* 有可用的控制终端吗?判据是能不能打开 /dev/tty, **不是** isatty(0):
|
||||
* `echo x | ligent ...` 时 stdin 是管道, 但终端还在, sudo 照样问得出来. */
|
||||
int priv_have_tty(void);
|
||||
|
||||
/* 把 argv 包成 `sudo -u <user> -- <原 argv>` (user 为空则 "root").
|
||||
* 故意**不带 -n**:需要密码时就让 sudo 自己去问.
|
||||
* 故意**不带 -n**: 需要密码时就让 sudo 自己去问.
|
||||
* 返回 malloc 的数组, 元素是借用的指针 —— 调用方 free 数组本身即可. */
|
||||
char **priv_sudo_argv(const char *user, char *const argv[]);
|
||||
|
||||
|
||||
+9
-9
@@ -18,7 +18,7 @@
|
||||
|
||||
/* bash → ash → dash → sh.
|
||||
*
|
||||
* 刻意不看 $SHELL:用户环境里的 $SHELL 可能是 xonsh 之类交互式外壳,
|
||||
* 刻意不看 $SHELL: 用户环境里的 $SHELL 可能是 xonsh 之类交互式外壳,
|
||||
* 在容器/受限环境里经常起不来, 反而把 /shell 弄坏. 返回的路径一定存在. */
|
||||
static const char *pick_shell(void)
|
||||
{
|
||||
@@ -30,11 +30,11 @@ static const char *pick_shell(void)
|
||||
return "/bin/sh";
|
||||
}
|
||||
|
||||
/* 把终端输出整理成"能读的记录":
|
||||
/* 把终端输出整理成"能读的记录":
|
||||
* 去掉 ANSI 转义、把 CRLF 归一成 LF、丢掉退格与其它控制字符, 并处理全屏程序.
|
||||
* 不清理的话模型看到的是一堆 ^[[32m 之类的噪音.
|
||||
*
|
||||
* 状态必须跨 read() 保存, 原因有三个:
|
||||
* 状态必须跨 read() 保存, 原因有三个:
|
||||
* - 转义序列可能正好被 read() 从中间切断, 分两次解析会串味;
|
||||
* - 全屏程序的备用屏幕进入/退出天然跨越多次 read;
|
||||
* - 连续空格要和后面的字符一起看才压得准. */
|
||||
@@ -179,7 +179,7 @@ char *shell_interactive(int *out_code, char *errbuf, size_t errlen)
|
||||
ioctl(master, TIOCSWINSZ, &ws);
|
||||
|
||||
/* 全屏程序 (nano/vim/less/top)靠"备用屏幕"切换来还原退出前的画面,
|
||||
* 但它们只在 TERM 声称支持时才这么做:TERM 为空或 dumb 时 nano 会退化成
|
||||
* 但它们只在 TERM 声称支持时才这么做: TERM 为空或 dumb 时 nano 会退化成
|
||||
* 逐行重绘, 几百个空格把记录淹掉, 也没法整段忽略.
|
||||
*
|
||||
* 而 ligent 自己的 REPL 就在用颜色、光标定位和括号粘贴 —— 这个终端至少是
|
||||
@@ -200,7 +200,7 @@ char *shell_interactive(int *out_code, char *errbuf, size_t errlen)
|
||||
}
|
||||
|
||||
pid_t pid = fork();
|
||||
/* 子进程已经继承了改过的 TERM, 父进程立刻还原, 免得影响 ligent 自己.
|
||||
/* 子进程已经继承了改过的 TERM, 父进程立刻还原, 以免影响 ligent 自己.
|
||||
* 必须显式判断 pid != 0 —— 子进程也会从 fork() 返回并往下走. */
|
||||
if (pid != 0 && term_restore) {
|
||||
if (term_was_set) setenv("TERM", term_saved, 1);
|
||||
@@ -228,7 +228,7 @@ char *shell_interactive(int *out_code, char *errbuf, size_t errlen)
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
/* ---- 父进程:把终端切到原始模式, 双向搬运 ---- */
|
||||
/* ---- 父进程: 把终端切到原始模式, 双向搬运 ---- */
|
||||
struct termios saved;
|
||||
int raw = (tty_raw(&saved) == 0);
|
||||
|
||||
@@ -269,7 +269,7 @@ char *shell_interactive(int *out_code, char *errbuf, size_t errlen)
|
||||
fflush(stdout);
|
||||
append_clean(&clean, &rec, buf, (size_t)r);
|
||||
} else if (r == 0 || (r < 0 && errno == EIO)) {
|
||||
eof = 1; /* 从设备关闭:Linux 上会得到 EIO */
|
||||
eof = 1; /* 从设备关闭: Linux 上会得到 EIO */
|
||||
} else if (r < 0 && errno != EAGAIN && errno != EINTR) {
|
||||
eof = 1;
|
||||
}
|
||||
@@ -295,9 +295,9 @@ char *shell_interactive(int *out_code, char *errbuf, size_t errlen)
|
||||
else if (WIFSIGNALED(status)) code = 128 + WTERMSIG(status);
|
||||
if (out_code) *out_code = code;
|
||||
|
||||
clean_flush_spaces(&clean, &rec); /* 收尾:还压在手里的空格 */
|
||||
clean_flush_spaces(&clean, &rec); /* 收尾: 还压在手里的空格 */
|
||||
/* 全屏程序被 Ctrl-C/SIGKILL 干掉时会来不及发退出序列, 补一个收尾标记,
|
||||
* 免得记录里留下一个没有配对的"开始". */
|
||||
* 以免记录里留下一个没有配对的"开始". */
|
||||
if (clean.in_alt && rec.len < LIGENT_SHELL_MAX)
|
||||
sb_puts(&rec, "[全屏程序结束]\n");
|
||||
return sb_detach(&rec);
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
|
||||
/* 起一个交互式 shell, 用户在里面随便敲; 退出时把整段会话录制下来.
|
||||
*
|
||||
* 用伪终端 (pty)做双向搬运:子进程以为自己接在真终端上, 所以行编辑、
|
||||
* 用伪终端 (pty)做双向搬运: 子进程以为自己接在真终端上, 所以行编辑、
|
||||
* 颜色、Ctrl-C、作业控制都照常工作; 我们在外面旁听并把输出记下来.
|
||||
*
|
||||
* out_code 回填子进程退出码.
|
||||
|
||||
+4
-4
@@ -20,7 +20,7 @@ static volatile int g_running;
|
||||
static int g_started;
|
||||
static int g_enabled;
|
||||
static char g_label[64];
|
||||
/* 当前这段等待的起点. 换标签时重置, 于是秒数反映的是"这个阶段等了多久":
|
||||
/* 当前这段等待的起点. 换标签时重置, 于是秒数反映的是"这个阶段等了多久":
|
||||
* 先看到"等待响应 (1s)", 切成"思考中"后重新从 0 数起. */
|
||||
static struct timespec g_t0;
|
||||
|
||||
@@ -63,7 +63,7 @@ static void *worker(void *arg)
|
||||
fflush(stderr);
|
||||
i = (i + 1) % 4;
|
||||
|
||||
/* 睡到下一帧. 这里必须用条件变量定时等, 不能 nanosleep:
|
||||
/* 睡到下一帧. 这里必须用条件变量定时等, 不能 nanosleep:
|
||||
* spinner_stop() 要 pthread_join, nanosleep 的话就得干等这一觉
|
||||
* 睡满 —— 每次收尾白搭最多 250ms, 而这笔账直接加在用户感知的
|
||||
* 延迟上 (每轮模型往返、每个工具来回都要付一次). */
|
||||
@@ -99,10 +99,10 @@ void spinner_start(const char *label)
|
||||
pthread_once(&g_cond_once, init_cond); /* 保证 g_cond 用的是单调时钟 */
|
||||
pthread_mutex_lock(&g_lock);
|
||||
if (!g_started) {
|
||||
/* 先把标签算成**一份副本**再写进 g_label:label 有可能就是 g_label 自己
|
||||
/* 先把标签算成**一份副本**再写进 g_label: label 有可能就是 g_label 自己
|
||||
* —— spinner_notice() 从一条重试提示里恢复动画时正是这么调的. 以前这里
|
||||
* 先执行 g_label[0] = '\0', 于是要读的那个字符串被自己清空, 标签退化成
|
||||
* 默认的"思考中":每次重试退避之后, 屏幕上的"等待响应"都会变成"思考中",
|
||||
* 默认的"思考中": 每次重试退避之后, 屏幕上的"等待响应"都会变成"思考中",
|
||||
* 而这两段等待的含义恰恰是它要区分的. */
|
||||
char want[sizeof g_label];
|
||||
snprintf(want, sizeof want, "%s",
|
||||
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
/* spinner.h - 等待指示器
|
||||
*
|
||||
* 存在的意义:模型不输出思考过程是好事, 但等待期间屏幕上什么都没有,
|
||||
* 存在的意义: 模型不输出思考过程是好事, 但等待期间屏幕上什么都没有,
|
||||
* 和"卡死了"看起来一模一样. 一个灰色的动画能把这个歧义消掉.
|
||||
*
|
||||
* 分两段显示, 因为这两种等待的性质不一样:
|
||||
* 分两段显示, 因为这两种等待的性质不一样:
|
||||
* 刚发出请求、还没收到任何东西 → "等待响应" (卡在网络或排队)
|
||||
* 开始收到思维链 → "思考中" (模型确实在推理)
|
||||
* 一收到正文就停 —— 那时已经有东西可看了.
|
||||
@@ -30,7 +30,7 @@ void spinner_set_label(const char *label);
|
||||
* 流式输出开始前必须先调用它, 否则内容会和动画抢同一行. */
|
||||
void spinner_stop(void);
|
||||
|
||||
/* 在动画中间插一条提示 (例如"稍后重试"):先停并清行, 打印, 再继续转.
|
||||
/* 在动画中间插一条提示 (例如"稍后重试"): 先停并清行, 打印, 再继续转.
|
||||
* 未在转时就是普通打印. */
|
||||
void spinner_notice(const char *fmt, ...) LIGENT_PRINTF(1, 2);
|
||||
|
||||
|
||||
+96
-96
@@ -1,6 +1,6 @@
|
||||
/* tools.c - 工具实现:shell、文件读写、编辑、列目录、搜索
|
||||
/* tools.c - 工具实现: shell、文件读写、编辑、列目录、搜索
|
||||
*
|
||||
* 设计取向:这是"全权执行"模式, 不做沙箱限制; 但仍对输出做截断,
|
||||
* 设计取向: 这是"全权执行"模式, 不做沙箱限制; 但仍对输出做截断,
|
||||
* 避免一次命令把上下文撑爆.
|
||||
*/
|
||||
#include "tools.h"
|
||||
@@ -64,7 +64,7 @@ LIGENT_PRINTF(1, 2) static char *err_result(const char *fmt, ...)
|
||||
va_start(ap, fmt);
|
||||
char *body = xvasprintf(fmt, ap);
|
||||
va_end(ap);
|
||||
char *out = xasprintf("错误:%s", body);
|
||||
char *out = xasprintf("错误: %s", body);
|
||||
free(body);
|
||||
return out;
|
||||
}
|
||||
@@ -90,7 +90,7 @@ static int file_is_binary(const char *path, long *at)
|
||||
return looks_binary(probe, n, at);
|
||||
}
|
||||
|
||||
/* 统一的拒绝说法:先摆事实 (第几字节有 NUL), 再说这个工具为什么只收文本、
|
||||
/* 统一的拒绝说法: 先摆事实 (第几字节有 NUL), 再说这个工具为什么只收文本、
|
||||
* 以及该改用什么. 只说"不行"等于让模型原地打转. */
|
||||
static char *binary_err(const char *path, long at, const char *why)
|
||||
{
|
||||
@@ -111,41 +111,41 @@ static char *tool_run_command(const JsonValue *args, int *is_error)
|
||||
const char *cmd = arg_str(args, "command");
|
||||
if (!cmd || !*cmd) {
|
||||
*is_error = 1;
|
||||
return xstrdup("错误:缺少必填参数 command");
|
||||
return xstrdup("错误: 缺少必填参数 command");
|
||||
}
|
||||
double t = json_get_number(args, "timeout", CMD_DEFAULT_TIMEOUT);
|
||||
if (t <= 0) t = CMD_DEFAULT_TIMEOUT;
|
||||
if (t > CMD_MAX_TIMEOUT) t = CMD_MAX_TIMEOUT;
|
||||
int64_t timeout_ms = (int64_t)(t * 1000);
|
||||
|
||||
/* elevated:以 root 执行这一条命令.
|
||||
/* elevated: 以 root 执行这一条命令.
|
||||
*
|
||||
* 不做额外的"授权提示" —— 密码本身就是授权:模型敲不出来, 你也不可能
|
||||
* 不做额外的"授权提示" —— 密码本身就是授权: 模型敲不出来, 你也不可能
|
||||
* 手滑敲出来. 确认提示 (run_command 本来就有的)负责让你**看清要跑什么**,
|
||||
* sudo 负责**不可伪造的授权**, 各司其职.
|
||||
* 没有控制终端就根本不去试:那会起一个等不到输入的进程. */
|
||||
* 没有控制终端就根本不去试: 那会起一个等不到输入的进程. */
|
||||
int elevated = json_get_bool(args, "elevated", 0);
|
||||
if (elevated && !priv_have_tty()) {
|
||||
*is_error = 1;
|
||||
return xstrdup(
|
||||
"错误:这条命令需要提权 (elevated), 但当前没有可用的控制终端"
|
||||
"错误: 这条命令需要提权 (elevated), 但当前没有可用的控制终端"
|
||||
" (/dev/tty), sudo 没法问密码. 请在终端里运行 ligent, "
|
||||
"或者换一个不需要提权的做法. ");
|
||||
}
|
||||
|
||||
int fds[2];
|
||||
if (pipe(fds) != 0)
|
||||
{ *is_error = 1; return err_result("pipe 失败:%s", strerror(errno)); }
|
||||
{ *is_error = 1; return err_result("pipe 失败: %s", strerror(errno)); }
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
close(fds[0]); close(fds[1]);
|
||||
*is_error = 1;
|
||||
return err_result("fork 失败:%s", strerror(errno));
|
||||
return err_result("fork 失败: %s", strerror(errno));
|
||||
}
|
||||
if (pid == 0) {
|
||||
/* 非提权:独立进程组, 超时时可以整组杀掉.
|
||||
* 提权:**不 setsid** —— sudo 是打开 /dev/tty 问密码的, 摘掉控制终端
|
||||
/* 非提权: 独立进程组, 超时时可以整组杀掉.
|
||||
* 提权: **不 setsid** —— sudo 是打开 /dev/tty 问密码的, 摘掉控制终端
|
||||
* 它就永远问不出来 (这正是上一版认证必然失败的成因).
|
||||
* 代价是提权这条没法整组杀, 超时只杀直接子进程. */
|
||||
if (!elevated) setsid();
|
||||
@@ -155,9 +155,9 @@ static char *tool_run_command(const JsonValue *args, int *is_error)
|
||||
dup2(fds[1], STDERR_FILENO);
|
||||
close(fds[0]);
|
||||
close(fds[1]);
|
||||
/* argv 形式执行:提权时是 `sudo -u root -- /bin/sh -c <命令>`,
|
||||
/* argv 形式执行: 提权时是 `sudo -u root -- /bin/sh -c <命令>`,
|
||||
* 命令内容不会被多解析一层. */
|
||||
/* 同上:execvp 要 char*[], 这里放进去的都不会被改写 */
|
||||
/* 同上: execvp 要 char*[], 这里放进去的都不会被改写 */
|
||||
char *plain[] = { (char *)"/bin/sh", (char *)"-c", (char *)cmd, NULL };
|
||||
char **w = elevated ? priv_sudo_argv(NULL, plain) : plain;
|
||||
execvp(w[0], w);
|
||||
@@ -232,7 +232,7 @@ static char *tool_run_command(const JsonValue *args, int *is_error)
|
||||
if (elevated && exit_code != 0) {
|
||||
char why[512] = "";
|
||||
priv_explain(exit_code, out.data, why, sizeof why);
|
||||
sb_printf(&result, "\n[提权命令失败:%s]", why);
|
||||
sb_printf(&result, "\n[提权命令失败: %s]", why);
|
||||
if (!out.data || !*out.data)
|
||||
sb_puts(&result,
|
||||
"\n (没有捕获到输出. sudo 的提示是直接写到终端上的, "
|
||||
@@ -268,7 +268,7 @@ static char *tool_read_file(const JsonValue *args, int *is_error)
|
||||
{
|
||||
REQUIRE_ERR_PTR(is_error);
|
||||
const char *path = arg_str(args, "path");
|
||||
if (!path || !*path) { *is_error = 1; return xstrdup("错误:缺少必填参数 path"); }
|
||||
if (!path || !*path) { *is_error = 1; return xstrdup("错误: 缺少必填参数 path"); }
|
||||
|
||||
double off = json_get_number(args, "offset", 1);
|
||||
double lim = json_get_number(args, "limit", 2000);
|
||||
@@ -277,7 +277,7 @@ static char *tool_read_file(const JsonValue *args, int *is_error)
|
||||
if (limit > 20000) limit = 20000;
|
||||
|
||||
FILE *f = fopen(path, "rb");
|
||||
if (!f) { *is_error = 1; return err_result("无法打开 %s:%s", path, strerror(errno)); }
|
||||
if (!f) { *is_error = 1; return err_result("无法打开 %s: %s", path, strerror(errno)); }
|
||||
|
||||
/* 二进制文件按文本读出来是乱码, 而且会在 NUL 处被截断 —— 直接拒绝.
|
||||
* 文件已经打开了, 就地探一遍前 8 KB (窗口与判据见 util.h), 不必再开一次. */
|
||||
@@ -301,12 +301,12 @@ static char *tool_read_file(const JsonValue *args, int *is_error)
|
||||
* "read_file 打出来的行号可以直接喂给 edit_file 的 line".
|
||||
*
|
||||
* 以前这里是 fgets + 固定 64 KB 缓冲, 于是"一行比缓冲长"时它会被切成好几块、
|
||||
* 每块各带一个行号:一个「70000 字节的单行 + 两行」的文件, read_file 说第 3 行
|
||||
* 每块各带一个行号: 一个「70000 字节的单行 + 两行」的文件, read_file 说第 3 行
|
||||
* 是 "second", 而 edit_file 认为第 3 行是 "third". 模型照 read_file 的行号去
|
||||
* 改, 要么改错地方、要么报越界 —— 而它没有任何办法察觉.
|
||||
*
|
||||
* 单行的字节数靠"上一块是不是以换行结尾" (in_line)来跟踪, 所以仍然**不**
|
||||
* 需要把整个文件读进内存:一行再长也只占 line[] 这么多, 超出输出上限就如实
|
||||
* 需要把整个文件读进内存: 一行再长也只占 line[] 这么多, 超出输出上限就如实
|
||||
* 说明这一行读不出来, 而不是把它伪装成若干行. */
|
||||
StrBuf sb;
|
||||
sb_init(&sb);
|
||||
@@ -344,7 +344,7 @@ static char *tool_read_file(const JsonValue *args, int *is_error)
|
||||
|
||||
if (sb.len + n > LIGENT_TOOL_OUTPUT_LIMIT) {
|
||||
/* 撞的是**字节**上限, 而且撞在"正在写的这一行"上 (前面那些行都已经
|
||||
* 完整给出). 分两种:这一行连一个字节都没进去 (前缀已写、内容为 0),
|
||||
* 完整给出). 分两种: 这一行连一个字节都没进去 (前缀已写、内容为 0),
|
||||
* 还是只给了它开头. 两种情况都要如实说, 不能让调用方以为拿到的是
|
||||
* 一份完整的行 —— 也不能把剩下的部分伪装成"下一行". */
|
||||
cut_by_bytes = 1;
|
||||
@@ -381,7 +381,7 @@ static char *tool_read_file(const JsonValue *args, int *is_error)
|
||||
}
|
||||
if (truncated) {
|
||||
if (cut_by_bytes && partial_line > 0)
|
||||
/* 撞的是字节上限:给出的是**半行**, 所以不能再说"从下一行继续" */
|
||||
/* 撞的是字节上限: 给出的是**半行**, 所以不能再说"从下一行继续" */
|
||||
sb_printf(&sb,
|
||||
"... [第 %ld 行本身就超过 %u 字节的输出上限, 这里只给了它的开头; "
|
||||
"要看后面的部分, 用 run_command (例如 sed -n '%ldp' <文件> | cut -c1-4000)]",
|
||||
@@ -401,10 +401,10 @@ static char *tool_write_file(const JsonValue *args, int *is_error)
|
||||
REQUIRE_ERR_PTR(is_error);
|
||||
const char *path = arg_str(args, "path");
|
||||
JsonValue *cv = json_object_get(args, "content");
|
||||
if (!path || !*path) { *is_error = 1; return xstrdup("错误:缺少必填参数 path"); }
|
||||
if (!path || !*path) { *is_error = 1; return xstrdup("错误: 缺少必填参数 path"); }
|
||||
if (!cv || cv->type != JSON_STRING) {
|
||||
*is_error = 1;
|
||||
return xstrdup("错误:缺少必填参数 content (必须是字符串)");
|
||||
return xstrdup("错误: 缺少必填参数 content (必须是字符串)");
|
||||
}
|
||||
const char *content = cv->u.string;
|
||||
|
||||
@@ -417,7 +417,7 @@ static char *tool_write_file(const JsonValue *args, int *is_error)
|
||||
}
|
||||
free(dir);
|
||||
|
||||
/* 别拿文本去整体覆盖一个二进制文件:那等于把原内容毁掉, 而模型多半没
|
||||
/* 别拿文本去整体覆盖一个二进制文件: 那等于把原内容毁掉, 而模型多半没
|
||||
* 意识到 (它只是想"写个文件"). 要真的换掉它, 先删或用 run_command. */
|
||||
{
|
||||
long at = 0;
|
||||
@@ -429,7 +429,7 @@ static char *tool_write_file(const JsonValue *args, int *is_error)
|
||||
}
|
||||
}
|
||||
|
||||
/* 落盘前再看一眼中断标志. 确认环节已经会拦下 Ctrl-C, 这里是纵深防御:
|
||||
/* 落盘前再看一眼中断标志. 确认环节已经会拦下 Ctrl-C, 这里是纵深防御:
|
||||
* "任何改盘的动作之前都检查中断"应当是一条无例外的约定,
|
||||
* 而不是依赖"恰好上一层拦住了". */
|
||||
if (g_ligent_interrupt) {
|
||||
@@ -438,14 +438,14 @@ static char *tool_write_file(const JsonValue *args, int *is_error)
|
||||
}
|
||||
|
||||
FILE *f = fopen(path, "wb");
|
||||
if (!f) { *is_error = 1; return err_result("无法写入 %s:%s", path, strerror(errno)); }
|
||||
if (!f) { *is_error = 1; return err_result("无法写入 %s: %s", path, strerror(errno)); }
|
||||
size_t n = strlen(content);
|
||||
size_t written = fwrite(content, 1, n, f);
|
||||
if (fclose(f) != 0 && written == n) written = (size_t)-1;
|
||||
|
||||
if (written != n) {
|
||||
*is_error = 1;
|
||||
return err_result("写入 %s 失败:%s", path, strerror(errno));
|
||||
return err_result("写入 %s 失败: %s", path, strerror(errno));
|
||||
}
|
||||
return xasprintf("已写入 %zu 字节到 %s", n, path);
|
||||
}
|
||||
@@ -454,35 +454,35 @@ static char *tool_write_file(const JsonValue *args, int *is_error)
|
||||
/* edit_file */
|
||||
/* ------------------------------------------------------------------ */
|
||||
/*
|
||||
* 替换受**行号集合**制约:old_string 的**起点**必须落在 line 圈住的行里, 且在
|
||||
* 替换受**行号集合**制约: old_string 的**起点**必须落在 line 圈住的行里, 且在
|
||||
* 这些行里只有一处匹配.
|
||||
*
|
||||
* line 是**集合型, 而且可以整个省略**:省略 = 不设行范围, 等于**全文件替换所有
|
||||
* line 是**集合型, 而且可以整个省略**: 省略 = 不设行范围, 等于**全文件替换所有
|
||||
* 匹配** (就是编辑器里那个"全局替换"). 给了它就是集合型 —— 单个整数 (等价于
|
||||
* 只有一个元素的集合)或整数数组都收, `"line": 12` 和 `"line": [12]` 是同一件事.
|
||||
* 集合里的行号就是 read_file 打出来的那些 (1 起).
|
||||
*
|
||||
* 为什么是集合而不是单个行号:模型看到的常是一整块内容 ("第 10~13 行是这段
|
||||
* 为什么是集合而不是单个行号: 模型看到的常是一整块内容 ("第 10~13 行是这段
|
||||
* 函数"), 但决定命中与否的是 old_string 的**起点**落在哪一行; 只给一个行号时
|
||||
* 它得先数清第一行到底是 10 还是 11, 数错一次就白烧一轮. 给了集合之后, 照
|
||||
* read_file 的显示把覆盖到的那几行都填进来就行. 集合只是**搜索范围**, 不是
|
||||
* "每行都改一遍":真改动的仍是唯一那一处 (replace_all 或省略 line 时才动全文件).
|
||||
* "每行都改一遍": 真改动的仍是唯一那一处 (replace_all 或省略 line 时才动全文件).
|
||||
*
|
||||
* 为什么不再只靠"全文唯一"来定位:行号是模型唯一的空间坐标 (read_file 给的),
|
||||
* 为什么不再只靠"全文唯一"来定位: 行号是模型唯一的空间坐标 (read_file 给的),
|
||||
* 而"全文唯一"要求整个文件里恰好只有一处 —— 文件一长这个条件经常不成立
|
||||
* (同一个 `return 0;` 到处都是), 模型只能不断加长上下文去凑唯一性; 凑不成
|
||||
* 就只能放弃, 或者赌一把改错地方. 把行号钉住之后, "改哪一处"由坐标决定,
|
||||
* 不再是个概率问题; old_string 也不必再为"唯一"而写得很长.
|
||||
*
|
||||
* 省略 line 是**故意留着**的逃生门:有些改动本来就该全文件生效 (比如统一改个
|
||||
* 省略 line 是**故意留着**的逃生门: 有些改动本来就该全文件生效 (比如统一改个
|
||||
* 名字), 但不给行号就没法声称"我知道改的是哪一处" —— 回执里也会直说这是全文件
|
||||
* 替换. 要"改一处"仍得给行号, 模型没法靠省略来回避看文件.
|
||||
*
|
||||
* replace_all 仍在, 含义不变:改文件里**所有**匹配. 但 line 集合必须圈住其中
|
||||
* replace_all 仍在, 含义不变: 改文件里**所有**匹配. 但 line 集合必须圈住其中
|
||||
* 真实的一处 —— 这等于强制模型先看过文件, 而不是凭印象做全文替换.
|
||||
*/
|
||||
|
||||
/* 文件按 read_file 的口径有多少行:末尾的换行不算新的一行.
|
||||
/* 文件按 read_file 的口径有多少行: 末尾的换行不算新的一行.
|
||||
* (read_file 用 fgets 读, 末尾那个"空行"不会产生输出, 两边口径必须一致,
|
||||
* 否则 read_file 显示的行号和这里的越界判断会互相矛盾. ) */
|
||||
static long count_lines(const char *s, size_t len)
|
||||
@@ -520,13 +520,13 @@ static int cmp_long(const void *a, const void *b)
|
||||
return x < y ? -1 : (x > y ? 1 : 0);
|
||||
}
|
||||
|
||||
/* 把 line 参数解析成升序去重后的行号数组:写进 out (容量 cap), 返回元素个数
|
||||
/* 把 line 参数解析成升序去重后的行号数组: 写进 out (容量 cap), 返回元素个数
|
||||
* (>=1). 失败返回 -1, 并把给模型看的说法写进 *err (调用方 free).
|
||||
*
|
||||
* 两种写法都收:整数 (历史写法)和整数数组 (集合). 所以老调用和老录制里的
|
||||
* 两种写法都收: 整数 (历史写法)和整数数组 (集合). 所以老调用和老录制里的
|
||||
* `"line": 41` 仍然照常工作, 不必区分.
|
||||
*
|
||||
* 去重不是洁癖:同一个行号处理两遍, 同一处匹配会被数成两处,
|
||||
* 去重不是洁癖: 同一个行号处理两遍, 同一处匹配会被数成两处,
|
||||
* 于是"行内只有一处"的判断会莫名其妙地失败. */
|
||||
static long parse_line_set(const JsonValue *lv, long *out, size_t cap, char **err)
|
||||
{
|
||||
@@ -535,13 +535,13 @@ static long parse_line_set(const JsonValue *lv, long *out, size_t cap, char **er
|
||||
size_t n = is_arr ? json_array_size(lv) : 1;
|
||||
|
||||
if (is_arr && n == 0) {
|
||||
*err = xstrdup("错误:line 是空数组 —— 要么至少给一个行号 (read_file "
|
||||
*err = xstrdup("错误: line 是空数组 —— 要么至少给一个行号 (read_file "
|
||||
"打出来的那个, 从 1 开始), 要么把 line 整个省略"
|
||||
" (省略 = 全文件替换所有匹配). ");
|
||||
return -1;
|
||||
}
|
||||
if (n > cap) {
|
||||
*err = xasprintf("错误:line 里有 %zu 个行号, 一次最多 %zu 个. "
|
||||
*err = xasprintf("错误: line 里有 %zu 个行号, 一次最多 %zu 个. "
|
||||
"集合是用来圈住 old_string 覆盖的那几行的 —— "
|
||||
"要改的地方太多, 先 read_file 看清再分几次改. ", n, cap);
|
||||
return -1;
|
||||
@@ -549,16 +549,16 @@ static long parse_line_set(const JsonValue *lv, long *out, size_t cap, char **er
|
||||
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
const JsonValue *e = is_arr ? json_array_get(lv, i) : lv;
|
||||
/* 先把范围夹住再判断是不是整数:JSON 里什么都可能来, 而把 1e300
|
||||
/* 先把范围夹住再判断是不是整数: JSON 里什么都可能来, 而把 1e300
|
||||
* 直接转成 long 是 UB (UBsan 会当场报出来). */
|
||||
double d = (e->type == JSON_NUMBER) ? e->u.number : 0.0;
|
||||
if (e->type != JSON_NUMBER || !(d >= 1.0) || d > 9.0e18
|
||||
|| d != (double)(long)d) {
|
||||
if (is_arr)
|
||||
*err = xasprintf("错误:line 里的第 %zu 个不是合法的行号 —— "
|
||||
*err = xasprintf("错误: line 里的第 %zu 个不是合法的行号 —— "
|
||||
"要的是 read_file 打出来的 >=1 的整数行号. ", i + 1);
|
||||
else
|
||||
*err = xasprintf("错误:line=%g 不合法 —— 要的是 read_file "
|
||||
*err = xasprintf("错误: line=%g 不合法 —— 要的是 read_file "
|
||||
"打出来的 >=1 的整数行号. ", d);
|
||||
return -1;
|
||||
}
|
||||
@@ -573,7 +573,7 @@ static long parse_line_set(const JsonValue *lv, long *out, size_t cap, char **er
|
||||
}
|
||||
|
||||
/* 把行号集合渲染成 "3、4、5" (报错与回执里用, 给模型看).
|
||||
* 超过 8 个就只列前 8 个:再往下只是噪声, 源文件的行号本来就在 read_file 里. */
|
||||
* 超过 8 个就只列前 8 个: 再往下只是噪声, 源文件的行号本来就在 read_file 里. */
|
||||
static void lines_text(const long *v, long n, char *buf, size_t cap)
|
||||
{
|
||||
size_t off = 0;
|
||||
@@ -593,27 +593,27 @@ static char *tool_edit_file(const JsonValue *args, int *is_error)
|
||||
const char *old_s = arg_str(args, "old_string");
|
||||
JsonValue *nv = json_object_get(args, "new_string");
|
||||
JsonValue *lv = json_object_get(args, "line");
|
||||
if (!path || !*path) { *is_error = 1; return xstrdup("错误:缺少必填参数 path"); }
|
||||
if (!old_s) { *is_error = 1; return xstrdup("错误:缺少必填参数 old_string"); }
|
||||
if (!path || !*path) { *is_error = 1; return xstrdup("错误: 缺少必填参数 path"); }
|
||||
if (!old_s) { *is_error = 1; return xstrdup("错误: 缺少必填参数 old_string"); }
|
||||
if (!nv || nv->type != JSON_STRING) {
|
||||
*is_error = 1;
|
||||
return xstrdup("错误:缺少必填参数 new_string (必须是字符串)");
|
||||
return xstrdup("错误: 缺少必填参数 new_string (必须是字符串)");
|
||||
}
|
||||
/* line 可以**整个省略**:省略 = 不设行范围 = 全文件替换所有匹配 ("全局替换").
|
||||
/* line 可以**整个省略**: 省略 = 不设行范围 = 全文件替换所有匹配 ("全局替换").
|
||||
* 类型给错了 (字符串之类)仍然当场报错 —— 那是手滑, 不是"想全局". */
|
||||
int global = (lv == NULL);
|
||||
if (!global && lv->type != JSON_NUMBER && lv->type != JSON_ARRAY) {
|
||||
*is_error = 1;
|
||||
return xstrdup("错误:line 必须是整数或整数数组 (集合型)——"
|
||||
return xstrdup("错误: line 必须是整数或整数数组 (集合型)——"
|
||||
"行号就是 read_file 打出来的那些, 从 1 开始. "
|
||||
"要全文件替换所有匹配, 就把 line 整个省略. ");
|
||||
}
|
||||
const char *new_s = nv->u.string;
|
||||
int replace_all = json_get_bool(args, "replace_all", 0);
|
||||
|
||||
if (!*old_s) { *is_error = 1; return xstrdup("错误:old_string 不能为空"); }
|
||||
if (!*old_s) { *is_error = 1; return xstrdup("错误: old_string 不能为空"); }
|
||||
|
||||
/* line 是集合:解析成升序去重的行号数组. 上界 (超过文件总行数)要读过文件
|
||||
/* line 是集合: 解析成升序去重的行号数组. 上界 (超过文件总行数)要读过文件
|
||||
* 才知道, 放在后面查; 这里只管写法本身对不对. 省略 line 时集合是空的,
|
||||
* 下面所有"按行"的检查都跳过, 直接当全文件替换. */
|
||||
long lines[EDIT_LINE_MAX];
|
||||
@@ -624,11 +624,11 @@ static char *tool_edit_file(const JsonValue *args, int *is_error)
|
||||
char *perr = NULL;
|
||||
nlines = parse_line_set(lv, lines, EDIT_LINE_MAX, &perr);
|
||||
if (nlines < 0) { *is_error = 1; return perr; }
|
||||
/* 报错与回执里要说清"是哪几行":模型据此判断行号有没有被理解对 */
|
||||
/* 报错与回执里要说清"是哪几行": 模型据此判断行号有没有被理解对 */
|
||||
lines_text(lines, nlines, req, sizeof req);
|
||||
}
|
||||
|
||||
/* 先看大小再读:edit_file 需要把整个文件放进内存,
|
||||
/* 先看大小再读: edit_file 需要把整个文件放进内存,
|
||||
* 对超大文件直接拒绝, 避免把内存吃干.
|
||||
* 用 stat 而不是读完再看, 否则拒绝之前就已经分配了. */
|
||||
struct stat st;
|
||||
@@ -642,9 +642,9 @@ static char *tool_edit_file(const JsonValue *args, int *is_error)
|
||||
|
||||
size_t flen = 0;
|
||||
char *src = read_entire_file(path, &flen);
|
||||
if (!src) { *is_error = 1; return err_result("无法读取 %s:%s", path, strerror(errno)); }
|
||||
if (!src) { *is_error = 1; return err_result("无法读取 %s: %s", path, strerror(errno)); }
|
||||
|
||||
/* 二进制拒绝. 这里看**全文件** (不像读类工具只看前 8 KB):edit_file 在内存里
|
||||
/* 二进制拒绝. 这里看**全文件** (不像读类工具只看前 8 KB): edit_file 在内存里
|
||||
* 是按 C 字符串处理的 (strstr/strlen), 文件里任何一个 NUL 都会让后面的内容
|
||||
* 被当成结束 —— 改下去就是把原文件毁掉. */
|
||||
{
|
||||
@@ -659,7 +659,7 @@ static char *tool_edit_file(const JsonValue *args, int *is_error)
|
||||
}
|
||||
}
|
||||
|
||||
/* 上界检查:下界 (>=1)和写法在 parse_line_set 里已经查过, 这里要文件总行数.
|
||||
/* 上界检查: 下界 (>=1)和写法在 parse_line_set 里已经查过, 这里要文件总行数.
|
||||
* 省略 line 时没有行号可查. */
|
||||
if (!global) {
|
||||
long total_lines = count_lines(src, flen);
|
||||
@@ -678,7 +678,7 @@ static char *tool_edit_file(const JsonValue *args, int *is_error)
|
||||
}
|
||||
|
||||
size_t olen = strlen(old_s);
|
||||
/* 搜索范围:给了集合就是集合圈住的那几行 —— 只认起点落在其中某一行上的匹配
|
||||
/* 搜索范围: 给了集合就是集合圈住的那几行 —— 只认起点落在其中某一行上的匹配
|
||||
* (old_string 可以跨行), 集合已去重所以同一处不会被数两遍; 省略 line 就是
|
||||
* 整个文件, 那时命中几处换几处 (见下面的替换分支). */
|
||||
size_t hits = 0;
|
||||
@@ -710,7 +710,7 @@ static char *tool_edit_file(const JsonValue *args, int *is_error)
|
||||
free(src);
|
||||
*is_error = 1;
|
||||
return err_result("%s 整个文件里没有 old_string (注意缩进与换行必须逐字符"
|
||||
"一致). 要给行范围就填 line:整数或整数数组. ", path);
|
||||
"一致). 要给行范围就填 line: 整数或整数数组. ", path);
|
||||
}
|
||||
|
||||
if (hits == 0) {
|
||||
@@ -776,7 +776,7 @@ static char *tool_edit_file(const JsonValue *args, int *is_error)
|
||||
}
|
||||
free(src);
|
||||
|
||||
if (g_ligent_interrupt) { /* 同 write_file:改盘前一律先看中断 */
|
||||
if (g_ligent_interrupt) { /* 同 write_file: 改盘前一律先看中断 */
|
||||
sb_free(&out);
|
||||
*is_error = 1;
|
||||
return xstrdup("[已中断] 用户中断了这次操作, 文件没有被改动. ");
|
||||
@@ -786,7 +786,7 @@ static char *tool_edit_file(const JsonValue *args, int *is_error)
|
||||
if (!f) {
|
||||
sb_free(&out);
|
||||
*is_error = 1;
|
||||
return err_result("无法写入 %s:%s", path, strerror(errno));
|
||||
return err_result("无法写入 %s: %s", path, strerror(errno));
|
||||
}
|
||||
size_t w = fwrite(out.data, 1, out.len, f);
|
||||
int wok = (w == out.len);
|
||||
@@ -797,7 +797,7 @@ static char *tool_edit_file(const JsonValue *args, int *is_error)
|
||||
*is_error = 1;
|
||||
return err_result("写入 %s 失败", path);
|
||||
}
|
||||
/* 回执里必须带上行号:模型据此确认"改的就是我以为的那一处". 省略 line 是
|
||||
/* 回执里必须带上行号: 模型据此确认"改的就是我以为的那一处". 省略 line 是
|
||||
* 全文件替换, 回执也得直说 —— 否则它会以为只改了一处. */
|
||||
if (global)
|
||||
return xasprintf("已在 %s 全文件替换 %ld 处 (写入 %zu 字节; line 省略 = "
|
||||
@@ -825,7 +825,7 @@ static char *tool_list_dir(const JsonValue *args, int *is_error)
|
||||
if (!path || !*path) path = ".";
|
||||
|
||||
DIR *d = opendir(path);
|
||||
if (!d) { *is_error = 1; return err_result("无法打开目录 %s:%s", path, strerror(errno)); }
|
||||
if (!d) { *is_error = 1; return err_result("无法打开目录 %s: %s", path, strerror(errno)); }
|
||||
|
||||
char **list = NULL;
|
||||
size_t count = 0, cap = 0;
|
||||
@@ -848,7 +848,7 @@ static char *tool_list_dir(const JsonValue *args, int *is_error)
|
||||
|
||||
StrBuf out;
|
||||
sb_init(&out);
|
||||
sb_printf(&out, "目录 %s (%zu 个条目):\n", path, count);
|
||||
sb_printf(&out, "目录 %s (%zu 个条目): \n", path, count);
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
char full[PATH_MAX];
|
||||
snprintf(full, sizeof full, "%s/%s", path, list[i]);
|
||||
@@ -869,15 +869,15 @@ static char *tool_list_dir(const JsonValue *args, int *is_error)
|
||||
return sb_detach(&out);
|
||||
}
|
||||
|
||||
/* read_image:把本地图片交给模型看.
|
||||
/* read_image: 把本地图片交给模型看.
|
||||
*
|
||||
* 协议里图片只有两条路:外部 URL, 或者 base64 内联. ligent 不替用户去网上抓图,
|
||||
* 协议里图片只有两条路: 外部 URL, 或者 base64 内联. ligent 不替用户去网上抓图,
|
||||
* 所以只收本地文件、产出 base64 内联.
|
||||
*
|
||||
* 图片字节不进历史:这里只把图片存成本地备份, 历史里挂一个对备份的引用,
|
||||
* 图片字节不进历史: 这里只把图片存成本地备份, 历史里挂一个对备份的引用,
|
||||
* 真正构造请求时才读出来编码 (见 image.c 的 image_expand_for_request).
|
||||
*
|
||||
* 尺寸在**本地**就判掉. 理由是图片会留在历史里:一次超限不是一次失败,
|
||||
* 尺寸在**本地**就判掉. 理由是图片会留在历史里: 一次超限不是一次失败,
|
||||
* 而是从此每一轮都超限. 与其发出去等一个 400, 不如当场告诉模型"太大了",
|
||||
* 并且**不备份** —— 一张永远发不出去的图, 留着备份没有意义.
|
||||
*
|
||||
@@ -887,7 +887,7 @@ static char *tool_read_image(const JsonValue *args, int *is_error)
|
||||
{
|
||||
REQUIRE_ERR_PTR(is_error);
|
||||
const char *path = arg_str(args, "path");
|
||||
if (!path || !*path) { *is_error = 1; return xstrdup("错误:缺少必填参数 path"); }
|
||||
if (!path || !*path) { *is_error = 1; return xstrdup("错误: 缺少必填参数 path"); }
|
||||
|
||||
size_t len = 0;
|
||||
const char *mime = NULL;
|
||||
@@ -910,41 +910,41 @@ static char *tool_read_image(const JsonValue *args, int *is_error)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 工具表:所有工具的**唯一真相** */
|
||||
/* 工具表: 所有工具的**唯一真相** */
|
||||
/* ------------------------------------------------------------------ */
|
||||
/*
|
||||
* 命名约定 (改名前先读这里; README §5 有同一份, 两处必须同步)
|
||||
*
|
||||
* 1. 形式:全小写 snake_case, 动宾结构「动词_对象」. 动词必须来自白名单,
|
||||
* 而白名单**按域划分** —— 要用新动词, 先把它加进对应域的白名单:
|
||||
* 文件与执行域:read / write / edit / list / run / view
|
||||
* 历史编辑域: list / read / replace / delete / insert / undo (meta.c)
|
||||
* 1. 形式: 全小写 snake_case, 动宾结构「动词_对象」. 动词必须来自白名单,
|
||||
* 而白名单**按域划分** —— 要用新动词, 先把它加进对应域的白名单:
|
||||
* 文件与执行域: read / write / edit / list / run / view
|
||||
* 历史编辑域: list / read / replace / delete / insert / undo (meta.c)
|
||||
* 两域的动词不混用 (run 不会出现在历史编辑域, undo 也不会出现在文件域).
|
||||
* 禁用 do / handle / process / get / exec 这类不说明对象的空动词.
|
||||
* 2. 名字必须能推出副作用:会改盘的名字里不会出现 read / list.
|
||||
* 2. 名字必须能推出副作用: 会改盘的名字里不会出现 read / list.
|
||||
* 模型靠名字判断该不该先征求同意 (.mutating 决定要不要问),
|
||||
* 含糊的名字等于让它猜.
|
||||
* 3. 不带 ligent / ai / tool 这类冗余词 —— 工具表本身就在工具命名空间里.
|
||||
* 4. 不与 REPL 命令同名 (/shell、/cd、/model、/log… 都已经被占了),
|
||||
* 否则人和模型两边会串. 这是 run_command 不叫 shell 的原因之一.
|
||||
* 5. 不与别的工具只在词序、单复数或大小写上不同 (read_file / file_read 这种).
|
||||
* 6. 长度 <= 24 字符:工具表**每个请求都要重发**, 名字长度是重复成本.
|
||||
* 7. 参数名与工具名同源:路径一律 path, 内容 content, 正则 pattern,
|
||||
* 6. 长度 <= 24 字符: 工具表**每个请求都要重发**, 名字长度是重复成本.
|
||||
* 7. 参数名与工具名同源: 路径一律 path, 内容 content, 正则 pattern,
|
||||
* 文件名过滤 include. 同一个概念在两个工具里必须同名.
|
||||
*
|
||||
* 这张表同时生成四样东西:请求里的 schema (tools_schema)、执行分发
|
||||
* 这张表同时生成四样东西: 请求里的 schema (tools_schema)、执行分发
|
||||
* (tool_execute)、要不要走确认 (tool_mutating)、/tools 的输出
|
||||
* (tools_print_list). 所以**改名只需要改 .name 一列** —— 以前这四处各有
|
||||
* 一份 strcmp 链, 改名必定漏一处, 而且漏了不报错 (只是静默地少认一个名字).
|
||||
*
|
||||
* 两条约束写在这里, 因为从表里看不出来:
|
||||
* 两条约束写在这里, 因为从表里看不出来:
|
||||
* - needs_vision == 1 的工具会在"探测到模型不收图片"时被摘掉
|
||||
* (agent.c 的 tools_for_request 会调 tools_drop).
|
||||
*
|
||||
* 顺序 = 请求里工具表的顺序, 也是 /tools 的打印顺序. 改顺序不影响行为,
|
||||
* 但会让 tests/fixtures 里录制的请求对不上, 所以顺序变动要连着 fixtures 一起改.
|
||||
*
|
||||
* 为什么**没有**搜索内容的工具 (曾经有过 search_files, 后来改名 grep, 最后删了):
|
||||
* 为什么**没有**搜索内容的工具 (曾经有过 search_files, 后来改名 grep, 最后删了):
|
||||
* 模型用 run_command 里的真 grep 就行 —— `grep -rn` 支持正则、文件名过滤、
|
||||
* 上下文行、大小写、多模式, 我们这个包装每一样都要自己重写一遍, 还得跟着
|
||||
* 需求慢慢长 (第一个版本就是"搜内容"却叫 search_files, 让人以为按文件名搜).
|
||||
@@ -953,7 +953,7 @@ static char *tool_read_image(const JsonValue *args, int *is_error)
|
||||
|
||||
typedef struct {
|
||||
const char *name;
|
||||
const char *type; /* JSON-Schema 类型:string / integer / boolean;
|
||||
const char *type; /* JSON-Schema 类型: string / integer / boolean;
|
||||
* 数组写成 "元素类型[]" (如 "integer[]"),
|
||||
* 交给 tools_schema 展开成 {type:array, items:{...}} */
|
||||
const char *desc;
|
||||
@@ -978,7 +978,7 @@ static const ToolParam P_RUN_COMMAND[] = {
|
||||
"要执行的 shell 命令 (由 /bin/sh -c 执行, 支持管道与重定向)", 1 },
|
||||
{ "elevated", "boolean",
|
||||
"置 true 时以 root 执行这一条命令. **只在真的撞到权限错误之后才用**, "
|
||||
"不要预防性地加. 加了之后:你会先被要求确认 (看清要跑什么), "
|
||||
"不要预防性地加. 加了之后: 你会先被要求确认 (看清要跑什么), "
|
||||
"再由 sudo 向用户要密码 —— 密码是用户敲的, 你敲不出来. "
|
||||
"所以被拒绝时不要反复重试, 说明你需要用户亲自做什么即可. ", 0 },
|
||||
{ "timeout", "integer", "超时秒数, 默认 60, 最大 600", 0 },
|
||||
@@ -1002,9 +1002,9 @@ static const ToolParam P_WRITE_FILE[] = {
|
||||
static const ToolParam P_EDIT_FILE[] = {
|
||||
{ "path", "string", "要修改的文件路径", 1 },
|
||||
{ "line", "integer[]",
|
||||
"old_string **起点**所在的行号, 集合型, **可以整个省略**:单个整数 (`12`)"
|
||||
"old_string **起点**所在的行号, 集合型, **可以整个省略**: 单个整数 (`12`)"
|
||||
"或整数数组 (`[12,13]`)都收, 行号就是 read_file 打出来的那些 (从 1 开始). "
|
||||
"数组的用法:old_string 跨几行、又不想赌起点是第几行时, 把覆盖到的那几行"
|
||||
"数组的用法: old_string 跨几行、又不想赌起点是第几行时, 把覆盖到的那几行"
|
||||
"都填进来; 替换只发生在这些行里. **省略 line = 全文件替换所有匹配**, ", 0 },
|
||||
{ "old_string", "string",
|
||||
"被替换的原文, 必须与文件内容逐字符一致 (含缩进), 且**在 line 圈住的行里**"
|
||||
@@ -1052,7 +1052,7 @@ static const ToolDef TOOLS[] = {
|
||||
},
|
||||
{
|
||||
TOOL_EDIT_FILE,
|
||||
"在文件中做精确字符串替换. line 是集合型且**可省略**:给一个整数 (`12`)"
|
||||
"在文件中做精确字符串替换. line 是集合型且**可省略**: 给一个整数 (`12`)"
|
||||
"或整数数组 (`[12,13]`)—— 把 old_string 覆盖到的那几行都填进来, 起点到底"
|
||||
"是第几行就不必赌; 替换只发生在这些行里, old_string 也只需在这些行里唯一. "
|
||||
"**省略 line = 全文件替换所有匹配** (就是编辑器的\"全局替换\"), 只在你确实"
|
||||
@@ -1081,7 +1081,7 @@ static const ToolDef TOOLS[] = {
|
||||
|
||||
/* 按名字找定义, 找不到返回 NULL.
|
||||
*
|
||||
* **故意不保留旧名别名**:schema 里只发新名, dispatch 再认旧名只会掩盖
|
||||
* **故意不保留旧名别名**: schema 里只发新名, dispatch 再认旧名只会掩盖
|
||||
* "调用方没跟着改"这件事 (旧存档、用户自写的提示词、测试夹具都一样).
|
||||
* 名字即协议, 认不出来的就明确报"未知工具", 比静默兼容更容易发现漏改. */
|
||||
static const ToolDef *find_tool(const char *name)
|
||||
@@ -1113,7 +1113,7 @@ JsonValue *tools_schema(void)
|
||||
JsonValue *req = json_new_array();
|
||||
for (const ToolParam *p = d->params; p && p->name; p++) {
|
||||
JsonValue *one = json_new_object();
|
||||
/* "整数[]" 这种写法表示数组:展开成 {"type":"array","items":{"type":"整数"}}.
|
||||
/* "整数[]" 这种写法表示数组: 展开成 {"type":"array","items":{"type":"整数"}}.
|
||||
* 后缀只认结尾的 "[]", 元素类型照抄前面那段. */
|
||||
const char *br = strstr(p->type, "[]");
|
||||
if (br && br[2] == '\0') {
|
||||
@@ -1153,7 +1153,7 @@ void tools_drop(JsonValue *tools, const char *name)
|
||||
}
|
||||
}
|
||||
|
||||
/* hide_image:探测到模型不收图片时为 1 —— /tools 不该列一个已经藏起来的工具,
|
||||
/* hide_image: 探测到模型不收图片时为 1 —— /tools 不该列一个已经藏起来的工具,
|
||||
* 否则用户看到的是谎话. 列表本身从工具表生成, 所以它和真正发出去的工具表
|
||||
* 天然一致 (以前这里是手写的一串 puts, 就漏过一个工具). */
|
||||
void tools_print_list(int hide_image)
|
||||
@@ -1165,7 +1165,7 @@ void tools_print_list(int hide_image)
|
||||
printf("%s\n", tf("tools.ui.hidden", "name", d->name, NULL));
|
||||
continue;
|
||||
}
|
||||
/* 文案在 i18n 表里 (键就在上面那一列):/tools 是纯给人看的东西,
|
||||
/* 文案在 i18n 表里 (键就在上面那一列): /tools 是纯给人看的东西,
|
||||
* 而表里的 desc 是发给模型的, 两者本来就该分开 */
|
||||
printf(" %-18s%s\n", d->name, t(d->ui_key));
|
||||
}
|
||||
@@ -1177,7 +1177,7 @@ int tool_mutating(const char *name)
|
||||
return d ? d->mutating : 0;
|
||||
}
|
||||
|
||||
/* line 是集合型参数:整数或整数数组. 渲染成 "3" / "3,4,5" (超过 8 个就省略
|
||||
/* line 是集合型参数: 整数或整数数组. 渲染成 "3" / "3,4,5" (超过 8 个就省略
|
||||
* 尾部)—— 用户/模型要确认的第一件事就是"改的是哪几行", 但也不能把终端刷满.
|
||||
* 一个合法行号都没渲染出来时留空串, 调用方据此走"没给行号"那条文案. */
|
||||
void tool_lines_label(const JsonValue *args, char *out, size_t cap)
|
||||
@@ -1201,8 +1201,8 @@ void tool_lines_label(const JsonValue *args, char *out, size_t cap)
|
||||
|
||||
/* ---- edit_file 的新旧对照 (带行号)----
|
||||
*
|
||||
* 行号取自 line 参数:替换就发生在那里, 所以两侧的起始行号相同 —— 新内容落在
|
||||
* 旧内容原来的位置上. line 省略 (全文件替换)时没有确定的起点, 就**不标**行号:
|
||||
* 行号取自 line 参数: 替换就发生在那里, 所以两侧的起始行号相同 —— 新内容落在
|
||||
* 旧内容原来的位置上. line 省略 (全文件替换)时没有确定的起点, 就**不标**行号:
|
||||
* 编一个出来比不标更糟.
|
||||
*/
|
||||
#define DIFF_MAX_LINES 8
|
||||
@@ -1212,7 +1212,7 @@ void tool_lines_label(const JsonValue *args, char *out, size_t cap)
|
||||
#define DIFF_DIM "\033[2m"
|
||||
#define DIFF_OFF "\033[0m"
|
||||
|
||||
/* 起始行号:line 是集合型, 取最小的那个; 没给 line 则返回 0 (= 不标行号) */
|
||||
/* 起始行号: line 是集合型, 取最小的那个; 没给 line 则返回 0 (= 不标行号) */
|
||||
static long diff_base_line(const JsonValue *args)
|
||||
{
|
||||
JsonValue *lv = json_object_get(args, "line");
|
||||
@@ -1277,10 +1277,10 @@ int tool_render_diff(FILE *out, const JsonValue *args,
|
||||
char *tool_execute(const char *name, const JsonValue *args, int *is_error)
|
||||
{
|
||||
if (is_error) *is_error = 0;
|
||||
if (!name) return xstrdup("错误:工具名为空");
|
||||
if (!name) return xstrdup("错误: 工具名为空");
|
||||
if (!args || args->type != JSON_OBJECT) {
|
||||
if (is_error) *is_error = 1;
|
||||
return xstrdup("错误:工具参数必须是 JSON 对象");
|
||||
return xstrdup("错误: 工具参数必须是 JSON 对象");
|
||||
}
|
||||
|
||||
const ToolDef *d = find_tool(name);
|
||||
@@ -1288,7 +1288,7 @@ char *tool_execute(const char *name, const JsonValue *args, int *is_error)
|
||||
if (is_error) *is_error = 1;
|
||||
return err_result("未知工具 \"%s\"", name);
|
||||
}
|
||||
/* fn == NULL 是一道守卫:表里目前没有这种工具, 但留着这条检查 ——
|
||||
/* fn == NULL 是一道守卫: 表里目前没有这种工具, 但留着这条检查 ——
|
||||
* 将来若加一个"由上层执行"的工具, 模型瞎编参数时也能得到明确回答,
|
||||
* 而不是静默什么都不干. */
|
||||
if (!d->fn) {
|
||||
|
||||
+3
-3
@@ -4,11 +4,11 @@
|
||||
|
||||
#include "json.h"
|
||||
|
||||
#include <stdio.h> /* FILE:tool_render_diff 要往 stdout / stderr 写 */
|
||||
#include <stdio.h> /* FILE: tool_render_diff 要往 stdout / stderr 写 */
|
||||
|
||||
/* ---- 工具名 ----
|
||||
*
|
||||
* 名字是协议的一部分:它进 schema、进执行分发、进 /tools 的输出. 所以**名字
|
||||
* 名字是协议的一部分: 它进 schema、进执行分发、进 /tools 的输出. 所以**名字
|
||||
* 只在这里定义一次**, 别处一律用宏 —— 改名时漏一处不会编译报错, 只会静默地
|
||||
* 少认一个工具.
|
||||
*
|
||||
@@ -30,7 +30,7 @@ JsonValue *tools_schema(void);
|
||||
void tools_drop(JsonValue *tools, const char *name);
|
||||
|
||||
/* 所有工具都通过 *is_error 回报失败. **调用方必须给一个有效指针** ——
|
||||
* 这条约定在每个工具函数的入口用 assert 钉住 (见 tools.c 的 REQUIRE_ERR_PTR):
|
||||
* 这条约定在每个工具函数的入口用 assert 钉住 (见 tools.c 的 REQUIRE_ERR_PTR):
|
||||
* 它既是文档, 也让静态分析不必为每个 `*is_error = 1` 报一遍"可能解引用空指针"
|
||||
* (那 30 多条噪声会把真正的问题淹掉). */
|
||||
#define REQUIRE_ERR_PTR(p) do { assert((p) != NULL); } while (0)
|
||||
|
||||
+5
-5
@@ -110,7 +110,7 @@ void sb_clear(StrBuf *sb)
|
||||
|
||||
void sb_reserve(StrBuf *sb, size_t extra)
|
||||
{
|
||||
/* 提前返回要求 data 非空:不能只看容量够不够.
|
||||
/* 提前返回要求 data 非空: 不能只看容量够不够.
|
||||
* 代码里其实维护着「data == NULL 当且仅当 cap == 0」这个不变量 (sb_init /
|
||||
* sb_free / sb_detach 三处), 但那是隐式的、没人强制; 一旦哪次改动让它不成立,
|
||||
* 这里就会带着 NULL 指针回去, 下一行 memcpy 才炸, 离原因很远.
|
||||
@@ -215,7 +215,7 @@ char *read_entire_file(const char *path, size_t *out_len)
|
||||
while ((n = fread(buf, 1, sizeof buf, f)) > 0)
|
||||
sb_append(&sb, buf, n);
|
||||
/* fread 返回 0 既可能是 EOF, 也可能是读错误. 分不清的话就会把**截断的
|
||||
* 内容**当成功返回 —— 对图片尤其糟:会拿半张图去备份、去上传. */
|
||||
* 内容**当成功返回 —— 对图片尤其糟: 会拿半张图去备份、去上传. */
|
||||
int bad = ferror(f);
|
||||
fclose(f);
|
||||
if (bad) { sb_free(&sb); return NULL; }
|
||||
@@ -299,7 +299,7 @@ size_t utf8_seq_len(const char *s, size_t len)
|
||||
return n;
|
||||
}
|
||||
|
||||
/* 近似 wcwidth:东亚宽字符返回 2, 覆盖常用 CJK 区间 */
|
||||
/* 近似 wcwidth: 东亚宽字符返回 2, 覆盖常用 CJK 区间 */
|
||||
int cp_width(unsigned cp)
|
||||
{
|
||||
if (cp < 32) return 0;
|
||||
@@ -408,7 +408,7 @@ void random_alnum(char *out, size_t n)
|
||||
fclose(f);
|
||||
}
|
||||
if (got < n) {
|
||||
/* 没有 urandom (或读短了):时间 + pid 播种, 够避免撞名即可 */
|
||||
/* 没有 urandom (或读短了): 时间 + pid 播种, 够避免撞名即可 */
|
||||
unsigned seed = (unsigned)time(NULL) ^ ((unsigned)getpid() << 16);
|
||||
for (size_t i = got; i < n; i++) {
|
||||
seed = seed * 1103515245u + 12345u;
|
||||
@@ -425,7 +425,7 @@ void random_alnum(char *out, size_t n)
|
||||
|
||||
size_t base64_encoded_len(size_t n)
|
||||
{
|
||||
/* 每 3 字节出 4 个字符, 不足的补 '='. 先算长度再决定要不要编码:
|
||||
/* 每 3 字节出 4 个字符, 不足的补 '='. 先算长度再决定要不要编码:
|
||||
* 一张 24 MiB 的图编码后是 32 MiB, 正好卡在内联上限上. */
|
||||
return ((n + 2) / 3) * 4;
|
||||
}
|
||||
|
||||
+9
-9
@@ -1,4 +1,4 @@
|
||||
/* util.h - 基础工具:内存分配、动态字符串缓冲 */
|
||||
/* util.h - 基础工具: 内存分配、动态字符串缓冲 */
|
||||
#ifndef LIGENT_UTIL_H
|
||||
#define LIGENT_UTIL_H
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
# define LIGENT_PRINTF(fmt_idx, arg_idx)
|
||||
#endif
|
||||
|
||||
/* ---- 内存:失败即退出, 避免到处判空 ---- */
|
||||
/* ---- 内存: 失败即退出, 避免到处判空 ---- */
|
||||
void *xmalloc(size_t n);
|
||||
void *xcalloc(size_t n, size_t size);
|
||||
void *xrealloc(void *p, size_t n);
|
||||
@@ -58,15 +58,15 @@ char *expand_env(const char *src);
|
||||
|
||||
/* ---- 文本 / 二进制的判据 (唯一定义处) ----
|
||||
*
|
||||
* 判据只有一条:**出现 NUL 字节**. 文本文件里不该有它, 而 UTF-16、压缩包、
|
||||
* 判据只有一条: **出现 NUL 字节**. 文本文件里不该有它, 而 UTF-16、压缩包、
|
||||
* 可执行文件、稀疏文件这类都会中招.
|
||||
*
|
||||
* 探测窗口是 8 KB —— 读类工具没必要为了拒绝一个 10 GB 的文件把它整个读一遍.
|
||||
* 判据和窗口必须由所有"处理文本"的地方共用:以前 tools.c 和 attach.c 各写了
|
||||
* 判据和窗口必须由所有"处理文本"的地方共用: 以前 tools.c 和 attach.c 各写了
|
||||
* 一份, 窗口还不一样 (一个看前 8 KB、一个看全文件), 于是同一个文件会出现
|
||||
* "/include 说它是二进制、read_file 说它能读"这种自相矛盾的说法.
|
||||
*
|
||||
* 唯一的例外是 edit_file 的**全文件**检查:它在内存里按 C 字符串处理,
|
||||
* 唯一的例外是 edit_file 的**全文件**检查: 它在内存里按 C 字符串处理,
|
||||
* 任何位置的 NUL 都会让结果从那里被截断, 所以它不能只看开头. */
|
||||
#define LIGENT_BIN_PROBE (8 * 1024)
|
||||
|
||||
@@ -92,15 +92,15 @@ char *base64_encode(const unsigned char *data, size_t n);
|
||||
/* ---- UTF-8 与终端显示宽度 ---- */
|
||||
|
||||
/* 解码一个码点, *adv 为消耗的字节数 (输入非法时退化为 1 字节).
|
||||
* 注意它是**宽松**的:不校验续字节格式、过长编码、代理区, 只按首字节定长度.
|
||||
* 注意它是**宽松**的: 不校验续字节格式、过长编码、代理区, 只按首字节定长度.
|
||||
* 想判断"这段字节是不是合法 UTF-8"要用下面的 utf8_seq_len. */
|
||||
unsigned utf8_decode(const char *s, size_t len, size_t *adv);
|
||||
/* s 处是否是一个**合法** UTF-8 序列:合法返回序列长度 (1-4), 非法/被截断返回 0.
|
||||
* 比 utf8_decode 严格 —— 它另外拒绝:续字节格式不对、过长编码、UTF-16 代理区
|
||||
/* s 处是否是一个**合法** UTF-8 序列: 合法返回序列长度 (1-4), 非法/被截断返回 0.
|
||||
* 比 utf8_decode 严格 —— 它另外拒绝: 续字节格式不对、过长编码、UTF-16 代理区
|
||||
* (U+D800..U+DFFF)、以及超出 U+10FFFF 的编码. 这些在 JSON 里同样是非法的,
|
||||
* 所以序列化前必须按这个判据过一遍. */
|
||||
size_t utf8_seq_len(const char *s, size_t len);
|
||||
/* 近似 wcwidth:东亚宽字符返回 2, 控制字符返回 0 */
|
||||
/* 近似 wcwidth: 东亚宽字符返回 2, 控制字符返回 0 */
|
||||
int cp_width(unsigned cp);
|
||||
/* 从 i 处前进/后退一个字符, 返回新下标 (不越界) */
|
||||
size_t utf8_next(const char *s, size_t n, size_t i);
|
||||
|
||||
+9
-9
@@ -5,7 +5,7 @@
|
||||
#include <string.h>
|
||||
|
||||
/* 长度账 (最坏情况都要放得下, 否则 gcc 会报 -Wformat-truncation,
|
||||
* 而那个警告是对的 —— 截断的版本串比报错更糟):
|
||||
* 而那个警告是对的 —— 截断的版本串比报错更糟):
|
||||
* 主体 2026.09.22-abcdef7.dirty 25
|
||||
* 分支 LIGENT_BRANCH_MAX 40 (见下, 截断)
|
||||
* 时间 2026-09-22 04:32:50 19
|
||||
@@ -23,10 +23,10 @@
|
||||
/* 编译器串和平台串的缓冲尺寸 (在 ligent_compiler_string/platform_string 里用) */
|
||||
#define LIGENT_COMPILER_MAX 96
|
||||
#define LIGENT_PLATFORM_MAX 64
|
||||
/* 主体:CalVer(10) + "-" + hash(7) + ".dirty"(6) + 余量 = 160 够宽松 */
|
||||
/* 主体: CalVer(10) + "-" + hash(7) + ".dirty"(6) + 余量 = 160 够宽松 */
|
||||
#define LIGENT_VERBODY_MAX 160
|
||||
|
||||
/* branch 显示缓冲:截断到 LIGENT_BRANCH_MAX 个字符, 每个 UTF-8 字符最多 4
|
||||
/* branch 显示缓冲: 截断到 LIGENT_BRANCH_MAX 个字符, 每个 UTF-8 字符最多 4
|
||||
* 字节, 再加 "..."(3) 和 NUL */
|
||||
#define LIGENT_BRANCH_BUF (LIGENT_BRANCH_MAX * 4 + 8)
|
||||
/* where = branch + ", " + date(10) + " " + time(8) */
|
||||
@@ -42,7 +42,7 @@ static char buf_full[LIGENT_FULL_BUF];
|
||||
/* 编译器标识 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
/*
|
||||
* 顺序有讲究:clang 也定义 __GNUC__ (它假装自己是 gcc 4.2), 所以 clang 必须
|
||||
* 顺序有讲究: clang 也定义 __GNUC__ (它假装自己是 gcc 4.2), 所以 clang 必须
|
||||
* 排在 GCC 前面判断. tcc 不定义 __GNUC__, 放哪都行, 但放在一起更清楚.
|
||||
*
|
||||
* tcc 只给一个整数 __TINYC__ (0.9.28 -> 928, 也就是去掉小数点当十进制读).
|
||||
@@ -76,8 +76,8 @@ static const char *compiler_version(void)
|
||||
return buf;
|
||||
#elif defined(__TINYC__)
|
||||
/* tcc 没有 __VERSION__, 只有整数 __TINYC__.
|
||||
* 编码方式是**去掉小数点当十进制读**:0.9.28 -> 928.
|
||||
* 所以主版本要 /1000 而不是 /100:
|
||||
* 编码方式是**去掉小数点当十进制读**: 0.9.28 -> 928.
|
||||
* 所以主版本要 /1000 而不是 /100:
|
||||
* 928 / 1000 = 0 <- 对
|
||||
* 928 / 100 = 9 <- 错 (第一版就是这么写的, 输出成 "tcc 9.2.8")
|
||||
* 这对 1000 以上也成立 (1.0.0 -> 1000, 1000/1000 = 1). */
|
||||
@@ -156,7 +156,7 @@ const char *ligent_platform_string(void)
|
||||
|
||||
const char *ligent_version_string(void)
|
||||
{
|
||||
/* 格式:<CalVer>-<hash>[.dirty]
|
||||
/* 格式: <CalVer>-<hash>[.dirty]
|
||||
* hash 为 "unknown" 时直接省掉它 */
|
||||
if (strcmp(LIGENT_GIT_HASH, "unknown") == 0)
|
||||
snprintf(buf_short, sizeof buf_short, "%s", LIGENT_VERSION);
|
||||
@@ -181,7 +181,7 @@ const char *ligent_version_full(void)
|
||||
{
|
||||
const char *v = ligent_version_string();
|
||||
|
||||
/* 括号里:分支 + 构建时间. 这两个是"这份二进制从哪来的", 排查时都要.
|
||||
/* 括号里: 分支 + 构建时间. 这两个是"这份二进制从哪来的", 排查时都要.
|
||||
* 分支在 detached HEAD (CI 上常见)时是 unknown, 那也照实说. */
|
||||
char branch[LIGENT_BRANCH_BUF];
|
||||
branch_display(branch, sizeof branch);
|
||||
@@ -193,7 +193,7 @@ const char *ligent_version_full(void)
|
||||
snprintf(where, sizeof where, "%s, %s %s", branch,
|
||||
LIGENT_BUILD_DATE, LIGENT_BUILD_TIME);
|
||||
|
||||
/* 整体:
|
||||
/* 整体:
|
||||
* ligent 2026.09.22-209538e (master, 2026-09-22 04:32:04)
|
||||
* [GCC 16.2.1 20260810] on linux/x86_64 */
|
||||
snprintf(buf_full, sizeof buf_full, "%s (%s) [%s] on %s",
|
||||
|
||||
+10
-10
@@ -1,6 +1,6 @@
|
||||
/* version.h - 版本号与构建信息
|
||||
*
|
||||
* 版本号由**构建系统注入**, 不写死在源码里:
|
||||
* 版本号由**构建系统注入**, 不写死在源码里:
|
||||
*
|
||||
* LIGENT_VERSION CalVer (YYYY.MM.DD), 来自 git tag; 没有 tag 时用
|
||||
* 源码里这个回退值
|
||||
@@ -9,16 +9,16 @@
|
||||
* LIGENT_BUILD_DATE 构建日期 (UTC, YYYY-MM-DD)
|
||||
* LIGENT_BUILD_TIME 构建时间 (UTC, HH:MM:SS)
|
||||
*
|
||||
* 编译器与目标平台**不走注入**, 在 version.c 里用编译期宏判断 (见下):
|
||||
* 编译器与目标平台**不走注入**, 在 version.c 里用编译期宏判断 (见下):
|
||||
* 那两项是"这份代码被什么编出来、给谁跑的", 编译器自己最清楚, 让构建脚本去猜
|
||||
* 反而多一个出错点.
|
||||
*
|
||||
* 为什么不让源码当"唯一真相":那样每发一次版就要改一次源码、多一个提交, 而且
|
||||
* 为什么不让源码当"唯一真相": 那样每发一次版就要改一次源码、多一个提交, 而且
|
||||
* 从 tarball 还是从 git 编出来的版本号会不一致. 让构建期算出来、编进去, 源码
|
||||
* 只留一个**回退默认值** (给"没有构建系统"的场景, 比如有人直接拿 gcc 编
|
||||
* src 下的所有 .c), 这是 dunst/curl 这类项目的通行做法.
|
||||
*
|
||||
* 为什么是 CalVer 而不是 SemVer:ligent 是个终端工具, 不是被别人链接的库.
|
||||
* 为什么是 CalVer 而不是 SemVer: ligent 是个终端工具, 不是被别人链接的库.
|
||||
* CalVer 表达的是"这是哪天的东西", 对一个还在快速演进的工具比"第几个大版本"
|
||||
* 有用得多 —— 用户看到 2026.02.14 立刻知道新旧, 看到 1.4.2 还得去查. 真到了
|
||||
* 需要表达"破坏性变更"的时候, git 历史和 README 比版本号数字更能说清楚.
|
||||
@@ -26,7 +26,7 @@
|
||||
#ifndef LIGENT_VERSION_H
|
||||
#define LIGENT_VERSION_H
|
||||
|
||||
/* 回退值:只在没有通过 -D 注入时生效 (直接拿 gcc 编、或从没有 .git 的
|
||||
/* 回退值: 只在没有通过 -D 注入时生效 (直接拿 gcc 编、或从没有 .git 的
|
||||
* tarball 编). 改这个值等于"手动打了一个版本", 正常发布流程不该动它. */
|
||||
#ifndef LIGENT_VERSION
|
||||
# define LIGENT_VERSION "0.0.0-unknown"
|
||||
@@ -49,14 +49,14 @@
|
||||
#endif
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 编译器与平台:用编译期宏判断, 不靠构建脚本传 */
|
||||
/* 编译器与平台: 用编译期宏判断, 不靠构建脚本传 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
/*
|
||||
* 为什么要区分这么多分支:__VERSION__ 是 gcc/clang 的约定, **tcc 没有这个宏**
|
||||
* 为什么要区分这么多分支: __VERSION__ 是 gcc/clang 的约定, **tcc 没有这个宏**
|
||||
* (它只有 __TINYC__ 加一个数字), 而 tcc 是这个项目明确支持的编译器之一
|
||||
* (make CC=tcc 是过全套测试的). 所以 tcc 那条腿得单独拼.
|
||||
*
|
||||
* 平台同理用 __linux__ / __APPLE__ 这些宏, **不用 uname()**:宏描述的是
|
||||
* 平台同理用 __linux__ / __APPLE__ 这些宏, **不用 uname()**: 宏描述的是
|
||||
* "这份二进制是给哪个平台编的", uname() 描述的是"现在跑在哪" —— 静态产物
|
||||
* 会被拷到别的机器上跑, 交叉编译时两者也不一致. 版本串里该记的是前者.
|
||||
*/
|
||||
@@ -67,11 +67,11 @@ const char *ligent_compiler_string(void);
|
||||
/* 目标平台, 例如 "linux/x86_64" */
|
||||
const char *ligent_platform_string(void);
|
||||
|
||||
/* 版本号主体:<CalVer>-<hash>, 例如 2026.09.22-209538e
|
||||
/* 版本号主体: <CalVer>-<hash>, 例如 2026.09.22-209538e
|
||||
* hash 未知时只有 CalVer. */
|
||||
const char *ligent_version_string(void);
|
||||
|
||||
/* 完整一行, 给 --version 和 doctor 用:
|
||||
/* 完整一行, 给 --version 和 doctor 用:
|
||||
* ligent 2026.09.22-209538e (master, 2026-09-22 04:32:04) [GCC 16.2.1] on linux/x86_64
|
||||
* 分配在静态缓冲里, 第二次调用会覆盖第一次的结果 —— 只给打印用. */
|
||||
const char *ligent_version_full(void);
|
||||
|
||||
Reference in new issue
Block a user