From d1612f1d3126fef3d189e3f91c407d130fc08144 Mon Sep 17 00:00:00 2001 From: Wang Zhiyu Date: Sun, 4 Oct 2026 13:50:33 +0800 Subject: [PATCH] =?UTF-8?q?style:=20=E5=BA=94=E7=94=A8=20clang-format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agent.c | 435 ++++---- src/agent.h | 68 +- src/archive.c | 168 +-- src/archive.h | 62 +- src/attach.c | 223 ++-- src/attach.h | 20 +- src/capability.c | 127 ++- src/capability.h | 32 +- src/config.c | 334 +++--- src/config.h | 69 +- src/confirm.c | 106 +- src/confirm.h | 26 +- src/doctor.c | 303 +++--- src/doctor.h | 6 +- src/http.c | 465 ++++---- src/http.h | 77 +- src/i18n.c | 2277 +++++++++++++++++++-------------------- src/i18n.h | 26 +- src/image.c | 193 ++-- src/image.h | 70 +- src/json.c | 633 +++++++---- src/json.h | 40 +- src/lineedit.c | 704 +++++++----- src/lineedit.h | 48 +- src/llm.c | 304 +++--- src/llm.h | 53 +- src/main.c | 658 ++++++----- src/markdown.c | 431 +++++--- src/markdown.h | 12 +- src/meta.c | 588 +++++----- src/meta.h | 35 +- src/models.c | 99 +- src/models.h | 16 +- src/priv.c | 23 +- src/priv.h | 28 +- src/sgr.h | 62 +- src/shell.c | 196 ++-- src/shell.h | 8 +- src/spinner.c | 105 +- src/spinner.h | 20 +- src/tools.c | 927 +++++++++------- src/tools.h | 45 +- src/util.c | 325 +++--- src/util.h | 68 +- src/version.c | 75 +- src/version.h | 40 +- tests/fuzz.c | 194 ++-- tests/reduce.c | 72 +- tests/test_agent.c | 130 ++- tests/test_archive.c | 119 +- tests/test_attach.c | 91 +- tests/test_capability.c | 35 +- tests/test_config.c | 215 ++-- tests/test_http.c | 58 +- tests/test_i18n.c | 125 ++- tests/test_image.c | 136 +-- tests/test_json.c | 160 +-- tests/test_lineedit.c | 93 +- tests/test_markdown.c | 291 ++--- tests/test_meta.c | 195 ++-- tests/test_models.c | 54 +- tests/test_priv.c | 73 +- tests/test_spinner.c | 92 +- tests/test_tools.c | 289 +++-- tools/md_demo.c | 219 ++-- 65 files changed, 7288 insertions(+), 5983 deletions(-) diff --git a/src/agent.c b/src/agent.c index c499e95..e2fbe6e 100644 --- a/src/agent.c +++ b/src/agent.c @@ -21,33 +21,29 @@ /* ------------------------------------------------------------------ */ /* 前置声明: 定义在文件靠后的上下文管理一节 */ -static int role_is(const JsonValue* m, const char* want); +static int role_is(const JsonValue *m, const char *want); -static const char* -col(const Agent* a, const char* code) -{ +static const char *col(const Agent *a, const char *code) { return a->color ? code : ""; } /* 把多行文本缩进显示, 最多 max_lines 行 */ -static void -print_indented(const char* text, const char* prefix, int max_lines, - const char* color_code, int use_color) -{ +static void print_indented(const char *text, const char *prefix, int max_lines, + const char *color_code, int use_color) { if (!text) return; - const char* p = text; + const char *p = text; int lines = 0; while (*p) { - const char* nl = strchr(p, '\n'); + const char *nl = strchr(p, '\n'); size_t n = nl ? (size_t)(nl - p) : strlen(p); if (lines >= max_lines) { printf("%s%s%s%s\n", use_color ? C_DIM : "", prefix, - t("agent.output.elided"), use_color ? C_OFF : ""); + t("agent.output.elided"), use_color ? C_OFF : ""); break; } printf("%s%s%.*s%s\n", use_color ? color_code : "", prefix, (int)n, p, - use_color ? C_OFF : ""); + use_color ? C_OFF : ""); lines++; if (!nl) break; @@ -55,23 +51,20 @@ print_indented(const char* text, const char* prefix, int max_lines, } if (lines == 0) printf("%s%s%s%s\n", use_color ? C_DIM : "", prefix, - t("agent.output.empty"), use_color ? C_OFF : ""); + t("agent.output.empty"), use_color ? C_OFF : ""); } /* ------------------------------------------------------------------ */ /* 流式回调 */ /* ------------------------------------------------------------------ */ -typedef struct -{ +typedef struct { int printed; - MdRenderer* md; + MdRenderer *md; } DeltaCtx; -static void -on_delta(void* ud, const char* text) -{ - DeltaCtx* c = ud; +static void on_delta(void *ud, const char *text) { + DeltaCtx *c = ud; /* 一出正文就把等待动画收掉, 否则动画会和正文抢同一行 */ spinner_stop(); /* 走 Markdown 渲染器: 它会处理被 SSE 切断的标记 */ @@ -82,9 +75,7 @@ on_delta(void* ud, const char* text) /* 收到思维链: 说明请求早就发出去了, 模型确实在推理, 而不是卡在网络. * 只换标签, 不打印内容 —— 思维链不显示. */ -static void -on_think(void* ud, const char* text) -{ +static void on_think(void *ud, const char *text) { (void)ud; (void)text; spinner_set_label(t("spinner.thinking")); @@ -94,14 +85,14 @@ on_think(void* ud, const char* text) /* 生命周期 */ /* ------------------------------------------------------------------ */ -char* agent_default_system_prompt(void) -{ +char *agent_default_system_prompt(void) { return xstrdup( "你是用户的 AI 助手\n" "\n" "你可以调用工具来读写文件和执行命令. \n" "工作原则: \n" - "1. 先观察再动手: 不确定文件内容或目录结构时, 先用 list_dir / read_file " + "1. 先观察再动手: 不确定文件内容或目录结构时, 先用 list_dir / " + "read_file " "查看, 不要凭猜测写代码. 搜内容用 run_command (如 grep -rn 模式 目录). " "\n" "2. 修改已有文件优先使用 edit_file: 改一处就带上 read_file 显示的行号 " @@ -125,8 +116,7 @@ char* agent_default_system_prompt(void) "除非用户说明, 不得直接或间接(如使用curl)获取任何在线资源.\n"); } -void agent_init(Agent* a) -{ +void agent_init(Agent *a) { memset(a, 0, sizeof *a); a->messages = json_new_array(); a->max_steps = 0; /* 0 = 不限往返次数 (默认, 由配置覆盖) */ @@ -146,14 +136,12 @@ void agent_init(Agent* a) a->caps.tools = CAP_UNKNOWN; } -void agent_set_color(Agent* a, int enabled) -{ +void agent_set_color(Agent *a, int enabled) { a->color = enabled ? 1 : 0; md_set_enabled(a->md, a->color); } -void agent_free(Agent* a) -{ +void agent_free(Agent *a) { if (!a) return; json_free(a->messages); @@ -164,20 +152,19 @@ void agent_free(Agent* a) a->md = NULL; } -void agent_add_system(Agent* a, const char* prompt) -{ +void agent_add_system(Agent *a, const char *prompt) { if (!prompt) return; - JsonValue* m = json_new_object(); + JsonValue *m = json_new_object(); json_object_set_string(m, "role", "system"); json_object_set_string(m, "content", prompt); /* system 永远是第一条; 历史里已有的旧 system 会被替换而不是叠加 */ - JsonValue* tmp = json_new_array(); + JsonValue *tmp = json_new_array(); json_array_push(tmp, m); for (size_t i = 0; i < json_array_size(a->messages); i++) { - JsonValue* old = json_array_get(a->messages, i); - const char* role = json_get_string(old, "role"); + JsonValue *old = json_array_get(a->messages, i); + const char *role = json_get_string(old, "role"); if (i == 0 && role && strcmp(role, "system") == 0) continue; json_array_push(tmp, json_clone(old)); @@ -191,9 +178,8 @@ void agent_add_system(Agent* a, const char* prompt) * * 为什么需要它: /include 要把文件、图片跟着用户这一句话一起发出去, 而 * agent_add_user 只能放纯字符串. */ -void agent_add_user_parts(Agent* a, const char* text, JsonValue* parts) -{ - JsonValue* m = json_new_object(); +void agent_add_user_parts(Agent *a, const char *text, JsonValue *parts) { + JsonValue *m = json_new_object(); json_object_set_string(m, "role", "user"); if (!parts || json_array_size(parts) == 0) { @@ -203,9 +189,9 @@ void agent_add_user_parts(Agent* a, const char* text, JsonValue* parts) return; } - JsonValue* content = json_new_array(); + JsonValue *content = json_new_array(); if (text && *text) { - JsonValue* tp = json_new_object(); + JsonValue *tp = json_new_object(); json_object_set_string(tp, "type", "text"); json_object_set_string(tp, "text", text); json_array_push(content, tp); @@ -217,25 +203,21 @@ void agent_add_user_parts(Agent* a, const char* text, JsonValue* parts) json_array_push(a->messages, m); } -void agent_add_user(Agent* a, const char* text) -{ - JsonValue* m = json_new_object(); +void agent_add_user(Agent *a, const char *text) { + JsonValue *m = json_new_object(); json_object_set_string(m, "role", "user"); json_object_set_string(m, "content", text); json_array_push(a->messages, m); } -void agent_reset(Agent* a) -{ +void agent_reset(Agent *a) { a->started_at = time(NULL); - JsonValue* keep = NULL; - JsonValue* first = json_array_get(a->messages, 0); - if (first - && strcmp(json_get_string(first, "role") - ? json_get_string(first, "role") - : "", - "system") - == 0) + JsonValue *keep = NULL; + JsonValue *first = json_array_get(a->messages, 0); + if (first && + strcmp(json_get_string(first, "role") ? json_get_string(first, "role") + : "", + "system") == 0) keep = json_clone(first); json_free(a->messages); a->messages = json_new_array(); @@ -243,9 +225,7 @@ void agent_reset(Agent* a) json_array_push(a->messages, keep); } -size_t -agent_history_len(const Agent* a) -{ +size_t agent_history_len(const Agent *a) { return json_array_size(a->messages); } @@ -257,19 +237,15 @@ agent_history_len(const Agent* a) * * 内部判断"历史变了没有"仍然用 agent_history_len (数组真实长度)—— 那是在比 * 大小, 不是在报数. */ -size_t -agent_conversation_len(const Agent* a) -{ +size_t agent_conversation_len(const Agent *a) { size_t n = json_array_size(a->messages); if (n > 0 && role_is(json_array_get(a->messages, 0), "system")) n--; return n; } -JsonValue* -agent_take_last_round(Agent* a) -{ - JsonValue* msgs = a->messages; +JsonValue *agent_take_last_round(Agent *a) { + JsonValue *msgs = a->messages; size_t n = json_array_size(msgs); size_t start = 0; @@ -284,8 +260,8 @@ agent_take_last_round(Agent* a) if (last_user == (size_t)-1 || last_user < start) return NULL; - JsonValue* removed = json_new_array(); - JsonValue** items = msgs->u.array.items; + JsonValue *removed = json_new_array(); + JsonValue **items = msgs->u.array.items; for (size_t i = last_user; i < n; i++) { json_array_push(removed, items[i]); /* 转移所有权 */ } @@ -293,11 +269,10 @@ agent_take_last_round(Agent* a) return removed; } -void agent_replace_history(Agent* a, JsonValue* messages) -{ +void agent_replace_history(Agent *a, JsonValue *messages) { /* 当前生效的 system 提示词要保留 */ - JsonValue* sys = NULL; - JsonValue* first = json_array_get(a->messages, 0); + JsonValue *sys = NULL; + JsonValue *first = json_array_get(a->messages, 0); if (first && role_is(first, "system")) sys = json_clone(first); @@ -308,7 +283,7 @@ void agent_replace_history(Agent* a, JsonValue* messages) if (messages && messages->type == JSON_ARRAY) { for (size_t i = 0; i < json_array_size(messages); i++) { - JsonValue* m = json_array_get(messages, i); + JsonValue *m = json_array_get(messages, i); if (role_is(m, "system")) continue; /* 存档里的 system 不带进来 */ json_array_push(a->messages, json_clone(m)); @@ -324,15 +299,12 @@ void agent_replace_history(Agent* a, JsonValue* messages) /* 是不是 4xx. 图片引用失效没有稳定的措辞可认 (各家不同), 所以只认状态码, * 再配合"历史里确实有图片引用"来决定要不要把图摘掉重试. */ -int agent_is_http_4xx(const char* err) -{ +int agent_is_http_4xx(const char *err) { return err && strstr(err, "HTTP 4") != NULL; } -static int -role_is(const JsonValue* m, const char* want) -{ - const char* r = json_get_string(m, "role"); +static int role_is(const JsonValue *m, const char *want) { + const char *r = json_get_string(m, "role"); return r && strcmp(r, want) == 0; } @@ -344,15 +316,14 @@ role_is(const JsonValue* m, const char* want) * assistant(tool_calls) + tool 那对切散, 下一轮直接 400. * * 判据很直白: content 是数组、且里面一个 text 块都没有. */ -int agent_opens_round(const JsonValue* m) -{ +int agent_opens_round(const JsonValue *m) { if (!role_is(m, "user")) return 0; - JsonValue* c = json_object_get(m, "content"); + JsonValue *c = json_object_get(m, "content"); if (!c || c->type != JSON_ARRAY) return 1; /* 纯字符串 = 真的一轮 */ for (size_t i = 0; i < json_array_size(c); i++) { - const char* t = json_get_string(json_array_get(c, i), "type"); + const char *t = json_get_string(json_array_get(c, i), "type"); if (t && strcmp(t, "text") == 0) return 1; } @@ -374,18 +345,17 @@ int agent_opens_round(const JsonValue* m) * * 现在这里只摆最要紧的那一两项 (改动的具体内容交给 show_edit_diff 去对照), * 参数解析不出来 (模型给了非法 JSON)时就只有工具名 —— 原因在结果里说. */ -static void -show_tool_call(const Agent* a, const char* name, const JsonValue* args) -{ +static void show_tool_call(const Agent *a, const char *name, + const JsonValue *args) { char shown[400] = ""; - const char* path = args ? json_get_string(args, "path") : NULL; + const char *path = args ? json_get_string(args, "path") : NULL; if (args && strcmp(name, TOOL_EDIT_FILE) == 0) { char lines[128]; tool_lines_label(args, lines, sizeof lines); /* diff 只显示"改成什么", 看不出命中几处 —— 所以"全文件替换"和 * "所有匹配都换掉"这两种范围必须在摘要里说清楚. */ - const char* scope = lines[0] ? lines : t("tool.call.all_lines"); + const char *scope = lines[0] ? lines : t("tool.call.all_lines"); if (json_get_bool(args, "replace_all", 0)) scope = tf("tool.call.scope_all", "scope", scope, NULL); /* 路径带引号、行号用方括号: `{/} edit_file "./Makefile" [280, 284]`. @@ -393,17 +363,17 @@ show_tool_call(const Agent* a, const char* name, const JsonValue* args) * read_file 打出来的那些行号. */ snprintf(shown, sizeof shown, "\"%s\" [%s]", path ? path : "?", scope); } else if (args && strcmp(name, TOOL_WRITE_FILE) == 0) { - const char* content = json_get_string(args, "content"); + const char *content = json_get_string(args, "content"); snprintf(shown, sizeof shown, "%s", - tf("agent.call.write", "path", path ? path : "?", "n", - i18n_num((long long)(content ? strlen(content) : 0)), - NULL)); + tf("agent.call.write", "path", path ? path : "?", "n", + i18n_num((long long)(content ? strlen(content) : 0)), + NULL)); } else if (args && strcmp(name, TOOL_RUN_COMMAND) == 0) { /* 提权必须一眼看见: 这条命令会以 root 跑 */ - const char* cmd = json_get_string(args, "command"); + const char *cmd = json_get_string(args, "command"); if (json_get_bool(args, "elevated", 0)) snprintf(shown, sizeof shown, "%s", - tf("agent.call.elevated", "cmd", cmd ? cmd : "", NULL)); + tf("agent.call.elevated", "cmd", cmd ? cmd : "", NULL)); else snprintf(shown, sizeof shown, "%s", cmd ? cmd : ""); } else if (path) { @@ -413,34 +383,32 @@ show_tool_call(const Agent* a, const char* name, const JsonValue* args) /* 其它工具 (包括 meta 自己那一套: list_rounds / read_messages / …): * 参数照原样看一眼, 但压到一行、截到 CALL_JSON_MAX 字符. 它们的参数里 * 没有上面那几种"整段正文", 短 JSON 反而是最省事也最准的摘要. */ - char* j = json_dump(args); + char *j = json_dump(args); size_t n = strlen(j); snprintf(shown, sizeof shown, "%.*s%s", CALL_JSON_MAX, j, - n > (size_t)CALL_JSON_MAX ? "…" : ""); + n > (size_t)CALL_JSON_MAX ? "…" : ""); free(j); } /* 压成一行 */ - for (char* p = shown; *p; p++) + for (char *p = shown; *p; p++) if (*p == '\n' || *p == '\r' || *p == '\t') *p = ' '; - printf("%s%s{/} %s%s", col(a, C_DIM), col(a, C_CYAN), name, - col(a, C_OFF)); + printf("%s%s{/} %s%s", col(a, C_DIM), col(a, C_CYAN), name, col(a, C_OFF)); if (shown[0]) printf("%s %s%s", col(a, C_DIM), shown, col(a, C_OFF)); putchar('\n'); fflush(stdout); } -static void -show_tool_result(const Agent* a, const char* name, const JsonValue* args, - const char* result, int is_error, int diff_shown) -{ +static void show_tool_result(const Agent *a, const char *name, + const JsonValue *args, const char *result, + int is_error, int diff_shown) { if (!a->show_tool_output) { printf("%s -> %s%s\n", col(a, is_error ? C_RED : C_DIM), - is_error ? t("agent.tool.failed") : t("agent.tool.done"), - col(a, C_OFF)); + is_error ? t("agent.tool.failed") : t("agent.tool.done"), + col(a, C_OFF)); fflush(stdout); return; } @@ -450,8 +418,8 @@ show_tool_result(const Agent* a, const char* name, const JsonValue* args, * * 但**确认提示里已经摆过一次** (默认就开着确认), 就不再重复第二遍: 同一个 * 东西在一屏里出现两次只会让人怀疑"是不是要改两次". */ - if (!is_error && !diff_shown && name && strcmp(name, TOOL_EDIT_FILE) == 0 - && args && tool_render_diff(stdout, args, " │ ", a->color)) { + if (!is_error && !diff_shown && name && strcmp(name, TOOL_EDIT_FILE) == 0 && + args && tool_render_diff(stdout, args, " │ ", a->color)) { fflush(stdout); return; } @@ -461,9 +429,7 @@ show_tool_result(const Agent* a, const char* name, const JsonValue* args, } /* 执行一批 tool_calls, 把结果作为 tool 消息追加到历史 */ -static int -execute_tool_calls(Agent* a, JsonValue* tool_calls) -{ +static int execute_tool_calls(Agent *a, JsonValue *tool_calls) { size_t count = json_array_size(tool_calls); size_t interrupted_at = count; /* 中断发生在第几个调用之前 */ @@ -473,28 +439,28 @@ execute_tool_calls(Agent* a, JsonValue* tool_calls) break; } - JsonValue* tc = json_array_get(tool_calls, i); - const char* id = json_get_string(tc, "id"); - JsonValue* fn = json_object_get(tc, "function"); - const char* name = fn ? json_get_string(fn, "name") : NULL; - const char* args_raw = fn ? json_get_string(fn, "arguments") : NULL; + JsonValue *tc = json_array_get(tool_calls, i); + const char *id = json_get_string(tc, "id"); + JsonValue *fn = json_object_get(tc, "function"); + const char *name = fn ? json_get_string(fn, "name") : NULL; + const char *args_raw = fn ? json_get_string(fn, "arguments") : NULL; if (!args_raw) args_raw = "{}"; char jerr[256]; - JsonValue* args = json_parse(args_raw, jerr, sizeof jerr); + JsonValue *args = json_parse(args_raw, jerr, sizeof jerr); /* 摘要打在**执行之前**: 命令可能跑很久, 用户得先看到要做什么. * 参数解析不出来时摘要只有工具名, 原因会在结果里说. */ show_tool_call(a, name ? name : t("agent.tool.name_unknown"), args); int is_error = 0; int diff_shown = 0; /* 确认提示里是不是已经摆过新旧对照 */ - char* result; + char *result; if (!args) { result = xasprintf("错误: 工具参数不是合法 JSON (%s)", jerr); is_error = 1; - } else if (a->confirm && a->is_mutating(name) - && !confirm_tool(name, args)) { + } else if (a->confirm && a->is_mutating(name) && + !confirm_tool(name, args)) { /* 拒绝不等于失败: 把结果回填给模型, 让它换个做法, * 而不是让整轮对话就此中断 */ if (confirm_turn_mode() == CONFIRM_NEVER) @@ -504,8 +470,9 @@ execute_tool_calls(Agent* a, JsonValue* tool_calls) "用户已表示这一轮不再执行任何改动. " "不要再尝试改动, 直接说明你的结论或需要用户做什么. "); else - result = xstrdup("用户拒绝了这次操作. 请换一种不改动磁盘的做法, " - "或先向用户说明你打算做什么并征得同意. "); + result = + xstrdup("用户拒绝了这次操作. 请换一种不改动磁盘的做法, " + "或先向用户说明你打算做什么并征得同意. "); is_error = 1; } else { /* 确认提示里已经把带行号的新旧对照摆出来了, 用户是看着它点头的 —— @@ -519,7 +486,7 @@ execute_tool_calls(Agent* a, JsonValue* tool_calls) show_tool_result(a, name, args, result, is_error, diff_shown); json_free(args); - JsonValue* tm = json_new_object(); + JsonValue *tm = json_new_object(); json_object_set_string(tm, "role", "tool"); json_object_set_string(tm, "tool_call_id", id ? id : ""); json_object_set_string(tm, "content", result); @@ -536,9 +503,9 @@ execute_tool_calls(Agent* a, JsonValue* tool_calls) * 顺序要紧: 占位 tool 消息必须在附件之前, 否则配对又断了. */ if (g_ligent_interrupt) { for (size_t i = interrupted_at; i < count; i++) { - JsonValue* tc = json_array_get(tool_calls, i); - const char* id = json_get_string(tc, "id"); - JsonValue* tm = json_new_object(); + JsonValue *tc = json_array_get(tool_calls, i); + const char *id = json_get_string(tc, "id"); + JsonValue *tm = json_new_object(); json_object_set_string(tm, "role", "tool"); json_object_set_string(tm, "tool_call_id", id ? id : ""); json_object_set_string( @@ -564,9 +531,9 @@ execute_tool_calls(Agent* a, JsonValue* tool_calls) * 服务端回 400: An assistant message with 'tool_calls' must be followed by * tool messages responding to each 'tool_call_id'. 而且这段历史留在上下文 * 里, 之后**每一轮**都会 400. 攒到现在一次性推出去就没有这个问题. */ - JsonValue* attach = image_pending_take(); + JsonValue *attach = image_pending_take(); if (attach && json_array_size(attach) > 0) { - JsonValue* um = json_new_object(); + JsonValue *um = json_new_object(); json_object_set_string(um, "role", "user"); json_object_set(um, "content", attach); /* 取得所有权 */ json_array_push(a->messages, um); @@ -592,14 +559,12 @@ execute_tool_calls(Agent* a, JsonValue* tool_calls) * 模型明确不收图片时把 read_image 摘掉 —— 留着它只会让模型白调一次, * 然后请求被 400 (虽然我们还有降级兜底, 但那是补救, 不如不给). * 模型明确不收 tools 时整张表都不发: 否则每一轮都会以同样的 400 失败. */ -static JsonValue* -tools_for_request(const Agent* a) -{ +static JsonValue *tools_for_request(const Agent *a) { if (!a->tools || json_array_size(a->tools) == 0) return NULL; if (a->caps.tools == CAP_NO) return NULL; - JsonValue* t = json_clone(a->tools); + JsonValue *t = json_clone(a->tools); if (a->caps.vision == CAP_NO) tools_drop(t, TOOL_READ_IMAGE); return t; @@ -607,15 +572,12 @@ tools_for_request(const Agent* a) /* 一次请求要用的一切: 消息副本 (图片引用已展开)+ 收敛过的工具表. * 两者都是新分配的, 用完一起释放 —— 三处 llm_chat 调用点各来一份. */ -typedef struct -{ - JsonValue* messages; - JsonValue* tools; +typedef struct { + JsonValue *messages; + JsonValue *tools; } Req; -static Req -req_prepare(Agent* a) -{ +static Req req_prepare(Agent *a) { Req r; /* 每次构造请求之前体检一遍结构: assistant(tool_calls) 后面必须紧跟对应的 @@ -627,30 +589,28 @@ req_prepare(Agent* a) * 没问题时它只是一次只读扫描, 不分配任何东西. */ { int fixed = 0; - JsonValue* repaired = agent_repair_tool_pairing(a->messages, &fixed); + JsonValue *repaired = agent_repair_tool_pairing(a->messages, &fixed); if (repaired) { json_free(a->messages); a->messages = repaired; fprintf(stderr, "%s%s%s\n", col(a, C_DIM), - tf("agent.structure.fixed", "n", i18n_num(fixed), NULL), - col(a, C_OFF)); + tf("agent.structure.fixed", "n", i18n_num(fixed), NULL), + col(a, C_OFF)); } } int degraded = 0; - JsonValue* wire = image_expand_for_request(a->messages, °raded); + JsonValue *wire = image_expand_for_request(a->messages, °raded); if (degraded > 0) fprintf(stderr, "%s%s%s\n", col(a, C_DIM), - tf("agent.image.degraded", "n", i18n_num(degraded), NULL), - col(a, C_OFF)); + tf("agent.image.degraded", "n", i18n_num(degraded), NULL), + col(a, C_OFF)); r.messages = wire ? wire : json_clone(a->messages); r.tools = tools_for_request(a); return r; } -static void -req_free(Req* r) -{ +static void req_free(Req *r) { json_free(r->messages); json_free(r->tools); r->messages = r->tools = NULL; @@ -669,54 +629,52 @@ req_free(Req* r) * 所以发请求之前统一体检一次: 缺的补占位、孤儿 tool 消息丢掉, 并如实说明. * * 没问题返回 NULL; 修过则返回重建好的数组 (调用方替换旧的). */ -JsonValue* -agent_repair_tool_pairing(const JsonValue* messages, int* out_fixed) -{ +JsonValue *agent_repair_tool_pairing(const JsonValue *messages, + int *out_fixed) { if (out_fixed) *out_fixed = 0; if (!messages || messages->type != JSON_ARRAY) return NULL; - typedef struct - { - char* id; + typedef struct { + char *id; int answered; } Pending; - Pending* pend = NULL; + Pending *pend = NULL; size_t npend = 0; - JsonValue* out = json_new_array(); + JsonValue *out = json_new_array(); int fixed = 0; /* 给还没被回应的调用补一条占位 tool 消息 */ -#define FLUSH_PENDING(where) \ - do { \ - for (size_t k_ = 0; k_ < npend; k_++) { \ - if (pend[k_].answered) \ - continue; \ - JsonValue* tm_ = json_new_object(); \ - json_object_set_string(tm_, "role", "tool"); \ - json_object_set_string(tm_, "tool_call_id", pend[k_].id); \ - json_object_set_string( \ - tm_, "content", \ - "错误: 这条工具调用没有得到结果 (历史结构被改坏了, " \ - "这里补一条占位). 不要假设它执行过. "); \ - json_array_push(out, tm_); \ - fixed++; \ - } \ - for (size_t k_ = 0; k_ < npend; k_++) \ - free(pend[k_].id); \ - free(pend); \ - pend = NULL; \ - npend = 0; \ +#define FLUSH_PENDING(where) \ + do { \ + for (size_t k_ = 0; k_ < npend; k_++) { \ + if (pend[k_].answered) \ + continue; \ + JsonValue *tm_ = json_new_object(); \ + json_object_set_string(tm_, "role", "tool"); \ + json_object_set_string(tm_, "tool_call_id", pend[k_].id); \ + json_object_set_string( \ + tm_, "content", \ + "错误: 这条工具调用没有得到结果 (历史结构被改坏了, " \ + "这里补一条占位). 不要假设它执行过. "); \ + json_array_push(out, tm_); \ + fixed++; \ + } \ + for (size_t k_ = 0; k_ < npend; k_++) \ + free(pend[k_].id); \ + free(pend); \ + pend = NULL; \ + npend = 0; \ } while (0) for (size_t i = 0; i < json_array_size(messages); i++) { - JsonValue* m = json_array_get(messages, i); - const char* role = json_get_string(m, "role"); + JsonValue *m = json_array_get(messages, i); + const char *role = json_get_string(m, "role"); /* tool 消息: 只留下"确实在等它"的那些, 孤儿丢掉 */ if (role && strcmp(role, "tool") == 0) { - const char* id = json_get_string(m, "tool_call_id"); + const char *id = json_get_string(m, "tool_call_id"); size_t hit = (size_t)-1; for (size_t k = 0; k < npend; k++) if (!pend[k].answered && id && strcmp(pend[k].id, id) == 0) { @@ -739,11 +697,11 @@ agent_repair_tool_pairing(const JsonValue* messages, int* out_fixed) if (!role || strcmp(role, "assistant") != 0) continue; - JsonValue* tcs = json_object_get(m, "tool_calls"); + JsonValue *tcs = json_object_get(m, "tool_calls"); if (!tcs || tcs->type != JSON_ARRAY) continue; for (size_t k = 0; k < json_array_size(tcs); k++) { - const char* id = json_get_string(json_array_get(tcs, k), "id"); + const char *id = json_get_string(json_array_get(tcs, k), "id"); if (!id || !*id) continue; pend = xrealloc(pend, (npend + 1) * sizeof *pend); @@ -774,12 +732,10 @@ agent_repair_tool_pairing(const JsonValue* messages, int* out_fixed) * 1. 它是**兜底**, 主要防线是发送前就把格式和体积查掉; * 2. 关键词只收那些几乎不可能出现在别处的词; * 3. 不确定时**不动手** —— 宁可让错误如实报出来, 也不误删用户的图. */ -static int -err_mentions_image(const char* err) -{ - static const char* kw[] = { "image", "vision", "multimodal", - "content type", "media type", "图片", - "图像", "模态", NULL }; +static int err_mentions_image(const char *err) { + static const char *kw[] = {"image", "vision", "multimodal", + "content type", "media type", "图片", + "图像", "模态", NULL}; if (!err) return 0; for (size_t i = 0; kw[i]; i++) @@ -798,9 +754,7 @@ err_mentions_image(const char* err) * 两条理由不同: 开关说的是"启动时别自动探测", 不是"禁止探测"; 而缓存是 * "这次进程里已经测过" —— 用户显式敲了 /probe, 就该真的重测一遍, 否则 * 敲了等于没敲. */ -static void -ensure_caps(Agent* a, int force) -{ +static void ensure_caps(Agent *a, int force) { if (!force && !a->probe_caps) return; /* 关掉探测: 不下任何结论, 一切照旧放行 */ if (force) @@ -808,26 +762,26 @@ ensure_caps(Agent* a, int force) char cerr[256] = ""; spinner_start(t("spinner.probe")); - int probed - = cap_get(&a->llm, 1, &a->caps, a->llm.verbose, cerr, sizeof cerr); + int probed = + cap_get(&a->llm, 1, &a->caps, a->llm.verbose, cerr, sizeof cerr); spinner_stop(); if (!probed) return; /* 命中缓存, 不用再啰嗦一遍 */ /* 一行灰字把结论说清楚. 探测到"不支持"时把**因此做了什么**也写在同一行里, * 否则用户只看到"不支持", 不知道我们顺手把工具藏了. */ - const char* vis = a->caps.vision == CAP_YES ? t("agent.caps.yes") - : a->caps.vision == CAP_NO ? t("agent.caps.no.vision") - : t("agent.caps.unknown"); - const char* tl = a->caps.tools == CAP_YES ? t("agent.caps.yes") - : a->caps.tools == CAP_NO ? t("agent.caps.no.tools") - : t("agent.caps.unknown"); + const char *vis = a->caps.vision == CAP_YES ? t("agent.caps.yes") + : a->caps.vision == CAP_NO ? t("agent.caps.no.vision") + : t("agent.caps.unknown"); + const char *tl = a->caps.tools == CAP_YES ? t("agent.caps.yes") + : a->caps.tools == CAP_NO ? t("agent.caps.no.tools") + : t("agent.caps.unknown"); char note[320] = ""; if (a->caps.vision == CAP_UNKNOWN || a->caps.tools == CAP_UNKNOWN) snprintf(note, sizeof note, "%s", - tf("agent.caps.note", "why", - cerr[0] ? cerr : t("agent.caps.baseline_failed"), NULL)); + tf("agent.caps.note", "why", + cerr[0] ? cerr : t("agent.caps.baseline_failed"), NULL)); fprintf( stderr, "%s%s%s\n", col(a, C_DIM), @@ -835,17 +789,10 @@ ensure_caps(Agent* a, int force) col(a, C_OFF)); } -void agent_ensure_caps(Agent* a) -{ - ensure_caps(a, 0); -} -void agent_probe_now(Agent* a) -{ - ensure_caps(a, 1); -} +void agent_ensure_caps(Agent *a) { ensure_caps(a, 0); } +void agent_probe_now(Agent *a) { ensure_caps(a, 1); } -int agent_run(Agent* a) -{ +int agent_run(Agent *a) { char err[1024]; /* "这一轮"的定义就在这儿: 一条用户消息引发的一整串工具调用. @@ -864,7 +811,7 @@ int agent_run(Agent* a) spinner_start(t("spinner.waiting")); Req req = req_prepare(a); - LlmResponse* resp = llm_chat( + LlmResponse *resp = llm_chat( &a->llm, req.messages, req.tools, a->llm.stream ? on_delta : NULL, &dc, a->llm.stream ? on_think : NULL, NULL, err, sizeof err); req_free(&req); @@ -880,8 +827,8 @@ int agent_run(Agent* a) * **真的删掉**, 不可逆. 用一句猜出来的判据去毁掉对话记录, * 代价不对等. * 2. 它把"历史太大"这个本该由用户决定的事 (/back 摘掉哪一轮、/reset - * 重来、还是换个窗口更大的模型)自动化掉了, 用户看到的是对话莫名其妙 - * 少了几轮. + * 重来、还是换个窗口更大的模型)自动化掉了, + * 用户看到的是对话莫名其妙 少了几轮. * * 现在超限就是如实报错, 历史一个字节都不动, 怎么处理由用户决定. */ @@ -891,27 +838,27 @@ int agent_run(Agent* a) * 这个变体不支持……). 那条引用躺在历史里, 不修的话之后每一轮都会以 * 同样的 4xx 失败、历史却原样保留 —— 用户只剩 /reset 一条路. * 这里宁可丢掉这些图, 也不能让整段对话死在这儿. */ - if (!resp && !dc.printed && !img_dropped && agent_is_http_4xx(err) - && image_has_refs(a->messages) && err_mentions_image(err)) { + if (!resp && !dc.printed && !img_dropped && agent_is_http_4xx(err) && + image_has_refs(a->messages) && err_mentions_image(err)) { int n = image_degrade_all(a->messages); if (n > 0) { img_dropped = 1; fprintf(stderr, "%s%s%s\n", col(a, C_DIM), - tf("agent.img.rejected", "n", i18n_num(n), NULL), - col(a, C_OFF)); + 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)); + tf("agent.img.server_said", "error", err, NULL), + col(a, C_OFF)); md_finish(a->md); dc.printed = 0; spinner_start(t("spinner.waiting")); Req req3 = req_prepare(a); resp = llm_chat(&a->llm, req3.messages, req3.tools, - a->llm.stream ? on_delta : NULL, &dc, - a->llm.stream ? on_think : NULL, NULL, err, - sizeof err); + a->llm.stream ? on_delta : NULL, &dc, + a->llm.stream ? on_think : NULL, NULL, err, + sizeof err); req_free(&req3); spinner_stop(); } @@ -920,7 +867,7 @@ int agent_run(Agent* a) if (!resp) { md_finish(a->md); fprintf(stderr, "%s%s%s\n", col(a, C_RED), - tf("agent.error", "error", err, NULL), col(a, C_OFF)); + tf("agent.error", "error", err, NULL), col(a, C_OFF)); return -1; } @@ -929,7 +876,7 @@ int agent_run(Agent* a) if (dc.printed) fputc('\n', stdout); printf("%s%s%s\n", col(a, C_DIM), t("agent.interrupted"), - col(a, C_OFF)); + col(a, C_OFF)); /* 把已经生成的部分内容留在历史里, 保持上下文连贯 */ json_array_push(a->messages, json_clone(resp->message)); llm_response_free(resp); @@ -944,7 +891,7 @@ int agent_run(Agent* a) * 否则内容被解析出来却永远不显示. */ int wrote_text = dc.printed; if (!wrote_text) { - const char* content = json_get_string(resp->message, "content"); + const char *content = json_get_string(resp->message, "content"); if (content && *content) { md_feed(a->md, content); wrote_text = 1; @@ -957,30 +904,30 @@ int agent_run(Agent* a) if (resp->incomplete) { fprintf(stderr, "%s%s%s\n", col(a, C_DIM), - tf("agent.stream.broken", "why", - resp->incomplete_reason ? resp->incomplete_reason : "", - NULL), - col(a, C_OFF)); + tf("agent.stream.broken", "why", + resp->incomplete_reason ? resp->incomplete_reason : "", + NULL), + col(a, C_OFF)); } if (resp->usage) { - a->tok_prompt - += (long long)json_get_number(resp->usage, "prompt_tokens", 0); - a->tok_completion += (long long)json_get_number( - resp->usage, "completion_tokens", 0); - a->tok_total - += (long long)json_get_number(resp->usage, "total_tokens", 0); - a->tok_last_prompt - = (long long)json_get_number(resp->usage, "prompt_tokens", 0); + a->tok_prompt += + (long long)json_get_number(resp->usage, "prompt_tokens", 0); + a->tok_completion += + (long long)json_get_number(resp->usage, "completion_tokens", 0); + a->tok_total += + (long long)json_get_number(resp->usage, "total_tokens", 0); + a->tok_last_prompt = + (long long)json_get_number(resp->usage, "prompt_tokens", 0); a->usage_reported = 1; } a->api_calls++; json_array_push(a->messages, json_clone(resp->message)); - JsonValue* tool_calls = json_object_get(resp->message, "tool_calls"); - int has_tools - = a->use_tools && tool_calls && json_array_size(tool_calls) > 0; + JsonValue *tool_calls = json_object_get(resp->message, "tool_calls"); + int has_tools = + a->use_tools && tool_calls && json_array_size(tool_calls) > 0; if (has_tools) tool_calls = json_clone(tool_calls); llm_response_free(resp); @@ -988,13 +935,13 @@ int agent_run(Agent* a) if (!has_tools) { if (step == 0 && a->llm.stream && !dc.printed) { /* 既没输出也没调工具, 提示一下, 避免看起来像卡死 */ - const char* c = json_get_string( + const char *c = json_get_string( json_array_get(a->messages, - json_array_size(a->messages) - 1), + json_array_size(a->messages) - 1), "content"); if (!c || !*c) printf("%s%s%s\n", col(a, C_DIM), t("agent.empty_reply"), - col(a, C_OFF)); + col(a, C_OFF)); } return 0; } @@ -1009,8 +956,8 @@ int agent_run(Agent* a) * 分别在 llm_chat 和 execute_tool_calls 里. */ if (a->max_steps > 0 && step + 1 >= a->max_steps) { fprintf(stderr, "%s%s%s\n", col(a, C_DIM), - tf("agent.max_steps", "n", i18n_num(a->max_steps), NULL), - col(a, C_OFF)); + tf("agent.max_steps", "n", i18n_num(a->max_steps), NULL), + col(a, C_OFF)); return 0; } } diff --git a/src/agent.h b/src/agent.h index 2627d48..d524ea9 100644 --- a/src/agent.h +++ b/src/agent.h @@ -9,35 +9,35 @@ #include /* Agent 用具名 struct (而不是匿名 typedef), 这样别的头文件可以只写 - * `struct Agent;` 做前置声明, 不必把整个定义 include 进去. + * `struct Agent;` 做前置声明, 不必把整个定义 include 进去. * 对使用者没有任何区别, `Agent x;` 照样写. */ struct Agent { - LlmConfig llm; - JsonValue *messages; /* 消息数组, 跨轮保留 */ - JsonValue *tools; /* tools schema, use_tools=0 时为 NULL */ - int max_steps; /* 单轮最多几次模型往返; <= 0 = 不限 */ - int use_tools; - int color; /* 是否输出 ANSI 颜色 */ - int show_tool_output; - int confirm; /* 写操作与命令执行前是否询问 */ - int probe_caps; /* 首个请求前跑一次能力探测序列 */ - Capabilities caps; /* 探测结果: 决定要不要暴露 read_image / 工具表 */ + LlmConfig llm; + JsonValue *messages; /* 消息数组, 跨轮保留 */ + JsonValue *tools; /* tools schema, use_tools=0 时为 NULL */ + int max_steps; /* 单轮最多几次模型往返; <= 0 = 不限 */ + int use_tools; + int color; /* 是否输出 ANSI 颜色 */ + int show_tool_output; + int confirm; /* 写操作与命令执行前是否询问 */ + int probe_caps; /* 首个请求前跑一次能力探测序列 */ + Capabilities caps; /* 探测结果: 决定要不要暴露 read_image / 工具表 */ - /* 工具分发. 默认指向 tools.c 里那套; meta 会话换成自己的, - * 于是两边的工具集天然隔离 —— 主模型看不到也调不到 meta 的工具, + /* 工具分发. 默认指向 tools.c 里那套; meta 会话换成自己的, + * 于是两边的工具集天然隔离 —— 主模型看不到也调不到 meta 的工具, * 反过来也一样. agent_run 只认这两个指针. */ char *(*exec_tool)(const char *name, const JsonValue *args, int *is_error); - int (*is_mutating)(const char *name); + int (*is_mutating)(const char *name); /* 本次会话的 token 用量 (服务端在响应里回报, 这里只做累加) */ - long long tok_prompt; - long long tok_completion; - long long tok_total; - long long tok_last_prompt; /* 最近一次的输入 token ≈ 当前上下文大小 */ - int api_calls; - int usage_reported; /* 服务端是否报过 usage */ - time_t started_at; /* 本轮对话开始时间, 存档文件名要用 */ - MdRenderer *md; /* 助手回复的 Markdown 渲染器 */ + long long tok_prompt; + long long tok_completion; + long long tok_total; + long long tok_last_prompt; /* 最近一次的输入 token ≈ 当前上下文大小 */ + int api_calls; + int usage_reported; /* 服务端是否报过 usage */ + time_t started_at; /* 本轮对话开始时间, 存档文件名要用 */ + MdRenderer *md; /* 助手回复的 Markdown 渲染器 */ }; typedef struct Agent Agent; @@ -51,31 +51,31 @@ 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); -/* 确保模型能力已探测 (按 base_url+model 缓存, 只真跑一次). +/* 确保模型能力已探测 (按 base_url+model 缓存, 只真跑一次). * 探测被关掉时什么都不做. */ void agent_ensure_caps(Agent *a); -/* 手动探测一次: **无视** --no-probe 与缓存, 重新测 (REPL 的 /probe 用它). +/* 手动探测一次: **无视** --no-probe 与缓存, 重新测 (REPL 的 /probe 用它). * 与自动探测的唯一区别就是这两点 —— 打出来的结论是同一份. */ void agent_probe_now(Agent *a); -/* 开关彩色 + Markdown 渲染. +/* 开关彩色 + Markdown 渲染. * 关闭时助手回复逐字节原样输出, 保证管道 / 重定向拿到干净文本. */ void agent_set_color(Agent *a, int enabled); /* 清空对话历史 (保留 system 提示词) */ void agent_reset(Agent *a); -/* 从最后一条 user 消息开始跑, 直到模型不再请求工具. +/* 从最后一条 user 消息开始跑, 直到模型不再请求工具. * 返回 0 正常结束, -1 出错, 1 被用户中断. */ int agent_run(Agent *a); -/* 结构体检: 保证 assistant(tool_calls) 后面紧跟对应的 tool 消息. - * 没问题返回 NULL; 修过则返回重建好的数组 (调用方负责替换旧的). +/* 结构体检: 保证 assistant(tool_calls) 后面紧跟对应的 tool 消息. + * 没问题返回 NULL; 修过则返回重建好的数组 (调用方负责替换旧的). * *out_fixed 回填补/删的处数. */ JsonValue *agent_repair_tool_pairing(const JsonValue *messages, int *out_fixed); @@ -85,13 +85,13 @@ size_t agent_history_len(const Agent *a); /* 面向用户的"历史消息数": 不含 system 提示词 */ size_t agent_conversation_len(const Agent *a); -/* 用 given 整体替换历史 (取得所有权). 存档里的 system 消息会被丢掉, - * 保留当前生效的那条 —— 否则载入旧存档会把用户现在的提示词换掉. +/* 用 given 整体替换历史 (取得所有权). 存档里的 system 消息会被丢掉, + * 保留当前生效的那条 —— 否则载入旧存档会把用户现在的提示词换掉. * 同时把对话开始时间重置为 now. */ void agent_replace_history(Agent *a, JsonValue *messages); -/* 摘掉最近一整轮 (最后一条 user 消息及其之后的一切), 返回被摘掉的消息数组, - * 没有可摘的则返回 NULL. 调用者负责释放. +/* 摘掉最近一整轮 (最后一条 user 消息及其之后的一切), 返回被摘掉的消息数组, + * 没有可摘的则返回 NULL. 调用者负责释放. * 用于 /back: 模型调用的工具与结果必须整组一起走, 不能只删中间几条. */ JsonValue *agent_take_last_round(Agent *a); @@ -100,7 +100,7 @@ int agent_is_http_4xx(const char *err); /* 这条 user 消息是否开启新的一轮. 判据见 agent.c: **只有图片附件、没有文字的** * user 消息不算新的一轮 —— 它是 read_image 补上去的, 按它切轮会把紧邻的 - * assistant(tool_calls) 与 tool 结果拆散 (下一轮请求直接 400). + * assistant(tool_calls) 与 tool 结果拆散 (下一轮请求直接 400). * meta.c 切轮时必须用同一个判据, 否则它报的"轮号"和主循环的口径对不上. */ int agent_opens_round(const JsonValue *m); diff --git a/src/archive.c b/src/archive.c index 60732fe..0f595d8 100644 --- a/src/archive.c +++ b/src/archive.c @@ -3,25 +3,23 @@ #include "i18n.h" #include "util.h" +#include +#include #include #include #include -#include -#include -#include #include +#include #define ARCHIVE_SUBDIR "ligent.archive" #define ARCHIVE_FORMAT "ligent.archive" -#define ARCHIVE_EXT ".json" +#define ARCHIVE_EXT ".json" -char *archive_dir(const char *bindir) -{ +char *archive_dir(const char *bindir) { return xasprintf("%s/%s", bindir && *bindir ? bindir : ".", ARCHIVE_SUBDIR); } -static void stamp(time_t t, const char *fmt, char *out, size_t n) -{ +static void stamp(time_t t, const char *fmt, char *out, size_t n) { struct tm tm; localtime_r(&t, &tm); strftime(out, n, fmt, &tm); @@ -31,23 +29,22 @@ static void stamp(time_t t, const char *fmt, char *out, size_t n) /* 名字生成 */ /* ------------------------------------------------------------------ */ -/* 随机字母数字串由 util.c 的 random_alnum 提供 ([a-z0-9], 与用户给的例子一致). +/* 随机字母数字串由 util.c 的 random_alnum 提供 ([a-z0-9], 与用户给的例子一致). * 图片备份名用的也是同一个函数 —— 以前两处各写了一份, 连退化种子都各写一遍. */ -/* 接一级分支之后是否还塞得下 (前缀也算在内). +/* 接一级分支之后是否还塞得下 (前缀也算在内). * meta 根多一个 'm', 所以要按 meta_len 算. */ -static int branch_fits(const char *lineage, size_t prefixlen, size_t meta_len) -{ - size_t need = prefixlen + strlen(lineage) + 1 + meta_len - + strlen(ARCHIVE_EXT); +static int branch_fits(const char *lineage, size_t prefixlen, size_t meta_len) { + size_t need = + prefixlen + strlen(lineage) + 1 + meta_len + strlen(ARCHIVE_EXT); return need <= ARCHIVE_NAME_MAX; } int archive_next_name(const char *bindir, const char *lineage, - const char *prefix, int meta, char **out, - char *errbuf, size_t errlen) -{ - if (out) *out = NULL; + const char *prefix, int meta, char **out, char *errbuf, + size_t errlen) { + if (out) + *out = NULL; char *dir = archive_dir(bindir); mkdir_p(dir); @@ -78,7 +75,10 @@ int archive_next_name(const char *bindir, const char *lineage, char *full = xasprintf("%s/%s%s", dir, name, ARCHIVE_EXT); if (access(full, F_OK) != 0) { free(dir); - if (out) *out = name; else free(name); + if (out) + *out = name; + else + free(name); free(full); return 0; } @@ -98,25 +98,26 @@ int archive_next_name(const char *bindir, const char *lineage, int archive_export(const JsonValue *messages, const char *bindir, const char *lineage, const char *prefix, int meta, - time_t created, time_t saved, - const char *model, const char *base_url, - char **out_path, char **out_name, - char *errbuf, size_t errlen) -{ - if (out_path) *out_path = NULL; - if (out_name) *out_name = NULL; + time_t created, time_t saved, const char *model, + const char *base_url, char **out_path, char **out_name, + char *errbuf, size_t errlen) { + if (out_path) + *out_path = NULL; + if (out_name) + *out_name = NULL; char *dir = archive_dir(bindir); if (mkdir_p(dir) != 0) { snprintf(errbuf, errlen, "%s", - tf("archive.err.mkdir", "dir", dir, - "error", strerror(errno), NULL)); + tf("archive.err.mkdir", "dir", dir, "error", strerror(errno), + NULL)); free(dir); return -1; } char *name = NULL; - if (archive_next_name(bindir, lineage, prefix, meta, &name, errbuf, errlen) != 0) { + if (archive_next_name(bindir, lineage, prefix, meta, &name, errbuf, + errlen) != 0) { free(dir); return -1; } @@ -125,13 +126,13 @@ int archive_export(const JsonValue *messages, const char *bindir, char ctime_s[32], stime_s[32]; stamp(created, "%Y-%m-%d %H:%M:%S", ctime_s, sizeof ctime_s); - stamp(saved, "%Y-%m-%d %H:%M:%S", stime_s, sizeof stime_s); + stamp(saved, "%Y-%m-%d %H:%M:%S", stime_s, sizeof stime_s); JsonValue *root = json_new_object(); json_object_set_string(root, "format", ARCHIVE_FORMAT); json_object_set_number(root, "version", 2); json_object_set_string(root, "name", name); - /* 记下来源, 这样即使文件名被改名或谱系因超长而重置, + /* 记下来源, 这样即使文件名被改名或谱系因超长而重置, * 也能从内容里看出它从哪来的 */ /* meta 版是另起的新根, 所以 parent 记空 —— 出处已经写在文件名里了 */ json_object_set_string(root, "parent", @@ -150,24 +151,34 @@ int archive_export(const JsonValue *messages, const char *bindir, FILE *f = fopen(path, "wb"); if (!f) { snprintf(errbuf, errlen, "%s", - tf("archive.err.write_open", "path", path, - "error", strerror(errno), NULL)); - free(text); free(path); free(name); + tf("archive.err.write_open", "path", path, "error", + strerror(errno), NULL)); + free(text); + free(path); + free(name); return -1; } size_t n = strlen(text); int ok = (fwrite(text, 1, n, f) == n); - if (fclose(f) != 0) ok = 0; + if (fclose(f) != 0) + ok = 0; free(text); if (!ok) { snprintf(errbuf, errlen, "%s", tf("archive.err.write_failed", "path", path, NULL)); - free(path); free(name); + free(path); + free(name); return -1; } - if (out_path) *out_path = path; else free(path); - if (out_name) *out_name = name; else free(name); + if (out_path) + *out_path = path; + else + free(path); + if (out_name) + *out_name = name; + else + free(name); return 0; } @@ -175,36 +186,41 @@ int archive_export(const JsonValue *messages, const char *bindir, /* 列举与载入 */ /* ------------------------------------------------------------------ */ -static int cmp_entry(const void *a, const void *b) -{ +static int cmp_entry(const void *a, const void *b) { const ArchiveEntry *x = a, *y = b; /* 新的在前; 时间相同时按名字, 保证顺序稳定 */ int c = strcmp(y->created ? y->created : "", x->created ? x->created : ""); - if (c) return c; + if (c) + return c; return strcmp(x->name ? x->name : "", y->name ? y->name : ""); } -size_t archive_list(const char *bindir, ArchiveEntry **out) -{ - if (out) *out = NULL; +size_t archive_list(const char *bindir, ArchiveEntry **out) { + if (out) + *out = NULL; char *dir = archive_dir(bindir); DIR *d = opendir(dir); - if (!d) { free(dir); return 0; } + if (!d) { + free(dir); + return 0; + } ArchiveEntry *list = NULL; size_t count = 0, cap = 0; struct dirent *de; while ((de = readdir(d)) != NULL) { - if (!str_ends_with(de->d_name, ARCHIVE_EXT)) continue; + if (!str_ends_with(de->d_name, ARCHIVE_EXT)) + continue; char *path = xasprintf("%s/%s", dir, de->d_name); size_t stemlen = strlen(de->d_name) - strlen(ARCHIVE_EXT); char *stem = xstrndup(de->d_name, stemlen); char err[256]; - char *created = NULL, *saved = NULL, *model = NULL, *name = NULL, *parent = NULL; - JsonValue *msgs = archive_load(path, &created, &saved, &model, &name, &parent, - err, sizeof err); + char *created = NULL, *saved = NULL, *model = NULL, *name = NULL, + *parent = NULL; + JsonValue *msgs = archive_load(path, &created, &saved, &model, &name, + &parent, err, sizeof err); if (count == cap) { cap = cap ? cap * 2 : 16; @@ -214,8 +230,8 @@ size_t archive_list(const char *bindir, ArchiveEntry **out) memset(e, 0, sizeof *e); e->path = path; e->name = stem; - e->created = created; /* 时间只从内容里读, 不看 mtime */ - e->saved = saved; /* 上次写出这份存档的时间 */ + e->created = created; /* 时间只从内容里读, 不看 mtime */ + e->saved = saved; /* 上次写出这份存档的时间 */ e->model = model; e->count = msgs ? (long)json_array_size(msgs) : -1; /* parent 只用于展示, 这里不单独留存 */ @@ -226,15 +242,18 @@ size_t archive_list(const char *bindir, ArchiveEntry **out) closedir(d); free(dir); - if (count > 1) qsort(list, count, sizeof(ArchiveEntry), cmp_entry); - if (out) *out = list; - else archive_list_free(list, count); + if (count > 1) + qsort(list, count, sizeof(ArchiveEntry), cmp_entry); + if (out) + *out = list; + else + archive_list_free(list, count); return count; } -void archive_list_free(ArchiveEntry *list, size_t n) -{ - if (!list) return; +void archive_list_free(ArchiveEntry *list, size_t n) { + if (!list) + return; for (size_t i = 0; i < n; i++) { free(list[i].path); free(list[i].name); @@ -246,21 +265,24 @@ void archive_list_free(ArchiveEntry *list, size_t n) free(list); } -static void dup_opt(char **dst, const char *src) -{ - if (dst) *dst = src ? xstrdup(src) : NULL; +static void dup_opt(char **dst, const char *src) { + if (dst) + *dst = src ? xstrdup(src) : NULL; } JsonValue *archive_load(const char *path, char **out_created, char **out_saved, - char **out_model, - char **out_name, char **out_parent, - char *errbuf, size_t errlen) -{ - if (out_created) *out_created = NULL; - if (out_saved) *out_saved = NULL; - if (out_model) *out_model = NULL; - if (out_name) *out_name = NULL; - if (out_parent) *out_parent = NULL; + char **out_model, char **out_name, char **out_parent, + char *errbuf, size_t errlen) { + if (out_created) + *out_created = NULL; + if (out_saved) + *out_saved = NULL; + if (out_model) + *out_model = NULL; + if (out_name) + *out_name = NULL; + if (out_parent) + *out_parent = NULL; size_t len = 0; char *text = read_entire_file(path, &len); @@ -294,10 +316,10 @@ JsonValue *archive_load(const char *path, char **out_created, char **out_saved, } dup_opt(out_created, json_get_string(root, "created")); - dup_opt(out_saved, json_get_string(root, "saved")); - dup_opt(out_model, json_get_string(root, "model")); - dup_opt(out_name, json_get_string(root, "name")); - dup_opt(out_parent, json_get_string(root, "parent")); + dup_opt(out_saved, json_get_string(root, "saved")); + dup_opt(out_model, json_get_string(root, "model")); + dup_opt(out_name, json_get_string(root, "name")); + dup_opt(out_parent, json_get_string(root, "parent")); JsonValue *copy = json_clone(msgs); json_free(root); diff --git a/src/archive.h b/src/archive.h index 07d1f85..67969ff 100644 --- a/src/archive.h +++ b/src/archive.h @@ -1,6 +1,6 @@ /* archive.h - 对话存档: 导出与载入 * - * 命名方式刻意做成"文件名即谱系": + * 命名方式刻意做成"文件名即谱系": * * a1b2c3d4 从零开始的一段对话 * a1b2c3d4_a9x 从 a1b2c3d4 分出来的分支 @@ -8,10 +8,10 @@ * * 这样不需要任何索引文件, 光看目录就能看出分支关系, 也不会因为索引与 * 实际文件不同步而出错. 代价是名字会越来越长, 所以超过长度上限时 - * 重新起一个 8 位随机根 (谱系就此断开, 但至少不会撑爆文件名). + * 重新起一个 8 位随机根 (谱系就此断开, 但至少不会撑爆文件名). * - * 存档放在「二进制所在目录」下的 ligent.archive/ 里 —— 和配置同一个原则: - * 整个程序连同它的数据都待在一个目录里, 拷走即走. + * 存档放在「二进制所在目录」下的 ligent.archive/ 里 —— 和配置同一个原则: + * 整个程序连同它的数据都待在一个目录里, 拷走即走. */ #ifndef LIGENT_ARCHIVE_H #define LIGENT_ARCHIVE_H @@ -22,22 +22,22 @@ /* 完整文件名 (含 .json)的长度上限 */ #define ARCHIVE_NAME_MAX 240 /* 根名与每级分支后缀的长度 */ -#define ARCHIVE_ROOT_LEN 8 +#define ARCHIVE_ROOT_LEN 8 #define ARCHIVE_BRANCH_LEN 3 -/* meta 改过的对话另起一个根, 但名字里留住出处: <原名>_m<3位>. - * 标记是 'm', 所以"下划线后有 4 个字符"只可能是 meta 根, +/* meta 改过的对话另起一个根, 但名字里留住出处: <原名>_m<3位>. + * 标记是 'm', 所以"下划线后有 4 个字符"只可能是 meta 根, * 而普通分支固定是 3 个 —— 按长度就能分开, 不会歧义. */ -#define ARCHIVE_META_MARK 'm' -#define ARCHIVE_META_LEN (1 + ARCHIVE_BRANCH_LEN) +#define ARCHIVE_META_MARK 'm' +#define ARCHIVE_META_LEN (1 + ARCHIVE_BRANCH_LEN) typedef struct { - char *path; /* 完整路径 */ - char *name; /* 文件名, 不含 .json */ - char *parent; /* 来源存档名; 是自己起的根则为空串 */ - char *created; /* "YYYY-MM-DD HH:MM:SS" */ - char *saved; - char *model; - long count; /* 消息条数; -1 表示读取失败 */ + char *path; /* 完整路径 */ + char *name; /* 文件名, 不含 .json */ + char *parent; /* 来源存档名; 是自己起的根则为空串 */ + char *created; /* "YYYY-MM-DD HH:MM:SS" */ + char *saved; + char *model; + long count; /* 消息条数; -1 表示读取失败 */ } ArchiveEntry; /* 存档目录: /ligent.archive (malloc) */ @@ -47,33 +47,33 @@ char *archive_dir(const char *bindir); * 区分开: 它只是"换掉当前对话之前先留一手", 不属于分支树的一部分. */ #define ARCHIVE_TMP_PREFIX "tmp_" -/* 生成下一个可用的文件名 (不含 .json), 保证在目录里不冲突. - * lineage 为空则起一个新根, 否则在其后接 _<3位随机>; - * 接上去会超出长度上限时也退回新根. - * prefix 加在名字最前面 (例如临时存档的 "tmp_"), 长度上限把它也算进去. +/* 生成下一个可用的文件名 (不含 .json), 保证在目录里不冲突. + * lineage 为空则起一个新根, 否则在其后接 _<3位随机>; + * 接上去会超出长度上限时也退回新根. + * prefix 加在名字最前面 (例如临时存档的 "tmp_"), 长度上限把它也算进去. * meta=1 时接的是 _m<3位>, 语义是"这份是 meta 改过的", 调用方应把 - * parent 记成空 (新根). + * parent 记成空 (新根). * 成功返回 0 并把名字写进 out (malloc), 失败返回 -1 并写 errbuf. */ int archive_next_name(const char *bindir, const char *lineage, - const char *prefix, int meta, char **out, - char *errbuf, size_t errlen); + const char *prefix, int meta, char **out, char *errbuf, + size_t errlen); -/* 导出. lineage 为来源存档名 (NULL/空 = 新根), prefix 见上. - * meta=1 表示这份是 meta 编辑过的: 名字用 _m<3位>, 且 parent 记空. +/* 导出. lineage 为来源存档名 (NULL/空 = 新根), prefix 见上. + * meta=1 表示这份是 meta 编辑过的: 名字用 _m<3位>, 且 parent 记空. * 成功返回 0, out_path 与 out_name 均为 malloc (可为 NULL). */ int archive_export(const JsonValue *messages, const char *bindir, const char *lineage, const char *prefix, int meta, - time_t created, time_t saved, - const char *model, const char *base_url, - char **out_path, char **out_name, + time_t created, time_t saved, const char *model, + const char *base_url, char **out_path, char **out_name, char *errbuf, size_t errlen); /* 列出全部存档, 按开始时间从新到旧排序. 返回条数, *out 为 malloc 的数组 */ size_t archive_list(const char *bindir, ArchiveEntry **out); -void archive_list_free(ArchiveEntry *list, size_t n); +void archive_list_free(ArchiveEntry *list, size_t n); -/* 读取存档里的 messages 数组 (调用者释放). - * out_created / out_saved / out_model / out_name / out_parent 可选回填 (malloc). */ +/* 读取存档里的 messages 数组 (调用者释放). + * out_created / out_saved / out_model / out_name / out_parent 可选回填 + * (malloc). */ JsonValue *archive_load(const char *path, char **out_created, char **out_saved, char **out_model, char **out_name, char **out_parent, char *errbuf, size_t errlen); diff --git a/src/attach.c b/src/attach.c index d32de57..cf2088f 100644 --- a/src/attach.c +++ b/src/attach.c @@ -16,26 +16,25 @@ #include /* 暂存区. 每一项都已经是一个可以直接塞进 content 数组的块 */ -static JsonValue *g_parts; /* 懒创建 */ -static size_t g_text_bytes; /* 文本累计字节数, 用来卡总量 */ +static JsonValue *g_parts; /* 懒创建 */ +static size_t g_text_bytes; /* 文本累计字节数, 用来卡总量 */ -/* 每个块对应的来源路径, 只用于给用户看. +/* 每个块对应的来源路径, 只用于给用户看. * * 刻意**不**把它塞进块里: 那会跟着请求一起发给服务端, 而 content 块里多一个 * 自造字段是可以被严格的服务端判 400 的. 显示用的东西就该留在本地. */ static char **g_names; static size_t g_names_n, g_names_cap; -static void names_clear(void) -{ - for (size_t i = 0; i < g_names_n; i++) free(g_names[i]); +static void names_clear(void) { + for (size_t i = 0; i < g_names_n; i++) + free(g_names[i]); free(g_names); g_names = NULL; g_names_n = g_names_cap = 0; } -void attach_clear(void) -{ +void attach_clear(void) { json_free(g_parts); g_parts = NULL; g_text_bytes = 0; @@ -43,8 +42,7 @@ void attach_clear(void) } /* 把来源路径记下来 (和 g_parts 同步增长) */ -static void remember(const char *path) -{ +static void remember(const char *path) { if (g_names_n == g_names_cap) { g_names_cap = g_names_cap ? g_names_cap * 2 : 8; g_names = xrealloc(g_names, g_names_cap * sizeof *g_names); @@ -52,29 +50,26 @@ static void remember(const char *path) g_names[g_names_n++] = xstrdup(path); } -static JsonValue *parts_arr(void) -{ - if (!g_parts) g_parts = json_new_array(); +static JsonValue *parts_arr(void) { + if (!g_parts) + g_parts = json_new_array(); return g_parts; } -size_t attach_count(void) -{ - return g_parts ? json_array_size(g_parts) : 0; -} +size_t attach_count(void) { return g_parts ? json_array_size(g_parts) : 0; } -int attach_has_image(void) -{ - if (!g_parts) return 0; +int attach_has_image(void) { + if (!g_parts) + return 0; for (size_t i = 0; i < json_array_size(g_parts); i++) { const char *t = json_get_string(json_array_get(g_parts, i), "type"); - if (t && strcmp(t, "ligent_image") == 0) return 1; + if (t && strcmp(t, "ligent_image") == 0) + return 1; } return 0; } -JsonValue *attach_take_parts(void) -{ +JsonValue *attach_take_parts(void) { JsonValue *p = g_parts; g_parts = NULL; g_text_bytes = 0; @@ -82,9 +77,9 @@ JsonValue *attach_take_parts(void) return p; } -void attach_print(void) -{ - if (!g_parts) return; +void attach_print(void) { + if (!g_parts) + return; for (size_t i = 0; i < json_array_size(g_parts); i++) { JsonValue *p = json_array_get(g_parts, i); const char *ty = json_get_string(p, "type"); @@ -100,27 +95,36 @@ void attach_print(void) /* 单个文件的处理 */ /* ------------------------------------------------------------------ */ -/* 二进制判定 (判据与 8 KB 窗口都在 util.h, 和 read_file 共用一份): +/* 二进制判定 (判据与 8 KB 窗口都在 util.h, 和 read_file 共用一份): * 以前这里扫的是**整个文件**而 read_file 只看前 8 KB, 于是同一个文件会出现 * "/include 说它是二进制、read_file 说它能读"这种自相矛盾的说法. */ -/* 把一个文件变成 content 块推入暂存区. +/* 把一个文件变成 content 块推入暂存区. * 返回 1 = 已附带, 0 = 跳过 (原因打到 skipped_why), -1 = 出错了 (同 0 处理) */ static int add_one(const char *path, AttachVisionFn can_image, void *ud, - const char **why) -{ + const char **why) { struct stat st; - if (stat(path, &st) != 0) { *why = t("attach.err.unreadable"); return 0; } - if (!S_ISREG(st.st_mode)) { *why = t("attach.err.not_regular"); return 0; } + if (stat(path, &st) != 0) { + *why = t("attach.err.unreadable"); + return 0; + } + if (!S_ISREG(st.st_mode)) { + *why = t("attach.err.not_regular"); + return 0; + } /* 先按内容试试是不是图片 —— 扩展名不算数 (和 read_image 一个规矩) */ { size_t len = 0; char *raw = read_entire_file(path, &len); - if (!raw) { *why = t("attach.err.unreadable"); return 0; } + if (!raw) { + *why = t("attach.err.unreadable"); + return 0; + } const char *ext = NULL, *mime = NULL; - int is_image = (image_sniff((const unsigned char *)raw, len, &ext, &mime) == 0); + int is_image = + (image_sniff((const unsigned char *)raw, len, &ext, &mime) == 0); free(raw); if (is_image) { @@ -162,7 +166,10 @@ static int add_one(const char *path, AttachVisionFn can_image, void *ud, size_t len = 0; char *raw = read_entire_file(path, &len); - if (!raw) { *why = t("attach.err.unreadable"); return 0; } + if (!raw) { + *why = t("attach.err.unreadable"); + return 0; + } if (looks_binary(raw, len, NULL)) { free(raw); @@ -170,14 +177,15 @@ static int add_one(const char *path, AttachVisionFn can_image, void *ud, return 0; } - /* 头 + 正文. 头用 ===== 包起来, 模型一眼能看出文件从哪开始. + /* 头 + 正文. 头用 ===== 包起来, 模型一眼能看出文件从哪开始. * 文件不以换行结尾时补一个, 以免下一个文件头黏在最后一行后面. */ StrBuf sb; sb_init(&sb); sb_printf(&sb, "===== %s (%zu 字节)=====\n", path, len); sb_append(&sb, raw, len); free(raw); - if (len > 0 && sb.data[sb.len - 1] != '\n') sb_putc(&sb, '\n'); + if (len > 0 && sb.data[sb.len - 1] != '\n') + sb_putc(&sb, '\n'); JsonValue *part = json_new_object(); json_object_set_string(part, "type", "text"); @@ -194,10 +202,11 @@ static int add_one(const char *path, AttachVisionFn can_image, void *ud, /* ------------------------------------------------------------------ */ int attach_glob(const char *pattern, AttachVisionFn can_image, void *ud, - size_t *skipped, char *err, size_t errlen) -{ - if (skipped) *skipped = 0; - if (err && errlen) err[0] = '\0'; + size_t *skipped, char *err, size_t errlen) { + if (skipped) + *skipped = 0; + if (err && errlen) + err[0] = '\0'; if (!pattern || !*pattern) { if (err && errlen) snprintf(err, errlen, "%s", t("attach.err.no_pattern")); @@ -206,9 +215,9 @@ int attach_glob(const char *pattern, AttachVisionFn can_image, void *ud, glob_t g; memset(&g, 0, sizeof g); - /* 认 ~/. **用 #ifdef 包起来**: GLOB_TILDE 是 GNU 扩展, glibc 一定有, - * 而 musl 那边不敢打包票 (它挂在编译期 —— 没有这个宏就是编译失败, - * 静态 musl 构建会直接挂在这儿). 有就带, 没有就退化成普通路径展开. + /* 认 ~/. **用 #ifdef 包起来**: GLOB_TILDE 是 GNU 扩展, glibc 一定有, + * 而 musl 那边不敢打包票 (它挂在编译期 —— 没有这个宏就是编译失败, + * 静态 musl 构建会直接挂在这儿). 有就带, 没有就退化成普通路径展开. * 不开 GLOB_BRACE: {a,b} 是 shell 特有写法, 开了会让用户以为别的 shell * 语法也能用. */ int gflags = 0; @@ -234,7 +243,10 @@ int attach_glob(const char *pattern, AttachVisionFn can_image, void *ud, int added = 0; size_t skipped_n = 0; for (size_t i = 0; i < g.gl_pathc; i++) { - if (attach_count() >= ATTACH_MAX_FILES) { skipped_n++; continue; } + if (attach_count() >= ATTACH_MAX_FILES) { + skipped_n++; + continue; + } const char *why = NULL; if (add_one(g.gl_pathv[i], can_image, ud, &why) == 1) { added++; @@ -247,7 +259,8 @@ int attach_glob(const char *pattern, AttachVisionFn can_image, void *ud, } } globfree(&g); - if (skipped) *skipped = skipped_n; + if (skipped) + *skipped = skipped_n; return added; } @@ -257,21 +270,23 @@ int attach_glob(const char *pattern, AttachVisionFn can_image, void *ud, #define PICK_MAX_SHOW 200 -static int cmp_str(const void *a, const void *b) -{ +static int cmp_str(const void *a, const void *b) { return strcmp(*(const char *const *)a, *(const char *const *)b); } /* 列一个目录. 返回 malloc 的完整路径数组 (目录带尾斜杠), *n 回填个数. */ -static char **list_dir_sorted(const char *dir, size_t *n) -{ +static char **list_dir_sorted(const char *dir, size_t *n) { char **out = NULL; size_t cnt = 0, cap = 0; DIR *d = opendir(dir); - if (!d) { *n = 0; return NULL; } + if (!d) { + *n = 0; + return NULL; + } struct dirent *de; while ((de = readdir(d)) != NULL) { - if (de->d_name[0] == '.') continue; /* 隐藏文件先不列 */ + if (de->d_name[0] == '.') + continue; /* 隐藏文件先不列 */ if (cnt == cap) { cap = cap ? cap * 2 : 32; out = xrealloc(out, cap * sizeof *out); @@ -286,29 +301,33 @@ static char **list_dir_sorted(const char *dir, size_t *n) out[cnt++] = full; } closedir(d); - if (cnt > 1) qsort(out, cnt, sizeof *out, cmp_str); + if (cnt > 1) + qsort(out, cnt, sizeof *out, cmp_str); *n = cnt; return out; } -static void free_list(char **l, size_t n) -{ - /* 空表就是 NULL (list_dir_sorted 在没列出东西时返回 NULL 并把 n 置 0, - * 两者是绑在一起的, 但那个不变量是隐式的). 既然 free(NULL) 本来就合法, - * 这里直接挡掉, 省得依赖调用方永远把 n 和指针配平 —— sb_free 也是这么写的. */ - if (!l) return; - for (size_t i = 0; i < n; i++) free(l[i]); +static void free_list(char **l, size_t n) { + /* 空表就是 NULL (list_dir_sorted 在没列出东西时返回 NULL 并把 n 置 0, + * 两者是绑在一起的, 但那个不变量是隐式的). 既然 free(NULL) 本来就合法, + * 这里直接挡掉, 省得依赖调用方永远把 n 和指针配平 —— sb_free 也是这么写的. + */ + if (!l) + return; + for (size_t i = 0; i < n; i++) + free(l[i]); free(l); } -/* 相对 dir 解析用户输入: 可能是序号、目录名、路径或 glob. +/* 相对 dir 解析用户输入: 可能是序号、目录名、路径或 glob. * 返回选中的路径列表 (malloc 数组), *n 回填个数. */ -static char **resolve_input(const char *dir, const char *input, size_t *n) -{ +static char **resolve_input(const char *dir, const char *input, size_t *n) { *n = 0; char *pat; - if (input[0] == '/') pat = xstrdup(input); - else pat = xasprintf("%s/%s", dir, input); + if (input[0] == '/') + pat = xstrdup(input); + else + pat = xasprintf("%s/%s", dir, input); glob_t g; memset(&g, 0, sizeof g); @@ -318,30 +337,36 @@ static char **resolve_input(const char *dir, const char *input, size_t *n) #endif int rc = glob(pat, gflags, NULL, &g); free(pat); - if (rc != 0) { globfree(&g); return NULL; } + if (rc != 0) { + globfree(&g); + return NULL; + } char **out = xcalloc(g.gl_pathc ? g.gl_pathc : 1, sizeof *out); size_t cnt = 0; - for (size_t i = 0; i < g.gl_pathc; i++) out[cnt++] = xstrdup(g.gl_pathv[i]); + for (size_t i = 0; i < g.gl_pathc; i++) + out[cnt++] = xstrdup(g.gl_pathv[i]); globfree(&g); *n = cnt; return out; } -static int human_size(long long n, char *buf, size_t len) -{ - if (n < 1024) return snprintf(buf, len, "%lld B", n); - if (n < 1024 * 1024) return snprintf(buf, len, "%.1f KB", (double)n / 1024.0); +static int human_size(long long n, char *buf, size_t len) { + if (n < 1024) + return snprintf(buf, len, "%lld B", n); + if (n < 1024 * 1024) + return snprintf(buf, len, "%.1f KB", (double)n / 1024.0); return snprintf(buf, len, "%.1f MB", (double)n / (1024.0 * 1024.0)); } int attach_pick(const char *start, AttachVisionFn can_image, void *ud, - size_t *skipped) -{ - if (skipped) *skipped = 0; + size_t *skipped) { + if (skipped) + *skipped = 0; char *dir = start && *start ? xstrdup(start) : getcwd(NULL, 0); - if (!dir) dir = xstrdup("."); + if (!dir) + dir = xstrdup("."); int added = 0; size_t skipped_n = 0; @@ -387,13 +412,21 @@ int attach_pick(const char *start, AttachVisionFn can_image, void *ud, } char *ans = str_trim(line); - if (*ans == '\0') { free(line); free_list(items, n); break; } + if (*ans == '\0') { + free(line); + free_list(items, n); + break; + } if (strcmp(ans, ".") == 0 || strcmp(ans, "..") == 0) { char *up = xasprintf("%s/..", dir); char *real = realpath(up, NULL); free(up); - if (real) { free(dir); dir = real; } - free(line); free_list(items, n); + if (real) { + free(dir); + dir = real; + } + free(line); + free_list(items, n); continue; } @@ -403,8 +436,9 @@ int attach_pick(const char *start, AttachVisionFn can_image, void *ud, if (numend != ans && *str_trim(numend) == '\0') { if (idx < 1 || (size_t)idx > n) { printf("%s", tf("attach.pick.index_range", "n", i18n_num(idx), - "max", i18n_num((long long)n), NULL)); - free(line); free_list(items, n); + "max", i18n_num((long long)n), NULL)); + free(line); + free_list(items, n); continue; } char *picked = xstrdup(items[idx - 1]); @@ -418,16 +452,18 @@ int attach_pick(const char *start, AttachVisionFn can_image, void *ud, const char *why = NULL; if (add_one(picked, can_image, ud, &why) == 1) { added++; - printf("%s\n", tf("attach.pick.selected", "file", picked, NULL)); + printf("%s\n", + tf("attach.pick.selected", "file", picked, NULL)); } else { skipped_n++; - printf("%s\n", tf("attach.pick.skipped", "file", picked, - "why", why ? why : t("attach.err.read_failed"), - NULL)); + printf("%s\n", + tf("attach.pick.skipped", "file", picked, "why", + why ? why : t("attach.err.read_failed"), NULL)); } free(picked); } - free(line); free_list(items, n); + free(line); + free_list(items, n); continue; } @@ -451,16 +487,20 @@ int attach_pick(const char *start, AttachVisionFn can_image, void *ud, } } for (size_t i = 0; i < rn; i++) { - if (attach_count() >= ATTACH_MAX_FILES) { skipped_n++; continue; } + if (attach_count() >= ATTACH_MAX_FILES) { + skipped_n++; + continue; + } const char *why = NULL; if (add_one(res[i], can_image, ud, &why) == 1) { added++; - printf("%s\n", tf("attach.pick.selected", "file", res[i], NULL)); + printf("%s\n", + tf("attach.pick.selected", "file", res[i], NULL)); } else { skipped_n++; - printf("%s\n", tf("attach.pick.skipped", "file", res[i], - "why", why ? why : t("attach.err.read_failed"), - NULL)); + printf("%s\n", + tf("attach.pick.skipped", "file", res[i], "why", + why ? why : t("attach.err.read_failed"), NULL)); } } free_list(res, rn); @@ -468,6 +508,7 @@ int attach_pick(const char *start, AttachVisionFn can_image, void *ud, } free(dir); - if (skipped) *skipped = skipped_n; + if (skipped) + *skipped = skipped_n; return added; } diff --git a/src/attach.h b/src/attach.h index 8c15896..3a00a77 100644 --- a/src/attach.h +++ b/src/attach.h @@ -2,15 +2,15 @@ * * 设计成"暂存"而不是"立刻发出去": 附带文件的意义在于配合一个提问 * ("这几个文件有什么问题"), 所以 /include 先把文件攒起来, 下一条消息 - * 带上它们一起发. 暂存是进程内状态, 发完就清空. + * 带上它们一起发. 暂存是进程内状态, 发完就清空. * - * 附带的文件在会话里有两种形态: + * 附带的文件在会话里有两种形态: * 文本 -> 直接作为 text 块 (就是内容本身) * 图片 -> 存成本机备份, 历史里只放 ligent_image 引用 —— 和 read_image - * 完全同一条路, 图片字节不进 JSON, 构造请求时才展开成 base64. + * 完全同一条路, 图片字节不进 JSON, 构造请求时才展开成 base64. * * 图片有额外前提: **模型得能看图**. 所以附带图片前会先用能力探测的结论 - * 确认一次, 不支持就跳过并如实说明, 而不是把图塞进去让服务端 400. + * 确认一次, 不支持就跳过并如实说明, 而不是把图塞进去让服务端 400. */ #ifndef LIGENT_ATTACH_H #define LIGENT_ATTACH_H @@ -19,9 +19,9 @@ #include -/* 单个文本文件的体积上限. 比 read_file 的工具输出上限 (100 KB)宽一些, +/* 单个文本文件的体积上限. 比 read_file 的工具输出上限 (100 KB)宽一些, * 因为这是用户点名要带的, 不是模型顺手的输出. */ -#define ATTACH_MAX_FILE (256u * 1024) +#define ATTACH_MAX_FILE (256u * 1024) /* 一次暂存的文本总量. 再多就该用 read_file 的分段读, 或者先挑一挑. */ #define ATTACH_MAX_TOTAL (1024u * 1024) /* 一次最多附带几个文件 */ @@ -36,21 +36,21 @@ size_t attach_count(void); /* 暂存里有图片吗 */ int attach_has_image(void); -/* 遇到图片时回调一次, 问"当前模型能收图吗", 返回 0 就跳过这张图. +/* 遇到图片时回调一次, 问"当前模型能收图吗", 返回 0 就跳过这张图. * * 做成回调而不是一个布尔入参: 判断"能不能收图"要跑能力探测, 而探测是要发 * 请求的. 纯文本的 /include 不该为了一张图都没有的一次附带去探测模型. */ typedef int (*AttachVisionFn)(void *ud); /* 按 glob 展开并暂存 (shell 语义, 由 glob(3) 展开; 目录要进去看请用 - * 交互式模式). can_image 可为 NULL (一律当能收). + * 交互式模式). can_image 可为 NULL (一律当能收). * 返回新增的文件数; *skipped 回填被跳过的 (模型不收图 / 二进制 / * 超限 / 读不到), 出错返回 -1 并把原因写进 err. */ int attach_glob(const char *pattern, AttachVisionFn can_image, void *ud, size_t *skipped, char *err, size_t errlen); -/* 交互式挑选: 打印目录列表, 用普通输入行逐层进入 (可递归), - * 也能直接敲 glob 或路径. 从 dir 开始 (NULL = 当前目录). +/* 交互式挑选: 打印目录列表, 用普通输入行逐层进入 (可递归), + * 也能直接敲 glob 或路径. 从 dir 开始 (NULL = 当前目录). * 返回新增的文件数; 用户取消返回 0. */ int attach_pick(const char *dir, AttachVisionFn can_image, void *ud, size_t *skipped); diff --git a/src/capability.c b/src/capability.c index 7a7c540..9d0637d 100644 --- a/src/capability.c +++ b/src/capability.c @@ -9,13 +9,13 @@ #include #include -/* 一张 1x1 的 PNG. 探测只需要"一个合法的图片字节流", 越小越好: +/* 一张 1x1 的 PNG. 探测只需要"一个合法的图片字节流", 越小越好: * 服务端会把它缩放后按图计费, 尺寸再大也换不来更多信息. */ -static const char PROBE_PNG_B64[] = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLv" - "AAAAAElFTkSuQmCC"; +static const char PROBE_PNG_B64[] = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1" + "PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLv" + "AAAAAElFTkSuQmCC"; -/* 缓存: 按 base_url + model 索引. 同一个进程里换模型会自然重测, +/* 缓存: 按 base_url + model 索引. 同一个进程里换模型会自然重测, * 切回来则命中. 条数很少 (人手切的模型个数). */ #define CAP_CACHE_MAX 8 @@ -28,8 +28,7 @@ typedef struct { static CapEntry g_cache[CAP_CACHE_MAX]; static size_t g_cache_n; -void cap_cache_clear(void) -{ +void cap_cache_clear(void) { for (size_t i = 0; i < g_cache_n; i++) { free(g_cache[i].base_url); free(g_cache[i].model); @@ -38,51 +37,51 @@ void cap_cache_clear(void) g_cache_n = 0; } -const char *cap_name(int v) -{ +const char *cap_name(int v) { switch (v) { - case CAP_YES: return "支持"; - case CAP_NO: return "不支持"; - default: return "未测"; + case CAP_YES: + return "支持"; + case CAP_NO: + return "不支持"; + default: + return "未测"; } } -/* 给人看的标签 (--verbose 的探测记录). cap_name() 是内部/测试用的中文常量, +/* 给人看的标签 (--verbose 的探测记录). cap_name() 是内部/测试用的中文常量, * 不随界面语言变, 所以这里另走一遍界面文案表. */ -static const char *cap_label(int v, int is_tools) -{ - if (v == CAP_YES) return t("agent.caps.yes"); - if (v == CAP_NO) return t(is_tools ? "agent.caps.no.tools" - : "agent.caps.no.vision"); +static const char *cap_label(int v, int is_tools) { + if (v == CAP_YES) + return t("agent.caps.yes"); + if (v == CAP_NO) + return t(is_tools ? "agent.caps.no.tools" : "agent.caps.no.vision"); return t("agent.caps.unknown"); } /* https://host/v1 + /chat/completions (base_url 末尾可能带斜杠) */ -static char *chat_url(const char *base_url) -{ +static char *chat_url(const char *base_url) { const char *bu = base_url ? base_url : ""; size_t bl = strlen(bu); - while (bl > 0 && bu[bl - 1] == '/') bl--; + while (bl > 0 && bu[bl - 1] == '/') + bl--; return xasprintf("%.*s/chat/completions", (int)bl, bu); } /* 一个试探请求的结论 */ /* 注意别叫 R_OK —— 里那个是 access() 的权限位, 会撞名 */ typedef enum { - PROBE_OK = 0, /* 2xx: 接受 */ - PROBE_REJECT = 1, /* 400: 明确拒绝 (只有在基线通过时才有意义) */ - PROBE_UNKNOWN = 2 /* 网络、401/403/404/429/5xx……: 什么也说明不了 */ + PROBE_OK = 0, /* 2xx: 接受 */ + PROBE_REJECT = 1, /* 400: 明确拒绝 (只有在基线通过时才有意义) */ + PROBE_UNKNOWN = 2 /* 网络、401/403/404/429/5xx……: 什么也说明不了 */ } ProbeResult; static ProbeResult probe(const LlmConfig *cfg, const char *url, - const JsonValue *messages, int with_tools, - int verbose, long *out_status, - char *errbuf, size_t errlen) -{ + const JsonValue *messages, int with_tools, int verbose, + long *out_status, char *errbuf, size_t errlen) { JsonValue *body = json_new_object(); json_object_set_string(body, "model", cfg->model); json_object_set(body, "messages", json_clone(messages)); - /* 只让它回一个 token: 探测要的是"收不收", 不是回复内容. + /* 只让它回一个 token: 探测要的是"收不收", 不是回复内容. * 不设这个的话, 默认开着思考的模型会为了回答"1"生成一大段推理. */ json_object_set_number(body, "max_tokens", 1); json_object_set_bool(body, "stream", 0); @@ -108,18 +107,21 @@ static ProbeResult probe(const LlmConfig *cfg, const char *url, long status = 0; char local_err[256]; local_err[0] = '\0'; - /* 探测不重试: 它只是一次判断, 服务端抖一下还有兜底 (一律按支持处理), + /* 探测不重试: 它只是一次判断, 服务端抖一下还有兜底 (一律按支持处理), * 不值得为此让每个进程启动都多等几个退避周期. */ HttpRetry no_retry = HTTP_RETRY_NONE; - int rc = http_post(url, cfg->api_key, text, cfg->timeout, verbose, - &no_retry, &resp_body, &status, NULL, - local_err, sizeof local_err); + int rc = + http_post(url, cfg->api_key, text, cfg->timeout, verbose, &no_retry, + &resp_body, &status, NULL, local_err, sizeof local_err); free(text); free(resp_body); - if (out_status) *out_status = status; + if (out_status) + *out_status = status; - if (rc == 0) return PROBE_OK; - if (status == 400) return PROBE_REJECT; + if (rc == 0) + return PROBE_OK; + if (status == 400) + return PROBE_REJECT; if (errbuf && errlen && !errbuf[0] && local_err[0]) snprintf(errbuf, errlen, "%s", local_err); @@ -127,8 +129,7 @@ static ProbeResult probe(const LlmConfig *cfg, const char *url, } /* 探测用的消息: 一句 "1", 可选带一张图 */ -static JsonValue *probe_messages(int with_image) -{ +static JsonValue *probe_messages(int with_image) { JsonValue *msgs = json_new_array(); JsonValue *m = json_new_object(); json_object_set_string(m, "role", "user"); @@ -159,9 +160,8 @@ static JsonValue *probe_messages(int with_image) return msgs; } -static void run_sequence(const LlmConfig *cfg, Capabilities *out, - int verbose, char *errbuf, size_t errlen) -{ +static void run_sequence(const LlmConfig *cfg, Capabilities *out, int verbose, + char *errbuf, size_t errlen) { out->vision = out->tools = CAP_UNKNOWN; out->probed = 1; out->conclusive = 0; @@ -171,15 +171,16 @@ static void run_sequence(const LlmConfig *cfg, Capabilities *out, /* --- 1. 基线 --- */ JsonValue *plain = probe_messages(0); - ProbeResult base = probe(cfg, url, plain, 0, verbose, &status, errbuf, errlen); + ProbeResult base = + probe(cfg, url, plain, 0, verbose, &status, errbuf, errlen); json_free(plain); if (base != PROBE_OK) { /* 连基线都不通, 后面的否定结论一概不作数 */ if (verbose) fprintf(stderr, "%s", - tf("capability.note.baseline_failed", "status", i18n_num(status), - NULL)); + tf("capability.note.baseline_failed", "status", + i18n_num(status), NULL)); free(url); return; } @@ -187,13 +188,15 @@ static void run_sequence(const LlmConfig *cfg, Capabilities *out, /* --- 2. 合并试探: 图片 + tools --- */ JsonValue *both = probe_messages(1); - ProbeResult combined = probe(cfg, url, both, 1, verbose, &status, errbuf, errlen); + 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")); + if (verbose) + fprintf(stderr, "%s", t("capability.note.both_ok")); free(url); return; } @@ -202,41 +205,46 @@ static void run_sequence(const LlmConfig *cfg, Capabilities *out, JsonValue *img = probe_messages(1); ProbeResult vr = probe(cfg, url, img, 0, verbose, &status, errbuf, errlen); json_free(img); - out->vision = (vr == PROBE_OK) ? CAP_YES : (vr == PROBE_REJECT ? CAP_NO : CAP_UNKNOWN); + out->vision = (vr == PROBE_OK) + ? CAP_YES + : (vr == PROBE_REJECT ? CAP_NO : CAP_UNKNOWN); JsonValue *txt = probe_messages(0); ProbeResult tr = probe(cfg, url, txt, 1, verbose, &status, errbuf, errlen); json_free(txt); - out->tools = (tr == PROBE_OK) ? CAP_YES : (tr == PROBE_REJECT ? CAP_NO : CAP_UNKNOWN); + out->tools = (tr == PROBE_OK) ? CAP_YES + : (tr == PROBE_REJECT ? CAP_NO : CAP_UNKNOWN); if (verbose) - /* 用界面语言的标签, 不用 cap_name() —— 后者是给日志和断言用的中文常量 */ + /* 用界面语言的标签, 不用 cap_name() —— 后者是给日志和断言用的中文常量 + */ fprintf(stderr, "%s", tf("capability.note.split", "vision", cap_label(out->vision, 0), "tools", cap_label(out->tools, 1), NULL)); free(url); } -int cap_cached(const LlmConfig *cfg, Capabilities *out) -{ +int cap_cached(const LlmConfig *cfg, Capabilities *out) { const char *model = (cfg && cfg->model) ? cfg->model : ""; const char *base = (cfg && cfg->base_url) ? cfg->base_url : ""; for (size_t i = 0; i < g_cache_n; i++) { if (strcmp(g_cache[i].model, model) == 0 && strcmp(g_cache[i].base_url, base) == 0) { - if (out) *out = g_cache[i].caps; + if (out) + *out = g_cache[i].caps; return 1; } } return 0; } -int cap_get(const LlmConfig *cfg, int enable, Capabilities *out, - int verbose, char *errbuf, size_t errlen) -{ - if (!out) return 0; - if (errbuf && errlen) errbuf[0] = '\0'; - if (!cfg) { /* 没有配置就什么都测不了, 按"未测"处理 */ +int cap_get(const LlmConfig *cfg, int enable, Capabilities *out, int verbose, + char *errbuf, size_t errlen) { + if (!out) + return 0; + if (errbuf && errlen) + errbuf[0] = '\0'; + if (!cfg) { /* 没有配置就什么都测不了, 按"未测"处理 */ out->vision = out->tools = CAP_UNKNOWN; out->probed = 0; out->conclusive = 0; @@ -268,7 +276,8 @@ int cap_get(const LlmConfig *cfg, int enable, Capabilities *out, if (g_cache_n == CAP_CACHE_MAX) { free(g_cache[0].base_url); free(g_cache[0].model); - memmove(&g_cache[0], &g_cache[1], sizeof g_cache[0] * (CAP_CACHE_MAX - 1)); + memmove(&g_cache[0], &g_cache[1], + sizeof g_cache[0] * (CAP_CACHE_MAX - 1)); g_cache_n--; } g_cache[g_cache_n].base_url = xstrdup(base); diff --git a/src/capability.h b/src/capability.h index 4859eb8..10ad0ea 100644 --- a/src/capability.h +++ b/src/capability.h @@ -2,9 +2,9 @@ * * 为什么需要它: OpenAI 兼容层**没有任何查询能力的接口**. DeepSeek 和 MiMo 的 * GET /v1/models 都只返回 id / object / owned_by, 没有 capability / modality - * 字段. 所以"这个模型收不收图片"只能靠**发一个试探请求**问出来. + * 字段. 所以"这个模型收不收图片"只能靠**发一个试探请求**问出来. * - * 这里叫"序列"是因为它不是一次请求, 而是有先后的几步: + * 这里叫"序列"是因为它不是一次请求, 而是有先后的几步: * * 1. 基线: 一句纯文本 + max_tokens=1 * └ 失败 → 什么都别下结论 (可能是 key、网络、模型名的问题) @@ -16,13 +16,13 @@ * * 关键在于**否定结论只在基线成功时才敢下**: 单个试探 400 而基线 200, 说明 * 差别就在那一样东西上. 这样不需要去猜服务端的错误文案 ("不支持图片"、 - * "invalid content"、"multimodal not supported"…… 各家写法都不一样), - * 也就不会因为文案变化而误判. + * "invalid content"、"multimodal not supported"…… 各家写法都不一样), + * 也就不会因为文案变化而误判. * * 猜不准的时候一律按"支持"处理: 多暴露一个工具, 代价是模型可能白调一次 - * (我们还有 4xx 降级兜底); 而错误地藏掉工具, 用户会以为模型看不见图. + * (我们还有 4xx 降级兜底); 而错误地藏掉工具, 用户会以为模型看不见图. * - * 结果按 base_url + model 缓存, 所以**换模型会自动重新测**, 切回来则命中缓存. + * 结果按 base_url + model 缓存, 所以**换模型会自动重新测**, 切回来则命中缓存. */ #ifndef LIGENT_CAPABILITY_H #define LIGENT_CAPABILITY_H @@ -32,8 +32,8 @@ #include #define CAP_UNKNOWN (-1) -#define CAP_NO 0 -#define CAP_YES 1 +#define CAP_NO 0 +#define CAP_YES 1 typedef struct { int vision; /* 能不能收图片输入 */ @@ -42,19 +42,19 @@ typedef struct { int conclusive; /* 基线成功了吗 —— 只有它成功, 否定结论才作数 */ } Capabilities; -/* 探测 (或命中缓存). enable=0 时直接给"没测过"的全支持结果. - * verbose 会打印每个试探请求的细节. errbuf 收探测过程中的失败原因, - * 仅供提示, 不影响结论. - * 返回 1 = 这次真的发了请求 (调用方可以据此提示用户一次); +/* 探测 (或命中缓存). enable=0 时直接给"没测过"的全支持结果. + * verbose 会打印每个试探请求的细节. errbuf 收探测过程中的失败原因, + * 仅供提示, 不影响结论. + * 返回 1 = 这次真的发了请求 (调用方可以据此提示用户一次); * 返回 0 = 命中缓存或被禁用 (什么都没发生, 别重复提示). */ -int cap_get(const LlmConfig *cfg, int enable, - Capabilities *out, int verbose, char *errbuf, size_t errlen); +int cap_get(const LlmConfig *cfg, int enable, Capabilities *out, int verbose, + char *errbuf, size_t errlen); -/* 只查缓存, 不发任何请求. 命中返回 1 并回填, 没测过返回 0. +/* 只查缓存, 不发任何请求. 命中返回 1 并回填, 没测过返回 0. * 给 /tools 这种"想知道但绝不该为此发请求"的地方用. */ int cap_cached(const LlmConfig *cfg, Capabilities *out); -/* 把已缓存的结果清掉 (下次调用重新探测). +/* 把已缓存的结果清掉 (下次调用重新探测). * 正常换模型不需要它 —— 缓存按 base_url+model 索引; 这个给测试用. */ void cap_cache_clear(void); diff --git a/src/config.c b/src/config.c index e3a5b25..a87acbf 100644 --- a/src/config.c +++ b/src/config.c @@ -5,29 +5,28 @@ #include "json.h" #include "util.h" -#include +#include #include +#include #include #include -#include -#include #include +#include #define DEFAULT_BASE_URL "https://api.deepseek.com/v1" -#define DEFAULT_MODEL "deepseek-flash" +#define DEFAULT_MODEL "deepseek-flash" -void config_defaults(LigentConfig *c) -{ +void config_defaults(LigentConfig *c) { memset(c, 0, sizeof *c); c->base_url = xstrdup(DEFAULT_BASE_URL); c->api_key = xstrdup(""); - c->api_key_env = NULL; /* 密钥来源, 供诊断展示 (见 config.h) */ + c->api_key_env = NULL; /* 密钥来源, 供诊断展示 (见 config.h) */ c->model = xstrdup(DEFAULT_MODEL); c->system = NULL; - c->temperature = -1; /* 不发送, 用服务端默认 */ - c->max_tokens = 0; /* 不发送 */ - c->thinking = THINKING_AUTO; /* 不发送 thinking, 由服务端决定 */ - c->reasoning_effort = NULL; /* 不发送 */ + c->temperature = -1; /* 不发送, 用服务端默认 */ + c->max_tokens = 0; /* 不发送 */ + c->thinking = THINKING_AUTO; /* 不发送 thinking, 由服务端决定 */ + c->reasoning_effort = NULL; /* 不发送 */ c->timeout = 300; c->stream = 1; /* max_steps <= 0 = 不限制单轮往返次数 (默认). 这是**默认**值: 模型陷进 @@ -42,70 +41,79 @@ void config_defaults(LigentConfig *c) c->config_path = NULL; } -static void set_str(char **dst, const char *val) -{ +static void set_str(char **dst, const char *val) { free(*dst); *dst = val ? xstrdup(val) : NULL; } /* 服务端认识的思考级别. DeepSeek 会把它们映射到 low/high/max 三档 - * (minimal/low→low, medium/high/xhigh→high, max/ultra→max), - * OpenAI 那边则用 minimal/low/medium/high. 这里只做白名单校验, + * (minimal/low→low, medium/high/xhigh→high, max/ultra→max), + * OpenAI 那边则用 minimal/low/medium/high. 这里只做白名单校验, * 不替用户改写 —— 原样发过去, 由服务端决定怎么映射. */ -static const char *const EFFORTS[] = { - "minimal", "low", "medium", "high", "xhigh", "max", "ultra", NULL -}; +static const char *const EFFORTS[] = {"minimal", "low", "medium", "high", + "xhigh", "max", "ultra", NULL}; -int config_effort_valid(const char *s) -{ - if (!s || !*s) return 0; +int config_effort_valid(const char *s) { + if (!s || !*s) + return 0; for (size_t i = 0; EFFORTS[i]; i++) - if (strcmp(s, EFFORTS[i]) == 0) return 1; + if (strcmp(s, EFFORTS[i]) == 0) + return 1; return 0; } -const char *config_effort_list(void) -{ +const char *config_effort_list(void) { static char buf[128]; if (!buf[0]) { size_t n = 0; for (size_t i = 0; EFFORTS[i]; i++) - n += (size_t)snprintf(buf + n, sizeof buf - n, "%s%s", - i ? "/" : "", EFFORTS[i]); + n += (size_t)snprintf(buf + n, sizeof buf - n, "%s%s", i ? "/" : "", + EFFORTS[i]); } return buf; } -const char *config_thinking_name(int thinking) -{ - if (thinking == THINKING_ON) return t("cfg.thinking.on"); - if (thinking == THINKING_OFF) return t("cfg.thinking.off"); +const char *config_thinking_name(int thinking) { + if (thinking == THINKING_ON) + return t("cfg.thinking.on"); + if (thinking == THINKING_OFF) + return t("cfg.thinking.off"); return t("cfg.thinking.auto"); } -int config_parse_thinking(const char *s, int *out) -{ - if (!s) return -1; - if (!*s || strcmp(s, "auto") == 0) { *out = THINKING_AUTO; return 0; } +int config_parse_thinking(const char *s, int *out) { + if (!s) + return -1; + if (!*s || strcmp(s, "auto") == 0) { + *out = THINKING_AUTO; + return 0; + } if (strcmp(s, "on") == 0 || strcmp(s, "true") == 0 || - strcmp(s, "enabled") == 0) { *out = THINKING_ON; return 0; } + strcmp(s, "enabled") == 0) { + *out = THINKING_ON; + return 0; + } if (strcmp(s, "off") == 0 || strcmp(s, "false") == 0 || - strcmp(s, "disabled") == 0) { *out = THINKING_OFF; return 0; } + strcmp(s, "disabled") == 0) { + *out = THINKING_OFF; + return 0; + } 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) -{ +static int load_thinking(LigentConfig *c, const JsonValue *v, const char *path, + char *errbuf, size_t errlen) { if (v->type == JSON_BOOL) { c->thinking = v->u.boolean ? THINKING_ON : THINKING_OFF; return 0; } const char *s = json_string_value(v); - if (s && config_parse_thinking(s, &c->thinking) == 0) return 0; - snprintf(errbuf, errlen, "%s", tf("cfg.err.thinking_type", "path", path, NULL)); + if (s && config_parse_thinking(s, &c->thinking) == 0) + return 0; + snprintf(errbuf, errlen, "%s", + tf("cfg.err.thinking_type", "path", path, NULL)); return -1; } @@ -115,29 +123,30 @@ static int load_thinking(LigentConfig *c, const JsonValue *v, /* ---- 定位二进制所在目录 ---- * - * 关键点: 路径要"跟着用户看到的那个文件走". 如果 ligent 是个软链接, + * 关键点: 路径要"跟着用户看到的那个文件走". 如果 ligent 是个软链接, * 用户是把它当 ./bin/ligent 用的, 配置就该在 ./bin/ 旁边, 而不是被 - * 悄悄挪到源文件所在的目录去. + * 悄悄挪到源文件所在的目录去. * * 所以这里优先用 argv[0] (不解析最后一段的软链接), 只在拿不到时 * 才退回 /proc/self/exe —— 那个会解析软链接. */ -/* 确认 cand 确实指向我们自己的可执行文件. - * 用 inode 比对, 避免 $PATH 里恰好有个同名的别的东西把目录带偏. +/* 确认 cand 确实指向我们自己的可执行文件. + * 用 inode 比对, 避免 $PATH 里恰好有个同名的别的东西把目录带偏. * 读不到 /proc/self/exe 时不做检查 (非 Linux 平台). */ -static int is_self(const char *cand) -{ +static int is_self(const char *cand) { struct stat a, b; - if (!cand || stat(cand, &a) != 0) return 0; /* stat 会跟随软链接 */ - if (stat("/proc/self/exe", &b) != 0) return 1; /* 无法比对, 放行 */ + if (!cand || stat(cand, &a) != 0) + return 0; /* stat 会跟随软链接 */ + if (stat("/proc/self/exe", &b) != 0) + return 1; /* 无法比对, 放行 */ return a.st_dev == b.st_dev && a.st_ino == b.st_ino; } /* 在 $PATH 里查找可执行且确实是我们自己的文件, 返回完整路径 (malloc)或 NULL */ -static char *search_path(const char *name) -{ +static char *search_path(const char *name) { const char *path = getenv("PATH"); - if (!path || !*path) return NULL; + if (!path || !*path) + return NULL; char *copy = xstrdup(path); char *save = NULL; @@ -145,7 +154,10 @@ static char *search_path(const char *name) char *found = NULL; while (dir) { char *cand = xasprintf("%s/%s", *dir ? dir : ".", name); - if (access(cand, X_OK) == 0 && is_self(cand)) { found = cand; break; } + if (access(cand, X_OK) == 0 && is_self(cand)) { + found = cand; + break; + } free(cand); dir = strtok_r(NULL, ":", &save); } @@ -154,26 +166,31 @@ static char *search_path(const char *name) } /* 取路径中最后一段之前的部分; 没有 '/' 时返回 NULL */ -static char *dir_of(const char *path) -{ +static char *dir_of(const char *path) { const char *slash = path ? strrchr(path, '/') : NULL; - if (!slash) return NULL; - if (slash == path) return xstrdup("/"); + if (!slash) + return NULL; + if (slash == path) + return xstrdup("/"); return xstrndup(path, (size_t)(slash - path)); } -/* 取所在目录并规范化 (解析目录部分里的软链接、. 和 ..), +/* 取所在目录并规范化 (解析目录部分里的软链接、. 和 ..), * 但刻意不解析最后一段 —— 那正是"软链接所在目录"的关键 */ -static char *dir_of_canonical(const char *path) -{ +static char *dir_of_canonical(const char *path) { char *dir = dir_of(path); - if (!dir) return NULL; + if (!dir) + return NULL; - char *canon = realpath(dir, NULL); /* 成功时由 malloc 分配 */ - if (canon) { free(dir); return canon; } + char *canon = realpath(dir, NULL); /* 成功时由 malloc 分配 */ + if (canon) { + free(dir); + return canon; + } /* 目录不存在等情况下退回未规范化的结果, 但要保证是绝对路径 */ - if (dir[0] == '/') return dir; + if (dir[0] == '/') + return dir; char cwd[PATH_MAX]; if (getcwd(cwd, sizeof cwd)) { char *full = xasprintf("%s/%s", cwd, dir); @@ -183,14 +200,14 @@ static char *dir_of_canonical(const char *path) return dir; } -char *config_binary_dir(const char *argv0) -{ +char *config_binary_dir(const char *argv0) { /* 优先 argv[0]: 它反映用户实际调用的那个路径 */ if (argv0 && *argv0) { if (strchr(argv0, '/')) { if (is_self(argv0)) { char *d = dir_of_canonical(argv0); - if (d) return d; + if (d) + return d; } } else { /* 只是个名字: 去 $PATH 里找 (返回的仍是软链接本身) */ @@ -198,27 +215,28 @@ char *config_binary_dir(const char *argv0) if (full) { char *d = dir_of_canonical(full); free(full); - if (d) return d; + if (d) + return d; } } } - /* 兜底: 问内核可执行文件在哪. 注意这会解析软链接, + /* 兜底: 问内核可执行文件在哪. 注意这会解析软链接, * 只在 argv[0] 给不出答案时使用 */ char buf[PATH_MAX]; ssize_t n = readlink("/proc/self/exe", buf, sizeof buf - 1); if (n > 0 && (size_t)n < sizeof buf) { buf[n] = '\0'; char *d = dir_of_canonical(buf); - if (d) return d; + if (d) + return d; } /* 都失败就认当前目录, 至少不会崩 */ return xstrdup("."); } -char *config_default_path(const char *argv0) -{ +char *config_default_path(const char *argv0) { char *dir = config_binary_dir(argv0); char *path = xasprintf("%s/ligent.json", dir ? dir : "."); free(dir); @@ -227,27 +245,27 @@ char *config_default_path(const char *argv0) /* ---- 查找要加载的配置文件 ---- */ -/* 只认两个来源: 命令行的显式指定, 以及二进制目录下的 ligent.json. +/* 只认两个来源: 命令行的显式指定, 以及二进制目录下的 ligent.json. * 不做用户级 / 系统级 / 当前工作目录的查找. */ -static char *find_config_file(const char *explicit_path, const char *argv0) -{ +static char *find_config_file(const char *explicit_path, const char *argv0) { if (explicit_path && *explicit_path) { /* 显式指定的路径不做存在性判断, 读不到会明确报错 */ return xstrdup(explicit_path); } const char *env = getenv("LIGENT_CONFIG"); - if (env && *env) return xstrdup(env); + if (env && *env) + return xstrdup(env); char *path = config_default_path(argv0); - if (access(path, R_OK) == 0) return path; - free(path); /* 不存在就表示"用内置默认值" */ + if (access(path, R_OK) == 0) + return path; + free(path); /* 不存在就表示"用内置默认值" */ return NULL; } -int config_load_file(LigentConfig *c, const char *path, - char *errbuf, size_t errlen) -{ +int config_load_file(LigentConfig *c, const char *path, char *errbuf, + size_t errlen) { size_t len = 0; char *text = read_entire_file(path, &len); if (!text) { @@ -271,19 +289,23 @@ int config_load_file(LigentConfig *c, const char *path, } const char *s; - if ((s = json_get_string(root, "base_url"))) set_str(&c->base_url, s); + if ((s = json_get_string(root, "base_url"))) + set_str(&c->base_url, s); if ((s = json_get_string(root, "api_key"))) { char *expanded = expand_env(s); - if (*expanded) c->api_key_from_file = 1; + if (*expanded) + c->api_key_from_file = 1; set_str(&c->api_key, expanded); free(expanded); } - if ((s = json_get_string(root, "model"))) set_str(&c->model, s); - /* 空字符串视为"没写": 内置提示词里带着工具使用规范, + if ((s = json_get_string(root, "model"))) + set_str(&c->model, s); + /* 空字符串视为"没写": 内置提示词里带着工具使用规范, * 静默丢掉会让 agent 行为明显变差; 而且下发一条空的 system 消息 * 既浪费又没有意义. */ if ((s = json_get_string(root, "system"))) { - if (*s) set_str(&c->system, s); + if (*s) + set_str(&c->system, s); } JsonValue *v; @@ -300,8 +322,8 @@ int config_load_file(LigentConfig *c, const char *path, if ((s = json_get_string(root, "reasoning_effort")) && *s) { if (!config_effort_valid(s)) { snprintf(errbuf, errlen, "%s", - tf("cfg.err.bad_effort", "path", path, - "list", config_effort_list(), NULL)); + tf("cfg.err.bad_effort", "path", path, "list", + config_effort_list(), NULL)); json_free(root); return -1; } @@ -334,32 +356,32 @@ int config_load_file(LigentConfig *c, const char *path, /* 环境变量 */ /* ------------------------------------------------------------------ */ -static void env_str(char **dst, const char *name) -{ +static void env_str(char **dst, const char *name) { const char *v = getenv(name); - if (v && *v) set_str(dst, v); + if (v && *v) + set_str(dst, v); } -void config_apply_env(LigentConfig *c) -{ +void config_apply_env(LigentConfig *c) { env_str(&c->base_url, "LIGENT_BASE_URL"); env_str(&c->model, "LIGENT_MODEL"); { const char *v = getenv("LIGENT_SYSTEM"); - if (v && *v) set_str(&c->system, v); + if (v && *v) + set_str(&c->system, v); } - /* 厂商专属的环境变量只在"确实在用这家"时才读. + /* 厂商专属的环境变量只在"确实在用这家"时才读. * - * 否则会出这种事: 配置文件里写的是 OpenRouter 的 key, - * 而 shell 里残留的 DEEPSEEK_API_KEY 把它悄悄顶掉, - * 结果 A 家的密钥被发给了 B 家 —— 对方只会回一个含糊的 401. + * 否则会出这种事: 配置文件里写的是 OpenRouter 的 key, + * 而 shell 里残留的 DEEPSEEK_API_KEY 把它悄悄顶掉, + * 结果 A 家的密钥被发给了 B 家 —— 对方只会回一个含糊的 401. * 用户明明配对了却怎么都调不通, 排查方向还会被带偏. */ const char *url = c->base_url ? c->base_url : ""; int use_deepseek = strstr(url, "deepseek") != NULL; - int use_openai = strstr(url, "api.openai.com") != NULL; + 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) { @@ -368,49 +390,66 @@ void config_apply_env(LigentConfig *c) } else if (!c->api_key || !c->api_key[0]) { const char *k = NULL; const char *src = NULL; - if (use_deepseek) { k = getenv("DEEPSEEK_API_KEY"); src = "DEEPSEEK_API_KEY"; } - else if (use_openai) { k = getenv("OPENAI_API_KEY"); src = "OPENAI_API_KEY"; } - if (k && *k) { set_str(&c->api_key, k); c->api_key_env = src; } + if (use_deepseek) { + k = getenv("DEEPSEEK_API_KEY"); + src = "DEEPSEEK_API_KEY"; + } else if (use_openai) { + k = getenv("OPENAI_API_KEY"); + src = "OPENAI_API_KEY"; + } + if (k && *k) { + set_str(&c->api_key, k); + c->api_key_env = src; + } } - /* OPENAI_BASE_URL 同理, 只在 base_url 还是内置默认值 (用户没显式配)时兜底 */ + /* OPENAI_BASE_URL 同理, 只在 base_url 还是内置默认值 (用户没显式配)时兜底 + */ if (use_openai || strcmp(url, DEFAULT_BASE_URL) == 0) { const char *bu = getenv("OPENAI_BASE_URL"); - if (bu && *bu) set_str(&c->base_url, bu); + if (bu && *bu) + set_str(&c->base_url, bu); } const char *v; - if ((v = getenv("LIGENT_TEMPERATURE")) && *v) c->temperature = atof(v); - if ((v = getenv("LIGENT_MAX_TOKENS")) && *v) c->max_tokens = atol(v); - if ((v = getenv("LIGENT_TIMEOUT")) && *v) c->timeout = atol(v); - if ((v = getenv("LIGENT_MAX_STEPS")) && *v) c->max_steps = atoi(v); + if ((v = getenv("LIGENT_TEMPERATURE")) && *v) + c->temperature = atof(v); + if ((v = getenv("LIGENT_MAX_TOKENS")) && *v) + c->max_tokens = atol(v); + if ((v = getenv("LIGENT_TIMEOUT")) && *v) + c->timeout = atol(v); + if ((v = getenv("LIGENT_MAX_STEPS")) && *v) + c->max_steps = atoi(v); if ((v = getenv("LIGENT_STREAM")) && *v) c->stream = !(strcmp(v, "0") == 0 || strcmp(v, "false") == 0); if ((v = getenv("LIGENT_TOOLS")) && *v) c->use_tools = !(strcmp(v, "0") == 0 || strcmp(v, "false") == 0); - if ((v = getenv("LIGENT_RETRIES")) && *v) c->retries = atoi(v); + if ((v = getenv("LIGENT_RETRIES")) && *v) + c->retries = atoi(v); if ((v = getenv("LIGENT_CONFIRM")) && *v) c->confirm = !(strcmp(v, "0") == 0 || strcmp(v, "false") == 0); if ((v = getenv("LIGENT_AUTO_SAVE")) && *v) c->auto_save = !(strcmp(v, "0") == 0 || strcmp(v, "false") == 0); if ((v = getenv("LIGENT_PROBE")) && *v) c->probe = !(strcmp(v, "0") == 0 || strcmp(v, "false") == 0); - /* 环境变量里写错了不报错、直接忽略: 环境变量是临时开关, + /* 环境变量里写错了不报错、直接忽略: 环境变量是临时开关, * 为它把整个程序拦下来太粗暴. 配置文件和命令行则严格校验. */ if ((v = getenv("LIGENT_THINKING")) && *v) { int t; - if (config_parse_thinking(v, &t) == 0) c->thinking = t; + if (config_parse_thinking(v, &t) == 0) + c->thinking = t; } if ((v = getenv("LIGENT_REASONING_EFFORT")) && *v) { - if (config_effort_valid(v)) set_str(&c->reasoning_effort, v); + if (config_effort_valid(v)) + set_str(&c->reasoning_effort, v); } } int config_init(LigentConfig *c, const char *explicit_path, const char *argv0, - char *errbuf, size_t errlen) -{ + char *errbuf, size_t errlen) { config_defaults(c); - if (errbuf && errlen) errbuf[0] = '\0'; + if (errbuf && errlen) + errbuf[0] = '\0'; /* 记下二进制目录: 配置和以后任何数据都只放这里 */ free(c->config_dir); @@ -419,12 +458,16 @@ int config_init(LigentConfig *c, const char *explicit_path, const char *argv0, char *path = find_config_file(explicit_path, argv0); if (path) { if (config_load_file(c, path, errbuf, errlen) != 0) { - if (explicit_path && *explicit_path) { free(path); return -1; } + if (explicit_path && *explicit_path) { + free(path); + return -1; + } /* 约定位置的配置坏了, 也不该直接拦住用户 */ if (errbuf && errlen) fprintf(stderr, "%s\n", tf("cfg.err.parse_ignored", "error", errbuf, NULL)); - if (errbuf && errlen) errbuf[0] = '\0'; + if (errbuf && errlen) + errbuf[0] = '\0'; } free(path); } @@ -433,9 +476,9 @@ int config_init(LigentConfig *c, const char *explicit_path, const char *argv0, return 0; } -void config_free(LigentConfig *c) -{ - if (!c) return; +void config_free(LigentConfig *c) { + if (!c) + return; free(c->base_url); free(c->api_key); free(c->model); @@ -450,24 +493,31 @@ void config_free(LigentConfig *c) /* 展示 */ /* ------------------------------------------------------------------ */ -static void mask_key(const char *key, char *out, size_t outlen) -{ - if (!key || !*key) { snprintf(out, outlen, "%s", t("cfg.val.unset")); return; } +static void mask_key(const char *key, char *out, size_t outlen) { + if (!key || !*key) { + snprintf(out, outlen, "%s", t("cfg.val.unset")); + return; + } size_t n = strlen(key); - if (n <= 8) { snprintf(out, outlen, "%s", t("cfg.val.empty_key")); return; } + if (n <= 8) { + snprintf(out, outlen, "%s", t("cfg.val.empty_key")); + return; + } char num[32]; snprintf(num, sizeof num, "%zu", n); snprintf(out, outlen, "%.4s...%.4s %s", key, key + n - 4, tf("cfg.val.chars", "n", num, NULL)); } -/* 字段名按显示宽度补齐. 中文标签一个字占 2 列, 用 %-13s 会整体顶歪, - * 而英文标签没有这个问题 —— 所以宽度得算, 不能写死空格 (同 doctor 的 label). */ -static void dump_field(const char *lbl, const char *fmt, ...) -{ +/* 字段名按显示宽度补齐. 中文标签一个字占 2 列, 用 %-13s 会整体顶歪, + * 而英文标签没有这个问题 —— 所以宽度得算, 不能写死空格 (同 doctor 的 label). */ +static void dump_field(const char *lbl, const char *fmt, ...) { size_t w = display_width(lbl, strlen(lbl)); fputs(lbl, stdout); - while (w < 13) { putchar(' '); w++; } + while (w < 13) { + putchar(' '); + w++; + } fputs(": ", stdout); va_list ap; va_start(ap, fmt); @@ -476,8 +526,7 @@ static void dump_field(const char *lbl, const char *fmt, ...) putchar('\n'); } -void config_dump(const LigentConfig *c) -{ +void config_dump(const LigentConfig *c) { char masked[128], num[32]; mask_key(c->api_key, masked, sizeof masked); @@ -502,7 +551,8 @@ void config_dump(const LigentConfig *c) dump_field("thinking", "%s", config_thinking_name(c->thinking)); dump_field(t("cfg.dump.label.effort"), "%s", (c->reasoning_effort && *c->reasoning_effort) - ? c->reasoning_effort : t("cfg.val.server_default")); + ? c->reasoning_effort + : t("cfg.val.server_default")); snprintf(num, sizeof num, "%ld", c->timeout); dump_field("timeout", "%s", tf("cfg.val.seconds", "n", num, NULL)); @@ -526,7 +576,8 @@ void config_dump(const LigentConfig *c) t(c->probe ? "cfg.val.probe_on" : "cfg.val.disabled")); snprintf(num, sizeof num, "%zu", http_log_size()); - dump_field(t("cfg.dump.label.log"), "%s", tf("cfg.val.log", "n", num, NULL)); + dump_field(t("cfg.dump.label.log"), "%s", + tf("cfg.val.log", "n", num, NULL)); dump_field("system", "%s", c->system ? c->system : t("cfg.val.builtin_system")); dump_field("language", "%s", i18n_lang_name()); @@ -545,11 +596,12 @@ void config_dump(const LigentConfig *c) /* 密钥来源的可读标签 (诊断用). 诊断代码不该去 strstr() 中文文案 * 判断来源 —— 界面语言一变那个判断就悄悄失效了. */ -const char *config_api_key_source(const LigentConfig *c) -{ - if (c->api_key_env) return tf("cfg.src.env", "name", c->api_key_env, NULL); - if (c->api_key_from_cli) return t("cfg.src.cli"); - if (c->api_key_from_file) return t("cfg.src.file"); +const char *config_api_key_source(const LigentConfig *c) { + if (c->api_key_env) + return tf("cfg.src.env", "name", c->api_key_env, NULL); + if (c->api_key_from_cli) + return t("cfg.src.cli"); + if (c->api_key_from_file) + return t("cfg.src.file"); return t("cfg.val.unknown"); } - diff --git a/src/config.h b/src/config.h index 9467d33..b3391ab 100644 --- a/src/config.h +++ b/src/config.h @@ -1,12 +1,12 @@ /* config.h - 配置: 内置默认值 < 配置文件 < 环境变量 < 命令行 * - * 唯一的配置文件位置是**二进制所在目录**下的 ligent.json: + * 唯一的配置文件位置是**二进制所在目录**下的 ligent.json: * * /path/to/ligent ← 可执行文件 * /path/to/ligent.json ← 配置 (不存在则用内置默认值) * - * 不做用户级 (~/.config、$XDG_CONFIG_HOME)也不做系统级 (/etc)查找, - * 以便整个程序和它的数据可以自包含在一个目录里 (U 盘 / 容器 / 拷贝即走). + * 不做用户级 (~/.config、$XDG_CONFIG_HOME)也不做系统级 (/etc)查找, + * 以便整个程序和它的数据可以自包含在一个目录里 (U 盘 / 容器 / 拷贝即走). */ #ifndef LIGENT_CONFIG_H #define LIGENT_CONFIG_H @@ -18,56 +18,57 @@ enum { THINKING_AUTO = -1, THINKING_OFF = 0, THINKING_ON = 1 }; typedef struct { - char *base_url; - char *api_key; - /* 密钥来源, 仅供诊断展示 (不拥有内存). + char *base_url; + char *api_key; + /* 密钥来源, 仅供诊断展示 (不拥有内存). * 以前这里放的是现成的中文文案, 诊断代码靠 strstr(..., "环境变量") 判断 * 来源 —— 界面语言一变那个判断就悄悄失效了, 所以改成结构化字段. */ - const char *api_key_env; /* 来自哪个环境变量; NULL 表示不是环境变量 */ - int api_key_from_file; - int api_key_from_cli; - char *model; - char *system; /* NULL = 使用内置提示词 */ - double temperature; /* < 0 = 不发送 */ - long max_tokens; /* <= 0 = 不发送 */ - int thinking; /* 思考模式: THINKING_AUTO / _OFF / _ON, 见下 */ - char *reasoning_effort; /* 思考级别; NULL/空 = 不发送 */ - long timeout; /* 秒 */ - int stream; - int max_steps; /* 单轮最多几次模型往返; <= 0 = 不限 */ - int use_tools; - int retries; /* 瞬态失败的额外重试次数 */ - int confirm; /* 写操作与命令执行前询问 */ - int auto_save; /* 退出/重置/导入前自动存档 */ - int probe; /* 启动/换模型时探测模型能力 (vision / tools) */ - char *config_dir; /* 二进制所在目录 (配置与数据的唯一存放处) */ - char *config_path; /* 实际加载到的配置文件; 没加载则为 NULL */ + const char *api_key_env; /* 来自哪个环境变量; NULL 表示不是环境变量 */ + int api_key_from_file; + int api_key_from_cli; + char *model; + char *system; /* NULL = 使用内置提示词 */ + double temperature; /* < 0 = 不发送 */ + long max_tokens; /* <= 0 = 不发送 */ + int thinking; /* 思考模式: THINKING_AUTO / _OFF / _ON, 见下 */ + char *reasoning_effort; /* 思考级别; NULL/空 = 不发送 */ + long timeout; /* 秒 */ + int stream; + int max_steps; /* 单轮最多几次模型往返; <= 0 = 不限 */ + int use_tools; + int retries; /* 瞬态失败的额外重试次数 */ + int confirm; /* 写操作与命令执行前询问 */ + int auto_save; /* 退出/重置/导入前自动存档 */ + int probe; /* 启动/换模型时探测模型能力 (vision / tools) */ + char *config_dir; /* 二进制所在目录 (配置与数据的唯一存放处) */ + char *config_path; /* 实际加载到的配置文件; 没加载则为 NULL */ } LigentConfig; void config_defaults(LigentConfig *c); -/* 加载配置: 先读配置文件, 再用环境变量覆盖. 返回 0 成功. +/* 加载配置: 先读配置文件, 再用环境变量覆盖. 返回 0 成功. * - * explicit_path 非 NULL 时只读该文件 (命令行 -c 的显式指定); - * 为 NULL 时读 <二进制目录>/ligent.json. + * explicit_path 非 NULL 时只读该文件 (命令行 -c 的显式指定); + * 为 NULL 时读 <二进制目录>/ligent.json. * argv0 仅用于 /proc/self/exe 不可用时回退定位. */ int config_init(LigentConfig *c, const char *explicit_path, const char *argv0, char *errbuf, size_t errlen); /* 从指定文件读取并合并 (供 config_init 内部与测试使用) */ -int config_load_file(LigentConfig *c, const char *path, - char *errbuf, size_t errlen); +int config_load_file(LigentConfig *c, const char *path, char *errbuf, + size_t errlen); void config_apply_env(LigentConfig *c); void config_free(LigentConfig *c); /* ---- 思考模式的小工具 (配置文件 / 环境变量 / 命令行 / /think 共用)---- */ -/* 把 "auto"/"on"/"off"/"true"/"false"/"enabled"/"disabled"/"" 解析成 THINKING_*. - * 成功返回 0, 无法识别返回 -1. */ +/* 把 "auto"/"on"/"off"/"true"/"false"/"enabled"/"disabled"/"" 解析成 + * THINKING_*. 成功返回 0, 无法识别返回 -1. */ int config_parse_thinking(const char *s, int *out); -/* 思考级别是否在服务端认识的白名单里 (minimal/low/medium/high/xhigh/max/ultra) */ +/* 思考级别是否在服务端认识的白名单里 (minimal/low/medium/high/xhigh/max/ultra) + */ int config_effort_valid(const char *s); /* 白名单拼成的展示串, 形如 "minimal/low/.../ultra" (静态缓冲, 不用释放) */ @@ -76,7 +77,7 @@ const char *config_effort_list(void); /* THINKING_* 的中文说明 (静态字面量) */ const char *config_thinking_name(int thinking); -/* 二进制所在目录 (malloc); 失败返回 NULL. +/* 二进制所在目录 (malloc); 失败返回 NULL. * 优先 /proc/self/exe, 其次 argv0 里的路径, 最后在 $PATH 里查 */ char *config_binary_dir(const char *argv0); diff --git a/src/confirm.c b/src/confirm.c index f63bf67..e0bd1bf 100644 --- a/src/confirm.c +++ b/src/confirm.c @@ -1,45 +1,50 @@ /* confirm.c - 写操作确认 */ #include "confirm.h" #include "i18n.h" -#include "tools.h" /* TOOL_* 宏: 工具名只在那里定义一次 */ +#include "tools.h" /* TOOL_* 宏: 工具名只在那里定义一次 */ #include "util.h" +#include #include #include #include #include -#include #include #define BOLD "\033[1m" -#define DIM "\033[2m" -#define YEL "\033[33m" -#define OFF "\033[0m" +#define DIM "\033[2m" +#define YEL "\033[33m" +#define OFF "\033[0m" static int g_enabled = 1; static ConfirmDescriber g_describer; static int g_color; -static int g_turn = CONFIRM_ASK; /* 本轮的提问策略, 每轮复位 */ +static int g_turn = CONFIRM_ASK; /* 本轮的提问策略, 每轮复位 */ static const char *c(const char *code) { return g_color ? code : ""; } /* 截断并压掉换行, 保持在一行内 */ -static void preview(const char *s, size_t max, char *out, size_t outlen) -{ - if (!s) { snprintf(out, outlen, "%s", t("confirm.val.empty")); return; } +static void preview(const char *s, size_t max, char *out, size_t outlen) { + if (!s) { + snprintf(out, outlen, "%s", t("confirm.val.empty")); + return; + } size_t n = 0; for (const char *p = s; *p && n + 1 < outlen; p++) { if (*p == '\n' || *p == '\r' || *p == '\t') { - if (n + 1 < outlen) out[n++] = ' '; + if (n + 1 < outlen) + out[n++] = ' '; continue; } out[n++] = *p; - if (n >= max) break; + if (n >= max) + break; } out[n] = '\0'; if (s[n] && n >= max) { size_t l = strlen(out); - if (l + 4 < outlen) snprintf(out + l, outlen - l, "..."); + if (l + 4 < outlen) + snprintf(out + l, outlen - l, "..."); } } @@ -47,77 +52,82 @@ static void preview(const char *s, size_t max, char *out, size_t outlen) * 同一份, 两处对"改的是哪几行"的说法必须一致. */ /* 把这次调用做什么讲清楚 —— 只说工具名等于没说, 用户没法判断 */ -static void describe(const char *name, const JsonValue *args) -{ +static void describe(const char *name, const JsonValue *args) { if (g_describer) { char *text = g_describer(name, args); - if (text) { fprintf(stderr, "%s", text); free(text); return; } + if (text) { + fprintf(stderr, "%s", text); + free(text); + return; + } } char buf[256], num[32]; const char *path = json_get_string(args, "path"); if (strcmp(name, TOOL_RUN_COMMAND) == 0) { preview(json_get_string(args, "command"), 200, buf, sizeof buf); - fprintf(stderr, " %s %s%s%s\n", t("confirm.lbl.cmd"), c(BOLD), buf, c(OFF)); + fprintf(stderr, " %s %s%s%s\n", t("confirm.lbl.cmd"), c(BOLD), buf, + c(OFF)); } else if (strcmp(name, TOOL_WRITE_FILE) == 0) { const char *content = json_get_string(args, "content"); preview(path, 160, buf, sizeof buf); snprintf(num, sizeof num, "%zu", content ? strlen(content) : 0); - fprintf(stderr, " %s %s%s%s %s\n", t("confirm.lbl.write"), c(BOLD), buf, c(OFF), - tf("confirm.note.overwrite", "bytes", num, NULL)); + fprintf(stderr, " %s %s%s%s %s\n", t("confirm.lbl.write"), c(BOLD), + buf, c(OFF), tf("confirm.note.overwrite", "bytes", num, NULL)); } else if (strcmp(name, TOOL_EDIT_FILE) == 0) { char lines[96]; preview(path, 160, buf, sizeof buf); tool_lines_label(args, lines, sizeof lines); - /* 行号必须打出来: 用户要确认的第一件事就是"改的是哪几行". + /* 行号必须打出来: 用户要确认的第一件事就是"改的是哪几行". * 没给 line 就是全文件替换 (工具不拒绝它), 摘要里直说"全文件". */ const char *scope = lines[0] ? lines : t("tool.call.all_lines"); if (json_get_bool(args, "replace_all", 0)) scope = tf("tool.call.scope_all", "scope", scope, NULL); fprintf(stderr, " %s %s\"%s\" [%s]%s\n", t("confirm.lbl.edit"), c(BOLD), buf, scope, c(OFF)); - /* 然后摆出**带行号**的新旧对照, 再问 Y/n —— 用户就是照着它决定放不放行. + /* 然后摆出**带行号**的新旧对照, 再问 Y/n —— 用户就是照着它决定放不放行. * 旧版这里打的是"替换 <旧> -> <新>": 两边各截 60 字符、压成一行, 看不出 * 到底动了几行、动的是哪几行, 长一点的改动更是只剩两个省略号. */ tool_render_diff(stderr, args, " ", g_color); } else if (path) { preview(path, 160, buf, sizeof buf); - fprintf(stderr, " %s %s%s%s\n", t("confirm.lbl.path"), c(BOLD), buf, c(OFF)); + fprintf(stderr, " %s %s%s%s\n", t("confirm.lbl.path"), c(BOLD), buf, + c(OFF)); } } -void confirm_set_describer(ConfirmDescriber fn) -{ - g_describer = fn; -} +void confirm_set_describer(ConfirmDescriber fn) { g_describer = fn; } -void confirm_init(int enabled) -{ +void confirm_init(int enabled) { g_enabled = enabled ? 1 : 0; g_color = isatty(STDERR_FILENO) ? 1 : 0; } void confirm_set_enabled(int on) { g_enabled = on ? 1 : 0; } -int confirm_enabled(void) { return g_enabled; } +int confirm_enabled(void) { return g_enabled; } void confirm_reset_turn(void) { g_turn = CONFIRM_ASK; } -int confirm_turn_mode(void) { return g_turn; } +int confirm_turn_mode(void) { return g_turn; } -int confirm_tool(const char *name, const JsonValue *args) -{ - if (!g_enabled) return 1; +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; + if (g_turn == CONFIRM_ALWAYS) + return 1; + if (g_turn == CONFIRM_NEVER) + return 0; /* 名字都没有就没什么可描述的, 也没有什么"改动"值得拦 —— 交给工具层 * 去回一句"未知工具". strcmp(NULL, ...) 会直接崩. */ - if (!name) return 1; + if (!name) + return 1; - /* 非交互输入: 没有人能回答, 按默认值 (Y)放行. + /* 非交互输入: 没有人能回答, 按默认值 (Y)放行. * 这也是"默认 Y"的自然推论 —— 问不到就等于不反对. */ - if (!isatty(STDIN_FILENO)) return 1; + if (!isatty(STDIN_FILENO)) + return 1; /* 提权命令会在参数里明说 elevated, describe() 已经把它打出来了 —— * 这里不需要额外的身份标注: 没有常驻提权状态, 一次命令一次身份. */ @@ -133,28 +143,32 @@ 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 关了, 没人能回答 —— 才按默认值放行. + * EINTR 用户按了 Ctrl-C. 他是在说"别问了" —— 必须拒绝. + * 真 EOF stdin 关了, 没人能回答 —— 才按默认值放行. * * 原来两者走同一个分支、都返回 1 (允许), 于是"在确认提示上按 * Ctrl-C"变成了"批准这次操作". run_command 侥幸自己查了中断 - * 标志才没执行, write_file / edit_file 没查, 文件照写. + * 标志才没执行, write_file / edit_file 没查, 文件照写. * * 返回 0 之后 g_ligent_interrupt 仍是 1, agent_run 会把这一轮 * 收掉 —— 和 Ctrl-C 在别处的语义 (中断当前请求)保持一致. */ - if (errno == EINTR) return 0; + if (errno == EINTR) + return 0; return 1; } char *ans = str_trim(line); /* 回车 = Y; A/V 大写小写都收, 也收全词 */ - if (*ans == '\0' || strcmp(ans, "y") == 0 || strcmp(ans, "Y") == 0) return 1; - if (strcmp(ans, "n") == 0 || strcmp(ans, "N") == 0) return 0; + if (*ans == '\0' || strcmp(ans, "y") == 0 || strcmp(ans, "Y") == 0) + return 1; + if (strcmp(ans, "n") == 0 || strcmp(ans, "N") == 0) + return 0; if (strcmp(ans, "a") == 0 || strcmp(ans, "A") == 0 || strcasecmp(ans, "always") == 0) { g_turn = CONFIRM_ALWAYS; - fprintf(stderr, " %s%s%s\n", c(DIM), t("confirm.always"), c(OFF)); + fprintf(stderr, " %s%s%s\n", c(DIM), t("confirm.always"), + c(OFF)); return 1; } if (strcmp(ans, "v") == 0 || strcmp(ans, "V") == 0 || diff --git a/src/confirm.h b/src/confirm.h index bbe8993..2ed2285 100644 --- a/src/confirm.h +++ b/src/confirm.h @@ -1,9 +1,9 @@ /* confirm.h - 写操作与命令执行的确认 * * 默认开启. ligent 最初是"全权执行"的设计, 但模型一旦看错文件或理解偏了 - * 需求, 后果是直接落到磁盘上的; 加一道确认是目前唯一有效的止损点. + * 需求, 后果是直接落到磁盘上的; 加一道确认是目前唯一有效的止损点. * - * 只读工具 (read_file / list_dir)不打扰. + * 只读工具 (read_file / list_dir)不打扰. */ #ifndef LIGENT_CONFIRM_H #define LIGENT_CONFIRM_H @@ -12,33 +12,33 @@ void confirm_init(int enabled); void confirm_set_enabled(int on); -int confirm_enabled(void); +int confirm_enabled(void); -/* 询问是否允许执行某个会改动东西的工具: 显示将要做什么, 然后问 (Y/n). - * 返回 1 允许, 0 拒绝. 回车即默认允许. +/* 询问是否允许执行某个会改动东西的工具: 显示将要做什么, 然后问 (Y/n). + * 返回 1 允许, 0 拒绝. 回车即默认允许. * * 拒绝时返回 0 而不是让整轮失败 —— 上层会把"用户拒绝了"作为工具结果 - * 回填给模型, 让它换一种做法. + * 回填给模型, 让它换一种做法. * * stdin 不是终端时 (管道/重定向)没有人能回答, 按默认值放行. */ -/* 本轮的提问策略. 作用域是"一轮"——一条用户消息引发的那串工具调用, +/* 本轮的提问策略. 作用域是"一轮"——一条用户消息引发的那串工具调用, * 下一轮自动复位. 会话级的开关是 /yolo, 配置级的是 confirm. */ enum { - CONFIRM_ASK = 0, /* 每次都要问 (默认) */ - CONFIRM_ALWAYS, /* 本轮剩下的改动一律放行 */ - CONFIRM_NEVER /* 本轮剩下的改动一律拒绝 */ + CONFIRM_ASK = 0, /* 每次都要问 (默认) */ + CONFIRM_ALWAYS, /* 本轮剩下的改动一律放行 */ + CONFIRM_NEVER /* 本轮剩下的改动一律拒绝 */ }; /* 复位成 CONFIRM_ASK. agent_run 每轮开头调一次 —— "这一轮"的定义就在那儿. */ void confirm_reset_turn(void); /* 当前轮次策略, 供上层展示/测试 */ -int confirm_turn_mode(void); +int confirm_turn_mode(void); int confirm_tool(const char *name, const JsonValue *args); -/* 可选: 自定义"这个工具要做什么"的说明. - * 返回 malloc 的文本 (每行自带缩进), 返回 NULL 表示回退到内置描述. +/* 可选: 自定义"这个工具要做什么"的说明. + * 返回 malloc 的文本 (每行自带缩进), 返回 NULL 表示回退到内置描述. * * meta 的工具需要它: 那些参数里带整段消息内容, 内置描述既打不出来 * 也不该打出来 —— 得总结成人话 ("压缩 2..6 轮, 6 条 -> 1 条"). */ diff --git a/src/doctor.c b/src/doctor.c index 90a9d17..d892c25 100644 --- a/src/doctor.c +++ b/src/doctor.c @@ -1,34 +1,34 @@ /* doctor.c - 环境体检 * - * 这是把前几轮踩过的坑固化成检查项: + * 这是把前几轮踩过的坑固化成检查项: * 1. 配置文件解析失败却只给一句"语法错误" (现在带行列号) * 2. shell 里残留的厂商环境变量顶掉配置文件里的密钥 * 3. base_url 重定向导致 Authorization 头被丢掉, 服务端回 401 * 4. 密钥与服务商不匹配 (把 DeepSeek 的 key 发给 OpenRouter) - * 这些问题的共同点是: 报错信息指向的方向是错的, 靠猜要花很久. + * 这些问题的共同点是: 报错信息指向的方向是错的, 靠猜要花很久. */ #include "doctor.h" -#include "version.h" #include "http.h" #include "i18n.h" #include "json.h" #include "llm.h" #include "util.h" +#include "version.h" +#include +#include #include #include #include -#include -#include #include -#include +#include #define BOLD "\033[1m" -#define DIM "\033[2m" -#define RED "\033[31m" -#define YEL "\033[33m" -#define GRN "\033[32m" -#define OFF "\033[0m" +#define DIM "\033[2m" +#define RED "\033[31m" +#define YEL "\033[33m" +#define GRN "\033[32m" +#define OFF "\033[0m" #define LABEL_W 16 @@ -38,24 +38,25 @@ static int warnings; static const char *c(const char *code) { return color_on ? code : ""; } -static void section(const char *name) -{ +static void section(const char *name) { /* 括号也随语言走: 英文里写【binary】很别扭 */ - printf("\n%s%s%s\n", c(BOLD), tf("fmt.section", "name", name, NULL), c(OFF)); + 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) -{ +static void label(const char *s, size_t width) { fputs(s, stdout); size_t w = display_width(s, strlen(s)); - while (w < width) { putchar(' '); w++; } - putchar(' '); /* 标签刚好占满时也要有分隔 */ + while (w < width) { + putchar(' '); + w++; + } + putchar(' '); /* 标签刚好占满时也要有分隔 */ } -LIGENT_PRINTF(2, 3) static void field(const char *lbl, const char *fmt, ...) -{ +LIGENT_PRINTF(2, 3) static void field(const char *lbl, const char *fmt, ...) { printf(" "); label(lbl, LABEL_W); va_list ap; @@ -66,12 +67,16 @@ LIGENT_PRINTF(2, 3) static void field(const char *lbl, const char *fmt, ...) } /* level: 0 = 正常, 1 = 警告, 2 = 问题, 3 = 仅陈列 (灰点) */ -LIGENT_PRINTF(3, 4) static void marked(int level, const char *lbl, const char *fmt, ...) -{ - const char *tag = level == 2 ? "[x]" : level == 1 ? "[!]" : - level == 3 ? "[-]" : "[v]"; - const char *col = level == 2 ? RED : level == 1 ? YEL : - level == 3 ? DIM : GRN; +LIGENT_PRINTF(3, 4) +static void marked(int level, const char *lbl, const char *fmt, ...) { + const char *tag = level == 2 ? "[x]" + : level == 1 ? "[!]" + : level == 3 ? "[-]" + : "[v]"; + const char *col = level == 2 ? RED + : level == 1 ? YEL + : level == 3 ? DIM + : GRN; printf(" "); label(lbl, LABEL_W); printf("%s%s%s ", c(col), tag, c(OFF)); @@ -80,13 +85,14 @@ LIGENT_PRINTF(3, 4) static void marked(int level, const char *lbl, const char *f vprintf(fmt, ap); va_end(ap); putchar('\n'); - if (level == 2) problems++; - else if (level == 1) warnings++; + if (level == 2) + problems++; + else if (level == 1) + warnings++; } /* 无标签的缩进说明行 */ -LIGENT_PRINTF(1, 2) static void sub(const char *fmt, ...) -{ +LIGENT_PRINTF(1, 2) static void sub(const char *fmt, ...) { printf(" "); va_list ap; va_start(ap, fmt); @@ -99,51 +105,66 @@ LIGENT_PRINTF(1, 2) static void sub(const char *fmt, ...) /* 小工具 */ /* ------------------------------------------------------------------ */ -static char *host_of(const char *url) -{ - if (!url) return NULL; +static char *host_of(const char *url) { + if (!url) + return NULL; const char *p = strstr(url, "://"); p = p ? p + 3 : url; const char *end = p; - while (*end && *end != '/' && *end != ':' && *end != '?') end++; + while (*end && *end != '/' && *end != ':' && *end != '?') + end++; return xstrndup(p, (size_t)(end - p)); } -static void mask_key(const char *key, char *out, size_t n) -{ +static void mask_key(const char *key, char *out, size_t n) { char num[32]; size_t len = key ? strlen(key) : 0; snprintf(num, sizeof num, "%zu", len); - if (len == 0) snprintf(out, n, "%s", t("doctor.val.empty")); - else if (len <= 10) snprintf(out, n, "%.4s...%s", key, - tf("doctor.val.chars", "n", num, NULL)); - else snprintf(out, n, "%.8s...%s", key, - tf("doctor.val.chars", "n", num, NULL)); + if (len == 0) + snprintf(out, n, "%s", t("doctor.val.empty")); + else if (len <= 10) + snprintf(out, n, "%.4s...%s", key, + tf("doctor.val.chars", "n", num, NULL)); + else + snprintf(out, n, "%.8s...%s", key, + tf("doctor.val.chars", "n", num, NULL)); } /* 从密钥前缀推断它属于哪家. 返回 NULL 表示认不出来 */ -static const char *key_owner(const char *key) -{ - if (!key || !*key) return NULL; - if (strncmp(key, "sk-or-", 6) == 0) return "OpenRouter"; - if (strncmp(key, "sk-ant-", 7) == 0) return "Anthropic"; - if (strncmp(key, "sk-proj-", 8) == 0) return "OpenAI"; - if (strncmp(key, "gsk_", 4) == 0) return "Groq"; - if (strncmp(key, "AIza", 4) == 0) return "Google"; - if (strncmp(key, "sk-", 3) == 0) return t("doctor.val.owner.sk"); +static const char *key_owner(const char *key) { + if (!key || !*key) + return NULL; + if (strncmp(key, "sk-or-", 6) == 0) + return "OpenRouter"; + if (strncmp(key, "sk-ant-", 7) == 0) + return "Anthropic"; + if (strncmp(key, "sk-proj-", 8) == 0) + return "OpenAI"; + if (strncmp(key, "gsk_", 4) == 0) + return "Groq"; + if (strncmp(key, "AIza", 4) == 0) + return "Google"; + if (strncmp(key, "sk-", 3) == 0) + return t("doctor.val.owner.sk"); return NULL; } /* 从端点推断它属于哪家 */ -static const char *host_owner(const char *host) -{ - if (!host) return NULL; - if (strstr(host, "openrouter")) return "OpenRouter"; - if (strstr(host, "deepseek")) return "DeepSeek"; - if (strstr(host, "anthropic")) return "Anthropic"; - if (strstr(host, "openai.com")) return "OpenAI"; - if (strstr(host, "groq")) return "Groq"; - if (strstr(host, "googleapis")) return "Google"; +static const char *host_owner(const char *host) { + if (!host) + return NULL; + if (strstr(host, "openrouter")) + return "OpenRouter"; + if (strstr(host, "deepseek")) + return "DeepSeek"; + if (strstr(host, "anthropic")) + return "Anthropic"; + if (strstr(host, "openai.com")) + return "OpenAI"; + if (strstr(host, "groq")) + return "Groq"; + if (strstr(host, "googleapis")) + return "Google"; return NULL; } @@ -151,18 +172,20 @@ static const char *host_owner(const char *host) /* 各部分检查 */ /* ------------------------------------------------------------------ */ -static void check_binary(const char *argv0) -{ +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()); char exe[PATH_MAX]; ssize_t n = readlink("/proc/self/exe", exe, sizeof exe - 1); - if (n > 0) { exe[n] = '\0'; field(t("doctor.lbl.exe"), "%s", exe); } - else field(t("doctor.lbl.exe"), "%s", t("doctor.val.unreadable_exe")); + if (n > 0) { + exe[n] = '\0'; + field(t("doctor.lbl.exe"), "%s", exe); + } else + field(t("doctor.lbl.exe"), "%s", t("doctor.val.unreadable_exe")); if (argv0 && *argv0) { field(t("doctor.lbl.argv0"), "%s", argv0); @@ -180,17 +203,18 @@ static void check_binary(const char *argv0) } char *dir = config_binary_dir(argv0); - field(t("doctor.lbl.config_dir"), "%s", dir ? dir : t("doctor.val.unknown")); + field(t("doctor.lbl.config_dir"), "%s", + dir ? dir : t("doctor.val.unknown")); free(dir); } static void check_config_file(const LigentConfig *cfg, const char *argv0, - const char *config_error) -{ + const char *config_error) { section(t("doctor.sec.config")); char *canon = config_default_path(argv0); - field(t("doctor.lbl.canon_path"), "%s", canon ? canon : t("doctor.val.unknown")); + field(t("doctor.lbl.canon_path"), "%s", + canon ? canon : t("doctor.val.unknown")); if (config_error && *config_error) { marked(2, t("doctor.lbl.status"), "%s", @@ -224,23 +248,24 @@ static void check_config_file(const LigentConfig *cfg, const char *argv0, JsonValue *v = NULL; size_t len = 0; char *text = read_entire_file(cfg->config_path, &len); - if (text) v = json_parse(text, NULL, 0); + if (text) + v = json_parse(text, NULL, 0); if (v) { - /* 这份清单必须与 config.c 的 config_load_file 认识的键一一对应: + /* 这份清单必须与 config.c 的 config_load_file 认识的键一一对应: * 它是"配置文件里到底设了哪些键"的唯一依据, 多写一个不存在的键、 - * 或者漏掉一个真键, 这一行说的就是假话. (以前这里写的是 "log", + * 或者漏掉一个真键, 这一行说的就是假话. (以前这里写的是 "log", * 而配置文件根本没有这个键, 同时真有的 "probe" 没有列出来. ) */ static const char *keys[] = { - "base_url", "api_key", "model", "system", "temperature", - "max_tokens", "thinking", "reasoning_effort", - "timeout", "stream", "max_steps", "tools", - "retries", "confirm", "auto_save", "probe" - }; + "base_url", "api_key", "model", "system", + "temperature", "max_tokens", "thinking", "reasoning_effort", + "timeout", "stream", "max_steps", "tools", + "retries", "confirm", "auto_save", "probe"}; StrBuf sb; sb_init(&sb); for (size_t i = 0; i < sizeof keys / sizeof keys[0]; i++) if (json_object_get(v, keys[i])) { - if (sb.len) sb_puts(&sb, ", "); + if (sb.len) + sb_puts(&sb, ", "); sb_puts(&sb, keys[i]); } field(t("doctor.lbl.set_keys"), "%s", @@ -253,8 +278,7 @@ static void check_config_file(const LigentConfig *cfg, const char *argv0, free(canon); } -static void check_key(const LigentConfig *cfg) -{ +static void check_key(const LigentConfig *cfg) { section(t("doctor.sec.key")); size_t len = cfg->api_key ? strlen(cfg->api_key) : 0; @@ -296,7 +320,8 @@ static void check_key(const LigentConfig *cfg) tf("doctor.val.match_ok", "owner", expect, NULL)); else marked(2, t("doctor.lbl.match"), "%s", - tf("doctor.val.match_bad", "owner", expect, "guess", owner, NULL)); + tf("doctor.val.match_bad", "owner", expect, "guess", owner, + NULL)); } else if (expect) { marked(1, t("doctor.lbl.match"), "%s", tf("doctor.val.match_unknown", "owner", expect, NULL)); @@ -306,8 +331,7 @@ static void check_key(const LigentConfig *cfg) free(host); } -static void check_endpoint(const LigentConfig *cfg) -{ +static void check_endpoint(const LigentConfig *cfg) { section(t("doctor.sec.endpoint")); field("base_url", "%s", @@ -322,8 +346,12 @@ static void check_endpoint(const LigentConfig *cfg) int local = strstr(cfg->base_url, "127.0.0.1") || strstr(cfg->base_url, "localhost") || strstr(cfg->base_url, "[::1]"); - if (local) marked(0, t("doctor.lbl.transport"), "%s", t("doctor.val.plain_local")); - else marked(1, t("doctor.lbl.transport"), "%s", t("doctor.val.plain_remote")); + if (local) + marked(0, t("doctor.lbl.transport"), "%s", + t("doctor.val.plain_local")); + else + marked(1, t("doctor.lbl.transport"), "%s", + t("doctor.val.plain_remote")); } else if (cfg->base_url && strncmp(cfg->base_url, "https://", 8) == 0) { marked(0, t("doctor.lbl.transport"), "%s", t("doctor.val.https")); } else { @@ -340,30 +368,34 @@ static void check_endpoint(const LigentConfig *cfg) free(url); } -static void check_env(const LigentConfig *cfg) -{ +static void check_env(const LigentConfig *cfg) { section(t("doctor.sec.env")); - static const struct { const char *name; const char *note_key; } vars[] = { - { "LIGENT_API_KEY", "doctor.env.note.api_key" }, - { "DEEPSEEK_API_KEY", "doctor.env.note.deepseek" }, - { "OPENAI_API_KEY", "doctor.env.note.openai" }, - { "LIGENT_BASE_URL", "doctor.env.note.base_url" }, - { "LIGENT_MODEL", "doctor.env.note.model" }, - { "LIGENT_CONFIG", "doctor.env.note.config" }, - { "LIGENT_LANG", "doctor.env.note.lang" }, - { "HTTPS_PROXY", "doctor.env.note.https_proxy" }, - { "HTTP_PROXY", "doctor.env.note.http_proxy" }, - { "ALL_PROXY", "doctor.env.note.all_proxy" }, - { "NO_PROXY", "doctor.env.note.no_proxy" }, + static const struct { + const char *name; + const char *note_key; + } vars[] = { + {"LIGENT_API_KEY", "doctor.env.note.api_key"}, + {"DEEPSEEK_API_KEY", "doctor.env.note.deepseek"}, + {"OPENAI_API_KEY", "doctor.env.note.openai"}, + {"LIGENT_BASE_URL", "doctor.env.note.base_url"}, + {"LIGENT_MODEL", "doctor.env.note.model"}, + {"LIGENT_CONFIG", "doctor.env.note.config"}, + {"LIGENT_LANG", "doctor.env.note.lang"}, + {"HTTPS_PROXY", "doctor.env.note.https_proxy"}, + {"HTTP_PROXY", "doctor.env.note.http_proxy"}, + {"ALL_PROXY", "doctor.env.note.all_proxy"}, + {"NO_PROXY", "doctor.env.note.no_proxy"}, }; for (size_t i = 0; i < sizeof vars / sizeof vars[0]; i++) { const char *v = getenv(vars[i].name); if (v && *v) { char masked[64]; - if (strstr(vars[i].name, "KEY")) mask_key(v, masked, sizeof masked); - else snprintf(masked, sizeof masked, "%s", v); + if (strstr(vars[i].name, "KEY")) + mask_key(v, masked, sizeof masked); + else + snprintf(masked, sizeof masked, "%s", v); marked(0, vars[i].name, "%s", tf("doctor.val.env_set", "value", masked, NULL)); } else { @@ -378,19 +410,19 @@ static void check_env(const LigentConfig *cfg) if (!used_from_env && ((ds && *ds) || (ok && *ok))) { const char *which = (ds && *ds) ? "DEEPSEEK_API_KEY" : "OPENAI_API_KEY"; marked(1, t("doctor.lbl.note"), "%s", - tf("doctor.val.env_ignored", "var", which, - "source", config_api_key_source(cfg), NULL)); + tf("doctor.val.env_ignored", "var", which, "source", + config_api_key_source(cfg), NULL)); } } -static void check_params(const LigentConfig *cfg) -{ +static void check_params(const LigentConfig *cfg) { char num[32]; section(t("doctor.sec.params")); - field("temperature", "%s", cfg->temperature < 0 - ? t("doctor.val.server_default") : (snprintf(num, sizeof num, "%.2f", - cfg->temperature), num)); + field("temperature", "%s", + cfg->temperature < 0 + ? t("doctor.val.server_default") + : (snprintf(num, sizeof num, "%.2f", cfg->temperature), num)); if (cfg->temperature > 2.0) sub("%s", t("doctor.sub.temp_too_high")); @@ -405,8 +437,10 @@ static void check_params(const LigentConfig *cfg) snprintf(num, sizeof num, "%ld", cfg->timeout); field("timeout", "%s", tf("doctor.val.timeout", "n", num, NULL)); field("thinking", "%s", config_thinking_name(cfg->thinking)); - field(t("doctor.lbl.effort"), "%s", (cfg->reasoning_effort && *cfg->reasoning_effort) - ? cfg->reasoning_effort : t("doctor.val.server_default")); + field(t("doctor.lbl.effort"), "%s", + (cfg->reasoning_effort && *cfg->reasoning_effort) + ? cfg->reasoning_effort + : t("doctor.val.server_default")); if (cfg->thinking == THINKING_ON && cfg->temperature >= 0) sub("%s", t("doctor.sub.thinking_temp")); @@ -419,23 +453,27 @@ static void check_params(const LigentConfig *cfg) } else { field("max_steps", "%s", t("cfg.val.unlimited")); } - field("tools", "%s", t(cfg->use_tools ? "cfg.val.enabled" : "cfg.val.disabled")); - field("stream", "%s", t(cfg->stream ? "cfg.val.enabled" : "cfg.val.disabled")); + field("tools", "%s", + t(cfg->use_tools ? "cfg.val.enabled" : "cfg.val.disabled")); + field("stream", "%s", + t(cfg->stream ? "cfg.val.enabled" : "cfg.val.disabled")); snprintf(num, sizeof num, "%zu", http_log_size()); field(t("doctor.lbl.log"), "%s", tf("doctor.val.log", "n", num, NULL)); } -static void check_network(const LigentConfig *cfg, int verbose) -{ +static void check_network(const LigentConfig *cfg, int verbose) { char num[32]; section(t("doctor.sec.net")); const char *proxy = getenv("HTTPS_PROXY"); - if (!proxy) proxy = getenv("https_proxy"); - if (!proxy) proxy = getenv("ALL_PROXY"); - if (!proxy) proxy = getenv("all_proxy"); - /* 一定要过 "%s": 代理 URL 里带 % 是常事 (密码 %40、路径 %2F), + if (!proxy) + proxy = getenv("https_proxy"); + if (!proxy) + proxy = getenv("ALL_PROXY"); + if (!proxy) + proxy = getenv("all_proxy"); + /* 一定要过 "%s": 代理 URL 里带 % 是常事 (密码 %40、路径 %2F), * 直接当格式串会读到不存在的参数. */ field(t("doctor.lbl.proxy"), "%s", proxy && *proxy ? proxy : t("doctor.val.proxy_unset")); @@ -447,8 +485,8 @@ static void check_network(const LigentConfig *cfg, int verbose) int have_key = cfg->api_key && *cfg->api_key; int rc = http_probe(url, have_key ? cfg->api_key : NULL, - cfg->timeout > 0 ? cfg->timeout : 300, verbose, - &status, &location, err, sizeof err); + cfg->timeout > 0 ? cfg->timeout : 300, verbose, &status, + &location, err, sizeof err); if (rc != 0) { marked(2, t("doctor.lbl.connectivity"), "%s", @@ -465,7 +503,8 @@ static void check_network(const LigentConfig *cfg, int verbose) if (status >= 300 && status < 400) { marked(2, t("doctor.lbl.redirect"), "%s", tf("doctor.val.redirect_to", "location", - location ? location : t("doctor.val.location_missing"), NULL)); + location ? location : t("doctor.val.location_missing"), + NULL)); sub("%s", t("doctor.sub.redirect_1")); sub("%s", t("doctor.sub.redirect_2")); sub("%s", t("doctor.sub.redirect_3")); @@ -483,14 +522,15 @@ static void check_network(const LigentConfig *cfg, int verbose) sub("%s", t("doctor.sub.auth_not_network")); } } else if (status == 404) { - /* 404 也不能当成"密钥没问题": 请求多半在匹配到路由之前就被回绝了, - * 这次探测根本没走到认证那一步. 而且 404 本身通常就是 base_url 写错了. */ + /* 404 也不能当成"密钥没问题": 请求多半在匹配到路由之前就被回绝了, + * 这次探测根本没走到认证那一步. 而且 404 本身通常就是 base_url 写错了. + */ marked(2, t("doctor.lbl.endpoint"), "%s", tf("doctor.val.endpoint_404", "url", url, NULL)); sub("%s", t("doctor.sub.404_base_url")); sub("%s", t("doctor.sub.404_no_key_info")); } else { - /* 400 / 405 / 200 这类都说明请求已经进到了路由里 —— 认证这一关过了. + /* 400 / 405 / 200 这类都说明请求已经进到了路由里 —— 认证这一关过了. * (405 就是我们用 GET 打一个只收 POST 的端点, 正常现象. ) */ marked(0, t("doctor.lbl.auth"), "%s", t(have_key ? "doctor.val.auth_ok" : "doctor.val.no_auth")); @@ -504,8 +544,7 @@ static void check_network(const LigentConfig *cfg, int verbose) /* ------------------------------------------------------------------ */ int doctor_run(const LigentConfig *cfg, const char *argv0, int do_network, - const char *config_error) -{ + const char *config_error) { color_on = isatty(STDOUT_FILENO) ? 1 : 0; printf("%s%s%s\n", c(BOLD), t("doctor.title"), c(OFF)); @@ -516,8 +555,10 @@ int doctor_run(const LigentConfig *cfg, const char *argv0, int do_network, check_endpoint(cfg); check_env(cfg); check_params(cfg); - if (do_network) check_network(cfg, 0); - else printf("\n%s%s%s\n", c(DIM), t("doctor.sec.net_skipped"), c(OFF)); + if (do_network) + check_network(cfg, 0); + else + printf("\n%s%s%s\n", c(DIM), t("doctor.sec.net_skipped"), c(OFF)); printf("\n%s", c(BOLD)); if (problems == 0 && warnings == 0) { @@ -526,8 +567,10 @@ int doctor_run(const LigentConfig *cfg, const char *argv0, int do_network, char pb[16], wb[16]; snprintf(pb, sizeof pb, "%d", problems); snprintf(wb, sizeof wb, "%d", warnings); - printf("%s%s\n", tf("doctor.conclusion.bad", "problems", pb, - "warnings", wb, NULL), c(OFF)); + printf( + "%s%s\n", + tf("doctor.conclusion.bad", "problems", pb, "warnings", wb, NULL), + c(OFF)); } if (problems > 0) printf("%s\n", t("doctor.conclusion.next")); diff --git a/src/doctor.h b/src/doctor.h index eadbdb1..8a30cd4 100644 --- a/src/doctor.h +++ b/src/doctor.h @@ -5,11 +5,11 @@ #include "config.h" /* 把整条链路逐环检查一遍: 二进制定位、配置文件、密钥来源与格式、 - * 端点、环境变量干扰、参数合理性、网络连通性与重定向. + * 端点、环境变量干扰、参数合理性、网络连通性与重定向. * - * do_network = 0 时跳过联网检查. + * do_network = 0 时跳过联网检查. * 返回"问题" ([x])的个数, main 用它决定退出码. */ -/* config_error 非 NULL 表示配置文件本身加载失败 —— 这种情况下更要跑 doctor, +/* config_error 非 NULL 表示配置文件本身加载失败 —— 这种情况下更要跑 doctor, * 因为那正是用户最需要诊断的时刻. */ int doctor_run(const LigentConfig *cfg, const char *argv0, int do_network, const char *config_error); diff --git a/src/http.c b/src/http.c index 2923104..7d0bf06 100644 --- a/src/http.c +++ b/src/http.c @@ -1,17 +1,17 @@ /* http.c - libcurl 封装: 同步 POST、流式 POST、瞬态失败重试退避 */ #include "http.h" #include "i18n.h" -#include "util.h" #include "spinner.h" +#include "util.h" +#include +#include +#include #include #include #include #include -#include -#include #include -#include volatile sig_atomic_t g_ligent_interrupt = 0; @@ -22,26 +22,29 @@ static char errsink[1]; /* 收发记录: 进程内滚动缓存 */ /* ------------------------------------------------------------------ */ -/* 上限. 一场长会话可能产生很多流量, 不能无限涨; +/* 上限. 一场长会话可能产生很多流量, 不能无限涨; * 超出后丢掉较早的部分, 保留最近的 (排障时通常更关心刚刚发生了什么) */ #define LOG_MAX (16u * 1024 * 1024) static StrBuf g_log; -static int g_log_init; -static int g_log_truncated; +static int g_log_init; +static int g_log_truncated; -static void log_append(const void *data, size_t n) -{ - if (!data || n == 0) return; - if (!g_log_init) { sb_init(&g_log); g_log_init = 1; } +static void log_append(const void *data, size_t n) { + if (!data || n == 0) + return; + if (!g_log_init) { + sb_init(&g_log); + g_log_init = 1; + } const char *p = data; - if (n > LOG_MAX) { /* 单块就超限: 只留尾部 */ + if (n > LOG_MAX) { /* 单块就超限: 只留尾部 */ p += n - LOG_MAX; n = LOG_MAX; g_log_truncated = 1; } - if (g_log.len + n > LOG_MAX) { /* 滚动丢弃较早的部分 */ + if (g_log.len + n > LOG_MAX) { /* 滚动丢弃较早的部分 */ size_t drop = g_log.len + n - LOG_MAX; memmove(g_log.data, g_log.data + drop, g_log.len - drop); g_log.len -= drop; @@ -53,23 +56,22 @@ static void log_append(const void *data, size_t n) /* 追加一个 C 字符串. 不要手写长度 —— 上面那行中文标签就是因为 * 手数成 20 字节 (实际 22)而被吃掉了两个字符. */ -static void log_line(const char *s) -{ - if (s) log_append(s, strlen(s)); +static void log_line(const char *s) { + if (s) + log_append(s, strlen(s)); } -LIGENT_PRINTF(1, 2) static void log_printf(const char *fmt, ...) -{ +LIGENT_PRINTF(1, 2) static void log_printf(const char *fmt, ...) { char buf[512]; va_list ap; va_start(ap, fmt); int n = vsnprintf(buf, sizeof buf, fmt, ap); va_end(ap); - if (n > 0) log_append(buf, n < (int)sizeof buf ? (size_t)n : sizeof buf - 1); + if (n > 0) + log_append(buf, n < (int)sizeof buf ? (size_t)n : sizeof buf - 1); } -static void log_stamp(void) -{ +static void log_stamp(void) { time_t t = time(NULL); struct tm tm; localtime_r(&t, &tm); @@ -79,32 +81,35 @@ static void log_stamp(void) } size_t http_log_size(void) { return g_log_init ? g_log.len : 0; } -int http_log_truncated(void) { return g_log_truncated; } +int http_log_truncated(void) { return g_log_truncated; } -void http_log_clear(void) -{ - if (g_log_init) sb_clear(&g_log); +void http_log_clear(void) { + if (g_log_init) + sb_clear(&g_log); g_log_truncated = 0; } -void http_log_free(void) -{ - if (g_log_init) sb_free(&g_log); +void http_log_free(void) { + if (g_log_init) + sb_free(&g_log); g_log_init = 0; g_log_truncated = 0; } -int http_log_save(const char *path, size_t *out_bytes, - char *errbuf, size_t errlen) -{ +int http_log_save(const char *path, size_t *out_bytes, char *errbuf, + size_t errlen) { /* 和其它 HTTP 入口一个规矩: 没有落点也不能空手写字 */ - if (!errbuf || !errlen) { errbuf = errsink; errlen = 1; } - if (out_bytes) *out_bytes = 0; + if (!errbuf || !errlen) { + errbuf = errsink; + errlen = 1; + } + if (out_bytes) + *out_bytes = 0; FILE *f = fopen(path, "a"); if (!f) { snprintf(errbuf, errlen, "%s", - tf("http.err.log_open", "path", path, - "error", strerror(errno), NULL)); + tf("http.err.log_open", "path", path, "error", strerror(errno), + NULL)); return -1; } if (g_log_init && g_log.len > 0) { @@ -114,15 +119,19 @@ int http_log_save(const char *path, size_t *out_bytes, char when[32]; strftime(when, sizeof when, "%Y-%m-%d %H:%M:%S", &tm); - fprintf(f, "%s", tf("http.log.header", "time", when, - "n", i18n_num((long long)g_log.len), NULL)); + fprintf(f, "%s", + tf("http.log.header", "time", when, "n", + i18n_num((long long)g_log.len), NULL)); /* 这里以前是 fputs —— 不处理格式, 于是文件里真的会出现字面量 "%u" */ if (g_log_truncated) - fprintf(f, "%s", tf("http.log.truncated", "limit", - i18n_num((long long)LOG_MAX), NULL)); + fprintf(f, "%s", + tf("http.log.truncated", "limit", + i18n_num((long long)LOG_MAX), NULL)); fwrite(g_log.data, 1, g_log.len, f); - if (g_log.data[g_log.len - 1] != '\n') fputc('\n', f); - if (out_bytes) *out_bytes = g_log.len; + if (g_log.data[g_log.len - 1] != '\n') + fputc('\n', f); + if (out_bytes) + *out_bytes = g_log.len; } int bad = (fclose(f) != 0); if (bad) { @@ -140,12 +149,11 @@ void http_global_cleanup(void) { curl_global_cleanup(); } /* 重试判定 */ /* ------------------------------------------------------------------ */ -int http_status_retryable(long status) -{ +int http_status_retryable(long status) { switch (status) { - case 408: /* Request Timeout */ - case 409: /* Conflict (部分网关用它表示临时冲突) */ - case 429: /* Too Many Requests */ + case 408: /* Request Timeout */ + case 409: /* Conflict (部分网关用它表示临时冲突) */ + case 429: /* Too Many Requests */ case 500: case 502: case 503: @@ -156,14 +164,13 @@ int http_status_retryable(long status) } } -/* 注意这里刻意不含 CURLE_OPERATION_TIMEDOUT: +/* 注意这里刻意不含 CURLE_OPERATION_TIMEDOUT: * 请求超时说明服务端很可能已经处理完了, 重发会重复扣费. */ -static int curl_code_retryable(CURLcode rc) -{ +static int curl_code_retryable(CURLcode rc) { switch (rc) { case CURLE_COULDNT_RESOLVE_PROXY: case CURLE_COULDNT_RESOLVE_HOST: - case CURLE_COULDNT_CONNECT: /* 连接根本没建立, 肯定没被处理 */ + case CURLE_COULDNT_CONNECT: /* 连接根本没建立, 肯定没被处理 */ case CURLE_SEND_ERROR: case CURLE_RECV_ERROR: case CURLE_GOT_NOTHING: @@ -175,8 +182,7 @@ static int curl_code_retryable(CURLcode rc) } /* 可被 Ctrl-C 打断的睡眠 */ -static void sleep_ms_interruptible(long ms) -{ +static void sleep_ms_interruptible(long ms) { while (ms > 0 && !g_ligent_interrupt) { long step = ms > 100 ? 100 : ms; struct timespec ts; @@ -188,14 +194,14 @@ static void sleep_ms_interruptible(long ms) } /* 第 attempt 次失败后的退避时长 (attempt 从 1 开始) */ -static long backoff_ms(const HttpRetry *r, int attempt, long retry_after_s) -{ +static long backoff_ms(const HttpRetry *r, int attempt, long retry_after_s) { if (retry_after_s > 0) { long ms = retry_after_s * 1000; return ms > r->max_delay_ms ? r->max_delay_ms : ms; } long ms = r->base_delay_ms; - for (int i = 1; i < attempt && ms < r->max_delay_ms; i++) ms *= 2; + for (int i = 1; i < attempt && ms < r->max_delay_ms; i++) + ms *= 2; /* 加一点抖动, 避免多个客户端同时重试打雷 */ ms += ms / 4 > 0 ? (long)(rand() % (1 + ms / 4)) : 0; return ms > r->max_delay_ms ? r->max_delay_ms : ms; @@ -206,22 +212,21 @@ static long backoff_ms(const HttpRetry *r, int attempt, long retry_after_s) /* ------------------------------------------------------------------ */ typedef struct { - StrBuf body; /* 非流式成功响应体 */ - StrBuf errbody; /* 错误响应体 */ - HttpSink sink; /* 流式回调; NULL 表示非流式 */ - void *ud; - long status; - long retry_after; /* 服务端给的 Retry-After (秒) */ - size_t delivered; /* 已交给 sink 的字节数 */ - int overflow; /* 响应体超过上限 */ - curl_off_t content_length; /* 服务端声明的长度, <=0 表示未知 */ - char *location; /* 3xx 重定向的目标 */ - int log_hdr_started; /* 响应头只写一次分隔标记 */ - int probe; /* 1 = GET 探测, 不发请求体 */ + StrBuf body; /* 非流式成功响应体 */ + StrBuf errbody; /* 错误响应体 */ + HttpSink sink; /* 流式回调; NULL 表示非流式 */ + void *ud; + long status; + long retry_after; /* 服务端给的 Retry-After (秒) */ + size_t delivered; /* 已交给 sink 的字节数 */ + int overflow; /* 响应体超过上限 */ + curl_off_t content_length; /* 服务端声明的长度, <=0 表示未知 */ + char *location; /* 3xx 重定向的目标 */ + int log_hdr_started; /* 响应头只写一次分隔标记 */ + int probe; /* 1 = GET 探测, 不发请求体 */ } HttpCtx; -static void ctx_init(HttpCtx *c, HttpSink sink, void *ud) -{ +static void ctx_init(HttpCtx *c, HttpSink sink, void *ud) { memset(c, 0, sizeof *c); sb_init(&c->body); sb_init(&c->errbody); @@ -229,20 +234,18 @@ static void ctx_init(HttpCtx *c, HttpSink sink, void *ud) c->ud = ud; } -static void ctx_free(HttpCtx *c) -{ +static void ctx_free(HttpCtx *c) { sb_free(&c->body); sb_free(&c->errbody); free(c->location); c->location = NULL; } -/* 必须按 curl 的契约返回 size_t —— 返回值不等于 n 会被当成传输失败. - * 以前这里写的 int: (int)n 在 x86-64 上碰巧是对的 (写 eax 会清零 rax 高位), - * 所以 gcc 不吭声、测试全绿, 只有 clang 借 libcurl 的类型检查把它指了出来. +/* 必须按 curl 的契约返回 size_t —— 返回值不等于 n 会被当成传输失败. + * 以前这里写的 int: (int)n 在 x86-64 上碰巧是对的 (写 eax 会清零 rax 高位), + * 所以 gcc 不吭声、测试全绿, 只有 clang 借 libcurl 的类型检查把它指了出来. * 换个 ABI, 高 32 位的垃圾值就会被当成「处理了多少字节」. */ -static size_t header_cb(char *buffer, size_t size, size_t nitems, void *ud) -{ +static size_t header_cb(char *buffer, size_t size, size_t nitems, void *ud) { HttpCtx *c = ud; size_t n = size * nitems; if (!c->log_hdr_started) { @@ -252,8 +255,9 @@ static size_t header_cb(char *buffer, size_t size, size_t nitems, void *ud) log_append(buffer, n); if (n >= 5 && strncmp(buffer, "HTTP/", 5) == 0) { const char *sp = memchr(buffer, ' ', n); - if (sp) c->status = strtol(sp + 1, NULL, 10); - c->retry_after = 0; /* 新响应开始, 重置 */ + if (sp) + c->status = strtol(sp + 1, NULL, 10); + c->retry_after = 0; /* 新响应开始, 重置 */ } else if (n > 12 && strncasecmp(buffer, "Retry-After:", 12) == 0) { c->retry_after = strtol(buffer + 12, NULL, 10); } else if (n > 15 && strncasecmp(buffer, "Content-Length:", 15) == 0) { @@ -261,22 +265,26 @@ static size_t header_cb(char *buffer, size_t size, size_t nitems, void *ud) } else if (n > 9 && strncasecmp(buffer, "Location:", 9) == 0) { const char *v = buffer + 9; size_t vl = n - 9; - while (vl && (*v == ' ' || *v == '\t')) { v++; vl--; } - while (vl && (v[vl - 1] == '\r' || v[vl - 1] == '\n')) vl--; + while (vl && (*v == ' ' || *v == '\t')) { + v++; + vl--; + } + while (vl && (v[vl - 1] == '\r' || v[vl - 1] == '\n')) + vl--; free(c->location); c->location = xstrndup(v, vl); } return n; } -static size_t write_cb(char *ptr, size_t size, size_t nmemb, void *ud) -{ +static size_t write_cb(char *ptr, size_t size, size_t nmemb, void *ud) { HttpCtx *c = ud; size_t n = size * nmemb; - log_append(ptr, n); /* 原样记下, 便于之后回放 */ - if (g_ligent_interrupt) return 0; + log_append(ptr, n); /* 原样记下, 便于之后回放 */ + if (g_ligent_interrupt) + return 0; - /* 一眼就能看出太大时, 连读都不读, 直接中止. + /* 一眼就能看出太大时, 连读都不读, 直接中止. * 这是流式路径唯一的整体大小防线 —— 流式没有"最终 body"可以事后检查. */ if (c->content_length > (curl_off_t)HTTP_MAX_BODY) { c->overflow = 1; @@ -294,7 +302,8 @@ static size_t write_cb(char *ptr, size_t size, size_t nmemb, void *ud) if (c->sink) { size_t r = c->sink(ptr, size, nmemb, c->ud); - if (r == n) c->delivered += n; + if (r == n) + c->delivered += n; return r; } @@ -308,14 +317,13 @@ static size_t write_cb(char *ptr, size_t size, size_t nmemb, void *ud) } void http_write_redacted(FILE *out, const char *data, size_t size, - const char *secret) -{ + const char *secret) { size_t slen = secret ? strlen(secret) : 0; - if (slen == 0) { /* 没有密钥就没什么可隐去的 */ + if (slen == 0) { /* 没有密钥就没什么可隐去的 */ fwrite(data, 1, size, out); return; } - for (size_t i = 0; i < size; ) { + for (size_t i = 0; i < size;) { if (i + slen <= size && memcmp(data + i, secret, slen) == 0) { fputs("<已隐藏>", out); i += slen; @@ -326,41 +334,54 @@ void http_write_redacted(FILE *out, const char *data, size_t size, } } -/* 自己接管 libcurl 的调试输出, 只为把密钥隐去. - * 装了 DEBUGFUNCTION 就得自己打印, 所以这里复刻 libcurl 的默认格式. +/* 自己接管 libcurl 的调试输出, 只为把密钥隐去. + * 装了 DEBUGFUNCTION 就得自己打印, 所以这里复刻 libcurl 的默认格式. * * 早期版本只处理 HEADER_OUT 那一段, 结果 HTTP/2 的 * "== Info: [HTTP/2] [1] [authorization: Bearer sk-...]" * 仍然把完整密钥打了出来. 现在按密钥值做替换, 所有通道一起覆盖. */ -static int debug_cb(CURL *handle, curl_infotype type, - char *data, size_t size, void *ud) -{ +static int debug_cb(CURL *handle, curl_infotype type, char *data, size_t size, + void *ud) { (void)handle; const char *secret = ud; const char *prefix; switch (type) { - case CURLINFO_TEXT: prefix = "== Info: "; break; - case CURLINFO_HEADER_OUT: prefix = "=> Send header: "; break; - case CURLINFO_HEADER_IN: prefix = "<= Recv header: "; break; - case CURLINFO_DATA_OUT: prefix = "=> Send data: "; break; - case CURLINFO_DATA_IN: prefix = "<= Recv data: "; break; - default: return 0; /* SSL 原始数据不打 */ + case CURLINFO_TEXT: + prefix = "== Info: "; + break; + case CURLINFO_HEADER_OUT: + prefix = "=> Send header: "; + break; + case CURLINFO_HEADER_IN: + prefix = "<= Recv header: "; + break; + case CURLINFO_DATA_OUT: + prefix = "=> Send data: "; + break; + case CURLINFO_DATA_IN: + prefix = "<= Recv data: "; + break; + default: + return 0; /* SSL 原始数据不打 */ } fputs(prefix, stderr); http_write_redacted(stderr, data, size, secret); - if (size && data[size - 1] != '\n') fputc('\n', stderr); + if (size && data[size - 1] != '\n') + fputc('\n', stderr); return 0; } -static int xferinfo_cb(void *ud, curl_off_t a, curl_off_t b, - curl_off_t c, curl_off_t d) -{ - (void)ud; (void)a; (void)b; (void)c; (void)d; +static int xferinfo_cb(void *ud, curl_off_t a, curl_off_t b, curl_off_t c, + curl_off_t d) { + (void)ud; + (void)a; + (void)b; + (void)c; + (void)d; return g_ligent_interrupt ? 1 : 0; } -static struct curl_slist *make_headers(const char *api_key) -{ +static struct curl_slist *make_headers(const char *api_key) { struct curl_slist *h = NULL; h = curl_slist_append(h, "Content-Type: application/json"); h = curl_slist_append(h, "Accept: text/event-stream, application/json"); @@ -378,18 +399,19 @@ static struct curl_slist *make_headers(const char *api_key) /* 返回 CURLcode; ctx 中带回 status / body / errbody / delivered */ static CURLcode attempt(const char *url, const char *api_key, const char *body, - long timeout_s, int verbose, HttpCtx *ctx, - char *cerr, size_t cerrlen) -{ + long timeout_s, int verbose, HttpCtx *ctx, char *cerr, + size_t cerrlen) { /* 必须**在最前面**清空. 下面 curl_easy_init 失败会直接 return, 而调用方 * 随后会拿 cerr 去判断 `*cerr ? cerr : curl_easy_strerror(rc)` —— 原先 * 这行在两百行之后、日志区段下面, 于是那条提前返回的路径会把一个未初始化 * 的栈数组当字符串读. (gcc -fanalyzer 抓到的, 测试跑不出来: curl_easy_init * 几乎不会失败. ) */ - if (cerr && cerrlen) cerr[0] = '\0'; + if (cerr && cerrlen) + cerr[0] = '\0'; CURL *curl = curl_easy_init(); - if (!curl) return CURLE_FAILED_INIT; + if (!curl) + return CURLE_FAILED_INIT; struct curl_slist *headers = make_headers(api_key); @@ -401,18 +423,18 @@ static CURLcode attempt(const char *url, const char *api_key, const char *body, if (ctx->probe) { curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L); } else { - /* body 为 NULL 时按空请求体处理 —— curl 的 POSTFIELDS 不收 NULL 指针. + /* body 为 NULL 时按空请求体处理 —— curl 的 POSTFIELDS 不收 NULL 指针. * 目前的调用方都不会这么调, 但函数签名允许, 而崩溃点离得只有一行. */ curl_easy_setopt(curl, CURLOPT_POST, 1L); curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body ? body : ""); curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE_LARGE, (curl_off_t)blen); } curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); - /* 刻意不跟随重定向: - * 1) libcurl 跨源重定向时会主动丢掉 Authorization 头 (安全设计), - * 结果是 401 "Missing Authentication header" —— 错误指向完全错的方向, - * 用户会去查自己的 key, 而真正的问题是 URL 跳转了; - * 2) 跟随 POST 重定向意味着把对话内容转发到另一个主机. + /* 刻意不跟随重定向: + * 1) libcurl 跨源重定向时会主动丢掉 Authorization 头 (安全设计), + * 结果是 401 "Missing Authentication header" —— 错误指向完全错的方向, + * 用户会去查自己的 key, 而真正的问题是 URL 跳转了; + * 2) 跟随 POST 重定向意味着把对话内容转发到另一个主机. * 收到 3xx 时直接把目标地址报给用户, 让他自己改 base_url. */ curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L); curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, ""); @@ -432,7 +454,8 @@ static CURLcode attempt(const char *url, const char *api_key, const char *body, log_stamp(); log_printf("请求: %s %s\n", ctx->probe ? "GET" : "POST", url); log_printf("----- 请求体 (%zu 字节) -----\n", blen); - if (blen) log_append(body, blen); + if (blen) + log_append(body, blen); log_line("\n"); curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, cerr); @@ -459,32 +482,37 @@ static CURLcode attempt(const char *url, const char *api_key, const char *body, return rc; } -int http_probe(const char *url, const char *api_key, long timeout_s, int verbose, - long *out_status, char **out_location, - char *errbuf, size_t errlen) -{ - if (!errbuf || !errlen) { errbuf = errsink; errlen = 1; } +int http_probe(const char *url, const char *api_key, long timeout_s, + int verbose, long *out_status, char **out_location, char *errbuf, + size_t errlen) { + if (!errbuf || !errlen) { + errbuf = errsink; + errlen = 1; + } errbuf[0] = '\0'; - if (out_status) *out_status = 0; - if (out_location) *out_location = NULL; + if (out_status) + *out_status = 0; + if (out_location) + *out_location = NULL; HttpCtx ctx; ctx_init(&ctx, NULL, NULL); ctx.probe = 1; char cerr[CURL_ERROR_SIZE]; - /* 把密钥带上: 探测的目的之一就是验证"这把密钥能不能过", + /* 把密钥带上: 探测的目的之一就是验证"这把密钥能不能过", * 不带密钥去问, 401 只说明端点要认证, 说明不了密钥好不好. */ - CURLcode rc = attempt(url, api_key, NULL, timeout_s, verbose, &ctx, - cerr, sizeof cerr); + CURLcode rc = attempt(url, api_key, NULL, timeout_s, verbose, &ctx, cerr, + sizeof cerr); - if (out_status) *out_status = ctx.status; - if (out_location) *out_location = ctx.location ? xstrdup(ctx.location) : NULL; + if (out_status) + *out_status = ctx.status; + if (out_location) + *out_location = ctx.location ? xstrdup(ctx.location) : NULL; int ret = 0; if (rc != CURLE_OK) { - snprintf(errbuf, errlen, "%s", - *cerr ? cerr : curl_easy_strerror(rc)); + snprintf(errbuf, errlen, "%s", *cerr ? cerr : curl_easy_strerror(rc)); ret = -1; } ctx_free(&ctx); @@ -497,9 +525,8 @@ int http_probe(const char *url, const char *api_key, long timeout_s, int verbose /* 返回 0 表示可以重试; 否则把最终错误写进 errbuf 并返回 -1 */ static int should_retry(const HttpRetry *rt, int attempt, CURLcode rc, - const HttpCtx *ctx, const char *cerr, - char *errbuf, size_t errlen) -{ + const HttpCtx *ctx, const char *cerr, char *errbuf, + size_t errlen) { if (g_ligent_interrupt) { snprintf(errbuf, errlen, "%s", t("http.err.interrupted")); return -1; @@ -509,8 +536,8 @@ static int should_retry(const HttpRetry *rt, int attempt, CURLcode rc, if (rc == CURLE_OK && ctx->status >= 300 && ctx->status < 400) { snprintf(errbuf, errlen, "%s", tf("http.err.redirect", "status", i18n_num(ctx->status), - "location", ctx->location ? ctx->location - : t("http.val.no_location"), + "location", + ctx->location ? ctx->location : t("http.val.no_location"), NULL)); return -1; } @@ -523,9 +550,10 @@ static int should_retry(const HttpRetry *rt, int attempt, CURLcode rc, } int retryable = 0; - if (rc != CURLE_OK) retryable = curl_code_retryable(rc); + if (rc != CURLE_OK) + retryable = curl_code_retryable(rc); else if (ctx->status < 200 || ctx->status >= 300) - retryable = http_status_retryable(ctx->status); + retryable = http_status_retryable(ctx->status); if (!retryable) { if (rc != CURLE_OK) { @@ -542,25 +570,29 @@ static int should_retry(const HttpRetry *rt, int attempt, CURLcode rc, if (rc != CURLE_OK) snprintf(errbuf, errlen, "%s", tf("http.err.network_retried", "error", - *cerr ? cerr : curl_easy_strerror(rc), - "n", i18n_num(attempt - 1), NULL)); + *cerr ? cerr : curl_easy_strerror(rc), "n", + i18n_num(attempt - 1), NULL)); else snprintf(errbuf, errlen, "%s", tf("http.err.status_retried", "status", - i18n_num(ctx->status), - "n", i18n_num(attempt - 1), NULL)); + i18n_num(ctx->status), "n", i18n_num(attempt - 1), + NULL)); return -1; } return 0; } -static void normalize_retry(const HttpRetry *in, HttpRetry *out) -{ - if (in) { *out = *in; return; } +static void normalize_retry(const HttpRetry *in, HttpRetry *out) { + if (in) { + *out = *in; + return; + } HttpRetry def = HTTP_RETRY_DEFAULT; *out = def; - if (out->base_delay_ms <= 0) out->base_delay_ms = 500; - if (out->max_delay_ms <= 0) out->max_delay_ms = 8000; + if (out->base_delay_ms <= 0) + out->base_delay_ms = 500; + if (out->max_delay_ms <= 0) + out->max_delay_ms = 8000; } /* ------------------------------------------------------------------ */ @@ -570,16 +602,21 @@ static void normalize_retry(const HttpRetry *in, HttpRetry *out) /* POST 与 GET 只差一个标志位, 重试、退避、错误体处理完全一样 —— * 合成一个实现, 以免两条路各修一遍 bug. */ static int http_request(int is_get, const char *url, const char *api_key, - const char *body, - long timeout_s, int verbose, const HttpRetry *retry, - char **out_body, long *out_status, int *out_attempts, - char *errbuf, size_t errlen) -{ - if (!errbuf || !errlen) { errbuf = errsink; errlen = 1; } + const char *body, long timeout_s, int verbose, + const HttpRetry *retry, char **out_body, + long *out_status, int *out_attempts, char *errbuf, + size_t errlen) { + if (!errbuf || !errlen) { + errbuf = errsink; + errlen = 1; + } errbuf[0] = '\0'; - if (out_body) *out_body = NULL; - if (out_status) *out_status = 0; - if (out_attempts) *out_attempts = 0; + if (out_body) + *out_body = NULL; + if (out_status) + *out_status = 0; + if (out_attempts) + *out_attempts = 0; HttpRetry rt; normalize_retry(retry, &rt); @@ -590,34 +627,40 @@ static int http_request(int is_get, const char *url, const char *api_key, for (;;) { HttpCtx ctx; ctx_init(&ctx, NULL, NULL); - if (is_get) ctx.probe = 1; + if (is_get) + ctx.probe = 1; attempt_no++; - CURLcode rc = attempt(url, api_key, body, timeout_s, verbose, - &ctx, cerr, sizeof cerr); + CURLcode rc = attempt(url, api_key, body, timeout_s, verbose, &ctx, + cerr, sizeof cerr); - if (out_status) *out_status = ctx.status; + if (out_status) + *out_status = ctx.status; if (rc == CURLE_OK && ctx.status >= 200 && ctx.status < 300) { - if (out_body) *out_body = sb_detach(&ctx.body); - if (out_attempts) *out_attempts = attempt_no; + if (out_body) + *out_body = sb_detach(&ctx.body); + if (out_attempts) + *out_attempts = attempt_no; ctx_free(&ctx); return 0; } - if (should_retry(&rt, attempt_no, rc, &ctx, cerr, errbuf, errlen) != 0) { + if (should_retry(&rt, attempt_no, rc, &ctx, cerr, errbuf, errlen) != + 0) { /* 失败时把错误体留给调用方, 便于展示服务端的具体说明 */ - if (out_body) *out_body = sb_detach(&ctx.errbody); - if (out_attempts) *out_attempts = attempt_no; + if (out_body) + *out_body = sb_detach(&ctx.errbody); + if (out_attempts) + *out_attempts = attempt_no; ctx_free(&ctx); return -1; } long delay = backoff_ms(&rt, attempt_no, ctx.retry_after); ctx_free(&ctx); - spinner_notice("%s", - tf("http.retry.notice", "delay", i18n_num(delay), - "n", i18n_num(attempt_no), NULL)); + spinner_notice("%s", tf("http.retry.notice", "delay", i18n_num(delay), + "n", i18n_num(attempt_no), NULL)); sleep_ms_interruptible(delay); } } @@ -625,19 +668,16 @@ static int http_request(int is_get, const char *url, const char *api_key, int http_post(const char *url, const char *api_key, const char *body, long timeout_s, int verbose, const HttpRetry *retry, char **out_body, long *out_status, int *out_attempts, - char *errbuf, size_t errlen) -{ + char *errbuf, size_t errlen) { return http_request(0, url, api_key, body, timeout_s, verbose, retry, out_body, out_status, out_attempts, errbuf, errlen); } -/* GET, 把响应体读回来. 给 /model 拉模型列表用. +/* GET, 把响应体读回来. 给 /model 拉模型列表用. * 语义与 http_post 完全一致, 只是方法是 GET、没有请求体. */ -int http_get(const char *url, const char *api_key, - long timeout_s, int verbose, const HttpRetry *retry, - char **out_body, long *out_status, int *out_attempts, - char *errbuf, size_t errlen) -{ +int http_get(const char *url, const char *api_key, long timeout_s, int verbose, + const HttpRetry *retry, char **out_body, long *out_status, + int *out_attempts, char *errbuf, size_t errlen) { return http_request(1, url, api_key, NULL, timeout_s, verbose, retry, out_body, out_status, out_attempts, errbuf, errlen); } @@ -648,17 +688,22 @@ int http_get(const char *url, const char *api_key, int http_post_stream(const char *url, const char *api_key, const char *body, long timeout_s, int verbose, const HttpRetry *retry, - HttpSink sink, void *userdata, - char **out_errbody, long *out_status, int *out_attempts, - size_t *out_delivered, - char *errbuf, size_t errlen) -{ - if (!errbuf || !errlen) { errbuf = errsink; errlen = 1; } + HttpSink sink, void *userdata, char **out_errbody, + long *out_status, int *out_attempts, size_t *out_delivered, + char *errbuf, size_t errlen) { + if (!errbuf || !errlen) { + errbuf = errsink; + errlen = 1; + } errbuf[0] = '\0'; - if (out_errbody) *out_errbody = NULL; - if (out_status) *out_status = 0; - if (out_attempts) *out_attempts = 0; - if (out_delivered) *out_delivered = 0; + if (out_errbody) + *out_errbody = NULL; + if (out_status) + *out_status = 0; + if (out_attempts) + *out_attempts = 0; + if (out_delivered) + *out_delivered = 0; HttpRetry rt; normalize_retry(retry, &rt); @@ -672,16 +717,20 @@ int http_post_stream(const char *url, const char *api_key, const char *body, ctx_init(&ctx, sink, userdata); attempt_no++; - CURLcode rc = attempt(url, api_key, body, timeout_s, verbose, - &ctx, cerr, sizeof cerr); + CURLcode rc = attempt(url, api_key, body, timeout_s, verbose, &ctx, + cerr, sizeof cerr); - if (out_status) *out_status = ctx.status; + if (out_status) + *out_status = ctx.status; total_delivered += ctx.delivered; - if (out_delivered) *out_delivered = total_delivered; + if (out_delivered) + *out_delivered = total_delivered; if (rc == CURLE_OK && ctx.status >= 200 && ctx.status < 300) { - if (out_errbody) *out_errbody = sb_detach(&ctx.errbody); - if (out_attempts) *out_attempts = attempt_no; + if (out_errbody) + *out_errbody = sb_detach(&ctx.errbody); + if (out_attempts) + *out_attempts = attempt_no; ctx_free(&ctx); return 0; } @@ -690,8 +739,10 @@ int http_post_stream(const char *url, const char *api_key, const char *body, snprintf(errbuf, errlen, "%s", tf("http.err.body_too_big", "limit", i18n_num((long long)HTTP_MAX_BODY), NULL)); - if (out_errbody) *out_errbody = sb_detach(&ctx.errbody); - if (out_attempts) *out_attempts = attempt_no; + if (out_errbody) + *out_errbody = sb_detach(&ctx.errbody); + if (out_attempts) + *out_attempts = attempt_no; ctx_free(&ctx); return -1; } @@ -706,24 +757,28 @@ int http_post_stream(const char *url, const char *api_key, const char *body, snprintf(errbuf, errlen, "%s", tf("http.err.status_partial", "status", i18n_num(ctx.status), NULL)); - if (out_errbody) *out_errbody = sb_detach(&ctx.errbody); - if (out_attempts) *out_attempts = attempt_no; + if (out_errbody) + *out_errbody = sb_detach(&ctx.errbody); + if (out_attempts) + *out_attempts = attempt_no; ctx_free(&ctx); return -1; } - if (should_retry(&rt, attempt_no, rc, &ctx, cerr, errbuf, errlen) != 0) { - if (out_errbody) *out_errbody = sb_detach(&ctx.errbody); - if (out_attempts) *out_attempts = attempt_no; + if (should_retry(&rt, attempt_no, rc, &ctx, cerr, errbuf, errlen) != + 0) { + if (out_errbody) + *out_errbody = sb_detach(&ctx.errbody); + if (out_attempts) + *out_attempts = attempt_no; ctx_free(&ctx); return -1; } long delay = backoff_ms(&rt, attempt_no, ctx.retry_after); ctx_free(&ctx); - spinner_notice("%s", - tf("http.retry.notice", "delay", i18n_num(delay), - "n", i18n_num(attempt_no), NULL)); + spinner_notice("%s", tf("http.retry.notice", "delay", i18n_num(delay), + "n", i18n_num(attempt_no), NULL)); sleep_ms_interruptible(delay); } } diff --git a/src/http.h b/src/http.h index 156f7cf..46333d6 100644 --- a/src/http.h +++ b/src/http.h @@ -2,97 +2,96 @@ #ifndef LIGENT_HTTP_H #define LIGENT_HTTP_H +#include #include #include -#include /* 全局中断标志 (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); +typedef size_t (*HttpSink)(char *data, size_t size, size_t nmemb, + void *userdata); -/* 重试策略. 只重试"服务端明确报错"或"连接根本没建立"的情况: - * 超时 (OPERATION_TIMEDOUT)不重试 —— 服务端可能已经处理完并计费了, +/* 重试策略. 只重试"服务端明确报错"或"连接根本没建立"的情况: + * 超时 (OPERATION_TIMEDOUT)不重试 —— 服务端可能已经处理完并计费了, * 重发会造成重复扣费. */ typedef struct { - int max_retries; /* 额外重试次数; 0 = 只尝试一次 */ - int base_delay_ms; /* 退避基数 */ - int max_delay_ms; /* 单次退避上限 */ + int max_retries; /* 额外重试次数; 0 = 只尝试一次 */ + int base_delay_ms; /* 退避基数 */ + int max_delay_ms; /* 单次退避上限 */ } HttpRetry; -#define HTTP_RETRY_DEFAULT { 2, 500, 8000 } -#define HTTP_RETRY_NONE { 0, 0, 0 } +#define HTTP_RETRY_DEFAULT {2, 500, 8000} +#define HTTP_RETRY_NONE {0, 0, 0} /* 非流式响应体上限. 防止服务端异常时把内存吃干 */ -#define HTTP_MAX_BODY (32u * 1024 * 1024) +#define HTTP_MAX_BODY (32u * 1024 * 1024) /* 错误响应体最多收集这么多, 够诊断就行 */ -#define HTTP_MAX_ERRBODY (256u * 1024) +#define HTTP_MAX_ERRBODY (256u * 1024) -int http_global_init(void); +int http_global_init(void); void http_global_cleanup(void); /* ---- 收发记录 ---- * - * 进程内滚动缓存每次请求的收发原文 (请求体、响应头、响应原文), - * 由 REPL 的 /log 命令导出到文件. 这样不必事先指定文件, - * 出问题时可以"回放式"地把已经发生过的事情捞出来. + * 进程内滚动缓存每次请求的收发原文 (请求体、响应头、响应原文), + * 由 REPL 的 /log 命令导出到文件. 这样不必事先指定文件, + * 出问题时可以"回放式"地把已经发生过的事情捞出来. * - * 只记请求体与响应, 不记请求头, 所以不会写入密钥. + * 只记请求体与响应, 不记请求头, 所以不会写入密钥. * 内存上限 LOG_MAX, 超出后丢弃较早的部分. */ size_t http_log_size(void); -int http_log_truncated(void); +int http_log_truncated(void); /* 追加写入 path; 成功返回 0, 失败返回 -1 并写 errbuf */ -int http_log_save(const char *path, size_t *out_bytes, - char *errbuf, size_t errlen); -void http_log_clear(void); -void http_log_free(void); +int http_log_save(const char *path, size_t *out_bytes, char *errbuf, + size_t errlen); +void http_log_clear(void); +void http_log_free(void); /* 该 HTTP 状态码是否值得重试 (408/409/429/5xx) */ int http_status_retryable(long status); -/* 把 data 里出现的 secret 全部替换成 <已隐藏> 后写入 out. +/* 把 data 里出现的 secret 全部替换成 <已隐藏> 后写入 out. * 用"按密钥值替换"而不是"匹配 Authorization 头", 是因为 libcurl 有多条 * 输出通道都会带出密钥: 除了 HEADER_OUT 那段, HTTP/2 还会在 TEXT 消息里 * 打一行 "[HTTP/2] [1] [authorization: Bearer sk-...]". */ void http_write_redacted(FILE *out, const char *data, size_t size, const char *secret); -/* 非流式 POST. - * 成功 (HTTP 2xx)返回 0; 否则返回 -1 并写 errbuf. - * out_* 均可为 NULL, out_attempts 报告实际尝试次数. +/* 非流式 POST. + * 成功 (HTTP 2xx)返回 0; 否则返回 -1 并写 errbuf. + * out_* 均可为 NULL, out_attempts 报告实际尝试次数. * 响应体超过 HTTP_MAX_BODY 时明确报错, 而不是把内存吃干. */ int http_post(const char *url, const char *api_key, const char *body, long timeout_s, int verbose, const HttpRetry *retry, char **out_body, long *out_status, int *out_attempts, char *errbuf, size_t errlen); -/* GET, 并把响应体读回来 (语义与 http_post 一致, 只是没有请求体、方法是 GET). +/* GET, 并把响应体读回来 (语义与 http_post 一致, 只是没有请求体、方法是 GET). * 给 /model 拉取模型列表用. */ -int http_get(const char *url, const char *api_key, - long timeout_s, int verbose, const HttpRetry *retry, - char **out_body, long *out_status, int *out_attempts, - char *errbuf, size_t errlen); +int http_get(const char *url, const char *api_key, long timeout_s, int verbose, + const HttpRetry *retry, char **out_body, long *out_status, + int *out_attempts, char *errbuf, size_t errlen); /* 探测: 发一个 GET (不跟随重定向、不带请求体), 用于 doctor 检查 - * 连通性、TLS 与是否发生重定向. + * 连通性、TLS 与是否发生重定向. * out_location 为 3xx 时的 Location (malloc, 可为 NULL). */ /* api_key 为 NULL 表示不带认证发出去. doctor 要拿它区分"端点要求认证但你没配 * 密钥"和"密钥被拒绝了" —— 不带密钥去探测却把 401 说成"密钥被拒", 会把用户 * 引到一个根本不存在的问题上. */ -int http_probe(const char *url, const char *api_key, long timeout_s, int verbose, - long *out_status, char **out_location, - char *errbuf, size_t errlen); +int http_probe(const char *url, const char *api_key, long timeout_s, + int verbose, long *out_status, char **out_location, char *errbuf, + size_t errlen); -/* 流式 POST. 2xx 时响应体逐块交给 sink; 非 2xx 时响应体被收集到 out_errbody. +/* 流式 POST. 2xx 时响应体逐块交给 sink; 非 2xx 时响应体被收集到 out_errbody. * 注意: 一旦有数据交给了 sink (见 out_delivered), 就不再重试 —— * 否则会把已经显示过的内容重复输出一遍. */ int http_post_stream(const char *url, const char *api_key, const char *body, long timeout_s, int verbose, const HttpRetry *retry, - HttpSink sink, void *userdata, - char **out_errbody, long *out_status, int *out_attempts, - size_t *out_delivered, + HttpSink sink, void *userdata, char **out_errbody, + long *out_status, int *out_attempts, size_t *out_delivered, char *errbuf, size_t errlen); #endif /* LIGENT_HTTP_H */ diff --git a/src/i18n.c b/src/i18n.c index 4972048..51220f5 100644 --- a/src/i18n.c +++ b/src/i18n.c @@ -1,1278 +1,1186 @@ /* i18n.c - 界面文案表与查找 * - * 表里每条两种语言各一份. 条目按 key 排序 (不需要, 但好查). - * 测试会遍历这张表: 两种语言都不许空, 占位符集合必须一致. + * 表里每条两种语言各一份. 条目按 key 排序 (不需要, 但好查). + * 测试会遍历这张表: 两种语言都不许空, 占位符集合必须一致. */ #include "i18n.h" +#include +#include #include #include -#include #include -#include -#define M(key, en, zh) { key, en, zh } +#define M(key, en, zh) {key, en, zh} static const LigentMsg MSGS[] = { -/* ---- 帮助与命令行错误 ---- */ -M("cli.help", -"ligent - an AI agent in plain C (OpenAI-compatible)\n" -"\n" -"Usage:\n" -" ligent [options] [task...] run the task once, then exit\n" -" ligent [options] start the interactive REPL\n" -" echo \"task\" | ligent read the task from stdin\n" -"\n" -"Options:\n" -" -m, --model NAME model name (default deepseek-flash)\n" -" -u, --base-url URL API base URL (default https://api.deepseek.com/v1)\n" -" -k, --api-key KEY API key (default $DEEPSEEK_API_KEY)\n" -" -c, --config PATH config file to use\n" -" --history FILE input history file (default /ligent.history)\n" -" --no-history do not read or write input history\n" -" -s, --system TEXT override the system prompt\n" -" -t, --temperature N sampling temperature\n" -" --max-tokens N max tokens per reply\n" -" --thinking MODE thinking mode: on / off / auto (default auto = server decides)\n" -" --effort LEVEL reasoning effort: minimal/low/medium/high/xhigh/max/ultra\n" -" --max-steps N max model round trips per task (default: no limit)\n" -" --timeout N per-request timeout, seconds (default 300)\n" -" --retries N retries for transient failures: 429/5xx/connect\n" -" (default 2, 0 = never retry)\n" -" --no-stream turn streaming off\n" -" --no-tools disable tools, plain conversation only\n" -" --no-confirm stop asking before writes and command execution\n" -" --no-auto-save do not auto-archive before exit/reset/import\n" -" --no-probe do not probe model capabilities before the first request\n" -" --lang LANG interface language: auto / en / zh (default auto)\n" -" -q, --quiet do not print tool output\n" -" -v, --verbose print HTTP details and debug info\n" -" --color / --no-color force colors on / off\n" -" --print-config print the effective config, then exit\n" -" -h, --help show this help\n" -" -V, --version show version\n" -"\n" -"Config file (JSON):\n" -" Only ligent.json next to the binary is read, e.g. /usr/local/bin/ligent\n" -" uses /usr/local/bin/ligent.json. Missing means built-in defaults.\n" -" There is no user-level (~/.config) or system-level (/etc) lookup, and the\n" -" current directory is not searched either. Use -c PATH or $LIGENT_CONFIG\n" -" to point at a different file.\n" -"\n" -"Config keys: base_url, api_key, model, system, temperature, max_tokens,\n" -" thinking, reasoning_effort,\n" -" timeout, stream, max_steps, tools, retries,\n" -" confirm, auto_save, probe\n" -"\n" -"REPL commands: /help /exit /reset /cls /meta /model /system /tools /probe\n" -" /think /config /cd /history /log /export /import /shell /usage\n" -" /include\n" -" /back /unback /yolo\n" -"\n" -"Subcommands:\n" -" ligent doctor [--offline] check config, key, endpoint and network in order\n", + /* ---- 帮助与命令行错误 ---- */ + M("cli.help", + "ligent - an AI agent in plain C (OpenAI-compatible)\n" + "\n" + "Usage:\n" + " ligent [options] [task...] run the task once, then exit\n" + " ligent [options] start the interactive REPL\n" + " echo \"task\" | ligent read the task from stdin\n" + "\n" + "Options:\n" + " -m, --model NAME model name (default deepseek-flash)\n" + " -u, --base-url URL API base URL (default " + "https://api.deepseek.com/v1)\n" + " -k, --api-key KEY API key (default $DEEPSEEK_API_KEY)\n" + " -c, --config PATH config file to use\n" + " --history FILE input history file (default " + "/ligent.history)\n" + " --no-history do not read or write input history\n" + " -s, --system TEXT override the system prompt\n" + " -t, --temperature N sampling temperature\n" + " --max-tokens N max tokens per reply\n" + " --thinking MODE thinking mode: on / off / auto (default auto " + "= server decides)\n" + " --effort LEVEL reasoning effort: " + "minimal/low/medium/high/xhigh/max/ultra\n" + " --max-steps N max model round trips per task (default: no " + "limit)\n" + " --timeout N per-request timeout, seconds (default 300)\n" + " --retries N retries for transient failures: " + "429/5xx/connect\n" + " (default 2, 0 = never retry)\n" + " --no-stream turn streaming off\n" + " --no-tools disable tools, plain conversation only\n" + " --no-confirm stop asking before writes and command " + "execution\n" + " --no-auto-save do not auto-archive before exit/reset/import\n" + " --no-probe do not probe model capabilities before the " + "first request\n" + " --lang LANG interface language: auto / en / zh (default " + "auto)\n" + " -q, --quiet do not print tool output\n" + " -v, --verbose print HTTP details and debug info\n" + " --color / --no-color force colors on / off\n" + " --print-config print the effective config, then exit\n" + " -h, --help show this help\n" + " -V, --version show version\n" + "\n" + "Config file (JSON):\n" + " Only ligent.json next to the binary is read, e.g. " + "/usr/local/bin/ligent\n" + " uses /usr/local/bin/ligent.json. Missing means built-in defaults.\n" + " There is no user-level (~/.config) or system-level (/etc) lookup, and " + "the\n" + " current directory is not searched either. Use -c PATH or " + "$LIGENT_CONFIG\n" + " to point at a different file.\n" + "\n" + "Config keys: base_url, api_key, model, system, temperature, " + "max_tokens,\n" + " thinking, reasoning_effort,\n" + " timeout, stream, max_steps, tools, retries,\n" + " confirm, auto_save, probe\n" + "\n" + "REPL commands: /help /exit /reset /cls /meta /model /system /tools " + "/probe\n" + " /think /config /cd /history /log /export /import /shell " + "/usage\n" + " /include\n" + " /back /unback /yolo\n" + "\n" + "Subcommands:\n" + " ligent doctor [--offline] check config, key, endpoint and network " + "in order\n", -"ligent - 纯 C 实现的 AI Agent (OpenAI 兼容接口)\n" -"\n" -"用法: \n" -" ligent [选项] [任务描述...] 带任务描述时一次性执行后退出\n" -" ligent [选项] 进入交互式 REPL\n" -" echo \"任务\" | ligent 从标准输入读取任务\n" -"\n" -"选项: \n" -" -m, --model NAME 模型名 (默认 deepseek-flash)\n" -" -u, --base-url URL API 基地址 (默认 https://api.deepseek.com/v1)\n" -" -k, --api-key KEY API key (默认读 $DEEPSEEK_API_KEY)\n" -" -c, --config PATH 指定配置文件\n" -" --history FILE 指定输入历史文件 (默认 <二进制目录>/ligent.history)\n" -" --no-history 不读写输入历史\n" -" -s, --system TEXT 覆盖 system 提示词\n" -" -t, --temperature N 采样温度\n" -" --max-tokens N 单次回复的最大 token 数\n" -" --thinking MODE 思考模式: on / off / auto (默认 auto = 由服务端决定)\n" -" --effort LEVEL 思考级别: minimal/low/medium/high/xhigh/max/ultra\n" -" --max-steps N 单轮任务里模型最多往返几次 (默认不限, 0 = 不限)\n" -" --timeout N 单次请求超时秒数 (默认 300)\n" -" --retries N 瞬态失败 (429/5xx/连接失败)的重试次数 (默认 2, 0 = 不重试)\n" -" --no-stream 关闭流式输出\n" -" --no-tools 禁用工具, 只做纯对话\n" -" --no-confirm 不再询问写操作与命令执行 (默认会问)\n" -" --no-auto-save 退出/重置/导入前不自动存档 (默认会存)\n" -" --no-probe 首个请求前不探测模型能力 (不额外发请求)\n" -" --lang LANG 界面语言: auto / en / zh (默认 auto)\n" -" -q, --quiet 不打印工具的输出内容\n" -" -v, --verbose 打印 HTTP 细节与调试信息\n" -" --color / --no-color 强制开关彩色输出\n" -" --print-config 打印最终生效的配置后退出\n" -" -h, --help 显示本帮助\n" -" -V, --version 显示版本\n" -"\n" -"配置文件 (JSON): \n" -" 只读「二进制所在目录」下的 ligent.json, 例如 /usr/local/bin/ligent 对应\n" -" /usr/local/bin/ligent.json. 不存在则使用内置默认值. \n" -" 不做用户级 (~/.config)和系统级 (/etc)查找, 也不看当前工作目录. \n" -" 临时换一份可用 -c PATH 或 $LIGENT_CONFIG. \n" -"\n" -"配置项: base_url, api_key, model, system, temperature, max_tokens,\n" -" thinking, reasoning_effort,\n" -" timeout, stream, max_steps, tools, retries,\n" -" confirm, auto_save, probe\n" -"\n" -"REPL 命令: /help /exit /reset /cls /meta /model /system /tools /probe\n" -" /think /config /cd /history /log /export /import /shell /usage\n" -" /include\n" -" /back /unback /yolo\n" -"\n" -"子命令: \n" -" ligent doctor [--offline] 体检: 配置、密钥、端点、网络逐环检查\n"), + "ligent - 纯 C 实现的 AI Agent (OpenAI 兼容接口)\n" + "\n" + "用法: \n" + " ligent [选项] [任务描述...] 带任务描述时一次性执行后退出\n" + " ligent [选项] 进入交互式 REPL\n" + " echo \"任务\" | ligent 从标准输入读取任务\n" + "\n" + "选项: \n" + " -m, --model NAME 模型名 (默认 deepseek-flash)\n" + " -u, --base-url URL API 基地址 (默认 " + "https://api.deepseek.com/v1)\n" + " -k, --api-key KEY API key (默认读 $DEEPSEEK_API_KEY)\n" + " -c, --config PATH 指定配置文件\n" + " --history FILE 指定输入历史文件 (默认 " + "<二进制目录>/ligent.history)\n" + " --no-history 不读写输入历史\n" + " -s, --system TEXT 覆盖 system 提示词\n" + " -t, --temperature N 采样温度\n" + " --max-tokens N 单次回复的最大 token 数\n" + " --thinking MODE 思考模式: on / off / auto (默认 auto = " + "由服务端决定)\n" + " --effort LEVEL 思考级别: " + "minimal/low/medium/high/xhigh/max/ultra\n" + " --max-steps N 单轮任务里模型最多往返几次 (默认不限, 0 = " + "不限)\n" + " --timeout N 单次请求超时秒数 (默认 300)\n" + " --retries N 瞬态失败 (429/5xx/连接失败)的重试次数 (默认 " + "2, 0 = 不重试)\n" + " --no-stream 关闭流式输出\n" + " --no-tools 禁用工具, 只做纯对话\n" + " --no-confirm 不再询问写操作与命令执行 (默认会问)\n" + " --no-auto-save 退出/重置/导入前不自动存档 (默认会存)\n" + " --no-probe 首个请求前不探测模型能力 (不额外发请求)\n" + " --lang LANG 界面语言: auto / en / zh (默认 auto)\n" + " -q, --quiet 不打印工具的输出内容\n" + " -v, --verbose 打印 HTTP 细节与调试信息\n" + " --color / --no-color 强制开关彩色输出\n" + " --print-config 打印最终生效的配置后退出\n" + " -h, --help 显示本帮助\n" + " -V, --version 显示版本\n" + "\n" + "配置文件 (JSON): \n" + " 只读「二进制所在目录」下的 ligent.json, 例如 /usr/local/bin/ligent " + "对应\n" + " /usr/local/bin/ligent.json. 不存在则使用内置默认值. \n" + " 不做用户级 (~/.config)和系统级 (/etc)查找, 也不看当前工作目录. \n" + " 临时换一份可用 -c PATH 或 $LIGENT_CONFIG. \n" + "\n" + "配置项: base_url, api_key, model, system, temperature, max_tokens,\n" + " thinking, reasoning_effort,\n" + " timeout, stream, max_steps, tools, retries,\n" + " confirm, auto_save, probe\n" + "\n" + "REPL 命令: /help /exit /reset /cls /meta /model /system /tools /probe\n" + " /think /config /cd /history /log /export /import /shell " + "/usage\n" + " /include\n" + " /back /unback /yolo\n" + "\n" + "子命令: \n" + " ligent doctor [--offline] 体检: 配置、密钥、端点、网络逐环检查\n"), -M("cli.err.bad_effort", - "ligent: --effort accepts only {list} (got \"{value}\")\n", - "ligent: --effort 只接受 {list} (收到 \"{value}\")\n"), -M("cli.err.bad_lang", - "ligent: --lang accepts only auto / en / zh (got \"{value}\")\n", - "ligent: --lang 只接受 auto / en / zh (收到 \"{value}\")\n"), -M("cli.err.bad_thinking", - "ligent: --thinking accepts only on / off / auto (got \"{value}\")\n", - "ligent: --thinking 只接受 on / off / auto (收到 \"{value}\")\n"), -M("cli.err.config", "ligent: {error}\n", "ligent: {error}\n"), -M("cli.err.curl_init", "ligent: libcurl init failed\n", "ligent: libcurl 初始化失败\n"), -M("cli.err.missing_value", "ligent: option {option} needs a value\n", - "ligent: 选项 {option} 缺少参数\n"), -M("cli.err.no_api_key", - "ligent: no API key configured.\n" - " 1) export DEEPSEEK_API_KEY=sk-xxx\n" - " 2) put a ligent.json next to the binary (see the path in --print-config)\n" - " with something like {\"api_key\": \"${DEEPSEEK_API_KEY}\", \"model\": \"deepseek-flash\"}\n" - " 3) ligent --api-key sk-xxx \"hi\"\n", - "ligent: 未配置 API key. \n" - " 方式一: export DEEPSEEK_API_KEY=sk-xxx\n" - " 方式二: 在二进制所在目录建一个 ligent.json (见 --print-config 显示的路径), \n" - " 内容形如 {\"api_key\": \"${DEEPSEEK_API_KEY}\", \"model\": \"deepseek-flash\"}\n" - " 方式三: ligent --api-key sk-xxx \"你好\"\n"), -M("cli.err.unknown_option", - "ligent: unknown option {option} (try --help)\n", - "ligent: 未知选项 {option} (--help 查看用法)\n"), + M("cli.err.bad_effort", + "ligent: --effort accepts only {list} (got \"{value}\")\n", + "ligent: --effort 只接受 {list} (收到 \"{value}\")\n"), + M("cli.err.bad_lang", + "ligent: --lang accepts only auto / en / zh (got \"{value}\")\n", + "ligent: --lang 只接受 auto / en / zh (收到 \"{value}\")\n"), + M("cli.err.bad_thinking", + "ligent: --thinking accepts only on / off / auto (got \"{value}\")\n", + "ligent: --thinking 只接受 on / off / auto (收到 \"{value}\")\n"), + M("cli.err.config", "ligent: {error}\n", "ligent: {error}\n"), + M("cli.err.curl_init", "ligent: libcurl init failed\n", + "ligent: libcurl 初始化失败\n"), + M("cli.err.missing_value", "ligent: option {option} needs a value\n", + "ligent: 选项 {option} 缺少参数\n"), + M("cli.err.no_api_key", + "ligent: no API key configured.\n" + " 1) export DEEPSEEK_API_KEY=sk-xxx\n" + " 2) put a ligent.json next to the binary (see the path in " + "--print-config)\n" + " with something like {\"api_key\": \"${DEEPSEEK_API_KEY}\", " + "\"model\": \"deepseek-flash\"}\n" + " 3) ligent --api-key sk-xxx \"hi\"\n", + "ligent: 未配置 API key. \n" + " 方式一: export DEEPSEEK_API_KEY=sk-xxx\n" + " 方式二: 在二进制所在目录建一个 ligent.json (见 --print-config " + "显示的路径), \n" + " 内容形如 {\"api_key\": \"${DEEPSEEK_API_KEY}\", \"model\": " + "\"deepseek-flash\"}\n" + " 方式三: ligent --api-key sk-xxx \"你好\"\n"), + M("cli.err.unknown_option", + "ligent: unknown option {option} (try --help)\n", + "ligent: 未知选项 {option} (--help 查看用法)\n"), -/* ---- 配置展示 (--print-config)---- */ -M("cfg.dump.config_file_missing", "(not found, using built-in defaults)", - "(不存在, 使用内置默认值)"), -M("cfg.dump.config_file_none", "(no config file in use)", "(未使用配置文件)"), -M("cfg.dump.label.effort", "effort", "思考级别"), -M("cfg.dump.label.log", "wire log", "收发记录"), -M("cfg.val.auto_save_on", "on (auto-archive before exit/reset/import)", - "启用 (退出/重置/导入前自动存档)"), -M("cfg.val.builtin_system", "(built-in default prompt)", "(内置默认提示词)"), -M("cfg.val.chars", "({n} chars)", " ({n} 字符)"), -M("cfg.val.confirm_on", "on (ask before writes and commands)", - "启用 (写操作与命令执行前询问)"), -M("cfg.val.disabled", "disabled", "禁用"), -M("cfg.val.empty_key", "****", "****"), -M("cfg.val.enabled", "enabled", "启用"), -M("cfg.val.log", "{n} bytes in memory (export with /log FILE in the REPL)", - "内存缓存 {n} 字节 (REPL 里 /log FILE 导出)"), -M("cfg.val.no", "no", "否"), -M("cfg.val.probe_on", "on (probe vision/tools support before the first request)", - "启用 (首个请求前探测模型是否支持图片/工具)"), -M("cfg.val.seconds", "{n}s", "{n} 秒"), -M("cfg.val.server_default", "(server default)", "(服务端默认)"), -M("cfg.val.times", "{n}", "{n} 次"), -M("cfg.val.unlimited", "no limit", "不限"), -M("cfg.val.unknown", "unknown", "未知"), -M("cfg.val.unset", "(not set)", "(未设置)"), -M("cfg.val.yes", "yes", "是"), -M("cfg.src.cli", "command line --api-key", "命令行 --api-key"), -M("cfg.src.env", "environment variable {name}", "环境变量 {name}"), -M("cfg.src.file", "config file", "配置文件"), -M("cfg.thinking.auto", "not sent (server decides)", "不发送 (由服务端决定)"), -M("cfg.thinking.off", "off (sends thinking.type=disabled)", - "关闭 (发 thinking.type=disabled)"), -M("cfg.thinking.on", "on (sends thinking.type=enabled)", - "开启 (发 thinking.type=enabled)"), -M("cfg.err.bad_json", "{path}: {error}", "{path}: {error}"), -M("cfg.err.bad_effort", - "{path}: reasoning_effort accepts only {list} (or leave it out)", - "{path}: reasoning_effort 只能是 {list} (或留空不发)"), -M("cfg.err.not_object", "{path}: the top level must be a JSON object ({...})", - "{path}: 顶层必须是 JSON 对象 ({...})"), -M("cfg.err.parse_ignored", - "ligent: config file could not be parsed; using built-in defaults instead\n" - " {error}", - "ligent: 配置文件解析失败, 已忽略改用内置默认值\n" - " {error}"), -M("cfg.err.read_failed", "cannot read config file {path}", - "无法读取配置文件 {path}"), -M("cfg.err.thinking_type", - "{path}: thinking accepts only true/false or \"on\"/\"off\"/\"auto\"", - "{path}: thinking 只能是 true/false, 或 \"on\"/\"off\"/\"auto\""), + /* ---- 配置展示 (--print-config)---- */ + M("cfg.dump.config_file_missing", "(not found, using built-in defaults)", + "(不存在, 使用内置默认值)"), + M("cfg.dump.config_file_none", "(no config file in use)", + "(未使用配置文件)"), + M("cfg.dump.label.effort", "effort", "思考级别"), + M("cfg.dump.label.log", "wire log", "收发记录"), + M("cfg.val.auto_save_on", "on (auto-archive before exit/reset/import)", + "启用 (退出/重置/导入前自动存档)"), + M("cfg.val.builtin_system", "(built-in default prompt)", + "(内置默认提示词)"), + M("cfg.val.chars", "({n} chars)", " ({n} 字符)"), + M("cfg.val.confirm_on", "on (ask before writes and commands)", + "启用 (写操作与命令执行前询问)"), + M("cfg.val.disabled", "disabled", "禁用"), + M("cfg.val.empty_key", "****", "****"), + M("cfg.val.enabled", "enabled", "启用"), + M("cfg.val.log", "{n} bytes in memory (export with /log FILE in the REPL)", + "内存缓存 {n} 字节 (REPL 里 /log FILE 导出)"), + M("cfg.val.no", "no", "否"), + M("cfg.val.probe_on", + "on (probe vision/tools support before the first request)", + "启用 (首个请求前探测模型是否支持图片/工具)"), + M("cfg.val.seconds", "{n}s", "{n} 秒"), + M("cfg.val.server_default", "(server default)", "(服务端默认)"), + M("cfg.val.times", "{n}", "{n} 次"), + M("cfg.val.unlimited", "no limit", "不限"), + M("cfg.val.unknown", "unknown", "未知"), + M("cfg.val.unset", "(not set)", "(未设置)"), + M("cfg.val.yes", "yes", "是"), + M("cfg.src.cli", "command line --api-key", "命令行 --api-key"), + M("cfg.src.env", "environment variable {name}", "环境变量 {name}"), + M("cfg.src.file", "config file", "配置文件"), + M("cfg.thinking.auto", "not sent (server decides)", + "不发送 (由服务端决定)"), + M("cfg.thinking.off", "off (sends thinking.type=disabled)", + "关闭 (发 thinking.type=disabled)"), + M("cfg.thinking.on", "on (sends thinking.type=enabled)", + "开启 (发 thinking.type=enabled)"), + M("cfg.err.bad_json", "{path}: {error}", "{path}: {error}"), + M("cfg.err.bad_effort", + "{path}: reasoning_effort accepts only {list} (or leave it out)", + "{path}: reasoning_effort 只能是 {list} (或留空不发)"), + M("cfg.err.not_object", + "{path}: the top level must be a JSON object ({...})", + "{path}: 顶层必须是 JSON 对象 ({...})"), + M("cfg.err.parse_ignored", + "ligent: config file could not be parsed; using built-in defaults " + "instead\n" + " {error}", + "ligent: 配置文件解析失败, 已忽略改用内置默认值\n" + " {error}"), + M("cfg.err.read_failed", "cannot read config file {path}", + "无法读取配置文件 {path}"), + M("cfg.err.thinking_type", + "{path}: thinking accepts only true/false or \"on\"/\"off\"/\"auto\"", + "{path}: thinking 只能是 true/false, 或 \"on\"/\"off\"/\"auto\""), -/* ---- 写操作确认 ---- */ -M("confirm.always", "no more prompts for the rest of this turn.", - "本轮后续的改动都不再询问. "), -M("confirm.bad_input", - "Enter Y / N / A (allow all this turn) / V (deny all this turn); Enter = Y", - "请输入 Y / N / A (本轮全允许) / V (本轮全拒绝), 回车 = Y"), -M("confirm.lbl.about", "about to run", "即将执行"), -M("confirm.lbl.cmd", "command:", "命令:"), -M("confirm.lbl.edit", "edit:", "修改:"), -M("confirm.lbl.path", "path:", "路径:"), -M("confirm.lbl.write", "write:", "写入:"), -M("confirm.never", "everything else in this turn is denied.", - "本轮后续的改动一律拒绝. "), -M("confirm.note.overwrite", "({bytes} bytes, overwrites the whole file)", - " ({bytes} 字节, 将整体覆盖)"), -M("confirm.prompt", " Continue? (Y/N/Always/neVer) ", - " 继续?(Y/N/Always/neVer) "), -M("confirm.val.empty", "(empty)", "(空)"), + /* ---- 写操作确认 ---- */ + M("confirm.always", "no more prompts for the rest of this turn.", + "本轮后续的改动都不再询问. "), + M("confirm.bad_input", + "Enter Y / N / A (allow all this turn) / V (deny all this turn); Enter = " + "Y", + "请输入 Y / N / A (本轮全允许) / V (本轮全拒绝), 回车 = Y"), + M("confirm.lbl.about", "about to run", "即将执行"), + M("confirm.lbl.cmd", "command:", "命令:"), + M("confirm.lbl.edit", "edit:", "修改:"), + M("confirm.lbl.path", "path:", "路径:"), + M("confirm.lbl.write", "write:", "写入:"), + M("confirm.never", "everything else in this turn is denied.", + "本轮后续的改动一律拒绝. "), + M("confirm.note.overwrite", "({bytes} bytes, overwrites the whole file)", + " ({bytes} 字节, 将整体覆盖)"), + M("confirm.prompt", " Continue? (Y/N/Always/neVer) ", + " 继续?(Y/N/Always/neVer) "), + M("confirm.val.empty", "(empty)", "(空)"), -/* ---- doctor: 体检 ---- */ -/* 分节标题的括号: 中文用【】, 英文用 [] */ -M("fmt.section", "[{name}]", "【{name}】"), -M("doctor.conclusion.bad", "Bottom line: {problems} problem(s), {warnings} warning(s).", - "结论: {problems} 个问题, {warnings} 处提醒. "), -M("doctor.conclusion.next", "start with the items marked [x].", - "先处理标 [x] 的项. "), -M("doctor.conclusion.ok", "Bottom line: no problems found.", - "结论: 没发现问题. "), -M("doctor.env.note.all_proxy", "proxy for all protocols", "通用代理"), -M("doctor.env.note.api_key", "explicit override, highest priority", - "显式覆盖, 优先级最高"), -M("doctor.env.note.base_url", "overrides base_url", "覆盖 base_url"), -M("doctor.env.note.config", "path to the config file", "指定配置文件路径"), -M("doctor.env.note.deepseek", "used only when the endpoint really is DeepSeek", - "仅在端点确实是 DeepSeek 时生效"), -M("doctor.env.note.http_proxy", "HTTP proxy", "HTTP 代理"), -M("doctor.env.note.https_proxy", "HTTPS proxy", "HTTPS 代理"), -M("doctor.env.note.lang", "overrides the interface language", "覆盖界面语言"), -M("doctor.env.note.model", "overrides the model", "覆盖模型"), -M("doctor.env.note.no_proxy", "proxy exceptions", "代理例外"), -M("doctor.env.note.openai", "used only when the endpoint really is OpenAI", - "仅在端点确实是 OpenAI 时生效"), -M("doctor.lbl.argv0", "argv[0]", "argv[0]"), -M("doctor.lbl.canon_path", "canonical path", "规范路径"), -M("doctor.lbl.model", "model", "模型"), -M("doctor.lbl.note", "note", "注意"), -M("doctor.lbl.url", "request URL", "实际请求地址"), -M("doctor.val.not_configured", "not configured", "未配置"), -M("doctor.lbl.auth", "auth", "认证"), -M("doctor.lbl.config_dir", "config dir", "配置目录"), -M("doctor.lbl.connectivity", "connectivity", "连通性"), -M("doctor.lbl.endpoint", "endpoint", "端点"), -M("doctor.lbl.exe", "real executable", "真实可执行文件"), -M("doctor.lbl.format_guess", "prefix looks like", "格式推断"), -M("doctor.lbl.how_to_set", "how to set", "怎么配"), -M("doctor.lbl.http_status", "HTTP status", "HTTP 状态"), -M("doctor.lbl.log", "wire log", "收发记录"), -M("doctor.lbl.match", "matches endpoint", "与端点匹配"), -M("doctor.lbl.proxy", "proxy", "代理"), -M("doctor.lbl.redirect", "redirect", "重定向"), -M("doctor.lbl.set_keys", "keys set", "已设置的项"), -M("doctor.lbl.source", "source", "来源"), -M("doctor.lbl.status", "status", "状态"), -M("doctor.lbl.symlink", "symlink", "软链接"), -M("doctor.lbl.tip", "tip", "提示"), -M("doctor.lbl.transport", "transport", "传输安全"), -M("doctor.lbl.trailing_slash", "trailing slash", "结尾斜杠"), -M("doctor.lbl.version", "version", "版本"), -M("doctor.lbl.effort", "effort", "思考级别"), -M("doctor.lbl.value", "value", "值"), -M("doctor.sec.binary", "binary & config location", "二进制与配置位置"), -M("doctor.sec.config", "config file", "配置文件"), -M("doctor.sec.endpoint", "endpoint", "端点"), -M("doctor.sec.env", "environment", "环境变量"), -M("doctor.sec.key", "API key", "API key"), -M("doctor.sec.net", "network", "网络"), -M("doctor.sec.net_skipped", "[network] skipped (--offline)", "【网络】已跳过 (--offline)"), -M("doctor.sec.params", "parameters", "参数"), -M("doctor.sub.404_base_url", - "usually a wrong base_url (a missing /v1, say); check it the way the redirect section describes", - "多半是 base_url 写错了 (比如少了 /v1); 先照下面「重定向」那节的方法核对"), -M("doctor.sub.404_no_key_info", - "this probe says nothing about the key: the request was refused before auth", - "这次探测说明不了密钥好不好: 请求没走到认证就被回绝了"), -M("doctor.sub.all_ignored", - "the whole file was ignored; this run uses built-in defaults", - "整个文件被忽略, 本次运行全部使用内置默认值. "), -M("doctor.sub.auth_not_network", - "this is not a network problem: it connects, there is just no key", - "这不是网络问题: 连得上, 只是没带钥匙"), -M("doctor.sub.auth_redirect", - "also check whether base_url was redirected: see the redirect section below", - "也确认一下 base_url 是不是被跳转过: 详见下面「重定向」"), -M("doctor.sub.max_tokens_huge", - " far above the usual output cap (mostly <= 65536); may be rejected or truncated", - " 远超常见模型的输出上限 (多数 <= 65536), 可能被拒或被截断"), -M("doctor.sub.redirect_1", - "ligent does not follow redirects: a cross-origin hop drops the Authorization header,", - "ligent 不自动跟随重定向: 跨源跳转会丢掉 Authorization 头, "), -M("doctor.sub.redirect_2", - "the server then replies 401 \"Missing Authentication header\", which looks like a missing key,", - "服务端只会回 401 \"Missing Authentication header\", 看起来像是密钥没带, "), -M("doctor.sub.redirect_3", - "when in fact the URL moved. Point base_url straight at the address above.", - "实际是 URL 跳了. 把 base_url 直接改成上面那个地址即可. "), -M("doctor.sub.temp_too_high", "above 2.0, most providers reject this", - "超过 2.0, 多数服务商会拒绝"), -M("doctor.sub.thinking_temp", - "temperature has no effect in thinking mode; the server ignores it", - "思考模式下 temperature 不起作用, 服务端会忽略它"), -M("doctor.title", "ligent doctor — walking the whole chain link by link", - "ligent doctor —— 把整条链路逐环检查一遍"), -M("doctor.val.auth_missing", - "HTTP {status} — the endpoint requires auth but no API key is configured", - "HTTP {status} —— 端点要求认证, 但当前没有配置 API key"), -M("doctor.val.auth_ok", "the endpoint accepted this key", "端点接受了这把密钥"), -M("doctor.val.auth_rejected", - "HTTP {status} — the server rejected this key; check its format and source " - "against the API key section above", - "HTTP {status} —— 服务端拒绝了这把密钥, 先用上面「API key」一节核对格式与来源"), -M("doctor.val.bad_scheme", "URL must start with http:// or https://", - "URL 必须以 http:// 或 https:// 开头"), -M("doctor.val.base_url_unset", "(not set)", "(未设置)"), -M("doctor.val.chars", "({n} chars)", " ({n} 字符)"), -M("doctor.val.connect_failed", "failed: {error}", "失败: {error}"), -M("doctor.val.connect_ok", "reachable, TLS verified (for HTTPS)", - "可连接, TLS 校验通过 (HTTPS 时)"), -M("doctor.val.empty", "(empty)", "(空)"), -M("doctor.val.endpoint_404", "HTTP 404 — no such address: {url}", - "HTTP 404 —— 这个地址不存在: {url}"), -M("doctor.val.env_ignored", - "{var} is set but unused this run (the key came from {source}). " - "If that is not what you want, unset it or use LIGENT_API_KEY", - "{var} 已设置, 但本次没用上 (密钥来自「{source}」). " - "如果这不是你想要的, unset 它或改用 LIGENT_API_KEY"), -M("doctor.val.env_set", "set: {value}", "已设置: {value}"), -M("doctor.val.env_unset", "not set", "未设置"), -M("doctor.val.exists_unloadable", - "present but not loaded (the top level must be a JSON object)", - "存在但加载失败 (顶层必须是 JSON 对象)"), -M("doctor.val.exists_unparsable", "present but unparsable: {error}", - "存在但无法解析: {error}"), -M("doctor.val.how_to_set_key", - "export DEEPSEEK_API_KEY=... or -k KEY or api_key in the config file", - "export DEEPSEEK_API_KEY=... 或 -k KEY 或配置文件 api_key"), -M("doctor.val.https", "HTTPS", "HTTPS"), -M("doctor.val.load_failed", "load failed: {error}", "加载失败: {error}"), -M("doctor.val.location_missing", "(the server sent no Location)", - "(服务端未提供 Location)"), -M("doctor.val.loaded", "loaded", "已加载"), -M("doctor.val.log", "{n} bytes in memory (export with /log FILE in the REPL)", - "内存缓存 {n} 字节 (REPL 里 /log FILE 导出)"), -M("doctor.val.match_bad", - "endpoint is {owner} but the key prefix looks like {guess}. " - "Keys are not interchangeable, so the server will only return a vague 401", - "端点属于 {owner}, 但密钥前缀像是 {guess} 的. " - "各家密钥不通用, 服务端只会回一个含糊的 401"), -M("doctor.val.match_ok", "endpoint is {owner} and the key format matches", - "端点属于 {owner}, 密钥格式吻合"), -M("doctor.val.match_skipped", "endpoint is not a known provider, skipping", - "端点不是已知服务商, 跳过检查"), -M("doctor.val.match_unknown", "endpoint is {owner} but the prefix is unrecognized", - "端点属于 {owner}, 但密钥前缀认不出来"), -M("doctor.val.missing", "not found, using built-in defaults", - "不存在, 使用内置默认值"), -M("doctor.val.net_tip", "check the network, the proxy settings and that base_url is right", - "检查网络、代理设置, 以及 base_url 是否写对"), -M("doctor.val.no_auth", "the endpoint requires no auth", "端点不要求认证"), -M("doctor.val.no_redirect", "none (the endpoint did not redirect)", - "无 (端点没有跳转)"), -M("doctor.val.none", "none", "无"), -M("doctor.val.none_set", "(none)", "(无)"), -M("doctor.val.owner.sk", "OpenAI or DeepSeek", "OpenAI 或 DeepSeek"), -M("doctor.val.plain_local", "plain HTTP, but the target is localhost", - "明文 HTTP, 但目标是本机"), -M("doctor.val.plain_remote", "plain HTTP, the key will travel in the clear", - "明文 HTTP, 密钥会以明文上网"), -M("doctor.val.prefix_unclear", "(unrecognized)", "(认不出前缀)"), -M("doctor.val.proxy_unset", "(not set)", "(未设置)"), -M("doctor.val.redirect_to", "endpoint redirects to {location}", - "端点跳转到 {location}"), -M("doctor.val.retries", "{n}", "{n} 次"), -M("doctor.val.steps", "{n} round trips per turn", "每轮最多 {n} 次往返"), -M("doctor.val.server_default", "(server default)", "(服务端默认)"), -M("doctor.val.symlink_diff", "argv[0] points at a different file than the real one", - "argv[0] 指向的文件与真实文件不同"), -M("doctor.val.timeout", "{n}s (non-streaming only)", "{n} 秒 (只对非流式生效)"), -M("doctor.val.trailing_slash_yes", "base_url ends with /, which tends to add a second slash", - "base_url 以 / 结尾, 容易多出一个斜杠"), -M("doctor.val.unreadable_exe", "(cannot read /proc/self/exe)", - "(无法读取 /proc/self/exe)"), -M("doctor.val.unknown", "(unknown)", "(未知)"), + /* ---- doctor: 体检 ---- */ + /* 分节标题的括号: 中文用【】, 英文用 [] */ + M("fmt.section", "[{name}]", "【{name}】"), + M("doctor.conclusion.bad", + "Bottom line: {problems} problem(s), {warnings} warning(s).", + "结论: {problems} 个问题, {warnings} 处提醒. "), + M("doctor.conclusion.next", "start with the items marked [x].", + "先处理标 [x] 的项. "), + M("doctor.conclusion.ok", "Bottom line: no problems found.", + "结论: 没发现问题. "), + M("doctor.env.note.all_proxy", "proxy for all protocols", "通用代理"), + M("doctor.env.note.api_key", "explicit override, highest priority", + "显式覆盖, 优先级最高"), + M("doctor.env.note.base_url", "overrides base_url", "覆盖 base_url"), + M("doctor.env.note.config", "path to the config file", "指定配置文件路径"), + M("doctor.env.note.deepseek", + "used only when the endpoint really is DeepSeek", + "仅在端点确实是 DeepSeek 时生效"), + M("doctor.env.note.http_proxy", "HTTP proxy", "HTTP 代理"), + M("doctor.env.note.https_proxy", "HTTPS proxy", "HTTPS 代理"), + M("doctor.env.note.lang", "overrides the interface language", + "覆盖界面语言"), + M("doctor.env.note.model", "overrides the model", "覆盖模型"), + M("doctor.env.note.no_proxy", "proxy exceptions", "代理例外"), + M("doctor.env.note.openai", "used only when the endpoint really is OpenAI", + "仅在端点确实是 OpenAI 时生效"), + M("doctor.lbl.argv0", "argv[0]", "argv[0]"), + M("doctor.lbl.canon_path", "canonical path", "规范路径"), + M("doctor.lbl.model", "model", "模型"), + M("doctor.lbl.note", "note", "注意"), + M("doctor.lbl.url", "request URL", "实际请求地址"), + M("doctor.val.not_configured", "not configured", "未配置"), + M("doctor.lbl.auth", "auth", "认证"), + M("doctor.lbl.config_dir", "config dir", "配置目录"), + M("doctor.lbl.connectivity", "connectivity", "连通性"), + M("doctor.lbl.endpoint", "endpoint", "端点"), + M("doctor.lbl.exe", "real executable", "真实可执行文件"), + M("doctor.lbl.format_guess", "prefix looks like", "格式推断"), + M("doctor.lbl.how_to_set", "how to set", "怎么配"), + M("doctor.lbl.http_status", "HTTP status", "HTTP 状态"), + M("doctor.lbl.log", "wire log", "收发记录"), + M("doctor.lbl.match", "matches endpoint", "与端点匹配"), + M("doctor.lbl.proxy", "proxy", "代理"), + M("doctor.lbl.redirect", "redirect", "重定向"), + M("doctor.lbl.set_keys", "keys set", "已设置的项"), + M("doctor.lbl.source", "source", "来源"), + M("doctor.lbl.status", "status", "状态"), + M("doctor.lbl.symlink", "symlink", "软链接"), + M("doctor.lbl.tip", "tip", "提示"), + M("doctor.lbl.transport", "transport", "传输安全"), + M("doctor.lbl.trailing_slash", "trailing slash", "结尾斜杠"), + M("doctor.lbl.version", "version", "版本"), + M("doctor.lbl.effort", "effort", "思考级别"), + M("doctor.lbl.value", "value", "值"), + M("doctor.sec.binary", "binary & config location", "二进制与配置位置"), + M("doctor.sec.config", "config file", "配置文件"), + M("doctor.sec.endpoint", "endpoint", "端点"), + M("doctor.sec.env", "environment", "环境变量"), + M("doctor.sec.key", "API key", "API key"), + M("doctor.sec.net", "network", "网络"), + M("doctor.sec.net_skipped", "[network] skipped (--offline)", + "【网络】已跳过 (--offline)"), + M("doctor.sec.params", "parameters", "参数"), + M("doctor.sub.404_base_url", + "usually a wrong base_url (a missing /v1, say); check it the way the " + "redirect section describes", + "多半是 base_url 写错了 (比如少了 /v1); " + "先照下面「重定向」那节的方法核对"), + M("doctor.sub.404_no_key_info", + "this probe says nothing about the key: the request was refused before " + "auth", + "这次探测说明不了密钥好不好: 请求没走到认证就被回绝了"), + M("doctor.sub.all_ignored", + "the whole file was ignored; this run uses built-in defaults", + "整个文件被忽略, 本次运行全部使用内置默认值. "), + M("doctor.sub.auth_not_network", + "this is not a network problem: it connects, there is just no key", + "这不是网络问题: 连得上, 只是没带钥匙"), + M("doctor.sub.auth_redirect", + "also check whether base_url was redirected: see the redirect section " + "below", + "也确认一下 base_url 是不是被跳转过: 详见下面「重定向」"), + M("doctor.sub.max_tokens_huge", + " far above the usual output cap (mostly <= 65536); may be rejected " + "or truncated", + " 远超常见模型的输出上限 (多数 <= 65536), 可能被拒或被截断"), + M("doctor.sub.redirect_1", + "ligent does not follow redirects: a cross-origin hop drops the " + "Authorization header,", + "ligent 不自动跟随重定向: 跨源跳转会丢掉 Authorization 头, "), + M("doctor.sub.redirect_2", + "the server then replies 401 \"Missing Authentication header\", which " + "looks like a missing key,", + "服务端只会回 401 \"Missing Authentication header\", " + "看起来像是密钥没带, "), + M("doctor.sub.redirect_3", + "when in fact the URL moved. Point base_url straight at the address " + "above.", + "实际是 URL 跳了. 把 base_url 直接改成上面那个地址即可. "), + M("doctor.sub.temp_too_high", "above 2.0, most providers reject this", + "超过 2.0, 多数服务商会拒绝"), + M("doctor.sub.thinking_temp", + "temperature has no effect in thinking mode; the server ignores it", + "思考模式下 temperature 不起作用, 服务端会忽略它"), + M("doctor.title", "ligent doctor — walking the whole chain link by link", + "ligent doctor —— 把整条链路逐环检查一遍"), + M("doctor.val.auth_missing", + "HTTP {status} — the endpoint requires auth but no API key is configured", + "HTTP {status} —— 端点要求认证, 但当前没有配置 API key"), + M("doctor.val.auth_ok", "the endpoint accepted this key", + "端点接受了这把密钥"), + M("doctor.val.auth_rejected", + "HTTP {status} — the server rejected this key; check its format and " + "source " + "against the API key section above", + "HTTP {status} —— 服务端拒绝了这把密钥, 先用上面「API " + "key」一节核对格式与来源"), + M("doctor.val.bad_scheme", "URL must start with http:// or https://", + "URL 必须以 http:// 或 https:// 开头"), + M("doctor.val.base_url_unset", "(not set)", "(未设置)"), + M("doctor.val.chars", "({n} chars)", " ({n} 字符)"), + M("doctor.val.connect_failed", "failed: {error}", "失败: {error}"), + M("doctor.val.connect_ok", "reachable, TLS verified (for HTTPS)", + "可连接, TLS 校验通过 (HTTPS 时)"), + M("doctor.val.empty", "(empty)", "(空)"), + M("doctor.val.endpoint_404", "HTTP 404 — no such address: {url}", + "HTTP 404 —— 这个地址不存在: {url}"), + M("doctor.val.env_ignored", + "{var} is set but unused this run (the key came from {source}). " + "If that is not what you want, unset it or use LIGENT_API_KEY", + "{var} 已设置, 但本次没用上 (密钥来自「{source}」). " + "如果这不是你想要的, unset 它或改用 LIGENT_API_KEY"), + M("doctor.val.env_set", "set: {value}", "已设置: {value}"), + M("doctor.val.env_unset", "not set", "未设置"), + M("doctor.val.exists_unloadable", + "present but not loaded (the top level must be a JSON object)", + "存在但加载失败 (顶层必须是 JSON 对象)"), + M("doctor.val.exists_unparsable", "present but unparsable: {error}", + "存在但无法解析: {error}"), + M("doctor.val.how_to_set_key", + "export DEEPSEEK_API_KEY=... or -k KEY or api_key in the config file", + "export DEEPSEEK_API_KEY=... 或 -k KEY 或配置文件 api_key"), + M("doctor.val.https", "HTTPS", "HTTPS"), + M("doctor.val.load_failed", "load failed: {error}", "加载失败: {error}"), + M("doctor.val.location_missing", "(the server sent no Location)", + "(服务端未提供 Location)"), + M("doctor.val.loaded", "loaded", "已加载"), + M("doctor.val.log", + "{n} bytes in memory (export with /log FILE in the REPL)", + "内存缓存 {n} 字节 (REPL 里 /log FILE 导出)"), + M("doctor.val.match_bad", + "endpoint is {owner} but the key prefix looks like {guess}. " + "Keys are not interchangeable, so the server will only return a vague " + "401", + "端点属于 {owner}, 但密钥前缀像是 {guess} 的. " + "各家密钥不通用, 服务端只会回一个含糊的 401"), + M("doctor.val.match_ok", "endpoint is {owner} and the key format matches", + "端点属于 {owner}, 密钥格式吻合"), + M("doctor.val.match_skipped", "endpoint is not a known provider, skipping", + "端点不是已知服务商, 跳过检查"), + M("doctor.val.match_unknown", + "endpoint is {owner} but the prefix is unrecognized", + "端点属于 {owner}, 但密钥前缀认不出来"), + M("doctor.val.missing", "not found, using built-in defaults", + "不存在, 使用内置默认值"), + M("doctor.val.net_tip", + "check the network, the proxy settings and that base_url is right", + "检查网络、代理设置, 以及 base_url 是否写对"), + M("doctor.val.no_auth", "the endpoint requires no auth", "端点不要求认证"), + M("doctor.val.no_redirect", "none (the endpoint did not redirect)", + "无 (端点没有跳转)"), + M("doctor.val.none", "none", "无"), + M("doctor.val.none_set", "(none)", "(无)"), + M("doctor.val.owner.sk", "OpenAI or DeepSeek", "OpenAI 或 DeepSeek"), + M("doctor.val.plain_local", "plain HTTP, but the target is localhost", + "明文 HTTP, 但目标是本机"), + M("doctor.val.plain_remote", "plain HTTP, the key will travel in the clear", + "明文 HTTP, 密钥会以明文上网"), + M("doctor.val.prefix_unclear", "(unrecognized)", "(认不出前缀)"), + M("doctor.val.proxy_unset", "(not set)", "(未设置)"), + M("doctor.val.redirect_to", "endpoint redirects to {location}", + "端点跳转到 {location}"), + M("doctor.val.retries", "{n}", "{n} 次"), + M("doctor.val.steps", "{n} round trips per turn", "每轮最多 {n} 次往返"), + M("doctor.val.server_default", "(server default)", "(服务端默认)"), + M("doctor.val.symlink_diff", + "argv[0] points at a different file than the real one", + "argv[0] 指向的文件与真实文件不同"), + M("doctor.val.timeout", "{n}s (non-streaming only)", + "{n} 秒 (只对非流式生效)"), + M("doctor.val.trailing_slash_yes", + "base_url ends with /, which tends to add a second slash", + "base_url 以 / 结尾, 容易多出一个斜杠"), + M("doctor.val.unreadable_exe", "(cannot read /proc/self/exe)", + "(无法读取 /proc/self/exe)"), + M("doctor.val.unknown", "(unknown)", "(未知)"), -/* ---- 其它 UI ---- */ -M("main.auto_saved", "auto-archived ({why}){temp}: {path}", - "已自动存档 ({why}){temp}: {path}"), -M("main.auto_saved.temp", " [temp]", "〔临时〕"), -M("main.err.auto_save_failed", "ligent: auto-archive failed: {error}\n", - "ligent: 自动存档失败: {error}\n"), -M("repl.banner.hint", "Type a task to start; /help lists commands, Ctrl-D exits.", - "输入任务开始对话, /help 查看命令, Ctrl-D 退出. "), -M("repl.bye", "Bye.", "再见. "), -M("spinner.probe", "probing capabilities", "检测模型能力"), -M("spinner.thinking", "thinking", "思考中"), -M("spinner.waiting", "waiting for response", "等待响应"), -M("repl.archive_empty", - "(this archive is empty)", - " (这段存档是空的)"), -M("repl.cancelled", - "cancelled.", - "已取消. "), -M("repl.colon", - ":", - ": "), -M("repl.export.done", - "exported {n} messages: {lineage}{arrow}{name}\n", - "已导出 {n} 条消息: {lineage}{arrow}{name}\n"), -M("repl.export.empty", - "this conversation is still empty, nothing to export.", - "当前对话还没有内容, 无需导出. "), -M("repl.export.failed", - "export failed: {error}\n", - "导出失败: {error}\n"), -M("repl.export.name_limit", - "(name hit the length cap; the lineage starts a new root here)", - " (名字已达长度上限, 谱系在这里重新起头)"), -M("repl.include.added", - "{n} file(s) attached{skipped}", - "已附带 {n} 个文件{skipped}"), -M("repl.include.cleared", - "cleared the pending attachments.", - "已清空待附带的文件. "), -M("repl.include.failed", - "attaching failed: {error}\n", - "附带失败: {error}\n"), -M("repl.include.next_hint", - "they go out with your next message. /include clear cancels.", - "下一条消息会带上它们. 用 /include clear 取消. "), -M("repl.include.none", - "nothing attached ({n} skipped).\n", - "没有附带任何文件 ({n} 个被跳过). \n"), -M("repl.include.skipped_more", - " ({n} more skipped)", - " (另有 {n} 个被跳过)"), -M("repl.index_out_of_range", - "index {n} is out of range (1..{max}), cancelled.\n", - "序号 {n} 超出范围 (1..{max}), 已取消. \n"), -M("repl.model.current", - " <- current", - " <- 当前"), -M("repl.model.fetching", - "fetching the model list ({url}/models)...\n", - "正在获取模型列表 ({url}/models)...\n"), -M("repl.model.list_failed", - "could not fetch the model list: {error}\n", - "拿不到模型列表: {error}\n"), -M("repl.model.list_failed_hint", - "you can still name one directly: /model NAME (the list is not required).", - "可以直接用 /model <名字> 指定, 列表拿不到不影响这条. "), -M("repl.model.list_header", - "\nthe server lists {n} models:\n\n", - "\n服务端列出了 {n} 个模型: \n\n"), -M("repl.model.pick_prompt", - "\nselect a number, or type a model name (Enter cancels) > ", - "\n选择序号, 或直接输入模型名 (回车取消)> "), -M("repl.model.row", - " %2zu) %s%s\n", - " %2zu) %s%s\n"), -M("repl.model.same", - "model unchanged: {model}\n", - "模型未变: {model}\n"), -M("repl.model.switched", - "switched to model {model}\n", - "模型已切换为 {model}\n"), -M("repl.no_content", - "(no content)", - " (没有内容)"), -M("repl.role.assistant", - "assistant", - "助手"), -M("repl.role.system", - "system", - "系统"), -M("repl.role.tool", - "tool", - "工具"), -M("repl.role.user", - "you", - "你"), -M("repl.tool_call", - " -> call {name} id={id}", - " -> 调用 {name} id={id}"), -M("repl.unknown_error", - "unknown error", - "未知错误"), -M("repl.yes_no.hint", - "please answer y or n; Enter means y.", - "请输入 y 或 n, 直接回车表示 y. "), -M("repl.back.done", - "removed the last round ({n} messages); {left} left. Undo with /unback.\n", - "已删除最近一轮 ({n} 条消息), 历史剩 {left} 条. 可以用 /unback 撤销. \n"), -M("repl.back.none", - "no rounds to roll back.", - "没有可以回退的对话轮次. "), -M("repl.cd.current", - "current directory: {dir}\n", - "当前目录: {dir}\n"), -M("repl.cd_failed", - "cd failed", - "cd 失败"), -M("repl.help", - "/help show this help\n/exit, /quit exit\n/reset clear the conversation (keeps the system prompt)\n/cls clear the screen (history untouched)\n/meta meta mode: edit the conversation with the model's help\n/model [NAME] switch model; with no name, pick from the online list\n/include [GLOB] attach files to the model; no argument = pick interactively, clear cancels\n/system TEXT set a new system prompt and clear the history\n/tools list available tools\n/probe probe model capabilities now (vision/tools); ignores --no-probe and the cache\n/think show thinking mode and effort\n/think on|off|auto switch thinking mode (auto = server decides)\n/think LEVEL set reasoning effort (minimal/low/medium/high/xhigh/max/ultra)\n/config show the effective config\n/cd PATH change working directory\n/history print the whole conversation (every message, tools included)\n/usage show token usage\n/shell [CMD] open an interactive shell, or run a single command\n/back roll back the most recent round\n/unback undo the last /back\n/yolo toggle write confirmation for this session\n/log [FILE] export the wire log (no argument = show size, /log clear = drop it)\n/export save this conversation under ligent.archive/\n/import load one of the archives (asks before printing the history)\n", - "/help 显示本帮助\n/exit, /quit 退出\n/reset 清空对话历史 (保留 system 提示词)\n/cls 清屏 (只动屏幕, 不动对话历史)\n/meta 进入 meta 模式: 用模型辅助编辑对话历史\n/model [NAME] 切换模型; 不带名字则拉取在线列表交互式选择\n/include [GLOB] 把文件附带给模型; 不带参数则交互式挑选, clear 取消\n/system TEXT 重新设置 system 提示词, 并清空历史\n/tools 列出可用工具\n/probe 现在测一次模型能力 (图片/工具); 无视 --no-probe 与缓存\n/think 查看思考模式与级别\n/think on|off|auto 切换思考模式 (auto = 由服务端决定)\n/think LEVEL 设置思考级别 (minimal/low/medium/high/xhigh/max/ultra)\n/config 显示当前生效配置\n/cd PATH 切换工作目录\n/history 把当前对话完整打印出来 (每条消息, 含工具调用与结果)\n/usage 显示 token 用量\n/shell [命令] 进入交互式 shell, 或直接执行一条命令\n/back 回退最近一轮对话\n/unback 撤销刚才的 /back\n/yolo 临时开关写操作确认\n/log [FILE] 导出收发记录 (无参数看大小, /log clear 清空)\n/export 把当前对话存到 ligent.archive/\n/import 从存档里选一份载入 (默认会问要不要打印历史)\n"), -M("repl.import.done", - "loaded {name} ({n} messages). Future /export will save as {name}_xxx\n", - "已载入 {name} ({n} 条消息). 之后 /export 会存成 {name}_xxx\n"), -M("repl.import.list_header", - "\n{dir} holds {n} archives:\n\n", - "\n{dir} 里有 {n} 份存档: \n\n"), -M("repl.import.load_failed", - "loading failed: {error}\n", - "载入失败: {error}\n"), -M("repl.import.none", - "no archives yet ({dir} is empty). Save one with /export first.\n", - "还没有任何存档 ({dir} 是空的). 先用 /export 存一份. \n"), -M("repl.import.not_found", - "no archive named {name}. Run /import with no argument to see them all.\n", - "找不到名为 {name} 的存档. 用 /import 不带参数可以看到全部. \n"), -M("repl.import.not_found_cancelled", - "no archive named {name}, cancelled.\n", - "找不到名为 {name} 的存档, 已取消. \n"), -M("repl.import.pick_prompt", - "\nselect a number or a name (Enter cancels) > ", - "\n选择序号或名字 (直接回车取消)> "), -M("repl.import.print_ask", - "print the contents of this history? (Y/n) ", - "是否输出这段历史的内容?(Y/n) "), -M("repl.import.row", - " %2zu) %-40s %s %3ld entries %s\n", - " %2zu) %-40s %s %3ld 条 %s\n"), -M("repl.import.row_failed", - " %2zu) %-40s unreadable\n", - " %2zu) %-40s 读取失败\n"), -M("repl.index_out_of_range_plain", - "index {n} is out of range, cancelled.\n", - "序号 {n} 超出范围, 已取消. \n"), -M("repl.reset.done", - "conversation history cleared.", - "已清空对话历史. "), -M("repl.system.builtin", - "(built-in default)", - "(内置默认)"), -M("repl.system.current", - "current system prompt:", - "当前 system 提示词: "), -M("repl.system.updated", - "system prompt updated and history cleared.", - "system 提示词已更新, 历史已清空. "), -M("repl.think.bad", - "not recognised: \"{value}\".\n mode: on / off / auto\n effort: {list}\n", - "无法识别 \"{value}\". \n 开关: on / off / auto\n 级别: {list}\n"), -M("repl.think.effort", - "reasoning effort: %s\n", - "思考级别: %s\n"), -M("repl.think.effort_set", - "reasoning effort set to {level} (this session only).\n", - "思考级别已设为 {level} (只影响本次会话). \n"), -M("repl.think.mode", - "thinking mode: %s\n", - "思考模式: %s\n"), -M("repl.tools.disabled", - "tools are disabled (--no-tools).", - "工具已禁用 (--no-tools). "), -M("repl.unback.done", - "restored {n} messages; history is back to {total}.\n", - "已恢复 {n} 条消息, 历史回到 {total} 条. \n"), -M("repl.unback.new_chat", - "a new conversation started after /back, cannot undo.", - "/back 之后已经开始了新对话, 无法撤销. "), -M("repl.unback.none", - "nothing to undo.", - "没有可撤销的操作. "), -M("repl.usage.calls", - " API calls : %d\n", - " API 调用 : %d 次\n"), -M("repl.usage.completion", - " completion : %lld\n", - " 输出 token : %lld\n"), -M("repl.usage.header", - "token usage (this session)\n", - "token 用量 (本次会话)\n"), -M("repl.usage.history", - " history : %zu messages (excluding system)\n", - " 历史消息 : %zu 条 (不含 system)\n"), -M("repl.usage.last", - " last prompt : %lld <- roughly the current context size\n", - " 最近一次输入 : %lld <- 大致等于当前上下文大小\n"), -M("repl.usage.none", - "the server has not reported usage yet (some do not send the usage field).", - "服务端还没有回报过用量 (有些服务端不返回 usage 字段). "), -M("repl.usage.prompt", - " prompt : %lld\n", - " 输入 token : %lld\n"), -M("repl.usage.total", - " total : %lld\n", - " 合计 : %lld\n"), -M("repl.why.before_import", - "before import", - "导入前"), -M("repl.why.before_meta", - "before entering meta", - "进入 meta 前"), -M("repl.why.before_reset", - "before reset", - "重置前"), -M("repl.why.exit", - "exit", - "退出"), -M("repl.yolo.off", - "off — no more prompts; run /yolo again to restore", - "关闭 —— 之后不再询问, 想恢复再敲一次 /yolo"), -M("repl.yolo.on", - "on", - "开启"), -M("repl.yolo.set", - "write confirmation is now {state} (this session only).\n", - "写操作确认已{state} (只影响本次会话). \n"), -M("repl.history.count", - "{n} messages in history (excluding the system prompt).\n", - "历史消息 {n} 条 (不含 system 提示词). \n"), -M("repl.history.full", - "printing all {total} messages below (system prompt included):\n", - "下面完整打印全部 {total} 条消息 (含 system 提示词): \n"), -M("repl.log.cleared", - "wire log cleared.", - "收发记录已清空. "), -M("repl.log.empty", - "no wire log yet, nothing written to {file}.\n", - "还没有任何收发记录, {file} 未写入内容. \n"), -M("repl.log.save_failed", - "export failed: {error}\n", - "导出失败: {error}\n"), -M("repl.log.saved", - "appended {n} bytes of wire log to {file}\n", - "已把 {n} 字节收发记录追加写入 {file}\n"), -M("repl.log.status", - "wire log: {n} bytes cached{trunc}\n /log FILE append to FILE (exporting does not clear it; can be run again)\n /log clear clear the cache\n", - "收发记录已缓存 {n} 字节{trunc}. \n /log FILE 追加写入 FILE (导出后不清空, 可反复导出)\n /log clear 清空缓存\n"), -M("repl.log.truncated", - " (older part dropped at the size cap)", - " (较早部分已因超上限被丢弃)"), -M("repl.shell.added_hint", - "(added to the context, no automatic follow-up question)", - " (已加入上下文, 不自动提问)"), -M("repl.shell.enter", - "entering an interactive shell. Type exit or press Ctrl-D to return; the whole session is added to the context when it ends.", - "进入交互式 shell. 输入 exit 或按 Ctrl-D 返回; 整段会话会在退出后记入上下文. "), -M("repl.shell.nothing", - "nothing was run this time, nothing added to the context.", - "这次没执行任何东西, 未记入上下文. "), -M("repl.shell.recorded", - "{n} bytes of session log added to the context (no automatic question).\n", - "已把 {n} 字节会话记录加入上下文 (不自动提问). \n"), -M("repl.shell.start_failed", - "could not start the shell: {error}\n", - "启动 shell 失败: {error}\n"), -M("repl.turn_failed", - "this turn failed; the history is kept. Keep asking, or /reset to clear it.\n", - "本轮失败, 历史保留, 可继续提问或 /reset 清空. \n"), -M("repl.unknown_command", - "unknown command: {cmd} (/help lists them)\n", - "未知命令: {cmd} (/help 查看帮助)\n"), -M("agent.call.elevated", "as root: {cmd}", "以 root: {cmd}"), -M("agent.call.write", "\"{path}\" ({n} bytes)", "\"{path}\" ({n} 字节)"), -M("agent.caps.baseline_failed", - "the baseline request did not go through", - "基线请求未通过"), -M("agent.caps.line", - "model capabilities: \n image input {vision} \n tool calls {tools}{note}", - "模型能力: \n 图片输入 {vision} \n 工具调用 {tools}{note}"), -M("agent.caps.no.tools", - "no (tool table no longer sent)", - "不支持 (不再发送工具表)"), -M("agent.caps.no.vision", - "no (read_image hidden)", - "不支持 (已隐藏 read_image)"), -M("agent.caps.note", - " — anything untested is treated as supported ({why})", - " —- 没测出来的一律按 \"支持\" 处理 ({why})"), -M("agent.caps.unknown", - "untested", - "未测"), -M("agent.caps.yes", - "yes", - "支持"), -M("agent.empty_reply", - "(the model returned no content)", - "(模型没有返回任何内容)"), -M("agent.error", - "error: {error}\n", - "错误: {error}\n"), -M("agent.image.degraded", - "{n} image reference(s) could not be expanded (local backup missing or over the size cap); they were downgraded to a text note in place, no retry.", - "有 {n} 处图片引用展开不出来 (本机备份缺失或体积超限), 已就地降级成文字说明, 不再重试. "), -M("agent.img.rejected", - "the server rejected the images in this request; removed {n} image block(s) from the conversation and retrying...\n", - "服务端拒绝了请求里的图片, 已把 {n} 处图片从对话里移除后重试...\n"), -M("agent.img.server_said", - "the server said: {error}", - "服务端原话: {error}"), -M("agent.interrupted", - "[interrupted]", - "[已中断]"), -M("agent.max_steps", - "reached the step limit ({n}); stopping. Set --max-steps 0 for no limit.\n", - "已达到最大步数 {n}, 停止. 要不设上限就写 --max-steps 0. \n"), -M("agent.output.elided", - "... [rest of output elided]", - "... [省略剩余输出]"), -M("agent.output.empty", - "(no output)", - "(无输出)"), -M("agent.stream.broken", - " transfer interrupted; the partial content above is all that arrived: {why}", - " 传输中断, 以上是已收到的部分内容: {why}"), -M("agent.structure.fixed", - "the conversation structure was broken (an assistant tool_calls message was not paired with its tool replies); repaired {n} place(s) — without this every later round would be rejected by the server.", - "对话结构有问题 (assistant 的 tool_calls 与 tool 消息对不上), 已自动修复 {n} 处 —— 不修的话之后每一轮都会被服务端拒绝. "), -M("agent.tool.done", - "done", - "完成"), -M("agent.tool.failed", - "failed", - "失败"), -M("agent.tool.name_unknown", - "(unknown)", - "(未知)"), -M("attach.err.binary", - "looks like a binary file", - "看起来是二进制文件"), -M("attach.err.glob_failed", - "glob failed: {pattern}", - "展开失败: {pattern}"), -M("attach.err.no_match", - "no files matched: {pattern}", - "没有匹配到文件: {pattern}"), -M("attach.err.no_pattern", - "no pattern given", - "没有给出模式"), -M("attach.err.no_vision", - "the current model does not take image input", - "当前模型不支持图片输入"), -M("attach.err.not_regular", - "not a regular file", - "不是普通文件"), -M("attach.err.read_failed", - "cannot read", - "读不了"), -M("attach.err.too_big", - "file too large (256 KB per file; use read_file with offsets instead)", - "文件太大 (单个上限 256 KB, 请改用 read_file 分段读)"), -M("attach.err.total_big", - "the attached text exceeds the total limit (1 MB)", - "本次附带的文本总量超过上限 (1 MB)"), -M("attach.err.unreadable", - "cannot read", - "读不到"), -M("attach.glob.skipped", - " skipped {file}: {why}", - " 跳过 {file}: {why}"), -M("attach.image_mark", - " [image]", - " 〔图片〕"), -M("attach.pick.cancelled", - "selection cancelled.", - "已取消选择. "), -M("attach.pick.dir", - "\ncurrent directory: {dir}\n\n", - "\n当前目录: {dir}\n\n"), -M("attach.pick.empty_dir", - "(empty directory, or no permission to read it)", - " (空目录, 或者没有权限读)"), -M("attach.pick.index_range", - "index {n} is out of range (1..{max}).\n", - "序号 {n} 超出范围 (1..{max}). \n"), -M("attach.pick.intro", - "interactive picker: a number enters a directory / selects a file; you can also type a glob or a path. Enter finishes.", - "进入交互式选择. 序号进目录 / 选中文件, 也可以直接敲 glob 或路径, 回车结束. "), -M("attach.pick.is_dir", - " (dir)", - " (目录)"), -M("attach.pick.more", - " ...{n} more not listed\n", - " ...还有 {n} 项没列出来\n"), -M("attach.pick.no_match", - "no match: {input}\n", - "没有匹配到: {input}\n"), -M("attach.pick.parent", - " .) .. go up one level", - " .) .. 返回上级"), -M("attach.pick.prompt", - "\nselected {n}. pick > ", - "\n已选 {n} 个. 选择 > "), -M("attach.pick.selected", - "selected {file}\n", - "已选中 {file}\n"), -M("attach.pick.skipped", - "skipped {file}: {why}", - "跳过 {file}: {why}"), + /* ---- 其它 UI ---- */ + M("main.auto_saved", "auto-archived ({why}){temp}: {path}", + "已自动存档 ({why}){temp}: {path}"), + M("main.auto_saved.temp", " [temp]", "〔临时〕"), + M("main.err.auto_save_failed", "ligent: auto-archive failed: {error}\n", + "ligent: 自动存档失败: {error}\n"), + M("repl.banner.hint", + "Type a task to start; /help lists commands, Ctrl-D exits.", + "输入任务开始对话, /help 查看命令, Ctrl-D 退出. "), + M("repl.bye", "Bye.", "再见. "), + M("spinner.probe", "probing capabilities", "检测模型能力"), + M("spinner.thinking", "thinking", "思考中"), + M("spinner.waiting", "waiting for response", "等待响应"), + M("repl.archive_empty", "(this archive is empty)", " (这段存档是空的)"), + M("repl.cancelled", "cancelled.", "已取消. "), + M("repl.colon", ":", ": "), + M("repl.export.done", "exported {n} messages: {lineage}{arrow}{name}\n", + "已导出 {n} 条消息: {lineage}{arrow}{name}\n"), + M("repl.export.empty", + "this conversation is still empty, nothing to export.", + "当前对话还没有内容, 无需导出. "), + M("repl.export.failed", "export failed: {error}\n", "导出失败: {error}\n"), + M("repl.export.name_limit", + "(name hit the length cap; the lineage starts a new root here)", + " (名字已达长度上限, 谱系在这里重新起头)"), + M("repl.include.added", "{n} file(s) attached{skipped}", + "已附带 {n} 个文件{skipped}"), + M("repl.include.cleared", "cleared the pending attachments.", + "已清空待附带的文件. "), + M("repl.include.failed", "attaching failed: {error}\n", + "附带失败: {error}\n"), + M("repl.include.next_hint", + "they go out with your next message. /include clear cancels.", + "下一条消息会带上它们. 用 /include clear 取消. "), + M("repl.include.none", "nothing attached ({n} skipped).\n", + "没有附带任何文件 ({n} 个被跳过). \n"), + M("repl.include.skipped_more", " ({n} more skipped)", + " (另有 {n} 个被跳过)"), + M("repl.index_out_of_range", + "index {n} is out of range (1..{max}), cancelled.\n", + "序号 {n} 超出范围 (1..{max}), 已取消. \n"), + M("repl.model.current", " <- current", " <- 当前"), + M("repl.model.fetching", "fetching the model list ({url}/models)...\n", + "正在获取模型列表 ({url}/models)...\n"), + M("repl.model.list_failed", "could not fetch the model list: {error}\n", + "拿不到模型列表: {error}\n"), + M("repl.model.list_failed_hint", + "you can still name one directly: /model NAME (the list is not " + "required).", + "可以直接用 /model <名字> 指定, 列表拿不到不影响这条. "), + M("repl.model.list_header", "\nthe server lists {n} models:\n\n", + "\n服务端列出了 {n} 个模型: \n\n"), + M("repl.model.pick_prompt", + "\nselect a number, or type a model name (Enter cancels) > ", + "\n选择序号, 或直接输入模型名 (回车取消)> "), + M("repl.model.row", " %2zu) %s%s\n", " %2zu) %s%s\n"), + M("repl.model.same", "model unchanged: {model}\n", "模型未变: {model}\n"), + M("repl.model.switched", "switched to model {model}\n", + "模型已切换为 {model}\n"), + M("repl.no_content", "(no content)", " (没有内容)"), + M("repl.role.assistant", "assistant", "助手"), + M("repl.role.system", "system", "系统"), + M("repl.role.tool", "tool", "工具"), + M("repl.role.user", "you", "你"), + M("repl.tool_call", " -> call {name} id={id}", + " -> 调用 {name} id={id}"), + M("repl.unknown_error", "unknown error", "未知错误"), + M("repl.yes_no.hint", "please answer y or n; Enter means y.", + "请输入 y 或 n, 直接回车表示 y. "), + M("repl.back.done", + "removed the last round ({n} messages); {left} left. Undo with " + "/unback.\n", + "已删除最近一轮 ({n} 条消息), 历史剩 {left} 条. 可以用 /unback 撤销. \n"), + M("repl.back.none", "no rounds to roll back.", "没有可以回退的对话轮次. "), + M("repl.cd.current", "current directory: {dir}\n", "当前目录: {dir}\n"), + M("repl.cd_failed", "cd failed", "cd 失败"), + M("repl.help", + "/help show this help\n/exit, /quit exit\n/reset " + " clear the conversation (keeps the system prompt)\n/cls " + "clear the screen (history untouched)\n/meta meta mode: edit " + "the conversation with the model's help\n/model [NAME] switch model; " + "with no name, pick from the online list\n/include [GLOB] attach files " + "to the model; no argument = pick interactively, clear cancels\n/system " + "TEXT set a new system prompt and clear the history\n/tools " + " list available tools\n/probe probe model capabilities now " + "(vision/tools); ignores --no-probe and the cache\n/think show " + "thinking mode and effort\n/think on|off|auto switch thinking mode " + "(auto = server decides)\n/think LEVEL set reasoning effort " + "(minimal/low/medium/high/xhigh/max/ultra)\n/config show the " + "effective config\n/cd PATH change working directory\n/history " + " print the whole conversation (every message, tools " + "included)\n/usage show token usage\n/shell [CMD] open an " + "interactive shell, or run a single command\n/back roll back " + "the most recent round\n/unback undo the last /back\n/yolo " + " toggle write confirmation for this session\n/log [FILE] " + "export the wire log (no argument = show size, /log clear = drop " + "it)\n/export save this conversation under " + "ligent.archive/\n/import load one of the archives (asks before " + "printing the history)\n", + "/help 显示本帮助\n/exit, /quit 退出\n/reset " + "清空对话历史 (保留 system 提示词)\n/cls 清屏 (只动屏幕, " + "不动对话历史)\n/meta 进入 meta 模式: " + "用模型辅助编辑对话历史\n/model [NAME] 切换模型; " + "不带名字则拉取在线列表交互式选择\n/include [GLOB] 把文件附带给模型; " + "不带参数则交互式挑选, clear 取消\n/system TEXT 重新设置 system " + "提示词, 并清空历史\n/tools 列出可用工具\n/probe " + "现在测一次模型能力 (图片/工具); 无视 --no-probe 与缓存\n/think " + " 查看思考模式与级别\n/think on|off|auto 切换思考模式 (auto = " + "由服务端决定)\n/think LEVEL 设置思考级别 " + "(minimal/low/medium/high/xhigh/max/ultra)\n/config " + "显示当前生效配置\n/cd PATH 切换工作目录\n/history " + "把当前对话完整打印出来 (每条消息, 含工具调用与结果)\n/usage " + "显示 token 用量\n/shell [命令] 进入交互式 shell, " + "或直接执行一条命令\n/back 回退最近一轮对话\n/unback " + "撤销刚才的 /back\n/yolo 临时开关写操作确认\n/log [FILE] " + "导出收发记录 (无参数看大小, /log clear 清空)\n/export " + "把当前对话存到 ligent.archive/\n/import 从存档里选一份载入 " + "(默认会问要不要打印历史)\n"), + M("repl.import.done", + "loaded {name} ({n} messages). Future /export will save as {name}_xxx\n", + "已载入 {name} ({n} 条消息). 之后 /export 会存成 {name}_xxx\n"), + M("repl.import.list_header", "\n{dir} holds {n} archives:\n\n", + "\n{dir} 里有 {n} 份存档: \n\n"), + M("repl.import.load_failed", "loading failed: {error}\n", + "载入失败: {error}\n"), + M("repl.import.none", + "no archives yet ({dir} is empty). Save one with /export first.\n", + "还没有任何存档 ({dir} 是空的). 先用 /export 存一份. \n"), + M("repl.import.not_found", + "no archive named {name}. Run /import with no argument to see them " + "all.\n", + "找不到名为 {name} 的存档. 用 /import 不带参数可以看到全部. \n"), + M("repl.import.not_found_cancelled", + "no archive named {name}, cancelled.\n", + "找不到名为 {name} 的存档, 已取消. \n"), + M("repl.import.pick_prompt", + "\nselect a number or a name (Enter cancels) > ", + "\n选择序号或名字 (直接回车取消)> "), + M("repl.import.print_ask", "print the contents of this history? (Y/n) ", + "是否输出这段历史的内容?(Y/n) "), + M("repl.import.row", " %2zu) %-40s %s %3ld entries %s\n", + " %2zu) %-40s %s %3ld 条 %s\n"), + M("repl.import.row_failed", " %2zu) %-40s unreadable\n", + " %2zu) %-40s 读取失败\n"), + M("repl.index_out_of_range_plain", + "index {n} is out of range, cancelled.\n", + "序号 {n} 超出范围, 已取消. \n"), + M("repl.reset.done", "conversation history cleared.", "已清空对话历史. "), + M("repl.system.builtin", "(built-in default)", "(内置默认)"), + M("repl.system.current", "current system prompt:", "当前 system 提示词: "), + M("repl.system.updated", "system prompt updated and history cleared.", + "system 提示词已更新, 历史已清空. "), + M("repl.think.bad", + "not recognised: \"{value}\".\n mode: on / off / auto\n effort: " + "{list}\n", + "无法识别 \"{value}\". \n 开关: on / off / auto\n 级别: {list}\n"), + M("repl.think.effort", "reasoning effort: %s\n", "思考级别: %s\n"), + M("repl.think.effort_set", + "reasoning effort set to {level} (this session only).\n", + "思考级别已设为 {level} (只影响本次会话). \n"), + M("repl.think.mode", "thinking mode: %s\n", "思考模式: %s\n"), + M("repl.tools.disabled", "tools are disabled (--no-tools).", + "工具已禁用 (--no-tools). "), + M("repl.unback.done", + "restored {n} messages; history is back to {total}.\n", + "已恢复 {n} 条消息, 历史回到 {total} 条. \n"), + M("repl.unback.new_chat", + "a new conversation started after /back, cannot undo.", + "/back 之后已经开始了新对话, 无法撤销. "), + M("repl.unback.none", "nothing to undo.", "没有可撤销的操作. "), + M("repl.usage.calls", " API calls : %d\n", " API 调用 : %d 次\n"), + M("repl.usage.completion", " completion : %lld\n", + " 输出 token : %lld\n"), + M("repl.usage.header", "token usage (this session)\n", + "token 用量 (本次会话)\n"), + M("repl.usage.history", + " history : %zu messages (excluding system)\n", + " 历史消息 : %zu 条 (不含 system)\n"), + M("repl.usage.last", + " last prompt : %lld <- roughly the current context size\n", + " 最近一次输入 : %lld <- 大致等于当前上下文大小\n"), + M("repl.usage.none", + "the server has not reported usage yet (some do not send the usage " + "field).", + "服务端还没有回报过用量 (有些服务端不返回 usage 字段). "), + M("repl.usage.prompt", " prompt : %lld\n", + " 输入 token : %lld\n"), + M("repl.usage.total", " total : %lld\n", " 合计 : %lld\n"), + M("repl.why.before_import", "before import", "导入前"), + M("repl.why.before_meta", "before entering meta", "进入 meta 前"), + M("repl.why.before_reset", "before reset", "重置前"), + M("repl.why.exit", "exit", "退出"), + M("repl.yolo.off", "off — no more prompts; run /yolo again to restore", + "关闭 —— 之后不再询问, 想恢复再敲一次 /yolo"), + M("repl.yolo.on", "on", "开启"), + M("repl.yolo.set", + "write confirmation is now {state} (this session only).\n", + "写操作确认已{state} (只影响本次会话). \n"), + M("repl.history.count", + "{n} messages in history (excluding the system prompt).\n", + "历史消息 {n} 条 (不含 system 提示词). \n"), + M("repl.history.full", + "printing all {total} messages below (system prompt included):\n", + "下面完整打印全部 {total} 条消息 (含 system 提示词): \n"), + M("repl.log.cleared", "wire log cleared.", "收发记录已清空. "), + M("repl.log.empty", "no wire log yet, nothing written to {file}.\n", + "还没有任何收发记录, {file} 未写入内容. \n"), + M("repl.log.save_failed", "export failed: {error}\n", + "导出失败: {error}\n"), + M("repl.log.saved", "appended {n} bytes of wire log to {file}\n", + "已把 {n} 字节收发记录追加写入 {file}\n"), + M("repl.log.status", + "wire log: {n} bytes cached{trunc}\n /log FILE append to FILE " + "(exporting does not clear it; can be run again)\n /log clear clear " + "the cache\n", + "收发记录已缓存 {n} 字节{trunc}. \n /log FILE 追加写入 FILE " + "(导出后不清空, 可反复导出)\n /log clear 清空缓存\n"), + M("repl.log.truncated", " (older part dropped at the size cap)", + " (较早部分已因超上限被丢弃)"), + M("repl.shell.added_hint", + "(added to the context, no automatic follow-up question)", + " (已加入上下文, 不自动提问)"), + M("repl.shell.enter", + "entering an interactive shell. Type exit or press Ctrl-D to return; the " + "whole session is added to the context when it ends.", + "进入交互式 shell. 输入 exit 或按 Ctrl-D 返回; " + "整段会话会在退出后记入上下文. "), + M("repl.shell.nothing", + "nothing was run this time, nothing added to the context.", + "这次没执行任何东西, 未记入上下文. "), + M("repl.shell.recorded", + "{n} bytes of session log added to the context (no automatic " + "question).\n", + "已把 {n} 字节会话记录加入上下文 (不自动提问). \n"), + M("repl.shell.start_failed", "could not start the shell: {error}\n", + "启动 shell 失败: {error}\n"), + M("repl.turn_failed", + "this turn failed; the history is kept. Keep asking, or /reset to clear " + "it.\n", + "本轮失败, 历史保留, 可继续提问或 /reset 清空. \n"), + M("repl.unknown_command", "unknown command: {cmd} (/help lists them)\n", + "未知命令: {cmd} (/help 查看帮助)\n"), + M("agent.call.elevated", "as root: {cmd}", "以 root: {cmd}"), + M("agent.call.write", "\"{path}\" ({n} bytes)", "\"{path}\" ({n} 字节)"), + M("agent.caps.baseline_failed", "the baseline request did not go through", + "基线请求未通过"), + M("agent.caps.line", + "model capabilities: \n image input {vision} \n tool calls {tools}{note}", + "模型能力: \n 图片输入 {vision} \n 工具调用 {tools}{note}"), + M("agent.caps.no.tools", "no (tool table no longer sent)", + "不支持 (不再发送工具表)"), + M("agent.caps.no.vision", "no (read_image hidden)", + "不支持 (已隐藏 read_image)"), + M("agent.caps.note", " — anything untested is treated as supported ({why})", + " —- 没测出来的一律按 \"支持\" 处理 ({why})"), + M("agent.caps.unknown", "untested", "未测"), + M("agent.caps.yes", "yes", "支持"), + M("agent.empty_reply", "(the model returned no content)", + "(模型没有返回任何内容)"), + M("agent.error", "error: {error}\n", "错误: {error}\n"), + M("agent.image.degraded", + "{n} image reference(s) could not be expanded (local backup missing or " + "over the size cap); they were downgraded to a text note in place, no " + "retry.", + "有 {n} 处图片引用展开不出来 (本机备份缺失或体积超限), " + "已就地降级成文字说明, 不再重试. "), + M("agent.img.rejected", + "the server rejected the images in this request; removed {n} image " + "block(s) from the conversation and retrying...\n", + "服务端拒绝了请求里的图片, 已把 {n} 处图片从对话里移除后重试...\n"), + M("agent.img.server_said", "the server said: {error}", + "服务端原话: {error}"), + M("agent.interrupted", "[interrupted]", "[已中断]"), + M("agent.max_steps", + "reached the step limit ({n}); stopping. Set --max-steps 0 for no " + "limit.\n", + "已达到最大步数 {n}, 停止. 要不设上限就写 --max-steps 0. \n"), + M("agent.output.elided", "... [rest of output elided]", + "... [省略剩余输出]"), + M("agent.output.empty", "(no output)", "(无输出)"), + M("agent.stream.broken", + " transfer interrupted; the partial content above is all that " + "arrived: {why}", + " 传输中断, 以上是已收到的部分内容: {why}"), + M("agent.structure.fixed", + "the conversation structure was broken (an assistant tool_calls message " + "was not paired with its tool replies); repaired {n} place(s) — without " + "this every later round would be rejected by the server.", + "对话结构有问题 (assistant 的 tool_calls 与 tool 消息对不上), 已自动修复 " + "{n} 处 —— 不修的话之后每一轮都会被服务端拒绝. "), + M("agent.tool.done", "done", "完成"), + M("agent.tool.failed", "failed", "失败"), + M("agent.tool.name_unknown", "(unknown)", "(未知)"), + M("attach.err.binary", "looks like a binary file", "看起来是二进制文件"), + M("attach.err.glob_failed", "glob failed: {pattern}", + "展开失败: {pattern}"), + M("attach.err.no_match", "no files matched: {pattern}", + "没有匹配到文件: {pattern}"), + M("attach.err.no_pattern", "no pattern given", "没有给出模式"), + M("attach.err.no_vision", "the current model does not take image input", + "当前模型不支持图片输入"), + M("attach.err.not_regular", "not a regular file", "不是普通文件"), + M("attach.err.read_failed", "cannot read", "读不了"), + M("attach.err.too_big", + "file too large (256 KB per file; use read_file with offsets instead)", + "文件太大 (单个上限 256 KB, 请改用 read_file 分段读)"), + M("attach.err.total_big", + "the attached text exceeds the total limit (1 MB)", + "本次附带的文本总量超过上限 (1 MB)"), + M("attach.err.unreadable", "cannot read", "读不到"), + M("attach.glob.skipped", " skipped {file}: {why}", " 跳过 {file}: {why}"), + M("attach.image_mark", " [image]", " 〔图片〕"), + M("attach.pick.cancelled", "selection cancelled.", "已取消选择. "), + M("attach.pick.dir", "\ncurrent directory: {dir}\n\n", + "\n当前目录: {dir}\n\n"), + M("attach.pick.empty_dir", "(empty directory, or no permission to read it)", + " (空目录, 或者没有权限读)"), + M("attach.pick.index_range", "index {n} is out of range (1..{max}).\n", + "序号 {n} 超出范围 (1..{max}). \n"), + M("attach.pick.intro", + "interactive picker: a number enters a directory / selects a file; you " + "can also type a glob or a path. Enter finishes.", + "进入交互式选择. 序号进目录 / 选中文件, 也可以直接敲 glob 或路径, " + "回车结束. "), + M("attach.pick.is_dir", " (dir)", " (目录)"), + M("attach.pick.more", " ...{n} more not listed\n", + " ...还有 {n} 项没列出来\n"), + M("attach.pick.no_match", "no match: {input}\n", "没有匹配到: {input}\n"), + M("attach.pick.parent", " .) .. go up one level", + " .) .. 返回上级"), + M("attach.pick.prompt", "\nselected {n}. pick > ", + "\n已选 {n} 个. 选择 > "), + M("attach.pick.selected", "selected {file}\n", "已选中 {file}\n"), + M("attach.pick.skipped", "skipped {file}: {why}", "跳过 {file}: {why}"), -/* ---- 存档 (archive.c): /export /import 的底层错误 ---- */ -M("archive.err.mkdir", - "cannot create the archive directory {dir}: {error}", - "无法创建存档目录 {dir}: {error}"), -M("archive.err.name_exhausted", - "could not come up with a non-clashing file name in 64 tries", - "连续 64 次都没能生成不冲突的文件名"), -M("archive.err.no_messages", - "{path} has no messages array", - "{path} 里没有 messages 数组"), -M("archive.err.not_archive", - "{path} is not a ligent archive (the format marker is missing)", - "{path} 不是 ligent 存档 (缺少 format 标记)"), -M("archive.err.not_json", - "{path} is not valid JSON: {error}", - "{path} 不是合法 JSON: {error}"), -M("archive.err.read_failed", - "cannot read {path}", - "无法读取 {path}"), -M("archive.err.write_failed", - "error while writing {path}", - "写入 {path} 时出错"), -M("archive.err.write_open", - "cannot write {path}: {error}", - "无法写入 {path}: {error}"), + /* ---- 存档 (archive.c): /export /import 的底层错误 ---- */ + M("archive.err.mkdir", "cannot create the archive directory {dir}: {error}", + "无法创建存档目录 {dir}: {error}"), + M("archive.err.name_exhausted", + "could not come up with a non-clashing file name in 64 tries", + "连续 64 次都没能生成不冲突的文件名"), + M("archive.err.no_messages", "{path} has no messages array", + "{path} 里没有 messages 数组"), + M("archive.err.not_archive", + "{path} is not a ligent archive (the format marker is missing)", + "{path} 不是 ligent 存档 (缺少 format 标记)"), + M("archive.err.not_json", "{path} is not valid JSON: {error}", + "{path} 不是合法 JSON: {error}"), + M("archive.err.read_failed", "cannot read {path}", "无法读取 {path}"), + M("archive.err.write_failed", "error while writing {path}", + "写入 {path} 时出错"), + M("archive.err.write_open", "cannot write {path}: {error}", + "无法写入 {path}: {error}"), -/* ---- 模型列表 (models.c)---- */ -M("models.err.empty_body", - "the server returned an empty response (HTTP {status})", - "服务端返回了空响应 (HTTP {status})"), -M("models.err.empty_list", - "the server returned an empty model list (HTTP {status}) — " - "this account may have no models available", - "服务端返回了空的模型列表 (HTTP {status})—— " - "这个账号下可能没有可用的模型"), -M("models.err.no_base_url", - "no base_url configured", - "没有配置 base_url"), -M("models.err.request_failed", - "request failed", - "请求失败"), -M("models.err.unparsable", - "the server did not return a usable model list (HTTP {status}, response {body})", - "服务端没有返回可用的模型列表 (HTTP {status}, 响应 {body})"), + /* ---- 模型列表 (models.c)---- */ + M("models.err.empty_body", + "the server returned an empty response (HTTP {status})", + "服务端返回了空响应 (HTTP {status})"), + M("models.err.empty_list", + "the server returned an empty model list (HTTP {status}) — " + "this account may have no models available", + "服务端返回了空的模型列表 (HTTP {status})—— " + "这个账号下可能没有可用的模型"), + M("models.err.no_base_url", "no base_url configured", "没有配置 base_url"), + M("models.err.request_failed", "request failed", "请求失败"), + M("models.err.unparsable", + "the server did not return a usable model list (HTTP {status}, response " + "{body})", + "服务端没有返回可用的模型列表 (HTTP {status}, 响应 {body})"), -/* ---- 请求与网络 (http.c / llm.c)---- */ -M("http.err.body_too_big", - "the response body exceeded the {limit}-byte cap, aborted", - "响应体超过 {limit} 字节上限, 已中止"), -M("http.err.interrupted", - "interrupted", - "已中断"), -M("http.err.log_open", - "cannot write {path}: {error}", - "无法写入 {path}: {error}"), -M("http.err.log_write", - "error while writing {path}", - "写入 {path} 时出错"), -M("http.err.network", - "network error: {error}", - "网络错误: {error}"), -M("http.err.network_partial", - "network error: {error} (some content was already shown, not retrying)", - "网络错误: {error} (已输出部分内容, 不重试)"), -M("http.err.network_retried", - "network error: {error} (after {n} retries)", - "网络错误: {error} (已重试 {n} 次)"), -M("http.err.redirect", - "HTTP {status} redirects to {location} — point base_url at that address " - "instead. (ligent does not follow redirects: a cross-origin hop would drop " - "the Authorization header, and the server would only answer 401)", - "HTTP {status} 重定向到 {location} —— 请直接把 base_url 改成该地址. " - " (ligent 不自动跟随重定向: 跨源跳转会丢掉 Authorization 头, " - "服务端只会返回 401)"), -M("http.err.status_partial", - "HTTP {status} (some content was already shown, not retrying)", - "HTTP {status} (已输出部分内容, 不重试)"), -M("http.err.status_retried", - "HTTP {status} (after {n} retries)", - "HTTP {status} (已重试 {n} 次)"), -M("http.log.header", - "# ligent request/response log — exported {time}, {n} bytes\n", - "# ligent 收发记录 —— 导出 {time}, 共 {n} 字节\n"), -M("http.log.truncated", - "# note: the in-memory limit of {limit} bytes was reached, " - "earlier parts were dropped\n", - "# 注意: 内存上限 {limit} 字节, 较早的部分已被丢弃\n"), -M("http.retry.notice", - "ligent: request failed, retrying in {delay} ms (attempt {n})", - "ligent: 请求失败, {delay} 毫秒后重试 (第 {n} 次)"), -M("http.val.no_location", - "(the server sent no Location)", - "(服务端未提供 Location)"), -M("llm.err.auth_hint", - "\n the key just sent is {masked}. Keys are not interchangeable across " - "providers — OpenRouter's start with sk-or-, DeepSeek's with sk-. Make sure " - "this one belongs to {url}, and check which of -k / $DEEPSEEK_API_KEY / " - "ligent.json is feeding it in.", - "\n 本次发送的密钥是 {masked}. 不同服务商的密钥互不通用——" - "OpenRouter 的以 sk-or- 开头, DeepSeek 的以 sk- 开头. " - "请确认它属于 {url}, 并检查 -k / $DEEPSEEK_API_KEY / ligent.json 里配的是哪一个. "), -M("llm.err.bad_json", - "the response is not valid JSON: {error}", - "响应不是合法 JSON: {error}"), -M("llm.err.no_api_key", - "no API key (set $DEEPSEEK_API_KEY or write one into the config file)", - "缺少 API key (设置 $DEEPSEEK_API_KEY 环境变量或写入配置文件)"), -M("llm.err.no_choices", - "the response has no choices[0].message", - "响应缺少 choices[0].message"), -M("llm.err.overflow", - "the response exceeded the cap ({content} bytes of content / {args} bytes of " - "tool arguments), aborted", - "响应内容超过上限 (正文 {content} 字节 / 工具参数 {args} 字节), 已中止"), -M("llm.err.server", - "server error: {error}", - "服务端错误: {error}"), -M("llm.err.unparsable_response", - "cannot parse the server response (neither streaming nor JSON)", - "无法解析服务端响应 (非流式且非 JSON)"), -M("llm.mask.empty", - "(empty)", - "(空)"), -M("llm.mask.partial", - "{head}... ({n} chars)", - "{head}... ({n} 字符)"), -M("llm.val.no_base_url", - "(base_url not set)", - "(未设置 base_url)"), -M("llm.val.unknown", - "(unknown)", - "未知"), + /* ---- 请求与网络 (http.c / llm.c)---- */ + M("http.err.body_too_big", + "the response body exceeded the {limit}-byte cap, aborted", + "响应体超过 {limit} 字节上限, 已中止"), + M("http.err.interrupted", "interrupted", "已中断"), + M("http.err.log_open", "cannot write {path}: {error}", + "无法写入 {path}: {error}"), + M("http.err.log_write", "error while writing {path}", "写入 {path} 时出错"), + M("http.err.network", "network error: {error}", "网络错误: {error}"), + M("http.err.network_partial", + "network error: {error} (some content was already shown, not retrying)", + "网络错误: {error} (已输出部分内容, 不重试)"), + M("http.err.network_retried", "network error: {error} (after {n} retries)", + "网络错误: {error} (已重试 {n} 次)"), + M("http.err.redirect", + "HTTP {status} redirects to {location} — point base_url at that address " + "instead. (ligent does not follow redirects: a cross-origin hop would " + "drop " + "the Authorization header, and the server would only answer 401)", + "HTTP {status} 重定向到 {location} —— 请直接把 base_url 改成该地址. " + " (ligent 不自动跟随重定向: 跨源跳转会丢掉 Authorization 头, " + "服务端只会返回 401)"), + M("http.err.status_partial", + "HTTP {status} (some content was already shown, not retrying)", + "HTTP {status} (已输出部分内容, 不重试)"), + M("http.err.status_retried", "HTTP {status} (after {n} retries)", + "HTTP {status} (已重试 {n} 次)"), + M("http.log.header", + "# ligent request/response log — exported {time}, {n} bytes\n", + "# ligent 收发记录 —— 导出 {time}, 共 {n} 字节\n"), + M("http.log.truncated", + "# note: the in-memory limit of {limit} bytes was reached, " + "earlier parts were dropped\n", + "# 注意: 内存上限 {limit} 字节, 较早的部分已被丢弃\n"), + M("http.retry.notice", + "ligent: request failed, retrying in {delay} ms (attempt {n})", + "ligent: 请求失败, {delay} 毫秒后重试 (第 {n} 次)"), + M("http.val.no_location", "(the server sent no Location)", + "(服务端未提供 Location)"), + M("llm.err.auth_hint", + "\n the key just sent is {masked}. Keys are not interchangeable " + "across " + "providers — OpenRouter's start with sk-or-, DeepSeek's with sk-. Make " + "sure " + "this one belongs to {url}, and check which of -k / $DEEPSEEK_API_KEY / " + "ligent.json is feeding it in.", + "\n 本次发送的密钥是 {masked}. 不同服务商的密钥互不通用——" + "OpenRouter 的以 sk-or- 开头, DeepSeek 的以 sk- 开头. " + "请确认它属于 {url}, 并检查 -k / $DEEPSEEK_API_KEY / ligent.json " + "里配的是哪一个. "), + M("llm.err.bad_json", "the response is not valid JSON: {error}", + "响应不是合法 JSON: {error}"), + M("llm.err.no_api_key", + "no API key (set $DEEPSEEK_API_KEY or write one into the config file)", + "缺少 API key (设置 $DEEPSEEK_API_KEY 环境变量或写入配置文件)"), + M("llm.err.no_choices", "the response has no choices[0].message", + "响应缺少 choices[0].message"), + M("llm.err.overflow", + "the response exceeded the cap ({content} bytes of content / {args} " + "bytes of " + "tool arguments), aborted", + "响应内容超过上限 (正文 {content} 字节 / 工具参数 {args} 字节), 已中止"), + M("llm.err.server", "server error: {error}", "服务端错误: {error}"), + M("llm.err.unparsable_response", + "cannot parse the server response (neither streaming nor JSON)", + "无法解析服务端响应 (非流式且非 JSON)"), + M("llm.mask.empty", "(empty)", "(空)"), + M("llm.mask.partial", "{head}... ({n} chars)", "{head}... ({n} 字符)"), + M("llm.val.no_base_url", "(base_url not set)", "(未设置 base_url)"), + M("llm.val.unknown", "(unknown)", "未知"), -/* ---- /shell (shell.c)---- */ -M("shell.err.fork", - "fork failed: {error}", - "fork 失败: {error}"), -M("shell.err.no_pty", - "cannot create a pty: {error}", - "无法创建伪终端: {error}"), -M("shell.err.no_slave_name", - "cannot get the pty slave device name", - "无法取得伪终端从设备名"), -M("shell.err.pty_init", - "pty initialization failed: {error}", - "伪终端初始化失败: {error}"), + /* ---- /shell (shell.c)---- */ + M("shell.err.fork", "fork failed: {error}", "fork 失败: {error}"), + M("shell.err.no_pty", "cannot create a pty: {error}", + "无法创建伪终端: {error}"), + M("shell.err.no_slave_name", "cannot get the pty slave device name", + "无法取得伪终端从设备名"), + M("shell.err.pty_init", "pty initialization failed: {error}", + "伪终端初始化失败: {error}"), -/* ---- 能力探测 (capability.c)---- */ -M("capability.note.baseline_failed", - "[capability probe] the baseline request failed (HTTP {status}); " - "drawing no conclusion, everything is treated as supported\n", - "[能力探测] 基线请求未通过 (HTTP {status}), " - "不做任何结论, 按「支持」处理\n"), -M("capability.note.both_ok", - "[capability probe] images + tools are both accepted\n", - "[能力探测] 图片 + tools 都被接受\n"), -M("capability.note.split", - "[capability probe] the combined probe failed; splitting it: " - "vision={vision} tools={tools}\n", - "[能力探测] 合并试探未通过, 拆开结果: 图片={vision} tools={tools}\n"), + /* ---- 能力探测 (capability.c)---- */ + M("capability.note.baseline_failed", + "[capability probe] the baseline request failed (HTTP {status}); " + "drawing no conclusion, everything is treated as supported\n", + "[能力探测] 基线请求未通过 (HTTP {status}), " + "不做任何结论, 按「支持」处理\n"), + M("capability.note.both_ok", + "[capability probe] images + tools are both accepted\n", + "[能力探测] 图片 + tools 都被接受\n"), + M("capability.note.split", + "[capability probe] the combined probe failed; splitting it: " + "vision={vision} tools={tools}\n", + "[能力探测] 合并试探未通过, 拆开结果: 图片={vision} tools={tools}\n"), -/* ---- JSON 解析诊断 (json.c): 给人看的那份 ---- - * - * 这一组是"两条腿": json_parse() 的报错固定中文 (它会被原样回填给模型, - * 属于给模型看的信息); json_parse_ui() 的报错跟随界面语言 (配置、存档、 - * 服务端响应这些给人看的路径). 文案只有这一份, 两种走法各取一列. - * - * 它们是 printf 模板 (%zu/%c/%s…)而不是 {name}, 所以上面那套占位符检查 - * 管不到 —— 转换符写岔了 snprintf 会读错参数类型, test_i18n 里另有一遍 - * 机械比对 (见 printf_specs). */ -M("json.err.at_end", - "line %zu, column %zu: %s (input ends here)", - "第 %zu 行第 %zu 列: %s (输入在此处结束)"), -M("json.err.at_got", - "line %zu, column %zu: %s, got \"%s\"", - "第 %zu 行第 %zu 列: %s, 实际读到 \"%s\""), -M("json.err.array_unterminated", "array is not closed", "数组未闭合"), -M("json.err.bad_number_got", "invalid number '%s'", "非法的数字 '%s'"), -M("json.err.empty_input", "input is empty", "输入为空"), -M("json.err.object_unterminated", "object is not closed", "对象未闭合"), -M("json.err.raw_control", - "unescaped control character 0x%02X in a string", - "字符串中出现未转义的控制字符 0x%02X"), -M("json.err.string_escape_truncated", - "a string escape is truncated at the end", - "字符串转义在末尾被截断"), -M("json.err.string_unterminated", "string is not closed", "字符串未闭合"), -M("json.err.too_deep", - "JSON nested too deeply (over %d levels)", - "JSON 嵌套过深 (超过 %d 层)"), -M("json.err.trailing", - "extra content after the JSON: '%.20s'", - "JSON 末尾存在多余内容: '%.20s'"), -M("json.err.u_bad_hex", - "invalid \\u hex digit '%c'", - "非法的 \\u 十六进制字符 '%c'"), -M("json.err.u_incomplete", "\\u escape is incomplete", "\\u 转义不完整"), -M("json.err.unexpected_char", "unexpected character '%c'", "意外的字符 '%c'"), -M("json.err.unexpected_eof", "unexpected end of input", "意外的输入结束"), -M("json.err.unknown_escape", "unknown escape '\\%c'", "未知的转义字符 '\\%c'"), -M("json.err.want_colon", - "expected ':' after key '%s' in an object", - "对象中键 '%s' 之后期望 ':'"), -M("json.err.want_comma_in_array", - "expected ',' or ']' in an array, got '%c'", - "数组中期望 ',' 或 ']', 实际是 '%c'"), -M("json.err.want_comma_in_object", - "expected ',' or '}' in an object, got '%c'", - "对象中期望 ',' 或 '}', 实际是 '%c'"), -M("json.err.want_false", - "invalid literal (expected false)", - "非法字面量 (期望 false)"), -M("json.err.want_key", - "expected an object key (a string)", - "期望对象的键 (字符串)"), -M("json.err.want_null", - "invalid literal (expected null)", - "非法字面量 (期望 null)"), -M("json.err.want_true", - "invalid literal (expected true)", - "非法字面量 (期望 true)"), + /* ---- JSON 解析诊断 (json.c): 给人看的那份 ---- + * + * 这一组是"两条腿": json_parse() 的报错固定中文 (它会被原样回填给模型, + * 属于给模型看的信息); json_parse_ui() 的报错跟随界面语言 (配置、存档、 + * 服务端响应这些给人看的路径). 文案只有这一份, 两种走法各取一列. + * + * 它们是 printf 模板 (%zu/%c/%s…)而不是 {name}, 所以上面那套占位符检查 + * 管不到 —— 转换符写岔了 snprintf 会读错参数类型, test_i18n 里另有一遍 + * 机械比对 (见 printf_specs). */ + M("json.err.at_end", "line %zu, column %zu: %s (input ends here)", + "第 %zu 行第 %zu 列: %s (输入在此处结束)"), + M("json.err.at_got", "line %zu, column %zu: %s, got \"%s\"", + "第 %zu 行第 %zu 列: %s, 实际读到 \"%s\""), + M("json.err.array_unterminated", "array is not closed", "数组未闭合"), + M("json.err.bad_number_got", "invalid number '%s'", "非法的数字 '%s'"), + M("json.err.empty_input", "input is empty", "输入为空"), + M("json.err.object_unterminated", "object is not closed", "对象未闭合"), + M("json.err.raw_control", "unescaped control character 0x%02X in a string", + "字符串中出现未转义的控制字符 0x%02X"), + M("json.err.string_escape_truncated", + "a string escape is truncated at the end", "字符串转义在末尾被截断"), + M("json.err.string_unterminated", "string is not closed", "字符串未闭合"), + M("json.err.too_deep", "JSON nested too deeply (over %d levels)", + "JSON 嵌套过深 (超过 %d 层)"), + M("json.err.trailing", "extra content after the JSON: '%.20s'", + "JSON 末尾存在多余内容: '%.20s'"), + M("json.err.u_bad_hex", "invalid \\u hex digit '%c'", + "非法的 \\u 十六进制字符 '%c'"), + M("json.err.u_incomplete", "\\u escape is incomplete", "\\u 转义不完整"), + M("json.err.unexpected_char", "unexpected character '%c'", + "意外的字符 '%c'"), + M("json.err.unexpected_eof", "unexpected end of input", "意外的输入结束"), + M("json.err.unknown_escape", "unknown escape '\\%c'", + "未知的转义字符 '\\%c'"), + M("json.err.want_colon", "expected ':' after key '%s' in an object", + "对象中键 '%s' 之后期望 ':'"), + M("json.err.want_comma_in_array", + "expected ',' or ']' in an array, got '%c'", + "数组中期望 ',' 或 ']', 实际是 '%c'"), + M("json.err.want_comma_in_object", + "expected ',' or '}' in an object, got '%c'", + "对象中期望 ',' 或 '}', 实际是 '%c'"), + M("json.err.want_false", "invalid literal (expected false)", + "非法字面量 (期望 false)"), + M("json.err.want_key", "expected an object key (a string)", + "期望对象的键 (字符串)"), + M("json.err.want_null", "invalid literal (expected null)", + "非法字面量 (期望 null)"), + M("json.err.want_true", "invalid literal (expected true)", + "非法字面量 (期望 true)"), -/* ---- 工具参数的呈现 (tools.c 的 tool_lines_label / tool_render_diff): - * 调用摘要、确认提示、执行后的展示共用同一份 ---- */ -M("tool.call.all_lines", "whole file", "全文件"), -M("tool.call.scope_all", "{scope} (all matches)", "{scope} (所有匹配)"), -M("tool.diff.more", "... ({n} more lines)", "... (还有 {n} 行)"), + /* ---- 工具参数的呈现 (tools.c 的 tool_lines_label / tool_render_diff): + * 调用摘要、确认提示、执行后的展示共用同一份 ---- */ + M("tool.call.all_lines", "whole file", "全文件"), + M("tool.call.scope_all", "{scope} (all matches)", "{scope} (所有匹配)"), + M("tool.diff.more", "... ({n} more lines)", "... (还有 {n} 行)"), -/* ---- /tools 的列表 (tools.c): 纯给人看的一行 ---- */ -M("tools.ui.edit_file", - "replace inside a set of lines (omit line to replace everywhere)", - "按行号集合精确替换 (省略 line = 全文件替换)"), -M("tools.ui.header", - "available tools:", - "可用工具: "), -M("tools.ui.hidden", - " ({name} is hidden: the current model does not accept image input)", - " ({name} 已隐藏: 当前模型不接受图片输入)"), -M("tools.ui.list_dir", - "list a directory", - "列出目录"), -M("tools.ui.read_file", - "read a text file (with line numbers; binary files are refused)", - "读取文本文件 (带行号; 二进制文件被拒)"), -M("tools.ui.read_image", - "read a local image for the model (kept as a local backup, inlined as base64 " - "per request)", - "读一张本地图片交给模型看 (本机留备份, 请求时内联 base64)"), -M("tools.ui.run_command", - "run a shell command", - "执行 shell 命令"), -M("tools.ui.write_file", - "create/overwrite a text file (never overwrites a binary)", - "创建/覆盖文本文件 (不覆盖二进制)"), + /* ---- /tools 的列表 (tools.c): 纯给人看的一行 ---- */ + M("tools.ui.edit_file", + "replace inside a set of lines (omit line to replace everywhere)", + "按行号集合精确替换 (省略 line = 全文件替换)"), + M("tools.ui.header", "available tools:", "可用工具: "), + M("tools.ui.hidden", + " ({name} is hidden: the current model does not accept image input)", + " ({name} 已隐藏: 当前模型不接受图片输入)"), + M("tools.ui.list_dir", "list a directory", "列出目录"), + M("tools.ui.read_file", + "read a text file (with line numbers; binary files are refused)", + "读取文本文件 (带行号; 二进制文件被拒)"), + M("tools.ui.read_image", + "read a local image for the model (kept as a local backup, inlined as " + "base64 " + "per request)", + "读一张本地图片交给模型看 (本机留备份, 请求时内联 base64)"), + M("tools.ui.run_command", "run a shell command", "执行 shell 命令"), + M("tools.ui.write_file", + "create/overwrite a text file (never overwrites a binary)", + "创建/覆盖文本文件 (不覆盖二进制)"), -/* ---- 杂项 (util.c)---- */ -M("util.oom", - "ligent: out of memory\n", - "ligent: 内存分配失败\n"), + /* ---- 杂项 (util.c)---- */ + M("util.oom", "ligent: out of memory\n", "ligent: 内存分配失败\n"), -/* ---- meta 模式 (meta.c): 只有给人看的那部分, 工具回执仍是中文 ---- */ -M("meta.already_in", - "already in meta mode.", - "已经在 meta 模式里了. "), -M("meta.desc.delete_rounds", - " rounds {from}..{to} deleted whole\n", - " 轮 {from}..{to} 整轮删除\n"), -M("meta.desc.insert", - " inserting {role} after #{after}: {preview}\n", - " 在 #{after} 之后插入 {role}: {preview}\n"), -M("meta.desc.replace", - " rewriting #{index}: {preview}\n", - " #{index} 的正文改成: {preview}\n"), -M("meta.desc.undo", - " undo the last edit\n", - " 撤销上一次改动\n"), -M("meta.diff.header", - "current history: {n} message(s), {bytes} bytes:\n", - "当前历史 {n} 条消息, {bytes} 字节: \n"), -M("meta.diff.no_text", - "(no text)", - "(无正文)"), -M("meta.diff.structural", - " [structural]", - " 〔结构〕"), -M("meta.enter", - "entered meta mode — you are now editing the conversation history itself, " - "not continuing it.", - "已进入 meta 模式 —— 改的是对话历史本身, 不是继续对话. "), -M("meta.enter.hint", - "say what to change in plain words; /help lists the commands, /exit goes back.", - "用自然语言说要改什么; /help 看命令, /exit 返回. "), -M("meta.exit", - "\nleft meta mode. ", - "\n退出 meta 模式. "), -M("meta.exit.edits", - "made {n} edit(s) this time, ", - "本次共改动 {n} 次, "), -M("meta.exit.lineage", - "this conversation has been edited: the next /export starts a new root, " - "named like _m<3 digits>.", - "这段对话已被编辑过: 下次 /export 会另起一根, 名字形如 <原名>_m<3位>. "), -M("meta.exit.no_edits", - "no edits this time, ", - "本次没有改动, "), -M("meta.exit.usage", - "meta itself used {calls} call(s), {in} in / {out} out tokens.\n", - "meta 自身消耗: {calls} 次调用, {in} 输入 / {out} 输出 token. \n"), -M("meta.help", - "/exit, /quit leave meta mode, back to the main conversation\n" - "/diff show the conversation as it stands (changes nothing)\n" - "/undo undo the last edit\n" - "/help show this help\n" - "\n" - "anything else is an instruction for the meta model, for example:\n" - " drop \"use Python\" from what I said in round 3\n" - " the assistant's conclusion in round 5 is wrong, fix it\n" - " rounds 2 to 4 go in circles, squeeze them into one paragraph\n", + /* ---- meta 模式 (meta.c): 只有给人看的那部分, 工具回执仍是中文 ---- */ + M("meta.already_in", "already in meta mode.", "已经在 meta 模式里了. "), + M("meta.desc.delete_rounds", " rounds {from}..{to} deleted whole\n", + " 轮 {from}..{to} 整轮删除\n"), + M("meta.desc.insert", " inserting {role} after #{after}: {preview}\n", + " 在 #{after} 之后插入 {role}: {preview}\n"), + M("meta.desc.replace", " rewriting #{index}: {preview}\n", + " #{index} 的正文改成: {preview}\n"), + M("meta.desc.undo", " undo the last edit\n", " 撤销上一次改动\n"), + M("meta.diff.header", "current history: {n} message(s), {bytes} bytes:\n", + "当前历史 {n} 条消息, {bytes} 字节: \n"), + M("meta.diff.no_text", "(no text)", "(无正文)"), + M("meta.diff.structural", " [structural]", " 〔结构〕"), + M("meta.enter", + "entered meta mode — you are now editing the conversation history " + "itself, " + "not continuing it.", + "已进入 meta 模式 —— 改的是对话历史本身, 不是继续对话. "), + M("meta.enter.hint", + "say what to change in plain words; /help lists the commands, /exit goes " + "back.", + "用自然语言说要改什么; /help 看命令, /exit 返回. "), + M("meta.exit", "\nleft meta mode. ", "\n退出 meta 模式. "), + M("meta.exit.edits", "made {n} edit(s) this time, ", "本次共改动 {n} 次, "), + M("meta.exit.lineage", + "this conversation has been edited: the next /export starts a new root, " + "named like _m<3 digits>.", + "这段对话已被编辑过: 下次 /export 会另起一根, 名字形如 <原名>_m<3位>. "), + M("meta.exit.no_edits", "no edits this time, ", "本次没有改动, "), + M("meta.exit.usage", + "meta itself used {calls} call(s), {in} in / {out} out tokens.\n", + "meta 自身消耗: {calls} 次调用, {in} 输入 / {out} 输出 token. \n"), + M("meta.help", + "/exit, /quit leave meta mode, back to the main conversation\n" + "/diff show the conversation as it stands (changes nothing)\n" + "/undo undo the last edit\n" + "/help show this help\n" + "\n" + "anything else is an instruction for the meta model, for example:\n" + " drop \"use Python\" from what I said in round 3\n" + " the assistant's conclusion in round 5 is wrong, fix it\n" + " rounds 2 to 4 go in circles, squeeze them into one paragraph\n", - "/exit, /quit 退出 meta 模式, 回到主对话\n" - "/diff 看看现在的对话历史 (不会改任何东西)\n" - "/undo 撤销上一次改动\n" - "/help 显示本帮助\n" - "\n" - "其余输入都是给 meta 模型的指令, 例如: \n" - " 把第 3 轮里我说的 \"用 Python\" 删掉\n" - " 第 5 轮助手那个结论是错的, 改掉\n" - " 第 2 到 4 轮在绕圈子, 压成一段\n"), -M("meta.turn_failed", - "this turn failed; the history is untouched, so you can say it again.\n", - "本轮失败, 历史未受影响, 可以重说一遍. \n"), -M("meta.undo.done", - "undid the last edit.", - "已撤销上一次改动. "), -M("meta.undo.none", - "there is nothing left to undo.", - "没有可撤销的改动了. "), -M("meta.unit.bytes", - "bytes", - "字节"), -M("meta.unknown_cmd", - "meta mode has no {cmd} command (/help lists the ones it has).\n", - "meta 模式里没有 {cmd} 这个命令 (/help 看可用的). \n"), + "/exit, /quit 退出 meta 模式, 回到主对话\n" + "/diff 看看现在的对话历史 (不会改任何东西)\n" + "/undo 撤销上一次改动\n" + "/help 显示本帮助\n" + "\n" + "其余输入都是给 meta 模型的指令, 例如: \n" + " 把第 3 轮里我说的 \"用 Python\" 删掉\n" + " 第 5 轮助手那个结论是错的, 改掉\n" + " 第 2 到 4 轮在绕圈子, 压成一段\n"), + M("meta.turn_failed", + "this turn failed; the history is untouched, so you can say it again.\n", + "本轮失败, 历史未受影响, 可以重说一遍. \n"), + M("meta.undo.done", "undid the last edit.", "已撤销上一次改动. "), + M("meta.undo.none", "there is nothing left to undo.", + "没有可撤销的改动了. "), + M("meta.unit.bytes", "bytes", "字节"), + M("meta.unknown_cmd", + "meta mode has no {cmd} command (/help lists the ones it has).\n", + "meta 模式里没有 {cmd} 这个命令 (/help 看可用的). \n"), }; #undef M #define NMSGS (sizeof MSGS / sizeof MSGS[0]) -/* tf() 用一圈静态缓冲轮流返回字符串 (这样调用点不必自己 malloc). +/* tf() 用一圈静态缓冲轮流返回字符串 (这样调用点不必自己 malloc). * 轮转的层数就是**嵌套调用**的上限: `tf(a, "x", tf(b, ...), NULL)` 里的内层 * 返回值必须在外层拼完之前一直有效, 嵌套超过 TF_BUFS 层就会踩坏自己. */ #define TF_BUFS 8 #define TF_BUFLEN 4096 -static int g_lang = LIGENT_LANG_EN; +static int g_lang = LIGENT_LANG_EN; static char g_buf[TF_BUFS][TF_BUFLEN]; -static int g_buf_i; +static int g_buf_i; -static const LigentMsg *find(const char *key) -{ +static const LigentMsg *find(const char *key) { for (size_t i = 0; i < NMSGS; i++) - if (strcmp(MSGS[i].key, key) == 0) return &MSGS[i]; + if (strcmp(MSGS[i].key, key) == 0) + return &MSGS[i]; return NULL; } -static char *next_buf(void) -{ +static char *next_buf(void) { char *b = g_buf[g_buf_i]; g_buf_i = (g_buf_i + 1) % TF_BUFS; return b; } -void i18n_set_lang(int lang) -{ +void i18n_set_lang(int lang) { g_lang = (lang == LIGENT_LANG_ZH) ? LIGENT_LANG_ZH : LIGENT_LANG_EN; } int i18n_lang(void) { return g_lang; } -const char *i18n_lang_name(void) -{ +const char *i18n_lang_name(void) { return g_lang == LIGENT_LANG_ZH ? "zh" : "en"; } -int i18n_parse_lang(const char *name) -{ - if (!name || !*name) return -1; +int i18n_parse_lang(const char *name) { + if (!name || !*name) + return -1; char lang[16]; size_t k = 0; /* 语言码取到第一个 '_' / '-' / '.' / '@' 之前: "zh_CN.UTF-8" → "zh" */ for (const char *p = name; *p && k < sizeof lang - 1; p++) { - if (*p == '_' || *p == '-' || *p == '.' || *p == '@') break; + if (*p == '_' || *p == '-' || *p == '.' || *p == '@') + break; lang[k++] = (char)tolower((unsigned char)*p); } lang[k] = '\0'; - if (strcmp(lang, "zh") == 0) return LIGENT_LANG_ZH; - if (strcmp(lang, "en") == 0) return LIGENT_LANG_EN; - if (strcmp(lang, "c") == 0 || strcmp(lang, "posix") == 0) return LIGENT_LANG_EN; + if (strcmp(lang, "zh") == 0) + return LIGENT_LANG_ZH; + if (strcmp(lang, "en") == 0) + return LIGENT_LANG_EN; + if (strcmp(lang, "c") == 0 || strcmp(lang, "posix") == 0) + return LIGENT_LANG_EN; return -1; } -int i18n_lang_from_env(void) -{ - static const char *vars[] = { "LIGENT_LANG", "LC_ALL", "LC_MESSAGES", "LANG" }; +int i18n_lang_from_env(void) { + static const char *vars[] = {"LIGENT_LANG", "LC_ALL", "LC_MESSAGES", + "LANG"}; for (size_t i = 0; i < sizeof vars / sizeof vars[0]; i++) { const char *v = getenv(vars[i]); - if (!v || !*v) continue; + if (!v || !*v) + continue; int lang = i18n_parse_lang(v); - if (lang >= 0) return lang; - /* 认不出来的语言码 (fr_FR、ja_JP…): 我们只有两种翻译, + if (lang >= 0) + return lang; + /* 认不出来的语言码 (fr_FR、ja_JP…): 我们只有两种翻译, * 与其猜, 不如按"没设"处理, 最后落到英文. */ return LIGENT_LANG_EN; } return LIGENT_LANG_EN; } -void i18n_init(int lang) -{ +void i18n_init(int lang) { i18n_set_lang(lang < 0 ? i18n_lang_from_env() : lang); } -const LigentMsg *i18n_table(size_t *count) -{ - if (count) *count = NMSGS; +const LigentMsg *i18n_table(size_t *count) { + if (count) + *count = NMSGS; return MSGS; } -const char *i18n_lookup(int lang, const char *key) -{ +const char *i18n_lookup(int lang, const char *key) { const LigentMsg *m = find(key); - if (!m) return NULL; + if (!m) + return NULL; return lang == LIGENT_LANG_ZH ? m->zh : m->en; } -const char *i18n_num(long long v) -{ +const char *i18n_num(long long v) { char *b = next_buf(); snprintf(b, TF_BUFLEN, "%lld", v); return b; } -const char *t(const char *key) -{ +const char *t(const char *key) { const LigentMsg *m = find(key); - if (!m) { /* 漏掉的键要看得见, 别静默成空白 */ + if (!m) { /* 漏掉的键要看得见, 别静默成空白 */ char *b = next_buf(); snprintf(b, TF_BUFLEN, "[?%s?]", key ? key : ""); return b; } const char *s = (g_lang == LIGENT_LANG_ZH) ? m->zh : m->en; - if (!s || !*s) s = (g_lang == LIGENT_LANG_ZH) ? m->en : m->zh; - if (!s || !*s) s = m->zh; + if (!s || !*s) + s = (g_lang == LIGENT_LANG_ZH) ? m->en : m->zh; + if (!s || !*s) + s = m->zh; return s; } -const char *tf(const char *key, ...) -{ +const char *tf(const char *key, ...) { const char *fmt = t(key); const char *names[16], *vals[16]; int n = 0; @@ -1281,7 +1189,8 @@ const char *tf(const char *key, ...) va_start(ap, key); while (n < (int)(sizeof names / sizeof names[0])) { const char *nm = va_arg(ap, const char *); - if (!nm) break; + if (!nm) + break; names[n] = nm; vals[n] = va_arg(ap, const char *); n++; @@ -1290,16 +1199,19 @@ const char *tf(const char *key, ...) char *out = next_buf(); size_t o = 0; - for (const char *p = fmt; *p; ) { + for (const char *p = fmt; *p;) { if (*p == '{') { const char *e = strchr(p + 1, '}'); if (e) { size_t want = (size_t)(e - (p + 1)); for (int i = 0; i < n; i++) { - if (strlen(names[i]) != want) continue; - if (strncmp(names[i], p + 1, want) != 0) continue; + if (strlen(names[i]) != want) + continue; + if (strncmp(names[i], p + 1, want) != 0) + continue; size_t l = strlen(vals[i]); - if (o + l >= TF_BUFLEN) l = TF_BUFLEN - 1 - o; + if (o + l >= TF_BUFLEN) + l = TF_BUFLEN - 1 - o; memcpy(out + o, vals[i], l); o += l; p = e + 1; @@ -1316,9 +1228,10 @@ const char *tf(const char *key, ...) continue; } } - if (o + 1 >= TF_BUFLEN) break; + if (o + 1 >= TF_BUFLEN) + break; out[o++] = *p++; -next: ; + next:; } out[o] = '\0'; return out; diff --git a/src/i18n.h b/src/i18n.h index e617e76..29f6e21 100644 --- a/src/i18n.h +++ b/src/i18n.h @@ -1,11 +1,11 @@ /* i18n.h - 界面文案的多语言表 * - * 只覆盖**给人看**的文案: 帮助、错误、诊断、确认提示、spinner 标签. - * 送给模型的那些 (系统提示词、工具描述、工具回执)**不在这里**, - * 它们跟界面语言是两件事, 见 README「界面语言」一节. + * 只覆盖**给人看**的文案: 帮助、错误、诊断、确认提示、spinner 标签. + * 送给模型的那些 (系统提示词、工具描述、工具回执)**不在这里**, + * 它们跟界面语言是两件事, 见 README「界面语言」一节. * * 键用点分 ID (`err.unknown_option`)而不是中文原文: 这样两种语言的 - * 条目能机械对齐 —— 少一条、占位符对不上, 测试都会报出来. + * 条目能机械对齐 —— 少一条、占位符对不上, 测试都会报出来. */ #ifndef LIGENT_I18N_H #define LIGENT_I18N_H @@ -22,16 +22,16 @@ typedef struct { /* 设置 / 查询当前语言 */ void i18n_set_lang(int lang); -int i18n_lang(void); +int i18n_lang(void); -/* 解析语言名: "zh"、"zh_CN"、"zh-Hans.UTF-8" → ZH; - * "en"、"C"、"POSIX" → EN; +/* 解析语言名: "zh"、"zh_CN"、"zh-Hans.UTF-8" → ZH; + * "en"、"C"、"POSIX" → EN; * "auto"、空、认不出来的 → -1 (调用方自行决定回退). */ -int i18n_parse_lang(const char *name); +int i18n_parse_lang(const char *name); -/* 按环境探测: LIGENT_LANG > LC_ALL > LC_MESSAGES > LANG. +/* 按环境探测: LIGENT_LANG > LC_ALL > LC_MESSAGES > LANG. * 都没有或都认不出来 → EN (POSIX 惯例: C locale 是英文). */ -int i18n_lang_from_env(void); +int i18n_lang_from_env(void); /* 初始化. lang 为 -1 时按环境探测. */ void i18n_init(int lang); @@ -39,16 +39,16 @@ void i18n_init(int lang); /* "en" / "zh" (静态字面量) */ const char *i18n_lang_name(void); -/* 查表取当前语言的文案. 未知键返回 "[?key?]" —— 显眼, +/* 查表取当前语言的文案. 未知键返回 "[?key?]" —— 显眼, * 以免漏掉的条目静默变成空白. */ const char *t(const char *key); -/* 带占位符: 模板里写 {name}, 调用时按名字成对给值, 最后传 NULL. +/* 带占位符: 模板里写 {name}, 调用时按名字成对给值, 最后传 NULL. * tf("err.unknown_option", "option", arg, NULL) * 按名字而非位置匹配, 所以译文可以调整语序. */ const char *tf(const char *key, ...); -/* 整数转字符串 (静态轮转缓冲), 专供 tf 的参数位置用: +/* 整数转字符串 (静态轮转缓冲), 专供 tf 的参数位置用: * tf("k", "n", i18n_num(count), NULL) * 省得每个调用点都开一个 char buf[32]. */ const char *i18n_num(long long v); diff --git a/src/image.c b/src/image.c index 852c8bd..03fd073 100644 --- a/src/image.c +++ b/src/image.c @@ -7,51 +7,53 @@ #include #include #include +#include #include #include -#include /* 识别四种服务端支持的格式. 都靠文件头那几个字节 —— 服务端也明说了 - * "格式由文件实际内容判断, 而非文件名或声明的 MIME 类型", 我们得跟它一致, + * "格式由文件实际内容判断, 而非文件名或声明的 MIME 类型", 我们得跟它一致, * 否则会把一个改名成 .png 的 JPEG 当成 PNG 报出去. */ -int image_sniff(const unsigned char *d, size_t n, - const char **ext, const char **mime) -{ - if (!d) return -1; +int image_sniff(const unsigned char *d, size_t n, const char **ext, + const char **mime) { + if (!d) + return -1; /* PNG: 89 50 4E 47 0D 0A 1A 0A */ if (n >= 8 && d[0] == 0x89 && d[1] == 'P' && d[2] == 'N' && d[3] == 'G' && d[4] == 0x0D && d[5] == 0x0A && d[6] == 0x1A && d[7] == 0x0A) { - *ext = "png"; *mime = "image/png"; + *ext = "png"; + *mime = "image/png"; return 0; } /* JPEG: FF D8 FF */ if (n >= 3 && d[0] == 0xFF && d[1] == 0xD8 && d[2] == 0xFF) { - *ext = "jpg"; *mime = "image/jpeg"; + *ext = "jpg"; + *mime = "image/jpeg"; return 0; } /* GIF: "GIF87a" / "GIF89a" */ if (n >= 6 && memcmp(d, "GIF8", 4) == 0 && (d[4] == '7' || d[4] == '9') && d[5] == 'a') { - *ext = "gif"; *mime = "image/gif"; + *ext = "gif"; + *mime = "image/gif"; return 0; } /* WebP: "RIFF" + 4 字节长度 + "WEBP" */ if (n >= 12 && memcmp(d, "RIFF", 4) == 0 && memcmp(d + 8, "WEBP", 4) == 0) { - *ext = "webp"; *mime = "image/webp"; + *ext = "webp"; + *mime = "image/webp"; return 0; } - return -1; /* 不认识的格式, 交给上层把话说清楚 */ + return -1; /* 不认识的格式, 交给上层把话说清楚 */ } -char *image_backup_dir(const char *bindir) -{ +char *image_backup_dir(const char *bindir) { return xasprintf("%s/ligent.archive/images", bindir && *bindir ? bindir : "."); } -char *image_backup_path(const char *bindir, const char *name) -{ +char *image_backup_path(const char *bindir, const char *name) { char *dir = image_backup_dir(bindir); char *p = xasprintf("%s/%s", dir, name); free(dir); @@ -61,10 +63,12 @@ char *image_backup_path(const char *bindir, const char *name) /* 名字里的随机后缀由 util.c 的 random_alnum 提供 (和存档名共用一份实现) */ char *image_backup_save(const char *bindir, const unsigned char *data, size_t n, - const char *ext) -{ + const char *ext) { char *dir = image_backup_dir(bindir); - if (mkdir_p(dir) != 0) { free(dir); return NULL; } + if (mkdir_p(dir) != 0) { + free(dir); + return NULL; + } /* <上传 UNIX 时间戳>_<4 位随机>.. 名字里带时间戳是为了能一眼看出 * 这张图是什么时候传的, 跟 file_id 的有效期对得上. */ @@ -76,12 +80,20 @@ char *image_backup_save(const char *bindir, const unsigned char *data, size_t n, if (access(path, F_OK) != 0) { FILE *f = fopen(path, "wb"); - if (!f) { free(path); free(name); free(dir); return NULL; } + if (!f) { + free(path); + free(name); + free(dir); + return NULL; + } size_t w = fwrite(data, 1, n, f); int ok = (w == n) && (fclose(f) == 0); free(path); free(dir); - if (!ok) { free(name); return NULL; } + if (!ok) { + free(name); + return NULL; + } return name; } free(path); @@ -95,23 +107,21 @@ char *image_backup_save(const char *bindir, const unsigned char *data, size_t n, /* 请求前展开: ligent_image -> image_url + base64 */ /* ------------------------------------------------------------------ */ -/* 内部块类型. 带 ligent_ 前缀是为了万一漏了展开、直接发出去时, +/* 内部块类型. 带 ligent_ 前缀是为了万一漏了展开、直接发出去时, * 服务端报的错能看出是我们的问题. */ #define PART_IMAGE "ligent_image" -#define PART_NOTE "ligent_note" +#define PART_NOTE "ligent_note" /* 块是不是某种内部类型 */ -static int part_is(const JsonValue *part, const char *type) -{ +static int part_is(const JsonValue *part, const char *type) { const char *t = json_get_string(part, "type"); return t && strcmp(t, type) == 0; } /* 造一个降级说明块. text 会被同时用在存储形态和发送形态里 —— - * 存储用 ligent_note (opens_round 不认它, 附件消息不会因此变成"一轮"), + * 存储用 ligent_note (opens_round 不认它, 附件消息不会因此变成"一轮"), * 发送时再换成协议认的 text. */ -static JsonValue *make_note(const char *text) -{ +static JsonValue *make_note(const char *text) { JsonValue *n = json_new_object(); json_object_set_string(n, "type", PART_NOTE); json_object_set_string(n, "text", text); @@ -119,8 +129,7 @@ static JsonValue *make_note(const char *text) } /* 存储形态 -> 发送形态. 只处理内部块, 别的原样克隆. */ -static JsonValue *to_wire_part(const JsonValue *part) -{ +static JsonValue *to_wire_part(const JsonValue *part) { if (part_is(part, PART_NOTE)) { JsonValue *w = json_new_object(); json_object_set_string(w, "type", "text"); @@ -132,8 +141,8 @@ static JsonValue *to_wire_part(const JsonValue *part) } /* 读一份备份并编码成 data URL. 失败返回 NULL, *why 给出给人看的理由. */ -static char *backup_to_data_url(const char *name, size_t *budget, const char **why) -{ +static char *backup_to_data_url(const char *name, size_t *budget, + const char **why) { const char *bindir = image_bindir(); char *path = image_backup_path(bindir, name); size_t len = 0; @@ -168,17 +177,21 @@ static char *backup_to_data_url(const char *name, size_t *budget, const char **w char *b64 = base64_encode((const unsigned char *)raw, len); free(raw); - if (!b64) { *why = "编码失败"; return NULL; } + if (!b64) { + *why = "编码失败"; + return NULL; + } char *url = xasprintf("data:%s;base64,%s", mime, b64); free(b64); return url; } -JsonValue *image_expand_for_request(JsonValue *messages, int *degraded) -{ - if (degraded) *degraded = 0; - if (!messages || messages->type != JSON_ARRAY) return NULL; +JsonValue *image_expand_for_request(JsonValue *messages, int *degraded) { + if (degraded) + *degraded = 0; + if (!messages || messages->type != JSON_ARRAY) + return NULL; size_t budget = IMAGE_MAX_TOTAL_B64; JsonValue *wire = json_new_array(); @@ -220,7 +233,7 @@ JsonValue *image_expand_for_request(JsonValue *messages, int *degraded) continue; } - /* 展开不出来: 把历史里这条坏引用就地换成文字说明, + /* 展开不出来: 把历史里这条坏引用就地换成文字说明, * 否则它每轮都会再失败一次. */ char *text = xasprintf("[图片 %s 无法交给模型: %s]", name && *name ? name : "?", why); @@ -228,8 +241,9 @@ JsonValue *image_expand_for_request(JsonValue *messages, int *degraded) json_array_push(wc, to_wire_part(note)); json_array_remove(c, k); - json_array_insert(c, k, note); /* 存储形态就地降级 */ - if (degraded) (*degraded)++; + json_array_insert(c, k, note); /* 存储形态就地降级 */ + if (degraded) + (*degraded)++; free(text); } @@ -242,25 +256,30 @@ JsonValue *image_expand_for_request(JsonValue *messages, int *degraded) } char *image_backup_from_path(const char *path, size_t *out_len, - const char **out_mime, char **out_msg) -{ - if (out_len) *out_len = 0; - if (out_mime) *out_mime = NULL; - if (out_msg) *out_msg = NULL; + const char **out_mime, char **out_msg) { + if (out_len) + *out_len = 0; + if (out_mime) + *out_mime = NULL; + if (out_msg) + *out_msg = NULL; size_t len = 0; char *raw = read_entire_file(path, &len); if (!raw) { - if (out_msg) *out_msg = xasprintf("读不到图片 %s: %s", path, strerror(errno)); + if (out_msg) + *out_msg = xasprintf("读不到图片 %s: %s", path, strerror(errno)); return NULL; } const char *ext = NULL, *mime = NULL; if (image_sniff((const unsigned char *)raw, len, &ext, &mime) != 0) { free(raw); - if (out_msg) *out_msg = xasprintf( - "不认识的图片格式: %s. 支持 PNG / JPEG / GIF / WebP" - " (按文件内容判断, 不看扩展名)", path); + if (out_msg) + *out_msg = + xasprintf("不认识的图片格式: %s. 支持 PNG / JPEG / GIF / WebP" + " (按文件内容判断, 不看扩展名)", + path); return NULL; } @@ -268,26 +287,32 @@ char *image_backup_from_path(const char *path, size_t *out_len, size_t b64len = base64_encoded_len(len); if (b64len > IMAGE_MAX_B64) { free(raw); - if (out_msg) *out_msg = xasprintf( - "图片太大, 没法交给模型看: %s 有 %.1f MiB, base64 内联后 %.1f MiB, " - "超过内联上限 %.0f MiB. " - " (图片会留在对话历史里, 所以这里当场拒掉, 而不是发出去等报错. " - "如果要看大图, 请先缩小尺寸或压缩后再读. )", - path, (double)len / 1048576.0, (double)b64len / 1048576.0, - (double)IMAGE_MAX_B64 / 1048576.0); + if (out_msg) + *out_msg = xasprintf( + "图片太大, 没法交给模型看: %s 有 %.1f MiB, base64 内联后 %.1f " + "MiB, " + "超过内联上限 %.0f MiB. " + " (图片会留在对话历史里, 所以这里当场拒掉, 而不是发出去等报错. " + "如果要看大图, 请先缩小尺寸或压缩后再读. )", + path, (double)len / 1048576.0, (double)b64len / 1048576.0, + (double)IMAGE_MAX_B64 / 1048576.0); return NULL; } const char *bindir = image_bindir(); - char *backup = image_backup_save(bindir, (const unsigned char *)raw, len, ext); + char *backup = + image_backup_save(bindir, (const unsigned char *)raw, len, ext); free(raw); if (!backup) { - if (out_msg) *out_msg = xasprintf("无法在 %s 下写备份", bindir); + if (out_msg) + *out_msg = xasprintf("无法在 %s 下写备份", bindir); return NULL; } - if (out_len) *out_len = len; - if (out_mime) *out_mime = mime; + if (out_len) + *out_len = len; + if (out_mime) + *out_mime = mime; return backup; } @@ -295,37 +320,41 @@ char *image_backup_from_path(const char *path, size_t *out_len, /* 服务端 4xx 之后的降级 */ /* ------------------------------------------------------------------ */ -int image_has_refs(const JsonValue *messages) -{ - if (!messages || messages->type != JSON_ARRAY) return 0; +int image_has_refs(const JsonValue *messages) { + if (!messages || messages->type != JSON_ARRAY) + return 0; for (size_t i = 0; i < json_array_size(messages); i++) { JsonValue *c = json_object_get(json_array_get(messages, i), "content"); - if (!c || c->type != JSON_ARRAY) continue; + if (!c || c->type != JSON_ARRAY) + continue; for (size_t k = 0; k < json_array_size(c); k++) - if (part_is(json_array_get(c, k), PART_IMAGE)) return 1; + if (part_is(json_array_get(c, k), PART_IMAGE)) + return 1; } return 0; } -int image_degrade_all(JsonValue *messages) -{ - if (!messages || messages->type != JSON_ARRAY) return 0; +int image_degrade_all(JsonValue *messages) { + if (!messages || messages->type != JSON_ARRAY) + return 0; int n = 0; for (size_t i = 0; i < json_array_size(messages); i++) { JsonValue *c = json_object_get(json_array_get(messages, i), "content"); - if (!c || c->type != JSON_ARRAY) continue; + if (!c || c->type != JSON_ARRAY) + continue; for (size_t k = 0; k < json_array_size(c); k++) { JsonValue *part = json_array_get(c, k); - if (!part_is(part, PART_IMAGE)) continue; + if (!part_is(part, PART_IMAGE)) + continue; /* 给模型看的话要能直接照做, 所以不把服务端的原始报错抄进来 —— - * 那通常是给人看的英文错误码. 原始原因由 agent.c 打到 stderr, + * 那通常是给人看的英文错误码. 原始原因由 agent.c 打到 stderr, * 想查的人看得到, 上下文里不脏. */ const char *name = json_get_string(part, "backup"); - char *text = xasprintf( - "[图片 %s 不符合 API 所需的格式标准, 无法上传, 请规范化图片后重试]", - name && *name ? name : "?"); + char *text = xasprintf("[图片 %s 不符合 API 所需的格式标准, " + "无法上传, 请规范化图片后重试]", + name && *name ? name : "?"); JsonValue *note = make_note(text); free(text); @@ -343,8 +372,7 @@ int image_degrade_all(JsonValue *messages) static char *g_bindir; -void image_set_bindir(const char *dir) -{ +void image_set_bindir(const char *dir) { free(g_bindir); g_bindir = (dir && *dir) ? xstrdup(dir) : xstrdup("."); } @@ -359,16 +387,17 @@ const char *image_bindir(void) { return g_bindir ? g_bindir : "."; } static char *g_pending[IMAGE_PENDING_MAX]; static size_t g_pending_n; -void image_pending_add(const char *backup) -{ - if (!backup || !*backup) return; - if (g_pending_n >= IMAGE_PENDING_MAX) return; /* 一次读 8 张够多了 */ +void image_pending_add(const char *backup) { + if (!backup || !*backup) + return; + if (g_pending_n >= IMAGE_PENDING_MAX) + return; /* 一次读 8 张够多了 */ g_pending[g_pending_n++] = xstrdup(backup); } -JsonValue *image_pending_take(void) -{ - if (g_pending_n == 0) return NULL; +JsonValue *image_pending_take(void) { + if (g_pending_n == 0) + return NULL; /* 存的是对备份的引用, 不是图片字节 —— 字节等构造请求时再去读 */ JsonValue *arr = json_new_array(); diff --git a/src/image.h b/src/image.h index fdcb9a0..e2b8823 100644 --- a/src/image.h +++ b/src/image.h @@ -1,20 +1,21 @@ /* image.h - 图片: 格式嗅探、本地备份、请求前展开 * - * 协议层面图片只有两条路能走: 给一个外部 URL, 或者把 base64 内联进请求. - * ligent 不替用户去网上抓图, 所以 read_image 只收本地文件, 产出 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,...."}} + * 发给服务端 + * {"type":"image_url","image_url":{"url":"data:image/png;base64,...."}} * - * 也就是说历史里只有对本地备份的引用 (几十字节), 备份文件才是字节的真身, - * 真正构造请求时才读出来编码. 备份目录是 /ligent.archive/images/. + * 也就是说历史里只有对本地备份的引用 (几十字节), 备份文件才是字节的真身, + * 真正构造请求时才读出来编码. 备份目录是 /ligent.archive/images/. * - * 两个内部块类型都带 ligent_ 前缀, 就是为了万一哪天漏了展开、直接把它发出去, - * 服务端报的错能一眼看出是我们的问题, 而不是一个莫名其妙的 400: + * 两个内部块类型都带 ligent_ 前缀, 就是为了万一哪天漏了展开、直接把它发出去, + * 服务端报的错能一眼看出是我们的问题, 而不是一个莫名其妙的 400: * ligent_image 引用一张本地备份 * ligent_note 展开失败后的文字说明 (引用读不到了、或体积超了) */ @@ -28,31 +29,32 @@ /* ---- 内联图片的上限 ---- * * 取各家文档里最严的一档 (ligent 同时对着 DeepSeek 和 MiMo 跑, 两边都发了 - * 文档里的数字): + * 文档里的数字): * 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) +#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) +#define IMAGE_MAX_TOTAL_B64 (44u * 1024 * 1024) -/* 支持的四种格式. 认出来返回 0, 并回填扩展名与 MIME (都是字面量, 不用释放). +/* 支持的四种格式. 认出来返回 0, 并回填扩展名与 MIME (都是字面量, 不用释放). * 按文件实际内容判断, 不看文件名 —— 服务端也是这么判的. */ -int image_sniff(const unsigned char *data, size_t n, - const char **ext, const char **mime); +int image_sniff(const unsigned char *data, size_t n, const char **ext, + const char **mime); /* 备份目录: /ligent.archive/images (malloc) */ char *image_backup_dir(const char *bindir); -/* 备份文件所在目录下的完整路径 (malloc). name 是 image_backup_save 返回的名字 */ +/* 备份文件所在目录下的完整路径 (malloc). name 是 image_backup_save 返回的名字 + */ char *image_backup_path(const char *bindir, const char *name); -/* 把一段图片字节存成备份, 返回文件名 (malloc, 形如 "1700000000_a1b2.png"). +/* 把一段图片字节存成备份, 返回文件名 (malloc, 形如 "1700000000_a1b2.png"). * 失败返回 NULL. dir 不存在会自动建. */ char *image_backup_save(const char *bindir, const unsigned char *data, size_t n, const char *ext); @@ -62,36 +64,36 @@ 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). + * (字面量)、*out_msg 置 NULL. + * 失败返回 NULL, *out_msg 是一句能直接给模型/用户看的中文说明 (malloc). * - * 体积超限的图**不备份** —— 一张发不出去的图, 留着备份没有意义. + * 体积超限的图**不备份** —— 一张发不出去的图, 留着备份没有意义. * read_image 和 /include 都走这一条, 以免两处的判断慢慢长歪. */ char *image_backup_from_path(const char *path, size_t *out_len, const char **out_mime, char **out_msg); /* ---- 待发附件 ---- - * 协议规定图片只能出现在 user 消息里 (system/assistant 带图直接 400), - * 而 read_image 是工具、它的结果只能是字符串. 所以工具把备份名挂在这里, - * agent_run 推完 tool 消息之后取走, 补一条 user 消息带上它. + * 协议规定图片只能出现在 user 消息里 (system/assistant 带图直接 400), + * 而 read_image 是工具、它的结果只能是字符串. 所以工具把备份名挂在这里, + * agent_run 推完 tool 消息之后取走, 补一条 user 消息带上它. * * 取走即清空. 返回 content 块数组 (调用者释放), 没有则返回 NULL. */ -void image_pending_add(const char *backup); +void image_pending_add(const char *backup); JsonValue *image_pending_take(void); /* ---- 请求前展开 ---- * * 把历史里的 ligent_image 块换成 image_url + base64, 返回一份**请求副本** * (调用者 json_free). 原 messages 不变, 除非有引用展开不出来 —— 那种情况 - * 会**就地**把它降级成 ligent_note, 把坏引用从历史里清掉. + * 会**就地**把它降级成 ligent_note, 把坏引用从历史里清掉. * - * 为什么就地降级: 那条引用躺在历史里, 不修的话每一轮都会重新失败一次, - * 用户除了 /reset 没有别的路. 这和上下文超限要丢老轮次是同一个道理. + * 为什么就地降级: 那条引用躺在历史里, 不修的话每一轮都会重新失败一次, + * 用户除了 /reset 没有别的路. 这和上下文超限要丢老轮次是同一个道理. * - * *degraded (可传 NULL)回填就地降级的处数, 调用方据此提示用户. + * *degraded (可传 NULL)回填就地降级的处数, 调用方据此提示用户. * 返回 NULL 表示分配失败或 messages 不是数组. */ JsonValue *image_expand_for_request(JsonValue *messages, int *degraded); @@ -99,8 +101,8 @@ JsonValue *image_expand_for_request(JsonValue *messages, int *degraded); * 判断用 —— 没有图片就别瞎猜. */ int image_has_refs(const JsonValue *messages); -/* 把历史里所有图片引用就地降级成文字说明 ("不符合 API 所需的格式标准, - * 无法上传, 请规范化图片后重试"), 返回处理处数. +/* 把历史里所有图片引用就地降级成文字说明 ("不符合 API 所需的格式标准, + * 无法上传, 请规范化图片后重试"), 返回处理处数. * 不给理由参数: 给模型的话要能直接照做, 服务端的原始英文报错由调用方打到 * stderr, 不往上下文里塞. */ int image_degrade_all(JsonValue *messages); diff --git a/src/json.c b/src/json.c index 228a455..e9520e0 100644 --- a/src/json.c +++ b/src/json.c @@ -3,11 +3,11 @@ #include "i18n.h" #include "util.h" +#include +#include #include #include #include -#include -#include #define JSON_MAX_DEPTH 200 @@ -15,42 +15,41 @@ /* 构造 */ /* ------------------------------------------------------------------ */ -static JsonValue *jv_new(JsonType t) -{ +static JsonValue *jv_new(JsonType t) { JsonValue *v = xcalloc(1, sizeof(JsonValue)); v->type = t; return v; } -JsonValue *json_new_null(void) { return jv_new(JSON_NULL); } -JsonValue *json_new_array(void) { return jv_new(JSON_ARRAY); } +JsonValue *json_new_null(void) { return jv_new(JSON_NULL); } +JsonValue *json_new_array(void) { return jv_new(JSON_ARRAY); } JsonValue *json_new_object(void) { return jv_new(JSON_OBJECT); } -JsonValue *json_new_bool(int b) -{ +JsonValue *json_new_bool(int b) { JsonValue *v = jv_new(JSON_BOOL); v->u.boolean = b ? 1 : 0; return v; } -JsonValue *json_new_number(double n) -{ +JsonValue *json_new_number(double n) { JsonValue *v = jv_new(JSON_NUMBER); v->u.number = n; return v; } -JsonValue *json_new_string(const char *s) -{ +JsonValue *json_new_string(const char *s) { JsonValue *v = jv_new(JSON_STRING); v->u.string = xstrdup(s ? s : ""); return v; } -void json_array_insert(JsonValue *arr, size_t i, JsonValue *item) -{ - if (!arr || arr->type != JSON_ARRAY) { json_free(item); return; } - if (i > arr->u.array.count) i = arr->u.array.count; +void json_array_insert(JsonValue *arr, size_t i, JsonValue *item) { + if (!arr || arr->type != JSON_ARRAY) { + json_free(item); + return; + } + if (i > arr->u.array.count) + i = arr->u.array.count; if (arr->u.array.count == arr->u.array.cap) { arr->u.array.cap = arr->u.array.cap ? arr->u.array.cap * 2 : 8; arr->u.array.items = xrealloc(arr->u.array.items, @@ -62,15 +61,16 @@ void json_array_insert(JsonValue *arr, size_t i, JsonValue *item) arr->u.array.count++; } -void json_array_steal(JsonValue *dst, JsonValue *src) -{ - if (!dst || dst->type != JSON_ARRAY || !src || src->type != JSON_ARRAY) return; - if (dst == src) return; +void json_array_steal(JsonValue *dst, JsonValue *src) { + if (!dst || dst->type != JSON_ARRAY || !src || src->type != JSON_ARRAY) + return; + if (dst == src) + return; for (size_t i = 0; i < src->u.array.count; i++) { if (dst->u.array.count == dst->u.array.cap) { dst->u.array.cap = dst->u.array.cap ? dst->u.array.cap * 2 : 8; - dst->u.array.items = xrealloc(dst->u.array.items, - dst->u.array.cap * sizeof(JsonValue *)); + dst->u.array.items = xrealloc( + dst->u.array.items, dst->u.array.cap * sizeof(JsonValue *)); } dst->u.array.items[dst->u.array.count++] = src->u.array.items[i]; } @@ -80,18 +80,20 @@ void json_array_steal(JsonValue *dst, JsonValue *src) src->u.array.cap = 0; } -void json_array_remove(JsonValue *arr, size_t i) -{ - if (!arr || arr->type != JSON_ARRAY || i >= arr->u.array.count) return; +void json_array_remove(JsonValue *arr, size_t i) { + if (!arr || arr->type != JSON_ARRAY || i >= arr->u.array.count) + return; json_free(arr->u.array.items[i]); memmove(arr->u.array.items + i, arr->u.array.items + i + 1, (arr->u.array.count - i - 1) * sizeof(JsonValue *)); arr->u.array.count--; } -void json_array_push(JsonValue *arr, JsonValue *item) -{ - if (!arr || arr->type != JSON_ARRAY) { json_free(item); return; } +void json_array_push(JsonValue *arr, JsonValue *item) { + if (!arr || arr->type != JSON_ARRAY) { + json_free(item); + return; + } if (arr->u.array.count == arr->u.array.cap) { arr->u.array.cap = arr->u.array.cap ? arr->u.array.cap * 2 : 8; arr->u.array.items = xrealloc(arr->u.array.items, @@ -100,9 +102,9 @@ void json_array_push(JsonValue *arr, JsonValue *item) arr->u.array.items[arr->u.array.count++] = item; } -void json_object_remove(JsonValue *obj, const char *key) -{ - if (!obj || obj->type != JSON_OBJECT) return; +void json_object_remove(JsonValue *obj, const char *key) { + if (!obj || obj->type != JSON_OBJECT) + return; for (size_t i = 0; i < obj->u.object.count; i++) { if (strcmp(obj->u.object.members[i].key, key) == 0) { free(obj->u.object.members[i].key); @@ -115,9 +117,11 @@ void json_object_remove(JsonValue *obj, const char *key) } } -void json_object_set(JsonValue *obj, const char *key, JsonValue *value) -{ - if (!obj || obj->type != JSON_OBJECT) { json_free(value); return; } +void json_object_set(JsonValue *obj, const char *key, JsonValue *value) { + if (!obj || obj->type != JSON_OBJECT) { + json_free(value); + return; + } for (size_t i = 0; i < obj->u.object.count; i++) { if (strcmp(obj->u.object.members[i].key, key) == 0) { json_free(obj->u.object.members[i].value); @@ -127,33 +131,38 @@ void json_object_set(JsonValue *obj, const char *key, JsonValue *value) } if (obj->u.object.count == obj->u.object.cap) { obj->u.object.cap = obj->u.object.cap ? obj->u.object.cap * 2 : 8; - obj->u.object.members = xrealloc(obj->u.object.members, - obj->u.object.cap * sizeof(JsonMember)); + obj->u.object.members = xrealloc( + obj->u.object.members, obj->u.object.cap * sizeof(JsonMember)); } obj->u.object.members[obj->u.object.count].key = xstrdup(key); obj->u.object.members[obj->u.object.count].value = value; obj->u.object.count++; } -void json_object_set_string(JsonValue *obj, const char *key, const char *value) -{ json_object_set(obj, key, value ? json_new_string(value) : json_new_null()); } +void json_object_set_string(JsonValue *obj, const char *key, + const char *value) { + json_object_set(obj, key, value ? json_new_string(value) : json_new_null()); +} -void json_object_set_number(JsonValue *obj, const char *key, double value) -{ json_object_set(obj, key, json_new_number(value)); } +void json_object_set_number(JsonValue *obj, const char *key, double value) { + json_object_set(obj, key, json_new_number(value)); +} -void json_object_set_bool(JsonValue *obj, const char *key, int value) -{ json_object_set(obj, key, json_new_bool(value)); } +void json_object_set_bool(JsonValue *obj, const char *key, int value) { + json_object_set(obj, key, json_new_bool(value)); +} -void json_object_set_null(JsonValue *obj, const char *key) -{ json_object_set(obj, key, json_new_null()); } +void json_object_set_null(JsonValue *obj, const char *key) { + json_object_set(obj, key, json_new_null()); +} /* ------------------------------------------------------------------ */ /* 释放 */ /* ------------------------------------------------------------------ */ -void json_free(JsonValue *v) -{ - if (!v) return; +void json_free(JsonValue *v) { + if (!v) + return; switch (v->type) { case JSON_STRING: free(v->u.string); @@ -176,14 +185,18 @@ void json_free(JsonValue *v) free(v); } -JsonValue *json_clone(const JsonValue *v) -{ - if (!v) return NULL; +JsonValue *json_clone(const JsonValue *v) { + if (!v) + return NULL; switch (v->type) { - case JSON_NULL: return json_new_null(); - case JSON_BOOL: return json_new_bool(v->u.boolean); - case JSON_NUMBER: return json_new_number(v->u.number); - case JSON_STRING: return json_new_string(v->u.string); + case JSON_NULL: + return json_new_null(); + case JSON_BOOL: + return json_new_bool(v->u.boolean); + case JSON_NUMBER: + return json_new_number(v->u.number); + case JSON_STRING: + return json_new_string(v->u.string); case JSON_ARRAY: { JsonValue *a = json_new_array(); for (size_t i = 0; i < v->u.array.count; i++) @@ -205,94 +218,102 @@ JsonValue *json_clone(const JsonValue *v) /* 读取 */ /* ------------------------------------------------------------------ */ -JsonValue *json_object_get(const JsonValue *obj, const char *key) -{ - if (!obj || obj->type != JSON_OBJECT || !key) return NULL; +JsonValue *json_object_get(const JsonValue *obj, const char *key) { + if (!obj || obj->type != JSON_OBJECT || !key) + return NULL; for (size_t i = 0; i < obj->u.object.count; i++) if (strcmp(obj->u.object.members[i].key, key) == 0) return obj->u.object.members[i].value; return NULL; } -const char *json_string_value(const JsonValue *v) -{ - if (!v || v->type != JSON_STRING) return NULL; +const char *json_string_value(const JsonValue *v) { + if (!v || v->type != JSON_STRING) + return NULL; return v->u.string; } -double json_number_value(const JsonValue *v, double def) -{ - if (!v || v->type != JSON_NUMBER) return def; +double json_number_value(const JsonValue *v, double def) { + if (!v || v->type != JSON_NUMBER) + return def; return v->u.number; } -int json_bool_value(const JsonValue *v, int def) -{ - if (!v) return def; - if (v->type == JSON_BOOL) return v->u.boolean; - if (v->type == JSON_NUMBER) return v->u.number != 0; +int json_bool_value(const JsonValue *v, int def) { + if (!v) + return def; + if (v->type == JSON_BOOL) + return v->u.boolean; + if (v->type == JSON_NUMBER) + return v->u.number != 0; return def; } -size_t json_array_size(const JsonValue *v) -{ - if (!v || v->type != JSON_ARRAY) return 0; +size_t json_array_size(const JsonValue *v) { + if (!v || v->type != JSON_ARRAY) + return 0; return v->u.array.count; } -JsonValue *json_array_get(const JsonValue *v, size_t i) -{ - if (!v || v->type != JSON_ARRAY || i >= v->u.array.count) return NULL; +JsonValue *json_array_get(const JsonValue *v, size_t i) { + if (!v || v->type != JSON_ARRAY || i >= v->u.array.count) + return NULL; return v->u.array.items[i]; } -const char *json_get_string(const JsonValue *obj, const char *key) -{ return json_string_value(json_object_get(obj, key)); } +const char *json_get_string(const JsonValue *obj, const char *key) { + return json_string_value(json_object_get(obj, key)); +} -double json_get_number(const JsonValue *obj, const char *key, double def) -{ return json_number_value(json_object_get(obj, key), def); } +double json_get_number(const JsonValue *obj, const char *key, double def) { + return json_number_value(json_object_get(obj, key), def); +} -int json_get_bool(const JsonValue *obj, const char *key, int def) -{ return json_bool_value(json_object_get(obj, key), def); } +int json_get_bool(const JsonValue *obj, const char *key, int def) { + return json_bool_value(json_object_get(obj, key), def); +} /* ------------------------------------------------------------------ */ /* 解析器 */ /* ------------------------------------------------------------------ */ typedef struct { - const char *start; /* 原文起点, 用来算出行列号 */ + const char *start; /* 原文起点, 用来算出行列号 */ const char *p; const char *end; - char *err; - size_t errlen; - int depth; - int ui; /* 1 = 报错跟随界面语言 (见 jfmt) */ + char *err; + size_t errlen; + int depth; + int ui; /* 1 = 报错跟随界面语言 (见 jfmt) */ } Parser; -/* 取一条诊断的模板. ui 为 0 时固定用中文那份 —— 解析器的"母语"是中文, +/* 取一条诊断的模板. ui 为 0 时固定用中文那份 —— 解析器的"母语"是中文, * 而它同时出现在**给模型看的**工具参数报错里 (agent.c 把 json_parse 的 - * 结论原样回填给模型), 那些不该随界面语言变, 见 README「界面语言」. + * 结论原样回填给模型), 那些不该随界面语言变, 见 README「界面语言」. * * 表里的 json.* 用的是 printf 转换 (%s/%c/%zu…)而不是 {name} —— 这里是 - * 格式化转发, 不是占位符替换. 两种语言的转换符序列必须逐字一致, + * 格式化转发, 不是占位符替换. 两种语言的转换符序列必须逐字一致, * tests/test_i18n.c 会机械地比一遍. */ -static const char *jfmt(int ui, const char *key) -{ - if (ui) return t(key); +static const char *jfmt(int ui, const char *key) { + if (ui) + return t(key); const char *zh = i18n_lookup(LIGENT_LANG_ZH, key); return zh ? zh : key; } -/* 只记录第一个错误; 带上行列号和出错处的原文片段, +/* 只记录第一个错误; 带上行列号和出错处的原文片段, * 否则用户只知道"语法错了"却不知道错在哪 —— 手写 JSON 时这几乎是必然的. */ -LIGENT_PRINTF(2, 3) static void perr(Parser *ps, const char *fmt, ...) -{ - if (!ps->err || ps->errlen == 0 || ps->err[0] != '\0') return; +LIGENT_PRINTF(2, 3) static void perr(Parser *ps, const char *fmt, ...) { + if (!ps->err || ps->errlen == 0 || ps->err[0] != '\0') + return; size_t line = 1, col = 1; for (const char *q = ps->start; q < ps->p && q < ps->end; q++) { - if (*q == '\n') { line++; col = 1; } - else col++; + if (*q == '\n') { + line++; + col = 1; + } else + col++; } char detail[192]; @@ -306,82 +327,121 @@ LIGENT_PRINTF(2, 3) static void perr(Parser *ps, const char *fmt, ...) size_t n = 0; for (const char *q = ps->p; q < ps->end && n < 24; q++) { unsigned char c = (unsigned char)*q; - if (c == '\n') break; + if (c == '\n') + break; excerpt[n++] = c < 0x20 || c == 0x7f ? '?' : (char)c; } excerpt[n] = '\0'; /* 模板里是同序的 %zu/%zu/%s (+%s)—— 两种语言必须完全一致 */ if (n > 0) - snprintf(ps->err, ps->errlen, jfmt(ps->ui, "json.err.at_got"), - line, col, detail, excerpt); + snprintf(ps->err, ps->errlen, jfmt(ps->ui, "json.err.at_got"), line, + col, detail, excerpt); else - snprintf(ps->err, ps->errlen, jfmt(ps->ui, "json.err.at_end"), - line, col, detail); + snprintf(ps->err, ps->errlen, jfmt(ps->ui, "json.err.at_end"), line, + col, detail); - /* 最后统一净化: 错误信息里不允许出现原始控制字符. - * 像 "意外的字符 '%c'" 这种格式化会直接把控制字符带进消息本体, + /* 最后统一净化: 错误信息里不允许出现原始控制字符. + * 像 "意外的字符 '%c'" 这种格式化会直接把控制字符带进消息本体, * 原样打到终端上会把用户的屏幕搞乱. */ for (char *q = ps->err; *q; q++) { unsigned char c = (unsigned char)*q; - if (c < 0x20 || c == 0x7f) *q = '?'; + if (c < 0x20 || c == 0x7f) + *q = '?'; } } -static void skip_ws(Parser *ps) -{ +static void skip_ws(Parser *ps) { while (ps->p < ps->end) { char c = *ps->p; - if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ps->p++; - else break; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') + ps->p++; + else + break; } } static JsonValue *parse_value(Parser *ps); /* 解析一个 JSON 字符串内容 (ps->p 指向起始双引号之后), 写入 sb */ -static int parse_string_raw(Parser *ps, StrBuf *sb, char *errb, size_t errlen) -{ - (void)errb; (void)errlen; +static int parse_string_raw(Parser *ps, StrBuf *sb, char *errb, size_t errlen) { + (void)errb; + (void)errlen; while (ps->p < ps->end) { unsigned char c = (unsigned char)*ps->p++; - if (c == '"') return 0; + if (c == '"') + return 0; if (c == '\\') { - if (ps->p >= ps->end) { perr(ps, "%s", jfmt(ps->ui, "json.err.string_escape_truncated")); return -1; } + if (ps->p >= ps->end) { + perr(ps, "%s", + jfmt(ps->ui, "json.err.string_escape_truncated")); + return -1; + } char e = *ps->p++; switch (e) { - case '"': sb_putc(sb, '"'); break; - case '\\': sb_putc(sb, '\\'); break; - case '/': sb_putc(sb, '/'); break; - case 'b': sb_putc(sb, '\b'); break; - case 'f': sb_putc(sb, '\f'); break; - case 'n': sb_putc(sb, '\n'); break; - case 'r': sb_putc(sb, '\r'); break; - case 't': sb_putc(sb, '\t'); break; + case '"': + sb_putc(sb, '"'); + break; + case '\\': + sb_putc(sb, '\\'); + break; + case '/': + sb_putc(sb, '/'); + break; + case 'b': + sb_putc(sb, '\b'); + break; + case 'f': + sb_putc(sb, '\f'); + break; + case 'n': + sb_putc(sb, '\n'); + break; + case 'r': + sb_putc(sb, '\r'); + break; + case 't': + sb_putc(sb, '\t'); + break; case 'u': { unsigned int cp = 0; - if (ps->end - ps->p < 4) { perr(ps, "%s", jfmt(ps->ui, "json.err.u_incomplete")); return -1; } + if (ps->end - ps->p < 4) { + perr(ps, "%s", jfmt(ps->ui, "json.err.u_incomplete")); + return -1; + } for (int i = 0; i < 4; i++) { char h = ps->p[i]; cp <<= 4; - if (h >= '0' && h <= '9') cp |= (unsigned)(h - '0'); - else if (h >= 'a' && h <= 'f') cp |= (unsigned)(h - 'a' + 10); - else if (h >= 'A' && h <= 'F') cp |= (unsigned)(h - 'A' + 10); - else { perr(ps, jfmt(ps->ui, "json.err.u_bad_hex"), h); return -1; } + if (h >= '0' && h <= '9') + cp |= (unsigned)(h - '0'); + else if (h >= 'a' && h <= 'f') + cp |= (unsigned)(h - 'a' + 10); + else if (h >= 'A' && h <= 'F') + cp |= (unsigned)(h - 'A' + 10); + else { + perr(ps, jfmt(ps->ui, "json.err.u_bad_hex"), h); + return -1; + } } ps->p += 4; /* 代理对 */ - if (cp >= 0xD800 && cp <= 0xDBFF && - ps->end - ps->p >= 6 && ps->p[0] == '\\' && ps->p[1] == 'u') { + if (cp >= 0xD800 && cp <= 0xDBFF && ps->end - ps->p >= 6 && + ps->p[0] == '\\' && ps->p[1] == 'u') { unsigned int lo = 0; int ok = 1; for (int i = 0; i < 4; i++) { char h = ps->p[2 + i]; lo <<= 4; - if (h >= '0' && h <= '9') lo |= (unsigned)(h - '0'); - else if (h >= 'a' && h <= 'f') lo |= (unsigned)(h - 'a' + 10); - else if (h >= 'A' && h <= 'F') lo |= (unsigned)(h - 'A' + 10); - else { ok = 0; break; } + if (h >= '0' && h <= '9') + lo |= (unsigned)(h - '0'); + else if (h >= 'a' && h <= 'f') + lo |= (unsigned)(h - 'a' + 10); + else if (h >= 'A' && h <= 'F') + lo |= (unsigned)(h - 'A' + 10); + else { + ok = 0; + break; + } } if (ok && lo >= 0xDC00 && lo <= 0xDFFF) { cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00); @@ -421,8 +481,7 @@ static int parse_string_raw(Parser *ps, StrBuf *sb, char *errb, size_t errlen) return -1; } -static JsonValue *parse_string(Parser *ps) -{ +static JsonValue *parse_string(Parser *ps) { StrBuf sb; sb_init(&sb); if (parse_string_raw(ps, &sb, NULL, 0) != 0) { @@ -434,34 +493,43 @@ 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("1e") 会老老实实返回 1, 那个残缺的指数就被静默吞掉了. * 配置文件和存档都是手写的, 把写错的数字当成另一个数继续用下去, 用户只会在 - * 很久之后从一个完全不相干的现象里发现它. 所以语法必须自己严查一遍: + * 很久之后从一个完全不相干的现象里发现它. 所以语法必须自己严查一遍: * strtod 只负责最后那步文本 -> double 的转换. */ -static JsonValue *parse_number(Parser *ps) -{ +static JsonValue *parse_number(Parser *ps) { const char *start = ps->p; - if (ps->p < ps->end && *ps->p == '-') ps->p++; + if (ps->p < ps->end && *ps->p == '-') + ps->p++; - if (ps->p >= ps->end || !isdigit((unsigned char)*ps->p)) goto bad; - if (*ps->p == '0') ps->p++; /* '0' 后面不能再跟数字 (01 非法) */ - else while (ps->p < ps->end && isdigit((unsigned char)*ps->p)) ps->p++; + if (ps->p >= ps->end || !isdigit((unsigned char)*ps->p)) + goto bad; + if (*ps->p == '0') + ps->p++; /* '0' 后面不能再跟数字 (01 非法) */ + else + while (ps->p < ps->end && isdigit((unsigned char)*ps->p)) + ps->p++; if (ps->p < ps->end && *ps->p == '.') { ps->p++; - if (ps->p >= ps->end || !isdigit((unsigned char)*ps->p)) goto bad; - while (ps->p < ps->end && isdigit((unsigned char)*ps->p)) ps->p++; + if (ps->p >= ps->end || !isdigit((unsigned char)*ps->p)) + goto bad; + while (ps->p < ps->end && isdigit((unsigned char)*ps->p)) + ps->p++; } if (ps->p < ps->end && (*ps->p == 'e' || *ps->p == 'E')) { ps->p++; - if (ps->p < ps->end && (*ps->p == '+' || *ps->p == '-')) ps->p++; - if (ps->p >= ps->end || !isdigit((unsigned char)*ps->p)) goto bad; - while (ps->p < ps->end && isdigit((unsigned char)*ps->p)) ps->p++; + if (ps->p < ps->end && (*ps->p == '+' || *ps->p == '-')) + ps->p++; + if (ps->p >= ps->end || !isdigit((unsigned char)*ps->p)) + goto bad; + while (ps->p < ps->end && isdigit((unsigned char)*ps->p)) + ps->p++; } { @@ -470,12 +538,13 @@ 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)); @@ -487,8 +556,7 @@ bad: } /* 解析 ps->p 指向的 "key" */ -static char *parse_key(Parser *ps) -{ +static char *parse_key(Parser *ps) { if (ps->p >= ps->end || *ps->p != '"') { perr(ps, "%s", jfmt(ps->ui, "json.err.want_key")); return NULL; @@ -496,41 +564,64 @@ static char *parse_key(Parser *ps) ps->p++; StrBuf sb; sb_init(&sb); - if (parse_string_raw(ps, &sb, NULL, 0) != 0) { sb_free(&sb); return NULL; } + if (parse_string_raw(ps, &sb, NULL, 0) != 0) { + sb_free(&sb); + return NULL; + } return sb_detach(&sb); } -static JsonValue *parse_array(Parser *ps) -{ +static JsonValue *parse_array(Parser *ps) { JsonValue *arr = json_new_array(); ps->p++; /* '[' */ skip_ws(ps); - if (ps->p < ps->end && *ps->p == ']') { ps->p++; return arr; } + if (ps->p < ps->end && *ps->p == ']') { + ps->p++; + return arr; + } for (;;) { skip_ws(ps); JsonValue *item = parse_value(ps); - if (!item) { json_free(arr); return NULL; } + if (!item) { + json_free(arr); + return NULL; + } json_array_push(arr, item); skip_ws(ps); - if (ps->p >= ps->end) { perr(ps, "%s", jfmt(ps->ui, "json.err.array_unterminated")); json_free(arr); return NULL; } - if (*ps->p == ',') { ps->p++; continue; } - if (*ps->p == ']') { ps->p++; return arr; } + if (ps->p >= ps->end) { + perr(ps, "%s", jfmt(ps->ui, "json.err.array_unterminated")); + json_free(arr); + return NULL; + } + if (*ps->p == ',') { + ps->p++; + continue; + } + if (*ps->p == ']') { + ps->p++; + return arr; + } perr(ps, jfmt(ps->ui, "json.err.want_comma_in_array"), *ps->p); json_free(arr); return NULL; } } -static JsonValue *parse_object(Parser *ps) -{ +static JsonValue *parse_object(Parser *ps) { JsonValue *obj = json_new_object(); ps->p++; /* '{' */ skip_ws(ps); - if (ps->p < ps->end && *ps->p == '}') { ps->p++; return obj; } + if (ps->p < ps->end && *ps->p == '}') { + ps->p++; + return obj; + } for (;;) { skip_ws(ps); char *key = parse_key(ps); - if (!key) { json_free(obj); return NULL; } + if (!key) { + json_free(obj); + return NULL; + } skip_ws(ps); if (ps->p >= ps->end || *ps->p != ':') { perr(ps, jfmt(ps->ui, "json.err.want_colon"), key); @@ -541,21 +632,34 @@ static JsonValue *parse_object(Parser *ps) ps->p++; skip_ws(ps); JsonValue *val = parse_value(ps); - if (!val) { free(key); json_free(obj); return NULL; } + if (!val) { + free(key); + json_free(obj); + return NULL; + } json_object_set(obj, key, val); free(key); skip_ws(ps); - if (ps->p >= ps->end) { perr(ps, "%s", jfmt(ps->ui, "json.err.object_unterminated")); json_free(obj); return NULL; } - if (*ps->p == ',') { ps->p++; continue; } - if (*ps->p == '}') { ps->p++; return obj; } + if (ps->p >= ps->end) { + perr(ps, "%s", jfmt(ps->ui, "json.err.object_unterminated")); + json_free(obj); + return NULL; + } + if (*ps->p == ',') { + ps->p++; + continue; + } + if (*ps->p == '}') { + ps->p++; + return obj; + } perr(ps, jfmt(ps->ui, "json.err.want_comma_in_object"), *ps->p); json_free(obj); return NULL; } } -static JsonValue *parse_value(Parser *ps) -{ +static JsonValue *parse_value(Parser *ps) { if (++ps->depth > JSON_MAX_DEPTH) { perr(ps, jfmt(ps->ui, "json.err.too_deep"), JSON_MAX_DEPTH); ps->depth--; @@ -568,26 +672,36 @@ static JsonValue *parse_value(Parser *ps) goto done; } switch (*ps->p) { - case '{': result = parse_object(ps); break; - case '[': result = parse_array(ps); break; - case '"': ps->p++; result = parse_string(ps); break; + case '{': + result = parse_object(ps); + break; + case '[': + result = parse_array(ps); + break; + case '"': + ps->p++; + result = parse_string(ps); + break; case 't': if (ps->end - ps->p >= 4 && memcmp(ps->p, "true", 4) == 0) { ps->p += 4; result = json_new_bool(1); - } else perr(ps, "%s", jfmt(ps->ui, "json.err.want_true")); + } else + perr(ps, "%s", jfmt(ps->ui, "json.err.want_true")); break; case 'f': if (ps->end - ps->p >= 5 && memcmp(ps->p, "false", 5) == 0) { ps->p += 5; result = json_new_bool(0); - } else perr(ps, "%s", jfmt(ps->ui, "json.err.want_false")); + } else + perr(ps, "%s", jfmt(ps->ui, "json.err.want_false")); break; case 'n': if (ps->end - ps->p >= 4 && memcmp(ps->p, "null", 4) == 0) { ps->p += 4; result = json_new_null(); - } else perr(ps, "%s", jfmt(ps->ui, "json.err.want_null")); + } else + perr(ps, "%s", jfmt(ps->ui, "json.err.want_null")); break; default: if (*ps->p == '-' || isdigit((unsigned char)*ps->p)) @@ -601,11 +715,12 @@ 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) -{ - if (errbuf && errlen) errbuf[0] = '\0'; +static JsonValue *parse_impl(const char *text, char *errbuf, size_t errlen, + int ui) { + if (errbuf && errlen) + errbuf[0] = '\0'; if (!text) { if (errbuf && errlen) snprintf(errbuf, errlen, "%s", jfmt(ui, "json.err.empty_input")); @@ -621,7 +736,8 @@ static JsonValue *parse_impl(const char *text, char *errbuf, size_t errlen, int ps.ui = ui; JsonValue *v = parse_value(&ps); - if (!v) return NULL; + if (!v) + return NULL; skip_ws(&ps); if (ps.p != ps.end) { perr(&ps, jfmt(ps.ui, "json.err.trailing"), ps.p); @@ -631,13 +747,11 @@ static JsonValue *parse_impl(const char *text, char *errbuf, size_t errlen, int return v; } -JsonValue *json_parse(const char *text, char *errbuf, size_t errlen) -{ +JsonValue *json_parse(const char *text, char *errbuf, size_t errlen) { return parse_impl(text, errbuf, errlen, 0); } -JsonValue *json_parse_ui(const char *text, char *errbuf, size_t errlen) -{ +JsonValue *json_parse_ui(const char *text, char *errbuf, size_t errlen) { return parse_impl(text, errbuf, errlen, 1); } @@ -645,49 +759,80 @@ JsonValue *json_parse_ui(const char *text, char *errbuf, size_t errlen) /* 序列化 */ /* ------------------------------------------------------------------ */ -static void dump_string(StrBuf *sb, const char *s) -{ +static void dump_string(StrBuf *sb, const char *s) { size_t len = strlen(s), i = 0; sb_putc(sb, '"'); while (i < len) { unsigned char c = (unsigned char)s[i]; const char *esc = NULL; switch (c) { - case '"': esc = "\\\""; break; - case '\\': esc = "\\\\"; break; - case '\b': esc = "\\b"; break; - case '\f': esc = "\\f"; break; - case '\n': esc = "\\n"; break; - case '\r': esc = "\\r"; break; - case '\t': esc = "\\t"; break; - default: break; + case '"': + esc = "\\\""; + break; + case '\\': + esc = "\\\\"; + break; + case '\b': + esc = "\\b"; + break; + case '\f': + esc = "\\f"; + break; + case '\n': + esc = "\\n"; + break; + case '\r': + esc = "\\r"; + break; + case '\t': + esc = "\\t"; + break; + default: + break; + } + if (esc) { + sb_puts(sb, esc); + i++; + continue; + } + if (c < 0x20) { + sb_printf(sb, "\\u%04x", c); + i++; + continue; + } + if (c < 0x80) { + sb_putc(sb, (char)c); + i++; + continue; } - if (esc) { sb_puts(sb, esc); i++; continue; } - if (c < 0x20) { sb_printf(sb, "\\u%04x", c); i++; continue; } - if (c < 0x80) { sb_putc(sb, (char)c); i++; continue; } - /* 非 ASCII 字节: 只有构成**合法** UTF-8 序列时才能原样进 JSON 字符串. + /* 非 ASCII 字节: 只有构成**合法** UTF-8 序列时才能原样进 JSON 字符串. * 以前这里是无条件 sb_putc, 于是工具输出里一个坏字节 (例如某条命令在 * 非 UTF-8 locale 下打出来的路径, 或者被截断的多字节字符)就会让**整个 * 请求体**不是合法 UTF-8, 服务端直接回 * 400 ... invalid unicode code point * 更糟的是那段坏数据已经进了对话历史, 之后每次请求都会失败 —— - * 除非 /reset 把整轮对话丢掉. + * 除非 /reset 把整轮对话丢掉. * * 兜在出口而不是只在入口清理, 是因为出口才是能保证"发出去的永远合法" * 的那一层: 对话内容还可能来自存档、粘贴、/include 进来的文件…… * 这里统一把非法字节换成 U+FFFD, 既让请求成立, 也让模型看到"这里有个 * 无法解码的字符", 而不是收到一段乱码. */ size_t n = utf8_seq_len(s + i, len - i); - if (n == 0) { sb_puts(sb, "\\ufffd"); i++; continue; } + if (n == 0) { + sb_puts(sb, "\\ufffd"); + i++; + continue; + } /* U+FFFD 一律写成 \ufffd 转义. 它是上面替换坏字节时用的字符, 如果这里 - * 让 EF BF BD 原样输出, dump→parse→dump 就不稳定了: 第一次是 \ufffd, - * 解析回来变成真的 U+FFFD, 第二次就原样吐三个字节. + * 让 EF BF BD 原样输出, dump→parse→dump 就不稳定了: 第一次是 \ufffd, + * 解析回来变成真的 U+FFFD, 第二次就原样吐三个字节. * fuzz 的"dump 幂等"这条不变量就是被这个当场抓住的 —— 幂等是有价值的 * 性质, 它保证存档反复读写在字节层面也是收敛的. 给这一个字符定死一个 * 规范形式, 代价是真正合法的 U+FFFD 会多占 3 个字节, 可以忽略. */ if (n == 3 && (unsigned char)s[i] == 0xEF && - (unsigned char)s[i + 1] == 0xBF && (unsigned char)s[i + 2] == 0xBD) { + (unsigned char)s[i + 1] == 0xBF && + (unsigned char)s[i + 2] == 0xBD) { sb_puts(sb, "\\ufffd"); i += 3; continue; @@ -698,13 +843,15 @@ static void dump_string(StrBuf *sb, const char *s) sb_putc(sb, '"'); } -static void dump_number(StrBuf *sb, double n) -{ - if (isnan(n) || isinf(n)) { sb_puts(sb, "null"); return; } +static void dump_number(StrBuf *sb, double n) { + if (isnan(n) || isinf(n)) { + sb_puts(sb, "null"); + return; + } char buf[64]; /* 先判范围, 再转换. double -> long long 在值超出范围时是 UB, 而 && 的 * 求值顺序保不住这个转换: 原来它写在条件最左边, 服务端回一个 1e300 - * 就会踩到 (clang 的 UBSan 报 float-cast-overflow; gcc 不插桩这条转换, + * 就会踩到 (clang 的 UBSan 报 float-cast-overflow; gcc 不插桩这条转换, * 所以 make SAN=1 抓不到). */ if (fabs(n) < 1e15 && n == (double)(long long)n) snprintf(buf, sizeof buf, "%lld", (long long)n); @@ -713,75 +860,97 @@ static void dump_number(StrBuf *sb, double n) sb_puts(sb, buf); } -static void dump_value(StrBuf *sb, const JsonValue *v, int indent, int pretty, int level) -{ - if (!v) { sb_puts(sb, "null"); return; } +static void dump_value(StrBuf *sb, const JsonValue *v, int indent, int pretty, + int level) { + if (!v) { + sb_puts(sb, "null"); + return; + } switch (v->type) { - case JSON_NULL: sb_puts(sb, "null"); break; - case JSON_BOOL: sb_puts(sb, v->u.boolean ? "true" : "false"); break; - case JSON_NUMBER: dump_number(sb, v->u.number); break; - case JSON_STRING: dump_string(sb, v->u.string); break; + case JSON_NULL: + sb_puts(sb, "null"); + break; + case JSON_BOOL: + sb_puts(sb, v->u.boolean ? "true" : "false"); + break; + case JSON_NUMBER: + dump_number(sb, v->u.number); + break; + case JSON_STRING: + dump_string(sb, v->u.string); + break; case JSON_ARRAY: - if (v->u.array.count == 0) { sb_puts(sb, "[]"); break; } + if (v->u.array.count == 0) { + sb_puts(sb, "[]"); + break; + } sb_putc(sb, '['); for (size_t i = 0; i < v->u.array.count; i++) { - if (i) sb_putc(sb, ','); + if (i) + sb_putc(sb, ','); if (pretty) { sb_putc(sb, '\n'); - for (int k = 0; k < (level + 1) * indent; k++) sb_putc(sb, ' '); + for (int k = 0; k < (level + 1) * indent; k++) + sb_putc(sb, ' '); } dump_value(sb, v->u.array.items[i], indent, pretty, level + 1); } if (pretty) { sb_putc(sb, '\n'); - for (int k = 0; k < level * indent; k++) sb_putc(sb, ' '); + for (int k = 0; k < level * indent; k++) + sb_putc(sb, ' '); } sb_putc(sb, ']'); break; case JSON_OBJECT: - if (v->u.object.count == 0) { sb_puts(sb, "{}"); break; } + if (v->u.object.count == 0) { + sb_puts(sb, "{}"); + break; + } sb_putc(sb, '{'); for (size_t i = 0; i < v->u.object.count; i++) { - if (i) sb_putc(sb, ','); + if (i) + sb_putc(sb, ','); if (pretty) { sb_putc(sb, '\n'); - for (int k = 0; k < (level + 1) * indent; k++) sb_putc(sb, ' '); + for (int k = 0; k < (level + 1) * indent; k++) + sb_putc(sb, ' '); } dump_string(sb, v->u.object.members[i].key); sb_putc(sb, ':'); - if (pretty) sb_putc(sb, ' '); - dump_value(sb, v->u.object.members[i].value, indent, pretty, level + 1); + if (pretty) + sb_putc(sb, ' '); + dump_value(sb, v->u.object.members[i].value, indent, pretty, + level + 1); } if (pretty) { sb_putc(sb, '\n'); - for (int k = 0; k < level * indent; k++) sb_putc(sb, ' '); + for (int k = 0; k < level * indent; k++) + sb_putc(sb, ' '); } sb_putc(sb, '}'); break; } } -char *json_dump(const JsonValue *v) -{ +char *json_dump(const JsonValue *v) { StrBuf sb; sb_init(&sb); dump_value(&sb, v, 0, 0, 0); return sb_detach(&sb); } -char *json_dump_pretty(const JsonValue *v) -{ +char *json_dump_pretty(const JsonValue *v) { StrBuf sb; sb_init(&sb); dump_value(&sb, v, 2, 1, 0); return sb_detach(&sb); } -char *json_quote_string(const char *s) -{ +char *json_quote_string(const char *s) { StrBuf sb; sb_init(&sb); dump_string(&sb, s ? s : ""); diff --git a/src/json.h b/src/json.h index bf70228..0b07023 100644 --- a/src/json.h +++ b/src/json.h @@ -16,25 +16,25 @@ typedef enum { typedef struct JsonValue JsonValue; typedef struct { - char *key; + char *key; JsonValue *value; } JsonMember; struct JsonValue { JsonType type; union { - int boolean; + int boolean; double number; - char *string; + char *string; struct { JsonValue **items; - size_t count; - size_t cap; + size_t count; + size_t cap; } array; struct { JsonMember *members; - size_t count; - size_t cap; + size_t count; + size_t cap; } object; } u; }; @@ -42,8 +42,8 @@ struct JsonValue { /* ---- 解析: 失败返回 NULL 并写入 errbuf ---- */ JsonValue *json_parse(const char *text, char *errbuf, size_t errlen); -/* 同上, 但报错跟随界面语言 (给人看的配置/存档/响应报错用这个). - * json_parse 的报错固定中文: 那些会原样回填给模型 (工具参数解析), +/* 同上, 但报错跟随界面语言 (给人看的配置/存档/响应报错用这个). + * json_parse 的报错固定中文: 那些会原样回填给模型 (工具参数解析), * 属于"给模型看的信息", 不随界面语言变. */ JsonValue *json_parse_ui(const char *text, char *errbuf, size_t errlen); @@ -51,16 +51,16 @@ JsonValue *json_parse_ui(const char *text, char *errbuf, size_t errlen); void json_free(JsonValue *v); /* ---- 读取 ---- */ -JsonValue *json_object_get(const JsonValue *obj, const char *key); -const char *json_string_value(const JsonValue *v); /* 非字符串返回 NULL */ -double json_number_value(const JsonValue *v, double def); -int json_bool_value(const JsonValue *v, int def); -size_t json_array_size(const JsonValue *v); -JsonValue *json_array_get(const JsonValue *v, size_t i); +JsonValue *json_object_get(const JsonValue *obj, const char *key); +const char *json_string_value(const JsonValue *v); /* 非字符串返回 NULL */ +double json_number_value(const JsonValue *v, double def); +int json_bool_value(const JsonValue *v, int def); +size_t json_array_size(const JsonValue *v); +JsonValue *json_array_get(const JsonValue *v, size_t i); /* 从对象里按 key 取值 */ const char *json_get_string(const JsonValue *obj, const char *key); -double json_get_number(const JsonValue *obj, const char *key, double def); -int json_get_bool(const JsonValue *obj, const char *key, int def); +double json_get_number(const JsonValue *obj, const char *key, double def); +int json_get_bool(const JsonValue *obj, const char *key, int def); /* ---- 构造 (取得所有权) ---- */ JsonValue *json_new_null(void); @@ -75,7 +75,7 @@ void json_array_push(JsonValue *arr, JsonValue *item); void json_array_insert(JsonValue *arr, size_t i, JsonValue *item); /* 删掉第 i 条并释放它. 越界不动 */ void json_array_remove(JsonValue *arr, size_t i); -/* 把 src 的所有元素搬到 dst 末尾 (偷所有权), src 变空. +/* 把 src 的所有元素搬到 dst 末尾 (偷所有权), src 变空. * 用于"我攒了一批块, 现在要把它们并进另一个数组"这种场景. */ void json_array_steal(JsonValue *dst, JsonValue *src); void json_object_set(JsonValue *obj, const char *key, JsonValue *value); @@ -86,8 +86,8 @@ void json_object_set_null(JsonValue *obj, const char *key); void json_object_remove(JsonValue *obj, const char *key); /* ---- 序列化: 返回 malloc 的字符串 ---- */ -char *json_dump(const JsonValue *v); /* 紧凑格式 */ -char *json_dump_pretty(const JsonValue *v); /* 2 空格缩进 */ +char *json_dump(const JsonValue *v); /* 紧凑格式 */ +char *json_dump_pretty(const JsonValue *v); /* 2 空格缩进 */ /* 把任意 C 字符串转义成 JSON 字符串字面量 (含首尾引号) */ char *json_quote_string(const char *s); diff --git a/src/lineedit.c b/src/lineedit.c index 7b55a42..72b7fb2 100644 --- a/src/lineedit.c +++ b/src/lineedit.c @@ -1,63 +1,59 @@ /* lineedit.c - REPL 行编辑器实现 */ #include "lineedit.h" +#include "http.h" /* g_ligent_interrupt */ #include "util.h" -#include "http.h" /* g_ligent_interrupt */ +#include +#include +#include #include #include #include -#include -#include -#include -#include -#include #include #include +#include +#include /* ------------------------------------------------------------------ */ /* 缓冲区操作 (纯逻辑, 可单测) */ /* ------------------------------------------------------------------ */ -void linebuf_init(LineBuf *lb) -{ - memset(lb, 0, sizeof *lb); -} +void linebuf_init(LineBuf *lb) { memset(lb, 0, sizeof *lb); } -void linebuf_free(LineBuf *lb) -{ +void linebuf_free(LineBuf *lb) { free(lb->buf); memset(lb, 0, sizeof *lb); } -static void lb_reserve(LineBuf *lb, size_t extra) -{ - if (lb->len + extra + 1 <= lb->cap) return; +static void lb_reserve(LineBuf *lb, size_t extra) { + if (lb->len + extra + 1 <= lb->cap) + return; size_t cap = lb->cap ? lb->cap : 64; - while (cap < lb->len + extra + 1) cap *= 2; + while (cap < lb->len + extra + 1) + cap *= 2; lb->buf = xrealloc(lb->buf, cap); lb->cap = cap; } -static void lb_terminate(LineBuf *lb) -{ +static void lb_terminate(LineBuf *lb) { lb_reserve(lb, 0); lb->buf[lb->len] = '\0'; } -void linebuf_set(LineBuf *lb, const char *text) -{ +void linebuf_set(LineBuf *lb, const char *text) { size_t n = text ? strlen(text) : 0; lb->len = 0; lb_reserve(lb, n); - if (n) memcpy(lb->buf, text, n); + if (n) + memcpy(lb->buf, text, n); lb->len = n; lb->pos = n; lb_terminate(lb); } -void linebuf_insert(LineBuf *lb, const char *text, size_t n) -{ - if (!text || n == 0) return; +void linebuf_insert(LineBuf *lb, const char *text, size_t n) { + if (!text || n == 0) + return; lb_reserve(lb, n); memmove(lb->buf + lb->pos + n, lb->buf + lb->pos, lb->len - lb->pos); memcpy(lb->buf + lb->pos, text, n); @@ -66,9 +62,9 @@ void linebuf_insert(LineBuf *lb, const char *text, size_t n) lb_terminate(lb); } -void linebuf_backspace(LineBuf *lb) -{ - if (lb->pos == 0) return; +void linebuf_backspace(LineBuf *lb) { + if (lb->pos == 0) + return; size_t prev = utf8_prev(lb->buf, lb->len, lb->pos); size_t n = lb->pos - prev; memmove(lb->buf + prev, lb->buf + lb->pos, lb->len - lb->pos); @@ -77,9 +73,9 @@ void linebuf_backspace(LineBuf *lb) lb_terminate(lb); } -void linebuf_delete(LineBuf *lb) -{ - if (lb->pos >= lb->len) return; +void linebuf_delete(LineBuf *lb) { + if (lb->pos >= lb->len) + return; size_t next = utf8_next(lb->buf, lb->len, lb->pos); size_t n = next - lb->pos; memmove(lb->buf + lb->pos, lb->buf + next, lb->len - next); @@ -87,55 +83,54 @@ void linebuf_delete(LineBuf *lb) lb_terminate(lb); } -void linebuf_left(LineBuf *lb) -{ +void linebuf_left(LineBuf *lb) { lb->pos = utf8_prev(lb->buf, lb->len, lb->pos); } -void linebuf_right(LineBuf *lb) -{ +void linebuf_right(LineBuf *lb) { lb->pos = utf8_next(lb->buf, lb->len, lb->pos); } -/* Home / End 走的是"逻辑行" (两个换行之间), 不是整个缓冲区. - * 粘贴进来一段多行文本后, 按 Home 应该回到当前那一行的行首, +/* Home / End 走的是"逻辑行" (两个换行之间), 不是整个缓冲区. + * 粘贴进来一段多行文本后, 按 Home 应该回到当前那一行的行首, * 而不是跳到整块的最开头 —— 后者是加多行支持时留下的毛病. */ -void linebuf_home(LineBuf *lb) -{ +void linebuf_home(LineBuf *lb) { size_t i = lb->pos; - while (i > 0 && lb->buf[i - 1] != '\n') i--; + while (i > 0 && lb->buf[i - 1] != '\n') + i--; lb->pos = i; } -void linebuf_end(LineBuf *lb) -{ +void linebuf_end(LineBuf *lb) { size_t i = lb->pos; - while (i < lb->len && lb->buf[i] != '\n') i++; + while (i < lb->len && lb->buf[i] != '\n') + i++; lb->pos = i; } -void linebuf_kill_to_end(LineBuf *lb) -{ +void linebuf_kill_to_end(LineBuf *lb) { lb->len = lb->pos; lb_terminate(lb); } -void linebuf_kill_to_start(LineBuf *lb) -{ - if (lb->pos == 0) return; +void linebuf_kill_to_start(LineBuf *lb) { + if (lb->pos == 0) + return; memmove(lb->buf, lb->buf + lb->pos, lb->len - lb->pos); lb->len -= lb->pos; lb->pos = 0; lb_terminate(lb); } -void linebuf_kill_word(LineBuf *lb) -{ +void linebuf_kill_word(LineBuf *lb) { size_t end = lb->pos; size_t i = end; - while (i > 0 && lb->buf[i - 1] == ' ') i--; - while (i > 0 && lb->buf[i - 1] != ' ') i--; - if (i == end) return; + while (i > 0 && lb->buf[i - 1] == ' ') + i--; + while (i > 0 && lb->buf[i - 1] != ' ') + i--; + if (i == end) + return; memmove(lb->buf + i, lb->buf + end, lb->len - end); lb->len -= end - i; lb->pos = i; @@ -150,8 +145,7 @@ static char **g_hist; static size_t g_hist_len, g_hist_cap; /* 直接接管一条已分配好的字符串 (加载文件时用, 不做去重) */ -static void hist_push_owned(char *line) -{ +static void hist_push_owned(char *line) { if (g_hist_len == g_hist_cap) { g_hist_cap = g_hist_cap ? g_hist_cap * 2 : 32; g_hist = xrealloc(g_hist, g_hist_cap * sizeof(char *)); @@ -159,18 +153,19 @@ static void hist_push_owned(char *line) g_hist[g_hist_len++] = line; } -int line_history_add(const char *line) -{ - if (!line || !*line) return 0; +int line_history_add(const char *line) { + if (!line || !*line) + return 0; /* 与上一条重复就不记 —— 和 bash 的 ignoredups 一个意思 */ - if (g_hist_len > 0 && strcmp(g_hist[g_hist_len - 1], line) == 0) return 0; + if (g_hist_len > 0 && strcmp(g_hist[g_hist_len - 1], line) == 0) + return 0; hist_push_owned(xstrdup(line)); return 1; } -void line_history_clear(void) -{ - for (size_t i = 0; i < g_hist_len; i++) free(g_hist[i]); +void line_history_clear(void) { + for (size_t i = 0; i < g_hist_len; i++) + free(g_hist[i]); free(g_hist); g_hist = NULL; g_hist_len = g_hist_cap = 0; @@ -178,8 +173,7 @@ void line_history_clear(void) size_t line_history_len(void) { return g_hist_len; } -const char *line_history_get(size_t i) -{ +const char *line_history_get(size_t i) { return i < g_hist_len ? g_hist[i] : NULL; } @@ -187,70 +181,90 @@ const char *line_history_get(size_t i) /* 输入历史持久化 */ /* ------------------------------------------------------------------ */ -/* 一条历史在文件里占一行. 用户输入可能含换行 (行尾反斜杠续行), +/* 一条历史在文件里占一行. 用户输入可能含换行 (行尾反斜杠续行), * 也可能含反斜杠, 所以这两个字符必须转义, 否则读回来就变了样. */ -static void hist_encode(const char *s, StrBuf *out) -{ +static void hist_encode(const char *s, StrBuf *out) { for (const unsigned char *p = (const unsigned char *)s; *p; p++) { - if (*p == '\\') sb_puts(out, "\\\\"); - else if (*p == '\n') sb_puts(out, "\\n"); - else sb_putc(out, (char)*p); + if (*p == '\\') + sb_puts(out, "\\\\"); + else if (*p == '\n') + sb_puts(out, "\\n"); + else + sb_putc(out, (char)*p); } } -/* 转义的反向. 遇到不认识的组合就原样保留, 宁可展示得怪一点, +/* 转义的反向. 遇到不认识的组合就原样保留, 宁可展示得怪一点, * 也不要把用户的东西吃掉. */ -static char *hist_decode(const char *s) -{ +static char *hist_decode(const char *s) { StrBuf out; sb_init(&out); for (const char *p = s; *p; p++) { - if (*p != '\\') { sb_putc(&out, *p); continue; } + if (*p != '\\') { + sb_putc(&out, *p); + continue; + } p++; - if (*p == '\\') sb_putc(&out, '\\'); - else if (*p == 'n') sb_putc(&out, '\n'); - else if (*p == '\0') { sb_putc(&out, '\\'); break; } - else { sb_putc(&out, '\\'); sb_putc(&out, *p); } + if (*p == '\\') + sb_putc(&out, '\\'); + else if (*p == 'n') + sb_putc(&out, '\n'); + else if (*p == '\0') { + sb_putc(&out, '\\'); + break; + } else { + sb_putc(&out, '\\'); + sb_putc(&out, *p); + } } return sb_detach(&out); } -/* 把内存里最新的 n 条重写回文件 (先写临时文件再 rename, +/* 把内存里最新的 n 条重写回文件 (先写临时文件再 rename, * 以免写到一半被杀掉就把历史弄没了). */ -static void hist_rewrite(const char *path, size_t n) -{ - if (g_hist_len < n) n = g_hist_len; +static void hist_rewrite(const char *path, size_t n) { + if (g_hist_len < n) + n = g_hist_len; size_t start = g_hist_len - n; char *tmp = xasprintf("%s.tmp", path); FILE *f = fopen(tmp, "w"); - if (!f) { free(tmp); return; } + if (!f) { + free(tmp); + return; + } int ok = 1; for (size_t i = start; i < g_hist_len && ok; i++) { StrBuf sb; sb_init(&sb); hist_encode(g_hist[i], &sb); - if (sb.data && fwrite(sb.data, 1, sb.len, f) != sb.len) ok = 0; - if (fputc('\n', f) == EOF) ok = 0; + if (sb.data && fwrite(sb.data, 1, sb.len, f) != sb.len) + ok = 0; + if (fputc('\n', f) == EOF) + ok = 0; sb_free(&sb); } - if (fclose(f) != 0) ok = 0; + if (fclose(f) != 0) + ok = 0; if (ok) { - chmod(tmp, 0600); /* 历史里可能有密钥, 别给同机其他用户看 */ - if (rename(tmp, path) != 0) unlink(tmp); + chmod(tmp, 0600); /* 历史里可能有密钥, 别给同机其他用户看 */ + if (rename(tmp, path) != 0) + unlink(tmp); } else { unlink(tmp); } free(tmp); } -int line_history_load(const char *path, size_t keep) -{ - if (!path || !*path) return 0; +int line_history_load(const char *path, size_t keep) { + if (!path || !*path) + return 0; FILE *f = fopen(path, "r"); - if (!f) return errno == ENOENT ? 0 : -1; - if (keep == 0) keep = 1; + if (!f) + return errno == ENOENT ? 0 : -1; + if (keep == 0) + keep = 1; /* 只留最新的 keep 条, 所以用环形缓冲边读边丢 —— 再大的历史文件 * 也只占 keep 条的内存. */ @@ -269,7 +283,8 @@ int line_history_load(const char *path, size_t keep) ring[slot] = hist_decode(line); total++; } - if (ferror(f)) rc = -1; + if (ferror(f)) + rc = -1; free(line); fclose(f); @@ -279,34 +294,44 @@ int line_history_load(const char *path, size_t keep) hist_push_owned(ring[slot]); ring[slot] = NULL; } - for (size_t i = 0; i < keep; i++) free(ring[i]); + for (size_t i = 0; i < keep; i++) + free(ring[i]); free(ring); /* 文件比上限长就地截短. 不这么做的话它会一直长下去. */ - if (rc == 0 && total > keep) hist_rewrite(path, keep); + if (rc == 0 && total > keep) + hist_rewrite(path, keep); return rc; } -int line_history_append(const char *path, const char *line) -{ - if (!path || !*path || !line || !*line) return 0; +int line_history_append(const char *path, const char *line) { + if (!path || !*path || !line || !*line) + return 0; /* 0600: 历史里可能有密钥, 不给同机其他用户读的机会 */ int fd = open(path, O_WRONLY | O_APPEND | O_CREAT, 0600); - if (fd < 0) return -1; + if (fd < 0) + return -1; FILE *f = fdopen(fd, "a"); - if (!f) { close(fd); return -1; } + if (!f) { + close(fd); + return -1; + } StrBuf sb; sb_init(&sb); hist_encode(line, &sb); int rc = 0; - if (sb.data && fwrite(sb.data, 1, sb.len, f) != sb.len) rc = -1; - if (fputc('\n', f) == EOF) rc = -1; + if (sb.data && fwrite(sb.data, 1, sb.len, f) != sb.len) + rc = -1; + if (fputc('\n', f) == EOF) + rc = -1; /* 立刻 flush: 另一个 ligent 实例要能马上翻到这条 */ - if (fflush(f) != 0) rc = -1; - if (fclose(f) != 0) rc = -1; + if (fflush(f) != 0) + rc = -1; + if (fclose(f) != 0) + rc = -1; sb_free(&sb); return rc; } @@ -316,28 +341,45 @@ int line_history_append(const char *path, const char *line) /* ------------------------------------------------------------------ */ enum { - K_EOF = 0, K_ENTER, K_INTERRUPT, K_ESC, K_CHAR, K_PASTE_START, K_PASTE_END, - K_UP, K_DOWN, K_LEFT, K_RIGHT, K_HOME, K_END, K_DELETE, - K_BACKSPACE, K_KILL_EOL, K_KILL_BOL, K_KILL_WORD, K_IGNORE + K_EOF = 0, + K_ENTER, + K_INTERRUPT, + K_ESC, + K_CHAR, + K_PASTE_START, + K_PASTE_END, + K_UP, + K_DOWN, + K_LEFT, + K_RIGHT, + K_HOME, + K_END, + K_DELETE, + K_BACKSPACE, + K_KILL_EOL, + K_KILL_BOL, + K_KILL_WORD, + K_IGNORE }; -static int term_cols(void) -{ +static int term_cols(void) { struct winsize ws; if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0) return ws.ws_col; const char *c = getenv("COLUMNS"); if (c && *c) { int n = atoi(c); - if (n > 0) return n; + if (n > 0) + return n; } return 80; } -static int tty_raw(struct termios *saved) -{ - if (!isatty(STDIN_FILENO)) return -1; - if (tcgetattr(STDIN_FILENO, saved) != 0) return -1; +static int tty_raw(struct termios *saved) { + if (!isatty(STDIN_FILENO)) + return -1; + if (tcgetattr(STDIN_FILENO, saved) != 0) + return -1; struct termios raw = *saved; raw.c_iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON); @@ -346,51 +388,66 @@ static int tty_raw(struct termios *saved) raw.c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG); raw.c_cc[VMIN] = 1; raw.c_cc[VTIME] = 0; - if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) != 0) return -1; + if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) != 0) + return -1; return 0; } -static void tty_restore(const struct termios *saved) -{ +static void tty_restore(const struct termios *saved) { tcsetattr(STDIN_FILENO, TCSAFLUSH, saved); } -static int classify(unsigned char c) -{ +static int classify(unsigned char c) { switch (c) { - case 0x0a: case 0x0d: return K_ENTER; - case 0x03: return K_INTERRUPT; - case 0x04: return K_EOF; - case 0x01: return K_HOME; - case 0x05: return K_END; - case 0x02: return K_LEFT; - case 0x06: return K_RIGHT; - case 0x0b: return K_KILL_EOL; - case 0x15: return K_KILL_BOL; - case 0x17: return K_KILL_WORD; - case 0x7f: case 0x08: return K_BACKSPACE; - default: return K_IGNORE; /* 其它控制键忽略, 避免污染缓冲区 */ + case 0x0a: + case 0x0d: + return K_ENTER; + case 0x03: + return K_INTERRUPT; + case 0x04: + return K_EOF; + case 0x01: + return K_HOME; + case 0x05: + return K_END; + case 0x02: + return K_LEFT; + case 0x06: + return K_RIGHT; + case 0x0b: + return K_KILL_EOL; + case 0x15: + return K_KILL_BOL; + case 0x17: + return K_KILL_WORD; + case 0x7f: + case 0x08: + return K_BACKSPACE; + default: + return K_IGNORE; /* 其它控制键忽略, 避免污染缓冲区 */ } } -/* 读一个按键. - * 普通可打印字节通过 *out_ch 返回并返回 K_CHAR; +/* 读一个按键. + * 普通可打印字节通过 *out_ch 返回并返回 K_CHAR; * 其余返回功能键码. 转义序列若在 40ms 内没有后续字节, 按"单独按 ESC"处理. */ -static int read_key(unsigned char *out_ch) -{ +static int read_key(unsigned char *out_ch) { unsigned char c; for (;;) { ssize_t r = read(STDIN_FILENO, &c, 1); - if (r == 1) break; + if (r == 1) + break; if (r < 0 && errno == EINTR) { - if (g_ligent_interrupt) return K_INTERRUPT; + if (g_ligent_interrupt) + return K_INTERRUPT; continue; } return K_EOF; } if (c != 0x1b) { - if (c < 0x20 || c == 0x7f) return classify(c); + if (c < 0x20 || c == 0x7f) + return classify(c); *out_ch = c; return K_CHAR; } @@ -402,35 +459,53 @@ static int read_key(unsigned char *out_ch) pfd.fd = STDIN_FILENO; pfd.events = POLLIN; pfd.revents = 0; - if (poll(&pfd, 1, 40) <= 0) break; /* 40ms 内没有后续 => 单独按了 ESC */ - if (read(STDIN_FILENO, &seq[n], 1) != 1) break; + if (poll(&pfd, 1, 40) <= 0) + break; /* 40ms 内没有后续 => 单独按了 ESC */ + if (read(STDIN_FILENO, &seq[n], 1) != 1) + break; n++; - if (n >= 2 && seq[n - 1] >= '@' && seq[n - 1] <= '~') break; /* CSI 终结符 */ - if (seq[0] != '[' && seq[0] != 'O') break; /* 不是 CSI/SS3 */ + if (n >= 2 && seq[n - 1] >= '@' && seq[n - 1] <= '~') + break; /* CSI 终结符 */ + if (seq[0] != '[' && seq[0] != 'O') + break; /* 不是 CSI/SS3 */ } - if (n == 0) return K_ESC; + if (n == 0) + return K_ESC; unsigned char final = seq[n - 1]; if (seq[0] == '[' || seq[0] == 'O') { switch (final) { - case 'A': return K_UP; - case 'B': return K_DOWN; - case 'C': return K_RIGHT; - case 'D': return K_LEFT; - case 'H': return K_HOME; - case 'F': return K_END; + case 'A': + return K_UP; + case 'B': + return K_DOWN; + case 'C': + return K_RIGHT; + case 'D': + return K_LEFT; + case 'H': + return K_HOME; + case 'F': + return K_END; case '~': { int num = 0; for (size_t i = 1; i + 1 < n; i++) - if (seq[i] >= '0' && seq[i] <= '9') num = num * 10 + (seq[i] - '0'); - if (num == 1 || num == 7) return K_HOME; - if (num == 4 || num == 8) return K_END; - if (num == 3) return K_DELETE; - if (num == 200) return K_PASTE_START; /* bracketed paste 开始 */ - if (num == 201) return K_PASTE_END; + if (seq[i] >= '0' && seq[i] <= '9') + num = num * 10 + (seq[i] - '0'); + if (num == 1 || num == 7) + return K_HOME; + if (num == 4 || num == 8) + return K_END; + if (num == 3) + return K_DELETE; + if (num == 200) + return K_PASTE_START; /* bracketed paste 开始 */ + if (num == 201) + return K_PASTE_END; return K_IGNORE; } - default: return K_IGNORE; + default: + return K_IGNORE; } } return K_IGNORE; @@ -438,78 +513,103 @@ static int read_key(unsigned char *out_ch) typedef struct { const char *prompt; - LineBuf *lb; - int drawn_row; /* 光标当前位于输入区的第几行, 用于回到起点 */ + LineBuf *lb; + int drawn_row; /* 光标当前位于输入区的第几行, 用于回到起点 */ } Editor; -/* 跳过一段 ANSI 转义序列, 返回跨过的字节数 (调用时保证 s[0] == ESC). - * 提示符带颜色时这些字节一个格子都不占, 必须跳过, 否则光标位置会整体偏移. +/* 跳过一段 ANSI 转义序列, 返回跨过的字节数 (调用时保证 s[0] == ESC). + * 提示符带颜色时这些字节一个格子都不占, 必须跳过, 否则光标位置会整体偏移. * 提示符是我们自己拼的、很短, 所以这里只认 CSI / OSC 两种最常见的形态. */ -static size_t ansi_skip(const char *s, size_t n) -{ +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++; + 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; } - if (s[i] == 0x1b && i + 1 < n && s[i + 1] == '\\') { i += 2; break; } + if (s[i] == '\a') { + i++; + break; + } + if (s[i] == 0x1b && i + 1 < n && s[i + 1] == '\\') { + i += 2; + break; + } i++; } return i; } - return i; /* 光秃秃的 ESC 按零宽处理 */ + return i; /* 光秃秃的 ESC 按零宽处理 */ } -/* 从给定起点"走"过 s[0..n), 推进 (row, col). +/* 从给定起点"走"过 s[0..n), 推进 (row, col). * 显式换行与自动折行都算. col 允许到达 cols, 表示"下一个字符会换行". */ -static void advance_pos(const char *s, size_t n, int cols, int *row, int *col) -{ - for (size_t i = 0; i < n; ) { +static void advance_pos(const char *s, size_t n, int cols, int *row, int *col) { + for (size_t i = 0; i < n;) { unsigned char c = (unsigned char)s[i]; - if (c == '\n') { (*row)++; *col = 0; i++; continue; } - if (c == '\r') { *col = 0; i++; continue; } - if (c == 0x1b) { i += ansi_skip(s + i, n - i); continue; } + if (c == '\n') { + (*row)++; + *col = 0; + i++; + continue; + } + if (c == '\r') { + *col = 0; + i++; + continue; + } + if (c == 0x1b) { + i += ansi_skip(s + i, n - i); + continue; + } size_t adv = 1; unsigned cp = utf8_decode(s + i, n - i, &adv); int w = cp_width(cp); - if (w <= 0) w = 1; /* 控制字符按占 1 列算 */ - if (*col + w > cols) { (*row)++; *col = 0; } + if (w <= 0) + w = 1; /* 控制字符按占 1 列算 */ + if (*col + w > cols) { + (*row)++; + *col = 0; + } *col += w; i += adv; } } -/* 打印完之后, 终端光标视觉上停在第几行第几列. +/* 打印完之后, 终端光标视觉上停在第几行第几列. * 关键: 字符正好填满一行时终端处于"待换行"状态, 光标仍停在本行末列. */ -static void end_pos(const char *prompt, const char *buf, size_t len, - int cols, int *row, int *col) -{ +static void end_pos(const char *prompt, const char *buf, size_t len, int cols, + int *row, int *col) { *row = 0; *col = 0; advance_pos(prompt, strlen(prompt), cols, row, col); advance_pos(buf, len, cols, row, col); - if (*col >= cols) *col = cols - 1; - if (*col < 0) *col = 0; + if (*col >= cols) + *col = cols - 1; + if (*col < 0) + *col = 0; } /* 光标应当停在哪里 (下一个字符会被写到的位置) */ -static void want_pos(const char *prompt, const char *buf, size_t pos, - int cols, int *row, int *col) -{ +static void want_pos(const char *prompt, const char *buf, size_t pos, int cols, + int *row, int *col) { *row = 0; *col = 0; advance_pos(prompt, strlen(prompt), cols, row, col); advance_pos(buf, pos, cols, row, col); - if (*col >= cols) { (*row)++; *col = 0; } + if (*col >= cols) { + (*row)++; + *col = 0; + } } -/* 输入行整行着色: 提示符和你敲进去的字都是绿的, 和模型的输出区分开. +/* 输入行整行着色: 提示符和你敲进去的字都是绿的, 和模型的输出区分开. * 颜色由这里统一加, 调用方传进来的 prompt 保持纯文本 —— 否则提示符自己 * 带一次 reset, 后面输入的正文就掉回默认色了. */ #define C_INPUT "\033[32m" @@ -518,95 +618,110 @@ static void want_pos(const char *prompt, const char *buf, size_t pos, static int g_input_color = 0; static const char *g_input_ink = C_INPUT; -void line_edit_set_color(int enabled) -{ - g_input_color = enabled ? 1 : 0; -} +void line_edit_set_color(int enabled) { g_input_color = enabled ? 1 : 0; } -void line_edit_set_ink(const char *ansi) -{ +void line_edit_set_ink(const char *ansi) { g_input_ink = ansi ? ansi : C_INPUT; } -/* 重绘整个输入区并把光标放回正确位置. +/* 重绘整个输入区并把光标放回正确位置. * 缓冲区可能包含换行 (粘贴多行), 也可能超出终端宽度自动折行. */ -static void refresh(Editor *e) -{ +static void refresh(Editor *e) { int cols = term_cols(); - if (cols < 2) cols = 80; + if (cols < 2) + cols = 80; int erow, ecol, trow, tcol; end_pos(e->prompt, e->lb->buf, e->lb->len, cols, &erow, &ecol); want_pos(e->prompt, e->lb->buf, e->lb->pos, cols, &trow, &tcol); putchar('\r'); - if (e->drawn_row > 0) printf("\033[%dA", e->drawn_row); - fputs("\033[J", stdout); /* 清到屏幕末尾 */ - if (g_input_color) fputs(g_input_ink, stdout); + if (e->drawn_row > 0) + printf("\033[%dA", e->drawn_row); + fputs("\033[J", stdout); /* 清到屏幕末尾 */ + if (g_input_color) + fputs(g_input_ink, stdout); fputs(e->prompt, stdout); - if (e->lb->len) fwrite(e->lb->buf, 1, e->lb->len, stdout); - if (g_input_color) fputs(C_INPUT_OFF, stdout); + if (e->lb->len) + fwrite(e->lb->buf, 1, e->lb->len, stdout); + if (g_input_color) + fputs(C_INPUT_OFF, stdout); /* 从"打印结束处"移到目标位置: 先回列 0, 再上下行, 再右移 */ int up = erow - trow; putchar('\r'); - if (up > 0) printf("\033[%dA", up); - else if (up < 0) printf("\033[%dB", -up); - if (tcol > 0) printf("\033[%dC", tcol); + if (up > 0) + printf("\033[%dA", up); + else if (up < 0) + printf("\033[%dB", -up); + if (tcol > 0) + printf("\033[%dC", tcol); fflush(stdout); e->drawn_row = trow; } -/* 把粘贴进来的内容清洗成"编辑器能正确渲染"的文本. +/* 把粘贴进来的内容清洗成"编辑器能正确渲染"的文本. * * 为什么必须清洗: read_paste 收下的是终端给的原始字节, 而缓冲区会被 - * 逐字节回显到终端上. 里面只要有控制字符, 两边的算盘就对不上: - * - \t 终端按制表位展开 (下一个 8 的倍数), 编辑器却只当 1 列, - * 粘一段带缩进的代码, 光标就从此偏掉, 后面每敲一个字都在错位置上; - * - ESC 会被终端当成转义序列真的执行掉 —— 粘贴内容能改颜色、清屏; - * - \r 把终端光标拉回行首, 屏幕当场错位; - * - NUL 会把 C 字符串截断. + * 逐字节回显到终端上. 里面只要有控制字符, 两边的算盘就对不上: + * - \t 终端按制表位展开 (下一个 8 的倍数), 编辑器却只当 1 列, + * 粘一段带缩进的代码, 光标就从此偏掉, 后面每敲一个字都在错位置上; + * - ESC 会被终端当成转义序列真的执行掉 —— 粘贴内容能改颜色、清屏; + * - \r 把终端光标拉回行首, 屏幕当场错位; + * - NUL 会把 C 字符串截断. * 处理办法: \n 保留 (多行粘贴整段提交靠它), \t 就地展开成空格 (这样 - * 缓冲区里不留制表符, 宽度怎么算都对), ESC 连同后面整条序列一起丢掉, - * 其余控制字符丢弃. + * 缓冲区里不留制表符, 宽度怎么算都对), ESC 连同后面整条序列一起丢掉, + * 其余控制字符丢弃. * * start_col 是插入点在屏幕上的列号 —— 制表位要从它开始算, 而不是从 0. */ -static char *sanitize_paste(const char *s, int start_col, int cols) -{ +static char *sanitize_paste(const char *s, int start_col, int cols) { StrBuf out; sb_init(&out); int col = start_col; size_t n = strlen(s); - for (size_t i = 0; i < n; ) { + for (size_t i = 0; i < n;) { unsigned char c = (unsigned char)s[i]; - if (c == '\n') { sb_putc(&out, '\n'); col = 0; i++; continue; } - if (c == 0x1b) { i += ansi_skip(s + i, n - i); continue; } + if (c == '\n') { + sb_putc(&out, '\n'); + col = 0; + i++; + continue; + } + if (c == 0x1b) { + i += ansi_skip(s + i, n - i); + continue; + } if (c == '\t') { - int w = 8 - (col % 8); /* 终端默认制表位是 8 的倍数 */ - for (int k = 0; k < w; k++) sb_putc(&out, ' '); + int w = 8 - (col % 8); /* 终端默认制表位是 8 的倍数 */ + for (int k = 0; k < w; k++) + sb_putc(&out, ' '); col += w; i++; continue; } - if (c == '\r' || c < 0x20 || c == 0x7f) { i++; continue; } + if (c == '\r' || c < 0x20 || c == 0x7f) { + i++; + continue; + } size_t adv = 1; unsigned cp = utf8_decode(s + i, n - i, &adv); int w = cp_width(cp); - if (w <= 0) w = 1; + if (w <= 0) + w = 1; col += w; - if (cols > 0 && col > cols) col -= cols; /* 自动折行 */ + if (cols > 0 && col > cols) + col -= cols; /* 自动折行 */ sb_append(&out, s + i, adv); i += adv; } return sb_detach(&out); } -/* 读取一段 bracketed paste 内容, 直到结束标记 \x1b[201~. +/* 读取一段 bracketed paste 内容, 直到结束标记 \x1b[201~. * 返回的文本可能含换行 —— 这正是粘贴多行不再被逐行执行的关键. */ -static char *read_paste(void) -{ +static char *read_paste(void) { static const char END[] = "\x1b[201~"; const size_t endlen = sizeof END - 1; @@ -618,49 +733,55 @@ static char *read_paste(void) unsigned char c; ssize_t r = read(STDIN_FILENO, &c, 1); if (r != 1) { - if (r < 0 && errno == EINTR && !g_ligent_interrupt) continue; - break; /* 中断或读到 EOF */ + if (r < 0 && errno == EINTR && !g_ligent_interrupt) + continue; + break; /* 中断或读到 EOF */ } if (c == (unsigned char)END[matched]) { - if (++matched == endlen) break; /* 正常结束, 标记本身不入缓冲区 */ + if (++matched == endlen) + break; /* 正常结束, 标记本身不入缓冲区 */ continue; } - if (matched > 0) { /* 之前只是部分匹配, 吐回去 */ + if (matched > 0) { /* 之前只是部分匹配, 吐回去 */ sb_append(&sb, END, matched); matched = 0; } - if (c == (unsigned char)END[0]) { matched = 1; continue; } + if (c == (unsigned char)END[0]) { + matched = 1; + continue; + } sb_putc(&sb, (char)c); } - if (matched > 0) sb_append(&sb, END, matched); + if (matched > 0) + sb_append(&sb, END, matched); return sb_detach(&sb); } -/* 进入/退出终端状态. bracketed paste 必须成对开关, +/* 进入/退出终端状态. bracketed paste 必须成对开关, * 否则会把用户的终端留在开启状态. */ -static void tty_enter(const struct termios *saved) -{ +static void tty_enter(const struct termios *saved) { (void)saved; - fputs("\033[?2004h", stdout); /* 开启 bracketed paste */ + fputs("\033[?2004h", stdout); /* 开启 bracketed paste */ fflush(stdout); } -static void tty_leave(const struct termios *saved) -{ - fputs("\033[?2004l", stdout); /* 关闭 bracketed paste */ +static void tty_leave(const struct termios *saved) { + fputs("\033[?2004l", stdout); /* 关闭 bracketed paste */ fflush(stdout); tty_restore(saved); } -char *line_edit(const char *prompt, int *interrupted) -{ - if (interrupted) *interrupted = 0; +char *line_edit(const char *prompt, int *interrupted) { + if (interrupted) + *interrupted = 0; /* 非终端 (管道 / 重定向)退化为普通的按行读取 */ if (!isatty(STDIN_FILENO) || !isatty(STDOUT_FILENO)) { - if (g_input_color) fputs(g_input_ink, stdout); + if (g_input_color) + fputs(g_input_ink, stdout); fputs(prompt, stdout); - if (g_input_color) fputs(C_INPUT_OFF, stdout); + if (g_input_color) + fputs(C_INPUT_OFF, stdout); fflush(stdout); StrBuf sb; sb_init(&sb); @@ -673,12 +794,16 @@ char *line_edit(const char *prompt, int *interrupted) } sb_append(&sb, buf, n); } - if (sb.len == 0) { sb_free(&sb); return NULL; } + if (sb.len == 0) { + sb_free(&sb); + return NULL; + } return sb_detach(&sb); } struct termios saved; - if (tty_raw(&saved) != 0) return NULL; + if (tty_raw(&saved) != 0) + return NULL; tty_enter(&saved); LineBuf lb; @@ -690,10 +815,10 @@ char *line_edit(const char *prompt, int *interrupted) e.lb = &lb; e.drawn_row = 0; - size_t hist_idx = line_history_len(); /* == len 表示"正在编辑新行" */ - char *draft = NULL; /* 翻历史前保存的当前输入 */ - char *result = NULL; - int at_eof = 0; + size_t hist_idx = line_history_len(); /* == len 表示"正在编辑新行" */ + char *draft = NULL; /* 翻历史前保存的当前输入 */ + char *result = NULL; + int at_eof = 0; refresh(&e); @@ -703,13 +828,16 @@ char *line_edit(const char *prompt, int *interrupted) int stop = 0; switch (key) { - case K_CHAR: linebuf_insert(&lb, (const char *)&ch, 1); break; + case K_CHAR: + linebuf_insert(&lb, (const char *)&ch, 1); + break; case K_PASTE_START: { - /* 整段插入, 缓冲区里允许出现换行 —— 提交时作为一个任务发出去. + /* 整段插入, 缓冲区里允许出现换行 —— 提交时作为一个任务发出去. * 插之前先算出插入点在第几列, 制表符展开要用它. */ char *pasted = read_paste(); int pcols = term_cols(); - if (pcols < 2) pcols = 80; + if (pcols < 2) + pcols = 80; int prow, pcol; want_pos(e.prompt, lb.buf, lb.pos, pcols, &prow, &pcol); char *clean = sanitize_paste(pasted, pcol, pcols); @@ -718,22 +846,49 @@ char *line_edit(const char *prompt, int *interrupted) free(pasted); break; } - case K_PASTE_END: break; /* 单独出现的结束标记, 忽略 */ - case K_ENTER: stop = 1; break; + case K_PASTE_END: + break; /* 单独出现的结束标记, 忽略 */ + case K_ENTER: + stop = 1; + break; case K_EOF: - if (lb.len == 0) { at_eof = 1; stop = 1; } - else linebuf_delete(&lb); /* 行非空时 Ctrl-D 删一个字符 */ + if (lb.len == 0) { + at_eof = 1; + stop = 1; + } else + linebuf_delete(&lb); /* 行非空时 Ctrl-D 删一个字符 */ + break; + case K_INTERRUPT: + at_eof = -1; + stop = 1; + break; + case K_BACKSPACE: + linebuf_backspace(&lb); + break; + case K_DELETE: + linebuf_delete(&lb); + break; + case K_LEFT: + linebuf_left(&lb); + break; + case K_RIGHT: + linebuf_right(&lb); + break; + case K_HOME: + linebuf_home(&lb); + break; + case K_END: + linebuf_end(&lb); + break; + case K_KILL_EOL: + linebuf_kill_to_end(&lb); + break; + case K_KILL_BOL: + linebuf_kill_to_start(&lb); + break; + case K_KILL_WORD: + linebuf_kill_word(&lb); break; - case K_INTERRUPT: at_eof = -1; stop = 1; break; - case K_BACKSPACE: linebuf_backspace(&lb); break; - case K_DELETE: linebuf_delete(&lb); break; - case K_LEFT: linebuf_left(&lb); break; - case K_RIGHT: linebuf_right(&lb); break; - case K_HOME: linebuf_home(&lb); break; - case K_END: linebuf_end(&lb); break; - case K_KILL_EOL: linebuf_kill_to_end(&lb); break; - case K_KILL_BOL: linebuf_kill_to_start(&lb); break; - case K_KILL_WORD: linebuf_kill_word(&lb); break; case K_UP: if (hist_idx > 0) { if (hist_idx == line_history_len()) { @@ -753,10 +908,12 @@ char *line_edit(const char *prompt, int *interrupted) linebuf_set(&lb, line_history_get(hist_idx)); } break; - default: break; /* K_ESC / K_IGNORE */ + default: + break; /* K_ESC / K_IGNORE */ } - if (stop) break; + if (stop) + break; refresh(&e); } @@ -766,7 +923,8 @@ char *line_edit(const char *prompt, int *interrupted) if (at_eof == -1) { fputs("^C\n", stdout); linebuf_free(&lb); - if (interrupted) *interrupted = 1; + if (interrupted) + *interrupted = 1; return NULL; } if (at_eof == 1) { diff --git a/src/lineedit.h b/src/lineedit.h index b752990..bbb7818 100644 --- a/src/lineedit.h +++ b/src/lineedit.h @@ -1,10 +1,10 @@ /* lineedit.h - REPL 的行编辑器 * - * 为什么需要它: 原来的 REPL 用 fgets 直接读, 终端停在规范模式, - * 方向键发出的转义序列 (\x1b[D 之类)会被当成普通字节读进来, - * 显示成 ^[[D 而不是移动光标. 要有行内编辑就必须自己进 raw 模式接管按键. + * 为什么需要它: 原来的 REPL 用 fgets 直接读, 终端停在规范模式, + * 方向键发出的转义序列 (\x1b[D 之类)会被当成普通字节读进来, + * 显示成 ^[[D 而不是移动光标. 要有行内编辑就必须自己进 raw 模式接管按键. * - * 缓冲区与编辑操作是纯逻辑, 可以脱离终端单测; 只有渲染与按键读取需要 tty. + * 缓冲区与编辑操作是纯逻辑, 可以脱离终端单测; 只有渲染与按键读取需要 tty. */ #ifndef LIGENT_LINEEDIT_H #define LIGENT_LINEEDIT_H @@ -13,7 +13,7 @@ /* 可编辑缓冲区. pos 是字节偏移, 且始终落在字符边界上 */ typedef struct { - char *buf; + char *buf; size_t len; size_t cap; size_t pos; @@ -27,25 +27,25 @@ void linebuf_set(LineBuf *lb, const char *text); /* 在光标处插入 n 字节, 光标随之后移 */ void linebuf_insert(LineBuf *lb, const char *text, size_t n); -void linebuf_backspace(LineBuf *lb); /* 删除光标前一个字符 */ -void linebuf_delete(LineBuf *lb); /* 删除光标处的字符 */ -void linebuf_left(LineBuf *lb); /* 光标左移一个字符 */ -void linebuf_right(LineBuf *lb); /* 光标右移一个字符 */ +void linebuf_backspace(LineBuf *lb); /* 删除光标前一个字符 */ +void linebuf_delete(LineBuf *lb); /* 删除光标处的字符 */ +void linebuf_left(LineBuf *lb); /* 光标左移一个字符 */ +void linebuf_right(LineBuf *lb); /* 光标右移一个字符 */ void linebuf_home(LineBuf *lb); void linebuf_end(LineBuf *lb); -void linebuf_kill_to_end(LineBuf *lb); /* 删到行尾 */ -void linebuf_kill_to_start(LineBuf *lb); /* 删到行首 */ -void linebuf_kill_word(LineBuf *lb); /* 往回删一个词 */ +void linebuf_kill_to_end(LineBuf *lb); /* 删到行尾 */ +void linebuf_kill_to_start(LineBuf *lb); /* 删到行首 */ +void linebuf_kill_word(LineBuf *lb); /* 往回删一个词 */ -/* 输入行是否着色 (整行: 提示符 + 你敲进去的字). +/* 输入行是否着色 (整行: 提示符 + 你敲进去的字). * 传进来的 prompt 应当是纯文本 —— 颜色由编辑器统一加在两头. */ void line_edit_set_color(int enabled); -/* 换输入行的墨色 (ANSI 起手式, 例如 "\033[35m"). NULL 恢复默认的绿色. +/* 换输入行的墨色 (ANSI 起手式, 例如 "\033[35m"). NULL 恢复默认的绿色. * meta 模式靠它和主提示符区分开. */ void line_edit_set_ink(const char *ansi); -/* 读入一行 (不含换行符, malloc). +/* 读入一行 (不含换行符, malloc). * Ctrl-C -> 返回 NULL, *interrupted = 1 * Ctrl-D 空行 -> 返回 NULL, *interrupted = 0 (终止输入) * stdin 非终端 -> 退化为按行读取 @@ -55,27 +55,27 @@ char *line_edit(const char *prompt, int *interrupted); /* 进程内的历史记录 (上下方向键用) */ /* 追加一条. 返回 1 表示真的记下了, 0 表示被忽略 (空行, 或与上一条重复). */ -int line_history_add(const char *line); -void line_history_clear(void); -size_t line_history_len(void); +int line_history_add(const char *line); +void line_history_clear(void); +size_t line_history_len(void); const char *line_history_get(size_t i); /* ---- 输入历史的持久化 ---- * - * 文件位置由调用方决定 (ligent 用 <二进制目录>/ligent.history). + * 文件位置由调用方决定 (ligent 用 <二进制目录>/ligent.history). * 一条历史在文件里占一行, 所以条目里的反斜杠和换行会被转义 * (\ -> \\, 换行 -> \n), 保证读回来和写出去一模一样. 不含反斜杠的 - * 普通命令在文件里与输入完全一致, 肉眼可读. + * 普通命令在文件里与输入完全一致, 肉眼可读. * * 时间戳之类的元数据刻意不记: 目的只是"上下键能翻到", 不是审计. */ -/* 从 path 读取历史, 追加到当前历史之后. 最多保留最新的 keep 条; - * 文件本身更长时会被就地截短 (否则会无限增长). +/* 从 path 读取历史, 追加到当前历史之后. 最多保留最新的 keep 条; + * 文件本身更长时会被就地截短 (否则会无限增长). * 文件不存在返回 0, 读出错返回 -1. */ int line_history_load(const char *path, size_t keep); -/* 把一条输入实时追加到 path —— 每提交一条就写一次并立刻 flush, - * 因此另一个 ligent 实例马上就能翻到. line 为空时什么都不做. +/* 把一条输入实时追加到 path —— 每提交一条就写一次并立刻 flush, + * 因此另一个 ligent 实例马上就能翻到. line 为空时什么都不做. * 成功返回 0, 失败返回 -1 (失败不打断 REPL). */ int line_history_append(const char *path, const char *line); diff --git a/src/llm.c b/src/llm.c index 0427431..e50ec69 100644 --- a/src/llm.c +++ b/src/llm.c @@ -1,6 +1,6 @@ /* llm.c - OpenAI 兼容接口: 请求构造、SSE 流式解析、tool_calls 增量拼接 */ #include "llm.h" -#include "config.h" /* THINKING_AUTO / _ON / _OFF */ +#include "config.h" /* THINKING_AUTO / _ON / _OFF */ #include "http.h" #include "i18n.h" #include "util.h" @@ -13,11 +13,12 @@ /* URL / 请求体 */ /* ------------------------------------------------------------------ */ -char *llm_endpoint_url(const char *base_url) -{ - const char *base = (base_url && *base_url) ? base_url : "https://api.openai.com/v1"; +char *llm_endpoint_url(const char *base_url) { + const char *base = + (base_url && *base_url) ? base_url : "https://api.openai.com/v1"; size_t n = strlen(base); - while (n > 0 && base[n - 1] == '/') n--; + while (n > 0 && base[n - 1] == '/') + n--; /* 允许用户直接给完整的 completions 地址 */ if (str_ends_with(base_url, "/chat/completions")) return xstrndup(base, n); @@ -25,8 +26,7 @@ char *llm_endpoint_url(const char *base_url) } JsonValue *llm_build_body(const LlmConfig *cfg, const JsonValue *messages, - const JsonValue *tools, int stream) -{ + const JsonValue *tools, int stream) { JsonValue *body = json_new_object(); json_object_set_string(body, "model", cfg->model); json_object_set(body, "messages", json_clone(messages)); @@ -39,8 +39,8 @@ JsonValue *llm_build_body(const LlmConfig *cfg, const JsonValue *messages, * OpenAI SDK 里的 extra_body 包装. */ if (cfg->thinking != THINKING_AUTO) { JsonValue *t = json_new_object(); - json_object_set_string(t, "type", - cfg->thinking == THINKING_ON ? "enabled" : "disabled"); + json_object_set_string( + t, "type", cfg->thinking == THINKING_ON ? "enabled" : "disabled"); json_object_set(body, "thinking", t); } if (cfg->reasoning_effort && *cfg->reasoning_effort) @@ -53,13 +53,13 @@ JsonValue *llm_build_body(const LlmConfig *cfg, const JsonValue *messages, return body; } -/* 单行 SSE 上限. 正常的 data: 行只有几十字节; - * 出现超长"行"说明对端根本不是 SSE (例如忽略 stream 直接返回整包 JSON, +/* 单行 SSE 上限. 正常的 data: 行只有几十字节; + * 出现超长"行"说明对端根本不是 SSE (例如忽略 stream 直接返回整包 JSON, * 而 JSON 里没有换行), 必须丢弃, 否则内存会被撑爆. */ -#define SSE_MAX_LINE (256u * 1024) +#define SSE_MAX_LINE (256u * 1024) /* 单条回复累积内容上限 */ #define SSE_MAX_CONTENT (32u * 1024 * 1024) -/* 单个工具调用参数上限. 正常人写不出 8MB 的参数, +/* 单个工具调用参数上限. 正常人写不出 8MB 的参数, * 超过基本可以断定是对端异常或恶意, 必须截住 */ #define SSE_MAX_TOOL_ARGS (8u * 1024 * 1024) @@ -67,36 +67,39 @@ JsonValue *llm_build_body(const LlmConfig *cfg, const JsonValue *messages, /* 错误响应的可读化 */ /* ------------------------------------------------------------------ */ -/* 很多服务端把 message 写成 \uXXXX 转义, 直接打印很难看. +/* 很多服务端把 message 写成 \uXXXX 转义, 直接打印很难看. * 这里尝试解析出 error.message, 失败则回退到原始文本. */ -static char *extract_error_message(const char *body) -{ - if (!body || !*body) return NULL; +static char *extract_error_message(const char *body) { + if (!body || !*body) + return NULL; JsonValue *root = json_parse(body, NULL, 0); - if (!root) return NULL; + if (!root) + return NULL; char *msg = NULL; JsonValue *err = json_object_get(root, "error"); if (err && err->type == JSON_OBJECT) { const char *m = json_get_string(err, "message"); - if (m && *m) msg = xstrdup(m); + if (m && *m) + msg = xstrdup(m); } else if (err && err->type == JSON_STRING) { msg = xstrdup(err->u.string); } if (!msg) { const char *m = json_get_string(root, "message"); - if (m && *m) msg = xstrdup(m); + if (m && *m) + msg = xstrdup(m); } json_free(root); return msg; } -/* 401/403 时补一句提示. 服务端的原文经常把人引向错误的方向: - * OpenRouter 对"格式不对的 key"回的是 "Missing Authentication header", +/* 401/403 时补一句提示. 服务端的原文经常把人引向错误的方向: + * OpenRouter 对"格式不对的 key"回的是 "Missing Authentication header", * 听起来像是请求里没带认证头, 实际上带了, 只是 key 不属于这家. */ -static void append_auth_hint(char *errbuf, size_t errlen, - const LlmConfig *cfg, long status) -{ - if (status != 401 && status != 403) return; +static void append_auth_hint(char *errbuf, size_t errlen, const LlmConfig *cfg, + long status) { + if (status != 401 && status != 403) + return; const char *key = cfg->api_key ? cfg->api_key : ""; size_t n = strlen(key); @@ -108,12 +111,13 @@ static void append_auth_hint(char *errbuf, size_t errlen, char head[8]; snprintf(head, sizeof head, "%.*s", n <= 10 ? 4 : 7, key); snprintf(masked, sizeof masked, "%s", - tf("llm.mask.partial", "head", head, - "n", i18n_num((long long)n), NULL)); + tf("llm.mask.partial", "head", head, "n", + i18n_num((long long)n), NULL)); } size_t cur = strlen(errbuf); - if (cur + 4 >= errlen) return; + if (cur + 4 >= errlen) + return; snprintf(errbuf + cur, errlen - cur, "%s", tf("llm.err.auth_hint", "masked", masked, "url", cfg->base_url && *cfg->base_url ? cfg->base_url @@ -122,12 +126,13 @@ static void append_auth_hint(char *errbuf, size_t errlen, } static void set_error_with_detail(char *errbuf, size_t errlen, - const char *prefix, const char *body) -{ + const char *prefix, const char *body) { snprintf(errbuf, errlen, "%s", prefix); - if (!body || !*body) return; + if (!body || !*body) + return; size_t cur = strlen(errbuf); - if (cur + 4 >= errlen) return; + if (cur + 4 >= errlen) + return; char *msg = extract_error_message(body); if (msg) { snprintf(errbuf + cur, errlen - cur, " — %s", msg); @@ -142,37 +147,37 @@ static void set_error_with_detail(char *errbuf, size_t errlen, /* ------------------------------------------------------------------ */ typedef struct { - char *id; - char *name; + char *id; + char *name; StrBuf args; - int started; + int started; } ToolAcc; typedef struct { - StrBuf raw; /* 原始字节, 用于 "服务端不支持流式" 时的回退 */ - StrBuf content; - StrBuf reasoning; /* reasoning_content 累积 (思考过程, 不显示) */ - ToolAcc *accs; - size_t acc_count; - size_t acc_cap; - char *finish_reason; - LlmDeltaFn on_delta; - void *ud; - LlmThinkFn on_think; - void *think_ud; - int got_event; - int overflow; /* 内容超过上限 */ - int raw_truncated; - char *error; /* 服务端返回的 error.message */ - JsonValue *usage; - StrBuf linebuf; + StrBuf raw; /* 原始字节, 用于 "服务端不支持流式" 时的回退 */ + StrBuf content; + StrBuf reasoning; /* reasoning_content 累积 (思考过程, 不显示) */ + ToolAcc *accs; + size_t acc_count; + size_t acc_cap; + char *finish_reason; + LlmDeltaFn on_delta; + void *ud; + LlmThinkFn on_think; + void *think_ud; + int got_event; + int overflow; /* 内容超过上限 */ + int raw_truncated; + char *error; /* 服务端返回的 error.message */ + JsonValue *usage; + StrBuf linebuf; } SseCtx; -static ToolAcc *acc_at(SseCtx *c, size_t index) -{ +static ToolAcc *acc_at(SseCtx *c, size_t index) { if (index >= c->acc_cap) { size_t cap = c->acc_cap ? c->acc_cap : 4; - while (cap <= index) cap *= 2; + while (cap <= index) + cap *= 2; c->accs = xrealloc(c->accs, cap * sizeof(ToolAcc)); memset(c->accs + c->acc_cap, 0, (cap - c->acc_cap) * sizeof(ToolAcc)); c->acc_cap = cap; @@ -181,19 +186,20 @@ static ToolAcc *acc_at(SseCtx *c, size_t index) if (!a->started) { a->started = 1; sb_init(&a->args); - if (index + 1 > c->acc_count) c->acc_count = index + 1; + if (index + 1 > c->acc_count) + c->acc_count = index + 1; } return a; } -static void sse_ctx_free(SseCtx *c) -{ +static void sse_ctx_free(SseCtx *c) { sb_free(&c->raw); sb_free(&c->content); sb_free(&c->reasoning); sb_free(&c->linebuf); for (size_t i = 0; i < c->acc_count; i++) { - if (!c->accs[i].started) continue; + if (!c->accs[i].started) + continue; free(c->accs[i].id); free(c->accs[i].name); sb_free(&c->accs[i].args); @@ -205,22 +211,25 @@ static void sse_ctx_free(SseCtx *c) } /* 处理一个 SSE data 负载 (不含 "data:" 前缀) */ -static void handle_payload(SseCtx *c, const char *payload, size_t len) -{ - if (len == 0) return; - if (len == 6 && memcmp(payload, "[DONE]", 6) == 0) return; +static void handle_payload(SseCtx *c, const char *payload, size_t len) { + if (len == 0) + return; + if (len == 6 && memcmp(payload, "[DONE]", 6) == 0) + return; char *js = xstrndup(payload, len); char err[256]; JsonValue *root = json_parse(js, err, sizeof err); free(js); - if (!root) return; + if (!root) + return; c->got_event = 1; JsonValue *errv = json_object_get(root, "error"); if (errv && errv->type == JSON_OBJECT) { const char *m = json_get_string(errv, "message"); - if (m && !c->error) c->error = xstrdup(m); + if (m && !c->error) + c->error = xstrdup(m); json_free(root); return; } @@ -230,15 +239,23 @@ static void handle_payload(SseCtx *c, const char *payload, size_t len) JsonValue *choices = json_object_get(root, "choices"); JsonValue *ch0 = json_array_get(choices, 0); - if (!ch0) { json_free(root); return; } + if (!ch0) { + json_free(root); + return; + } const char *fr = json_get_string(ch0, "finish_reason"); - if (fr && *fr && !c->finish_reason) c->finish_reason = xstrdup(fr); + if (fr && *fr && !c->finish_reason) + c->finish_reason = xstrdup(fr); /* OpenAI 流式用 delta, 少数实现直接给 message */ JsonValue *d = json_object_get(ch0, "delta"); - if (!d) d = json_object_get(ch0, "message"); - if (!d || d->type != JSON_OBJECT) { json_free(root); return; } + if (!d) + d = json_object_get(ch0, "message"); + if (!d || d->type != JSON_OBJECT) { + json_free(root); + return; + } const char *piece = json_get_string(d, "content"); if (piece && *piece) { @@ -246,24 +263,27 @@ static void handle_payload(SseCtx *c, const char *payload, size_t len) c->overflow = 1; } else { sb_puts(&c->content, piece); - if (c->on_delta) c->on_delta(c->ud, piece); + if (c->on_delta) + c->on_delta(c->ud, piece); } } /* 思考过程. 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"); + if (!think) + think = json_get_string(d, "reasoning"); if (think && *think) { if (c->reasoning.len + strlen(think) > SSE_MAX_CONTENT) { c->overflow = 1; } else { sb_puts(&c->reasoning, think); - /* 不往正文回调里塞 —— 思维链不该被渲染. 单独走一路, + /* 不往正文回调里塞 —— 思维链不该被渲染. 单独走一路, * 让上层知道"模型开始推理了". */ - if (c->on_think) c->on_think(c->think_ud, think); + if (c->on_think) + c->on_think(c->think_ud, think); } } @@ -271,16 +291,19 @@ static void handle_payload(SseCtx *c, const char *payload, size_t len) if (tcs && tcs->type == JSON_ARRAY) { for (size_t i = 0; i < json_array_size(tcs); i++) { JsonValue *tc = json_array_get(tcs, i); - if (!tc) continue; + if (!tc) + continue; size_t idx = (size_t)json_get_number(tc, "index", (double)i); ToolAcc *a = acc_at(c, idx); const char *id = json_get_string(tc, "id"); - if (id && *id && !a->id) a->id = xstrdup(id); + if (id && *id && !a->id) + a->id = xstrdup(id); JsonValue *fn = json_object_get(tc, "function"); if (fn && fn->type == JSON_OBJECT) { const char *name = json_get_string(fn, "name"); if (name && *name) { - if (!a->name) a->name = xstrdup(name); + if (!a->name) + a->name = xstrdup(name); else if (strcmp(a->name, name) != 0) { /* 有些服务端会拆着发名字, 追加处理 */ char *joined = xasprintf("%s%s", a->name, name); @@ -302,20 +325,24 @@ static void handle_payload(SseCtx *c, const char *payload, size_t len) } /* 按行切分, 逐条处理 "data:" */ -static void feed(SseCtx *c, const char *data, size_t len) -{ +static void feed(SseCtx *c, const char *data, size_t len) { sb_append(&c->linebuf, data, len); char *buf = c->linebuf.data; size_t start = 0; for (size_t i = 0; i < c->linebuf.len; i++) { - if (buf[i] != '\n') continue; + if (buf[i] != '\n') + continue; size_t line_len = i - start; - if (line_len > 0 && buf[start + line_len - 1] == '\r') line_len--; + if (line_len > 0 && buf[start + line_len - 1] == '\r') + line_len--; const char *line = buf + start; if (line_len > 5 && memcmp(line, "data:", 5) == 0) { const char *p = line + 5; size_t n = line_len - 5; - while (n > 0 && (*p == ' ' || *p == '\t')) { p++; n--; } + while (n > 0 && (*p == ' ' || *p == '\t')) { + p++; + n--; + } handle_payload(c, p, n); } start = i + 1; @@ -327,24 +354,27 @@ static void feed(SseCtx *c, const char *data, size_t len) } /* 残行过长: 丢弃, 保证内存有界 */ - if (c->linebuf.len > SSE_MAX_LINE) sb_clear(&c->linebuf); + if (c->linebuf.len > SSE_MAX_LINE) + sb_clear(&c->linebuf); } -static size_t sse_write_cb(char *ptr, size_t size, size_t nmemb, void *ud) -{ +static size_t sse_write_cb(char *ptr, size_t size, size_t nmemb, void *ud) { SseCtx *c = ud; size_t n = size * nmemb; - if (c->overflow) return 0; /* 超限, 中止传输 */ + if (c->overflow) + return 0; /* 超限, 中止传输 */ - if (c->raw.len < 65536) { /* 仅保留开头用于回退判断 */ + if (c->raw.len < 65536) { /* 仅保留开头用于回退判断 */ size_t take = n < 65536 - c->raw.len ? n : 65536 - c->raw.len; sb_append(&c->raw, ptr, take); - if (take < n) c->raw_truncated = 1; + if (take < n) + c->raw_truncated = 1; } else { c->raw_truncated = 1; } feed(c, ptr, n); - if (c->overflow) return 0; + if (c->overflow) + return 0; return n; } @@ -352,44 +382,51 @@ static size_t sse_write_cb(char *ptr, size_t size, size_t nmemb, void *ud) /* 从整包 JSON 响应里抽出 assistant 消息 */ /* ------------------------------------------------------------------ */ -static LlmResponse *from_full_response(JsonValue *root, size_t raw_len) -{ +static LlmResponse *from_full_response(JsonValue *root, size_t raw_len) { (void)raw_len; JsonValue *errv = json_object_get(root, "error"); - if (errv && errv->type == JSON_OBJECT) return NULL; + if (errv && errv->type == JSON_OBJECT) + return NULL; JsonValue *ch0 = json_array_get(json_object_get(root, "choices"), 0); - if (!ch0) return NULL; + if (!ch0) + return NULL; JsonValue *msg = json_object_get(ch0, "message"); - if (!msg || msg->type != JSON_OBJECT) return NULL; + if (!msg || msg->type != JSON_OBJECT) + return NULL; LlmResponse *r = xcalloc(1, sizeof(LlmResponse)); r->message = json_clone(msg); JsonValue *u = json_object_get(root, "usage"); - if (u && u->type == JSON_OBJECT) r->usage = json_clone(u); + if (u && u->type == JSON_OBJECT) + r->usage = json_clone(u); const char *fr = json_get_string(ch0, "finish_reason"); - if (fr) r->finish_reason = xstrdup(fr); + if (fr) + r->finish_reason = xstrdup(fr); return r; } /* 把累积结构整理成 LlmResponse */ -static LlmResponse *from_sse(SseCtx *c) -{ +static LlmResponse *from_sse(SseCtx *c) { LlmResponse *r = xcalloc(1, sizeof(LlmResponse)); r->message = json_new_object(); json_object_set_string(r->message, "role", "assistant"); int has_tools = 0; for (size_t i = 0; i < c->acc_count; i++) - if (c->accs[i].started) { has_tools = 1; break; } + if (c->accs[i].started) { + has_tools = 1; + break; + } if (c->content.len > 0 || !has_tools) - json_object_set_string(r->message, "content", c->content.data ? c->content.data : ""); + json_object_set_string(r->message, "content", + c->content.data ? c->content.data : ""); else json_object_set_null(r->message, "content"); - /* 思考过程跟着 assistant 消息一起进历史. 用户看不到它, 但它必须原样回传: - * 只要请求里带 tools, 服务端就要求把历史里的 reasoning_content 全带上, + /* 思考过程跟着 assistant 消息一起进历史. 用户看不到它, 但它必须原样回传: + * 只要请求里带 tools, 服务端就要求把历史里的 reasoning_content 全带上, * 否则下一轮直接 400. */ if (c->reasoning.len > 0) json_object_set_string(r->message, "reasoning_content", @@ -399,7 +436,8 @@ static LlmResponse *from_sse(SseCtx *c) JsonValue *arr = json_new_array(); for (size_t i = 0; i < c->acc_count; i++) { ToolAcc *a = &c->accs[i]; - if (!a->started) continue; + if (!a->started) + continue; JsonValue *tc = json_new_object(); if (a->id) { json_object_set_string(tc, "id", a->id); @@ -419,8 +457,10 @@ static LlmResponse *from_sse(SseCtx *c) json_object_set(r->message, "tool_calls", arr); } - if (c->finish_reason) r->finish_reason = xstrdup(c->finish_reason); - if (c->usage) r->usage = json_clone(c->usage); + if (c->finish_reason) + r->finish_reason = xstrdup(c->finish_reason); + if (c->usage) + r->usage = json_clone(c->usage); return r; } @@ -429,13 +469,14 @@ static LlmResponse *from_sse(SseCtx *c) /* ------------------------------------------------------------------ */ LlmResponse *llm_chat(const LlmConfig *cfg, const JsonValue *messages, - const JsonValue *tools, - LlmDeltaFn on_delta, void *ud, - LlmThinkFn on_think, void *think_ud, - char *errbuf, size_t errlen) -{ + const JsonValue *tools, LlmDeltaFn on_delta, void *ud, + LlmThinkFn on_think, void *think_ud, char *errbuf, + size_t errlen) { static char errsink[1]; - if (!errbuf || !errlen) { errbuf = errsink; errlen = 1; } + if (!errbuf || !errlen) { + errbuf = errsink; + errlen = 1; + } errbuf[0] = '\0'; if (!cfg->api_key || !*cfg->api_key) { snprintf(errbuf, errlen, "%s", t("llm.err.no_api_key")); @@ -447,7 +488,8 @@ LlmResponse *llm_chat(const LlmConfig *cfg, const JsonValue *messages, json_free(body); if (cfg->verbose) - fprintf(stderr, "[ligent] POST %s\n[ligent] body: %.2000s\n", url, payload); + fprintf(stderr, "[ligent] POST %s\n[ligent] body: %.2000s\n", url, + payload); LlmResponse *result = NULL; char http_err[512]; @@ -471,8 +513,8 @@ LlmResponse *llm_chat(const LlmConfig *cfg, const JsonValue *messages, rt.max_retries = cfg->retries; int rc = http_post_stream(url, cfg->api_key, payload, cfg->timeout, cfg->verbose, &rt, sse_write_cb, &ctx, - &errbody, &status, NULL, &delivered, - http_err, sizeof http_err); + &errbody, &status, NULL, &delivered, http_err, + sizeof http_err); if (g_ligent_interrupt) { result = xcalloc(1, sizeof(LlmResponse)); result->interrupted = 1; @@ -482,15 +524,16 @@ LlmResponse *llm_chat(const LlmConfig *cfg, const JsonValue *messages, ctx.content.data ? ctx.content.data : ""); if (ctx.reasoning.len > 0) json_object_set_string(result->message, "reasoning_content", - ctx.reasoning.data ? ctx.reasoning.data : ""); + ctx.reasoning.data ? ctx.reasoning.data + : ""); } else if (ctx.overflow) { snprintf(errbuf, errlen, "%s", - tf("llm.err.overflow", - "content", i18n_num((long long)SSE_MAX_CONTENT), - "args", i18n_num((long long)SSE_MAX_TOOL_ARGS), NULL)); + tf("llm.err.overflow", "content", + i18n_num((long long)SSE_MAX_CONTENT), "args", + i18n_num((long long)SSE_MAX_TOOL_ARGS), NULL)); } else if (rc != 0 && (ctx.content.len > 0 || ctx.got_event)) { - /* 传输断了, 但已经收到并显示了部分内容. - * 这些内容用户已经看到, 丢掉会让上下文和屏幕对不上; + /* 传输断了, 但已经收到并显示了部分内容. + * 这些内容用户已经看到, 丢掉会让上下文和屏幕对不上; * 所以保留成一条"不完整"的消息, 交给上层提示. */ result = from_sse(&ctx); result->incomplete = 1; @@ -524,7 +567,8 @@ LlmResponse *llm_chat(const LlmConfig *cfg, const JsonValue *messages, if (status >= 400) snprintf(errbuf, errlen, "HTTP %ld", status); else - snprintf(errbuf, errlen, "%s", t("llm.err.unparsable_response")); + snprintf(errbuf, errlen, "%s", + t("llm.err.unparsable_response")); } } free(errbody); @@ -533,9 +577,9 @@ LlmResponse *llm_chat(const LlmConfig *cfg, const JsonValue *messages, char *resp = NULL; HttpRetry rt = HTTP_RETRY_DEFAULT; rt.max_retries = cfg->retries; - int rc = http_post(url, cfg->api_key, payload, cfg->timeout, - cfg->verbose, &rt, &resp, &status, NULL, - http_err, sizeof http_err); + int rc = + http_post(url, cfg->api_key, payload, cfg->timeout, cfg->verbose, + &rt, &resp, &status, NULL, http_err, sizeof http_err); if (rc != 0) { if (resp && *resp) set_error_with_detail(errbuf, errlen, http_err, resp); @@ -543,8 +587,8 @@ LlmResponse *llm_chat(const LlmConfig *cfg, const JsonValue *messages, snprintf(errbuf, errlen, "%s", http_err); append_auth_hint(errbuf, errlen, cfg, status); } else { - JsonValue *root = json_parse_ui(resp ? resp : "", - http_err, sizeof http_err); + JsonValue *root = + json_parse_ui(resp ? resp : "", http_err, sizeof http_err); if (root) { JsonValue *errv = json_object_get(root, "error"); if (errv && errv->type == JSON_OBJECT) { @@ -573,9 +617,9 @@ LlmResponse *llm_chat(const LlmConfig *cfg, const JsonValue *messages, return result; } -void llm_response_free(LlmResponse *r) -{ - if (!r) return; +void llm_response_free(LlmResponse *r) { + if (!r) + return; json_free(r->message); json_free(r->usage); free(r->finish_reason); diff --git a/src/llm.h b/src/llm.h index 1b1f781..df5bdff 100644 --- a/src/llm.h +++ b/src/llm.h @@ -6,34 +6,34 @@ #include typedef struct { - char *base_url; /* 例如 https://api.deepseek.com/v1 */ - char *api_key; - char *model; - double temperature; /* < 0 表示不发送该字段 */ - long max_tokens; /* <= 0 表示不发送 */ - int thinking; /* config.h 的 THINKING_*; AUTO 表示不发 thinking 字段 */ - const char *reasoning_effort; /* NULL/空 表示不发送 */ - int stream; /* 1 = SSE 流式 */ - int verbose; - long timeout; /* 秒, <=0 用默认 */ - int retries; /* 瞬态失败的额外重试次数, 0 = 不重试 */ + char *base_url; /* 例如 https://api.deepseek.com/v1 */ + char *api_key; + char *model; + double temperature; /* < 0 表示不发送该字段 */ + long max_tokens; /* <= 0 表示不发送 */ + int thinking; /* config.h 的 THINKING_*; AUTO 表示不发 thinking 字段 */ + const char *reasoning_effort; /* NULL/空 表示不发送 */ + int stream; /* 1 = SSE 流式 */ + int verbose; + long timeout; /* 秒, <=0 用默认 */ + int retries; /* 瞬态失败的额外重试次数, 0 = 不重试 */ } LlmConfig; typedef struct { - JsonValue *message; /* assistant 消息对象 {role, content, tool_calls?} */ - JsonValue *usage; /* 可能为 NULL */ - char *finish_reason; /* 可能为 NULL */ - int interrupted; - int incomplete; /* 传输中断, 内容是部分的 */ - char *incomplete_reason; + JsonValue *message; /* assistant 消息对象 {role, content, tool_calls?} */ + JsonValue *usage; /* 可能为 NULL */ + char *finish_reason; /* 可能为 NULL */ + int interrupted; + int incomplete; /* 传输中断, 内容是部分的 */ + char *incomplete_reason; } LlmResponse; -/* 流式正文增量回调. 只有 delta.content 会触发它 —— 正文是"给用户看的", +/* 流式正文增量回调. 只有 delta.content 会触发它 —— 正文是"给用户看的", * 上层据此停止等待动画、开始渲染. */ typedef void (*LlmDeltaFn)(void *ud, const char *text); -/* 流式思维链增量回调. 只有 reasoning_content (或部分实现的 reasoning)会触发它. - * 思维链本身不显示, 这个回调的用途是告诉上层"模型真的开始推理了", +/* 流式思维链增量回调. 只有 reasoning_content (或部分实现的 reasoning)会触发它. + * 思维链本身不显示, 这个回调的用途是告诉上层"模型真的开始推理了", * 好把等待动画从"等待响应"切成"思考中". */ typedef void (*LlmThinkFn)(void *ud, const char *text); @@ -44,16 +44,15 @@ char *llm_endpoint_url(const char *base_url); JsonValue *llm_build_body(const LlmConfig *cfg, const JsonValue *messages, const JsonValue *tools, int stream); -/* 发起一次对话请求. 成功返回 LlmResponse (调用者用 llm_response_free 释放), - * 失败返回 NULL 并写 errbuf. +/* 发起一次对话请求. 成功返回 LlmResponse (调用者用 llm_response_free 释放), + * 失败返回 NULL 并写 errbuf. * - * on_delta / ud 收正文增量; on_think / think_ud 收思维链增量. + * on_delta / ud 收正文增量; on_think / think_ud 收思维链增量. * 两者都可以传 NULL (非流式时本来就都是 NULL). */ LlmResponse *llm_chat(const LlmConfig *cfg, const JsonValue *messages, - const JsonValue *tools, - LlmDeltaFn on_delta, void *ud, - LlmThinkFn on_think, void *think_ud, - char *errbuf, size_t errlen); + const JsonValue *tools, LlmDeltaFn on_delta, void *ud, + LlmThinkFn on_think, void *think_ud, char *errbuf, + size_t errlen); void llm_response_free(LlmResponse *r); diff --git a/src/main.c b/src/main.c index 68735e9..ffe3b34 100644 --- a/src/main.c +++ b/src/main.c @@ -1,47 +1,45 @@ /* main.c - 入口: 命令行解析、单次任务模式、交互式 REPL */ -#include "config.h" #include "agent.h" -#include "tools.h" -#include "llm.h" -#include "util.h" -#include "http.h" -#include "lineedit.h" -#include "meta.h" -#include "doctor.h" -#include "spinner.h" #include "archive.h" #include "attach.h" -#include "image.h" -#include "models.h" +#include "config.h" #include "confirm.h" +#include "doctor.h" +#include "http.h" #include "i18n.h" +#include "image.h" +#include "lineedit.h" +#include "llm.h" +#include "meta.h" +#include "models.h" #include "shell.h" +#include "spinner.h" +#include "tools.h" +#include "util.h" #include "version.h" +#include +#include #include #include #include #include -#include -#include /* ------------------------------------------------------------------ */ /* 信号 */ /* ------------------------------------------------------------------ */ -static void on_sigint(int sig) -{ +static void on_sigint(int sig) { (void)sig; g_ligent_interrupt = 1; } -static void install_signals(void) -{ +static void install_signals(void) { struct sigaction sa; memset(&sa, 0, sizeof sa); sa.sa_handler = on_sigint; sigemptyset(&sa.sa_mask); - sa.sa_flags = 0; /* 不设 SA_RESTART: 让 read/fgets 被 EINTR 打断 */ + sa.sa_flags = 0; /* 不设 SA_RESTART: 让 read/fgets 被 EINTR 打断 */ sigaction(SIGINT, &sa, NULL); signal(SIGPIPE, SIG_IGN); } @@ -50,18 +48,14 @@ static void install_signals(void) /* 帮助 */ /* ------------------------------------------------------------------ */ -static void print_usage(FILE *out) -{ - fputs(t("cli.help"), out); -} +static void print_usage(FILE *out) { fputs(t("cli.help"), out); } /* ------------------------------------------------------------------ */ /* 把配置灌进 agent */ /* ------------------------------------------------------------------ */ -static void apply_config(Agent *a, LigentConfig *c) -{ - a->llm.base_url = c->base_url; /* 有意借用, 配置对象活得和进程一样久 */ +static void apply_config(Agent *a, LigentConfig *c) { + a->llm.base_url = c->base_url; /* 有意借用, 配置对象活得和进程一样久 */ a->llm.api_key = c->api_key; a->llm.model = c->model; a->llm.temperature = c->temperature; @@ -83,8 +77,7 @@ static void apply_config(Agent *a, LigentConfig *c) /* 读取全部 stdin */ /* ------------------------------------------------------------------ */ -static char *read_stdin_all(void) -{ +static char *read_stdin_all(void) { StrBuf sb; sb_init(&sb); char buf[4096]; @@ -98,38 +91,36 @@ static char *read_stdin_all(void) /* REPL */ /* ------------------------------------------------------------------ */ -static void print_banner(const LigentConfig *c, const Agent *a) -{ +static void print_banner(const LigentConfig *c, const Agent *a) { char cwd[1024]; - if (!getcwd(cwd, sizeof cwd)) snprintf(cwd, sizeof cwd, "."); + if (!getcwd(cwd, sizeof cwd)) + snprintf(cwd, sizeof cwd, "."); printf("%sligent %s%s %s(model: %s, cwd: %s)%s\n", - a->color ? "\033[1m" : "", ligent_version_string(), a->color ? "\033[0m" : "", - a->color ? "\033[2m" : "", c->model, cwd, + a->color ? "\033[1m" : "", ligent_version_string(), + a->color ? "\033[0m" : "", a->color ? "\033[2m" : "", c->model, cwd, + a->color ? "\033[0m" : ""); + printf("%s%s%s\n\n", a->color ? "\033[2m" : "", t("repl.banner.hint"), a->color ? "\033[0m" : ""); - printf("%s%s%s\n\n", a->color ? "\033[2m" : "", - t("repl.banner.hint"), a->color ? "\033[0m" : ""); } /* 存档目录跟二进制走, 和配置同一个原则 */ static const char *g_bindir; -/* 当前对话的谱系: 最近一次写出或载入的存档名 (不含 .json). +/* 当前对话的谱系: 最近一次写出或载入的存档名 (不含 .json). * 下次存档就在它后面接一级分支, 于是文件名本身就记录了分支关系. */ static char *g_lineage; -static void set_lineage(const char *name) -{ +static void set_lineage(const char *name) { free(g_lineage); g_lineage = (name && *name) ? xstrdup(name) : NULL; } -/* /back 摘掉的消息, 供 /unback 撤销; g_back_len 记录摘完之后的长度, +/* /back 摘掉的消息, 供 /unback 撤销; g_back_len 记录摘完之后的长度, * 用来判断"之后是不是又开了新对话" */ static JsonValue *g_back_undo; -static size_t g_back_len; +static size_t g_back_len; -static void back_undo_clear(void) -{ +static void back_undo_clear(void) { json_free(g_back_undo); g_back_undo = NULL; g_back_len = 0; @@ -138,28 +129,29 @@ static void back_undo_clear(void) /* 上次自动存档时的状态, 用来避免重复存同一段对话 */ static size_t g_saved_count; static time_t g_saved_start; -static int g_auto_save_on; +static int g_auto_save_on; -/* 自动存档. 只在"有内容"且"自上次存档后又变了"时才写, +/* 自动存档. 只在"有内容"且"自上次存档后又变了"时才写, * 所以退出、重置、导入前各调一次不会产生重复文件. */ static void auto_save(Agent *a, LigentConfig *c, const char *why, - const char *prefix) -{ - if (!g_auto_save_on) return; + const char *prefix) { + if (!g_auto_save_on) + return; size_t n = agent_history_len(a); - if (n <= 1) return; /* 只有 system, 没内容 */ + if (n <= 1) + return; /* 只有 system, 没内容 */ if (n == g_saved_count && a->started_at == g_saved_start) - return; /* 内容没变 */ + return; /* 内容没变 */ char *path = NULL, *name = NULL; char err[512]; /* meta 改过就另起一根: <原名>_m<3位>, parent 记空 */ int meta = meta_reroot_pending(); if (archive_export(a->messages, g_bindir ? g_bindir : ".", g_lineage, - prefix, meta, a->started_at, time(NULL), - c->model, c->base_url, - &path, &name, err, sizeof err) != 0) { - fprintf(stderr, "%s", tf("main.err.auto_save_failed", "error", err, NULL)); + prefix, meta, a->started_at, time(NULL), c->model, + c->base_url, &path, &name, err, sizeof err) != 0) { + fprintf(stderr, "%s", + tf("main.err.auto_save_failed", "error", err, NULL)); return; } g_saved_count = n; @@ -167,19 +159,19 @@ static void auto_save(Agent *a, LigentConfig *c, const char *why, /* 临时存档只是"换掉当前对话前留一手", 不该成为以后分支的祖先 */ if (!prefix || !*prefix) { set_lineage(name); - if (meta) meta_reroot_clear(); /* 新根已经落名, 标记用掉了 */ + if (meta) + meta_reroot_clear(); /* 新根已经落名, 标记用掉了 */ } fprintf(stderr, "\033[2m%s\033[0m\n", - tf("main.auto_saved", "why", why, "path", path, - "temp", (prefix && *prefix) ? t("main.auto_saved.temp") : "", NULL)); + tf("main.auto_saved", "why", why, "path", path, "temp", + (prefix && *prefix) ? t("main.auto_saved.temp") : "", NULL)); free(path); free(name); } /* 真正落地一次模型切换. cap 缓存是按 base_url+model 索引的, 所以下次请求 * 会自动重新探测新模型的能力, 这里不用额外处理. */ -static void apply_model(Agent *a, LigentConfig *c, const char *name) -{ +static void apply_model(Agent *a, LigentConfig *c, const char *name) { if (strcmp(c->model, name) == 0) { printf("%s", tf("repl.model.same", "model", name, NULL)); return; @@ -190,18 +182,17 @@ static void apply_model(Agent *a, LigentConfig *c, const char *name) printf("%s", tf("repl.model.switched", "model", c->model, NULL)); } -/* /model 不带参数: 拉一次在线列表, 打印出来, 然后用**普通输入行**问要哪个. +/* /model 不带参数: 拉一次在线列表, 打印出来, 然后用**普通输入行**问要哪个. * * 刻意不用"光标选择"那种菜单: 一是它会用 ANSI 反复重画、把 stdout 搅乱 * (重定向、日志、粘贴都会跟着遭殃), 二是**没法输入列表里没有的东西** —— - * 而"列表里没有"恰恰很常见: 内部模型、灰度模型、服务端没列出来的别名. + * 而"列表里没有"恰恰很常见: 内部模型、灰度模型、服务端没列出来的别名. * 所以这里是 /import 那一套: 列表打在上面, 输入行里既可以填序号, 也可以 - * 直接写模型名, 回车取消. + * 直接写模型名, 回车取消. * * 拉不到列表就明说原因, 并提示还能直接用 /model <名字> —— 不能因为网络不行 * 或者服务端不列, 就把这条路堵死. */ -static void cmd_model_pick(Agent *a, LigentConfig *c) -{ +static void cmd_model_pick(Agent *a, LigentConfig *c) { printf("%s", tf("repl.model.fetching", "url", c->base_url, NULL)); fflush(stdout); @@ -210,12 +201,13 @@ static void cmd_model_pick(Agent *a, LigentConfig *c) char **list = models_fetch(c, &n, a->llm.verbose, err, sizeof err); if (!list) { printf("%s", tf("repl.model.list_failed", "error", - err[0] ? err : t("repl.unknown_error"), NULL)); + err[0] ? err : t("repl.unknown_error"), NULL)); puts(t("repl.model.list_failed_hint")); return; } - printf("%s", tf("repl.model.list_header", "n", i18n_num((long long)n), NULL)); + printf("%s", + tf("repl.model.list_header", "n", i18n_num((long long)n), NULL)); for (size_t i = 0; i < n; i++) { int cur = (strcmp(list[i], c->model) == 0); printf(t("repl.model.row"), i + 1, list[i], @@ -238,14 +230,14 @@ static void cmd_model_pick(Agent *a, LigentConfig *c) return; } - /* 纯数字按序号理解; 其它一律当模型名 —— 这条正是"内部模型"的入口, + /* 纯数字按序号理解; 其它一律当模型名 —— 这条正是"内部模型"的入口, * 所以不去列表里校验它, 服务端认不认由后端说了算. */ char *numend = NULL; long idx = strtol(ans, &numend, 10); if (numend != ans && *str_trim(numend) == '\0') { if (idx < 1 || (size_t)idx > n) { printf("%s", tf("repl.index_out_of_range", "n", i18n_num(idx), - "max", i18n_num((long long)n), NULL)); + "max", i18n_num((long long)n), NULL)); free(line); models_free(list, n); return; @@ -262,18 +254,16 @@ static void cmd_model_pick(Agent *a, LigentConfig *c) /* /include 遇到图片时才会被问到. 这时候才去跑能力探测 —— 纯文本的附带 * 不该为了"可能出现的图"先花掉两个探测请求. 探测结论按 base_url+model * 缓存, 所以一个模型只会真探测一次. */ -static int include_can_image(void *ud) -{ +static int include_can_image(void *ud) { Agent *a = ud; agent_ensure_caps(a); - /* 只有**明确不支持**才拦. 未测 (探测被关掉或结论不确定)按支持放行, + /* 只有**明确不支持**才拦. 未测 (探测被关掉或结论不确定)按支持放行, * 和别处一个原则: 宁可让服务端说不行, 也不错误地藏掉东西. */ return a->caps.vision != CAP_NO; } /* /include [glob|clear] —— 不带参数进交互式挑选 */ -static void cmd_include(Agent *a, const char *arg) -{ +static void cmd_include(Agent *a, const char *arg) { if (strcmp(arg, "clear") == 0) { attach_clear(); puts(t("repl.include.cleared")); @@ -284,7 +274,8 @@ static void cmd_include(Agent *a, const char *arg) int added; if (*arg) { char err[512]; - added = attach_glob(arg, include_can_image, a, &skipped, err, sizeof err); + added = + attach_glob(arg, include_can_image, a, &skipped, err, sizeof err); if (added < 0) { printf("%s", tf("repl.include.failed", "error", err, NULL)); return; @@ -294,22 +285,21 @@ static void cmd_include(Agent *a, const char *arg) } if (added > 0) { - printf("%s%s%s\n", - tf("repl.include.added", "n", i18n_num(added), NULL), + printf("%s%s%s\n", tf("repl.include.added", "n", i18n_num(added), NULL), skipped ? tf("repl.include.skipped_more", "n", - i18n_num((long long)skipped), NULL) : "", + i18n_num((long long)skipped), NULL) + : "", t("repl.colon")); attach_print(); puts(t("repl.include.next_hint")); } else if (skipped > 0) { - printf("%s", tf("repl.include.none", "n", - i18n_num((long long)skipped), NULL)); + printf("%s", tf("repl.include.none", "n", i18n_num((long long)skipped), + NULL)); } /* added == 0 且 skipped == 0 = 用户在交互式界面里取消了, 不用多嘴 */ } -static void cmd_export(Agent *a, LigentConfig *c) -{ +static void cmd_export(Agent *a, LigentConfig *c) { /* 只有 system 一条说明这段对话还没发生过什么, 导出没有意义 */ if (agent_history_len(a) <= 1) { puts(t("repl.export.empty")); @@ -319,19 +309,19 @@ static void cmd_export(Agent *a, LigentConfig *c) char *path = NULL, *name = NULL; char err[512]; int meta = meta_reroot_pending(); - if (archive_export(a->messages, g_bindir ? g_bindir : ".", g_lineage, - NULL, meta, a->started_at, time(NULL), - c->model, c->base_url, + if (archive_export(a->messages, g_bindir ? g_bindir : ".", g_lineage, NULL, + meta, a->started_at, time(NULL), c->model, c->base_url, &path, &name, err, sizeof err) != 0) { printf("%s", tf("repl.export.failed", "error", err, NULL)); return; } - printf("%s", tf("repl.export.done", - "n", i18n_num((long long)agent_conversation_len(a)), - "lineage", g_lineage ? g_lineage : "", - "arrow", g_lineage ? " -> " : "", "name", name, NULL)); + printf("%s", tf("repl.export.done", "n", + i18n_num((long long)agent_conversation_len(a)), "lineage", + g_lineage ? g_lineage : "", "arrow", + g_lineage ? " -> " : "", "name", name, NULL)); if (g_lineage && - strlen(g_lineage) + 1 + ARCHIVE_BRANCH_LEN + strlen(".json") > ARCHIVE_NAME_MAX) + strlen(g_lineage) + 1 + ARCHIVE_BRANCH_LEN + strlen(".json") > + ARCHIVE_NAME_MAX) printf("%s\n", t("repl.export.name_limit")); /* 手动导出也算"已存档", 否则退出时的自动存档会把同样的内容 @@ -339,78 +329,97 @@ static void cmd_export(Agent *a, LigentConfig *c) g_saved_count = agent_history_len(a); g_saved_start = a->started_at; set_lineage(name); - if (meta) meta_reroot_clear(); /* 新根已经落名, 标记用掉了 */ + if (meta) + meta_reroot_clear(); /* 新根已经落名, 标记用掉了 */ free(path); free(name); } /* 按名字找一份存档: 支持完整文件名、不含 .json 的名字、以及唯一前缀 */ static const ArchiveEntry *find_entry(const ArchiveEntry *list, size_t n, - const char *want) -{ + const char *want) { size_t stemlen = strlen(want); - if (str_ends_with(want, ".json")) stemlen -= strlen(".json"); + if (str_ends_with(want, ".json")) + stemlen -= strlen(".json"); const ArchiveEntry *hit = NULL; for (size_t i = 0; i < n; i++) { if (strncmp(list[i].name, want, stemlen) == 0) { - if (strlen(list[i].name) == stemlen) return &list[i]; /* 精确匹配 */ - if (!hit) hit = &list[i]; /* 前缀匹配 */ + if (strlen(list[i].name) == stemlen) + return &list[i]; /* 精确匹配 */ + if (!hit) + hit = &list[i]; /* 前缀匹配 */ } } return hit; } -/* 载入存档: 给了名字就直接载入, 否则交互式选一份. +/* 载入存档: 给了名字就直接载入, 否则交互式选一份. * 只把历史装回去, 不重放历史内容. */ -/* 问一个是非题, (Y/n) 语义: 直接回车算"是". +/* 问一个是非题, (Y/n) 语义: 直接回车算"是". * 非终端时没人能回答, 按默认值返回 1 —— 和 confirm_tool 一致. */ -static int ask_yes_no(const char *question) -{ - if (!isatty(STDIN_FILENO)) return 1; +static int ask_yes_no(const char *question) { + if (!isatty(STDIN_FILENO)) + return 1; for (;;) { int intr = 0; char *line = line_edit(question, &intr); - if (!line) { putchar('\n'); return 1; } /* Ctrl-C / EOF 也按默认值 */ + if (!line) { + putchar('\n'); + return 1; + } /* Ctrl-C / EOF 也按默认值 */ char *ans = str_trim(line); int yes = (*ans == '\0' || *ans == 'y' || *ans == 'Y'); - int no = (*ans == 'n' || *ans == 'N'); + int no = (*ans == 'n' || *ans == 'N'); free(line); - if (yes) return 1; - if (no) return 0; + if (yes) + return 1; + if (no) + return 0; puts(t("repl.yes_no.hint")); } } -/* /import 载入后用户可能想看看这段历史到底聊了什么. +/* /import 载入后用户可能想看看这段历史到底聊了什么. * * 这里是**完整**转储: 不截断正文、不省略工具参数、不砍总量. 既然用户 - * 明确回答了 Y, 就该看到全部 —— 截一半反而会让人以为历史里就这么点东西. - * 所以只做排版, 不做取舍. + * 明确回答了 Y, 就该看到全部 —— 截一半反而会让人以为历史里就这么点东西. + * 所以只做排版, 不做取舍. * - * 唯一"少打印"的地方是没有 content 的消息 (工具调用型的 assistant), + * 唯一"少打印"的地方是没有 content 的消息 (工具调用型的 assistant), * 那种消息本来就没有正文, 打出来的是它的 tool_calls. */ -static void print_history(const Agent *a, const JsonValue *msgs) -{ - const char *dim = a->color ? "\033[2m" : ""; - const char *off = a->color ? "\033[0m" : ""; - const char *grn = a->color ? "\033[32m" : ""; +static void print_history(const Agent *a, const JsonValue *msgs) { + const char *dim = a->color ? "\033[2m" : ""; + const char *off = a->color ? "\033[0m" : ""; + const char *grn = a->color ? "\033[32m" : ""; const char *cyan = a->color ? "\033[36m" : ""; - const char *yel = a->color ? "\033[33m" : ""; + const char *yel = a->color ? "\033[33m" : ""; size_t n = json_array_size(msgs); for (size_t i = 0; i < n; i++) { JsonValue *m = json_array_get(msgs, i); const char *role = json_get_string(m, "role"); - if (!role) role = "?"; + if (!role) + role = "?"; const char *label, *color; - if (strcmp(role, "system") == 0) { label = t("repl.role.system"); color = dim; } - else if (strcmp(role, "user") == 0) { label = t("repl.role.user"); color = grn; } - else if (strcmp(role, "assistant") == 0) { label = t("repl.role.assistant"); color = cyan; } - else if (strcmp(role, "tool") == 0) { label = t("repl.role.tool"); color = yel; } - else { label = role; color = off; } + if (strcmp(role, "system") == 0) { + label = t("repl.role.system"); + color = dim; + } else if (strcmp(role, "user") == 0) { + label = t("repl.role.user"); + color = grn; + } else if (strcmp(role, "assistant") == 0) { + label = t("repl.role.assistant"); + color = cyan; + } else if (strcmp(role, "tool") == 0) { + label = t("repl.role.tool"); + color = yel; + } else { + label = role; + color = off; + } printf("\n%s-- %zu/%zu %s --%s\n", dim, i + 1, n, label, off); @@ -425,7 +434,8 @@ static void print_history(const Agent *a, const JsonValue *msgs) fputs(color, stdout); fputs(content, stdout); fputs(off, stdout); - if (content[strlen(content) - 1] != '\n') putchar('\n'); + if (content[strlen(content) - 1] != '\n') + putchar('\n'); } else if (!json_object_get(m, "tool_calls")) { printf("%s%s%s\n", dim, t("repl.no_content"), off); } @@ -439,19 +449,20 @@ static void print_history(const Agent *a, const JsonValue *msgs) const char *args = json_get_string(fn, "arguments"); const char *id = json_get_string(tc, "id"); printf("%s%s%s\n", dim, - tf("repl.tool_call", "name", name ? name : "?", - "id", id ? id : "", NULL), off); + tf("repl.tool_call", "name", name ? name : "?", "id", + id ? id : "", NULL), + off); if (args && *args) { printf("%s %s%s\n", dim, args, off); } } } - if (n == 0) printf("%s%s%s\n", dim, t("repl.archive_empty"), off); + if (n == 0) + printf("%s%s%s\n", dim, t("repl.archive_empty"), off); putchar('\n'); } -static void cmd_import(Agent *a, const char *arg) -{ +static void cmd_import(Agent *a, const char *arg) { ArchiveEntry *list = NULL; size_t n = archive_list(g_bindir ? g_bindir : ".", &list); @@ -473,8 +484,8 @@ static void cmd_import(Agent *a, const char *arg) } } else { char *dir = archive_dir(g_bindir ? g_bindir : "."); - printf("%s", tf("repl.import.list_header", "dir", dir, - "n", i18n_num((long long)n), NULL)); + printf("%s", tf("repl.import.list_header", "dir", dir, "n", + i18n_num((long long)n), NULL)); free(dir); for (size_t i = 0; i < n; i++) { if (list[i].count < 0) @@ -487,7 +498,10 @@ static void cmd_import(Agent *a, const char *arg) int intr = 0; char *line = line_edit(t("repl.import.pick_prompt"), &intr); - if (!line) { archive_list_free(list, n); return; } + if (!line) { + archive_list_free(list, n); + return; + } char *ans = str_trim(line); if (*ans == '\0') { @@ -500,8 +514,8 @@ static void cmd_import(Agent *a, const char *arg) long idx = strtol(ans, &numend, 10); if (numend != ans && *str_trim(numend) == '\0') { if (idx < 1 || (size_t)idx > n) { - printf("%s", tf("repl.index_out_of_range_plain", - "n", i18n_num(idx), NULL)); + printf("%s", tf("repl.index_out_of_range_plain", "n", + i18n_num(idx), NULL)); free(line); archive_list_free(list, n); return; @@ -510,8 +524,8 @@ static void cmd_import(Agent *a, const char *arg) } else { pick = find_entry(list, n, ans); if (!pick) { - printf("%s", tf("repl.import.not_found_cancelled", - "name", ans, NULL)); + printf("%s", tf("repl.import.not_found_cancelled", "name", ans, + NULL)); free(line); archive_list_free(list, n); return; @@ -521,24 +535,24 @@ static void cmd_import(Agent *a, const char *arg) } char err[512]; - JsonValue *msgs = archive_load(pick->path, NULL, NULL, NULL, NULL, NULL, - err, sizeof err); + JsonValue *msgs = + archive_load(pick->path, NULL, NULL, NULL, NULL, NULL, err, sizeof err); if (!msgs) { printf("%s", tf("repl.import.load_failed", "error", err, NULL)); archive_list_free(list, n); return; } char *loaded = xstrdup(pick->name); - agent_replace_history(a, msgs); /* 取得所有权 */ - /* 载入之后再数: 换成和 /history 一样的口径 (不含 system), + agent_replace_history(a, msgs); /* 取得所有权 */ + /* 载入之后再数: 换成和 /history 一样的口径 (不含 system), * 否则"已载入 N 条"和"历史消息 M 条"会差 1, 看着像出了错 */ size_t count = agent_conversation_len(a); /* 输入历史不跟着清: 它现在是一份持久的、跨会话的用户资源 (像 bash 的 * HISTFILE), 记的是"我在 ligent> 敲过什么", 和当前装的是哪段对话无关. */ - set_lineage(loaded); /* 之后的分支都挂在它下面 */ - printf("%s", tf("repl.import.done", "name", loaded, - "n", i18n_num((long long)count), NULL)); - /* 问一句再决定要不要把这段历史打出来 —— 直接回车就输出 (默认 Y), + set_lineage(loaded); /* 之后的分支都挂在它下面 */ + printf("%s", tf("repl.import.done", "name", loaded, "n", + i18n_num((long long)count), NULL)); + /* 问一句再决定要不要把这段历史打出来 —— 直接回车就输出 (默认 Y), * 历史很长、不想被刷屏时回 n 即可, 条数随时可以用 /history 看. */ if (ask_yes_no(t("repl.import.print_ask"))) print_history(a, a->messages); @@ -546,31 +560,28 @@ static void cmd_import(Agent *a, const char *arg) archive_list_free(list, n); } -static void repl_help(void) -{ - fputs(t("repl.help"), stdout); -} +static void repl_help(void) { fputs(t("repl.help"), stdout); } -/* 带参数命令的匹配: /name 之后必须是空白或字符串结束. +/* 带参数命令的匹配: /name 之后必须是空白或字符串结束. * * 只用前缀匹配是不够的 —— 那样 "/modelxxx" 会被当成 "/model" 并把 xxx * 当作模型名去切模型, "/thinkfoo" 也会落进 /think 分支. 命中返回参数起点 * (已跳过空白; 指进 dup 里, 调用方不要 free), 不命中返回 NULL. */ -static char *cmd_arg(char *cmd, const char *name) -{ +static char *cmd_arg(char *cmd, const char *name) { size_t n = strlen(name); - if (strncmp(cmd, name, n) != 0) return NULL; + if (strncmp(cmd, name, n) != 0) + return NULL; char c = cmd[n]; - if (c != '\0' && c != ' ' && c != '\t') return NULL; + if (c != '\0' && c != ' ' && c != '\t') + return NULL; return str_trim(cmd + n); } /* 返回 0 继续, 1 退出 */ -static int handle_command(Agent *a, LigentConfig *c, const char *line) -{ +static int handle_command(Agent *a, LigentConfig *c, const char *line) { char *dup = xstrdup(line); char *cmd = str_trim(dup); - char *arg = NULL; /* 当前分支的参数; 指向 dup 内部, 不单独释放 */ + char *arg = NULL; /* 当前分支的参数; 指向 dup 内部, 不单独释放 */ if (strcmp(cmd, "/exit") == 0 || strcmp(cmd, "/quit") == 0) { free(dup); @@ -582,18 +593,20 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line) } else if (strcmp(cmd, "/reset") == 0 || strcmp(cmd, "/clear") == 0) { auto_save(a, c, t("repl.why.before_reset"), NULL); agent_reset(a); - /* 刚存的是"上一段"对话, 和新开的这段没关系了, 所以把判重状态清掉. + /* 刚存的是"上一段"对话, 和新开的这段没关系了, 所以把判重状态清掉. * 不清的话会漏存: 判重只看 (条数, started_at), 而 time() 只有秒级 * 精度 —— 同一秒里重置、又聊出同样多条消息, 退出时就会被当成 * "内容没变"而跳过存档. */ g_saved_count = 0; g_saved_start = 0; - set_lineage(NULL); /* 新对话另起一个根 */ - back_undo_clear(); /* 旧的回退记录不再适用 */ + set_lineage(NULL); /* 新对话另起一个根 */ + back_undo_clear(); /* 旧的回退记录不再适用 */ puts(t("repl.reset.done")); } else if ((arg = cmd_arg(cmd, "/model")) != NULL) { - if (*arg) apply_model(a, c, arg); - else cmd_model_pick(a, c); + if (*arg) + apply_model(a, c, arg); + else + cmd_model_pick(a, c); } else if ((arg = cmd_arg(cmd, "/include")) != NULL) { cmd_include(a, arg); } else if ((arg = cmd_arg(cmd, "/system")) != NULL) { @@ -601,7 +614,7 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line) free(c->system); c->system = xstrdup(arg); agent_reset(a); - attach_clear(); /* 重新开始, 暂存的附件也一并丢掉 */ + attach_clear(); /* 重新开始, 暂存的附件也一并丢掉 */ agent_add_system(a, c->system); puts(t("repl.system.updated")); } else { @@ -621,49 +634,53 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line) a->llm.reasoning_effort = c->reasoning_effort; printf("%s", tf("repl.think.effort_set", "level", arg, NULL)); } else { - printf("%s", tf("repl.think.bad", "value", arg, - "list", config_effort_list(), NULL)); + printf("%s", tf("repl.think.bad", "value", arg, "list", + config_effort_list(), NULL)); } } else { printf(t("repl.think.mode"), config_thinking_name(c->thinking)); printf(t("repl.think.effort"), (c->reasoning_effort && *c->reasoning_effort) - ? c->reasoning_effort : t("cfg.val.server_default")); + ? c->reasoning_effort + : t("cfg.val.server_default")); } } else if (strcmp(cmd, "/meta") == 0) { - /* 进 meta 前先留一份保底存档: 里面全是不可逆的删改. + /* 进 meta 前先留一份保底存档: 里面全是不可逆的删改. * 用 tmp_ 前缀, 以免它变成以后分支的祖先. */ auto_save(a, c, t("repl.why.before_meta"), ARCHIVE_TMP_PREFIX); int r = meta_session(a, c); if (r == 1) { - /* 历史被改过: /back 的撤销栈基于旧历史, 作废; + /* 历史被改过: /back 的撤销栈基于旧历史, 作废; * 存档判重也清掉, 保证退出时一定存下这个新版本. */ back_undo_clear(); g_saved_count = 0; g_saved_start = 0; } } else if (strcmp(cmd, "/cls") == 0) { - /* 只清屏幕, 不碰对话历史 —— 和 /reset 是两回事. + /* 只清屏幕, 不碰对话历史 —— 和 /reset 是两回事. * 3J 连回滚缓冲一起清, 跟 clear(1) 的行为对齐; 不支持 3J 的终端会 * 忽略它, 2J + H 照样把可见区域清干净. */ - if (isatty(STDOUT_FILENO)) fputs("\033[2J\033[3J\033[H", stdout); + if (isatty(STDOUT_FILENO)) + fputs("\033[2J\033[3J\033[H", stdout); fflush(stdout); } else if (strcmp(cmd, "/tools") == 0) { if (a->use_tools) { /* 只查缓存, 绝不为了显示这一行去发探测请求 */ Capabilities caps; - tools_print_list(cap_cached(&a->llm, &caps) && caps.vision == CAP_NO); - } - else puts(t("repl.tools.disabled")); + tools_print_list(cap_cached(&a->llm, &caps) && + caps.vision == CAP_NO); + } else + puts(t("repl.tools.disabled")); } else if (strcmp(cmd, "/probe") == 0) { - /* 手动测一次模型能力 (收不收图片 / 收不收 tools). + /* 手动测一次模型能力 (收不收图片 / 收不收 tools). * 刻意绕开 --no-probe 与缓存 —— 理由见 agent_probe_now 的注释. */ agent_probe_now(a); } else if (strcmp(cmd, "/config") == 0) { config_dump(c); } else if ((arg = cmd_arg(cmd, "/cd")) != NULL) { if (*arg) { - if (chdir(arg) != 0) perror(t("repl.cd_failed")); + if (chdir(arg) != 0) + perror(t("repl.cd_failed")); else { char cwd[1024]; if (getcwd(cwd, sizeof cwd)) @@ -671,7 +688,8 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line) } } else { char cwd[1024]; - if (getcwd(cwd, sizeof cwd)) printf("%s\n", cwd); + if (getcwd(cwd, sizeof cwd)) + printf("%s\n", cwd); } } else if (strcmp(cmd, "/usage") == 0) { if (!a->usage_reported) { @@ -697,14 +715,16 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line) puts(t("repl.back.none")); } else { size_t rn = json_array_size(removed); - if (!g_back_undo) g_back_undo = json_new_array(); + if (!g_back_undo) + g_back_undo = json_new_array(); for (size_t i = 0; i < rn; i++) - json_array_push(g_back_undo, json_clone(json_array_get(removed, i))); + json_array_push(g_back_undo, + json_clone(json_array_get(removed, i))); json_free(removed); - g_back_len = agent_history_len(a); /* 比对用, 保持数组真实长度 */ - printf("%s", tf("repl.back.done", "n", i18n_num((long long)rn), - "left", i18n_num((long long)agent_conversation_len(a)), - NULL)); + g_back_len = agent_history_len(a); /* 比对用, 保持数组真实长度 */ + printf("%s", + tf("repl.back.done", "n", i18n_num((long long)rn), "left", + i18n_num((long long)agent_conversation_len(a)), NULL)); } } else if (strcmp(cmd, "/unback") == 0) { size_t n = g_back_undo ? json_array_size(g_back_undo) : 0; @@ -716,9 +736,9 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line) for (size_t i = 0; i < n; i++) json_array_push(a->messages, json_clone(json_array_get(g_back_undo, i))); - printf("%s", tf("repl.unback.done", "n", i18n_num((long long)n), - "total", i18n_num((long long)agent_conversation_len(a)), - NULL)); + printf("%s", + tf("repl.unback.done", "n", i18n_num((long long)n), "total", + i18n_num((long long)agent_conversation_len(a)), NULL)); back_undo_clear(); } } else if ((arg = cmd_arg(cmd, "/shell")) != NULL) { @@ -730,8 +750,8 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line) char *r = tool_execute(TOOL_RUN_COMMAND, args, &is_err); json_free(args); puts(r); - char *msg = xasprintf("我在 shell 里执行了: %s\n\n结果是: \n%s", - arg, r); + char *msg = + xasprintf("我在 shell 里执行了: %s\n\n结果是: \n%s", arg, r); agent_add_user(a, msg); free(msg); free(r); @@ -745,9 +765,10 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line) printf("%s", tf("repl.shell.start_failed", "error", err, NULL)); } else { if (rec[0]) { - char *msg = xasprintf( - "我在交互式 shell 里做了以下操作 (shell 退出码 %d): \n\n" - "```console\n%s\n```\n", code, rec); + char *msg = xasprintf("我在交互式 shell 里做了以下操作 " + "(shell 退出码 %d): \n\n" + "```console\n%s\n```\n", + code, rec); agent_add_user(a, msg); printf("\n%s\n", tf("repl.shell.recorded", "n", @@ -768,9 +789,9 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line) } else if ((arg = cmd_arg(cmd, "/log")) != NULL) { if (*arg == '\0') { printf("%s", tf("repl.log.status", "n", - i18n_num((long long)http_log_size()), - "trunc", http_log_truncated() - ? t("repl.log.truncated") : "", NULL)); + i18n_num((long long)http_log_size()), "trunc", + http_log_truncated() ? t("repl.log.truncated") : "", + NULL)); } else if (strcmp(arg, "clear") == 0 || strcmp(arg, "reset") == 0) { http_log_clear(); puts(t("repl.log.cleared")); @@ -782,15 +803,15 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line) else if (n == 0) printf("%s", tf("repl.log.empty", "file", arg, NULL)); else - printf("%s", tf("repl.log.saved", "n", - i18n_num((long long)n), "file", arg, NULL)); + printf("%s", tf("repl.log.saved", "n", i18n_num((long long)n), + "file", arg, NULL)); } } else if (strcmp(cmd, "/history") == 0) { - /* 一行摘要 + 完整对话. + /* 一行摘要 + 完整对话. * - * 摘要那行报的是"不含 system"的口径 (和 /usage、/back、/export 一致), + * 摘要那行报的是"不含 system"的口径 (和 /usage、/back、/export 一致), * 而下面列出来的是**含** system 的全部消息 —— 两个数不一样, 所以必须 - * 说清楚, 否则用户会以为打印漏了或者多了一条. + * 说清楚, 否则用户会以为打印漏了或者多了一条. * * 为什么要连 system 一起列: 那是"模型被要求怎么做"的原文, 想核对提示词 * 时只有这里能看到全文 (/config 里那行会把多行提示词挤成一团). */ @@ -806,22 +827,21 @@ static int handle_command(Agent *a, LigentConfig *c, const char *line) return 0; } -/* 输入历史最多保留多少条. 和 bash 一样有上限, 否则文件会一直长下去; +/* 输入历史最多保留多少条. 和 bash 一样有上限, 否则文件会一直长下去; * 只保留最新的, 旧的在下次启动时被就地截掉. */ #define LIGENT_HISTORY_KEEP 1000 /* 输入历史文件; NULL 表示禁用 (--no-history). */ static char *g_hist_path = NULL; -/* 提示符保持纯文本: 绿色由行编辑器统一加在整个输入行 (提示符 + 你敲的字)上. +/* 提示符保持纯文本: 绿色由行编辑器统一加在整个输入行 (提示符 + 你敲的字)上. * 在这里塞颜色的话, 提示符自带的 reset 会让后面的输入掉回默认色. */ #define PROMPT_MAIN "ligent> " #define PROMPT_CONT " ... " -/* 读一条完整输入. 支持行尾反斜杠续行: 每段都由行编辑器独立编辑, +/* 读一条完整输入. 支持行尾反斜杠续行: 每段都由行编辑器独立编辑, * 因此方向键、Home/End、历史在续行的每一段里都可用. */ -static char *read_prompt_line(const char *prompt, int *interrupted) -{ +static char *read_prompt_line(const char *prompt, int *interrupted) { StrBuf sb; sb_init(&sb); *interrupted = 0; @@ -841,7 +861,8 @@ static char *read_prompt_line(const char *prompt, int *interrupted) } /* EOF: 缓冲区里还有内容就把它交出去, 否则终止 */ if (sb.len > 0) { - if (sb.data[sb.len - 1] != '\n') sb_putc(&sb, '\n'); + if (sb.data[sb.len - 1] != '\n') + sb_putc(&sb, '\n'); return sb_detach(&sb); } sb_free(&sb); @@ -857,13 +878,16 @@ static char *read_prompt_line(const char *prompt, int *interrupted) size_t n = strlen(seg); int cont = 0; - if (n > 0 && seg[n - 1] == '\\') { seg[n - 1] = '\0'; cont = 1; } + if (n > 0 && seg[n - 1] == '\\') { + seg[n - 1] = '\0'; + cont = 1; + } sb_puts(&sb, seg); free(seg); if (!cont) { sb_putc(&sb, '\n'); - /* 历史记的是"这一整条输入": 多行续行算一条, 上下键能整条召回. + /* 历史记的是"这一整条输入": 多行续行算一条, 上下键能整条召回. * 续行的中间段不单独记. 这里借尾部那个换行临时当结束符. */ if (sb.len > 1) { sb.data[sb.len - 1] = '\0'; @@ -878,10 +902,9 @@ static char *read_prompt_line(const char *prompt, int *interrupted) return sb_detach(&sb); } -static int run_repl(Agent *a, LigentConfig *c) -{ +static int run_repl(Agent *a, LigentConfig *c) { g_bindir = c->config_dir; - /* 图片备份也要落在二进制旁边. 这个函数一度**从来没有被调用过**, + /* 图片备份也要落在二进制旁边. 这个函数一度**从来没有被调用过**, * image_bindir() 于是退化成 ".", 备份跟着当前工作目录跑 —— /cd 到别的 * 目录再带图, 备份就写到那儿去了, 之后从别处发消息会因为找不到备份而 * 把图降级掉. 测试一直从二进制所在目录跑, "." 恰好等于正确值, 所以 @@ -892,16 +915,21 @@ static int run_repl(Agent *a, LigentConfig *c) int interrupted = 0; char *line = read_prompt_line(PROMPT_MAIN, &interrupted); if (!line) { - if (interrupted) continue; - break; /* EOF */ + if (interrupted) + continue; + break; /* EOF */ } char *trimmed = str_trim(line); - if (*trimmed == '\0') { free(line); continue; } + if (*trimmed == '\0') { + free(line); + continue; + } if (*trimmed == '/') { int quit = handle_command(a, c, trimmed); free(line); - if (quit) break; + if (quit) + break; continue; } @@ -924,13 +952,12 @@ static int run_repl(Agent *a, LigentConfig *c) /* main */ /* ------------------------------------------------------------------ */ -int main(int argc, char **argv) -{ +int main(int argc, char **argv) { LigentConfig cfg; 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 就来不及了. */ { @@ -945,7 +972,7 @@ int main(int argc, char **argv) break; } } - i18n_init(lang); /* -1 = 按环境探测 */ + i18n_init(lang); /* -1 = 按环境探测 */ } /* 先用一个临时结构收集命令行覆盖项 */ @@ -985,7 +1012,8 @@ int main(int argc, char **argv) const char *eq = strchr(arg, '='); if (eq) { size_t n = (size_t)(eq - arg); - if (n >= sizeof optbuf) n = sizeof optbuf - 1; + if (n >= sizeof optbuf) + n = sizeof optbuf - 1; memcpy(optbuf, arg, n); optbuf[n] = '\0'; arg = optbuf; @@ -993,16 +1021,17 @@ int main(int argc, char **argv) } } -#define NEED_VAL() \ - do { \ - if (!val) { \ - if (i + 1 >= argc) { \ - fprintf(stderr, "%s", tf("cli.err.missing_value", "option", arg, NULL)); \ - return 2; \ - } \ - val = argv[++i]; \ - } \ - } while (0) +#define NEED_VAL() \ + do { \ + if (!val) { \ + if (i + 1 >= argc) { \ + fprintf(stderr, "%s", \ + tf("cli.err.missing_value", "option", arg, NULL)); \ + return 2; \ + } \ + val = argv[++i]; \ + } \ + } while (0) if (strcmp(arg, "-h") == 0 || strcmp(arg, "--help") == 0) { print_usage(stdout); @@ -1011,97 +1040,138 @@ int main(int argc, char **argv) printf("ligent %s\n", ligent_version_full()); return 0; } else if (strcmp(arg, "-m") == 0 || strcmp(arg, "--model") == 0) { - NEED_VAL(); cli.model = val; + NEED_VAL(); + cli.model = val; } else if (strcmp(arg, "-u") == 0 || strcmp(arg, "--base-url") == 0) { - NEED_VAL(); cli.base_url = val; + NEED_VAL(); + cli.base_url = val; } else if (strcmp(arg, "-k") == 0 || strcmp(arg, "--api-key") == 0) { - NEED_VAL(); cli.api_key = val; + NEED_VAL(); + cli.api_key = val; } else if (strcmp(arg, "-c") == 0 || strcmp(arg, "--config") == 0) { - NEED_VAL(); explicit_config = val; + NEED_VAL(); + explicit_config = val; } else if (strcmp(arg, "--history") == 0) { - NEED_VAL(); cli.history = val; cli.history_set = 1; cli.history_off = 0; + NEED_VAL(); + cli.history = val; + cli.history_set = 1; + cli.history_off = 0; } else if (strcmp(arg, "--no-history") == 0) { - cli.history_set = 1; cli.history_off = 1; + cli.history_set = 1; + cli.history_off = 1; } else if (strcmp(arg, "-s") == 0 || strcmp(arg, "--system") == 0) { - NEED_VAL(); cli.system = val; - } else if (strcmp(arg, "-t") == 0 || strcmp(arg, "--temperature") == 0) { - NEED_VAL(); cli.temperature = atof(val); cli.have_temperature = 1; + NEED_VAL(); + cli.system = val; + } else if (strcmp(arg, "-t") == 0 || + strcmp(arg, "--temperature") == 0) { + NEED_VAL(); + cli.temperature = atof(val); + cli.have_temperature = 1; } else if (strcmp(arg, "--max-tokens") == 0) { - NEED_VAL(); cli.max_tokens = atol(val); cli.have_max_tokens = 1; - } else if (strcmp(arg, "--thinking") == 0 || strcmp(arg, "--think") == 0) { + NEED_VAL(); + cli.max_tokens = atol(val); + cli.have_max_tokens = 1; + } else if (strcmp(arg, "--thinking") == 0 || + strcmp(arg, "--think") == 0) { NEED_VAL(); if (config_parse_thinking(val, &cli.thinking) != 0) { - fprintf(stderr, "%s", tf("cli.err.bad_thinking", "value", val, NULL)); + fprintf(stderr, "%s", + tf("cli.err.bad_thinking", "value", val, NULL)); return 2; } cli.thinking_set = 1; - } else if (strcmp(arg, "--effort") == 0 || strcmp(arg, "--reasoning-effort") == 0) { + } else if (strcmp(arg, "--effort") == 0 || + strcmp(arg, "--reasoning-effort") == 0) { NEED_VAL(); if (!config_effort_valid(val)) { - fprintf(stderr, "%s", tf("cli.err.bad_effort", "list", - config_effort_list(), "value", val, NULL)); + fprintf(stderr, "%s", + tf("cli.err.bad_effort", "list", config_effort_list(), + "value", val, NULL)); return 2; } cli.effort = val; } else if (strcmp(arg, "--max-steps") == 0) { - NEED_VAL(); cli.max_steps = atoi(val); cli.have_max_steps = 1; + NEED_VAL(); + cli.max_steps = atoi(val); + cli.have_max_steps = 1; } else if (strcmp(arg, "--timeout") == 0) { - NEED_VAL(); cli.timeout = atol(val); cli.have_timeout = 1; + NEED_VAL(); + cli.timeout = atol(val); + cli.have_timeout = 1; } else if (strcmp(arg, "--retries") == 0) { - NEED_VAL(); cli.retries = atoi(val); cli.have_retries = 1; + NEED_VAL(); + cli.retries = atoi(val); + cli.have_retries = 1; } else if (strcmp(arg, "--no-stream") == 0) { - cli.stream_set = 1; cli.stream_val = 0; + cli.stream_set = 1; + cli.stream_val = 0; } else if (strcmp(arg, "--stream") == 0) { - cli.stream_set = 1; cli.stream_val = 1; - } else if (strcmp(arg, "doctor") == 0 && !cli.doctor && cli.prompt.len == 0) { - cli.doctor = 1; /* 子命令: ligent doctor */ + cli.stream_set = 1; + cli.stream_val = 1; + } else if (strcmp(arg, "doctor") == 0 && !cli.doctor && + cli.prompt.len == 0) { + cli.doctor = 1; /* 子命令: ligent doctor */ } else if (strcmp(arg, "--offline") == 0) { cli.offline = 1; } else if (strcmp(arg, "--no-confirm") == 0) { - cli.confirm_set = 1; cli.confirm_val = 0; + cli.confirm_set = 1; + cli.confirm_val = 0; } else if (strcmp(arg, "--confirm") == 0) { - cli.confirm_set = 1; cli.confirm_val = 1; + cli.confirm_set = 1; + cli.confirm_val = 1; } else if (strcmp(arg, "--no-auto-save") == 0) { - cli.autosave_set = 1; cli.autosave_val = 0; + cli.autosave_set = 1; + cli.autosave_val = 0; } else if (strcmp(arg, "--auto-save") == 0) { - cli.autosave_set = 1; cli.autosave_val = 1; + cli.autosave_set = 1; + cli.autosave_val = 1; } else if (strcmp(arg, "--no-probe") == 0) { - cli.probe_set = 1; cli.probe_val = 0; + cli.probe_set = 1; + cli.probe_val = 0; } else if (strcmp(arg, "--probe") == 0) { - cli.probe_set = 1; cli.probe_val = 1; + cli.probe_set = 1; + cli.probe_val = 1; } else if (strcmp(arg, "--no-tools") == 0) { - cli.tools_set = 1; cli.tools_val = 0; + cli.tools_set = 1; + cli.tools_val = 0; } else if (strcmp(arg, "--tools") == 0) { - cli.tools_set = 1; cli.tools_val = 1; + cli.tools_set = 1; + cli.tools_val = 1; } else if (strcmp(arg, "-q") == 0 || strcmp(arg, "--quiet") == 0) { cli.quiet = 1; } else if (strcmp(arg, "-v") == 0 || strcmp(arg, "--verbose") == 0) { cli.verbose = 1; } else if (strcmp(arg, "--color") == 0) { - cli.color_set = 1; cli.color_val = 1; + cli.color_set = 1; + cli.color_val = 1; } else if (strcmp(arg, "--no-color") == 0) { - cli.color_set = 1; cli.color_val = 0; + cli.color_set = 1; + cli.color_val = 0; } else if (strcmp(arg, "--lang") == 0) { NEED_VAL(); int l = i18n_parse_lang(val); if (l < 0 && strcmp(val, "auto") != 0) { - fprintf(stderr, "%s", tf("cli.err.bad_lang", "value", val, NULL)); + 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) { for (int j = i + 1; j < argc; j++) { - if (cli.prompt.len) sb_putc(&cli.prompt, ' '); + if (cli.prompt.len) + sb_putc(&cli.prompt, ' '); sb_puts(&cli.prompt, argv[j]); } break; } else if (arg[0] == '-' && arg[1] != '\0') { - fprintf(stderr, "%s", tf("cli.err.unknown_option", "option", arg, NULL)); + fprintf(stderr, "%s", + tf("cli.err.unknown_option", "option", arg, NULL)); return 2; } else { - if (cli.prompt.len) sb_putc(&cli.prompt, ' '); + if (cli.prompt.len) + sb_putc(&cli.prompt, ' '); sb_puts(&cli.prompt, argv[i]); } #undef NEED_VAL @@ -1118,26 +1188,49 @@ int main(int argc, char **argv) } snprintf(cfg_error, sizeof cfg_error, "%s", err); } - if (cli.model) { free(cfg.model); cfg.model = xstrdup(cli.model); } - if (cli.base_url) { free(cfg.base_url); cfg.base_url = xstrdup(cli.base_url); } - if (cli.api_key) { free(cfg.api_key); cfg.api_key = xstrdup(cli.api_key); - cfg.api_key_from_cli = 1; } - if (cli.system && *cli.system) { free(cfg.system); cfg.system = xstrdup(cli.system); } - if (cli.have_temperature) cfg.temperature = cli.temperature; - if (cli.have_max_tokens) cfg.max_tokens = cli.max_tokens; - if (cli.thinking_set) cfg.thinking = cli.thinking; + if (cli.model) { + free(cfg.model); + cfg.model = xstrdup(cli.model); + } + if (cli.base_url) { + free(cfg.base_url); + cfg.base_url = xstrdup(cli.base_url); + } + if (cli.api_key) { + free(cfg.api_key); + cfg.api_key = xstrdup(cli.api_key); + cfg.api_key_from_cli = 1; + } + if (cli.system && *cli.system) { + free(cfg.system); + cfg.system = xstrdup(cli.system); + } + if (cli.have_temperature) + cfg.temperature = cli.temperature; + if (cli.have_max_tokens) + cfg.max_tokens = cli.max_tokens; + if (cli.thinking_set) + cfg.thinking = cli.thinking; if (cli.effort) { free(cfg.reasoning_effort); cfg.reasoning_effort = xstrdup(cli.effort); } - if (cli.have_timeout) cfg.timeout = cli.timeout; - if (cli.have_max_steps) cfg.max_steps = cli.max_steps; - if (cli.have_retries) cfg.retries = cli.retries; - if (cli.stream_set) cfg.stream = cli.stream_val; - if (cli.tools_set) cfg.use_tools = cli.tools_val; - if (cli.probe_set) cfg.probe = cli.probe_val; - if (cli.confirm_set) cfg.confirm = cli.confirm_val; - if (cli.autosave_set) cfg.auto_save = cli.autosave_val; + if (cli.have_timeout) + cfg.timeout = cli.timeout; + if (cli.have_max_steps) + cfg.max_steps = cli.max_steps; + if (cli.have_retries) + cfg.retries = cli.retries; + if (cli.stream_set) + cfg.stream = cli.stream_val; + if (cli.tools_set) + cfg.use_tools = cli.tools_val; + if (cli.probe_set) + cfg.probe = cli.probe_val; + if (cli.confirm_set) + cfg.confirm = cli.confirm_val; + if (cli.autosave_set) + cfg.auto_save = cli.autosave_val; /* doctor 要在"缺 key 直接退出"之前跑 —— 没配好正是它要诊断的场景 */ if (cli.doctor) { @@ -1176,9 +1269,11 @@ int main(int argc, char **argv) agent.show_tool_output = !cli.quiet; confirm_init(cfg.confirm); g_auto_save_on = cfg.auto_save; - if (cli.color_set) agent_set_color(&agent, cli.color_val); - else agent_set_color(&agent, isatty(fileno(stdout)) ? 1 : 0); - line_edit_set_color(agent.color); /* 输入行跟正文用同一个配色开关 */ + if (cli.color_set) + agent_set_color(&agent, cli.color_val); + else + agent_set_color(&agent, isatty(fileno(stdout)) ? 1 : 0); + line_edit_set_color(agent.color); /* 输入行跟正文用同一个配色开关 */ if (cfg.system) { agent_add_system(&agent, cfg.system); @@ -1196,9 +1291,9 @@ int main(int argc, char **argv) int exit_code = 0; g_bindir = cfg.config_dir; - image_set_bindir(cfg.config_dir); /* 理由见 run_repl 里那段注释 */ + image_set_bindir(cfg.config_dir); /* 理由见 run_repl 里那段注释 */ - /* 输入历史文件: 命令行 > 环境变量 > <二进制目录>/ligent.history. + /* 输入历史文件: 命令行 > 环境变量 > <二进制目录>/ligent.history. * 和配置一样, 默认只放二进制旁边, 不碰用户级/系统级目录. */ { const char *env = getenv("LIGENT_HISTORY"); @@ -1213,7 +1308,8 @@ int main(int argc, char **argv) } /* 读不回来不是错误: 第一次跑本来就没有这个文件, 只读的目录也不该 * 拦住 REPL. 加载失败静默降级成"这次没有历史". */ - if (g_hist_path) line_history_load(g_hist_path, LIGENT_HISTORY_KEEP); + if (g_hist_path) + line_history_load(g_hist_path, LIGENT_HISTORY_KEEP); } if (cli.prompt.len > 0) { diff --git a/src/markdown.c b/src/markdown.c index 18588e8..998f048 100644 --- a/src/markdown.c +++ b/src/markdown.c @@ -1,27 +1,27 @@ /* markdown.c - 流式 Markdown 渲染状态机 * * 核心约束: SSE 的分片位置是任意的, 一个标记 (`**`、行首的 `#`、`[`) - * 完全可能被切成两片. 因此遇到"可能是标记前缀"的尾部时不能立即输出, - * 必须把这段字节留在缓冲区里等下一个分片凑齐后再判定. + * 完全可能被切成两片. 因此遇到"可能是标记前缀"的尾部时不能立即输出, + * 必须把这段字节留在缓冲区里等下一个分片凑齐后再判定. */ #include "markdown.h" -#include "util.h" #include "sgr.h" +#include "util.h" #include #include #include -#define MD_RESET C_OFF -#define MD_BOLD C_BOLD -#define MD_DIM C_DIM +#define MD_RESET C_OFF +#define MD_BOLD C_BOLD +#define MD_DIM C_DIM #define MD_ITALIC C_ITALIC -#define MD_CODE C_CYAN +#define MD_CODE C_CYAN #define MD_BULLET C_CYAN -#define MD_LINK C_UNDERLINE C_CYAN -#define MD_H1 C_BOLD C_CYAN -#define MD_H2 C_BOLD C_MAGENTA -#define MD_H3 C_BOLD C_BLUE -#define MD_H4 C_BOLD +#define MD_LINK C_UNDERLINE C_CYAN +#define MD_H1 C_BOLD C_CYAN +#define MD_H2 C_BOLD C_MAGENTA +#define MD_H3 C_BOLD C_BLUE +#define MD_H4 C_BOLD /* 等待更多数据 (当前字节可能是一个被切断的标记) */ #define MD_NEED_MORE ((size_t)-1) @@ -36,55 +36,54 @@ #define MD_MAX_TABLE_ROWS 256 struct MdRenderer { - int enabled; - FILE *out; - StrBuf in; /* 跨分片的残留数据 */ + int enabled; + FILE *out; + StrBuf in; /* 跨分片的残留数据 */ - int line_start; /* 下一个字符是否位于行首 (块级语法只在行首生效) */ - int line_style; /* 本行处于行级样式 (标题)中, 换行时要复位 */ - int last_ch; /* 上一个已消费的输入字节, 用于 _ 和 [ 的边界判断 */ + int line_start; /* 下一个字符是否位于行首 (块级语法只在行首生效) */ + int line_style; /* 本行处于行级样式 (标题)中, 换行时要复位 */ + int last_ch; /* 上一个已消费的输入字节, 用于 _ 和 [ 的边界判断 */ - int in_fence; /* 位于围栏代码块内 */ + int in_fence; /* 位于围栏代码块内 */ char fence_char; - int fence_len; + int fence_len; int in_bold; int in_italic; - int in_code; /* 行内代码 */ - int in_bracket; /* 位于 [链接文字] 内 */ + int in_code; /* 行内代码 */ + int in_bracket; /* 位于 [链接文字] 内 */ int bracket_len; - int in_table; /* 正在累积一个表格 */ - StrBuf table; /* 表格原始行 */ - int table_rows; + int in_table; /* 正在累积一个表格 */ + StrBuf table; /* 表格原始行 */ + int table_rows; - /* 置位后表示"不会再有新数据", 所有挂起都必须就地解开. - * 这是"输出与分片方式无关"的关键: 挂起只允许发生在还有后续数据时, + /* 置位后表示"不会再有新数据", 所有挂起都必须就地解开. + * 这是"输出与分片方式无关"的关键: 挂起只允许发生在还有后续数据时, * 到 EOF 一律按固定规则判定, 决策因此只依赖字节流本身. */ - int eof; + int eof; }; /* ------------------------------------------------------------------ */ /* 输出原语 */ /* ------------------------------------------------------------------ */ -static void put(MdRenderer *r, const char *s, size_t n) -{ - if (n) fwrite(s, 1, n, r->out); +static void put(MdRenderer *r, const char *s, size_t n) { + if (n) + fwrite(s, 1, n, r->out); } static void putc_str(MdRenderer *r, const char *s) { fputs(s, r->out); } -static void style(MdRenderer *r, const char *code) -{ - if (r->enabled) fputs(code, r->out); +static void style(MdRenderer *r, const char *code) { + if (r->enabled) + fputs(code, r->out); } /* 行尾清掉所有行内/行级样式, 避免染到下一行 */ -static void close_line_styles(MdRenderer *r) -{ - if (r->in_bold || r->in_italic || r->in_code || - r->in_bracket || r->line_style) { +static void close_line_styles(MdRenderer *r) { + if (r->in_bold || r->in_italic || r->in_code || r->in_bracket || + r->line_style) { style(r, MD_RESET); r->in_bold = r->in_italic = r->in_code = 0; r->in_bracket = 0; @@ -106,33 +105,45 @@ typedef struct { typedef enum { ALIGN_LEFT, ALIGN_CENTER, ALIGN_RIGHT } TblAlign; -static void row_free(TblRow *row) -{ - for (size_t i = 0; i < row->n; i++) free(row->cell[i]); +static void row_free(TblRow *row) { + for (size_t i = 0; i < row->n; i++) + free(row->cell[i]); free(row->cell); row->cell = NULL; row->n = 0; } /* 按 '|' 拆分一行, 去掉首尾空白与首尾空单元格 */ -static void row_split(const char *line, size_t n, TblRow *out) -{ +static void row_split(const char *line, size_t n, TblRow *out) { out->cell = NULL; out->n = 0; size_t cap = 0; - while (n > 0 && (line[0] == ' ' || line[0] == '\t')) { line++; n--; } - while (n > 0 && (line[n - 1] == ' ' || line[n - 1] == '\t')) n--; - if (n > 0 && line[0] == '|') { line++; n--; } - if (n > 0 && line[n - 1] == '|') n--; + while (n > 0 && (line[0] == ' ' || line[0] == '\t')) { + line++; + n--; + } + while (n > 0 && (line[n - 1] == ' ' || line[n - 1] == '\t')) + n--; + if (n > 0 && line[0] == '|') { + line++; + n--; + } + if (n > 0 && line[n - 1] == '|') + n--; size_t start = 0; for (size_t i = 0; i <= n; i++) { - if (i != n && line[i] != '|') continue; + if (i != n && line[i] != '|') + continue; const char *c = line + start; size_t cl = i - start; - while (cl > 0 && (c[0] == ' ' || c[0] == '\t')) { c++; cl--; } - while (cl > 0 && (c[cl - 1] == ' ' || c[cl - 1] == '\t')) cl--; + while (cl > 0 && (c[0] == ' ' || c[0] == '\t')) { + c++; + cl--; + } + while (cl > 0 && (c[cl - 1] == ' ' || c[cl - 1] == '\t')) + cl--; if (out->n == cap) { cap = cap ? cap * 2 : 8; out->cell = xrealloc(out->cell, cap * sizeof(char *)); @@ -143,37 +154,42 @@ static void row_split(const char *line, size_t n, TblRow *out) } /* 分隔单元格: :?-+:? */ -static int is_delim_cell(const char *c, size_t n) -{ +static int is_delim_cell(const char *c, size_t n) { size_t i = 0, dashes = 0; - if (i < n && c[i] == ':') i++; - while (i < n && c[i] == '-') { i++; dashes++; } - if (dashes == 0) return 0; - if (i < n && c[i] == ':') i++; + if (i < n && c[i] == ':') + i++; + while (i < n && c[i] == '-') { + i++; + dashes++; + } + if (dashes == 0) + return 0; + if (i < n && c[i] == ':') + i++; return i == n; } -/* 表头行 + 分隔行是否构成合法表格: +/* 表头行 + 分隔行是否构成合法表格: * 两者单元格数必须相同, 且第二行每格都是 :?-+:? * (这条校验同时挡掉了 "cat a | grep b" 跟着一行 "--------" 的误判) */ -static int table_head_ok(const char *h, size_t hn, const char *d, size_t dn) -{ +static int table_head_ok(const char *h, size_t hn, const char *d, size_t dn) { TblRow hrow, drow; row_split(h, hn, &hrow); row_split(d, dn, &drow); int ok = (hrow.n > 0) && (hrow.n == drow.n); for (size_t i = 0; ok && i < drow.n; i++) - if (!is_delim_cell(drow.cell[i], strlen(drow.cell[i]))) ok = 0; + if (!is_delim_cell(drow.cell[i], strlen(drow.cell[i]))) + ok = 0; row_free(&hrow); row_free(&drow); return ok; } -static int count_pipes(const char *s, size_t n) -{ +static int count_pipes(const char *s, size_t n) { int c = 0; for (size_t i = 0; i < n; i++) - if (s[i] == '|') c++; + if (s[i] == '|') + c++; return c; } @@ -181,27 +197,35 @@ static int count_pipes(const char *s, size_t n) /* 表格: 渲染 */ /* ------------------------------------------------------------------ */ -static void emit_padded(MdRenderer *r, const char *cell, size_t width, TblAlign al) -{ +static void emit_padded(MdRenderer *r, const char *cell, size_t width, + TblAlign al) { size_t w = display_width(cell, strlen(cell)); size_t pad = width > w ? width - w : 0; size_t left = 0, right = pad; - if (al == ALIGN_CENTER) { left = pad / 2; right = pad - left; } - else if (al == ALIGN_RIGHT) { left = pad; right = 0; } + if (al == ALIGN_CENTER) { + left = pad / 2; + right = pad - left; + } else if (al == ALIGN_RIGHT) { + left = pad; + right = 0; + } - for (size_t i = 0; i < left; i++) putc_str(r, " "); + for (size_t i = 0; i < left; i++) + putc_str(r, " "); putc_str(r, cell); - for (size_t i = 0; i < right; i++) putc_str(r, " "); + for (size_t i = 0; i < right; i++) + putc_str(r, " "); } static void emit_rule(MdRenderer *r, const size_t *w, size_t ncols, - const char *left, const char *mid, const char *right) -{ + const char *left, const char *mid, const char *right) { style(r, MD_DIM); putc_str(r, left); for (size_t c = 0; c < ncols; c++) { - for (size_t k = 0; k < w[c] + 2; k++) putc_str(r, "─"); - if (c + 1 < ncols) putc_str(r, mid); + for (size_t k = 0; k < w[c] + 2; k++) + putc_str(r, "─"); + if (c + 1 < ncols) + putc_str(r, mid); } putc_str(r, right); style(r, MD_RESET); @@ -209,14 +233,14 @@ static void emit_rule(MdRenderer *r, const size_t *w, size_t ncols, } static void emit_row(MdRenderer *r, const TblRow *row, const size_t *w, - const TblAlign *al, size_t ncols, int header) -{ + const TblAlign *al, size_t ncols, int header) { style(r, MD_DIM); putc_str(r, "│"); for (size_t c = 0; c < ncols; c++) { putc_str(r, " "); style(r, MD_RESET); - if (header) style(r, MD_BOLD); + if (header) + style(r, MD_BOLD); emit_padded(r, c < row->n ? row->cell[c] : "", w[c], al[c]); style(r, MD_RESET); style(r, MD_DIM); @@ -227,18 +251,19 @@ static void emit_row(MdRenderer *r, const TblRow *row, const size_t *w, } /* 把 r->table 里累积的原始行渲染成带边框的表格 */ -static void table_flush(MdRenderer *r) -{ +static void table_flush(MdRenderer *r) { r->in_table = 0; r->table_rows = 0; - if (r->table.len == 0) return; + if (r->table.len == 0) + return; TblRow *rows = NULL; size_t nrows = 0, cap = 0; char *data = r->table.data; size_t start = 0; for (size_t i = 0; i <= r->table.len; i++) { - if (i != r->table.len && data[i] != '\n') continue; + if (i != r->table.len && data[i] != '\n') + continue; size_t ln = i - start; if (ln > 0) { if (nrows == cap) { @@ -252,46 +277,59 @@ static void table_flush(MdRenderer *r) } r->table.len = 0; - if (nrows < 2) { /* 不成表, 原样吐出 */ + if (nrows < 2) { /* 不成表, 原样吐出 */ for (size_t i = 0; i < nrows; i++) { for (size_t c = 0; c < rows[i].n; c++) { - if (c) putc_str(r, " | "); + if (c) + putc_str(r, " | "); putc_str(r, rows[i].cell[c]); } putc_str(r, "\n"); } - for (size_t i = 0; i < nrows; i++) row_free(&rows[i]); + for (size_t i = 0; i < nrows; i++) + row_free(&rows[i]); free(rows); return; } size_t ncols = 0; for (size_t i = 0; i < nrows; i++) - if (rows[i].n > ncols) ncols = rows[i].n; - if (ncols == 0) { free(rows); return; } + if (rows[i].n > ncols) + ncols = rows[i].n; + if (ncols == 0) { + free(rows); + return; + } /* 对齐方式来自分隔行 (第 1 行) */ TblAlign *al = xmalloc(ncols * sizeof(TblAlign)); - for (size_t c = 0; c < ncols; c++) al[c] = ALIGN_LEFT; + for (size_t c = 0; c < ncols; c++) + al[c] = ALIGN_LEFT; for (size_t c = 0; c < rows[1].n && c < ncols; c++) { const char *s = rows[1].cell[c]; size_t n = strlen(s); int l = (n > 0 && s[0] == ':'); int rr = (n > 0 && s[n - 1] == ':'); - if (l && rr) al[c] = ALIGN_CENTER; - else if (rr) al[c] = ALIGN_RIGHT; + if (l && rr) + al[c] = ALIGN_CENTER; + else if (rr) + al[c] = ALIGN_RIGHT; } /* 列宽由表头与所有数据行决定 (跳过第 1 行的分隔行) */ size_t *w = xcalloc(ncols, sizeof(size_t)); for (size_t i = 0; i < nrows; i++) { - if (i == 1) continue; + if (i == 1) + continue; for (size_t c = 0; c < rows[i].n && c < ncols; c++) { size_t cw = display_width(rows[i].cell[c], strlen(rows[i].cell[c])); - if (cw > w[c]) w[c] = cw; + if (cw > w[c]) + w[c] = cw; } } - for (size_t c = 0; c < ncols; c++) if (w[c] == 0) w[c] = 1; + for (size_t c = 0; c < ncols; c++) + if (w[c] == 0) + w[c] = 1; emit_rule(r, w, ncols, "┌", "┬", "┐"); emit_row(r, &rows[0], w, al, ncols, 1); @@ -300,7 +338,8 @@ static void table_flush(MdRenderer *r) emit_row(r, &rows[i], w, al, ncols, 0); emit_rule(r, w, ncols, "└", "┴", "┘"); - for (size_t i = 0; i < nrows; i++) row_free(&rows[i]); + for (size_t i = 0; i < nrows; i++) + row_free(&rows[i]); free(rows); free(w); free(al); @@ -308,43 +347,52 @@ static void table_flush(MdRenderer *r) /* ------------------------------------------------------------------ */ /* 块级语法: 只在行首调用 */ -/* 返回消耗的字节数; 0 表示"不是块级语法, 按普通字符处理"; +/* 返回消耗的字节数; 0 表示"不是块级语法, 按普通字符处理"; * MD_NEED_MORE 表示需要更多数据才能判定 */ /* ------------------------------------------------------------------ */ -static size_t scan_line(const char *s, size_t len, size_t i, int *has_nl) -{ +static size_t scan_line(const char *s, size_t len, size_t i, int *has_nl) { size_t j = i; - while (j < len && s[j] != '\n') j++; + while (j < len && s[j] != '\n') + j++; *has_nl = (j < len); return j - i; } -/* 规则表: 从上往下依次尝试. - * 关键约定: 每条规则只有在"确实消费了字节"时才 return; +/* 规则表: 从上往下依次尝试. + * 关键约定: 每条规则只有在"确实消费了字节"时才 return; * 判定为"不适用"时必须继续往下走, 绝不能中途 return 0 —— * 否则会跳过后面所有规则 (早期版本正是因此让表格探测吃掉了列表规则). */ -static size_t try_block(MdRenderer *r, size_t i) -{ +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; + if (s[i] != r->fence_char) + return 0; size_t run = 0; - while (i + run < len && s[i + run] == r->fence_char) run++; - if (i + run >= len && !r->eof) return MD_NEED_MORE; - if ((int)run < r->fence_len) return 0; + while (i + run < len && s[i + run] == r->fence_char) + run++; + if (i + run >= len && !r->eof) + return MD_NEED_MORE; + if ((int)run < r->fence_len) + return 0; size_t j = i + run; - while (j < len && (s[j] == ' ' || s[j] == '\t')) j++; - if (j >= len && !r->eof) return MD_NEED_MORE; - if (j < len && s[j] != '\n') return 0; /* 围栏后还有内容, 不是闭合行 */ + while (j < len && (s[j] == ' ' || s[j] == '\t')) + j++; + if (j >= len && !r->eof) + return MD_NEED_MORE; + if (j < len && s[j] != '\n') + return 0; /* 围栏后还有内容, 不是闭合行 */ size_t emit = j - i; put(r, s + i, emit); - if (j < len) { put(r, "\n", 1); emit++; } + if (j < len) { + put(r, "\n", 1); + emit++; + } r->in_fence = 0; r->line_start = 1; return emit; @@ -354,7 +402,8 @@ static size_t try_block(MdRenderer *r, size_t i) if (r->in_table) { int has_nl; size_t ln = scan_line(s, len, i, &has_nl); - if (!has_nl && !r->eof) return MD_NEED_MORE; + if (!has_nl && !r->eof) + return MD_NEED_MORE; if (ln > 0 && count_pipes(s + i, ln) >= 1) { sb_append(&r->table, s + i, ln); sb_putc(&r->table, '\n'); @@ -366,22 +415,23 @@ static size_t try_block(MdRenderer *r, size_t i) return ln + (has_nl ? 1 : 0); } table_flush(r); - /* 表格到此结束. 注意不 return: 当前行可能是个标题/列表, + /* 表格到此结束. 注意不 return: 当前行可能是个标题/列表, * 要继续往下走让它被正常渲染. */ } /* ---- 标题 # ~ ###### ---- */ if (s[i] == '#') { size_t run = 0; - while (i + run < len && s[i + run] == '#') run++; + while (i + run < len && s[i + run] == '#') + run++; if (i + run >= len && !r->eof) { - if (run <= 6) return MD_NEED_MORE; + if (run <= 6) + return MD_NEED_MORE; /* run > 6: 确定不是标题, 继续往下判断 */ } else if (run >= 1 && run <= 6 && (s[i + run] == ' ' || s[i + run] == '\t')) { - static const char *codes[6] = { - MD_H1, MD_H2, MD_H3, MD_H4, MD_H4, MD_H4 - }; + static const char *codes[6] = {MD_H1, MD_H2, MD_H3, + MD_H4, MD_H4, MD_H4}; style(r, codes[run - 1]); put(r, s + i, run); put(r, s + i + run, 1); @@ -393,7 +443,8 @@ static size_t try_block(MdRenderer *r, size_t i) /* ---- 引用 > ---- */ if (s[i] == '>') { - if (i + 1 >= len && !r->eof) return MD_NEED_MORE; + if (i + 1 >= len && !r->eof) + return MD_NEED_MORE; size_t n = (i + 1 < len && s[i + 1] == ' ') ? 2 : 1; style(r, MD_DIM); putc_str(r, "│ "); @@ -405,18 +456,25 @@ static size_t try_block(MdRenderer *r, size_t i) /* ---- 围栏代码块开启 ---- */ if (s[i] == '`') { size_t run = 0; - while (i + run < len && s[i + run] == '`') run++; - if (i + run >= len && !r->eof) return MD_NEED_MORE; + while (i + run < len && s[i + run] == '`') + run++; + if (i + run >= len && !r->eof) + return MD_NEED_MORE; if (run >= 3) { size_t j = i + run; - while (j < len && s[j] != '\n') j++; + while (j < len && s[j] != '\n') + j++; int has_nl = (j < len); r->in_fence = 1; r->fence_char = '`'; r->fence_len = (int)run; put(r, s + i, j - i); - if (has_nl) { put(r, "\n", 1); r->line_start = 1; } - else { r->line_start = 0; } + if (has_nl) { + put(r, "\n", 1); + r->line_start = 1; + } else { + r->line_start = 0; + } return (j - i) + (has_nl ? 1 : 0); } /* 少于 3 个反引号: 交给行内代码逻辑 */ @@ -425,7 +483,8 @@ static size_t try_block(MdRenderer *r, size_t i) /* ---- 无序列表 / 分隔线 ---- */ if (s[i] == '-' || s[i] == '*' || s[i] == '+') { if (i + 1 >= len) { - if (!r->eof) return MD_NEED_MORE; /* 可能是 "- " 列表或 "---" */ + if (!r->eof) + return MD_NEED_MORE; /* 可能是 "- " 列表或 "---" */ /* EOF 且只剩这一个字符: 当字面量 */ } else { if (s[i + 1] == ' ') { @@ -439,17 +498,22 @@ static size_t try_block(MdRenderer *r, size_t i) } if (s[i] == '-') { size_t run = 0; - while (i + run < len && s[i + run] == '-') run++; - if (i + run >= len && !r->eof) return MD_NEED_MORE; + while (i + run < len && s[i + run] == '-') + run++; + if (i + run >= len && !r->eof) + return MD_NEED_MORE; if (run >= 3) { size_t j = i + run; - while (j < len && (s[j] == ' ' || s[j] == '\t')) j++; - if (j >= len && !r->eof) return MD_NEED_MORE; + while (j < len && (s[j] == ' ' || s[j] == '\t')) + j++; + if (j >= len && !r->eof) + return MD_NEED_MORE; if (j >= len || s[j] == '\n') { style(r, MD_DIM); put(r, s + i, run); style(r, MD_RESET); - if (j < len) put(r, "\n", 1); + if (j < len) + put(r, "\n", 1); r->line_start = 1; return (j - i) + (j < len ? 1 : 0); } @@ -463,25 +527,28 @@ static size_t try_block(MdRenderer *r, size_t i) int nl1; size_t ln1 = scan_line(s, len, i, &nl1); - /* 表格必须顶格以 '|' 开头. + /* 表格必须顶格以 '|' 开头. * 这个判断在行的第 0 个字节就能做出 —— 这是关键: 只要"是否挂起" * 依赖行内更靠后的内容 (例如"整行是否含竖线"), 那么当分片边界 - * 落在竖线之前时, 行的前半段已经被当成普通文本输出了, 再也无法回头, + * 落在竖线之前时, 行的前半段已经被当成普通文本输出了, 再也无法回头, * 结果就会随分片方式而变. */ if (s[i] == '|') { if (!nl1 && !r->eof) { - if (ln1 > MD_MAX_TABLE_LINE) return 0; + if (ln1 > MD_MAX_TABLE_LINE) + return 0; return MD_NEED_MORE; } size_t j = i + ln1 + (nl1 ? 1 : 0); if (j >= len) { - if (r->eof) return 0; /* 没有第二行, 必然不是表格 */ + if (r->eof) + return 0; /* 没有第二行, 必然不是表格 */ return MD_NEED_MORE; } int nl2; size_t ln2 = scan_line(s, len, j, &nl2); if (!nl2 && !r->eof) { - if (ln2 > MD_MAX_TABLE_LINE) return 0; + if (ln2 > MD_MAX_TABLE_LINE) + return 0; return MD_NEED_MORE; } if (table_head_ok(s + i, ln1, s + j, ln2)) { @@ -503,16 +570,14 @@ static size_t try_block(MdRenderer *r, size_t i) /* 主循环 */ /* ------------------------------------------------------------------ */ -static int is_word_char(int c) -{ - /* 只把 ASCII 词字符算作"词内", 非 ASCII (中文等)不算. +static int is_word_char(int c) { + /* 只把 ASCII 词字符算作"词内", 非 ASCII (中文等)不算. * 这样 "中文[链接](url)" 能高亮, 而 "arr[i]" 不会. */ return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'; } -static void feed_enabled(MdRenderer *r, const char *text, size_t len) -{ +static void feed_enabled(MdRenderer *r, const char *text, size_t len) { sb_append(&r->in, text, len); size_t i = 0; @@ -522,8 +587,12 @@ static void feed_enabled(MdRenderer *r, const char *text, size_t len) if (r->line_start && !r->in_code) { size_t consumed = try_block(r, i); - if (consumed == MD_NEED_MORE) break; - if (consumed > 0) { i += consumed; continue; } + if (consumed == MD_NEED_MORE) + break; + if (consumed > 0) { + i += consumed; + continue; + } } char c = s[i]; @@ -563,17 +632,23 @@ static void feed_enabled(MdRenderer *r, const char *text, size_t len) /* ---- 粗体 / 斜体 ---- */ if (c == '*') { if (remain >= 2 && s[i + 1] == '*') { - if (r->in_bold) { style(r, MD_RESET); r->in_bold = 0; } - else { style(r, MD_BOLD); r->in_bold = 1; } + if (r->in_bold) { + style(r, MD_RESET); + r->in_bold = 0; + } else { + style(r, MD_BOLD); + r->in_bold = 1; + } r->line_start = 0; i += 2; continue; } if (remain < 2) { - /* 末尾的单个 '*': 可能是 '**' 的前半个, 必须等下一个分片. - * 只有到 EOF 才能确定后面没有第二个 '*', 此时按固定规则解开: + /* 末尾的单个 '*': 可能是 '**' 的前半个, 必须等下一个分片. + * 只有到 EOF 才能确定后面没有第二个 '*', 此时按固定规则解开: * 正在斜体就当作收尾, 否则当字面量. */ - if (!r->eof) break; + if (!r->eof) + break; if (r->in_italic) { style(r, MD_RESET); r->in_italic = 0; @@ -603,19 +678,21 @@ 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: 没有后一个字符, 当字面量 */ + if (!r->eof) + break; /* 需要看后一个字符 */ + put(r, "_", 1); /* EOF: 没有后一个字符, 当字面量 */ r->line_start = 0; i++; continue; } int next = (unsigned char)s[i + 1]; - /* 与另一个下划线相邻时一律当字面量: + /* 与另一个下划线相邻时一律当字面量: * 这样 Python 的 __init__、__main__ 不会被拆成斜体 */ if (prev == '_' || next == '_') { put(r, "_", 1); @@ -631,8 +708,8 @@ static void feed_enabled(MdRenderer *r, const char *text, size_t len) } else { put(r, "_", 1); } - } else if (!is_word_char(prev) && next != ' ' && - next != '\t' && next != '\n') { + } else if (!is_word_char(prev) && next != ' ' && next != '\t' && + next != '\n') { style(r, MD_ITALIC); r->in_italic = 1; } else { @@ -646,15 +723,17 @@ static void feed_enabled(MdRenderer *r, const char *text, size_t len) /* ---- [链接文字]: 蓝色高亮, 其余部分 (含 (url))原样保留 ---- */ if (c == '[') { if (remain < 2) { - if (!r->eof) break; /* 需要看后一个字符 */ - put(r, "[", 1); /* EOF: 当字面量 */ + if (!r->eof) + break; /* 需要看后一个字符 */ + put(r, "[", 1); /* EOF: 当字面量 */ r->line_start = 0; i++; continue; } int prev = (i > 0) ? (unsigned char)s[i - 1] : r->last_ch; int next = (unsigned char)s[i + 1]; - if (!is_word_char(prev) && next != ' ' && next != '\t' && next != '\n') { + if (!is_word_char(prev) && next != ' ' && next != '\t' && + next != '\n') { style(r, MD_LINK); put(r, "[", 1); r->in_bracket = 1; @@ -694,7 +773,8 @@ static void feed_enabled(MdRenderer *r, const char *text, size_t len) i++; } - if (i > 0) r->last_ch = (unsigned char)r->in.data[i - 1]; + if (i > 0) + r->last_ch = (unsigned char)r->in.data[i - 1]; /* 丢弃已处理的前缀, 只保留等待更多数据的尾部 */ if (i > 0) { @@ -712,27 +792,29 @@ static void feed_enabled(MdRenderer *r, const char *text, size_t len) } } -void md_feed(MdRenderer *r, const char *text) -{ - if (!r || !text) return; +void md_feed(MdRenderer *r, const char *text) { + if (!r || !text) + return; size_t len = strlen(text); if (!r->enabled) { - if (len) fwrite(text, 1, len, r->out); + if (len) + fwrite(text, 1, len, r->out); return; } feed_enabled(r, text, len); } -void md_finish(MdRenderer *r) -{ - if (!r) return; +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_table) + table_flush(r); + if (r->in.len > 0) { /* 兜底: 正常不该走到 */ close_line_styles(r); put(r, r->in.data, r->in.len); r->in.len = 0; @@ -749,8 +831,7 @@ void md_finish(MdRenderer *r) /* 生命周期 */ /* ------------------------------------------------------------------ */ -MdRenderer *md_new(int enabled) -{ +MdRenderer *md_new(int enabled) { MdRenderer *r = xcalloc(1, sizeof *r); r->enabled = enabled ? 1 : 0; r->out = stdout; @@ -760,19 +841,19 @@ MdRenderer *md_new(int enabled) return r; } -void md_set_enabled(MdRenderer *r, int enabled) -{ - if (r) r->enabled = enabled ? 1 : 0; +void md_set_enabled(MdRenderer *r, int enabled) { + if (r) + r->enabled = enabled ? 1 : 0; } -void md_set_out(MdRenderer *r, FILE *out) -{ - if (r && out) r->out = out; +void md_set_out(MdRenderer *r, FILE *out) { + if (r && out) + r->out = out; } -void md_free(MdRenderer *r) -{ - if (!r) return; +void md_free(MdRenderer *r) { + if (!r) + return; sb_free(&r->in); sb_free(&r->table); free(r); diff --git a/src/markdown.h b/src/markdown.h index 9bca79f..72e87e9 100644 --- a/src/markdown.h +++ b/src/markdown.h @@ -1,13 +1,13 @@ /* markdown.h - 面向终端输出的流式 Markdown 渲染器 * - * 设计目标: 在"纯行式 REPL"的前提下把常见 Markdown 变成 ANSI 样式, - * 且必须能处理任意分片 (SSE 的一小片文本可能把一个标记切成两半). + * 设计目标: 在"纯行式 REPL"的前提下把常见 Markdown 变成 ANSI 样式, + * 且必须能处理任意分片 (SSE 的一小片文本可能把一个标记切成两半). * - * 渲染: + * 渲染: * 块级 # ~ ###### 标题、> 引用、- * + 无序列表、--- 分隔线、| 表格 * 行内 **粗体**、*斜体* / _斜体_、`行内代码`、[链接文字] (蓝色高亮) * - * 不渲染 (原样保留, 一个字节都不丢): + * 不渲染 (原样保留, 一个字节都不丢): * 围栏代码块 (含 ``` 标记本身)、图片 ![alt](src)、链接的 (url) 部分、 * 行内代码的内容 */ @@ -21,10 +21,10 @@ typedef struct MdRenderer MdRenderer; /* enabled = 0 时完全透传, 逐字节不改写内容 (供管道 / --no-color 使用) */ MdRenderer *md_new(int enabled); -void md_set_enabled(MdRenderer *r, int enabled); +void md_set_enabled(MdRenderer *r, int enabled); /* 输出目标, 默认 stdout (测试时指向内存流) */ -void md_set_out(MdRenderer *r, FILE *out); +void md_set_out(MdRenderer *r, FILE *out); /* 喂入一段文本 (NUL 结尾). 内部保持状态, 可被任意切分 */ void md_feed(MdRenderer *r, const char *text); diff --git a/src/meta.c b/src/meta.c index e0d45c3..4c44631 100644 --- a/src/meta.c +++ b/src/meta.c @@ -1,23 +1,23 @@ /* meta.c - /meta: 用模型辅助编辑对话历史 * * 结构上是一个"第二个 Agent": 它有自己的 messages (meta 自己的会话)、 - * 自己的 tools、自己的 system 提示词, 但共用主对话的 LLM 配置. - * agent_run() 一行不改就能跑它 —— 工具分发已经从硬编码改成了函数指针. + * 自己的 tools、自己的 system 提示词, 但共用主对话的 LLM 配置. + * agent_run() 一行不改就能跑它 —— 工具分发已经从硬编码改成了函数指针. * - * 三条硬约束 (决定了下面所有工具的形状): - * 1. assistant 带 tool_calls 时, 后面必须紧跟一一对应的 tool 消息; - * 2. system 必须在最前, 且不归 meta 管 (那是 /system 的事); - * 3. 改完之后, 历史必须仍是一段合法的对话. - * 所以"删单条消息"只对 user 和"不带 tool_calls 的 assistant"开放, - * 结构消息一律拒绝, 让模型改用 delete_rounds. + * 三条硬约束 (决定了下面所有工具的形状): + * 1. assistant 带 tool_calls 时, 后面必须紧跟一一对应的 tool 消息; + * 2. system 必须在最前, 且不归 meta 管 (那是 /system 的事); + * 3. 改完之后, 历史必须仍是一段合法的对话. + * 所以"删单条消息"只对 user 和"不带 tool_calls 的 assistant"开放, + * 结构消息一律拒绝, 让模型改用 delete_rounds. */ #include "meta.h" -#include "i18n.h" #include "confirm.h" +#include "i18n.h" #include "lineedit.h" #include "llm.h" #include "spinner.h" -#include "tools.h" /* LIGENT_TOOL_OUTPUT_LIMIT */ +#include "tools.h" /* LIGENT_TOOL_OUTPUT_LIMIT */ #include "util.h" #include @@ -26,38 +26,41 @@ /* meta 模型的 system 提示词. 措辞由用户逐字确定, 不要"顺手润色". */ static const char *META_SYSTEM_PROMPT = -"你是对话历史的元编辑器. 你的工作对象是用户和一段已经存在的AI助手的对话记录. \n" -"你不是这段对话的参与者, 不要把它当成自己的对话往下接. \n" -"\n" -"## 为什么会有你\n" -"用户想改这段历史, 通常是因为助手沿着一个他不满意的方向走下去了. 他要的\n" -"往往不是 \"省 token\" , 而是**让这段历史不再诱导助手重蹈覆辙**——\n" -"删掉那句说错的前提、去掉那个被推翻的方案、删掉助手那次自信但错误的结论. \n" -"另外 你拥有修改assistant的消息的能力, 这是常规的修改所不能完成的\n" -"\n" -"## 你的任务\n" -"按用户的指示对这段历史做外科手术式的修改\n" -"\n" -"## 工作方式\n" -"1. 动手前先看清楚. 先 list_rounds 看目录、再 read_messages 读原文. \n" -" 不要凭用户的转述猜内容. \n" -"2. 改最小必要范围. 用户说 \"删掉那句用 Python\" , 就删那一句, \n" -" 不要顺手把整轮都删了. \n" -"3. 用户没要求压缩就不要压缩 —— 压缩会丢证据. \n" -"4. 每次改动后消息编号会重排. 要继续改, 先重新 list_rounds. \n" -"5. 改完汇报: 改了什么、为什么、增减多少. 不要复述历史. \n" -"\n" -"## 红线\n" -"- 带 tool_calls 的 assistant 消息和紧随其后的 tool 结果是不可拆的整体, 否则会造成400错误. \n" -"- 不要为了 \"整齐\" 重排顺序. \n" -"\n" -"## 拿不准就问\n" -"如果删掉某个点会让后面的回答失去上下文, 把这个后果指出来, 让用户决定. \n" -"不要猜. \n" -"\n" -"你的修改应当也遵循原对话中助手的风格, 但你对用户的交互应当一如既往地专业可靠\n" -"\n" -"用中文回复 (除非用户用了别的语言). "; + "你是对话历史的元编辑器. " + "你的工作对象是用户和一段已经存在的AI助手的对话记录. \n" + "你不是这段对话的参与者, 不要把它当成自己的对话往下接. \n" + "\n" + "## 为什么会有你\n" + "用户想改这段历史, 通常是因为助手沿着一个他不满意的方向走下去了. 他要的\n" + "往往不是 \"省 token\" , 而是**让这段历史不再诱导助手重蹈覆辙**——\n" + "删掉那句说错的前提、去掉那个被推翻的方案、删掉助手那次自信但错误的结论. \n" + "另外 你拥有修改assistant的消息的能力, 这是常规的修改所不能完成的\n" + "\n" + "## 你的任务\n" + "按用户的指示对这段历史做外科手术式的修改\n" + "\n" + "## 工作方式\n" + "1. 动手前先看清楚. 先 list_rounds 看目录、再 read_messages 读原文. \n" + " 不要凭用户的转述猜内容. \n" + "2. 改最小必要范围. 用户说 \"删掉那句用 Python\" , 就删那一句, \n" + " 不要顺手把整轮都删了. \n" + "3. 用户没要求压缩就不要压缩 —— 压缩会丢证据. \n" + "4. 每次改动后消息编号会重排. 要继续改, 先重新 list_rounds. \n" + "5. 改完汇报: 改了什么、为什么、增减多少. 不要复述历史. \n" + "\n" + "## 红线\n" + "- 带 tool_calls 的 assistant 消息和紧随其后的 tool 结果是不可拆的整体, " + "否则会造成400错误. \n" + "- 不要为了 \"整齐\" 重排顺序. \n" + "\n" + "## 拿不准就问\n" + "如果删掉某个点会让后面的回答失去上下文, 把这个后果指出来, 让用户决定. \n" + "不要猜. \n" + "\n" + "你的修改应当也遵循原对话中助手的风格, " + "但你对用户的交互应当一如既往地专业可靠\n" + "\n" + "用中文回复 (除非用户用了别的语言). "; /* 快照栈: 每次改动前压一份, 供 /undo 和 undo_last_edit 回退 */ #define META_UNDO_MAX 10 @@ -66,77 +69,81 @@ static const char *META_SYSTEM_PROMPT = #define META_PREVIEW 60 typedef struct { - Agent *target; /* 被编辑的主对话 */ - JsonValue *snap[META_UNDO_MAX]; /* 快照栈 (环形) */ - size_t snap_n; /* 已压入总数 */ - int edited; /* 本次会话是否成功改过 */ - size_t ops; /* 成功改动的次数 */ + Agent *target; /* 被编辑的主对话 */ + JsonValue *snap[META_UNDO_MAX]; /* 快照栈 (环形) */ + size_t snap_n; /* 已压入总数 */ + int edited; /* 本次会话是否成功改过 */ + size_t ops; /* 成功改动的次数 */ } MetaCtx; -/* 工具分发签名里没有上下文参数, 只能借文件级指针. +/* 工具分发签名里没有上下文参数, 只能借文件级指针. * 同一时刻只允许一个 meta 会话 (下面用 in_session 兜住). */ static MetaCtx *g_ctx; -int meta_tools_attach(Agent *target) -{ - if (g_ctx) return -1; +int meta_tools_attach(Agent *target) { + if (g_ctx) + return -1; g_ctx = xcalloc(1, sizeof(MetaCtx)); g_ctx->target = target; return 0; } -void meta_tools_detach(void) -{ - if (!g_ctx) return; - for (size_t i = 0; i < META_UNDO_MAX; i++) json_free(g_ctx->snap[i]); +void meta_tools_detach(void) { + if (!g_ctx) + return; + for (size_t i = 0; i < META_UNDO_MAX; i++) + json_free(g_ctx->snap[i]); free(g_ctx); g_ctx = NULL; } size_t meta_tools_ops(void) { return g_ctx ? g_ctx->ops : 0; } - /* "这段历史被 meta 动过"的标记. 跨 meta 会话保留, 直到下一次真正落盘. */ static int g_edited_ever; int meta_reroot_pending(void) { return g_edited_ever; } -void meta_reroot_clear(void) { g_edited_ever = 0; } +void meta_reroot_clear(void) { g_edited_ever = 0; } /* ------------------------------------------------------------------ */ /* 历史视图: 把消息按"轮"切开 */ /* ------------------------------------------------------------------ */ -static const char *role_of(const JsonValue *m) -{ +static const char *role_of(const JsonValue *m) { const char *r = json_get_string(m, "role"); return r ? r : ""; } -static int has_tool_calls(const JsonValue *m) -{ +static int has_tool_calls(const JsonValue *m) { JsonValue *tc = json_object_get(m, "tool_calls"); return tc && tc->type == JSON_ARRAY && json_array_size(tc) > 0; } -int meta_deletable(const JsonValue *messages, size_t index) -{ - if (!messages || index >= json_array_size(messages)) return 0; +int meta_deletable(const JsonValue *messages, size_t index) { + if (!messages || index >= json_array_size(messages)) + return 0; JsonValue *m = json_array_get(messages, index); const char *r = role_of(m); - if (strcmp(r, "system") == 0) return 0; /* system 不归 meta 管 */ - if (strcmp(r, "tool") == 0) return 0; /* 工具结果离不开它的调用 */ - if (strcmp(r, "assistant") == 0 && has_tool_calls(m)) return 0; - return 1; /* user / 普通 assistant */ + if (strcmp(r, "system") == 0) + return 0; /* system 不归 meta 管 */ + if (strcmp(r, "tool") == 0) + return 0; /* 工具结果离不开它的调用 */ + if (strcmp(r, "assistant") == 0 && has_tool_calls(m)) + return 0; + return 1; /* user / 普通 assistant */ } -int meta_rounds_build(const JsonValue *messages, MetaRounds *out) -{ +int meta_rounds_build(const JsonValue *messages, MetaRounds *out) { memset(out, 0, sizeof *out); - if (!messages || messages->type != JSON_ARRAY) return 0; + if (!messages || messages->type != JSON_ARRAY) + return 0; size_t n = json_array_size(messages); - if (n == 0) return 0; - out->head = (strcmp(role_of(json_array_get(messages, 0)), "system") == 0) ? 1 : 0; - if (out->head >= n) return 0; /* 只有一条 system: 没有可编辑的东西 */ + if (n == 0) + return 0; + out->head = + (strcmp(role_of(json_array_get(messages, 0)), "system") == 0) ? 1 : 0; + if (out->head >= n) + return 0; /* 只有一条 system: 没有可编辑的东西 */ size_t cap = 8; out->starts = xmalloc(cap * sizeof(size_t)); @@ -148,7 +155,8 @@ int meta_rounds_build(const JsonValue *messages, MetaRounds *out) * 补的那条"只有图片附件"的 user 消息会被当成一轮的开头: /meta 报出的轮号 * 和主循环的口径对不上, 模型照着轮号删下去, 删掉的也不是它以为的那一轮. */ for (size_t i = out->head + 1; i < n; i++) { - if (!agent_opens_round(json_array_get(messages, i))) continue; + if (!agent_opens_round(json_array_get(messages, i))) + continue; if (out->count == cap) { cap *= 2; out->starts = xrealloc(out->starts, cap * sizeof(size_t)); @@ -158,17 +166,16 @@ int meta_rounds_build(const JsonValue *messages, MetaRounds *out) return 1; } -void meta_rounds_free(MetaRounds *r) -{ +void meta_rounds_free(MetaRounds *r) { free(r->starts); r->starts = NULL; r->count = 0; } /* 第 n 轮 (1 起)的结束下标 (不含). 越界返回 (size_t)-1 */ -static size_t round_end(const MetaRounds *r, size_t n, size_t total) -{ - if (n < 1 || n > r->count) return (size_t)-1; +static size_t round_end(const MetaRounds *r, size_t n, size_t total) { + if (n < 1 || n > r->count) + return (size_t)-1; return (n == r->count) ? total : r->starts[n]; } @@ -176,24 +183,24 @@ static size_t round_end(const MetaRounds *r, size_t n, size_t total) /* 快照 */ /* ------------------------------------------------------------------ */ -static void snap_push(MetaCtx *c) -{ +static void snap_push(MetaCtx *c) { JsonValue *copy = json_clone(c->target->messages); if (c->snap_n < META_UNDO_MAX) { c->snap[c->snap_n] = copy; } else { /* 满了: 丢掉最老的一份, 整体前移 */ json_free(c->snap[0]); - memmove(c->snap, c->snap + 1, (META_UNDO_MAX - 1) * sizeof(JsonValue *)); + memmove(c->snap, c->snap + 1, + (META_UNDO_MAX - 1) * sizeof(JsonValue *)); c->snap[META_UNDO_MAX - 1] = copy; } c->snap_n++; } /* 回退一份快照. 成功返回 1 */ -static int snap_pop(MetaCtx *c) -{ - if (c->snap_n == 0) return 0; +static int snap_pop(MetaCtx *c) { + if (c->snap_n == 0) + return 0; size_t idx = (c->snap_n - 1) % META_UNDO_MAX; JsonValue *old = c->snap[idx]; c->snap[idx] = NULL; @@ -207,8 +214,7 @@ static int snap_pop(MetaCtx *c) /* 小工具 */ /* ------------------------------------------------------------------ */ -static char *preview_of(const char *s, size_t max) -{ +static char *preview_of(const char *s, size_t max) { StrBuf sb; sb_init(&sb); size_t n = 0; @@ -216,38 +222,42 @@ static char *preview_of(const char *s, size_t max) char ch = (*p == '\n' || *p == '\r' || *p == '\t') ? ' ' : *p; sb_putc(&sb, ch); } - if (s && s[n]) sb_puts(&sb, "..."); + if (s && s[n]) + sb_puts(&sb, "..."); return sb_detach(&sb); } /* 角色的中文短标签, 给目录用 */ -static const char *role_short(const JsonValue *m) -{ +static const char *role_short(const JsonValue *m) { const char *r = role_of(m); - if (strcmp(r, "user") == 0) return "user"; - if (strcmp(r, "assistant") == 0) return has_tool_calls(m) ? "assistant(tool_calls)" : "assistant"; - if (strcmp(r, "tool") == 0) return "tool"; - if (strcmp(r, "system") == 0) return "system"; + if (strcmp(r, "user") == 0) + return "user"; + if (strcmp(r, "assistant") == 0) + return has_tool_calls(m) ? "assistant(tool_calls)" : "assistant"; + if (strcmp(r, "tool") == 0) + return "tool"; + if (strcmp(r, "system") == 0) + return "system"; return r; } /* 一条消息的字节数 (content + 工具参数), 用于"省了多少"的汇报 */ -static size_t msg_bytes(const JsonValue *m) -{ +static size_t msg_bytes(const JsonValue *m) { size_t n = 0; const char *c = json_get_string(m, "content"); - if (c) n += strlen(c); + if (c) + n += strlen(c); JsonValue *tc = json_object_get(m, "tool_calls"); for (size_t i = 0; i < json_array_size(tc); i++) { JsonValue *fn = json_object_get(json_array_get(tc, i), "function"); const char *a = json_get_string(fn, "arguments"); - if (a) n += strlen(a); + if (a) + n += strlen(a); } return n; } -static size_t history_bytes(const JsonValue *messages) -{ +static size_t history_bytes(const JsonValue *messages) { size_t n = 0; for (size_t i = 0; i < json_array_size(messages); i++) n += msg_bytes(json_array_get(messages, i)); @@ -255,34 +265,38 @@ static size_t history_bytes(const JsonValue *messages) } /* 取一个整数参数, 缺失或类型不对时把 *ok 置 0 */ -static long arg_long(const JsonValue *args, const char *key, int *ok) -{ +static long arg_long(const JsonValue *args, const char *key, int *ok) { JsonValue *v = json_object_get(args, key); - if (!v || v->type != JSON_NUMBER) { *ok = 0; return 0; } + if (!v || v->type != JSON_NUMBER) { + *ok = 0; + return 0; + } *ok = 1; return (long)v->u.number; } -/* 把 (from,to) 两个参数解析成闭区间; 缺省时用整段. +/* 把 (from,to) 两个参数解析成闭区间; 缺省时用整段. * 返回 0 表示参数不合法. */ -static int arg_range(const JsonValue *args, long lo, long hi, - long *from, long *to) -{ +static int arg_range(const JsonValue *args, long lo, long hi, long *from, + long *to) { int ok; JsonValue *f = json_object_get(args, "from"); JsonValue *t = json_object_get(args, "to"); *from = f ? arg_long(args, "from", &ok) : lo; - if (f && !ok) return 0; + if (f && !ok) + return 0; *to = t ? arg_long(args, "to", &ok) : hi; - if (t && !ok) return 0; - if (*from < lo) *from = lo; - if (*to > hi) *to = hi; + if (t && !ok) + return 0; + if (*from < lo) + *from = lo; + if (*to > hi) + *to = hi; return *from <= *to; } /* 改动之后的统一收尾: 打标记、清掉和旧历史绑定的状态 */ -static void after_edit(MetaCtx *c) -{ +static void after_edit(MetaCtx *c) { c->edited = 1; g_edited_ever = 1; c->ops++; @@ -292,8 +306,7 @@ static void after_edit(MetaCtx *c) /* 工具实现 */ /* ------------------------------------------------------------------ */ -static char *m_list_rounds(const JsonValue *args, int *is_error) -{ +static char *m_list_rounds(const JsonValue *args, int *is_error) { MetaCtx *c = g_ctx; JsonValue *msgs = c->target->messages; size_t total = json_array_size(msgs); @@ -321,11 +334,13 @@ static char *m_list_rounds(const JsonValue *args, int *is_error) size_t s = r.starts[n - 1]; size_t e = round_end(&r, (size_t)n, total); size_t bytes = 0; - for (size_t i = s; i < e; i++) bytes += msg_bytes(json_array_get(msgs, i)); + for (size_t i = s; i < e; i++) + bytes += msg_bytes(json_array_get(msgs, i)); JsonValue *first = json_array_get(msgs, s); char *pv = preview_of(json_get_string(first, "content"), META_PREVIEW); - char *line = xasprintf("轮 %ld #%zu..#%zu %zu 条 %zu 字节 %s: %s\n", - n, s, e - 1, e - s, bytes, role_short(first), pv); + char *line = + xasprintf("轮 %ld #%zu..#%zu %zu 条 %zu 字节 %s: %s\n", n, s, + e - 1, e - s, bytes, role_short(first), pv); sb_puts(&sb, line); free(line); free(pv); @@ -334,12 +349,14 @@ static char *m_list_rounds(const JsonValue *args, int *is_error) return sb_detach(&sb); } -static char *m_read_messages(const JsonValue *args, int *is_error) -{ +static char *m_read_messages(const JsonValue *args, int *is_error) { MetaCtx *c = g_ctx; JsonValue *msgs = c->target->messages; size_t total = json_array_size(msgs); - if (total == 0) { *is_error = 1; return xstrdup("历史是空的. "); } + if (total == 0) { + *is_error = 1; + return xstrdup("历史是空的. "); + } long from, to; if (!arg_range(args, 0, (long)total - 1, &from, &to)) { *is_error = 1; @@ -351,13 +368,15 @@ static char *m_read_messages(const JsonValue *args, int *is_error) for (long i = from; i <= to; i++) { JsonValue *m = json_array_get(msgs, (size_t)i); const char *content = json_get_string(m, "content"); - char *head = xasprintf("\n--- #%ld %s%s ---\n", i, role_short(m), - meta_deletable(msgs, (size_t)i) ? "" : " 〔结构消息, 不可单删〕"); + char *head = xasprintf( + "\n--- #%ld %s%s ---\n", i, role_short(m), + meta_deletable(msgs, (size_t)i) ? "" : " 〔结构消息, 不可单删〕"); sb_puts(&sb, head); free(head); if (content && *content) { sb_puts(&sb, content); - if (content[strlen(content) - 1] != '\n') sb_putc(&sb, '\n'); + if (content[strlen(content) - 1] != '\n') + sb_putc(&sb, '\n'); } else if (!has_tool_calls(m)) { sb_puts(&sb, "(空)\n"); } @@ -366,8 +385,8 @@ static char *m_read_messages(const JsonValue *args, int *is_error) JsonValue *fn = json_object_get(json_array_get(tc, k), "function"); const char *name = json_get_string(fn, "name"); const char *a = json_get_string(fn, "arguments"); - char *l = xasprintf("[调用 %s] %s\n", name ? name : "?", - a ? a : "{}"); + char *l = + xasprintf("[调用 %s] %s\n", name ? name : "?", a ? a : "{}"); sb_puts(&sb, l); free(l); } @@ -387,8 +406,7 @@ static char *m_read_messages(const JsonValue *args, int *is_error) return sb_detach(&sb); } -static char *m_replace_message(const JsonValue *args, int *is_error) -{ +static char *m_replace_message(const JsonValue *args, int *is_error) { MetaCtx *c = g_ctx; JsonValue *msgs = c->target->messages; size_t total = json_array_size(msgs); @@ -406,7 +424,8 @@ static char *m_replace_message(const JsonValue *args, int *is_error) long i = (long)iv->u.number; if (i < 0 || (size_t)i >= total) { *is_error = 1; - return xasprintf("index #%ld 越界 (0..%zu). ", i, total ? total - 1 : 0); + return xasprintf("index #%ld 越界 (0..%zu). ", i, + total ? total - 1 : 0); } JsonValue *m = json_array_get(msgs, (size_t)i); const char *role = role_of(m); @@ -425,11 +444,11 @@ static char *m_replace_message(const JsonValue *args, int *is_error) 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) -{ +static char *m_delete_messages(const JsonValue *args, int *is_error) { MetaCtx *c = g_ctx; JsonValue *msgs = c->target->messages; size_t total = json_array_size(msgs); @@ -460,10 +479,10 @@ static char *m_delete_messages(const JsonValue *args, int *is_error) if (!meta_deletable(msgs, (size_t)i)) { free(idx); *is_error = 1; - return xasprintf( - "#%ld 是%s, 和它前后的消息是一个不可拆的整体 (拆了下一轮请求会 400). \n" - "要删它, 请用 delete_rounds 删掉整轮. ", - i, role_short(json_array_get(msgs, (size_t)i))); + return xasprintf("#%ld 是%s, 和它前后的消息是一个不可拆的整体 " + "(拆了下一轮请求会 400). \n" + "要删它, 请用 delete_rounds 删掉整轮. ", + i, role_short(json_array_get(msgs, (size_t)i))); } idx[k] = (size_t)i; } @@ -471,10 +490,15 @@ static char *m_delete_messages(const JsonValue *args, int *is_error) /* 去重, 并从大到小删, 以免删一个前面就错位 */ 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; } + if (idx[b] > idx[a]) { + size_t t = idx[a]; + idx[a] = idx[b]; + idx[b] = t; + } size_t uniq = 0; for (size_t a = 0; a < n; a++) - if (a == 0 || idx[a] != idx[a - 1]) idx[uniq++] = idx[a]; + if (a == 0 || idx[a] != idx[a - 1]) + idx[uniq++] = idx[a]; size_t before = history_bytes(msgs); snap_push(c); @@ -482,7 +506,8 @@ static char *m_delete_messages(const JsonValue *args, int *is_error) for (size_t a = 0; a < uniq; a++) { size_t i = idx[a]; json_free(items[i]); - memmove(items + i, items + i + 1, (msgs->u.array.count - i - 1) * sizeof(JsonValue *)); + memmove(items + i, items + i + 1, + (msgs->u.array.count - i - 1) * sizeof(JsonValue *)); msgs->u.array.count--; } free(idx); @@ -495,8 +520,7 @@ static char *m_delete_messages(const JsonValue *args, int *is_error) uniq, before, after, before - after); } -static char *m_delete_rounds(const JsonValue *args, int *is_error) -{ +static char *m_delete_rounds(const JsonValue *args, int *is_error) { MetaCtx *c = g_ctx; JsonValue *msgs = c->target->messages; size_t total = json_array_size(msgs); @@ -514,7 +538,8 @@ static char *m_delete_rounds(const JsonValue *args, int *is_error) if (from == 1 && to == (long)r.count) { meta_rounds_free(&r); *is_error = 1; - return xstrdup("这样会把整段历史删光. 如果确实想清空, 让用户敲 /reset. "); + return xstrdup( + "这样会把整段历史删光. 如果确实想清空, 让用户敲 /reset. "); } size_t s = r.starts[from - 1]; @@ -525,8 +550,10 @@ static char *m_delete_rounds(const JsonValue *args, int *is_error) snap_push(c); JsonValue **items = msgs->u.array.items; - for (size_t i = 0; i < cnt; i++) json_free(items[s + i]); - memmove(items + s, items + e, (msgs->u.array.count - e) * sizeof(JsonValue *)); + for (size_t i = 0; i < cnt; i++) + json_free(items[s + i]); + memmove(items + s, items + e, + (msgs->u.array.count - e) * sizeof(JsonValue *)); msgs->u.array.count -= cnt; after_edit(c); @@ -535,8 +562,7 @@ static char *m_delete_rounds(const JsonValue *args, int *is_error) from, to, cnt, before, after, before - after); } -static char *m_insert_message(const JsonValue *args, int *is_error) -{ +static char *m_insert_message(const JsonValue *args, int *is_error) { MetaCtx *c = g_ctx; JsonValue *msgs = c->target->messages; size_t total = json_array_size(msgs); @@ -544,18 +570,29 @@ static char *m_insert_message(const JsonValue *args, int *is_error) JsonValue *av = json_object_get(args, "after"); const char *role = json_get_string(args, "role"); const char *content = json_get_string(args, "content"); - if (!av || av->type != JSON_NUMBER) { *is_error = 1; return xstrdup("缺少 after (插到哪条之后). "); } - if (!role || !*role) { *is_error = 1; return xstrdup("缺少 role. "); } - if (!content) { *is_error = 1; return xstrdup("缺少 content. "); } + if (!av || av->type != JSON_NUMBER) { + *is_error = 1; + return xstrdup("缺少 after (插到哪条之后). "); + } + if (!role || !*role) { + *is_error = 1; + return xstrdup("缺少 role. "); + } + if (!content) { + *is_error = 1; + return xstrdup("缺少 content. "); + } if (strcmp(role, "assistant") != 0 && strcmp(role, "user") != 0) { *is_error = 1; - return xstrdup("role 只能是 user 或 assistant —— tool 消息必须配对 tool_calls, " - "不能凭空插一条. "); + return xstrdup( + "role 只能是 user 或 assistant —— tool 消息必须配对 tool_calls, " + "不能凭空插一条. "); } long a = (long)av->u.number; if (a < -1 || (size_t)(a + 1) > total) { *is_error = 1; - return xasprintf("after #%ld 越界 (-1 表示插到最前面, 最大 %zu). ", a, total ? total - 1 : 0); + return xasprintf("after #%ld 越界 (-1 表示插到最前面, 最大 %zu). ", a, + total ? total - 1 : 0); } snap_push(c); @@ -565,18 +602,18 @@ static char *m_insert_message(const JsonValue *args, int *is_error) size_t at = (size_t)(a + 1); json_array_insert(msgs, at, m); after_edit(c); - return xasprintf("已在 #%ld 之后插入一条 %s (%zu 字节). ", a, role, strlen(content)); + return xasprintf("已在 #%ld 之后插入一条 %s (%zu 字节). ", a, role, + strlen(content)); } -static char *m_undo(const JsonValue *args, int *is_error) -{ +static char *m_undo(const JsonValue *args, int *is_error) { (void)args; MetaCtx *c = g_ctx; if (!snap_pop(c)) { *is_error = 1; return xstrdup("没有可撤销的改动了. "); } - g_edited_ever = 1; /* 撤销本身也是"历史被动过", 仍要另起一根 */ + g_edited_ever = 1; /* 撤销本身也是"历史被动过", 仍要另起一根 */ return xstrdup("已撤销上一次改动. "); } @@ -587,76 +624,77 @@ static char *m_undo(const JsonValue *args, int *is_error) typedef struct { const char *name; const char *desc; - const char *params; /* JSON Schema 片段 */ - int mutating; + const char *params; /* JSON Schema 片段 */ + int mutating; char *(*fn)(const JsonValue *args, int *is_error); } MetaTool; static const MetaTool TOOLS[] = { - { "list_rounds", - "看这份对话的目录: 每轮一行 (轮号、消息编号范围、条数、字节数、开头预览). " - "动手前先看它. ", - "{\"type\":\"object\",\"properties\":{" - "\"from\":{\"type\":\"number\",\"description\":\"起始轮号 (从 1 开始), 默认 1\"}," - "\"to\":{\"type\":\"number\",\"description\":\"结束轮号, 默认最后一轮\"}}}", - 0, m_list_rounds }, + {"list_rounds", + "看这份对话的目录: 每轮一行 (轮号、消息编号范围、条数、字节数、开头预览). " + "动手前先看它. ", + "{\"type\":\"object\",\"properties\":{" + "\"from\":{\"type\":\"number\",\"description\":\"起始轮号 (从 1 开始), " + "默认 1\"}," + "\"to\":{\"type\":\"number\",\"description\":\"结束轮号, " + "默认最后一轮\"}}}", + 0, m_list_rounds}, - { "read_messages", - "读指定消息编号范围的完整内容 (含工具调用参数). 编号是 #N, 从 0 开始, " - "和 list_rounds 显示的一致. ", - "{\"type\":\"object\",\"properties\":{" - "\"from\":{\"type\":\"number\",\"description\":\"起始消息编号 #N\"}," - "\"to\":{\"type\":\"number\",\"description\":\"结束消息编号 #N\"}}," - "\"required\":[\"from\",\"to\"]}", - 0, m_read_messages }, + {"read_messages", + "读指定消息编号范围的完整内容 (含工具调用参数). 编号是 #N, 从 0 开始, " + "和 list_rounds 显示的一致. ", + "{\"type\":\"object\",\"properties\":{" + "\"from\":{\"type\":\"number\",\"description\":\"起始消息编号 #N\"}," + "\"to\":{\"type\":\"number\",\"description\":\"结束消息编号 #N\"}}," + "\"required\":[\"from\",\"to\"]}", + 0, m_read_messages}, - { "replace_message", - "改写某一条消息的内容. 只换文字, 消息的角色和工具调用结构都不动. " - "这是唯一能修改 assistant 说过的话的办法. ", - "{\"type\":\"object\",\"properties\":{" - "\"index\":{\"type\":\"number\",\"description\":\"消息编号 #N\"}," - "\"content\":{\"type\":\"string\",\"description\":\"新的内容\"}}," - "\"required\":[\"index\",\"content\"]}", - 1, m_replace_message }, + {"replace_message", + "改写某一条消息的内容. 只换文字, 消息的角色和工具调用结构都不动. " + "这是唯一能修改 assistant 说过的话的办法. ", + "{\"type\":\"object\",\"properties\":{" + "\"index\":{\"type\":\"number\",\"description\":\"消息编号 #N\"}," + "\"content\":{\"type\":\"string\",\"description\":\"新的内容\"}}," + "\"required\":[\"index\",\"content\"]}", + 1, m_replace_message}, - { "delete_messages", - "删掉若干条消息. 只能删 user 和不带 tool_calls 的 assistant; " - "结构消息 (带 tool_calls 的 assistant、tool 结果)会被拒绝, " - "那种情况请用 delete_rounds. ", - "{\"type\":\"object\",\"properties\":{" - "\"indices\":{\"type\":\"array\",\"items\":{\"type\":\"number\"}," - "\"description\":\"要删除的消息编号 #N 列表\"}}," - "\"required\":[\"indices\"]}", - 1, m_delete_messages }, + {"delete_messages", + "删掉若干条消息. 只能删 user 和不带 tool_calls 的 assistant; " + "结构消息 (带 tool_calls 的 assistant、tool 结果)会被拒绝, " + "那种情况请用 delete_rounds. ", + "{\"type\":\"object\",\"properties\":{" + "\"indices\":{\"type\":\"array\",\"items\":{\"type\":\"number\"}," + "\"description\":\"要删除的消息编号 #N 列表\"}}," + "\"required\":[\"indices\"]}", + 1, m_delete_messages}, - { "delete_rounds", - "删掉连续的若干整轮 (含轮内所有工具往返). 要清掉一次走不通的探索, 用这个. ", - "{\"type\":\"object\",\"properties\":{" - "\"from\":{\"type\":\"number\",\"description\":\"起始轮号 (从 1 开始)\"}," - "\"to\":{\"type\":\"number\",\"description\":\"结束轮号\"}}," - "\"required\":[\"from\",\"to\"]}", - 1, m_delete_rounds }, + {"delete_rounds", + "删掉连续的若干整轮 (含轮内所有工具往返). 要清掉一次走不通的探索, " + "用这个. ", + "{\"type\":\"object\",\"properties\":{" + "\"from\":{\"type\":\"number\",\"description\":\"起始轮号 (从 1 开始)\"}," + "\"to\":{\"type\":\"number\",\"description\":\"结束轮号\"}}," + "\"required\":[\"from\",\"to\"]}", + 1, m_delete_rounds}, - { "insert_message", - "在某条消息之后插入一条新消息. 用来补一句迟到的约束或澄清. " - "role 只能是 user 或 assistant. ", - "{\"type\":\"object\",\"properties\":{" - "\"after\":{\"type\":\"number\",\"description\":\"插到这条 #N 之后; -1 表示插到最前面\"}," - "\"role\":{\"type\":\"string\",\"enum\":[\"user\",\"assistant\"]}," - "\"content\":{\"type\":\"string\"}}," - "\"required\":[\"after\",\"role\",\"content\"]}", - 1, m_insert_message }, + {"insert_message", + "在某条消息之后插入一条新消息. 用来补一句迟到的约束或澄清. " + "role 只能是 user 或 assistant. ", + "{\"type\":\"object\",\"properties\":{" + "\"after\":{\"type\":\"number\",\"description\":\"插到这条 #N 之后; -1 " + "表示插到最前面\"}," + "\"role\":{\"type\":\"string\",\"enum\":[\"user\",\"assistant\"]}," + "\"content\":{\"type\":\"string\"}}," + "\"required\":[\"after\",\"role\",\"content\"]}", + 1, m_insert_message}, - { "undo_last_edit", - "撤销上一次改动. 改错了就用它. ", - "{\"type\":\"object\",\"properties\":{}}", - 1, m_undo }, + {"undo_last_edit", "撤销上一次改动. 改错了就用它. ", + "{\"type\":\"object\",\"properties\":{}}", 1, m_undo}, }; #define TOOL_COUNT (sizeof TOOLS / sizeof TOOLS[0]) -static JsonValue *meta_tools_schema(void) -{ +static JsonValue *meta_tools_schema(void) { JsonValue *arr = json_new_array(); for (size_t i = 0; i < TOOL_COUNT; i++) { JsonValue *t = json_new_object(); @@ -673,32 +711,34 @@ static JsonValue *meta_tools_schema(void) return arr; } -char *meta_tools_call(const char *name, const JsonValue *args, int *is_error) -{ +char *meta_tools_call(const char *name, const JsonValue *args, int *is_error) { *is_error = 0; - if (!g_ctx) { *is_error = 1; return xstrdup("meta 会话已经结束. "); } + if (!g_ctx) { + *is_error = 1; + return xstrdup("meta 会话已经结束. "); + } for (size_t i = 0; i < TOOL_COUNT; i++) - if (strcmp(TOOLS[i].name, name) == 0) return TOOLS[i].fn(args, is_error); + if (strcmp(TOOLS[i].name, name) == 0) + return TOOLS[i].fn(args, is_error); *is_error = 1; return xasprintf("未知工具: %s. 可用的只有列表里那几个. ", name); } -int meta_tools_mutating(const char *name) -{ +int meta_tools_mutating(const char *name) { for (size_t i = 0; i < TOOL_COUNT; i++) - if (strcmp(TOOLS[i].name, name) == 0) return TOOLS[i].mutating; + if (strcmp(TOOLS[i].name, name) == 0) + return TOOLS[i].mutating; return 0; } -/* 让确认提示说人话: meta 工具的参数字段名和普通工具完全不同, +/* 让确认提示说人话: meta 工具的参数字段名和普通工具完全不同, * 内置描述要么打不出来、要么会把整段消息内容刷屏. */ -static char *meta_describe(const char *name, const JsonValue *args) -{ +static char *meta_describe(const char *name, const JsonValue *args) { char *msg = NULL; int ok; if (strcmp(name, "list_rounds") == 0 || strcmp(name, "read_messages") == 0) - return NULL; /* 只读工具根本不会走到确认 */ + return NULL; /* 只读工具根本不会走到确认 */ if (strcmp(name, "delete_messages") == 0) { JsonValue *arr = json_object_get(args, "indices"); @@ -706,7 +746,8 @@ static char *meta_describe(const char *name, const JsonValue *args) sb_init(&sb); sb_puts(&sb, " "); for (size_t i = 0; i < json_array_size(arr); i++) { - char *one = xasprintf("#%ld ", (long)json_number_value(json_array_get(arr, i), -1)); + char *one = xasprintf( + "#%ld ", (long)json_number_value(json_array_get(arr, i), -1)); sb_puts(&sb, one); free(one); } @@ -714,22 +755,22 @@ static char *meta_describe(const char *name, const JsonValue *args) msg = sb_detach(&sb); } else if (strcmp(name, "delete_rounds") == 0) { long f = arg_long(args, "from", &ok), t = arg_long(args, "to", &ok); - msg = xstrdup(tf("meta.desc.delete_rounds", "from", i18n_num(f), - "to", i18n_num(t), NULL)); + msg = xstrdup(tf("meta.desc.delete_rounds", "from", i18n_num(f), "to", + i18n_num(t), NULL)); } else if (strcmp(name, "replace_message") == 0) { long i = arg_long(args, "index", &ok); const char *content = json_get_string(args, "content"); char *pv = preview_of(content, 120); - msg = xstrdup(tf("meta.desc.replace", "index", i18n_num(i), - "preview", pv, NULL)); + msg = xstrdup( + tf("meta.desc.replace", "index", i18n_num(i), "preview", pv, NULL)); free(pv); } else if (strcmp(name, "insert_message") == 0) { long a = arg_long(args, "after", &ok); const char *role = json_get_string(args, "role"); const char *content = json_get_string(args, "content"); char *pv = preview_of(content, 120); - msg = xstrdup(tf("meta.desc.insert", "after", i18n_num(a), - "role", role ? role : "?", "preview", pv, NULL)); + msg = xstrdup(tf("meta.desc.insert", "after", i18n_num(a), "role", + role ? role : "?", "preview", pv, NULL)); free(pv); } else if (strcmp(name, "undo_last_edit") == 0) { msg = xstrdup(t("meta.desc.undo")); @@ -741,33 +782,28 @@ static char *meta_describe(const char *name, const JsonValue *args) /* meta 自己的 REPL */ /* ------------------------------------------------------------------ */ -static void meta_help(void) -{ - fputs(t("meta.help"), stdout); -} +static void meta_help(void) { fputs(t("meta.help"), stdout); } /* /diff: 把当前历史按消息列出来 (只读, 不走模型) */ -static void meta_diff(Agent *target) -{ +static void meta_diff(Agent *target) { size_t total = json_array_size(target->messages); - printf("%s", tf("meta.diff.header", "n", i18n_num((long long)total), - "bytes", i18n_num((long long)history_bytes(target->messages)), - NULL)); + printf("%s", + tf("meta.diff.header", "n", i18n_num((long long)total), "bytes", + i18n_num((long long)history_bytes(target->messages)), NULL)); for (size_t i = 0; i < total; i++) { JsonValue *m = json_array_get(target->messages, i); const char *content = json_get_string(m, "content"); - char *pv = preview_of(content && *content ? content - : t("meta.diff.no_text"), 70); - printf(" #%-3zu %-22s %6zu %s %s%s\n", i, role_short(m), - msg_bytes(m), t("meta.unit.bytes"), pv, - meta_deletable(target->messages, i) - ? "" : t("meta.diff.structural")); + char *pv = preview_of( + content && *content ? content : t("meta.diff.no_text"), 70); + printf(" #%-3zu %-22s %6zu %s %s%s\n", i, role_short(m), msg_bytes(m), + t("meta.unit.bytes"), pv, + meta_deletable(target->messages, i) ? "" + : t("meta.diff.structural")); free(pv); } } -int meta_session(Agent *target, LigentConfig *cfg) -{ +int meta_session(Agent *target, LigentConfig *cfg) { (void)cfg; if (meta_tools_attach(target) != 0) { puts(t("meta.already_in")); @@ -776,7 +812,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 +827,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")); @@ -803,18 +839,36 @@ int meta_session(Agent *target, LigentConfig *cfg) int intr = 0; char *line = line_edit("meta> ", &intr); if (!line) { - if (intr) continue; /* Ctrl-C 丢掉当前行 */ - break; /* Ctrl-D 退出 */ + if (intr) + continue; /* Ctrl-C 丢掉当前行 */ + break; /* Ctrl-D 退出 */ } char *ans = str_trim(line); - if (*ans == '\0') { free(line); continue; } + if (*ans == '\0') { + free(line); + continue; + } - if (strcmp(ans, "/exit") == 0 || strcmp(ans, "/quit") == 0) { free(line); break; } - if (strcmp(ans, "/help") == 0 || strcmp(ans, "/h") == 0) { meta_help(); free(line); continue; } - if (strcmp(ans, "/diff") == 0) { meta_diff(target); free(line); continue; } + if (strcmp(ans, "/exit") == 0 || strcmp(ans, "/quit") == 0) { + free(line); + break; + } + if (strcmp(ans, "/help") == 0 || strcmp(ans, "/h") == 0) { + meta_help(); + free(line); + continue; + } + if (strcmp(ans, "/diff") == 0) { + meta_diff(target); + free(line); + continue; + } if (strcmp(ans, "/undo") == 0) { - if (snap_pop(g_ctx)) { g_edited_ever = 1; puts(t("meta.undo.done")); } - else puts(t("meta.undo.none")); + if (snap_pop(g_ctx)) { + g_edited_ever = 1; + puts(t("meta.undo.done")); + } else + puts(t("meta.undo.none")); free(line); continue; } @@ -832,25 +886,27 @@ int meta_session(Agent *target, LigentConfig *cfg) int r = agent_run(&m); spinner_stop(); putchar('\n'); - if (r < 0) fprintf(stderr, "%s", t("meta.turn_failed")); + if (r < 0) + fprintf(stderr, "%s", t("meta.turn_failed")); } size_t ops = meta_tools_ops(); int edited = (ops > 0); - line_edit_set_ink(NULL); /* 还回主提示符的绿色 */ + line_edit_set_ink(NULL); /* 还回主提示符的绿色 */ confirm_set_describer(NULL); - agent_free(&m); /* m.tools 由它一起释放, 别再 free 一次 */ + agent_free(&m); /* m.tools 由它一起释放, 别再 free 一次 */ meta_tools_detach(); printf("%s", t("meta.exit")); if (ops > 0) - printf("%s", tf("meta.exit.edits", "n", i18n_num((long long)ops), NULL)); + printf("%s", + tf("meta.exit.edits", "n", i18n_num((long long)ops), NULL)); else printf("%s", t("meta.exit.no_edits")); - printf("%s", tf("meta.exit.usage", "calls", i18n_num(m.api_calls), - "in", i18n_num(m.tok_prompt), - "out", i18n_num(m.tok_completion), NULL)); + printf("%s", + tf("meta.exit.usage", "calls", i18n_num(m.api_calls), "in", + i18n_num(m.tok_prompt), "out", i18n_num(m.tok_completion), NULL)); if (ops > 0) puts(t("meta.exit.lineage")); return edited ? 1 : 0; diff --git a/src/meta.h b/src/meta.h index 0f800dd..4a1ee29 100644 --- a/src/meta.h +++ b/src/meta.h @@ -1,11 +1,11 @@ /* meta.h - /meta: 用模型辅助编辑对话历史 * - * 和主对话的区别: 主模型是来"干活"的, meta 模型是来"改历史"的. - * 它不是那场对话的参与者, 而是拿用户指令去删改那段记录的编辑者. + * 和主对话的区别: 主模型是来"干活"的, meta 模型是来"改历史"的. + * 它不是那场对话的参与者, 而是拿用户指令去删改那段记录的编辑者. * * 两边工具集完全隔离: meta 会话的 Agent 用的是 meta_tools_schema() 和 * meta_exec_tool(), 主模型那边是 tools.c 那套. 主模型就算瞎编一个 - * delete_rounds 也只会拿到"未知工具". + * delete_rounds 也只会拿到"未知工具". */ #ifndef LIGENT_META_H #define LIGENT_META_H @@ -15,39 +15,40 @@ /* 被 meta 改过的对话要在下一次导出时另起一根 (名字 <原名>_m<3位>、 * parent 记空). meta.c 只管打标记, 具体命名由 main.c 的存档流程做. */ -int meta_reroot_pending(void); +int meta_reroot_pending(void); void meta_reroot_clear(void); -/* 进入 meta 模式. 编辑作用在 target 上; meta 自己的会话不落地. +/* 进入 meta 模式. 编辑作用在 target 上; meta 自己的会话不落地. * 返回 -1 = 没能进入; 0 = 进来又退出、没改动; 1 = 改过 (调用方据此 * 清掉 /back 撤销栈、让退出时必定重新存档). */ int meta_session(Agent *target, LigentConfig *cfg); /* 供单测用: 把一段历史按轮切开. 返回 0 表示"没有可编辑的轮次" - * (空历史, 或只有一条 system). + * (空历史, 或只有一条 system). * * 轮的定义与主循环**共用同一个判据** (agent_opens_round): 一轮从一条带文字的 * user 消息开始, 到下一条这样的消息之前; read_image 补的那条"只有图片附件"的 * user 消息不算新的一轮. head 是第一条可编辑消息的下标 (跳过 system). */ typedef struct { - size_t *starts; /* 每轮第一条消息的全局下标 */ - size_t count; - size_t head; /* 第一条可编辑消息的下标 (跳过 system) */ + size_t *starts; /* 每轮第一条消息的全局下标 */ + size_t count; + size_t head; /* 第一条可编辑消息的下标 (跳过 system) */ } MetaRounds; -int meta_rounds_build(const JsonValue *messages, MetaRounds *out); +int meta_rounds_build(const JsonValue *messages, MetaRounds *out); void meta_rounds_free(MetaRounds *r); -/* 供单测用: 这条消息能不能被单独删掉 (false = 结构消息, 牵连 tool_calls 配对) */ -int meta_deletable(const JsonValue *messages, size_t index); +/* 供单测用: 这条消息能不能被单独删掉 (false = 结构消息, 牵连 tool_calls 配对) + */ +int meta_deletable(const JsonValue *messages, size_t index); /* ---- 工具分发 ---- - * meta_session 内部走的就是这几个; 单测可以直接挂一段历史上来驱动它们, + * meta_session 内部走的就是这几个; 单测可以直接挂一段历史上来驱动它们, * 不必起模型会话、不必联网. 同一时刻只能挂一个. */ -int meta_tools_attach(Agent *target); /* 成功 0, 已在会话中返回 -1 */ -void meta_tools_detach(void); +int meta_tools_attach(Agent *target); /* 成功 0, 已在会话中返回 -1 */ +void meta_tools_detach(void); char *meta_tools_call(const char *name, const JsonValue *args, int *is_err); -int meta_tools_mutating(const char *name); -size_t meta_tools_ops(void); /* 成功改动次数 */ +int meta_tools_mutating(const char *name); +size_t meta_tools_ops(void); /* 成功改动次数 */ #endif /* LIGENT_META_H */ diff --git a/src/models.c b/src/models.c index eab033e..45d5d6d 100644 --- a/src/models.c +++ b/src/models.c @@ -9,22 +9,24 @@ #include #include -#define MODELS_MAX 512 /* 再多就不是给人挑的了, 多半是服务端出了什么问题 */ +#define MODELS_MAX 512 /* 再多就不是给人挑的了, 多半是服务端出了什么问题 */ -static int already_have(char **list, size_t n, const char *id) -{ +static int already_have(char **list, size_t n, const char *id) { for (size_t i = 0; i < n; i++) - if (strcmp(list[i], id) == 0) return 1; + if (strcmp(list[i], id) == 0) + return 1; return 0; } -char **models_parse(const char *json, size_t *out_n) -{ - if (out_n) *out_n = 0; - if (!json || !*json) return NULL; +char **models_parse(const char *json, size_t *out_n) { + if (out_n) + *out_n = 0; + if (!json || !*json) + return NULL; JsonValue *root = json_parse(json, NULL, 0); - if (!root) return NULL; + if (!root) + return NULL; /* 标准形状是 {"data": [...]}; 也见过直接回一个裸数组的兼容实现 */ const JsonValue *arr = NULL; @@ -32,12 +34,17 @@ char **models_parse(const char *json, size_t *out_n) arr = root; } else { JsonValue *data = json_object_get(root, "data"); - if (data && data->type == JSON_ARRAY) arr = data; + if (data && data->type == JSON_ARRAY) + arr = data; + } + if (!arr) { + json_free(root); + return NULL; } - if (!arr) { json_free(root); return NULL; } size_t cap = json_array_size(arr) + 1; - if (cap > MODELS_MAX) cap = MODELS_MAX; + if (cap > MODELS_MAX) + cap = MODELS_MAX; char **list = xcalloc(cap ? cap : 1, sizeof *list); size_t n = 0; @@ -45,60 +52,74 @@ char **models_parse(const char *json, size_t *out_n) const JsonValue *item = json_array_get(arr, i); /* 元素可能是字符串本身, 也可能是 {"id": "..."} */ const char *id = NULL; - if (item && item->type == JSON_STRING) id = json_string_value(item); - else if (item) id = json_get_string(item, "id"); + if (item && item->type == JSON_STRING) + id = json_string_value(item); + else if (item) + id = json_get_string(item, "id"); - if (!id || !*id) continue; - if (already_have(list, n, id)) continue; /* 有些服务端会重复列 */ + if (!id || !*id) + continue; + if (already_have(list, n, id)) + continue; /* 有些服务端会重复列 */ list[n++] = xstrdup(id); } json_free(root); - if (n == 0) { free(list); return NULL; } + if (n == 0) { + free(list); + return NULL; + } /* 保序: 服务端给的顺序是它自己的偏好, 我们不去替它排序 */ - if (out_n) *out_n = n; + if (out_n) + *out_n = n; return list; } -int models_is_empty_list(const char *json) -{ - if (!json || !*json) return 0; +int models_is_empty_list(const char *json) { + if (!json || !*json) + return 0; JsonValue *root = json_parse(json, NULL, 0); - if (!root) return 0; + if (!root) + return 0; const JsonValue *arr = NULL; if (root->type == JSON_ARRAY) { arr = root; } else { JsonValue *data = json_object_get(root, "data"); - if (data && data->type == JSON_ARRAY) arr = data; + if (data && data->type == JSON_ARRAY) + arr = data; } int empty = (arr && json_array_size((JsonValue *)arr) == 0); json_free(root); return empty; } -void models_free(char **list, size_t n) -{ - if (!list) return; - for (size_t i = 0; i < n; i++) free(list[i]); +void models_free(char **list, size_t n) { + if (!list) + return; + for (size_t i = 0; i < n; i++) + free(list[i]); free(list); } -char **models_fetch(const LigentConfig *cfg, size_t *out_n, - int verbose, char *err, size_t errlen) -{ - if (out_n) *out_n = 0; - if (err && errlen) err[0] = '\0'; +char **models_fetch(const LigentConfig *cfg, size_t *out_n, int verbose, + char *err, size_t errlen) { + if (out_n) + *out_n = 0; + if (err && errlen) + err[0] = '\0'; const char *bu = (cfg && cfg->base_url) ? cfg->base_url : ""; if (!*bu) { - if (err && errlen) snprintf(err, errlen, "%s", t("models.err.no_base_url")); + if (err && errlen) + snprintf(err, errlen, "%s", t("models.err.no_base_url")); return NULL; } size_t bl = strlen(bu); - while (bl > 0 && bu[bl - 1] == '/') bl--; + while (bl > 0 && bu[bl - 1] == '/') + bl--; char *url = xasprintf("%.*s/models", (int)bl, bu); char *body = NULL; @@ -106,14 +127,15 @@ char **models_fetch(const LigentConfig *cfg, size_t *out_n, char buf[512]; /* 读列表是只读的, 值得重试; 用默认退避策略 */ HttpRetry rt = HTTP_RETRY_DEFAULT; - int rc = http_get(url, cfg->api_key, cfg->timeout, verbose, &rt, - &body, &status, NULL, buf, sizeof buf); + int rc = http_get(url, cfg->api_key, cfg->timeout, verbose, &rt, &body, + &status, NULL, buf, sizeof buf); free(url); char **list = NULL; if (rc != 0) { if (err && errlen) - snprintf(err, errlen, "%s", buf[0] ? buf : t("models.err.request_failed")); + snprintf(err, errlen, "%s", + buf[0] ? buf : t("models.err.request_failed")); } else { list = models_parse(body, out_n); if (!list && err && errlen) { @@ -122,7 +144,8 @@ char **models_fetch(const LigentConfig *cfg, size_t *out_n, tf("models.err.empty_list", "status", i18n_num(status), NULL)); else if (body && *body) { - /* 只回显开头一段: 服务端偶尔回一大坨 HTML, 全塞进错误里没法读 */ + /* 只回显开头一段: 服务端偶尔回一大坨 HTML, 全塞进错误里没法读 + */ char head[121]; snprintf(head, sizeof head, "%.120s", body); snprintf(err, errlen, "%s", diff --git a/src/models.h b/src/models.h index 80749c3..8efdf18 100644 --- a/src/models.h +++ b/src/models.h @@ -1,11 +1,11 @@ /* models.h - 拉取并解析服务端的模型列表 (GET {base_url}/models) * - * 只为了 /model 不带参数时的那个交互式选择列表. OpenAI 兼容的响应体长这样: + * 只为了 /model 不带参数时的那个交互式选择列表. OpenAI 兼容的响应体长这样: * * {"object": "list", "data": [{"id": "deepseek-chat", ...}, ...]} * - * 我们只取 id. 别的字段 (object / owned_by / created)对挑模型没有帮助, - * 各家还都不一样 —— 取了反而要跟着人家的变化改. + * 我们只取 id. 别的字段 (object / owned_by / created)对挑模型没有帮助, + * 各家还都不一样 —— 取了反而要跟着人家的变化改. */ #ifndef LIGENT_MODELS_H #define LIGENT_MODELS_H @@ -15,7 +15,7 @@ #include /* 从 /models 的响应体里取模型名. 保序、去重, 返回 malloc 的字符串数组 - * (元素也是 malloc), *out_n 回填个数. 解析不出任何模型名时返回 NULL. + * (元素也是 malloc), *out_n 回填个数. 解析不出任何模型名时返回 NULL. * * 兼容两种形状: {"data":[...]} 和裸数组 [...]. 纯函数, 可单测. */ char **models_parse(const char *json, size_t *out_n); @@ -25,11 +25,11 @@ char **models_parse(const char *json, size_t *out_n); * 这两种情况的下一步动作完全不同, 含糊地说成"拿不到列表"就是误导. */ int models_is_empty_list(const char *json); -/* 打一次网络: GET {base_url}/models, 解析出模型名. - * 成功返回数组 (调用方用 models_free 释放), 失败返回 NULL 并把原因写进 err. +/* 打一次网络: GET {base_url}/models, 解析出模型名. + * 成功返回数组 (调用方用 models_free 释放), 失败返回 NULL 并把原因写进 err. * verbose 会打印 HTTP 细节. */ -char **models_fetch(const LigentConfig *cfg, size_t *out_n, - int verbose, char *err, size_t errlen); +char **models_fetch(const LigentConfig *cfg, size_t *out_n, int verbose, + char *err, size_t errlen); /* 释放 models_parse / models_fetch 返回的数组 */ void models_free(char **list, size_t n); diff --git a/src/priv.c b/src/priv.c index d5a4a72..ed7ff3d 100644 --- a/src/priv.c +++ b/src/priv.c @@ -9,21 +9,21 @@ #include #include -int priv_have_tty(void) -{ +int priv_have_tty(void) { int fd = open("/dev/tty", O_RDWR); - if (fd < 0) return 0; + if (fd < 0) + return 0; close(fd); return 1; } -char **priv_sudo_argv(const char *user, char *const argv[]) -{ +char **priv_sudo_argv(const char *user, char *const argv[]) { size_t n = 0; - while (argv[n]) n++; + while (argv[n]) + n++; const char *u = (user && *user) ? user : "root"; - /* 刻意不带 -n: 需要密码时就让 sudo 自己问. + /* 刻意不带 -n: 需要密码时就让 sudo 自己问. * 也不带 -S: 那要求把密码从 stdin 喂进去, 等于让 ligent 经手密码. */ /* execvp 的签名要 char *const[], 但这里放进去的都是字面量和调用方的 * 指针, **没有任何人会去改写它们** (execvp 本身也不会). 所以这里显式 @@ -33,13 +33,14 @@ char **priv_sudo_argv(const char *user, char *const argv[]) w[1] = (char *)"-u"; w[2] = (char *)u; w[3] = (char *)"--"; - for (size_t i = 0; i < n; i++) w[4 + i] = argv[i]; + for (size_t i = 0; i < n; i++) + w[4 + i] = argv[i]; return w; } -void priv_explain(int rc, const char *out, char *err, size_t errlen) -{ - if (!err || !errlen) return; +void priv_explain(int rc, const char *out, char *err, size_t errlen) { + if (!err || !errlen) + return; const char *o = out ? out : ""; if (rc == 127 && strstr(o, "无法执行")) diff --git a/src/priv.h b/src/priv.h index 96c8770..546259e 100644 --- a/src/priv.h +++ b/src/priv.h @@ -1,41 +1,41 @@ /* priv.h - 提权: 经 sudo 执行单条命令 * - * 先说清楚这个模块**不做**什么, 以及为什么: + * 先说清楚这个模块**不做**什么, 以及为什么: * * PAM 认证不等于获得权限. su 之所以能变 root, 是因为 /usr/bin/su 这个文件 * 本身是 setuid root —— 内核先把 root 给它, 它再验密码、验不过就降权. 普通 * 进程调 pam_authenticate() 只能得到"密码对不对", uid 一位都不变. 要让 ligent * 内部认证后真的变 root, 唯一的路是把它编译成 setuid root; 而它的功能是 - * "让模型执行任意命令", setuid root 的它等于把 root 远程执行做成一等功能. - * 所以不走那条路. + * "让模型执行任意命令", setuid root 的它等于把 root 远程执行做成一等功能. + * 所以不走那条路. * - * 也不做"授权提示". **密码本身就是授权**: 模型敲不出来, 你也不可能手滑敲出来. + * 也不做"授权提示". **密码本身就是授权**: 模型敲不出来, 你也不可能手滑敲出来. * 再加一道 y/N 只会是多余的摩擦, 还会给出一个虚假的安全模型 —— 好像把关的是 - * ligent 的提示, 其实真正把关的是 sudo. 所以这里只做两件事: + * ligent 的提示, 其实真正把关的是 sudo. 所以这里只做两件事: * - * 1. 把命令包成 `sudo -u <用户> -- ` 交出去 (**不经 shell**, - * 所以路径/命令里的引号、换行、`;` 注入不进 root 那条命令里); + * 1. 把命令包成 `sudo -u <用户> -- ` 交出去 (**不经 shell**, + * 所以路径/命令里的引号、换行、`;` 注入不进 root 那条命令里); * 2. 需要密码时, **让终端留在 sudo 手上**, 由它自己问、自己存 —— ligent - * 不读、不存、不转发密码. + * 不读、不存、不转发密码. * - * 唯一由 ligent 把守的是"别卡死": 没有控制终端就**根本不去试**, 直接说清楚. - * 否则就会起一个在后台等一个没人能看见的密码提示的进程. + * 唯一由 ligent 把守的是"别卡死": 没有控制终端就**根本不去试**, 直接说清楚. + * 否则就会起一个在后台等一个没人能看见的密码提示的进程. */ #ifndef LIGENT_PRIV_H #define LIGENT_PRIV_H #include -/* 有可用的控制终端吗?判据是能不能打开 /dev/tty, **不是** isatty(0): +/* 有可用的控制终端吗?判据是能不能打开 /dev/tty, **不是** isatty(0): * `echo x | ligent ...` 时 stdin 是管道, 但终端还在, sudo 照样问得出来. */ int priv_have_tty(void); -/* 把 argv 包成 `sudo -u -- <原 argv>` (user 为空则 "root"). - * 故意**不带 -n**: 需要密码时就让 sudo 自己去问. +/* 把 argv 包成 `sudo -u -- <原 argv>` (user 为空则 "root"). + * 故意**不带 -n**: 需要密码时就让 sudo 自己去问. * 返回 malloc 的数组, 元素是借用的指针 —— 调用方 free 数组本身即可. */ char **priv_sudo_argv(const char *user, char *const argv[]); -/* sudo 失败时把它的原话换成一句能照做的中文 (写进 err). +/* sudo 失败时把它的原话换成一句能照做的中文 (写进 err). * 认不出来就原样透传 —— 宁可给原文, 也不要编一句可能不对的解释. */ void priv_explain(int rc, const char *out, char *err, size_t errlen); diff --git a/src/sgr.h b/src/sgr.h index 890bb01..bbefbf2 100644 --- a/src/sgr.h +++ b/src/sgr.h @@ -1,39 +1,39 @@ // ECMA-48 SGR 序列, 参见 console_codes(4) -#define C_OFF "\033[0m" // 复原所有属性 +#define C_OFF "\033[0m" // 复原所有属性 -#define C_BOLD "\033[1m" // 粗体 -#define C_DIM "\033[2m" // 半亮度 -#define C_ITALIC "\033[3m" // 斜体 -#define C_UNDERLINE "\033[4m" // 下划线 -#define C_REVERSE "\033[7m" // 反显 +#define C_BOLD "\033[1m" // 粗体 +#define C_DIM "\033[2m" // 半亮度 +#define C_ITALIC "\033[3m" // 斜体 +#define C_UNDERLINE "\033[4m" // 下划线 +#define C_REVERSE "\033[7m" // 反显 -#define C_BLACK "\033[30m" -#define C_RED "\033[31m" -#define C_GREEN "\033[32m" -#define C_YELLOW "\033[33m" -#define C_BLUE "\033[34m" -#define C_MAGENTA "\033[35m" -#define C_CYAN "\033[36m" -#define C_WHITE "\033[37m" +#define C_BLACK "\033[30m" +#define C_RED "\033[31m" +#define C_GREEN "\033[32m" +#define C_YELLOW "\033[33m" +#define C_BLUE "\033[34m" +#define C_MAGENTA "\033[35m" +#define C_CYAN "\033[36m" +#define C_WHITE "\033[37m" -#define C_FG_DEFAULT "\033[39m" // 默认前景色 -#define C_BG_DEFAULT "\033[49m" // 默认背景色 +#define C_FG_DEFAULT "\033[39m" // 默认前景色 +#define C_BG_DEFAULT "\033[49m" // 默认背景色 -#define C_BG_BLACK "\033[40m" -#define C_BG_RED "\033[41m" -#define C_BG_GREEN "\033[42m" -#define C_BG_BROWN "\033[43m" -#define C_BG_YELLOW "\033[43m" -#define C_BG_BLUE "\033[44m" +#define C_BG_BLACK "\033[40m" +#define C_BG_RED "\033[41m" +#define C_BG_GREEN "\033[42m" +#define C_BG_BROWN "\033[43m" +#define C_BG_YELLOW "\033[43m" +#define C_BG_BLUE "\033[44m" #define C_BG_MAGENTA "\033[45m" -#define C_BG_CYAN "\033[46m" -#define C_BG_WHITE "\033[47m" +#define C_BG_CYAN "\033[46m" +#define C_BG_WHITE "\033[47m" -#define C_BRIGHT_BLACK "\033[90m" -#define C_BRIGHT_RED "\033[91m" -#define C_BRIGHT_GREEN "\033[92m" -#define C_BRIGHT_YELLOW "\033[93m" -#define C_BRIGHT_BLUE "\033[94m" +#define C_BRIGHT_BLACK "\033[90m" +#define C_BRIGHT_RED "\033[91m" +#define C_BRIGHT_GREEN "\033[92m" +#define C_BRIGHT_YELLOW "\033[93m" +#define C_BRIGHT_BLUE "\033[94m" #define C_BRIGHT_MAGENTA "\033[95m" -#define C_BRIGHT_CYAN "\033[96m" -#define C_BRIGHT_WHITE "\033[97m" +#define C_BRIGHT_CYAN "\033[96m" +#define C_BRIGHT_WHITE "\033[97m" diff --git a/src/shell.c b/src/shell.c index c10b11f..0eed6a2 100644 --- a/src/shell.c +++ b/src/shell.c @@ -1,79 +1,86 @@ /* shell.c - 交互式 shell 会话 (pty 双向搬运) */ #include "shell.h" +#include "http.h" #include "i18n.h" #include "util.h" -#include "http.h" +#include +#include +#include +#include #include #include #include -#include -#include -#include -#include -#include -#include #include #include +#include +#include -/* bash → ash → dash → sh. +/* bash → ash → dash → sh. * - * 刻意不看 $SHELL: 用户环境里的 $SHELL 可能是 xonsh 之类交互式外壳, + * 刻意不看 $SHELL: 用户环境里的 $SHELL 可能是 xonsh 之类交互式外壳, * 在容器/受限环境里经常起不来, 反而把 /shell 弄坏. 返回的路径一定存在. */ -static const char *pick_shell(void) -{ - static const char *cands[] = { - "/bin/bash", "/bin/ash", "/bin/dash", "/bin/sh", NULL - }; +static const char *pick_shell(void) { + static const char *cands[] = {"/bin/bash", "/bin/ash", "/bin/dash", + "/bin/sh", NULL}; for (size_t i = 0; cands[i]; i++) - if (access(cands[i], X_OK) == 0) return cands[i]; + if (access(cands[i], X_OK) == 0) + return cands[i]; return "/bin/sh"; } -/* 把终端输出整理成"能读的记录": - * 去掉 ANSI 转义、把 CRLF 归一成 LF、丢掉退格与其它控制字符, 并处理全屏程序. - * 不清理的话模型看到的是一堆 ^[[32m 之类的噪音. +/* 把终端输出整理成"能读的记录": + * 去掉 ANSI 转义、把 CRLF 归一成 LF、丢掉退格与其它控制字符, 并处理全屏程序. + * 不清理的话模型看到的是一堆 ^[[32m 之类的噪音. * - * 状态必须跨 read() 保存, 原因有三个: - * - 转义序列可能正好被 read() 从中间切断, 分两次解析会串味; - * - 全屏程序的备用屏幕进入/退出天然跨越多次 read; + * 状态必须跨 read() 保存, 原因有三个: + * - 转义序列可能正好被 read() 从中间切断, 分两次解析会串味; + * - 全屏程序的备用屏幕进入/退出天然跨越多次 read; * - 连续空格要和后面的字符一起看才压得准. */ enum { CLEAN_NORM = 0, CLEAN_ESC, CLEAN_CSI, CLEAN_OSC, CLEAN_OSC_ESC }; typedef struct { int state; - int in_alt; /* 正在备用屏幕里 (vim/less/top 这类全屏程序) */ - size_t space_run; /* 还没输出的连续空格 */ + int in_alt; /* 正在备用屏幕里 (vim/less/top 这类全屏程序) */ + size_t space_run; /* 还没输出的连续空格 */ char csi[40]; size_t csi_len; } CleanCtx; -/* 全屏程序 (nano/top 这类)靠成片空格重绘, 一次刷新就是成百上千个. - * 原样记录会让模型看到满屏空白, 把真正的输出淹掉. +/* 全屏程序 (nano/top 这类)靠成片空格重绘, 一次刷新就是成百上千个. + * 原样记录会让模型看到满屏空白, 把真正的输出淹掉. * 短的缩进 (<= 8)原样保留; 更长的压成一个空格. */ -static void clean_flush_spaces(CleanCtx *cx, StrBuf *out) -{ - if (cx->space_run == 0 || cx->in_alt) { cx->space_run = 0; return; } +static void clean_flush_spaces(CleanCtx *cx, StrBuf *out) { + if (cx->space_run == 0 || cx->in_alt) { + cx->space_run = 0; + return; + } size_t k = cx->space_run <= 8 ? cx->space_run : 1; - for (size_t i = 0; i < k && out->len < LIGENT_SHELL_MAX; i++) sb_putc(out, ' '); + for (size_t i = 0; i < k && out->len < LIGENT_SHELL_MAX; i++) + sb_putc(out, ' '); cx->space_run = 0; } -static void clean_putc(CleanCtx *cx, StrBuf *out, unsigned char c) -{ - if (cx->in_alt || out->len >= LIGENT_SHELL_MAX) return; - if (c == ' ') { cx->space_run++; return; } +static void clean_putc(CleanCtx *cx, StrBuf *out, unsigned char c) { + if (cx->in_alt || out->len >= LIGENT_SHELL_MAX) + return; + if (c == ' ') { + cx->space_run++; + return; + } clean_flush_spaces(cx, out); - if (out->len < LIGENT_SHELL_MAX) sb_putc(out, (char)c); + if (out->len < LIGENT_SHELL_MAX) + sb_putc(out, (char)c); } -/* 备用屏幕的开启序列有 ?1049 (vim/less)、?1047 和 ?47 (老程序)几种写法, +/* 备用屏幕的开启序列有 ?1049 (vim/less)、?1047 和 ?47 (老程序)几种写法, * 结束用的是同样的编号加 'l'. */ -static int csi_alt_screen(const CleanCtx *cx, int *entering) -{ - if (cx->csi_len == 0) return 0; +static int csi_alt_screen(const CleanCtx *cx, int *entering) { + if (cx->csi_len == 0) + return 0; char last = cx->csi[cx->csi_len - 1]; - if (last != 'h' && last != 'l') return 0; + if (last != 'h' && last != 'l') + return 0; if (!strstr(cx->csi, "?1049") && !strstr(cx->csi, "?1047") && !strstr(cx->csi, "?47")) return 0; @@ -81,21 +88,26 @@ static int csi_alt_screen(const CleanCtx *cx, int *entering) return 1; } -static void append_clean(CleanCtx *cx, StrBuf *out, const char *data, size_t n) -{ +static void append_clean(CleanCtx *cx, StrBuf *out, const char *data, + size_t n) { for (size_t i = 0; i < n; i++) { unsigned char c = (unsigned char)data[i]; switch (cx->state) { - case CLEAN_ESC: /* ESC 后面看是什么序列 */ - if (c == '[') { cx->state = CLEAN_CSI; cx->csi_len = 0; } - else if (c == ']') cx->state = CLEAN_OSC; - else cx->state = CLEAN_NORM; + case CLEAN_ESC: /* ESC 后面看是什么序列 */ + if (c == '[') { + cx->state = CLEAN_CSI; + cx->csi_len = 0; + } else if (c == ']') + cx->state = CLEAN_OSC; + else + cx->state = CLEAN_NORM; continue; case CLEAN_CSI: - if (cx->csi_len < sizeof cx->csi - 1) cx->csi[cx->csi_len++] = (char)c; - if (c >= 0x40 && c <= 0x7e) { /* 终结字节, 序列到此为止 */ + if (cx->csi_len < sizeof cx->csi - 1) + cx->csi[cx->csi_len++] = (char)c; + if (c >= 0x40 && c <= 0x7e) { /* 终结字节, 序列到此为止 */ int entering = 0; cx->csi[cx->csi_len] = '\0'; if (csi_alt_screen(cx, &entering)) { @@ -103,7 +115,8 @@ static void append_clean(CleanCtx *cx, StrBuf *out, const char *data, size_t n) clean_flush_spaces(cx, out); cx->in_alt = 1; if (out->len < LIGENT_SHELL_MAX) - sb_puts(out, "\n[全屏程序运行中, 画面内容已省略]\n"); + sb_puts(out, + "\n[全屏程序运行中, 画面内容已省略]\n"); } else if (!entering && cx->in_alt) { cx->in_alt = 0; cx->space_run = 0; @@ -115,9 +128,11 @@ static void append_clean(CleanCtx *cx, StrBuf *out, const char *data, size_t n) } continue; - case CLEAN_OSC: /* OSC 到 BEL 或 ST 结束 */ - if (c == '\a') cx->state = CLEAN_NORM; - else if (c == 0x1b) cx->state = CLEAN_OSC_ESC; + case CLEAN_OSC: /* OSC 到 BEL 或 ST 结束 */ + if (c == '\a') + cx->state = CLEAN_NORM; + else if (c == 0x1b) + cx->state = CLEAN_OSC_ESC; continue; case CLEAN_OSC_ESC: @@ -128,18 +143,25 @@ static void append_clean(CleanCtx *cx, StrBuf *out, const char *data, size_t n) break; } - if (c == 0x1b) { cx->state = CLEAN_ESC; continue; } - if (c == '\r') continue; /* CRLF 归一成 LF; 孤立的 CR 丢掉 */ - if (c == '\b') continue; /* 退格在记录里没有意义 */ - if (c < 0x09 || (c > 0x0d && c < 0x20)) continue; + if (c == 0x1b) { + cx->state = CLEAN_ESC; + continue; + } + if (c == '\r') + continue; /* CRLF 归一成 LF; 孤立的 CR 丢掉 */ + if (c == '\b') + continue; /* 退格在记录里没有意义 */ + if (c < 0x09 || (c > 0x0d && c < 0x20)) + continue; clean_putc(cx, out, c); } } -static int tty_raw(struct termios *saved) -{ - if (!isatty(STDIN_FILENO)) return -1; - if (tcgetattr(STDIN_FILENO, saved) != 0) return -1; +static int tty_raw(struct termios *saved) { + if (!isatty(STDIN_FILENO)) + return -1; + if (tcgetattr(STDIN_FILENO, saved) != 0) + return -1; struct termios raw = *saved; raw.c_iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON); raw.c_cflag |= CS8; @@ -149,9 +171,9 @@ static int tty_raw(struct termios *saved) return tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) == 0 ? 0 : -1; } -char *shell_interactive(int *out_code, char *errbuf, size_t errlen) -{ - if (out_code) *out_code = 0; +char *shell_interactive(int *out_code, char *errbuf, size_t errlen) { + if (out_code) + *out_code = 0; int master = posix_openpt(O_RDWR | O_NOCTTY); if (master < 0) { @@ -178,9 +200,9 @@ char *shell_interactive(int *out_code, char *errbuf, size_t errlen) if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0) ioctl(master, TIOCSWINSZ, &ws); - /* 全屏程序 (nano/vim/less/top)靠"备用屏幕"切换来还原退出前的画面, + /* 全屏程序 (nano/vim/less/top)靠"备用屏幕"切换来还原退出前的画面, * 但它们只在 TERM 声称支持时才这么做: TERM 为空或 dumb 时 nano 会退化成 - * 逐行重绘, 几百个空格把记录淹掉, 也没法整段忽略. + * 逐行重绘, 几百个空格把记录淹掉, 也没法整段忽略. * * 而 ligent 自己的 REPL 就在用颜色、光标定位和括号粘贴 —— 这个终端至少是 * ANSI 兼容的, TERM=dumb 属于谎报. 所以只在 TERM 缺失或为 dumb 时补一个 @@ -200,11 +222,13 @@ 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); - else unsetenv("TERM"); + if (term_was_set) + setenv("TERM", term_saved, 1); + else + unsetenv("TERM"); } if (pid < 0) { snprintf(errbuf, errlen, "%s", @@ -215,12 +239,14 @@ char *shell_interactive(int *out_code, char *errbuf, size_t errlen) if (pid == 0) { setsid(); int slave = open(slave_path, O_RDWR); - if (slave < 0) _exit(127); - ioctl(slave, TIOCSCTTY, 0); /* 让这个 pty 成为控制终端 */ + if (slave < 0) + _exit(127); + ioctl(slave, TIOCSCTTY, 0); /* 让这个 pty 成为控制终端 */ dup2(slave, STDIN_FILENO); dup2(slave, STDOUT_FILENO); dup2(slave, STDERR_FILENO); - if (slave > 2) close(slave); + if (slave > 2) + close(slave); close(master); const char *sh = pick_shell(); execl(sh, sh, "-i", (char *)NULL); @@ -242,7 +268,8 @@ char *shell_interactive(int *out_code, char *errbuf, size_t errlen) while (!(child_done && eof)) { if (!child_done) { pid_t w = waitpid(pid, &status, WNOHANG); - if (w == pid) child_done = 1; + if (w == pid) + child_done = 1; } if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0) ioctl(master, TIOCSWINSZ, &ws); @@ -257,7 +284,8 @@ char *shell_interactive(int *out_code, char *errbuf, size_t errlen) int pr = poll(fds, eof ? 1 : 2, 100); if (pr < 0) { - if (errno == EINTR) continue; + if (errno == EINTR) + continue; break; } @@ -269,7 +297,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; } @@ -278,25 +306,31 @@ char *shell_interactive(int *out_code, char *errbuf, size_t errlen) char buf[4096]; ssize_t r = read(STDIN_FILENO, buf, sizeof buf); if (r > 0) { - if (write(master, buf, (size_t)r) < 0 && errno == EIO) eof = 1; + if (write(master, buf, (size_t)r) < 0 && errno == EIO) + eof = 1; } } } if (!child_done) { kill(-pid, SIGKILL); - while (waitpid(pid, &status, 0) < 0 && errno == EINTR) { } + while (waitpid(pid, &status, 0) < 0 && errno == EINTR) { + } } close(master); - if (raw) tcsetattr(STDIN_FILENO, TCSAFLUSH, &saved); + if (raw) + tcsetattr(STDIN_FILENO, TCSAFLUSH, &saved); int code = -1; - if (WIFEXITED(status)) code = WEXITSTATUS(status); - else if (WIFSIGNALED(status)) code = 128 + WTERMSIG(status); - if (out_code) *out_code = code; + if (WIFEXITED(status)) + code = WEXITSTATUS(status); + else if (WIFSIGNALED(status)) + code = 128 + WTERMSIG(status); + if (out_code) + *out_code = code; - clean_flush_spaces(&clean, &rec); /* 收尾: 还压在手里的空格 */ - /* 全屏程序被 Ctrl-C/SIGKILL 干掉时会来不及发退出序列, 补一个收尾标记, + clean_flush_spaces(&clean, &rec); /* 收尾: 还压在手里的空格 */ + /* 全屏程序被 Ctrl-C/SIGKILL 干掉时会来不及发退出序列, 补一个收尾标记, * 以免记录里留下一个没有配对的"开始". */ if (clean.in_alt && rec.len < LIGENT_SHELL_MAX) sb_puts(&rec, "[全屏程序结束]\n"); diff --git a/src/shell.h b/src/shell.h index 9621d92..40b1301 100644 --- a/src/shell.h +++ b/src/shell.h @@ -4,13 +4,13 @@ #include -/* 起一个交互式 shell, 用户在里面随便敲; 退出时把整段会话录制下来. +/* 起一个交互式 shell, 用户在里面随便敲; 退出时把整段会话录制下来. * * 用伪终端 (pty)做双向搬运: 子进程以为自己接在真终端上, 所以行编辑、 - * 颜色、Ctrl-C、作业控制都照常工作; 我们在外面旁听并把输出记下来. + * 颜色、Ctrl-C、作业控制都照常工作; 我们在外面旁听并把输出记下来. * - * out_code 回填子进程退出码. - * 返回 malloc 的录制文本 (已去掉 ANSI 控制序列, 最长 LIGENT_SHELL_MAX); + * out_code 回填子进程退出码. + * 返回 malloc 的录制文本 (已去掉 ANSI 控制序列, 最长 LIGENT_SHELL_MAX); * 失败返回 NULL 并写 errbuf. 退出码 127 表示 shell 都没找到. */ char *shell_interactive(int *out_code, char *errbuf, size_t errlen); diff --git a/src/spinner.c b/src/spinner.c index f62a7c4..839d16f 100644 --- a/src/spinner.c +++ b/src/spinner.c @@ -2,36 +2,35 @@ #include "spinner.h" #include "i18n.h" -#include -#include -#include -#include -#include #include +#include +#include +#include +#include +#include #define DIM "\033[2m" #define OFF "\033[0m" -static pthread_t g_thread; +static pthread_t g_thread; static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; -static pthread_cond_t g_cond; -static pthread_once_t g_cond_once = PTHREAD_ONCE_INIT; -static volatile int g_running; -static int g_started; -static int g_enabled; -static char g_label[64]; -/* 当前这段等待的起点. 换标签时重置, 于是秒数反映的是"这个阶段等了多久": +static pthread_cond_t g_cond; +static pthread_once_t g_cond_once = PTHREAD_ONCE_INIT; +static volatile int g_running; +static int g_started; +static int g_enabled; +static char g_label[64]; +/* 当前这段等待的起点. 换标签时重置, 于是秒数反映的是"这个阶段等了多久": * 先看到"等待响应 (1s)", 切成"思考中"后重新从 0 数起. */ static struct timespec g_t0; -/* 条件变量必须显式指定用 CLOCK_MONOTONIC. +/* 条件变量必须显式指定用 CLOCK_MONOTONIC. * * pthread_cond_timedwait 默认按 CLOCK_REALTIME 解释超时, 而我们算出来的 * 是 CLOCK_MONOTONIC 的绝对时间. 开机时长 (几十万秒)永远小于 epoch 时间 - * (十几亿秒), 于是"超时时刻"永远被当成已经过去 —— timedwait 立刻返回, + * (十几亿秒), 于是"超时时刻"永远被当成已经过去 —— timedwait 立刻返回, * worker 变成疯狂重绘的忙循环. */ -static void init_cond(void) -{ +static void init_cond(void) { pthread_condattr_t attr; pthread_condattr_init(&attr); pthread_condattr_setclock(&attr, CLOCK_MONOTONIC); @@ -39,10 +38,9 @@ static void init_cond(void) pthread_condattr_destroy(&attr); } -static void *worker(void *arg) -{ +static void *worker(void *arg) { (void)arg; - static const char *dots[] = { ".", "..", "...", "...." }; + static const char *dots[] = {".", "..", "...", "...."}; int i = 0; struct timespec next; clock_gettime(CLOCK_MONOTONIC, &next); @@ -52,18 +50,22 @@ static void *worker(void *arg) long elapsed; pthread_mutex_lock(&g_lock); - if (!g_running) { pthread_mutex_unlock(&g_lock); break; } + if (!g_running) { + pthread_mutex_unlock(&g_lock); + break; + } clock_gettime(CLOCK_MONOTONIC, &now); elapsed = (long)(now.tv_sec - g_t0.tv_sec); fprintf(stderr, "\r\033[K%s%s%s%s", DIM, g_label, dots[i], OFF); /* 等得越久越该说明"还活着", 超过 5 秒就把秒数亮出来 */ - if (elapsed >= 5) fprintf(stderr, " %s(%lds)%s", DIM, elapsed, OFF); + if (elapsed >= 5) + fprintf(stderr, " %s(%lds)%s", DIM, elapsed, OFF); fflush(stderr); i = (i + 1) % 4; - /* 睡到下一帧. 这里必须用条件变量定时等, 不能 nanosleep: + /* 睡到下一帧. 这里必须用条件变量定时等, 不能 nanosleep: * spinner_stop() 要 pthread_join, nanosleep 的话就得干等这一觉 * 睡满 —— 每次收尾白搭最多 250ms, 而这笔账直接加在用户感知的 * 延迟上 (每轮模型往返、每个工具来回都要付一次). */ @@ -78,37 +80,34 @@ static void *worker(void *arg) return NULL; } -void spinner_init(int enabled) -{ - g_enabled = enabled ? 1 : 0; -} +void spinner_init(int enabled) { g_enabled = enabled ? 1 : 0; } -/* 调用方必须已经持有锁. 标签真的变了才重置计时, +/* 调用方必须已经持有锁. 标签真的变了才重置计时, * 否则每来一片思维链都重置, "思考中 (12s)"永远停在 0. */ -static void set_label_locked(const char *label) -{ +static void set_label_locked(const char *label) { const char *want = (label && *label) ? label : t("spinner.thinking"); - if (strcmp(g_label, want) == 0) return; + if (strcmp(g_label, want) == 0) + return; snprintf(g_label, sizeof g_label, "%s", want); clock_gettime(CLOCK_MONOTONIC, &g_t0); } -void spinner_start(const char *label) -{ - if (!g_enabled) return; - pthread_once(&g_cond_once, init_cond); /* 保证 g_cond 用的是单调时钟 */ +void spinner_start(const char *label) { + if (!g_enabled) + return; + pthread_once(&g_cond_once, init_cond); /* 保证 g_cond 用的是单调时钟 */ pthread_mutex_lock(&g_lock); if (!g_started) { /* 先把标签算成**一份副本**再写进 g_label: label 有可能就是 g_label 自己 * —— spinner_notice() 从一条重试提示里恢复动画时正是这么调的. 以前这里 * 先执行 g_label[0] = '\0', 于是要读的那个字符串被自己清空, 标签退化成 - * 默认的"思考中": 每次重试退避之后, 屏幕上的"等待响应"都会变成"思考中", + * 默认的"思考中": 每次重试退避之后, 屏幕上的"等待响应"都会变成"思考中", * 而这两段等待的含义恰恰是它要区分的. */ char want[sizeof g_label]; snprintf(want, sizeof want, "%s", (label && *label) ? label : t("spinner.thinking")); - g_label[0] = '\0'; /* 让 set_label_locked 一定认成"变了" */ - set_label_locked(want); /* 传副本, 不传 g_label 自己 */ + g_label[0] = '\0'; /* 让 set_label_locked 一定认成"变了" */ + set_label_locked(want); /* 传副本, 不传 g_label 自己 */ g_running = 1; g_started = 1; if (pthread_create(&g_thread, NULL, worker, NULL) != 0) { @@ -119,40 +118,43 @@ void spinner_start(const char *label) pthread_mutex_unlock(&g_lock); } -void spinner_set_label(const char *label) -{ - if (!g_enabled) return; +void spinner_set_label(const char *label) { + if (!g_enabled) + return; pthread_mutex_lock(&g_lock); set_label_locked(label); pthread_mutex_unlock(&g_lock); } -void spinner_stop(void) -{ - if (!g_enabled) return; +void spinner_stop(void) { + if (!g_enabled) + return; pthread_mutex_lock(&g_lock); - if (!g_started) { pthread_mutex_unlock(&g_lock); return; } + if (!g_started) { + pthread_mutex_unlock(&g_lock); + return; + } g_running = 0; - pthread_cond_signal(&g_cond); /* 立刻叫醒 worker, 别等它睡满一帧 */ + pthread_cond_signal(&g_cond); /* 立刻叫醒 worker, 别等它睡满一帧 */ pthread_mutex_unlock(&g_lock); pthread_join(g_thread, NULL); pthread_mutex_lock(&g_lock); g_started = 0; - fputs("\r\033[K", stderr); /* 把动画那一行擦干净 */ + fputs("\r\033[K", stderr); /* 把动画那一行擦干净 */ fflush(stderr); pthread_mutex_unlock(&g_lock); } -void spinner_notice(const char *fmt, ...) -{ +void spinner_notice(const char *fmt, ...) { int was_on; pthread_mutex_lock(&g_lock); was_on = g_started; pthread_mutex_unlock(&g_lock); - if (was_on) spinner_stop(); + if (was_on) + spinner_stop(); va_list ap; va_start(ap, fmt); @@ -161,5 +163,6 @@ void spinner_notice(const char *fmt, ...) fputc('\n', stderr); fflush(stderr); - if (was_on) spinner_start(g_label); + if (was_on) + spinner_start(g_label); } diff --git a/src/spinner.h b/src/spinner.h index 9c87897..ec845bf 100644 --- a/src/spinner.h +++ b/src/spinner.h @@ -1,19 +1,19 @@ /* spinner.h - 等待指示器 * - * 存在的意义: 模型不输出思考过程是好事, 但等待期间屏幕上什么都没有, - * 和"卡死了"看起来一模一样. 一个灰色的动画能把这个歧义消掉. + * 存在的意义: 模型不输出思考过程是好事, 但等待期间屏幕上什么都没有, + * 和"卡死了"看起来一模一样. 一个灰色的动画能把这个歧义消掉. * - * 分两段显示, 因为这两种等待的性质不一样: + * 分两段显示, 因为这两种等待的性质不一样: * 刚发出请求、还没收到任何东西 → "等待响应" (卡在网络或排队) * 开始收到思维链 → "思考中" (模型确实在推理) - * 一收到正文就停 —— 那时已经有东西可看了. + * 一收到正文就停 —— 那时已经有东西可看了. * - * 输出走 stderr, 且只在 stderr 是终端时才启用, 所以不会污染管道输出. + * 输出走 stderr, 且只在 stderr 是终端时才启用, 所以不会污染管道输出. */ #ifndef LIGENT_SPINNER_H #define LIGENT_SPINNER_H -#include "util.h" /* LIGENT_PRINTF */ +#include "util.h" /* LIGENT_PRINTF */ /* enabled = 0 时所有接口都变成空操作 */ void spinner_init(int enabled); @@ -21,16 +21,16 @@ void spinner_init(int enabled); /* 开始转动. 已在转则忽略 (幂等) */ void spinner_start(const char *label); -/* 换标签 (例如从"等待响应"切到"思考中"). - * 标签真的变了才重置计时, 所以每来一片思维链都调用它也没问题. +/* 换标签 (例如从"等待响应"切到"思考中"). + * 标签真的变了才重置计时, 所以每来一片思维链都调用它也没问题. * 未在转时只记下标签, 什么都不打印. */ void spinner_set_label(const char *label); -/* 停止并擦掉那一行. 未在转则忽略 (幂等). +/* 停止并擦掉那一行. 未在转则忽略 (幂等). * 流式输出开始前必须先调用它, 否则内容会和动画抢同一行. */ void spinner_stop(void); -/* 在动画中间插一条提示 (例如"稍后重试"): 先停并清行, 打印, 再继续转. +/* 在动画中间插一条提示 (例如"稍后重试"): 先停并清行, 打印, 再继续转. * 未在转时就是普通打印. */ void spinner_notice(const char *fmt, ...) LIGENT_PRINTF(1, 2); diff --git a/src/tools.c b/src/tools.c index 4453f44..dd3ec7d 100644 --- a/src/tools.c +++ b/src/tools.c @@ -1,44 +1,46 @@ /* tools.c - 工具实现: shell、文件读写、编辑、列目录、搜索 * - * 设计取向: 这是"全权执行"模式, 不做沙箱限制; 但仍对输出做截断, - * 避免一次命令把上下文撑爆. + * 设计取向: 这是"全权执行"模式, 不做沙箱限制; 但仍对输出做截断, + * 避免一次命令把上下文撑爆. */ #include "tools.h" -#include "util.h" -#include "i18n.h" #include "http.h" +#include "i18n.h" #include "image.h" -#include "priv.h" #include "llm.h" +#include "priv.h" +#include "util.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include #include #include #include -#include +#include +#include /* ------------------------------------------------------------------ */ /* 小工具 */ /* ------------------------------------------------------------------ */ -static void append_capped(StrBuf *sb, const char *data, size_t n, int *truncated) -{ - if (*truncated) return; +static void append_capped(StrBuf *sb, const char *data, size_t n, + int *truncated) { + if (*truncated) + return; if (sb->len + n > LIGENT_TOOL_OUTPUT_LIMIT) { size_t take = sb->len < LIGENT_TOOL_OUTPUT_LIMIT - ? LIGENT_TOOL_OUTPUT_LIMIT - sb->len : 0; + ? LIGENT_TOOL_OUTPUT_LIMIT - sb->len + : 0; sb_append(sb, data, take); *truncated = 1; return; @@ -46,20 +48,17 @@ static void append_capped(StrBuf *sb, const char *data, size_t n, int *truncated sb_append(sb, data, n); } -static int64_t now_ms(void) -{ +static int64_t now_ms(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; } -static const char *arg_str(const JsonValue *args, const char *key) -{ +static const char *arg_str(const JsonValue *args, const char *key) { return json_string_value(json_object_get(args, key)); } -LIGENT_PRINTF(1, 2) static char *err_result(const char *fmt, ...) -{ +LIGENT_PRINTF(1, 2) static char *err_result(const char *fmt, ...) { va_list ap; va_start(ap, fmt); char *body = xvasprintf(fmt, ap); @@ -73,17 +72,17 @@ LIGENT_PRINTF(1, 2) static char *err_result(const char *fmt, ...) * * 判据 (出现 NUL 字节)与探测窗口 (前 8 KB)都在 util.h 里, 由所有处理文本的 * 地方共用一份 —— 以前这里和 attach.c 各写一份、窗口还不一样, 于是同一个文件 - * 会出现"/include 说它是二进制、read_file 说它能读"的矛盾说法. + * 会出现"/include 说它是二进制、read_file 说它能读"的矛盾说法. * edit_file 是唯一的例外, 它检查**全文件** (见那里的说明). */ -/* 文件看起来是二进制吗?是则返回 1 并把第一个 NUL 的偏移写进 *at. +/* 文件看起来是二进制吗?是则返回 1 并把第一个 NUL 的偏移写进 *at. * - * 打不开或读不出来时返回 0 —— 让真正做这件事的调用方去报"读不到", + * 打不开或读不出来时返回 0 —— 让真正做这件事的调用方去报"读不到", * 否则同一次失败会同时冒出两条互相竞争的错误信息. */ -static int file_is_binary(const char *path, long *at) -{ +static int file_is_binary(const char *path, long *at) { FILE *f = fopen(path, "rb"); - if (!f) return 0; + if (!f) + return 0; char probe[LIGENT_BIN_PROBE]; size_t n = fread(probe, 1, sizeof probe, f); fclose(f); @@ -92,8 +91,7 @@ static int file_is_binary(const char *path, long *at) /* 统一的拒绝说法: 先摆事实 (第几字节有 NUL), 再说这个工具为什么只收文本、 * 以及该改用什么. 只说"不行"等于让模型原地打转. */ -static char *binary_err(const char *path, long at, const char *why) -{ +static char *binary_err(const char *path, long at, const char *why) { return err_result("%s 看起来是二进制文件 (第 %ld 字节处有 NUL 字节). %s", path, at + 1, why); } @@ -103,10 +101,9 @@ static char *binary_err(const char *path, long at, const char *why) /* ------------------------------------------------------------------ */ #define CMD_DEFAULT_TIMEOUT 60 -#define CMD_MAX_TIMEOUT 600 +#define CMD_MAX_TIMEOUT 600 -static char *tool_run_command(const JsonValue *args, int *is_error) -{ +static char *tool_run_command(const JsonValue *args, int *is_error) { REQUIRE_ERR_PTR(is_error); const char *cmd = arg_str(args, "command"); if (!cmd || !*cmd) { @@ -114,15 +111,17 @@ static char *tool_run_command(const JsonValue *args, int *is_error) 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; + 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 负责**不可伪造的授权**, 各司其职. + * 手滑敲出来. 确认提示 (run_command 本来就有的)负责让你**看清要跑什么**, + * sudo 负责**不可伪造的授权**, 各司其职. * 没有控制终端就根本不去试: 那会起一个等不到输入的进程. */ int elevated = json_get_bool(args, "elevated", 0); if (elevated && !priv_have_tty()) { @@ -134,31 +133,38 @@ static char *tool_run_command(const JsonValue *args, int *is_error) } int fds[2]; - if (pipe(fds) != 0) - { *is_error = 1; return err_result("pipe 失败: %s", strerror(errno)); } + if (pipe(fds) != 0) { + *is_error = 1; + return err_result("pipe 失败: %s", strerror(errno)); + } pid_t pid = fork(); if (pid < 0) { - close(fds[0]); close(fds[1]); + close(fds[0]); + close(fds[1]); *is_error = 1; return err_result("fork 失败: %s", strerror(errno)); } if (pid == 0) { - /* 非提权: 独立进程组, 超时时可以整组杀掉. + /* 非提权: 独立进程组, 超时时可以整组杀掉. * 提权: **不 setsid** —— sudo 是打开 /dev/tty 问密码的, 摘掉控制终端 - * 它就永远问不出来 (这正是上一版认证必然失败的成因). + * 它就永远问不出来 (这正是上一版认证必然失败的成因). * 代价是提权这条没法整组杀, 超时只杀直接子进程. */ - if (!elevated) setsid(); + if (!elevated) + setsid(); int devnull = open("/dev/null", O_RDONLY); - if (devnull >= 0) { dup2(devnull, STDIN_FILENO); close(devnull); } + if (devnull >= 0) { + dup2(devnull, STDIN_FILENO); + close(devnull); + } dup2(fds[1], STDOUT_FILENO); dup2(fds[1], STDERR_FILENO); close(fds[0]); close(fds[1]); - /* argv 形式执行: 提权时是 `sudo -u root -- /bin/sh -c <命令>`, + /* argv 形式执行: 提权时是 `sudo -u root -- /bin/sh -c <命令>`, * 命令内容不会被多解析一层. */ /* 同上: execvp 要 char*[], 这里放进去的都不会被改写 */ - char *plain[] = { (char *)"/bin/sh", (char *)"-c", (char *)cmd, NULL }; + char *plain[] = {(char *)"/bin/sh", (char *)"-c", (char *)cmd, NULL}; char **w = elevated ? priv_sudo_argv(NULL, plain) : plain; execvp(w[0], w); _exit(127); @@ -166,7 +172,8 @@ static char *tool_run_command(const JsonValue *args, int *is_error) close(fds[1]); int fl = fcntl(fds[0], F_GETFL, 0); - if (fl >= 0) fcntl(fds[0], F_SETFL, fl | O_NONBLOCK); + if (fl >= 0) + fcntl(fds[0], F_SETFL, fl | O_NONBLOCK); StrBuf out; sb_init(&out); @@ -196,16 +203,29 @@ static char *tool_run_command(const JsonValue *args, int *is_error) pfd.events = POLLIN; pfd.revents = 0; int pr = poll(&pfd, 1, poll_ms); - if (pr < 0) { if (errno == EINTR) continue; break; } - if (pr == 0) continue; + if (pr < 0) { + if (errno == EINTR) + continue; + break; + } + if (pr == 0) + continue; for (;;) { char buf[8192]; ssize_t n = read(fds[0], buf, sizeof buf); - if (n > 0) { append_capped(&out, buf, (size_t)n, &truncated); continue; } - if (n == 0) { eof = 1; break; } - if (errno == EINTR) continue; - if (errno == EAGAIN || errno == EWOULDBLOCK) break; + if (n > 0) { + append_capped(&out, buf, (size_t)n, &truncated); + continue; + } + if (n == 0) { + eof = 1; + break; + } + if (errno == EINTR) + continue; + if (errno == EAGAIN || errno == EWOULDBLOCK) + break; eof = 1; break; } @@ -215,16 +235,22 @@ static char *tool_run_command(const JsonValue *args, int *is_error) int status = 0; if (timed_out || interrupted) kill(elevated ? pid : -pid, SIGKILL); - while (waitpid(pid, &status, 0) < 0 && errno == EINTR) { /* retry */ } + while (waitpid(pid, &status, 0) < 0 && errno == EINTR) { /* retry */ + } int exit_code = -1; int by_signal = 0; - if (WIFEXITED(status)) exit_code = WEXITSTATUS(status); - else if (WIFSIGNALED(status)) { exit_code = 128 + WTERMSIG(status); by_signal = 1; } + if (WIFEXITED(status)) + exit_code = WEXITSTATUS(status); + else if (WIFSIGNALED(status)) { + exit_code = 128 + WTERMSIG(status); + by_signal = 1; + } StrBuf result; sb_init(&result); - if (out.len > 0) sb_append(&result, out.data, out.len); + if (out.len > 0) + sb_append(&result, out.data, out.len); if (truncated) sb_puts(&result, "\n... [输出超过 100KB, 已截断]"); /* 提权失败时, 别让模型去猜. sudo 的提示写的是 /dev/tty, 多半不在我们 @@ -253,7 +279,8 @@ static char *tool_run_command(const JsonValue *args, int *is_error) *is_error = 1; } else { sb_printf(&result, "[exit code: %d]", exit_code); - if (exit_code != 0) *is_error = 1; + if (exit_code != 0) + *is_error = 1; } sb_free(&out); @@ -264,23 +291,29 @@ static char *tool_run_command(const JsonValue *args, int *is_error) /* read_file */ /* ------------------------------------------------------------------ */ -static char *tool_read_file(const JsonValue *args, int *is_error) -{ +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); long offset = off > 0 ? (long)off : 1; long limit = lim > 0 ? (long)lim : 2000; - if (limit > 20000) limit = 20000; + 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), 不必再开一次. */ + /* 二进制文件按文本读出来是乱码, 而且会在 NUL 处被截断 —— 直接拒绝. + * 文件已经打开了, 就地探一遍前 8 KB (窗口与判据见 util.h), 不必再开一次. */ { char probe[LIGENT_BIN_PROBE]; size_t pn = fread(probe, 1, sizeof probe, f); @@ -289,7 +322,8 @@ static char *tool_read_file(const JsonValue *args, int *is_error) if (looks_binary(probe, pn, &at)) { fclose(f); *is_error = 1; - return binary_err(path, at, + return binary_err( + path, at, "read_file 只按文本读 (读出来会是乱码, 还会在 NUL 处被截断). " "看二进制内容用 run_command (file / xxd / strings / od); " "看图片用 read_image. "); @@ -298,12 +332,13 @@ static char *tool_read_file(const JsonValue *args, int *is_error) /* 按 '\n' 切行, 而且行号口径必须与 edit_file 的 count_lines/line_range * **完全一致** (末尾那个换行不算新的一行)—— 工具描述和 README 都写着 - * "read_file 打出来的行号可以直接喂给 edit_file 的 line". + * "read_file 打出来的行号可以直接喂给 edit_file 的 line". * - * 以前这里是 fgets + 固定 64 KB 缓冲, 于是"一行比缓冲长"时它会被切成好几块、 - * 每块各带一个行号: 一个「70000 字节的单行 + 两行」的文件, read_file 说第 3 行 - * 是 "second", 而 edit_file 认为第 3 行是 "third". 模型照 read_file 的行号去 - * 改, 要么改错地方、要么报越界 —— 而它没有任何办法察觉. + * 以前这里是 fgets + 固定 64 KB 缓冲, + * 于是"一行比缓冲长"时它会被切成好几块、 每块各带一个行号: 一个「70000 + * 字节的单行 + 两行」的文件, read_file 说第 3 行 是 "second", 而 edit_file + * 认为第 3 行是 "third". 模型照 read_file 的行号去 改, + * 要么改错地方、要么报越界 —— 而它没有任何办法察觉. * * 单行的字节数靠"上一块是不是以换行结尾" (in_line)来跟踪, 所以仍然**不** * 需要把整个文件读进内存: 一行再长也只占 line[] 这么多, 超出输出上限就如实 @@ -311,28 +346,35 @@ static char *tool_read_file(const JsonValue *args, int *is_error) StrBuf sb; sb_init(&sb); char line[65536]; - long lineno = 0; /* 已读到的行号 (只在真正换行时递增) */ - long emitted = 0; /* 已输出的行数 */ - long partial_line = 0; /* 这一行只给了开头 (它自己超过输出上限) */ - long empty_line = 0; /* 这一行一个字节都没能进去 */ - size_t line_bytes = 0; /* 当前这一行已经进了缓冲多少字节 */ - int cut_by_bytes = 0; /* 截断的原因是字节上限, 而不是行数上限 */ + long lineno = 0; /* 已读到的行号 (只在真正换行时递增) */ + long emitted = 0; /* 已输出的行数 */ + long partial_line = 0; /* 这一行只给了开头 (它自己超过输出上限) */ + long empty_line = 0; /* 这一行一个字节都没能进去 */ + size_t line_bytes = 0; /* 当前这一行已经进了缓冲多少字节 */ + int cut_by_bytes = 0; /* 截断的原因是字节上限, 而不是行数上限 */ int truncated = 0; - int in_line = 0; /* 上一块不以 '\n' 结尾 => 这一块还是同一行 */ + int in_line = 0; /* 上一块不以 '\n' 结尾 => 这一块还是同一行 */ while (fgets(line, sizeof line, f)) { size_t n = strlen(line); int has_nl = (n > 0 && line[n - 1] == '\n'); - if (!in_line) { /* 新的一行从这里开始 */ + if (!in_line) { /* 新的一行从这里开始 */ lineno++; line_bytes = 0; - if (lineno < offset) { in_line = !has_nl; continue; } - if (emitted >= limit) { truncated = 1; break; } + if (lineno < offset) { + in_line = !has_nl; + continue; + } + if (emitted >= limit) { + truncated = 1; + break; + } char prefix[32]; int pl = snprintf(prefix, sizeof prefix, "%6ld\t", lineno); if (sb.len + (size_t)pl >= LIGENT_TOOL_OUTPUT_LIMIT) { - if (emitted == 0) empty_line = lineno; + if (emitted == 0) + empty_line = lineno; cut_by_bytes = 1; truncated = 1; break; @@ -344,7 +386,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; @@ -361,7 +403,8 @@ static char *tool_read_file(const JsonValue *args, int *is_error) sb_append(&sb, line, n); line_bytes += n; /* 文件最后一行没有换行符时补一个, 否则行号前缀会跟下一行粘连 */ - if (!has_nl) sb_putc(&sb, '\n'); + if (!has_nl) + sb_putc(&sb, '\n'); } fclose(f); @@ -377,17 +420,22 @@ static char *tool_read_file(const JsonValue *args, int *is_error) "可以用 run_command 只取它的一部分, 例如 " "sed -n '%ldp' <文件> | cut -c1-4000)", empty_line, (unsigned)LIGENT_TOOL_OUTPUT_LIMIT, empty_line); - return xasprintf("(文件只有 %ld 行, offset=%ld 超出范围)", lineno, offset); + return xasprintf("(文件只有 %ld 行, offset=%ld 超出范围)", lineno, + offset); } if (truncated) { if (cut_by_bytes && partial_line > 0) /* 撞的是字节上限: 给出的是**半行**, 所以不能再说"从下一行继续" */ sb_printf(&sb, - "... [第 %ld 行本身就超过 %u 字节的输出上限, 这里只给了它的开头; " - "要看后面的部分, 用 run_command (例如 sed -n '%ldp' <文件> | cut -c1-4000)]", - partial_line, (unsigned)LIGENT_TOOL_OUTPUT_LIMIT, partial_line); + "... [第 %ld 行本身就超过 %u 字节的输出上限, " + "这里只给了它的开头; " + "要看后面的部分, 用 run_command (例如 sed -n '%ldp' " + "<文件> | cut -c1-4000)]", + partial_line, (unsigned)LIGENT_TOOL_OUTPUT_LIMIT, + partial_line); else - sb_printf(&sb, "... [已截断, 从第 %ld 行继续读取]", offset + emitted); + sb_printf(&sb, "... [已截断, 从第 %ld 行继续读取]", + offset + emitted); } return sb_detach(&sb); } @@ -396,12 +444,14 @@ static char *tool_read_file(const JsonValue *args, int *is_error) /* write_file */ /* ------------------------------------------------------------------ */ -static char *tool_write_file(const JsonValue *args, int *is_error) -{ +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 (必须是字符串)"); @@ -423,14 +473,16 @@ static char *tool_write_file(const JsonValue *args, int *is_error) long at = 0; if (file_is_binary(path, &at)) { *is_error = 1; - return binary_err(path, at, + return binary_err( + path, at, "write_file 只写文本, 整体覆盖它等于把原来的内容毁掉. " - "确实要换掉这个文件, 先用 run_command 删掉它, 或者换一个路径. "); + "确实要换掉这个文件, 先用 run_command 删掉它, " + "或者换一个路径. "); } } - /* 落盘前再看一眼中断标志. 确认环节已经会拦下 Ctrl-C, 这里是纵深防御: - * "任何改盘的动作之前都检查中断"应当是一条无例外的约定, + /* 落盘前再看一眼中断标志. 确认环节已经会拦下 Ctrl-C, 这里是纵深防御: + * "任何改盘的动作之前都检查中断"应当是一条无例外的约定, * 而不是依赖"恰好上一层拦住了". */ if (g_ligent_interrupt) { *is_error = 1; @@ -438,10 +490,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 (fclose(f) != 0 && written == n) + written = (size_t)-1; if (written != n) { *is_error = 1; @@ -455,53 +511,57 @@ static char *tool_write_file(const JsonValue *args, int *is_error) /* ------------------------------------------------------------------ */ /* * 替换受**行号集合**制约: old_string 的**起点**必须落在 line 圈住的行里, 且在 - * 这些行里只有一处匹配. + * 这些行里只有一处匹配. * * line 是**集合型, 而且可以整个省略**: 省略 = 不设行范围, 等于**全文件替换所有 * 匹配** (就是编辑器里那个"全局替换"). 给了它就是集合型 —— 单个整数 (等价于 - * 只有一个元素的集合)或整数数组都收, `"line": 12` 和 `"line": [12]` 是同一件事. - * 集合里的行号就是 read_file 打出来的那些 (1 起). + * 只有一个元素的集合)或整数数组都收, `"line": 12` 和 `"line": [12]` 是同一件事. + * 集合里的行号就是 read_file 打出来的那些 (1 起). * * 为什么是集合而不是单个行号: 模型看到的常是一整块内容 ("第 10~13 行是这段 * 函数"), 但决定命中与否的是 old_string 的**起点**落在哪一行; 只给一个行号时 * 它得先数清第一行到底是 10 还是 11, 数错一次就白烧一轮. 给了集合之后, 照 * read_file 的显示把覆盖到的那几行都填进来就行. 集合只是**搜索范围**, 不是 - * "每行都改一遍": 真改动的仍是唯一那一处 (replace_all 或省略 line 时才动全文件). + * "每行都改一遍": 真改动的仍是唯一那一处 (replace_all 或省略 line + * 时才动全文件). * - * 为什么不再只靠"全文唯一"来定位: 行号是模型唯一的空间坐标 (read_file 给的), + * 为什么不再只靠"全文唯一"来定位: 行号是模型唯一的空间坐标 (read_file 给的), * 而"全文唯一"要求整个文件里恰好只有一处 —— 文件一长这个条件经常不成立 * (同一个 `return 0;` 到处都是), 模型只能不断加长上下文去凑唯一性; 凑不成 - * 就只能放弃, 或者赌一把改错地方. 把行号钉住之后, "改哪一处"由坐标决定, - * 不再是个概率问题; old_string 也不必再为"唯一"而写得很长. + * 就只能放弃, 或者赌一把改错地方. 把行号钉住之后, "改哪一处"由坐标决定, + * 不再是个概率问题; old_string 也不必再为"唯一"而写得很长. * * 省略 line 是**故意留着**的逃生门: 有些改动本来就该全文件生效 (比如统一改个 * 名字), 但不给行号就没法声称"我知道改的是哪一处" —— 回执里也会直说这是全文件 - * 替换. 要"改一处"仍得给行号, 模型没法靠省略来回避看文件. + * 替换. 要"改一处"仍得给行号, 模型没法靠省略来回避看文件. * * replace_all 仍在, 含义不变: 改文件里**所有**匹配. 但 line 集合必须圈住其中 - * 真实的一处 —— 这等于强制模型先看过文件, 而不是凭印象做全文替换. + * 真实的一处 —— 这等于强制模型先看过文件, 而不是凭印象做全文替换. */ -/* 文件按 read_file 的口径有多少行: 末尾的换行不算新的一行. - * (read_file 用 fgets 读, 末尾那个"空行"不会产生输出, 两边口径必须一致, +/* 文件按 read_file 的口径有多少行: 末尾的换行不算新的一行. + * (read_file 用 fgets 读, 末尾那个"空行"不会产生输出, 两边口径必须一致, * 否则 read_file 显示的行号和这里的越界判断会互相矛盾. ) */ -static long count_lines(const char *s, size_t len) -{ +static long count_lines(const char *s, size_t len) { long n = 0; for (size_t i = 0; i < len; i++) - if (s[i] == '\n') n++; - if (len > 0 && s[len - 1] != '\n') n++; + if (s[i] == '\n') + n++; + if (len > 0 && s[len - 1] != '\n') + n++; return n; } /* 第 n 行 (1 起)的字节范围 [*start, *end), 不含行尾换行. 越界返回 0 */ -static int line_range(const char *s, size_t len, long n, size_t *start, size_t *end) -{ - if (n < 1 || n > count_lines(s, len)) return 0; +static int line_range(const char *s, size_t len, long n, size_t *start, + size_t *end) { + if (n < 1 || n > count_lines(s, len)) + return 0; size_t i = 0; for (long line = 1; line < n; line++) { const char *nl = memchr(s + i, '\n', len - i); - if (!nl) return 0; + if (!nl) + return 0; i = (size_t)(nl - s) + 1; } *start = i; @@ -510,26 +570,25 @@ static int line_range(const char *s, size_t len, long n, size_t *start, size_t * return 1; } -/* line 集合里最多几个行号. 它是用来圈住 old_string 覆盖的那几行的, +/* line 集合里最多几个行号. 它是用来圈住 old_string 覆盖的那几行的, * 给到几十个以上就说明模型在瞎猜行号 —— 那时该做的是重新 read_file 看一遍. */ #define EDIT_LINE_MAX 64 -static int cmp_long(const void *a, const void *b) -{ +static int cmp_long(const void *a, const void *b) { long x = *(const long *)a, y = *(const long *)b; return x < y ? -1 : (x > y ? 1 : 0); } /* 把 line 参数解析成升序去重后的行号数组: 写进 out (容量 cap), 返回元素个数 - * (>=1). 失败返回 -1, 并把给模型看的说法写进 *err (调用方 free). + * (>=1). 失败返回 -1, 并把给模型看的说法写进 *err (调用方 free). * * 两种写法都收: 整数 (历史写法)和整数数组 (集合). 所以老调用和老录制里的 - * `"line": 41` 仍然照常工作, 不必区分. + * `"line": 41` 仍然照常工作, 不必区分. * - * 去重不是洁癖: 同一个行号处理两遍, 同一处匹配会被数成两处, + * 去重不是洁癖: 同一个行号处理两遍, 同一处匹配会被数成两处, * 于是"行内只有一处"的判断会莫名其妙地失败. */ -static long parse_line_set(const JsonValue *lv, long *out, size_t cap, char **err) -{ +static long parse_line_set(const JsonValue *lv, long *out, size_t cap, + char **err) { *err = NULL; int is_arr = (lv->type == JSON_ARRAY); size_t n = is_arr ? json_array_size(lv) : 1; @@ -543,7 +602,8 @@ static long parse_line_set(const JsonValue *lv, long *out, size_t cap, char **er if (n > cap) { *err = xasprintf("错误: line 里有 %zu 个行号, 一次最多 %zu 个. " "集合是用来圈住 old_string 覆盖的那几行的 —— " - "要改的地方太多, 先 read_file 看清再分几次改. ", n, cap); + "要改的地方太多, 先 read_file 看清再分几次改. ", + n, cap); return -1; } @@ -552,14 +612,16 @@ static long parse_line_set(const JsonValue *lv, long *out, size_t cap, char **er /* 先把范围夹住再判断是不是整数: 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 (e->type != JSON_NUMBER || !(d >= 1.0) || d > 9.0e18 || + d != (double)(long)d) { if (is_arr) *err = xasprintf("错误: line 里的第 %zu 个不是合法的行号 —— " - "要的是 read_file 打出来的 >=1 的整数行号. ", i + 1); + "要的是 read_file 打出来的 >=1 的整数行号. ", + i + 1); else *err = xasprintf("错误: line=%g 不合法 —— 要的是 read_file " - "打出来的 >=1 的整数行号. ", d); + "打出来的 >=1 的整数行号. ", + d); return -1; } out[i] = (long)d; @@ -568,39 +630,49 @@ static long parse_line_set(const JsonValue *lv, long *out, size_t cap, char **er qsort(out, n, sizeof *out, cmp_long); size_t k = 0; for (size_t i = 0; i < n; i++) - if (k == 0 || out[i] != out[k - 1]) out[k++] = out[i]; + if (k == 0 || out[i] != out[k - 1]) + out[k++] = out[i]; return (long)k; } -/* 把行号集合渲染成 "3、4、5" (报错与回执里用, 给模型看). - * 超过 8 个就只列前 8 个: 再往下只是噪声, 源文件的行号本来就在 read_file 里. */ -static void lines_text(const long *v, long n, char *buf, size_t cap) -{ +/* 把行号集合渲染成 "3、4、5" (报错与回执里用, 给模型看). + * 超过 8 个就只列前 8 个: 再往下只是噪声, 源文件的行号本来就在 read_file 里. */ +static void lines_text(const long *v, long n, char *buf, size_t cap) { size_t off = 0; buf[0] = '\0'; for (long i = 0; i < n; i++) { - if (i >= 8) { snprintf(buf + off, cap - off, "…"); break; } + if (i >= 8) { + snprintf(buf + off, cap - off, "…"); + break; + } int w = snprintf(buf + off, cap - off, "%s%ld", i ? "、" : "", v[i]); - if (w < 0 || (size_t)w >= cap - off) break; + if (w < 0 || (size_t)w >= cap - off) + break; off += (size_t)w; } } -static char *tool_edit_file(const JsonValue *args, int *is_error) -{ +static char *tool_edit_file(const JsonValue *args, int *is_error) { REQUIRE_ERR_PTR(is_error); const char *path = arg_str(args, "path"); const char *old_s = arg_str(args, "old_string"); JsonValue *nv = json_object_get(args, "new_string"); JsonValue *lv = json_object_get(args, "line"); - if (!path || !*path) { *is_error = 1; return xstrdup("错误: 缺少必填参数 path"); } - if (!old_s) { *is_error = 1; return xstrdup("错误: 缺少必填参数 old_string"); } + if (!path || !*path) { + *is_error = 1; + return xstrdup("错误: 缺少必填参数 path"); + } + if (!old_s) { + *is_error = 1; + return xstrdup("错误: 缺少必填参数 old_string"); + } if (!nv || nv->type != JSON_STRING) { *is_error = 1; return xstrdup("错误: 缺少必填参数 new_string (必须是字符串)"); } - /* line 可以**整个省略**: 省略 = 不设行范围 = 全文件替换所有匹配 ("全局替换"). - * 类型给错了 (字符串之类)仍然当场报错 —— 那是手滑, 不是"想全局". */ + /* line 可以**整个省略**: 省略 = 不设行范围 = 全文件替换所有匹配 + * ("全局替换"). 类型给错了 (字符串之类)仍然当场报错 —— 那是手滑, + * 不是"想全局". */ int global = (lv == NULL); if (!global && lv->type != JSON_NUMBER && lv->type != JSON_ARRAY) { *is_error = 1; @@ -611,10 +683,13 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) 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]; long nlines = 0; @@ -623,16 +698,20 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) if (!global) { char *perr = NULL; nlines = parse_line_set(lv, lines, EDIT_LINE_MAX, &perr); - if (nlines < 0) { *is_error = 1; return perr; } + if (nlines < 0) { + *is_error = 1; + return perr; + } /* 报错与回执里要说清"是哪几行": 模型据此判断行号有没有被理解对 */ lines_text(lines, nlines, req, sizeof req); } - /* 先看大小再读: edit_file 需要把整个文件放进内存, - * 对超大文件直接拒绝, 避免把内存吃干. + /* 先看大小再读: edit_file 需要把整个文件放进内存, + * 对超大文件直接拒绝, 避免把内存吃干. * 用 stat 而不是读完再看, 否则拒绝之前就已经分配了. */ struct stat st; - if (stat(path, &st) == 0 && (unsigned long long)st.st_size > LIGENT_MAX_EDIT_FILE) { + if (stat(path, &st) == 0 && + (unsigned long long)st.st_size > LIGENT_MAX_EDIT_FILE) { *is_error = 1; return err_result( "%s 有 %lld 字节, 超过 edit_file 的 %u 字节上限. " @@ -642,38 +721,48 @@ 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 在内存里 - * 是按 C 字符串处理的 (strstr/strlen), 文件里任何一个 NUL 都会让后面的内容 - * 被当成结束 —— 改下去就是把原文件毁掉. */ + /* 二进制拒绝. 这里看**全文件** (不像读类工具只看前 8 KB): edit_file + * 在内存里 是按 C 字符串处理的 (strstr/strlen), 文件里任何一个 NUL + * 都会让后面的内容 被当成结束 —— 改下去就是把原文件毁掉. */ { long at = buf_nul_at(src, flen); if (at >= 0) { free(src); *is_error = 1; - return binary_err(path, at, + return binary_err( + path, at, "edit_file 是按字符串做替换的, NUL 会让结果从那里被截断 —— " "改下去等于把原文件毁了. 要改二进制内容, 用 run_command" " (printf / dd / perl -pi 这类). "); } } - /* 上界检查: 下界 (>=1)和写法在 parse_line_set 里已经查过, 这里要文件总行数. + /* 上界检查: 下界 (>=1)和写法在 parse_line_set 里已经查过, 这里要文件总行数. * 省略 line 时没有行号可查. */ if (!global) { long total_lines = count_lines(src, flen); for (long i = 0; i < nlines; i++) { - if (lines[i] <= total_lines) continue; + if (lines[i] <= total_lines) + continue; free(src); *is_error = 1; if (total_lines == 0) - return err_result("%s 是空文件, 没有第 %ld 行. ", path, lines[i]); + return err_result("%s 是空文件, 没有第 %ld 行. ", path, + lines[i]); if (nlines == 1) - return err_result("%s 只有 %ld 行, line=%ld 越界 (行号从 1 开始, " - "用 read_file 看当前行号). ", path, total_lines, lines[i]); - return err_result("%s 只有 %ld 行, line 里的 %ld 越界 (行号从 1 开始, " - "用 read_file 看当前行号). ", path, total_lines, lines[i]); + return err_result( + "%s 只有 %ld 行, line=%ld 越界 (行号从 1 开始, " + "用 read_file 看当前行号). ", + path, total_lines, lines[i]); + return err_result( + "%s 只有 %ld 行, line 里的 %ld 越界 (行号从 1 开始, " + "用 read_file 看当前行号). ", + path, total_lines, lines[i]); } } @@ -684,21 +773,26 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) size_t hits = 0; const char *at = NULL; if (global) { - for (const char *p = src; ; ) { + for (const char *p = src;;) { const char *hit = strstr(p, old_s); - if (!hit) break; - if (hits == 0) at = hit; + if (!hit) + break; + if (hits == 0) + at = hit; hits++; p = hit + olen; } } else { for (long i = 0; i < nlines; i++) { size_t ls = 0, le = 0; - if (!line_range(src, flen, lines[i], &ls, &le)) continue; /* 上面查过, 不会越界 */ - for (const char *p = src + ls; ; ) { + if (!line_range(src, flen, lines[i], &ls, &le)) + continue; /* 上面查过, 不会越界 */ + for (const char *p = src + ls;;) { const char *hit = strstr(p, old_s); - if (!hit || (size_t)(hit - src) >= le) break; - if (hits == 0) at = hit; + if (!hit || (size_t)(hit - src) >= le) + break; + if (hits == 0) + at = hit; hits++; p = hit + olen; } @@ -709,8 +803,10 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) /* 省略 line 时没有"给的哪几行"可说, 只说整个文件里没有 */ free(src); *is_error = 1; - return err_result("%s 整个文件里没有 old_string (注意缩进与换行必须逐字符" - "一致). 要给行范围就填 line: 整数或整数数组. ", path); + return err_result( + "%s 整个文件里没有 old_string (注意缩进与换行必须逐字符" + "一致). 要给行范围就填 line: 整数或整数数组. ", + path); } if (hits == 0) { @@ -720,8 +816,13 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) const char *q = src, *scan = src; long ln = 1, found = 0; while ((q = strstr(q, old_s)) != NULL) { - while (scan < q) { if (*scan == '\n') ln++; scan++; } - if (found < 4) sb_printf(&w, "%s%ld", found ? "、" : "", ln); + while (scan < q) { + if (*scan == '\n') + ln++; + scan++; + } + if (found < 4) + sb_printf(&w, "%s%ld", found ? "、" : "", ln); found++; q += olen; } @@ -731,15 +832,17 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) char *msg; if (found == 0) msg = err_result("%s 第 %s 行没有 old_string, 整个文件里也没有" - " (注意缩进与换行必须逐字符一致). ", path, req); + " (注意缩进与换行必须逐字符一致). ", + path, req); else if (found <= 4) msg = err_result("%s 第 %s 行没有 old_string, 它在第 %s 行. " "要改那里, 就把那一行的行号填进 line. ", path, req, where); else - msg = err_result("%s 第 %s 行没有 old_string. 它在第 %s 行等共 %ld 处, " - "要改哪一处就把那一行的行号填进 line. ", - path, req, where, found); + msg = err_result( + "%s 第 %s 行没有 old_string. 它在第 %s 行等共 %ld 处, " + "要改哪一处就把那一行的行号填进 line. ", + path, req, where, found); free(where); return msg; } @@ -747,10 +850,11 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) if (hits > 1 && !replace_all && !global) { free(src); *is_error = 1; - return err_result("%s 第 %s 行里有 %zu 处 old_string, 定不下来是哪一处. " - "把 old_string 写长一点 (带上这几行里的上下文), " - "或者设 replace_all=true 把文件里所有匹配都换掉. ", - path, req, hits); + return err_result( + "%s 第 %s 行里有 %zu 处 old_string, 定不下来是哪一处. " + "把 old_string 写长一点 (带上这几行里的上下文), " + "或者设 replace_all=true 把文件里所有匹配都换掉. ", + path, req, hits); } StrBuf out; @@ -760,7 +864,10 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) const char *p = src; for (;;) { const char *hit = strstr(p, old_s); - if (!hit) { sb_puts(&out, p); break; } + if (!hit) { + sb_puts(&out, p); + break; + } sb_append(&out, p, (size_t)(hit - p)); sb_puts(&out, new_s); p = hit + olen; @@ -776,7 +883,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("[已中断] 用户中断了这次操作, 文件没有被改动. "); @@ -790,7 +897,8 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) } size_t w = fwrite(out.data, 1, out.len, f); int wok = (w == out.len); - if (fclose(f) != 0) wok = 0; + if (fclose(f) != 0) + wok = 0; size_t total = out.len; sb_free(&out); if (!wok) { @@ -800,39 +908,45 @@ static char *tool_edit_file(const JsonValue *args, int *is_error) /* 回执里必须带上行号: 模型据此确认"改的就是我以为的那一处". 省略 line 是 * 全文件替换, 回执也得直说 —— 否则它会以为只改了一处. */ if (global) - return xasprintf("已在 %s 全文件替换 %ld 处 (写入 %zu 字节; line 省略 = " - "没设行范围)", path, replaced, total); + return xasprintf( + "已在 %s 全文件替换 %ld 处 (写入 %zu 字节; line 省略 = " + "没设行范围)", + path, replaced, total); if (replace_all) return xasprintf("已在 %s 替换 %ld 处 (写入 %zu 字节; line=%s 圈住的" - "那几行里至少有一处)", path, replaced, total, req); - return xasprintf("已在 %s 第 %s 行替换 1 处 (写入 %zu 字节)", - path, req, total); + "那几行里至少有一处)", + path, replaced, total, req); + return xasprintf("已在 %s 第 %s 行替换 1 处 (写入 %zu 字节)", path, req, + total); } /* ------------------------------------------------------------------ */ /* list_dir */ /* ------------------------------------------------------------------ */ -static int cmp_names(const void *a, const void *b) -{ +static int cmp_names(const void *a, const void *b) { return strcmp(*(const char *const *)a, *(const char *const *)b); } -static char *tool_list_dir(const JsonValue *args, int *is_error) -{ +static char *tool_list_dir(const JsonValue *args, int *is_error) { REQUIRE_ERR_PTR(is_error); const char *path = arg_str(args, "path"); - if (!path || !*path) path = "."; + 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; struct dirent *de; while ((de = readdir(d)) != NULL) { - if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue; + if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) + continue; if (count == cap) { cap = cap ? cap * 2 : 64; list = xrealloc(list, cap * sizeof(char *)); @@ -842,9 +956,10 @@ static char *tool_list_dir(const JsonValue *args, int *is_error) closedir(d); /* 空目录时 list 还是 NULL, 而 qsort 的第一个参数声明为不可为空 —— - * 传 NULL 进去是 UB (UBSan 会当场报 "null pointer passed as argument 1"). - * attach.c 的 list_dir_sorted 用的是同一条 count > 1 守卫, 两边保持一致. */ - if (count > 1) qsort(list, count, sizeof(char *), cmp_names); + * 传 NULL 进去是 UB (UBSan 会当场报 "null pointer passed as argument 1"). + * attach.c 的 list_dir_sorted 用的是同一条 count > 1 守卫, 两边保持一致. */ + if (count > 1) + qsort(list, count, sizeof(char *), cmp_names); StrBuf out; sb_init(&out); @@ -859,7 +974,8 @@ static char *tool_list_dir(const JsonValue *args, int *is_error) else if (S_ISLNK(st.st_mode)) sb_printf(&out, " %s -> (符号链接)\n", list[i]); else - sb_printf(&out, " %s (%lld 字节)\n", list[i], (long long)st.st_size); + sb_printf(&out, " %s (%lld 字节)\n", list[i], + (long long)st.st_size); } else { sb_printf(&out, " %s\n", list[i]); } @@ -869,25 +985,27 @@ static char *tool_list_dir(const JsonValue *args, int *is_error) return sb_detach(&out); } -/* read_image: 把本地图片交给模型看. +/* read_image: 把本地图片交给模型看. * - * 协议里图片只有两条路: 外部 URL, 或者 base64 内联. ligent 不替用户去网上抓图, - * 所以只收本地文件、产出 base64 内联. + * 协议里图片只有两条路: 外部 URL, 或者 base64 内联. ligent 不替用户去网上抓图, + * 所以只收本地文件、产出 base64 内联. * - * 图片字节不进历史: 这里只把图片存成本地备份, 历史里挂一个对备份的引用, - * 真正构造请求时才读出来编码 (见 image.c 的 image_expand_for_request). + * 图片字节不进历史: 这里只把图片存成本地备份, 历史里挂一个对备份的引用, + * 真正构造请求时才读出来编码 (见 image.c 的 image_expand_for_request). * - * 尺寸在**本地**就判掉. 理由是图片会留在历史里: 一次超限不是一次失败, - * 而是从此每一轮都超限. 与其发出去等一个 400, 不如当场告诉模型"太大了", - * 并且**不备份** —— 一张永远发不出去的图, 留着备份没有意义. + * 尺寸在**本地**就判掉. 理由是图片会留在历史里: 一次超限不是一次失败, + * 而是从此每一轮都超限. 与其发出去等一个 400, 不如当场告诉模型"太大了", + * 并且**不备份** —— 一张永远发不出去的图, 留着备份没有意义. * * 工具结果只能是字符串 (协议要求), 所以图片挂在"待发附件"里, 由 * agent_run 补一条 user 消息带上去 —— 图片只能出现在 user 消息中. */ -static char *tool_read_image(const JsonValue *args, int *is_error) -{ +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; @@ -900,11 +1018,11 @@ static char *tool_read_image(const JsonValue *args, int *is_error) image_pending_add(backup); - char *msg = xasprintf("已读入图片 %s (%s, %zu 字节, base64 内联后约 %.1f MiB). " - "对话历史里只有对它的引用, 不发图片数据; " - "本机备份 %s, 发请求时展开成 base64. ", - path, mime, len, - (double)base64_encoded_len(len) / 1048576.0, backup); + char *msg = xasprintf( + "已读入图片 %s (%s, %zu 字节, base64 内联后约 %.1f MiB). " + "对话历史里只有对它的引用, 不发图片数据; " + "本机备份 %s, 发请求时展开成 base64. ", + path, mime, len, (double)base64_encoded_len(len) / 1048576.0, backup); free(backup); return msg; } @@ -915,186 +1033,169 @@ static char *tool_read_image(const JsonValue *args, int *is_error) /* * 命名约定 (改名前先读这里; README §5 有同一份, 两处必须同步) * - * 1. 形式: 全小写 snake_case, 动宾结构「动词_对象」. 动词必须来自白名单, - * 而白名单**按域划分** —— 要用新动词, 先把它加进对应域的白名单: + * 1. 形式: 全小写 snake_case, 动宾结构「动词_对象」. 动词必须来自白名单, + * 而白名单**按域划分** —— 要用新动词, 先把它加进对应域的白名单: * 文件与执行域: read / write / edit / list / run / view * 历史编辑域: list / read / replace / delete / insert / undo (meta.c) - * 两域的动词不混用 (run 不会出现在历史编辑域, undo 也不会出现在文件域). - * 禁用 do / handle / process / get / exec 这类不说明对象的空动词. - * 2. 名字必须能推出副作用: 会改盘的名字里不会出现 read / list. - * 模型靠名字判断该不该先征求同意 (.mutating 决定要不要问), - * 含糊的名字等于让它猜. - * 3. 不带 ligent / ai / tool 这类冗余词 —— 工具表本身就在工具命名空间里. - * 4. 不与 REPL 命令同名 (/shell、/cd、/model、/log… 都已经被占了), - * 否则人和模型两边会串. 这是 run_command 不叫 shell 的原因之一. - * 5. 不与别的工具只在词序、单复数或大小写上不同 (read_file / file_read 这种). - * 6. 长度 <= 24 字符: 工具表**每个请求都要重发**, 名字长度是重复成本. - * 7. 参数名与工具名同源: 路径一律 path, 内容 content, 正则 pattern, - * 文件名过滤 include. 同一个概念在两个工具里必须同名. + * 两域的动词不混用 (run 不会出现在历史编辑域, undo 也不会出现在文件域). + * 禁用 do / handle / process / get / exec 这类不说明对象的空动词. + * 2. 名字必须能推出副作用: 会改盘的名字里不会出现 read / list. + * 模型靠名字判断该不该先征求同意 (.mutating 决定要不要问), + * 含糊的名字等于让它猜. + * 3. 不带 ligent / ai / tool 这类冗余词 —— 工具表本身就在工具命名空间里. + * 4. 不与 REPL 命令同名 (/shell、/cd、/model、/log… 都已经被占了), + * 否则人和模型两边会串. 这是 run_command 不叫 shell 的原因之一. + * 5. 不与别的工具只在词序、单复数或大小写上不同 (read_file / file_read 这种). + * 6. 长度 <= 24 字符: 工具表**每个请求都要重发**, 名字长度是重复成本. + * 7. 参数名与工具名同源: 路径一律 path, 内容 content, 正则 pattern, + * 文件名过滤 include. 同一个概念在两个工具里必须同名. * * 这张表同时生成四样东西: 请求里的 schema (tools_schema)、执行分发 * (tool_execute)、要不要走确认 (tool_mutating)、/tools 的输出 * (tools_print_list). 所以**改名只需要改 .name 一列** —— 以前这四处各有 - * 一份 strcmp 链, 改名必定漏一处, 而且漏了不报错 (只是静默地少认一个名字). + * 一份 strcmp 链, 改名必定漏一处, 而且漏了不报错 (只是静默地少认一个名字). * - * 两条约束写在这里, 因为从表里看不出来: + * 两条约束写在这里, 因为从表里看不出来: * - needs_vision == 1 的工具会在"探测到模型不收图片"时被摘掉 - * (agent.c 的 tools_for_request 会调 tools_drop). + * (agent.c 的 tools_for_request 会调 tools_drop). * - * 顺序 = 请求里工具表的顺序, 也是 /tools 的打印顺序. 改顺序不影响行为, - * 但会让 tests/fixtures 里录制的请求对不上, 所以顺序变动要连着 fixtures 一起改. + * 顺序 = 请求里工具表的顺序, 也是 /tools 的打印顺序. 改顺序不影响行为, + * 但会让 tests/fixtures 里录制的请求对不上, 所以顺序变动要连着 fixtures 一起改. * - * 为什么**没有**搜索内容的工具 (曾经有过 search_files, 后来改名 grep, 最后删了): - * 模型用 run_command 里的真 grep 就行 —— `grep -rn` 支持正则、文件名过滤、 - * 上下文行、大小写、多模式, 我们这个包装每一样都要自己重写一遍, 还得跟着 - * 需求慢慢长 (第一个版本就是"搜内容"却叫 search_files, 让人以为按文件名搜). - * 少一个非必需的工具, 工具表更短、模型的误用面更小. + * 为什么**没有**搜索内容的工具 (曾经有过 search_files, 后来改名 grep, + * 最后删了): 模型用 run_command 里的真 grep 就行 —— `grep -rn` + * 支持正则、文件名过滤、 上下文行、大小写、多模式, + * 我们这个包装每一样都要自己重写一遍, 还得跟着 需求慢慢长 + * (第一个版本就是"搜内容"却叫 search_files, 让人以为按文件名搜). + * 少一个非必需的工具, 工具表更短、模型的误用面更小. */ typedef struct { const char *name; - const char *type; /* JSON-Schema 类型: string / integer / boolean; - * 数组写成 "元素类型[]" (如 "integer[]"), - * 交给 tools_schema 展开成 {type:array, items:{...}} */ + const char *type; /* JSON-Schema 类型: string / integer / boolean; + * 数组写成 "元素类型[]" (如 "integer[]"), + * 交给 tools_schema 展开成 {type:array, items:{...}} */ const char *desc; - int required; + int required; } ToolParam; /* 参数表以它结尾 —— 比单独维护一个"参数个数"少一处可以写错的地方 */ -#define PARAM_END { NULL, NULL, NULL, 0 } +#define PARAM_END {NULL, NULL, NULL, 0} typedef struct { - const char *name; - const char *desc; /* 给模型看的完整说明 */ - const char *ui_key; /* /tools 那一行文案的 i18n 键 (纯 UI) */ - const ToolParam *params; /* 以 PARAM_END 结尾; 可以为 NULL */ - int mutating; /* 会改盘或执行命令 → 先问用户 */ - int needs_vision; /* 模型不收图时摘掉 */ + const char *name; + const char *desc; /* 给模型看的完整说明 */ + const char *ui_key; /* /tools 那一行文案的 i18n 键 (纯 UI) */ + const ToolParam *params; /* 以 PARAM_END 结尾; 可以为 NULL */ + int mutating; /* 会改盘或执行命令 → 先问用户 */ + int needs_vision; /* 模型不收图时摘掉 */ char *(*fn)(const JsonValue *args, int *is_error); } ToolDef; static const ToolParam P_RUN_COMMAND[] = { - { "command", "string", - "要执行的 shell 命令 (由 /bin/sh -c 执行, 支持管道与重定向)", 1 }, - { "elevated", "boolean", - "置 true 时以 root 执行这一条命令. **只在真的撞到权限错误之后才用**, " - "不要预防性地加. 加了之后: 你会先被要求确认 (看清要跑什么), " - "再由 sudo 向用户要密码 —— 密码是用户敲的, 你敲不出来. " - "所以被拒绝时不要反复重试, 说明你需要用户亲自做什么即可. ", 0 }, - { "timeout", "integer", "超时秒数, 默认 60, 最大 600", 0 }, - PARAM_END -}; + {"command", "string", + "要执行的 shell 命令 (由 /bin/sh -c 执行, 支持管道与重定向)", 1}, + {"elevated", "boolean", + "置 true 时以 root 执行这一条命令. **只在真的撞到权限错误之后才用**, " + "不要预防性地加. 加了之后: 你会先被要求确认 (看清要跑什么), " + "再由 sudo 向用户要密码 —— 密码是用户敲的, 你敲不出来. " + "所以被拒绝时不要反复重试, 说明你需要用户亲自做什么即可. ", + 0}, + {"timeout", "integer", "超时秒数, 默认 60, 最大 600", 0}, + PARAM_END}; static const ToolParam P_READ_FILE[] = { - { "path", "string", "文件路径, 相对于工作目录或绝对路径", 1 }, - { "offset", "integer", "起始行号 (从 1 开始), 默认 1", 0 }, - { "limit", "integer", "最多读取的行数, 默认 2000", 0 }, - PARAM_END -}; + {"path", "string", "文件路径, 相对于工作目录或绝对路径", 1}, + {"offset", "integer", "起始行号 (从 1 开始), 默认 1", 0}, + {"limit", "integer", "最多读取的行数, 默认 2000", 0}, + PARAM_END}; static const ToolParam P_WRITE_FILE[] = { - { "path", "string", - "文件路径; 父目录不存在会自动创建. **已经存在的二进制文件会被拒绝覆盖**", 1 }, - { "content", "string", "要写入的完整文件内容", 1 }, - PARAM_END -}; + {"path", "string", + "文件路径; 父目录不存在会自动创建. **已经存在的二进制文件会被拒绝覆盖**", + 1}, + {"content", "string", "要写入的完整文件内容", 1}, + PARAM_END}; static const ToolParam P_EDIT_FILE[] = { - { "path", "string", "要修改的文件路径", 1 }, - { "line", "integer[]", - "old_string **起点**所在的行号, 集合型, **可以整个省略**: 单个整数 (`12`)" - "或整数数组 (`[12,13]`)都收, 行号就是 read_file 打出来的那些 (从 1 开始). " - "数组的用法: old_string 跨几行、又不想赌起点是第几行时, 把覆盖到的那几行" - "都填进来; 替换只发生在这些行里. **省略 line = 全文件替换所有匹配**, ", 0 }, - { "old_string", "string", - "被替换的原文, 必须与文件内容逐字符一致 (含缩进), 且**在 line 圈住的行里**" - "只有一处匹配 (匹配可以跨行, 起点落在集合里即可)", 1 }, - { "new_string", "string", "替换后的新文本", 1 }, - { "replace_all", "boolean", - "为 true 时替换**全文件**里所有匹配 (line 仍必须圈住其中真实的一处), " - "默认 false (只替换 line 圈住的那一处)", 0 }, - PARAM_END -}; + {"path", "string", "要修改的文件路径", 1}, + {"line", "integer[]", + "old_string **起点**所在的行号, 集合型, **可以整个省略**: 单个整数 (`12`)" + "或整数数组 (`[12,13]`)都收, 行号就是 read_file 打出来的那些 (从 1 开始). " + "数组的用法: old_string 跨几行、又不想赌起点是第几行时, 把覆盖到的那几行" + "都填进来; 替换只发生在这些行里. **省略 line = 全文件替换所有匹配**, ", + 0}, + {"old_string", "string", + "被替换的原文, 必须与文件内容逐字符一致 (含缩进), 且**在 line 圈住的行里**" + "只有一处匹配 (匹配可以跨行, 起点落在集合里即可)", + 1}, + {"new_string", "string", "替换后的新文本", 1}, + {"replace_all", "boolean", + "为 true 时替换**全文件**里所有匹配 (line 仍必须圈住其中真实的一处), " + "默认 false (只替换 line 圈住的那一处)", + 0}, + PARAM_END}; static const ToolParam P_LIST_DIR[] = { - { "path", "string", "目录路径, 默认当前目录", 0 }, - PARAM_END -}; + {"path", "string", "目录路径, 默认当前目录", 0}, PARAM_END}; static const ToolParam P_READ_IMAGE[] = { - { "path", "string", - "本地图片路径. 支持 PNG / JPEG / GIF / WebP (按文件内容判断, 不看扩展名)", 1 }, - PARAM_END -}; + {"path", "string", + "本地图片路径. 支持 PNG / JPEG / GIF / WebP (按文件内容判断, 不看扩展名)", + 1}, + PARAM_END}; static const ToolDef TOOLS[] = { - { - TOOL_RUN_COMMAND, - "在工作目录中执行 shell 命令, 返回合并后的 stdout/stderr 与退出码. " - "适合编译、运行测试、git 操作等. ", - "tools.ui.run_command", - P_RUN_COMMAND, 1, 0, tool_run_command - }, - { - TOOL_READ_FILE, - "读取文本文件内容, 返回带行号的文本; 行号可以直接喂给 edit_file 的 line. " - "大文件请用 offset/limit 分段读取. " - "**二进制文件会被拒绝** (它只处理文本). ", - "tools.ui.read_file", - P_READ_FILE, 0, 0, tool_read_file - }, - { - TOOL_WRITE_FILE, - "创建或整体覆盖一个文本文件 (会覆盖原有内容). 修改已有文件优先用 edit_file. " - "目标已经是二进制文件时会被拒绝覆盖. ", - "tools.ui.write_file", - P_WRITE_FILE, 1, 0, tool_write_file - }, - { - TOOL_EDIT_FILE, - "在文件中做精确字符串替换. line 是集合型且**可省略**: 给一个整数 (`12`)" - "或整数数组 (`[12,13]`)—— 把 old_string 覆盖到的那几行都填进来, 起点到底" - "是第几行就不必赌; 替换只发生在这些行里, old_string 也只需在这些行里唯一. " - "**省略 line = 全文件替换所有匹配** (就是编辑器的\"全局替换\"), 只在你确实" - "要改全文件时才省. 行号对不上会报出 old_string 真正在哪几行, 照着改 line. ", - "tools.ui.edit_file", - P_EDIT_FILE, 1, 0, tool_edit_file - }, - { - TOOL_LIST_DIR, - "列出目录内容, 含文件类型与大小. ", - "tools.ui.list_dir", - P_LIST_DIR, 0, 0, tool_list_dir - }, - { - TOOL_READ_IMAGE, - "读取一张本地图片并把它交给你看 (截图、设计稿、图表、报错弹窗都行). " - "图片会以 base64 内联进请求, 本机留一份备份、历史里只记对备份的引用. " - "需要看图片内容时用它, 不要试着用 read_file 去读二进制. " - "图片超过内联体积上限时会被直接拒绝, 这时请让用户先缩小或压缩. ", - "tools.ui.read_image", - P_READ_IMAGE, 0, 1, tool_read_image - }, + {TOOL_RUN_COMMAND, + "在工作目录中执行 shell 命令, 返回合并后的 stdout/stderr 与退出码. " + "适合编译、运行测试、git 操作等. ", + "tools.ui.run_command", P_RUN_COMMAND, 1, 0, tool_run_command}, + {TOOL_READ_FILE, + "读取文本文件内容, 返回带行号的文本; 行号可以直接喂给 edit_file 的 line. " + "大文件请用 offset/limit 分段读取. " + "**二进制文件会被拒绝** (它只处理文本). ", + "tools.ui.read_file", P_READ_FILE, 0, 0, tool_read_file}, + {TOOL_WRITE_FILE, + "创建或整体覆盖一个文本文件 (会覆盖原有内容). 修改已有文件优先用 " + "edit_file. " + "目标已经是二进制文件时会被拒绝覆盖. ", + "tools.ui.write_file", P_WRITE_FILE, 1, 0, tool_write_file}, + {TOOL_EDIT_FILE, + "在文件中做精确字符串替换. line 是集合型且**可省略**: 给一个整数 (`12`)" + "或整数数组 (`[12,13]`)—— 把 old_string 覆盖到的那几行都填进来, 起点到底" + "是第几行就不必赌; 替换只发生在这些行里, old_string 也只需在这些行里唯一. " + "**省略 line = 全文件替换所有匹配** (就是编辑器的\"全局替换\"), 只在你确实" + "要改全文件时才省. 行号对不上会报出 old_string 真正在哪几行, 照着改 " + "line. ", + "tools.ui.edit_file", P_EDIT_FILE, 1, 0, tool_edit_file}, + {TOOL_LIST_DIR, "列出目录内容, 含文件类型与大小. ", "tools.ui.list_dir", + P_LIST_DIR, 0, 0, tool_list_dir}, + {TOOL_READ_IMAGE, + "读取一张本地图片并把它交给你看 (截图、设计稿、图表、报错弹窗都行). " + "图片会以 base64 内联进请求, 本机留一份备份、历史里只记对备份的引用. " + "需要看图片内容时用它, 不要试着用 read_file 去读二进制. " + "图片超过内联体积上限时会被直接拒绝, 这时请让用户先缩小或压缩. ", + "tools.ui.read_image", P_READ_IMAGE, 0, 1, tool_read_image}, }; #define TOOL_COUNT (sizeof TOOLS / sizeof TOOLS[0]) -/* 按名字找定义, 找不到返回 NULL. +/* 按名字找定义, 找不到返回 NULL. * * **故意不保留旧名别名**: schema 里只发新名, dispatch 再认旧名只会掩盖 - * "调用方没跟着改"这件事 (旧存档、用户自写的提示词、测试夹具都一样). + * "调用方没跟着改"这件事 (旧存档、用户自写的提示词、测试夹具都一样). * 名字即协议, 认不出来的就明确报"未知工具", 比静默兼容更容易发现漏改. */ -static const ToolDef *find_tool(const char *name) -{ - if (!name || !*name) return NULL; +static const ToolDef *find_tool(const char *name) { + if (!name || !*name) + return NULL; for (size_t i = 0; i < TOOL_COUNT; i++) - if (strcmp(TOOLS[i].name, name) == 0) return &TOOLS[i]; + if (strcmp(TOOLS[i].name, name) == 0) + return &TOOLS[i]; return NULL; } /* 从表生成 chat/completions 的 tools 数组 (调用者释放) */ -JsonValue *tools_schema(void) -{ +JsonValue *tools_schema(void) { JsonValue *tools = json_new_array(); for (size_t i = 0; i < TOOL_COUNT; i++) { @@ -1113,12 +1214,14 @@ JsonValue *tools_schema(void) JsonValue *req = json_new_array(); for (const ToolParam *p = d->params; p && p->name; p++) { JsonValue *one = json_new_object(); - /* "整数[]" 这种写法表示数组: 展开成 {"type":"array","items":{"type":"整数"}}. - * 后缀只认结尾的 "[]", 元素类型照抄前面那段. */ + /* "整数[]" 这种写法表示数组: 展开成 + * {"type":"array","items":{"type":"整数"}}. 后缀只认结尾的 "[]", + * 元素类型照抄前面那段. */ const char *br = strstr(p->type, "[]"); if (br && br[2] == '\0') { char elem[16]; - snprintf(elem, sizeof elem, "%.*s", (int)(br - p->type), p->type); + snprintf(elem, sizeof elem, "%.*s", (int)(br - p->type), + p->type); JsonValue *items = json_new_object(); json_object_set_string(items, "type", elem); json_object_set_string(one, "type", "array"); @@ -1128,7 +1231,8 @@ JsonValue *tools_schema(void) } json_object_set_string(one, "description", p->desc); json_object_set(props, p->name, one); - if (p->required) json_array_push(req, json_new_string(p->name)); + if (p->required) + json_array_push(req, json_new_string(p->name)); } json_object_set(params, "properties", props); json_object_set(params, "required", req); @@ -1140,9 +1244,9 @@ JsonValue *tools_schema(void) return tools; } -void tools_drop(JsonValue *tools, const char *name) -{ - if (!tools || tools->type != JSON_ARRAY || !name) return; +void tools_drop(JsonValue *tools, const char *name) { + if (!tools || tools->type != JSON_ARRAY || !name) + return; for (size_t i = 0; i < json_array_size(tools); i++) { JsonValue *fn = json_object_get(json_array_get(tools, i), "function"); const char *n = fn ? json_get_string(fn, "name") : NULL; @@ -1153,11 +1257,10 @@ void tools_drop(JsonValue *tools, const char *name) } } -/* hide_image: 探测到模型不收图片时为 1 —— /tools 不该列一个已经藏起来的工具, +/* hide_image: 探测到模型不收图片时为 1 —— /tools 不该列一个已经藏起来的工具, * 否则用户看到的是谎话. 列表本身从工具表生成, 所以它和真正发出去的工具表 * 天然一致 (以前这里是手写的一串 puts, 就漏过一个工具). */ -void tools_print_list(int hide_image) -{ +void tools_print_list(int hide_image) { puts(t("tools.ui.header")); for (size_t i = 0; i < TOOL_COUNT; i++) { const ToolDef *d = &TOOLS[i]; @@ -1165,23 +1268,21 @@ 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)); } } -int tool_mutating(const char *name) -{ +int tool_mutating(const char *name) { const ToolDef *d = find_tool(name); return d ? d->mutating : 0; } /* line 是集合型参数: 整数或整数数组. 渲染成 "3" / "3,4,5" (超过 8 个就省略 - * 尾部)—— 用户/模型要确认的第一件事就是"改的是哪几行", 但也不能把终端刷满. + * 尾部)—— 用户/模型要确认的第一件事就是"改的是哪几行", 但也不能把终端刷满. * 一个合法行号都没渲染出来时留空串, 调用方据此走"没给行号"那条文案. */ -void tool_lines_label(const JsonValue *args, char *out, size_t cap) -{ +void tool_lines_label(const JsonValue *args, char *out, size_t cap) { JsonValue *lv = json_object_get(args, "line"); int is_arr = (lv && lv->type == JSON_ARRAY); size_t n = is_arr ? json_array_size(lv) : (lv ? 1 : 0); @@ -1189,11 +1290,16 @@ void tool_lines_label(const JsonValue *args, char *out, size_t cap) out[0] = '\0'; for (size_t i = 0; i < n; i++) { JsonValue *e = is_arr ? json_array_get(lv, i) : lv; - if (e->type != JSON_NUMBER) continue; - if (wrote >= 8) { snprintf(out + off, cap - off, "…"); break; } - int w = snprintf(out + off, cap - off, "%s%ld", - wrote ? ", " : "", (long)e->u.number); - if (w < 0 || (size_t)w >= cap - off) break; + if (e->type != JSON_NUMBER) + continue; + if (wrote >= 8) { + snprintf(out + off, cap - off, "…"); + break; + } + int w = snprintf(out + off, cap - off, "%s%ld", wrote ? ", " : "", + (long)e->u.number); + if (w < 0 || (size_t)w >= cap - off) + break; off += (size_t)w; wrote++; } @@ -1202,39 +1308,43 @@ void tool_lines_label(const JsonValue *args, char *out, size_t cap) /* ---- edit_file 的新旧对照 (带行号)---- * * 行号取自 line 参数: 替换就发生在那里, 所以两侧的起始行号相同 —— 新内容落在 - * 旧内容原来的位置上. line 省略 (全文件替换)时没有确定的起点, 就**不标**行号: - * 编一个出来比不标更糟. + * 旧内容原来的位置上. line 省略 (全文件替换)时没有确定的起点, 就**不标**行号: + * 编一个出来比不标更糟. */ #define DIFF_MAX_LINES 8 -#define DIFF_MAX_COLS 200 -#define DIFF_RED "\033[31m" +#define DIFF_MAX_COLS 200 +#define DIFF_RED "\033[31m" #define DIFF_GREEN "\033[32m" -#define DIFF_DIM "\033[2m" -#define DIFF_OFF "\033[0m" +#define DIFF_DIM "\033[2m" +#define DIFF_OFF "\033[0m" /* 起始行号: line 是集合型, 取最小的那个; 没给 line 则返回 0 (= 不标行号) */ -static long diff_base_line(const JsonValue *args) -{ +static long diff_base_line(const JsonValue *args) { JsonValue *lv = json_object_get(args, "line"); - if (!lv) return 0; - if (lv->type == JSON_NUMBER) return (long)lv->u.number; - if (lv->type != JSON_ARRAY) return 0; + if (!lv) + return 0; + if (lv->type == JSON_NUMBER) + return (long)lv->u.number; + if (lv->type != JSON_ARRAY) + return 0; long base = 0; for (size_t i = 0; i < json_array_size(lv); i++) { JsonValue *e = json_array_get(lv, i); - if (!e || e->type != JSON_NUMBER) continue; + if (!e || e->type != JSON_NUMBER) + continue; long v = (long)e->u.number; - if (base == 0 || v < base) base = v; + if (base == 0 || v < base) + base = v; } return base; } static void diff_side(FILE *out, const char *prefix, const char *sign, - const char *color, int use_color, const char *text, long base) -{ + const char *color, int use_color, const char *text, + long base) { int total = 0, shown = 0; long lineno = base; - for (const char *p = text; ; ) { + for (const char *p = text;;) { const char *nl = strchr(p, '\n'); size_t n = nl ? (size_t)(nl - p) : strlen(p); total++; @@ -1251,7 +1361,8 @@ static void diff_side(FILE *out, const char *prefix, const char *sign, shown++; } lineno++; - if (!nl || !nl[1]) break; /* 末尾那个换行不算多一行 */ + if (!nl || !nl[1]) + break; /* 末尾那个换行不算多一行 */ p = nl + 1; } if (total > shown) @@ -1260,41 +1371,47 @@ static void diff_side(FILE *out, const char *prefix, const char *sign, use_color ? DIFF_OFF : ""); } -int tool_render_diff(FILE *out, const JsonValue *args, - const char *prefix, int use_color) -{ - if (!out || !args) return 0; +int tool_render_diff(FILE *out, const JsonValue *args, const char *prefix, + int use_color) { + if (!out || !args) + return 0; const char *old_s = json_string_value(json_object_get(args, "old_string")); const char *new_s = json_string_value(json_object_get(args, "new_string")); - if (!old_s || !new_s) return 0; + if (!old_s || !new_s) + return 0; long base = diff_base_line(args); - diff_side(out, prefix, "-", DIFF_RED, use_color, old_s, base); + diff_side(out, prefix, "-", DIFF_RED, use_color, old_s, base); diff_side(out, prefix, "+", DIFF_GREEN, use_color, new_s, base); return 1; } -char *tool_execute(const char *name, const JsonValue *args, int *is_error) -{ - if (is_error) *is_error = 0; - if (!name) return xstrdup("错误: 工具名为空"); +char *tool_execute(const char *name, const JsonValue *args, int *is_error) { + if (is_error) + *is_error = 0; + if (!name) + return xstrdup("错误: 工具名为空"); if (!args || args->type != JSON_OBJECT) { - if (is_error) *is_error = 1; + if (is_error) + *is_error = 1; return xstrdup("错误: 工具参数必须是 JSON 对象"); } const ToolDef *d = find_tool(name); if (!d) { - if (is_error) *is_error = 1; + if (is_error) + *is_error = 1; return err_result("未知工具 \"%s\"", name); } /* fn == NULL 是一道守卫: 表里目前没有这种工具, 但留着这条检查 —— - * 将来若加一个"由上层执行"的工具, 模型瞎编参数时也能得到明确回答, + * 将来若加一个"由上层执行"的工具, 模型瞎编参数时也能得到明确回答, * 而不是静默什么都不干. */ if (!d->fn) { - if (is_error) *is_error = 1; + if (is_error) + *is_error = 1; return err_result("工具 \"%s\" 不能在这里直接执行 (它只在主对话里可用, " - "由 agent.c 处理)", name); + "由 agent.c 处理)", + name); } return d->fn(args, is_error); } diff --git a/src/tools.h b/src/tools.h index 918edc6..0e27305 100644 --- a/src/tools.h +++ b/src/tools.h @@ -4,42 +4,45 @@ #include "json.h" -#include /* FILE: tool_render_diff 要往 stdout / stderr 写 */ +#include /* FILE: tool_render_diff 要往 stdout / stderr 写 */ /* ---- 工具名 ---- * * 名字是协议的一部分: 它进 schema、进执行分发、进 /tools 的输出. 所以**名字 * 只在这里定义一次**, 别处一律用宏 —— 改名时漏一处不会编译报错, 只会静默地 - * 少认一个工具. + * 少认一个工具. * * 命名约定 (动宾结构、动词白名单、副作用可读、长度上限……)见 tools.c 顶部的 * 工具表注释, 用户向的说明在 README §5, 三处必须同步. */ -#define TOOL_RUN_COMMAND "run_command" -#define TOOL_READ_FILE "read_file" -#define TOOL_WRITE_FILE "write_file" -#define TOOL_EDIT_FILE "edit_file" -#define TOOL_LIST_DIR "list_dir" -#define TOOL_READ_IMAGE "read_image" +#define TOOL_RUN_COMMAND "run_command" +#define TOOL_READ_FILE "read_file" +#define TOOL_WRITE_FILE "write_file" +#define TOOL_EDIT_FILE "edit_file" +#define TOOL_LIST_DIR "list_dir" +#define TOOL_READ_IMAGE "read_image" -/* 返回 tools 数组, 用于 chat/completions 请求 (调用者释放). +/* 返回 tools 数组, 用于 chat/completions 请求 (调用者释放). * 内容由 tools.c 的工具表生成, 不在这里维护. */ JsonValue *tools_schema(void); -/* 从工具表里摘掉一个工具 (按 function.name 找, 用上面的 TOOL_* 宏). +/* 从工具表里摘掉一个工具 (按 function.name 找, 用上面的 TOOL_* 宏). * 没找到就什么都不做. 能力探测发现模型不收图片时, 用它把 read_image 收敛掉. */ 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) +#define REQUIRE_ERR_PTR(p) \ + do { \ + assert((p) != NULL); \ + } while (0) /* 执行一个工具调用. 返回 malloc 的结果文本; *is_error 置 1 表示失败 */ char *tool_execute(const char *name, const JsonValue *args, int *is_error); -/* 该工具是否会改动磁盘或执行命令. - * 只读工具 (read_file / list_dir)返回 0. +/* 该工具是否会改动磁盘或执行命令. + * 只读工具 (read_file / list_dir)返回 0. * 这份"哪些工具会写"的知识属于工具层, 确认策略由上层决定. */ int tool_mutating(const char *name); @@ -51,24 +54,24 @@ void tools_print_list(int hide_image); * "全文件"那条文案. 确认提示与调用摘要共用这一处. */ void tool_lines_label(const JsonValue *args, char *out, size_t cap); -/* 把 edit_file 的旧/新文本渲染成**带行号的 diff** ("- 旧 / + 新"), 写进 out. +/* 把 edit_file 的旧/新文本渲染成**带行号的 diff** ("- 旧 / + 新"), 写进 out. * * 行号取自 line 参数 —— 替换就发生在那里, 两侧起始行号相同 (新内容落在原来的 * 位置上); line 省略 (全文件替换)时没有确定的起始行号, 就不标 —— 编一个比 - * 不标更糟. 每侧最多 8 行, 其余折成一句"还有 N 行". + * 不标更糟. 每侧最多 8 行, 其余折成一句"还有 N 行". * - * prefix 是每行前面那截 (调用摘要用 " │ "、确认提示用 " "); + * prefix 是每行前面那截 (调用摘要用 " │ "、确认提示用 " "); * use_color=1 时给 - / + 上红绿. 返回 1 表示渲染过内容 (args 里没有这两个字段 - * 时返回 0, 调用方回退到别的显示). + * 时返回 0, 调用方回退到别的显示). * * 确认提示 (动手前)和执行之后用的是**同一个**渲染 —— 用户确认时看到的和事后 * 看到的必须是同一份东西, 不能因为两处各写一遍而长得不一样. */ -int tool_render_diff(FILE *out, const JsonValue *args, - const char *prefix, int use_color); +int tool_render_diff(FILE *out, const JsonValue *args, const char *prefix, + int use_color); /* 单次工具输出的上限 (字节) */ #define LIGENT_TOOL_OUTPUT_LIMIT (100 * 1024) /* edit_file 能处理的文件大小上限. 它要把整个文件读进内存 */ -#define LIGENT_MAX_EDIT_FILE (32u * 1024 * 1024) +#define LIGENT_MAX_EDIT_FILE (32u * 1024 * 1024) #endif /* LIGENT_TOOLS_H */ diff --git a/src/util.c b/src/util.c index 7225af2..b58e421 100644 --- a/src/util.c +++ b/src/util.c @@ -2,80 +2,81 @@ #include "util.h" #include "i18n.h" +#include #include #include #include -#include -#include -#include #include #include +#include +#include /* ------------------------------------------------------------------ */ /* 内存 */ /* ------------------------------------------------------------------ */ -static void oom(void) -{ +static void oom(void) { fputs(t("util.oom"), stderr); exit(1); } -void *xmalloc(size_t n) -{ +void *xmalloc(size_t n) { void *p = malloc(n ? n : 1); - if (!p) oom(); + if (!p) + oom(); return p; } -void *xcalloc(size_t n, size_t size) -{ +void *xcalloc(size_t n, size_t size) { void *p = calloc(n ? n : 1, size ? size : 1); - if (!p) oom(); + if (!p) + oom(); return p; } -void *xrealloc(void *p, size_t n) -{ +void *xrealloc(void *p, size_t n) { void *q = realloc(p, n ? n : 1); - if (!q) oom(); + if (!q) + oom(); return q; } -char *xstrdup(const char *s) -{ - if (!s) return NULL; +char *xstrdup(const char *s) { + if (!s) + return NULL; size_t n = strlen(s); char *p = xmalloc(n + 1); memcpy(p, s, n + 1); return p; } -char *xstrndup(const char *s, size_t n) -{ +char *xstrndup(const char *s, size_t n) { size_t len = 0; - while (len < n && s[len]) len++; + while (len < n && s[len]) + len++; char *p = xmalloc(len + 1); memcpy(p, s, len); p[len] = '\0'; return p; } -char *xvasprintf(const char *fmt, va_list ap) -{ - if (!fmt) fmt = ""; +char *xvasprintf(const char *fmt, va_list ap) { + if (!fmt) + fmt = ""; va_list ap2; va_copy(ap2, ap); int n = vsnprintf(NULL, 0, fmt, ap); - if (n < 0) { va_end(ap2); return xstrdup(""); } + if (n < 0) { + va_end(ap2); + return xstrdup(""); + } char *buf = xmalloc((size_t)n + 1); vsnprintf(buf, (size_t)n + 1, fmt, ap2); va_end(ap2); return buf; } -char *xasprintf(const char *fmt, ...) -{ +char *xasprintf(const char *fmt, ...) { va_list ap; va_start(ap, fmt); char *s = xvasprintf(fmt, ap); @@ -87,47 +88,47 @@ char *xasprintf(const char *fmt, ...) /* StrBuf */ /* ------------------------------------------------------------------ */ -void sb_init(StrBuf *sb) -{ +void sb_init(StrBuf *sb) { sb->data = NULL; sb->len = 0; sb->cap = 0; } -void sb_free(StrBuf *sb) -{ - if (!sb) return; +void sb_free(StrBuf *sb) { + if (!sb) + return; free(sb->data); sb->data = NULL; sb->len = sb->cap = 0; } -void sb_clear(StrBuf *sb) -{ +void sb_clear(StrBuf *sb) { sb->len = 0; - if (sb->data) sb->data[0] = '\0'; + if (sb->data) + sb->data[0] = '\0'; } -void sb_reserve(StrBuf *sb, size_t extra) -{ - /* 提前返回要求 data 非空: 不能只看容量够不够. +void sb_reserve(StrBuf *sb, size_t extra) { + /* 提前返回要求 data 非空: 不能只看容量够不够. * 代码里其实维护着「data == NULL 当且仅当 cap == 0」这个不变量 (sb_init / - * sb_free / sb_detach 三处), 但那是隐式的、没人强制; 一旦哪次改动让它不成立, - * 这里就会带着 NULL 指针回去, 下一行 memcpy 才炸, 离原因很远. - * 写成 sb->data && … 之后, 函数出口天然保证 data 非空 (xrealloc 失败即退出), - * 不变量坏掉也只会退化成一次多余的重分配. */ - if (sb->data && sb->len + extra + 1 <= sb->cap) return; + * sb_free / sb_detach 三处), 但那是隐式的、没人强制; + * 一旦哪次改动让它不成立, 这里就会带着 NULL 指针回去, 下一行 memcpy 才炸, + * 离原因很远. 写成 sb->data && … 之后, 函数出口天然保证 data 非空 (xrealloc + * 失败即退出), 不变量坏掉也只会退化成一次多余的重分配. */ + if (sb->data && sb->len + extra + 1 <= sb->cap) + return; size_t cap = sb->cap ? sb->cap : 64; - while (cap < sb->len + extra + 1) cap *= 2; + while (cap < sb->len + extra + 1) + cap *= 2; sb->data = xrealloc(sb->data, cap); sb->cap = cap; } -void sb_append(StrBuf *sb, const char *data, size_t len) -{ +void sb_append(StrBuf *sb, const char *data, size_t len) { if (!data || len == 0) { sb_reserve(sb, 0); - if (sb->data) sb->data[sb->len] = '\0'; + if (sb->data) + sb->data[sb->len] = '\0'; return; } sb_reserve(sb, len); @@ -136,20 +137,18 @@ void sb_append(StrBuf *sb, const char *data, size_t len) sb->data[sb->len] = '\0'; } -void sb_puts(StrBuf *sb, const char *s) -{ - if (s) sb_append(sb, s, strlen(s)); +void sb_puts(StrBuf *sb, const char *s) { + if (s) + sb_append(sb, s, strlen(s)); } -void sb_putc(StrBuf *sb, char c) -{ +void sb_putc(StrBuf *sb, char c) { sb_reserve(sb, 1); sb->data[sb->len++] = c; sb->data[sb->len] = '\0'; } -void sb_printf(StrBuf *sb, const char *fmt, ...) -{ +void sb_printf(StrBuf *sb, const char *fmt, ...) { va_list ap; va_start(ap, fmt); char *s = xvasprintf(fmt, ap); @@ -158,8 +157,7 @@ void sb_printf(StrBuf *sb, const char *fmt, ...) free(s); } -char *sb_detach(StrBuf *sb) -{ +char *sb_detach(StrBuf *sb) { if (!sb->data) { char *p = xmalloc(1); p[0] = '\0'; @@ -175,39 +173,44 @@ char *sb_detach(StrBuf *sb) /* 字符串工具 */ /* ------------------------------------------------------------------ */ -int str_starts_with(const char *s, const char *prefix) -{ - if (!s || !prefix) return 0; +int str_starts_with(const char *s, const char *prefix) { + if (!s || !prefix) + return 0; return strncmp(s, prefix, strlen(prefix)) == 0; } -int str_ends_with(const char *s, const char *suffix) -{ - if (!s || !suffix) return 0; +int str_ends_with(const char *s, const char *suffix) { + if (!s || !suffix) + return 0; size_t ls = strlen(s), lf = strlen(suffix); - if (lf > ls) return 0; + if (lf > ls) + return 0; return memcmp(s + ls - lf, suffix, lf) == 0; } -char *str_trim(char *s) -{ - if (!s) return s; +char *str_trim(char *s) { + if (!s) + return s; char *p = s; - while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') p++; - if (p != s) memmove(s, p, strlen(p) + 1); + while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') + p++; + if (p != s) + memmove(s, p, strlen(p) + 1); size_t n = strlen(s); while (n > 0) { char c = s[n - 1]; - if (c == ' ' || c == '\t' || c == '\r' || c == '\n') s[--n] = '\0'; - else break; + if (c == ' ' || c == '\t' || c == '\r' || c == '\n') + s[--n] = '\0'; + else + break; } return s; } -char *read_entire_file(const char *path, size_t *out_len) -{ +char *read_entire_file(const char *path, size_t *out_len) { FILE *f = fopen(path, "rb"); - if (!f) return NULL; + if (!f) + return NULL; StrBuf sb; sb_init(&sb); char buf[8192]; @@ -218,18 +221,23 @@ char *read_entire_file(const char *path, size_t *out_len) * 内容**当成功返回 —— 对图片尤其糟: 会拿半张图去备份、去上传. */ int bad = ferror(f); fclose(f); - if (bad) { sb_free(&sb); return NULL; } - if (out_len) *out_len = sb.len; + if (bad) { + sb_free(&sb); + return NULL; + } + if (out_len) + *out_len = sb.len; return sb_detach(&sb); } -int mkdir_p(const char *path) -{ - if (!path || !*path) return -1; +int mkdir_p(const char *path) { + if (!path || !*path) + return -1; char *tmp = xstrdup(path); size_t len = strlen(tmp); /* 去掉结尾的 '/' */ - while (len > 1 && tmp[len - 1] == '/') tmp[--len] = '\0'; + while (len > 1 && tmp[len - 1] == '/') + tmp[--len] = '\0'; for (char *p = tmp + 1; *p; p++) { if (*p == '/') { @@ -242,16 +250,19 @@ int mkdir_p(const char *path) } } int rc = 0; - if (mkdir(tmp, 0777) != 0 && errno != EEXIST) rc = -1; + if (mkdir(tmp, 0777) != 0 && errno != EEXIST) + rc = -1; free(tmp); return rc; } -unsigned utf8_decode(const char *s, size_t len, size_t *adv) -{ +unsigned utf8_decode(const char *s, size_t len, size_t *adv) { const unsigned char *p = (const unsigned char *)s; unsigned char c = p[0]; - if (c < 0x80) { *adv = 1; return c; } + if (c < 0x80) { + *adv = 1; + return c; + } if ((c & 0xE0) == 0xC0 && len >= 2) { *adv = 2; return ((unsigned)(c & 0x1F) << 6) | (p[1] & 0x3F); @@ -270,58 +281,73 @@ unsigned utf8_decode(const char *s, size_t len, size_t *adv) return c; } -size_t utf8_seq_len(const char *s, size_t len) -{ - if (len == 0) return 0; +size_t utf8_seq_len(const char *s, size_t len) { + if (len == 0) + return 0; const unsigned char *p = (const unsigned char *)s; unsigned char c = p[0]; - if (c < 0x80) return 1; + if (c < 0x80) + return 1; size_t n; unsigned cp; - if ((c & 0xE0) == 0xC0) { n = 2; cp = c & 0x1Fu; } - else if ((c & 0xF0) == 0xE0) { n = 3; cp = c & 0x0Fu; } - else if ((c & 0xF8) == 0xF0) { n = 4; cp = c & 0x07u; } - else return 0; /* 0x80-0xBF 是孤立的续字节; 0xF8-0xFF 不可能是首字节 */ + if ((c & 0xE0) == 0xC0) { + n = 2; + cp = c & 0x1Fu; + } else if ((c & 0xF0) == 0xE0) { + n = 3; + cp = c & 0x0Fu; + } else if ((c & 0xF8) == 0xF0) { + n = 4; + cp = c & 0x07u; + } else + return 0; /* 0x80-0xBF 是孤立的续字节; 0xF8-0xFF 不可能是首字节 */ - if (len < n) return 0; /* 被截断 */ + if (len < n) + return 0; /* 被截断 */ for (size_t i = 1; i < n; i++) { - if ((p[i] & 0xC0) != 0x80) return 0; + if ((p[i] & 0xC0) != 0x80) + return 0; cp = (cp << 6) | (p[i] & 0x3Fu); } /* 下面这些"格式对但值非法"的情况, json 解析器一样会拒 —— 过长编码 * (比如 0xC0 0xAF 表示 '/')、UTF-16 代理区、超出 U+10FFFF. */ - if (n == 2 && cp < 0x80) return 0; - if (n == 3 && cp < 0x800) return 0; - if (n == 4 && cp < 0x10000) return 0; - if (cp >= 0xD800 && cp <= 0xDFFF) return 0; - if (cp > 0x10FFFF) return 0; + if (n == 2 && cp < 0x80) + return 0; + if (n == 3 && cp < 0x800) + return 0; + if (n == 4 && cp < 0x10000) + return 0; + if (cp >= 0xD800 && cp <= 0xDFFF) + return 0; + if (cp > 0x10FFFF) + return 0; return n; } /* 近似 wcwidth: 东亚宽字符返回 2, 覆盖常用 CJK 区间 */ -int cp_width(unsigned cp) -{ - if (cp < 32) return 0; - if (cp < 0x1100) return 1; - if ((cp >= 0x1100 && cp <= 0x115F) || /* 谚文字母 */ - (cp >= 0x2E80 && cp <= 0x303E) || /* CJK 部首、康熙部首、CJK 标点 */ - (cp >= 0x3041 && cp <= 0x33FF) || /* 假名、注音、谚文兼容、CJK 兼容 */ - (cp >= 0x3400 && cp <= 0x4DBF) || /* CJK 扩展 A */ - (cp >= 0x4E00 && cp <= 0x9FFF) || /* CJK 基本区 */ - (cp >= 0xA000 && cp <= 0xA4CF) || /* 彝文 */ - (cp >= 0xAC00 && cp <= 0xD7A3) || /* 谚文音节 */ - (cp >= 0xF900 && cp <= 0xFAFF) || /* CJK 兼容表意文字 */ - (cp >= 0xFE30 && cp <= 0xFE4F) || /* CJK 兼容形式 */ - (cp >= 0xFF00 && cp <= 0xFF60) || /* 全角字符 */ +int cp_width(unsigned cp) { + if (cp < 32) + return 0; + if (cp < 0x1100) + return 1; + if ((cp >= 0x1100 && cp <= 0x115F) || /* 谚文字母 */ + (cp >= 0x2E80 && cp <= 0x303E) || /* CJK 部首、康熙部首、CJK 标点 */ + (cp >= 0x3041 && cp <= 0x33FF) || /* 假名、注音、谚文兼容、CJK 兼容 */ + (cp >= 0x3400 && cp <= 0x4DBF) || /* CJK 扩展 A */ + (cp >= 0x4E00 && cp <= 0x9FFF) || /* CJK 基本区 */ + (cp >= 0xA000 && cp <= 0xA4CF) || /* 彝文 */ + (cp >= 0xAC00 && cp <= 0xD7A3) || /* 谚文音节 */ + (cp >= 0xF900 && cp <= 0xFAFF) || /* CJK 兼容表意文字 */ + (cp >= 0xFE30 && cp <= 0xFE4F) || /* CJK 兼容形式 */ + (cp >= 0xFF00 && cp <= 0xFF60) || /* 全角字符 */ (cp >= 0xFFE0 && cp <= 0xFFE6) || - (cp >= 0x20000 && cp <= 0x3FFFD)) /* CJK 扩展 B 及以上 */ + (cp >= 0x20000 && cp <= 0x3FFFD)) /* CJK 扩展 B 及以上 */ return 2; return 1; } -size_t display_width(const char *s, size_t n) -{ +size_t display_width(const char *s, size_t n) { size_t w = 0, i = 0; while (i < n) { size_t adv = 1; @@ -332,26 +358,26 @@ size_t display_width(const char *s, size_t n) return w; } - -size_t utf8_next(const char *s, size_t n, size_t i) -{ - if (i >= n) return n; +size_t utf8_next(const char *s, size_t n, size_t i) { + if (i >= n) + return n; size_t adv = 1; utf8_decode(s + i, n - i, &adv); return i + adv > n ? n : i + adv; } -size_t utf8_prev(const char *s, size_t n, size_t i) -{ - if (i == 0 || i > n) return 0; +size_t utf8_prev(const char *s, size_t n, size_t i) { + if (i == 0 || i > n) + return 0; size_t j = i - 1; - while (j > 0 && ((unsigned char)s[j] & 0xC0) == 0x80) j--; + while (j > 0 && ((unsigned char)s[j] & 0xC0) == 0x80) + j--; return j; } -char *expand_env(const char *src) -{ - if (!src) return NULL; +char *expand_env(const char *src) { + if (!src) + return NULL; StrBuf out; sb_init(&out); const char *p = src; @@ -361,7 +387,8 @@ char *expand_env(const char *src) if (end) { char *name = xstrndup(p + 2, (size_t)(end - (p + 2))); const char *val = getenv(name); - if (val) sb_puts(&out, val); + if (val) + sb_puts(&out, val); free(name); p = end + 1; continue; @@ -376,18 +403,22 @@ char *expand_env(const char *src) /* 文本 / 二进制的判据 */ /* ------------------------------------------------------------------ */ -long buf_nul_at(const char *data, size_t n) -{ +long buf_nul_at(const char *data, size_t n) { const char *p = memchr(data, '\0', n); return p ? (long)(p - data) : -1; } -int looks_binary(const char *data, size_t n, long *at) -{ - if (!data) return 0; - if (n > LIGENT_BIN_PROBE) n = LIGENT_BIN_PROBE; +int looks_binary(const char *data, size_t n, long *at) { + if (!data) + return 0; + if (n > LIGENT_BIN_PROBE) + n = LIGENT_BIN_PROBE; long off = buf_nul_at(data, n); - if (off >= 0) { if (at) *at = off; return 1; } + if (off >= 0) { + if (at) + *at = off; + return 1; + } return 0; } @@ -395,13 +426,13 @@ int looks_binary(const char *data, size_t n, long *at) /* 随机名 */ /* ------------------------------------------------------------------ */ -void random_alnum(char *out, size_t n) -{ +void random_alnum(char *out, size_t n) { static const char alpha[] = "abcdefghijklmnopqrstuvwxyz0123456789"; unsigned char buf[64]; size_t got = 0; - if (n > sizeof buf) n = sizeof buf; + if (n > sizeof buf) + n = sizeof buf; FILE *f = fopen("/dev/urandom", "rb"); if (f) { got = fread(buf, 1, n, f); @@ -415,7 +446,8 @@ void random_alnum(char *out, size_t n) buf[i] = (unsigned char)(seed >> 16); } } - for (size_t i = 0; i < n; i++) out[i] = alpha[buf[i] % (sizeof alpha - 1)]; + for (size_t i = 0; i < n; i++) + out[i] = alpha[buf[i] % (sizeof alpha - 1)]; out[n] = '\0'; } @@ -423,29 +455,30 @@ void random_alnum(char *out, size_t n) /* base64 */ /* ------------------------------------------------------------------ */ -size_t base64_encoded_len(size_t n) -{ - /* 每 3 字节出 4 个字符, 不足的补 '='. 先算长度再决定要不要编码: +size_t base64_encoded_len(size_t n) { + /* 每 3 字节出 4 个字符, 不足的补 '='. 先算长度再决定要不要编码: * 一张 24 MiB 的图编码后是 32 MiB, 正好卡在内联上限上. */ return ((n + 2) / 3) * 4; } -char *base64_encode(const unsigned char *data, size_t n) -{ +char *base64_encode(const unsigned char *data, size_t n) { static const char tbl[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - if (!data && n > 0) return NULL; + if (!data && n > 0) + return NULL; size_t out_len = base64_encoded_len(n); char *out = xmalloc(out_len + 1); size_t o = 0; for (size_t i = 0; i < n; i += 3) { - size_t rem = n - i; /* 1..3 */ + size_t rem = n - i; /* 1..3 */ unsigned v = (unsigned)data[i] << 16; - if (rem > 1) v |= (unsigned)data[i + 1] << 8; - if (rem > 2) v |= (unsigned)data[i + 2]; + if (rem > 1) + v |= (unsigned)data[i + 1] << 8; + if (rem > 2) + v |= (unsigned)data[i + 2]; out[o++] = tbl[(v >> 18) & 0x3F]; out[o++] = tbl[(v >> 12) & 0x3F]; diff --git a/src/util.h b/src/util.h index 2d814ab..70d2c50 100644 --- a/src/util.h +++ b/src/util.h @@ -2,20 +2,20 @@ #ifndef LIGENT_UTIL_H #define LIGENT_UTIL_H -#include #include +#include -/* 让我们自己的 printf 风格函数也受编译器检查. +/* 让我们自己的 printf 风格函数也受编译器检查. * * 之前全项目 __attribute__ 出现 0 次 —— 于是 xasprintf / sb_printf / * err_result 这一百多个调用点的格式串**编译器一个都没在看**. 直接写 fprintf - * 的能查 (confirm.c 那次 %s 对 int 就是这么抓到的), 走包装的就漏过去了. + * 的能查 (confirm.c 那次 %s 对 int 就是这么抓到的), 走包装的就漏过去了. * gcc/clang 给属性, tcc 没有这个机制, 空宏. */ #if defined(__GNUC__) || defined(__clang__) -# define LIGENT_PRINTF(fmt_idx, arg_idx) \ - __attribute__((format(printf, fmt_idx, arg_idx))) +#define LIGENT_PRINTF(fmt_idx, arg_idx) \ + __attribute__((format(printf, fmt_idx, arg_idx))) #else -# define LIGENT_PRINTF(fmt_idx, arg_idx) +#define LIGENT_PRINTF(fmt_idx, arg_idx) #endif /* ---- 内存: 失败即退出, 避免到处判空 ---- */ @@ -29,56 +29,56 @@ char *xvasprintf(const char *fmt, va_list ap) LIGENT_PRINTF(1, 0); /* ---- 动态字符串缓冲 ---- */ typedef struct { - char *data; + char *data; size_t len; size_t cap; } StrBuf; -void sb_init(StrBuf *sb); -void sb_free(StrBuf *sb); -void sb_clear(StrBuf *sb); -void sb_reserve(StrBuf *sb, size_t extra); -void sb_append(StrBuf *sb, const char *data, size_t len); -void sb_puts(StrBuf *sb, const char *s); -void sb_putc(StrBuf *sb, char c); -void sb_printf(StrBuf *sb, const char *fmt, ...) LIGENT_PRINTF(2, 3); +void sb_init(StrBuf *sb); +void sb_free(StrBuf *sb); +void sb_clear(StrBuf *sb); +void sb_reserve(StrBuf *sb, size_t extra); +void sb_append(StrBuf *sb, const char *data, size_t len); +void sb_puts(StrBuf *sb, const char *s); +void sb_putc(StrBuf *sb, char c); +void sb_printf(StrBuf *sb, const char *fmt, ...) LIGENT_PRINTF(2, 3); /* 取出缓冲区所有权 (NUL 结尾), 并把 sb 重置为空 */ char *sb_detach(StrBuf *sb); /* ---- 字符串小工具 ---- */ -int str_starts_with(const char *s, const char *prefix); -int str_ends_with(const char *s, const char *suffix); +int str_starts_with(const char *s, const char *prefix); +int str_ends_with(const char *s, const char *suffix); char *str_trim(char *s); /* 读取整个文件到内存; 失败返回 NULL. *out_len 可为 NULL */ char *read_entire_file(const char *path, size_t *out_len); /* 递归创建目录 (类似 mkdir -p); 成功返回 0 */ -int mkdir_p(const char *path); +int mkdir_p(const char *path); /* 把 src 中所有 ${VAR} 展开为环境变量 (未定义则展开为空串) */ char *expand_env(const char *src); /* ---- 文本 / 二进制的判据 (唯一定义处) ---- * * 判据只有一条: **出现 NUL 字节**. 文本文件里不该有它, 而 UTF-16、压缩包、 - * 可执行文件、稀疏文件这类都会中招. + * 可执行文件、稀疏文件这类都会中招. * - * 探测窗口是 8 KB —— 读类工具没必要为了拒绝一个 10 GB 的文件把它整个读一遍. + * 探测窗口是 8 KB —— 读类工具没必要为了拒绝一个 10 GB 的文件把它整个读一遍. * 判据和窗口必须由所有"处理文本"的地方共用: 以前 tools.c 和 attach.c 各写了 * 一份, 窗口还不一样 (一个看前 8 KB、一个看全文件), 于是同一个文件会出现 - * "/include 说它是二进制、read_file 说它能读"这种自相矛盾的说法. + * "/include 说它是二进制、read_file 说它能读"这种自相矛盾的说法. * - * 唯一的例外是 edit_file 的**全文件**检查: 它在内存里按 C 字符串处理, + * 唯一的例外是 edit_file 的**全文件**检查: 它在内存里按 C 字符串处理, * 任何位置的 NUL 都会让结果从那里被截断, 所以它不能只看开头. */ #define LIGENT_BIN_PROBE (8 * 1024) /* 返回第一个 NUL 的偏移; 没有则返回 -1 */ long buf_nul_at(const char *data, size_t n); /* 这段字节看起来是二进制吗?*at (可传 NULL)回填第一个 NUL 的偏移 */ -int looks_binary(const char *data, size_t n, long *at); +int looks_binary(const char *data, size_t n, long *at); /* ---- 随机名 ---- * - * n 个 [a-z0-9] 字符 + NUL 结尾 (out 至少 n+1 字节). 优先 /dev/urandom, - * 拿不到就用时间 ^ pid 播种的伪随机兜底 —— 它只用来避免文件名撞车, 不是密钥. + * n 个 [a-z0-9] 字符 + NUL 结尾 (out 至少 n+1 字节). 优先 /dev/urandom, + * 拿不到就用时间 ^ pid 播种的伪随机兜底 —— 它只用来避免文件名撞车, 不是密钥. * 存档名与图片备份名共用这一处 (以前各写了一份, 连退化种子都各写一遍). */ void random_alnum(char *out, size_t n); @@ -91,21 +91,21 @@ char *base64_encode(const unsigned char *data, size_t n); /* ---- UTF-8 与终端显示宽度 ---- */ -/* 解码一个码点, *adv 为消耗的字节数 (输入非法时退化为 1 字节). - * 注意它是**宽松**的: 不校验续字节格式、过长编码、代理区, 只按首字节定长度. +/* 解码一个码点, *adv 为消耗的字节数 (输入非法时退化为 1 字节). + * 注意它是**宽松**的: 不校验续字节格式、过长编码、代理区, 只按首字节定长度. * 想判断"这段字节是不是合法 UTF-8"要用下面的 utf8_seq_len. */ unsigned utf8_decode(const char *s, size_t len, size_t *adv); -/* s 处是否是一个**合法** UTF-8 序列: 合法返回序列长度 (1-4), 非法/被截断返回 0. +/* s 处是否是一个**合法** UTF-8 序列: 合法返回序列长度 (1-4), 非法/被截断返回 0. * 比 utf8_decode 严格 —— 它另外拒绝: 续字节格式不对、过长编码、UTF-16 代理区 - * (U+D800..U+DFFF)、以及超出 U+10FFFF 的编码. 这些在 JSON 里同样是非法的, + * (U+D800..U+DFFF)、以及超出 U+10FFFF 的编码. 这些在 JSON 里同样是非法的, * 所以序列化前必须按这个判据过一遍. */ -size_t utf8_seq_len(const char *s, size_t len); +size_t utf8_seq_len(const char *s, size_t len); /* 近似 wcwidth: 东亚宽字符返回 2, 控制字符返回 0 */ -int cp_width(unsigned cp); +int cp_width(unsigned cp); /* 从 i 处前进/后退一个字符, 返回新下标 (不越界) */ -size_t utf8_next(const char *s, size_t n, size_t i); -size_t utf8_prev(const char *s, size_t n, size_t i); +size_t utf8_next(const char *s, size_t n, size_t i); +size_t utf8_prev(const char *s, size_t n, size_t i); /* 整段文本占用的终端列数 */ -size_t display_width(const char *s, size_t n); +size_t display_width(const char *s, size_t n); #endif /* LIGENT_UTIL_H */ diff --git a/src/version.c b/src/version.c index 64a8eb2..e6108a0 100644 --- a/src/version.c +++ b/src/version.c @@ -4,8 +4,8 @@ #include #include -/* 长度账 (最坏情况都要放得下, 否则 gcc 会报 -Wformat-truncation, - * 而那个警告是对的 —— 截断的版本串比报错更糟): +/* 长度账 (最坏情况都要放得下, 否则 gcc 会报 -Wformat-truncation, + * 而那个警告是对的 —— 截断的版本串比报错更糟): * 主体 2026.09.22-abcdef7.dirty 25 * 分支 LIGENT_BRANCH_MAX 40 (见下, 截断) * 时间 2026-09-22 04:32:50 19 @@ -20,7 +20,8 @@ * 因为 where[] 和 buf_short[] 的尺寸加起来就已经超过 320 了. 让编译器能自己 * 算出"装得下", 比加大数字再赌一把可靠. */ #define LIGENT_BRANCH_MAX 40 -/* 编译器串和平台串的缓冲尺寸 (在 ligent_compiler_string/platform_string 里用) */ +/* 编译器串和平台串的缓冲尺寸 (在 ligent_compiler_string/platform_string 里用) + */ #define LIGENT_COMPILER_MAX 96 #define LIGENT_PLATFORM_MAX 64 /* 主体: CalVer(10) + "-" + hash(7) + ".dirty"(6) + 余量 = 160 够宽松 */ @@ -32,8 +33,9 @@ /* where = branch + ", " + date(10) + " " + time(8) */ #define LIGENT_WHERE_BUF (LIGENT_BRANCH_BUF + 32) /* full = 主体 + " (" + where + ") [" + 编译器 + "] on " + 平台 */ -#define LIGENT_FULL_BUF (LIGENT_VERBODY_MAX + LIGENT_WHERE_BUF + \ - LIGENT_COMPILER_MAX + LIGENT_PLATFORM_MAX + 16) +#define LIGENT_FULL_BUF \ + (LIGENT_VERBODY_MAX + LIGENT_WHERE_BUF + LIGENT_COMPILER_MAX + \ + LIGENT_PLATFORM_MAX + 16) static char buf_short[LIGENT_VERBODY_MAX]; static char buf_full[LIGENT_FULL_BUF]; @@ -43,13 +45,12 @@ static char buf_full[LIGENT_FULL_BUF]; /* ------------------------------------------------------------------ */ /* * 顺序有讲究: clang 也定义 __GNUC__ (它假装自己是 gcc 4.2), 所以 clang 必须 - * 排在 GCC 前面判断. tcc 不定义 __GNUC__, 放哪都行, 但放在一起更清楚. + * 排在 GCC 前面判断. tcc 不定义 __GNUC__, 放哪都行, 但放在一起更清楚. * - * tcc 只给一个整数 __TINYC__ (0.9.28 -> 928, 也就是去掉小数点当十进制读). - * 拼出来是 "tcc 0.9.28", 比原样输出一个 928 有用. + * tcc 只给一个整数 __TINYC__ (0.9.28 -> 928, 也就是去掉小数点当十进制读). + * 拼出来是 "tcc 0.9.28", 比原样输出一个 928 有用. */ -static const char *compiler_name(void) -{ +static const char *compiler_name(void) { #if defined(__clang__) return "Clang"; #elif defined(__TINYC__) @@ -61,32 +62,32 @@ static const char *compiler_name(void) #endif } -static const char *compiler_version(void) -{ +static const char *compiler_version(void) { #if defined(__clang__) /* clang 的 __clang_version__ 是 "22.1.8 " —— **带一个尾随空格** (它是从 * 某个更长的串里切出来的). 原样用会在版本号里留下 "Clang 22.1.8 ]" * 这种双空格, 所以掐掉. 不用 __VERSION__ 是因为各家格式不统一 - * (clang 给 "Clang 22.1.8", gcc 给 "16.2.1 20260810"), + * (clang 给 "Clang 22.1.8", gcc 给 "16.2.1 20260810"), * 用 __clang_version__ 再自己 trim 更可控. */ static char buf[LIGENT_COMPILER_MAX]; snprintf(buf, sizeof buf, "%s", __clang_version__); size_t n = strlen(buf); - while (n > 0 && (buf[n - 1] == ' ' || buf[n - 1] == '\t')) buf[--n] = '\0'; + while (n > 0 && (buf[n - 1] == ' ' || buf[n - 1] == '\t')) + buf[--n] = '\0'; return buf; #elif defined(__TINYC__) - /* tcc 没有 __VERSION__, 只有整数 __TINYC__. - * 编码方式是**去掉小数点当十进制读**: 0.9.28 -> 928. - * 所以主版本要 /1000 而不是 /100: + /* tcc 没有 __VERSION__, 只有整数 __TINYC__. + * 编码方式是**去掉小数点当十进制读**: 0.9.28 -> 928. + * 所以主版本要 /1000 而不是 /100: * 928 / 1000 = 0 <- 对 * 928 / 100 = 9 <- 错 (第一版就是这么写的, 输出成 "tcc 9.2.8") * 这对 1000 以上也成立 (1.0.0 -> 1000, 1000/1000 = 1). */ static char buf[LIGENT_COMPILER_MAX]; - snprintf(buf, sizeof buf, "%d.%d.%d", - __TINYC__ / 1000, (__TINYC__ / 100) % 10, __TINYC__ % 100); + snprintf(buf, sizeof buf, "%d.%d.%d", __TINYC__ / 1000, + (__TINYC__ / 100) % 10, __TINYC__ % 100); return buf; #elif defined(__GNUC__) - return __VERSION__; /* gcc: "16.2.1 20260810" */ + return __VERSION__; /* gcc: "16.2.1 20260810" */ #else return "unknown"; #endif @@ -95,8 +96,7 @@ static const char *compiler_version(void) /* ------------------------------------------------------------------ */ /* 目标平台 */ /* ------------------------------------------------------------------ */ -static const char *os_name(void) -{ +static const char *os_name(void) { #if defined(__linux__) return "linux"; #elif defined(__CYGWIN__) @@ -116,8 +116,7 @@ static const char *os_name(void) #endif } -static const char *arch_name(void) -{ +static const char *arch_name(void) { #if defined(__x86_64__) || defined(_M_X64) return "x86_64"; #elif defined(__i386__) || defined(_M_IX86) @@ -135,15 +134,13 @@ static const char *arch_name(void) #endif } -const char *ligent_compiler_string(void) -{ +const char *ligent_compiler_string(void) { static char buf[LIGENT_COMPILER_MAX]; snprintf(buf, sizeof buf, "%s %s", compiler_name(), compiler_version()); return buf; } -const char *ligent_platform_string(void) -{ +const char *ligent_platform_string(void) { static char buf[LIGENT_PLATFORM_MAX]; snprintf(buf, sizeof buf, "%s/%s", os_name(), arch_name()); return buf; @@ -153,9 +150,7 @@ const char *ligent_platform_string(void) /* 版本号 */ /* ------------------------------------------------------------------ */ - -const char *ligent_version_string(void) -{ +const char *ligent_version_string(void) { /* 格式: -[.dirty] * hash 为 "unknown" 时直接省掉它 */ if (strcmp(LIGENT_GIT_HASH, "unknown") == 0) @@ -167,8 +162,7 @@ const char *ligent_version_string(void) } /* 截断分支名以免分支名超限溢出 */ -static void branch_display(char *out, size_t n) -{ +static void branch_display(char *out, size_t n) { size_t len = strlen(LIGENT_GIT_BRANCH); if (len <= LIGENT_BRANCH_MAX) { snprintf(out, n, "%s", LIGENT_GIT_BRANCH); @@ -177,11 +171,10 @@ static void branch_display(char *out, size_t n) snprintf(out, n, "%.*s...", LIGENT_BRANCH_MAX, LIGENT_GIT_BRANCH); } -const char *ligent_version_full(void) -{ +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); @@ -190,13 +183,13 @@ const char *ligent_version_full(void) if (strcmp(LIGENT_BUILD_TIME, "unknown") == 0) snprintf(where, sizeof where, "%s, %s", branch, LIGENT_BUILD_DATE); else - snprintf(where, sizeof where, "%s, %s %s", branch, - LIGENT_BUILD_DATE, LIGENT_BUILD_TIME); + snprintf(where, sizeof where, "%s, %s %s", branch, LIGENT_BUILD_DATE, + LIGENT_BUILD_TIME); - /* 整体: + /* 整体: * ligent 2026.09.22-209538e (master, 2026-09-22 04:32:04) * [GCC 16.2.1 20260810] on linux/x86_64 */ - snprintf(buf_full, sizeof buf_full, "%s (%s) [%s] on %s", - v, where, ligent_compiler_string(), ligent_platform_string()); + snprintf(buf_full, sizeof buf_full, "%s (%s) [%s] on %s", v, where, + ligent_compiler_string(), ligent_platform_string()); return buf_full; } diff --git a/src/version.h b/src/version.h index fe03779..3a7846f 100644 --- a/src/version.h +++ b/src/version.h @@ -1,27 +1,27 @@ /* version.h - 版本号与构建信息 * - * 版本号由**构建系统注入**, 不写死在源码里: + * 版本号由**构建系统注入**, 不写死在源码里: * * LIGENT_VERSION CalVer (YYYY.MM.DD), 来自 git tag; 没有 tag 时用 * 源码里这个回退值 - * LIGENT_GIT_HASH git 短哈希; 工作区脏了带 ".dirty"; 非 git 构建为 "unknown" - * LIGENT_GIT_BRANCH 分支名; detached HEAD 以及非 git 构建为 "unknown" - * LIGENT_BUILD_DATE 构建日期 (UTC, YYYY-MM-DD) - * LIGENT_BUILD_TIME 构建时间 (UTC, HH:MM:SS) + * LIGENT_GIT_HASH git 短哈希; 工作区脏了带 ".dirty"; 非 git 构建为 + * "unknown" LIGENT_GIT_BRANCH 分支名; detached HEAD 以及非 git 构建为 + * "unknown" LIGENT_BUILD_DATE 构建日期 (UTC, YYYY-MM-DD) LIGENT_BUILD_TIME + * 构建时间 (UTC, HH:MM:SS) * - * 编译器与目标平台**不走注入**, 在 version.c 里用编译期宏判断 (见下): + * 编译器与目标平台**不走注入**, 在 version.c 里用编译期宏判断 (见下): * 那两项是"这份代码被什么编出来、给谁跑的", 编译器自己最清楚, 让构建脚本去猜 - * 反而多一个出错点. + * 反而多一个出错点. * * 为什么不让源码当"唯一真相": 那样每发一次版就要改一次源码、多一个提交, 而且 * 从 tarball 还是从 git 编出来的版本号会不一致. 让构建期算出来、编进去, 源码 * 只留一个**回退默认值** (给"没有构建系统"的场景, 比如有人直接拿 gcc 编 - * src 下的所有 .c), 这是 dunst/curl 这类项目的通行做法. + * src 下的所有 .c), 这是 dunst/curl 这类项目的通行做法. * - * 为什么是 CalVer 而不是 SemVer: ligent 是个终端工具, 不是被别人链接的库. + * 为什么是 CalVer 而不是 SemVer: ligent 是个终端工具, 不是被别人链接的库. * CalVer 表达的是"这是哪天的东西", 对一个还在快速演进的工具比"第几个大版本" * 有用得多 —— 用户看到 2026.02.14 立刻知道新旧, 看到 1.4.2 还得去查. 真到了 - * 需要表达"破坏性变更"的时候, git 历史和 README 比版本号数字更能说清楚. + * 需要表达"破坏性变更"的时候, git 历史和 README 比版本号数字更能说清楚. */ #ifndef LIGENT_VERSION_H #define LIGENT_VERSION_H @@ -29,23 +29,23 @@ /* 回退值: 只在没有通过 -D 注入时生效 (直接拿 gcc 编、或从没有 .git 的 * tarball 编). 改这个值等于"手动打了一个版本", 正常发布流程不该动它. */ #ifndef LIGENT_VERSION -# define LIGENT_VERSION "0.0.0-unknown" +#define LIGENT_VERSION "0.0.0-unknown" #endif #ifndef LIGENT_GIT_HASH -# define LIGENT_GIT_HASH "unknown" +#define LIGENT_GIT_HASH "unknown" #endif #ifndef LIGENT_GIT_BRANCH -# define LIGENT_GIT_BRANCH "unknown" +#define LIGENT_GIT_BRANCH "unknown" #endif #ifndef LIGENT_BUILD_DATE -# define LIGENT_BUILD_DATE "unknown" +#define LIGENT_BUILD_DATE "unknown" #endif #ifndef LIGENT_BUILD_TIME -# define LIGENT_BUILD_TIME "unknown" +#define LIGENT_BUILD_TIME "unknown" #endif /* ------------------------------------------------------------------ */ @@ -54,11 +54,11 @@ /* * 为什么要区分这么多分支: __VERSION__ 是 gcc/clang 的约定, **tcc 没有这个宏** * (它只有 __TINYC__ 加一个数字), 而 tcc 是这个项目明确支持的编译器之一 - * (make CC=tcc 是过全套测试的). 所以 tcc 那条腿得单独拼. + * (make CC=tcc 是过全套测试的). 所以 tcc 那条腿得单独拼. * * 平台同理用 __linux__ / __APPLE__ 这些宏, **不用 uname()**: 宏描述的是 * "这份二进制是给哪个平台编的", uname() 描述的是"现在跑在哪" —— 静态产物 - * 会被拷到别的机器上跑, 交叉编译时两者也不一致. 版本串里该记的是前者. + * 会被拷到别的机器上跑, 交叉编译时两者也不一致. 版本串里该记的是前者. */ /* 编译器标识, 例如 "GCC 16.2.1 20260810" / "Clang 22.1.8" / "tcc 0.9.28" */ @@ -71,9 +71,9 @@ const char *ligent_platform_string(void); * hash 未知时只有 CalVer. */ const char *ligent_version_string(void); -/* 完整一行, 给 --version 和 doctor 用: - * ligent 2026.09.22-209538e (master, 2026-09-22 04:32:04) [GCC 16.2.1] on linux/x86_64 - * 分配在静态缓冲里, 第二次调用会覆盖第一次的结果 —— 只给打印用. */ +/* 完整一行, 给 --version 和 doctor 用: + * ligent 2026.09.22-209538e (master, 2026-09-22 04:32:04) [GCC 16.2.1] on + * linux/x86_64 分配在静态缓冲里, 第二次调用会覆盖第一次的结果 —— 只给打印用. */ const char *ligent_version_full(void); #endif /* LIGENT_VERSION_H */ diff --git a/tests/fuzz.c b/tests/fuzz.c index 78d17b9..2452a61 100644 --- a/tests/fuzz.c +++ b/tests/fuzz.c @@ -1,9 +1,9 @@ /* fuzz.c - 对抗性模糊测试: JSON 解析器 + Markdown 渲染器 * - * 这两个模块是"解析不可信输入"的地方 (服务端响应、模型输出), - * 也是最可能出崩溃/越界/泄漏的地方. 所以用随机输入去砸它们. + * 这两个模块是"解析不可信输入"的地方 (服务端响应、模型输出), + * 也是最可能出崩溃/越界/泄漏的地方. 所以用随机输入去砸它们. * - * 检查的不变量: + * 检查的不变量: * JSON * 1. 任何输入都不能崩溃、不能泄漏 (靠 ASan/UBSan/LeakSanitizer) * 2. 解析成功 => dump 再 parse 再 dump, 两次 dump 必须一致 (幂等) @@ -19,15 +19,14 @@ #include "markdown.h" #include "util.h" +#include #include #include #include -#include static uint64_t RNG; -static uint32_t rnd(void) -{ +static uint32_t rnd(void) { RNG ^= RNG << 13; RNG ^= RNG >> 7; RNG ^= RNG << 17; @@ -41,13 +40,24 @@ static uint32_t rnd_below(uint32_t n) { return n ? rnd() % n : 0; } /* ------------------------------------------------------------------ */ static const char *STR_POOL[] = { - "", "a", "hello", "中文", "😀", "line\nbreak", "tab\there", "quote\"x", - "back\\slash", "ctrl\x01\x1f", "surrogate\u00e9", "aaaaaaaaaaaaaaaaaaaa", - "/", "\x7f", "\xc3\xa9\xe4\xb8\xad", + "", + "a", + "hello", + "中文", + "😀", + "line\nbreak", + "tab\there", + "quote\"x", + "back\\slash", + "ctrl\x01\x1f", + "surrogate\u00e9", + "aaaaaaaaaaaaaaaaaaaa", + "/", + "\x7f", + "\xc3\xa9\xe4\xb8\xad", }; -static void gen_json_string(StrBuf *sb) -{ +static void gen_json_string(StrBuf *sb) { const char *s = STR_POOL[rnd_below(sizeof STR_POOL / sizeof STR_POOL[0])]; char *q = json_quote_string(s); sb_puts(sb, q); @@ -55,27 +65,37 @@ static void gen_json_string(StrBuf *sb) } /* 递归生成合法 JSON */ -static void gen_json(StrBuf *sb, int depth) -{ +static void gen_json(StrBuf *sb, int depth) { uint32_t kind = rnd_below(depth > 3 ? 4 : 6); switch (kind) { - case 0: sb_puts(sb, "null"); break; - case 1: sb_puts(sb, rnd() & 1 ? "true" : "false"); break; + case 0: + sb_puts(sb, "null"); + break; + case 1: + sb_puts(sb, rnd() & 1 ? "true" : "false"); + break; case 2: { char buf[64]; int mode = (int)rnd_below(3); - if (mode == 0) snprintf(buf, sizeof buf, "%d", (int)rnd() - 1000); - else if (mode == 1) snprintf(buf, sizeof buf, "%.6g", (double)rnd() / 7.0); - else snprintf(buf, sizeof buf, "%de%d", (int)rnd_below(1000), (int)rnd_below(20) - 10); + if (mode == 0) + snprintf(buf, sizeof buf, "%d", (int)rnd() - 1000); + else if (mode == 1) + snprintf(buf, sizeof buf, "%.6g", (double)rnd() / 7.0); + else + snprintf(buf, sizeof buf, "%de%d", (int)rnd_below(1000), + (int)rnd_below(20) - 10); sb_puts(sb, buf); break; } - case 3: gen_json_string(sb); break; + case 3: + gen_json_string(sb); + break; case 4: { sb_putc(sb, '['); uint32_t n = rnd_below(5); for (uint32_t i = 0; i < n; i++) { - if (i) sb_putc(sb, ','); + if (i) + sb_putc(sb, ','); gen_json(sb, depth + 1); } sb_putc(sb, ']'); @@ -85,7 +105,8 @@ static void gen_json(StrBuf *sb, int depth) sb_putc(sb, '{'); uint32_t n = rnd_below(5); for (uint32_t i = 0; i < n; i++) { - if (i) sb_putc(sb, ','); + if (i) + sb_putc(sb, ','); gen_json_string(sb); sb_putc(sb, ':'); gen_json(sb, depth + 1); @@ -97,8 +118,7 @@ static void gen_json(StrBuf *sb, int depth) } /* 返回 1 表示这一轮失败 */ -static int fuzz_json_once(char *err, size_t errlen) -{ +static int fuzz_json_once(char *err, size_t errlen) { StrBuf sb; sb_init(&sb); @@ -107,36 +127,43 @@ static int fuzz_json_once(char *err, size_t errlen) /* 变异模式: 先造合法 JSON, 再随机破坏 */ gen_json(&sb, 0); size_t n = sb.len; - if (n == 0) { sb_free(&sb); return 0; } - /* 每次突变都必须基于"当前"长度. 早期版本用了突变前记下的旧长度, - * 截断之后再按下标写就会覆盖掉结尾的 '\0', 后续 strlen 冲出分配区, - * 让 ASan 在 json_parse 里报出假阳性 —— 模糊测试器自己不可信比没有更糟. */ + if (n == 0) { + sb_free(&sb); + return 0; + } + /* 每次突变都必须基于"当前"长度. 早期版本用了突变前记下的旧长度, + * 截断之后再按下标写就会覆盖掉结尾的 '\0', 后续 strlen 冲出分配区, + * 让 ASan 在 json_parse 里报出假阳性 —— 模糊测试器自己不可信比没有更糟. + */ uint32_t muts = 1 + rnd_below(4); for (uint32_t i = 0; i < muts && sb.len > 0; i++) { uint32_t op = rnd_below(3); - if (op == 0) { /* 翻一个字节 (下标恒 < len) */ + if (op == 0) { /* 翻一个字节 (下标恒 < len) */ sb.data[rnd_below((uint32_t)sb.len)] = (char)(rnd() & 0xFF); - } else if (op == 1) { /* 截断 */ + } else if (op == 1) { /* 截断 */ sb.len = rnd_below((uint32_t)sb.len); - } else { /* 插入一个字节 */ + } else { /* 插入一个字节 */ if (sb.len < 4096) { size_t pos = rnd_below((uint32_t)sb.len + 1); sb_reserve(&sb, 1); memmove(sb.data + pos + 1, sb.data + pos, sb.len - pos + 1); - sb.data[pos] = (char)"{}[]\",:0123456789.eE+-truefalsn"[rnd_below(30)]; + sb.data[pos] = + (char)"{}[]\",:0123456789.eE+-truefalsn"[rnd_below(30)]; sb.len++; } } } /* 无论怎么突变, 结尾符必须是好的 */ - if (sb.data) sb.data[sb.len] = '\0'; + if (sb.data) + sb.data[sb.len] = '\0'; n = sb.len; } else { /* 纯随机字节 */ uint32_t n = rnd_below(256); for (uint32_t i = 0; i < n; i++) { /* 偏向 JSON 元字符, 提高触发率 */ - static const char meta[] = "{}[]\",:0123456789.eE+-truefalsn \t\n\\/u"; + static const char meta[] = + "{}[]\",:0123456789.eE+-truefalsn \t\n\\/u"; char c = (rnd() & 3) ? meta[rnd_below(sizeof meta - 1)] : (char)(rnd() & 0xFF); sb_putc(&sb, c); @@ -175,14 +202,19 @@ static int fuzz_json_once(char *err, size_t errlen) } d2 = json_dump(v2); if (strcmp(d1, d2) != 0) { - snprintf(err, errlen, "dump 不幂等: \n 输入 %.200s\n 一次 %.200s\n 二次 %.200s", - input, d1, d2); - free(d1); free(d2); - json_free(v); json_free(v2); + snprintf( + err, errlen, + "dump 不幂等: \n 输入 %.200s\n 一次 %.200s\n 二次 %.200s", + input, d1, d2); + free(d1); + free(d2); + json_free(v); + json_free(v2); sb_free(&sb); return 1; } - free(d1); free(d2); + free(d1); + free(d2); json_free(v2); json_free(v); } @@ -197,12 +229,12 @@ static int fuzz_json_once(char *err, size_t errlen) #define MD_RENDER_LIMIT (1u << 20) /* 渲染到内存; chunk==0 表示一次性 */ -static char *md_render(const char *src, size_t len, size_t chunk) -{ +static char *md_render(const char *src, size_t len, size_t chunk) { char *buf = NULL; size_t size = 0; FILE *f = open_memstream(&buf, &size); - if (!f) return NULL; + if (!f) + return NULL; MdRenderer *r = md_new(1); md_set_out(r, f); @@ -222,16 +254,14 @@ static char *md_render(const char *src, size_t len, size_t chunk) return buf; } -static int markdown_metachar(int c) -{ +static int markdown_metachar(int c) { /* 这些字符会触发渲染器改写输出; 其余字符必须原样通过 */ return c == '*' || c == '_' || c == '`' || c == '[' || c == ']' || c == '#' || c == '>' || c == '-' || c == '+' || c == '|' || c == '\n' || c == '\r'; } -static int fuzz_md_once(char *err, size_t errlen) -{ +static int fuzz_md_once(char *err, size_t errlen) { StrBuf sb; sb_init(&sb); @@ -240,7 +270,7 @@ static int fuzz_md_once(char *err, size_t errlen) size_t alen = sizeof alpha - 1; uint32_t n = rnd_below(400); - int pure = (int)rnd_below(4) == 0; /* 1/4 概率生成"无元字符"的干净文本 */ + int pure = (int)rnd_below(4) == 0; /* 1/4 概率生成"无元字符"的干净文本 */ for (uint32_t i = 0; i < n; i++) { char c; if (pure) { @@ -249,41 +279,57 @@ static int fuzz_md_once(char *err, size_t errlen) } while (markdown_metachar((unsigned char)c) || c == '\0'); } else { uint32_t mode = rnd_below(10); - if (mode < 7) c = alpha[rnd_below((uint32_t)alen)]; - else if (mode < 9) c = (char)(0x20 + rnd_below(0x5F)); - else c = (char)(rnd() & 0xFF); /* 偶尔来点非法 UTF-8 */ + if (mode < 7) + c = alpha[rnd_below((uint32_t)alen)]; + else if (mode < 9) + c = (char)(0x20 + rnd_below(0x5F)); + else + c = (char)(rnd() & 0xFF); /* 偶尔来点非法 UTF-8 */ } - if (c == '\0') c = 'x'; + if (c == '\0') + c = 'x'; sb_putc(&sb, c); } const char *src = sb.data ? sb.data : ""; size_t len = sb.len; - if (len == 0) { sb_free(&sb); return 0; } + if (len == 0) { + sb_free(&sb); + return 0; + } char *whole = md_render(src, len, 0); - if (!whole) { sb_free(&sb); return 0; } + if (!whole) { + sb_free(&sb); + return 0; + } if (strlen(whole) > MD_RENDER_LIMIT * 4) { - snprintf(err, errlen, "输出异常膨胀: 输入 %zu 字节 => %zu 字节", - len, strlen(whole)); - free(whole); sb_free(&sb); + snprintf(err, errlen, "输出异常膨胀: 输入 %zu 字节 => %zu 字节", len, + strlen(whole)); + free(whole); + sb_free(&sb); return 1; } /* 分片不变性 */ size_t chunks[4]; - for (int k = 0; k < 4; k++) chunks[k] = 1 + rnd_below(8); + for (int k = 0; k < 4; k++) + chunks[k] = 1 + rnd_below(8); for (int k = 0; k < 4; k++) { char *part = md_render(src, len, chunks[k]); - if (!part) continue; + if (!part) + continue; if (strcmp(whole, part) != 0) { - snprintf(err, errlen, - "分片不变性被破坏 (%zu 字节/片)", - chunks[k]); + snprintf(err, errlen, "分片不变性被破坏 (%zu 字节/片)", chunks[k]); /* 落盘, 方便用 build/reduce 做最小化 */ FILE *fp = fopen("build/md_fail.bin", "wb"); - if (fp) { fwrite(src, 1, len, fp); fclose(fp); } - free(part); free(whole); sb_free(&sb); + if (fp) { + fwrite(src, 1, len, fp); + fclose(fp); + } + free(part); + free(whole); + sb_free(&sb); return 1; } free(part); @@ -292,9 +338,11 @@ static int fuzz_md_once(char *err, size_t errlen) /* 无元字符 => 必须逐字节原样 */ if (pure) { if (strcmp(whole, src) != 0) { - snprintf(err, errlen, "纯净文本被改写了: \n 输入 %.300s\n 输出 %.300s", - src, whole); - free(whole); sb_free(&sb); + snprintf(err, errlen, + "纯净文本被改写了: \n 输入 %.300s\n 输出 %.300s", src, + whole); + free(whole); + sb_free(&sb); return 1; } } @@ -306,23 +354,24 @@ static int fuzz_md_once(char *err, size_t errlen) /* ------------------------------------------------------------------ */ -int main(int argc, char **argv) -{ +int main(int argc, char **argv) { long iters = (argc > 1) ? strtol(argv[1], NULL, 10) : 3000; RNG = (argc > 2) ? strtoull(argv[2], NULL, 10) : 0x9E3779B97F4A7C15ULL; - if (!RNG) RNG = 1; + if (!RNG) + RNG = 1; char err[512]; long json_fail = 0, md_fail = 0; - printf("模糊测试: JSON %ld 轮 + Markdown %ld 轮 (种子 %llu)\n", - iters, iters, (unsigned long long)RNG); + printf("模糊测试: JSON %ld 轮 + Markdown %ld 轮 (种子 %llu)\n", iters, + iters, (unsigned long long)RNG); for (long i = 0; i < iters; i++) { err[0] = '\0'; if (fuzz_json_once(err, sizeof err)) { printf(" ✗ JSON 第 %ld 轮失败: %s\n", i, err); - if (++json_fail >= 3) break; + if (++json_fail >= 3) + break; } } printf(" JSON %ld 轮, %ld 次失败\n", iters, json_fail); @@ -331,7 +380,8 @@ int main(int argc, char **argv) err[0] = '\0'; if (fuzz_md_once(err, sizeof err)) { printf(" ✗ Markdown 第 %ld 轮失败: %s\n", i, err); - if (++md_fail >= 3) break; + if (++md_fail >= 3) + break; } } printf(" Markdown %ld 轮, %ld 次失败\n", iters, md_fail); diff --git a/tests/reduce.c b/tests/reduce.c index a67e6bc..5876f90 100644 --- a/tests/reduce.c +++ b/tests/reduce.c @@ -1,6 +1,6 @@ /* reduce.c - 把 fuzz 找到的失败输入最小化到最短复现样例 * - * 判定 (oracle): 同一段输入, "一次性喂" 与 "逐字节喂" 的渲染结果不一致. + * 判定 (oracle): 同一段输入, "一次性喂" 与 "逐字节喂" 的渲染结果不一致. * 用法: build/reduce build/md_fail.bin */ #include "markdown.h" @@ -11,8 +11,7 @@ /* 注意: md_feed 只接受 NUL 结尾的字符串, 而最小化过程中的缓冲区 * 是"长度受限"的, 所以必须先做一份带结尾符的副本. */ -static char *render(const char *src, size_t len, size_t chunk) -{ +static char *render(const char *src, size_t len, size_t chunk) { char *text = xmalloc(len + 1); memcpy(text, src, len); text[len] = '\0'; @@ -20,7 +19,10 @@ static char *render(const char *src, size_t len, size_t chunk) char *buf = NULL; size_t size = 0; FILE *f = open_memstream(&buf, &size); - if (!f) { free(text); return NULL; } + if (!f) { + free(text); + return NULL; + } MdRenderer *r = md_new(1); md_set_out(r, f); if (chunk == 0) { @@ -41,43 +43,60 @@ static char *render(const char *src, size_t len, size_t chunk) } /* 只要"一次性"与"逐字节"不一致就算失败 */ -static int fails(const char *src, size_t len) -{ - if (len == 0) return 0; +static int fails(const char *src, size_t len) { + if (len == 0) + return 0; /* md_feed 以 NUL 结尾, 输入里不能有 NUL */ - for (size_t i = 0; i < len; i++) if (src[i] == '\0') return 0; + for (size_t i = 0; i < len; i++) + if (src[i] == '\0') + return 0; char *a = render(src, len, 0); char *b = render(src, len, 1); int bad = (a && b && strcmp(a, b) != 0); if (bad) { /* 确认真的不是"空输出 vs 空输出"这种假阳性 */ - if (strcmp(a, b) == 0) bad = 0; + if (strcmp(a, b) == 0) + bad = 0; } - free(a); free(b); + free(a); + free(b); return bad; } -static void show(const char *label, const char *buf, size_t len) -{ +static void show(const char *label, const char *buf, size_t len) { printf("%s (%zu 字节): ", label, len); for (size_t i = 0; i < len; i++) { unsigned char c = (unsigned char)buf[i]; - if (c == '\n') printf("\\n"); - else if (c == '\t') printf("\\t"); - else if (c == 0x1b) printf(""); - else if (c >= 0x20 && c < 0x7f) putchar(c); - else printf("\\x%02x", c); + if (c == '\n') + printf("\\n"); + else if (c == '\t') + printf("\\t"); + else if (c == 0x1b) + printf(""); + else if (c >= 0x20 && c < 0x7f) + putchar(c); + else + printf("\\x%02x", c); } putchar('\n'); } -int main(int argc, char **argv) -{ - if (argc < 2) { fputs("用法: reduce <失败输入文件>\n", stderr); return 2; } +int main(int argc, char **argv) { + if (argc < 2) { + fputs("用法: reduce <失败输入文件>\n", stderr); + return 2; + } size_t len = 0; char *buf = read_entire_file(argv[1], &len); - if (!buf) { perror("读取失败"); return 2; } - if (!fails(buf, len)) { puts("该输入当前不复现失败"); free(buf); return 1; } + if (!buf) { + perror("读取失败"); + return 2; + } + if (!fails(buf, len)) { + puts("该输入当前不复现失败"); + free(buf); + return 1; + } puts("== 原始失败输入 =="); show(" ", buf, len); @@ -85,7 +104,8 @@ int main(int argc, char **argv) char *a = render(buf, len, 0), *b = render(buf, len, 1); show(" 一次性", a, strlen(a)); show(" 逐字节", b, strlen(b)); - free(a); free(b); + free(a); + free(b); } /* 贪心块删除 */ @@ -105,7 +125,8 @@ int main(int argc, char **argv) } free(save); } - if (gran == 1) break; + if (gran == 1) + break; gran /= 2; } @@ -115,7 +136,8 @@ int main(int argc, char **argv) char *a = render(buf, len, 0), *b = render(buf, len, 1); show(" 一次性", a, strlen(a)); show(" 逐字节", b, strlen(b)); - free(a); free(b); + free(a); + free(b); } free(buf); return 0; diff --git a/tests/test_agent.c b/tests/test_agent.c index 69b3383..6dbd397 100644 --- a/tests/test_agent.c +++ b/tests/test_agent.c @@ -10,23 +10,23 @@ static int failures = 0; static int checks = 0; -#define CHECK(cond, ...) \ - do { \ - checks++; \ - if (!(cond)) { \ - failures++; \ - printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ - printf(__VA_ARGS__); \ - printf("\n"); \ - } \ +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ } while (0) -static size_t count_role(const Agent *a, const char *role) -{ +static size_t count_role(const Agent *a, const char *role) { size_t n = 0; for (size_t i = 0; i < json_array_size(a->messages); i++) { const char *r = json_get_string(json_array_get(a->messages, i), "role"); - if (r && strcmp(r, role) == 0) n++; + if (r && strcmp(r, role) == 0) + n++; } return n; } @@ -35,8 +35,7 @@ static size_t count_role(const Agent *a, const char *role) /* 上下文超限的自动恢复 */ /* ------------------------------------------------------------------ */ -static void add_msg(Agent *a, const char *role, const char *content) -{ +static void add_msg(Agent *a, const char *role, const char *content) { JsonValue *m = json_new_object(); json_object_set_string(m, "role", role); json_object_set_string(m, "content", content); @@ -44,8 +43,8 @@ static void add_msg(Agent *a, const char *role, const char *content) } /* 造一条 assistant 带 tool_calls 的消息 */ -static void add_assistant_tool_call(Agent *a, const char *id, const char *name) -{ +static void add_assistant_tool_call(Agent *a, const char *id, + const char *name) { JsonValue *m = json_new_object(); json_object_set_string(m, "role", "assistant"); json_object_set_null(m, "content"); @@ -62,8 +61,7 @@ static void add_assistant_tool_call(Agent *a, const char *id, const char *name) json_array_push(a->messages, m); } -static void add_tool_result(Agent *a, const char *id, const char *content) -{ +static void add_tool_result(Agent *a, const char *id, const char *content) { JsonValue *m = json_new_object(); json_object_set_string(m, "role", "tool"); json_object_set_string(m, "tool_call_id", id); @@ -72,20 +70,17 @@ static void add_tool_result(Agent *a, const char *id, const char *content) } /* 序列化历史, 便于断言 */ -static char *dump_history(const Agent *a) -{ - return json_dump(a->messages); -} +static char *dump_history(const Agent *a) { return json_dump(a->messages); } -/* 配对不变量: 任何 assistant(tool_calls) 后面必须**紧跟**同 id 的 tool 消息. +/* 配对不变量: 任何 assistant(tool_calls) 后面必须**紧跟**同 id 的 tool 消息. * 服务端就是这么要求的, 违反了下一轮直接 400 —— 所以每次动过历史之后都要查. */ -static void check_pairing(Agent *a) -{ +static void check_pairing(Agent *a) { size_t n = json_array_size(a->messages); for (size_t i = 0; i < n; i++) { JsonValue *m = json_array_get(a->messages, i); JsonValue *tcs = json_object_get(m, "tool_calls"); - if (!tcs) continue; + if (!tcs) + continue; const char *id = json_get_string(json_array_get(tcs, 0), "id"); CHECK(i + 1 < n, "tool_calls 之后必须有消息"); if (i + 1 < n) { @@ -97,15 +92,14 @@ static void check_pairing(Agent *a) } } -/* 一轮一轮摘下去 (/back 走的就是这条路), 历史在每一步都必须仍然自洽. +/* 一轮一轮摘下去 (/back 走的就是这条路), 历史在每一步都必须仍然自洽. * * 这里原来测的是"上下文超限自动裁剪" (agent_drop_oldest_round)—— 那套机制 * 已经**整个删掉**: 它的判据只是错误文案里的关键词, 却会不可逆地删掉最老的 * 几轮对话. 现在超限就是如实报错、历史一个字节都不动, 怎么处理由用户决定 * (/back 摘一轮、/reset 重来). 但"摘掉一整轮之后 tool 配对不能散"这条 * 不变量与由谁触发无关, 所以留着继续测. */ -static void test_take_rounds_keeps_pairing(void) -{ +static void test_take_rounds_keeps_pairing(void) { puts("逐轮摘掉之后历史仍然自洽"); Agent a; agent_init(&a); @@ -122,10 +116,11 @@ static void test_take_rounds_keeps_pairing(void) int rounds = 0; for (;;) { JsonValue *r = agent_take_last_round(&a); - if (!r) break; + if (!r) + break; json_free(r); rounds++; - check_pairing(&a); /* 每摘一轮都查一遍配对 */ + check_pairing(&a); /* 每摘一轮都查一遍配对 */ } CHECK(rounds == 4, "共应摘掉 4 轮, 实际 %d", rounds); CHECK(agent_history_len(&a) == 1, "最后应只剩 system, 实际 %zu", @@ -133,8 +128,7 @@ static void test_take_rounds_keeps_pairing(void) agent_free(&a); } -static void test_take_last_round(void) -{ +static void test_take_last_round(void) { puts("摘掉最近一轮 (/back 用)"); Agent a; agent_init(&a); @@ -146,7 +140,8 @@ static void test_take_last_round(void) add_msg(&a, "user", "U2"); add_msg(&a, "assistant", "A2"); - CHECK(agent_history_len(&a) == 7, "初始 7 条, 实际 %zu", agent_history_len(&a)); + CHECK(agent_history_len(&a) == 7, "初始 7 条, 实际 %zu", + agent_history_len(&a)); JsonValue *r = agent_take_last_round(&a); CHECK(r != NULL, "应摘到一轮"); @@ -192,8 +187,7 @@ static void test_take_last_round(void) /* ------------------------------------------------------------------ */ /* 造一条 assistant, 带 count 个工具调用 (id 依次是 c0, c1, ...) */ -static JsonValue *mk_assistant(size_t count) -{ +static JsonValue *mk_assistant(size_t count) { JsonValue *m = json_new_object(); json_object_set_string(m, "role", "assistant"); JsonValue *tcs = json_new_array(); @@ -213,8 +207,7 @@ static JsonValue *mk_assistant(size_t count) return m; } -static JsonValue *mk_tool(const char *id) -{ +static JsonValue *mk_tool(const char *id) { JsonValue *m = json_new_object(); json_object_set_string(m, "role", "tool"); json_object_set_string(m, "tool_call_id", id); @@ -222,8 +215,7 @@ static JsonValue *mk_tool(const char *id) return m; } -static JsonValue *mk_plain(const char *role, const char *text) -{ +static JsonValue *mk_plain(const char *role, const char *text) { JsonValue *m = json_new_object(); json_object_set_string(m, "role", role); json_object_set_string(m, "content", text); @@ -231,35 +223,37 @@ static JsonValue *mk_plain(const char *role, const char *text) } /* 数一数某个 assistant 之后紧跟的 tool 消息覆盖了几个 id */ -static const char *nth_role(const JsonValue *msgs, size_t i) -{ +static const char *nth_role(const JsonValue *msgs, size_t i) { return json_get_string(json_array_get((JsonValue *)msgs, i), "role"); } -static int pairing_ok(const JsonValue *msgs) -{ +static int pairing_ok(const JsonValue *msgs) { size_t n = json_array_size((JsonValue *)msgs); for (size_t i = 0; i < n; i++) { JsonValue *m = json_array_get((JsonValue *)msgs, i); const char *r = json_get_string(m, "role"); - if (!r || strcmp(r, "assistant") != 0) continue; + if (!r || strcmp(r, "assistant") != 0) + continue; JsonValue *tcs = json_object_get(m, "tool_calls"); - if (!tcs || json_array_size(tcs) == 0) continue; + if (!tcs || json_array_size(tcs) == 0) + continue; for (size_t k = 0; k < json_array_size(tcs); k++) { const char *id = json_get_string(json_array_get(tcs, k), "id"); size_t j = i + 1; int found = 0; for (; j < n && strcmp(nth_role(msgs, j), "tool") == 0; j++) if (strcmp(json_get_string(json_array_get((JsonValue *)msgs, j), - "tool_call_id"), id) == 0) found = 1; - if (!found) return 0; + "tool_call_id"), + id) == 0) + found = 1; + if (!found) + return 0; } } return 1; } -static void test_repair_pairing(void) -{ +static void test_repair_pairing(void) { int fixed = 0; puts("结构体检: 本来没问题就不该动它"); @@ -290,18 +284,19 @@ static void test_repair_pairing(void) if (r) { CHECK(pairing_ok(r), "修完之后配对必须成立"); /* 两处: 给 c1 补一条占位 (它没被回应), 再把错位的那条 c1 结果丢掉 - * (它被 user 消息隔开, 已经不在 tool 段里了 —— 留着就是孤儿). + * (它被 user 消息隔开, 已经不在 tool 段里了 —— 留着就是孤儿). * 丢掉真实结果听着可惜, 但结构坏了只能二选一: 要么丢它, 要么留一段 * 服务端必然拒绝的历史. 模型会从占位消息里知道该重跑. */ CHECK(fixed == 2, "应补 1 处、丢 1 处孤儿, 实际 %d", fixed); - CHECK(json_array_size(r) == 5, - "补一条、丢一条之后仍是 5 条, 实际 %zu", json_array_size(r)); + CHECK(json_array_size(r) == 5, "补一条、丢一条之后仍是 5 条, 实际 %zu", + json_array_size(r)); /* 占位消息必须紧跟在 assistant 的 tool 段里, 而不是被塞到最后 */ CHECK(strcmp(nth_role(r, 2), "tool") == 0 && - strcmp(nth_role(r, 3), "tool") == 0, + strcmp(nth_role(r, 3), "tool") == 0, "两条 tool 消息应紧跟在 assistant 之后"); const char *c = json_get_string(json_array_get(r, 3), "content"); - CHECK(c && strstr(c, "没有执行") == NULL && strstr(c, "没有得到结果") != NULL, + CHECK(c && strstr(c, "没有执行") == NULL && + strstr(c, "没有得到结果") != NULL, "占位消息要说明它没拿到结果, 实际 %s", c ? c : "(null)"); json_free(r); } @@ -313,7 +308,8 @@ static void test_repair_pairing(void) json_array_push(tail, mk_assistant(1)); fixed = 0; r = agent_repair_tool_pairing(tail, &fixed); - CHECK(r && pairing_ok(r) && fixed == 1, "结尾缺的也要补上 (fixed=%d)", fixed); + CHECK(r && pairing_ok(r) && fixed == 1, "结尾缺的也要补上 (fixed=%d)", + fixed); json_free(r); json_free(tail); @@ -329,7 +325,8 @@ static void test_repair_pairing(void) puts("空输入与边界"); fixed = 7; - CHECK(agent_repair_tool_pairing(NULL, &fixed) == NULL && fixed == 0, "NULL 输入"); + CHECK(agent_repair_tool_pairing(NULL, &fixed) == NULL && fixed == 0, + "NULL 输入"); JsonValue *empty = json_new_array(); CHECK(agent_repair_tool_pairing(empty, &fixed) == NULL, "空历史没什么可修"); json_free(empty); @@ -346,8 +343,7 @@ static void test_repair_pairing(void) /* 面向用户的历史口径: 不含 system */ /* ------------------------------------------------------------------ */ -static void test_conversation_len(void) -{ +static void test_conversation_len(void) { puts("历史条数: system 是配置, 不该算进对话"); Agent a; agent_init(&a); @@ -382,8 +378,7 @@ static void test_conversation_len(void) agent_free(&b); } -int main(void) -{ +int main(void) { Agent a; agent_init(&a); CHECK(a.messages && a.messages->type == JSON_ARRAY, "messages 应为数组"); @@ -397,11 +392,13 @@ int main(void) /* 关键不变量: 重复设置 system 应替换而不是追加 */ agent_add_system(&a, "S2"); - CHECK(count_role(&a, "system") == 1, "重复设置 system 不应叠加, 实际 %zu 条", - count_role(&a, "system")); - CHECK(strcmp(json_get_string(json_array_get(a.messages, 0), "content"), "S2") == 0, + CHECK(count_role(&a, "system") == 1, + "重复设置 system 不应叠加, 实际 %zu 条", count_role(&a, "system")); + CHECK(strcmp(json_get_string(json_array_get(a.messages, 0), "content"), + "S2") == 0, "新 system 应在第 0 条"); - CHECK(strcmp(json_get_string(json_array_get(a.messages, 1), "content"), "U1") == 0, + CHECK(strcmp(json_get_string(json_array_get(a.messages, 1), "content"), + "U1") == 0, "原有对话应保留"); agent_add_user(&a, "U2"); @@ -411,7 +408,8 @@ int main(void) CHECK(agent_history_len(&a) == 1, "reset 后只保留 system, 实际 %zu", agent_history_len(&a)); CHECK(count_role(&a, "system") == 1, "reset 后 system 仍在"); - CHECK(strcmp(json_get_string(json_array_get(a.messages, 0), "content"), "S2") == 0, + CHECK(strcmp(json_get_string(json_array_get(a.messages, 0), "content"), + "S2") == 0, "reset 保留的是最新 system"); /* 历史里没有 system 时 reset 应得到空历史 */ diff --git a/tests/test_archive.c b/tests/test_archive.c index e3c87c5..4e11448 100644 --- a/tests/test_archive.c +++ b/tests/test_archive.c @@ -5,49 +5,53 @@ #include #include #include -#include #include +#include static int failures = 0; static int checks = 0; -#define CHECK(cond, ...) \ - do { \ - checks++; \ - if (!(cond)) { \ - failures++; \ - printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ - printf(__VA_ARGS__); \ - printf("\n"); \ - } \ +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ } while (0) -static int is_alnum_lower(const char *s, size_t n) -{ +static int is_alnum_lower(const char *s, size_t n) { for (size_t i = 0; i < n; i++) if (!((s[i] >= 'a' && s[i] <= 'z') || (s[i] >= '0' && s[i] <= '9'))) return 0; return 1; } -int main(void) -{ +int main(void) { char tmpl[] = "/tmp/ligent_arch_XXXXXX"; char *dir = mkdtemp(tmpl); - if (!dir) { perror("mkdtemp"); return 2; } + if (!dir) { + perror("mkdtemp"); + return 2; + } char err[256]; puts("根名: 8 位小写字母数字"); char *n1 = NULL; - CHECK(archive_next_name(dir, NULL, NULL, 0, &n1, err, sizeof err) == 0, "生成失败: %s", err); + CHECK(archive_next_name(dir, NULL, NULL, 0, &n1, err, sizeof err) == 0, + "生成失败: %s", err); CHECK(n1 && strlen(n1) == ARCHIVE_ROOT_LEN, "根名应为 %d 位, 实际 %s", ARCHIVE_ROOT_LEN, n1 ? n1 : "(null)"); - CHECK(n1 && is_alnum_lower(n1, strlen(n1)), "根名字符集应为 [a-z0-9]: %s", n1); + CHECK(n1 && is_alnum_lower(n1, strlen(n1)), "根名字符集应为 [a-z0-9]: %s", + n1); free(n1); /* 空字符串与 NULL 等价: 都表示"没有来源" */ char *n2 = NULL; - CHECK(archive_next_name(dir, "", NULL, 0, &n2, err, sizeof err) == 0, "空 lineage 应可用"); + CHECK(archive_next_name(dir, "", NULL, 0, &n2, err, sizeof err) == 0, + "空 lineage 应可用"); CHECK(n2 && strlen(n2) == ARCHIVE_ROOT_LEN, "空 lineage 也应给出根名"); free(n2); @@ -57,7 +61,8 @@ int main(void) "分支生成失败: %s", err); CHECK(b && strncmp(b, "a1b2c3d4_", 9) == 0, "应以来源加下划线开头: %s", b); CHECK(b && strlen(b) == 8 + 1 + ARCHIVE_BRANCH_LEN, "分支长度不对: %s", b); - CHECK(b && is_alnum_lower(b + 9, ARCHIVE_BRANCH_LEN), "后缀字符集不对: %s", b); + CHECK(b && is_alnum_lower(b + 9, ARCHIVE_BRANCH_LEN), "后缀字符集不对: %s", + b); free(b); /* 多次生成应当不同 (随机性) */ @@ -65,17 +70,20 @@ int main(void) archive_next_name(dir, "a1b2c3d4", NULL, 0, &x1, err, sizeof err); archive_next_name(dir, "a1b2c3d4", NULL, 0, &x2, err, sizeof err); CHECK(x1 && x2 && strcmp(x1, x2) != 0, "两次生成不应相同: %s / %s", x1, x2); - free(x1); free(x2); + free(x1); + free(x2); puts("长度上限: 接不下就重新起根"); /* 边界: 来源长度 + 1 + 3 + ".json" <= 240 */ - size_t maxlineage = ARCHIVE_NAME_MAX - 1 - ARCHIVE_BRANCH_LEN - strlen(".json"); + size_t maxlineage = + ARCHIVE_NAME_MAX - 1 - ARCHIVE_BRANCH_LEN - strlen(".json"); char *ok = xmalloc(maxlineage + 1); memset(ok, 'a', maxlineage); ok[maxlineage] = '\0'; char *edge = NULL; - CHECK(archive_next_name(dir, ok, NULL, 0, &edge, err, sizeof err) == 0, "边界生成失败"); + CHECK(archive_next_name(dir, ok, NULL, 0, &edge, err, sizeof err) == 0, + "边界生成失败"); CHECK(edge && strncmp(edge, "aaa", 3) == 0, "刚好装得下时应继续接分支"); CHECK(edge && strlen(edge) + strlen(".json") <= ARCHIVE_NAME_MAX, "完整文件名不应超上限: %zu", edge ? strlen(edge) + 5 : 0); @@ -86,35 +94,40 @@ int main(void) memset(too, 'a', maxlineage + 1); too[maxlineage + 1] = '\0'; char *fresh = NULL; - CHECK(archive_next_name(dir, too, NULL, 0, &fresh, err, sizeof err) == 0, "超限生成失败"); + CHECK(archive_next_name(dir, too, NULL, 0, &fresh, err, sizeof err) == 0, + "超限生成失败"); CHECK(fresh && strlen(fresh) == ARCHIVE_ROOT_LEN, - "超限时应退回 %d 位根名, 实际 %s (%zu 位)", - ARCHIVE_ROOT_LEN, fresh ? fresh : "(null)", fresh ? strlen(fresh) : 0); + "超限时应退回 %d 位根名, 实际 %s (%zu 位)", ARCHIVE_ROOT_LEN, + fresh ? fresh : "(null)", fresh ? strlen(fresh) : 0); free(fresh); puts("前缀: 临时存档"); char *tp = NULL; - CHECK(archive_next_name(dir, "a1b2c3d4", "tmp_", 0, &tp, err, sizeof err) == 0, + CHECK(archive_next_name(dir, "a1b2c3d4", "tmp_", 0, &tp, err, sizeof err) == + 0, "带前缀生成失败"); CHECK(tp && strncmp(tp, "tmp_a1b2c3d4_", 13) == 0, "前缀应在最前面、其后才是分支名: %s", tp ? tp : "(null)"); free(tp); char *tr = NULL; - CHECK(archive_next_name(dir, NULL, "tmp_", 0, &tr, err, sizeof err) == 0, "带前缀建根失败"); + CHECK(archive_next_name(dir, NULL, "tmp_", 0, &tr, err, sizeof err) == 0, + "带前缀建根失败"); CHECK(tr && strncmp(tr, "tmp_", 4) == 0 && - strlen(tr) == 4 + ARCHIVE_ROOT_LEN, + strlen(tr) == 4 + ARCHIVE_ROOT_LEN, "新根也应带前缀: %s", tr ? tr : "(null)"); free(tr); /* 前缀要算进长度上限: 原本刚好装得下的, 加了 tmp_ 就该退回新根 */ char *edgep = NULL; - CHECK(archive_next_name(dir, ok, "tmp_", 0, &edgep, err, sizeof err) == 0, "边界失败"); + CHECK(archive_next_name(dir, ok, "tmp_", 0, &edgep, err, sizeof err) == 0, + "边界失败"); CHECK(edgep && strlen(edgep) == 4 + ARCHIVE_ROOT_LEN, "加了前缀装不下时应退回根名, 实际 %s (%zu 位)", edgep ? edgep : "(null)", edgep ? strlen(edgep) : 0); free(edgep); - free(ok); free(too); + free(ok); + free(too); puts("导出与载入往返"); JsonValue *msgs = json_new_array(); @@ -126,30 +139,38 @@ int main(void) char *path = NULL, *name = NULL; time_t t = 1700000000; CHECK(archive_export(msgs, dir, "parentname", NULL, 0, t, t + 5, - "test-model", "http://example/v1", - &path, &name, err, sizeof err) == 0, + "test-model", "http://example/v1", &path, &name, err, + sizeof err) == 0, "导出失败: %s", err); CHECK(name && strncmp(name, "parentname_", 11) == 0, "导出名应挂在来源下: %s", name ? name : "(null)"); CHECK(path && access(path, R_OK) == 0, "文件应存在: %s", path ? path : ""); - char *created = NULL, *saved = NULL, *model = NULL, *iname = NULL, *iparent = NULL; - JsonValue *back = archive_load(path, &created, &saved, &model, &iname, &iparent, - err, sizeof err); + char *created = NULL, *saved = NULL, *model = NULL, *iname = NULL, + *iparent = NULL; + JsonValue *back = archive_load(path, &created, &saved, &model, &iname, + &iparent, err, sizeof err); CHECK(back != NULL, "载入失败: %s", err); CHECK(back && json_array_size(back) == 1, "消息条数应为 1"); - CHECK(iname && name && strcmp(iname, name) == 0, "内容里的 name 应与文件名一致"); + CHECK(iname && name && strcmp(iname, name) == 0, + "内容里的 name 应与文件名一致"); CHECK(iparent && strcmp(iparent, "parentname") == 0, "内容里的 parent 应记下来源: %s", iparent ? iparent : "(null)"); CHECK(model && strcmp(model, "test-model") == 0, "model 应往返"); - CHECK(created && strstr(created, "2023-") != NULL, - "created 应可读: %s", created ? created : "(null)"); - /* saved 是"这份存档写出时的时间", 与 created (这段对话的开始时间)是两回事 */ - CHECK(saved && strstr(saved, "2023-") != NULL, - "saved 应可读: %s", saved ? saved : "(null)"); + CHECK(created && strstr(created, "2023-") != NULL, "created 应可读: %s", + created ? created : "(null)"); + /* saved 是"这份存档写出时的时间", 与 created (这段对话的开始时间)是两回事 + */ + CHECK(saved && strstr(saved, "2023-") != NULL, "saved 应可读: %s", + saved ? saved : "(null)"); - json_free(back); json_free(msgs); - free(created); free(saved); free(model); free(iname); free(iparent); + json_free(back); + json_free(msgs); + free(created); + free(saved); + free(model); + free(iname); + free(iparent); puts("列举"); ArchiveEntry *list = NULL; @@ -165,13 +186,19 @@ int main(void) /* 非 ligent 存档 (没有 format 标记)应被忽略 */ char *jd = xasprintf("%s/junk.json", dir); FILE *j2 = fopen(jd, "w"); - if (j2) { fputs("{\"hello\":1}", j2); fclose(j2); } + if (j2) { + fputs("{\"hello\":1}", j2); + fclose(j2); + } n = archive_list(dir, &list); CHECK(n == 1, "非 ligent 存档应被忽略, 实际 %zu", n); archive_list_free(list, n); - unlink(jd); free(jd); + unlink(jd); + free(jd); - unlink(path); free(path); free(name); + unlink(path); + free(path); + free(name); rmdir(dir); printf("\n%d 项检查, %d 项失败\n", checks, failures); return failures ? 1 : 0; diff --git a/tests/test_attach.c b/tests/test_attach.c index 8db06d0..c24e33a 100644 --- a/tests/test_attach.c +++ b/tests/test_attach.c @@ -1,7 +1,7 @@ /* test_attach.c - /include 的暂存、glob 展开与体积/类型判断 * - * 交互式挑选 (attach_pick)要真终端才能测, 由 e2e 第 55 段覆盖; - * 这里测的是不需要人操作的那一半. + * 交互式挑选 (attach_pick)要真终端才能测, 由 e2e 第 55 段覆盖; + * 这里测的是不需要人操作的那一半. */ #include "attach.h" #include "image.h" @@ -17,57 +17,69 @@ static int failures = 0; static int checks = 0; -#define CHECK(cond, ...) \ - do { \ - checks++; \ - if (!(cond)) { \ - failures++; \ - printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ - printf(__VA_ARGS__); \ - printf("\n"); \ - } \ +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ } while (0) -static void write_file(const char *path, const void *data, size_t n) -{ +static void write_file(const char *path, const void *data, size_t n) { FILE *f = fopen(path, "wb"); - if (!f) { perror(path); exit(2); } - if (n && fwrite(data, 1, n, f) != n) { perror("fwrite"); exit(2); } + if (!f) { + perror(path); + exit(2); + } + if (n && fwrite(data, 1, n, f) != n) { + perror("fwrite"); + exit(2); + } fclose(f); } -static const unsigned char PNG[] = { - 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 'p', 'a', 'y', 'l', 'o', 'a', 'd' -}; +static const unsigned char PNG[] = {0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, + 'p', 'a', 'y', 'l', 'o', 'a', 'd'}; /* 数一数暂存里某类块有几个 */ -static size_t count_type(const JsonValue *parts, const char *type) -{ - if (!parts) return 0; +static size_t count_type(const JsonValue *parts, const char *type) { + if (!parts) + return 0; size_t n = 0; for (size_t i = 0; i < json_array_size((JsonValue *)parts); i++) { - const char *t = json_get_string(json_array_get((JsonValue *)parts, i), "type"); - if (t && strcmp(t, type) == 0) n++; + const char *t = + json_get_string(json_array_get((JsonValue *)parts, i), "type"); + if (t && strcmp(t, type) == 0) + n++; } return n; } -/* 记一下回调被问了几次 —— 它只在遇到图片时该被问到. +/* 记一下回调被问了几次 —— 它只在遇到图片时该被问到. * 答案和计数放在一起, 以免"计数版只实现了一种答案"这种测试自己的错. */ static int ask_count; static int ask_answer; -static int counting_ask(void *ud) { (void)ud; ask_count++; return ask_answer; } +static int counting_ask(void *ud) { + (void)ud; + ask_count++; + return ask_answer; +} -int main(void) -{ +int main(void) { char tmpl[] = "/tmp/ligent_attach_XXXXXX"; char *dir = mkdtemp(tmpl); - if (!dir) { perror("mkdtemp"); return 2; } + if (!dir) { + perror("mkdtemp"); + return 2; + } /* 备份目录也指到临时目录, 别污染仓库 */ image_set_bindir(dir); - char *f_c = xasprintf("%s/a.c", dir); + char *f_c = xasprintf("%s/a.c", dir); char *f_txt = xasprintf("%s/b.txt", dir); char *f_bin = xasprintf("%s/bin.dat", dir); char *f_png = xasprintf("%s/pic.png", dir); @@ -133,24 +145,28 @@ int main(void) CHECK(added == 1, "png 被收下"); CHECK(attach_has_image(), "暂存里有图片"); parts = attach_take_parts(); - CHECK(count_type(parts, "ligent_image") == 1, "应当是一个 ligent_image 引用块"); + CHECK(count_type(parts, "ligent_image") == 1, + "应当是一个 ligent_image 引用块"); json_free(parts); puts("模型不收图时跳过图片 (回调只在遇到图片时被问)"); attach_clear(); - ask_count = 0; ask_answer = 1; + ask_count = 0; + ask_answer = 1; added = attach_glob(f_txt, counting_ask, NULL, &skipped, err, sizeof err); CHECK(added == 1, "纯文本照带"); CHECK(ask_count == 0, "纯文本不该去问'能不能收图' (问了一次就是白探测)"); attach_clear(); - ask_count = 0; ask_answer = 0; + ask_count = 0; + ask_answer = 0; added = attach_glob(f_png, counting_ask, NULL, &skipped, err, sizeof err); CHECK(added == 0 && skipped == 1, "回调说不行就跳过"); CHECK(ask_count == 1, "遇到图片时问了一次, 实际 %d", ask_count); puts("匹配不到 / 参数非法"); attach_clear(); - added = attach_glob("/tmp/绝对不存在的模式*.zzz", NULL, NULL, &skipped, err, sizeof err); + added = attach_glob("/tmp/绝对不存在的模式*.zzz", NULL, NULL, &skipped, err, + sizeof err); CHECK(added == -1 && err[0], "匹配不到返回 -1 并给出原因: %s", err); added = attach_glob(NULL, NULL, NULL, &skipped, err, sizeof err); CHECK(added == -1, "NULL 模式返回 -1"); @@ -166,10 +182,15 @@ int main(void) free(pat); attach_clear(); - free(f_c); free(f_txt); free(f_bin); free(f_png); free(f_big); + free(f_c); + free(f_txt); + free(f_bin); + free(f_png); + free(f_big); image_set_bindir("."); char *cmd = xasprintf("rm -rf %s", tmpl); - if (system(cmd) != 0) { } + if (system(cmd) != 0) { + } free(cmd); printf("\n%d 项检查, %d 项失败\n", checks, failures); diff --git a/tests/test_capability.c b/tests/test_capability.c index 895adcd..cf6b5f1 100644 --- a/tests/test_capability.c +++ b/tests/test_capability.c @@ -1,13 +1,13 @@ /* test_capability.c - 能力探测的纯逻辑部分 * - * 探测序列本身 (基线 -> 合并 -> 拆开)要发 HTTP, 属于端到端范畴, + * 探测序列本身 (基线 -> 合并 -> 拆开)要发 HTTP, 属于端到端范畴, * 由 e2e 第 51 段对着 mock 覆盖: 正常模型 / 不收图片 / 不收 tools / - * 探测失败 (401)/ --no-probe 五种走法都在那儿验. + * 探测失败 (401)/ --no-probe 五种走法都在那儿验. * - * 这里只测不需要网络的: 名字映射、关闭探测时的语义、缓存查询. - * 其中"关闭探测必须给未测而不是不支持"那条是回归测试 —— 那句话看着像废话, + * 这里只测不需要网络的: 名字映射、关闭探测时的语义、缓存查询. + * 其中"关闭探测必须给未测而不是不支持"那条是回归测试 —— 那句话看着像废话, * 但 CAP_NO 恰好是 0, 而 Agent 是 memset 出来的, 忘了显式初始化就会一声不响地 - * 把所有工具摘掉. 这个 bug 真的发生过一次. + * 把所有工具摘掉. 这个 bug 真的发生过一次. */ #include "capability.h" #include "llm.h" @@ -18,19 +18,18 @@ static int failures = 0; static int checks = 0; -#define CHECK(cond, ...) \ - do { \ - checks++; \ - if (!(cond)) { \ - failures++; \ - printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ - printf(__VA_ARGS__); \ - printf("\n"); \ - } \ +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ } while (0) -int main(void) -{ +int main(void) { /* CAP_NO 必须是 0 —— Agent 是 memset 出来的, 整个 capability 模块的 * 设计都建立在"零值不能是某个真实结论"之上. 这条断言是那个 bug 的护栏. */ CHECK(CAP_NO == 0, "CAP_NO 应为 0 (零值即'不支持'是事故的根源)"); @@ -45,7 +44,7 @@ int main(void) CHECK(strcmp(cap_name(42), "未测") == 0, "越界值应落到未测"); puts("关掉探测: 一律未测, 绝不冒充结论"); - /* 用可变数组而不是字面量: LlmConfig 的这些字段是 char * (真要释放的), + /* 用可变数组而不是字面量: LlmConfig 的这些字段是 char * (真要释放的), * 而 -Wwrite-strings 下 "字面量" 是 const char * —— 直接赋值会被警告. */ char model[] = "some-model"; char base_url[] = "http://127.0.0.1:1/v1"; @@ -58,7 +57,7 @@ int main(void) cap_cache_clear(); Capabilities c; - memset(&c, 0xAB, sizeof c); /* 故意填脏, 验证会被完整覆盖 */ + memset(&c, 0xAB, sizeof c); /* 故意填脏, 验证会被完整覆盖 */ int probed = cap_get(&cfg, 0, &c, 0, NULL, 0); CHECK(probed == 0, "被禁用时不该声称探测过"); CHECK(c.probed == 0, "probed 应为 0"); diff --git a/tests/test_config.c b/tests/test_config.c index b0a99f3..51b0b27 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -1,44 +1,42 @@ /* test_config.c - 配置默认值 / 环境变量 / 配置文件覆盖 */ #include "config.h" -#include "util.h" #include "i18n.h" +#include "util.h" +#include #include #include #include -#include -#include #include +#include static int failures = 0; static int checks = 0; -#define CHECK(cond, ...) \ - do { \ - checks++; \ - if (!(cond)) { \ - failures++; \ - printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ - printf(__VA_ARGS__); \ - printf("\n"); \ - } \ +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ } while (0) -static void unset_all(void) -{ +static void unset_all(void) { const char *vars[] = { - "LIGENT_API_KEY", "DEEPSEEK_API_KEY", "OPENAI_API_KEY", - "LIGENT_BASE_URL", "LIGENT_MODEL", "LIGENT_SYSTEM", + "LIGENT_API_KEY", "DEEPSEEK_API_KEY", "OPENAI_API_KEY", + "LIGENT_BASE_URL", "LIGENT_MODEL", "LIGENT_SYSTEM", "LIGENT_TEMPERATURE", "LIGENT_MAX_TOKENS", "LIGENT_TIMEOUT", - "LIGENT_MAX_STEPS", "LIGENT_STREAM", "LIGENT_TOOLS", - "OPENAI_BASE_URL", "LIGENT_CONFIG", + "LIGENT_MAX_STEPS", "LIGENT_STREAM", "LIGENT_TOOLS", + "OPENAI_BASE_URL", "LIGENT_CONFIG", }; for (size_t i = 0; i < sizeof vars / sizeof vars[0]; i++) unsetenv(vars[i]); } -static void test_defaults(void) -{ +static void test_defaults(void) { puts("默认值"); unset_all(); LigentConfig c; @@ -55,8 +53,7 @@ static void test_defaults(void) config_free(&c); } -static void test_env_key(void) -{ +static void test_env_key(void) { puts("环境变量取 key"); unset_all(); LigentConfig c; @@ -98,8 +95,7 @@ static void test_env_key(void) config_free(&c); } -static void test_env_others(void) -{ +static void test_env_others(void) { puts("环境变量其他项"); unset_all(); LigentConfig c; @@ -132,24 +128,26 @@ static void test_env_others(void) config_free(&c); } -static void test_thinking_parse(void) -{ +static void test_thinking_parse(void) { puts("思考模式 / 级别解析"); int t = 123; CHECK(config_parse_thinking("auto", &t) == 0 && t == THINKING_AUTO, "auto"); - CHECK(config_parse_thinking("", &t) == 0 && t == THINKING_AUTO, "空串 = auto"); + CHECK(config_parse_thinking("", &t) == 0 && t == THINKING_AUTO, + "空串 = auto"); CHECK(config_parse_thinking("on", &t) == 0 && t == THINKING_ON, "on"); CHECK(config_parse_thinking("true", &t) == 0 && t == THINKING_ON, "true"); - CHECK(config_parse_thinking("enabled", &t) == 0 && t == THINKING_ON, "enabled"); + CHECK(config_parse_thinking("enabled", &t) == 0 && t == THINKING_ON, + "enabled"); CHECK(config_parse_thinking("off", &t) == 0 && t == THINKING_OFF, "off"); - CHECK(config_parse_thinking("false", &t) == 0 && t == THINKING_OFF, "false"); - CHECK(config_parse_thinking("disabled", &t) == 0 && t == THINKING_OFF, "disabled"); + CHECK(config_parse_thinking("false", &t) == 0 && t == THINKING_OFF, + "false"); + CHECK(config_parse_thinking("disabled", &t) == 0 && t == THINKING_OFF, + "disabled"); CHECK(config_parse_thinking("maybe", &t) != 0, "无法识别的值应报错"); CHECK(config_parse_thinking(NULL, &t) != 0, "NULL 应报错"); - static const char *const good[] = { - "minimal", "low", "medium", "high", "xhigh", "max", "ultra", NULL - }; + static const char *const good[] = {"minimal", "low", "medium", "high", + "xhigh", "max", "ultra", NULL}; for (size_t i = 0; good[i]; i++) CHECK(config_effort_valid(good[i]), "%s 应是合法级别", good[i]); CHECK(!config_effort_valid("huge"), "huge 不是合法级别"); @@ -161,8 +159,7 @@ static void test_thinking_parse(void) CHECK(config_thinking_name(THINKING_OFF) != NULL, "THINKING_OFF 有说明"); } -static void test_file_thinking(void) -{ +static void test_file_thinking(void) { puts("配置文件里的 thinking / reasoning_effort"); char err[256]; LigentConfig c; @@ -170,7 +167,10 @@ static void test_file_thinking(void) /* 布尔写法 */ char t1[] = "/tmp/ligent_think_XXXXXX"; int fd = mkstemp(t1); - if (fd < 0) { CHECK(0, "mkstemp 失败"); return; } + if (fd < 0) { + CHECK(0, "mkstemp 失败"); + return; + } const char *j1 = "{\"thinking\":false,\"reasoning_effort\":\"medium\"}"; ssize_t w = write(fd, j1, strlen(j1)); close(fd); @@ -192,7 +192,8 @@ static void test_file_thinking(void) close(fd); CHECK(w == (ssize_t)strlen(j2), "写临时配置失败"); config_defaults(&c); - CHECK(config_load_file(&c, t2, err, sizeof err) == 0, "加载失败: %s", err); + CHECK(config_load_file(&c, t2, err, sizeof err) == 0, "加载失败: %s", + err); CHECK(c.thinking == THINKING_ON, "thinking:\"on\" 应为开启"); config_free(&c); unlink(t2); @@ -228,8 +229,7 @@ static void test_file_thinking(void) } } -static void test_file_override(void) -{ +static void test_file_override(void) { puts("配置文件覆盖 + ${VAR} 展开"); unset_all(); setenv("MY_SECRET", "sk-from-env", 1); @@ -237,16 +237,16 @@ static void test_file_override(void) char tmpl[] = "/tmp/ligent_cfg_XXXXXX"; int fd = mkstemp(tmpl); CHECK(fd >= 0, "mkstemp 失败"); - if (fd < 0) return; - const char *json = - "{\"base_url\":\"http://localhost:11434/v1\"," - "\"api_key\":\"${MY_SECRET}\"," - "\"model\":\"qwen2.5:14b\"," - "\"temperature\":0.3," - "\"max_tokens\":4096," - "\"max_steps\":9," - "\"stream\":false," - "\"tools\":false}"; + if (fd < 0) + return; + const char *json = "{\"base_url\":\"http://localhost:11434/v1\"," + "\"api_key\":\"${MY_SECRET}\"," + "\"model\":\"qwen2.5:14b\"," + "\"temperature\":0.3," + "\"max_tokens\":4096," + "\"max_steps\":9," + "\"stream\":false," + "\"tools\":false}"; ssize_t w = write(fd, json, strlen(json)); close(fd); CHECK(w == (ssize_t)strlen(json), "写临时配置失败"); @@ -256,7 +256,8 @@ static void test_file_override(void) char err[256]; int rc = config_load_file(&c, tmpl, err, sizeof err); CHECK(rc == 0, "加载配置失败: %s", err); - CHECK(strcmp(c.base_url, "http://localhost:11434/v1") == 0, "base_url 覆盖"); + CHECK(strcmp(c.base_url, "http://localhost:11434/v1") == 0, + "base_url 覆盖"); CHECK(strcmp(c.model, "qwen2.5:14b") == 0, "model 覆盖"); CHECK(strcmp(c.api_key, "sk-from-env") == 0, "${MY_SECRET} 应被展开, 实际 [%s]", c.api_key); @@ -265,9 +266,10 @@ static void test_file_override(void) CHECK(c.max_steps == 9, "max_steps 覆盖"); CHECK(c.stream == 0, "stream 覆盖"); CHECK(c.use_tools == 0, "tools 覆盖"); - CHECK(c.config_path && strcmp(c.config_path, tmpl) == 0, "记录配置文件路径"); + CHECK(c.config_path && strcmp(c.config_path, tmpl) == 0, + "记录配置文件路径"); - /* 坏配置应报错而不是静默通过. + /* 坏配置应报错而不是静默通过. * * 解析器的报错跟着界面语言走 (json_parse_ui), 所以下面这段断语先把语言 * 钉住, 再顺手把英文那份也验一遍 —— 位置信息两种语言都得有. */ @@ -293,7 +295,7 @@ static void test_file_override(void) i18n_set_lang(LIGENT_LANG_EN); config_defaults(&c3); CHECK(config_load_file(&c3, tmpl2, err, sizeof err) != 0 && - strstr(err, "line") != NULL, + strstr(err, "line") != NULL, "英文界面下报错也带位置 (且是英文): %s", err); config_free(&c3); i18n_set_lang(LIGENT_LANG_ZH); @@ -306,8 +308,7 @@ static void test_file_override(void) } /* 配置只认二进制所在目录: 不做用户级、系统级, 也不做当前目录查找 */ -static void test_binary_dir_lookup(void) -{ +static void test_binary_dir_lookup(void) { puts("配置位置 = 二进制所在目录"); char err[256]; @@ -322,11 +323,18 @@ static void test_binary_dir_lookup(void) /* 当前目录放一份 ligent.json, 它绝不能被加载 */ char tmpl[] = "/tmp/ligent_cwd_XXXXXX"; char *tmp = mkdtemp(tmpl); - if (!tmp) { free(dir); free(canon); return; } + if (!tmp) { + free(dir); + free(canon); + return; + } char *fake = xasprintf("%s/ligent.json", tmp); FILE *f = fopen(fake, "w"); - if (f) { fputs("{\"model\":\"FROM-CWD-ONLY\"}", f); fclose(f); } + if (f) { + fputs("{\"model\":\"FROM-CWD-ONLY\"}", f); + fclose(f); + } char *saved = getcwd(NULL, 0); int loaded_from_cwd = 0; @@ -338,7 +346,8 @@ static void test_binary_dir_lookup(void) loaded_from_cwd = (c.model && strcmp(c.model, "FROM-CWD-ONLY") == 0); got_dir = c.config_dir ? xstrdup(c.config_dir) : NULL; config_free(&c); - if (saved) chdir(saved); + if (saved) + chdir(saved); } CHECK(!loaded_from_cwd, "不应加载当前工作目录下的 ligent.json"); @@ -347,7 +356,7 @@ static void test_binary_dir_lookup(void) free(got_dir); free(saved); - unlink(fake); /* 先删文件再释放路径字符串 */ + unlink(fake); /* 先删文件再释放路径字符串 */ free(fake); rmdir(tmp); free(dir); @@ -355,25 +364,27 @@ static void test_binary_dir_lookup(void) } /* 写一个临时配置文件并加载 */ -static int load_temp_config(LigentConfig *c, const char *json) -{ +static int load_temp_config(LigentConfig *c, const char *json) { char tmpl[] = "/tmp/ligent_prec_XXXXXX"; int fd = mkstemp(tmpl); - if (fd < 0) return -1; + if (fd < 0) + return -1; ssize_t w = write(fd, json, strlen(json)); close(fd); - if (w != (ssize_t)strlen(json)) { unlink(tmpl); return -1; } + if (w != (ssize_t)strlen(json)) { + unlink(tmpl); + return -1; + } char err[256]; int rc = config_load_file(c, tmpl, err, sizeof err); unlink(tmpl); return rc; } -/* 厂商专属的环境变量不能越权覆盖配置文件里写好的密钥. - * 这正是用户踩到的坑: 配置写了 OpenRouter 的 key, +/* 厂商专属的环境变量不能越权覆盖配置文件里写好的密钥. + * 这正是用户踩到的坑: 配置写了 OpenRouter 的 key, * shell 里残留的 DEEPSEEK_API_KEY 把它顶掉, 于是 A 家的密钥发给了 B 家. */ -static void test_env_key_precedence(void) -{ +static void test_env_key_precedence(void) { puts("api_key 优先级: 厂商环境变量不得越权"); unset_all(); setenv("DEEPSEEK_API_KEY", "sk-leftover-in-shell", 1); @@ -381,9 +392,9 @@ static void test_env_key_precedence(void) /* 1) 配置文件写了 key => 它就是最终值, 环境变量顶不掉 */ LigentConfig c; config_defaults(&c); - CHECK(load_temp_config(&c, - "{\"base_url\":\"https://openrouter.ai/api/v1\"," - "\"api_key\":\"sk-or-v1-from-file\"}") == 0, "加载配置失败"); + CHECK(load_temp_config(&c, "{\"base_url\":\"https://openrouter.ai/api/v1\"," + "\"api_key\":\"sk-or-v1-from-file\"}") == 0, + "加载配置失败"); config_apply_env(&c); CHECK(strcmp(c.api_key, "sk-or-v1-from-file") == 0, "配置文件里的 key 不应被残留环境变量顶掉, 实际 %s", c.api_key); @@ -391,7 +402,8 @@ static void test_env_key_precedence(void) /* 2) 配置文件没写 key + 端点就是 DeepSeek => 用 DEEPSEEK_API_KEY 兜底 */ config_defaults(&c); - CHECK(load_temp_config(&c, "{\"base_url\":\"https://api.deepseek.com/v1\"}") == 0, + CHECK(load_temp_config( + &c, "{\"base_url\":\"https://api.deepseek.com/v1\"}") == 0, "加载配置失败"); config_apply_env(&c); CHECK(strcmp(c.api_key, "sk-leftover-in-shell") == 0, @@ -400,7 +412,8 @@ static void test_env_key_precedence(void) /* 3) 配置文件没写 key + 端点是别家 => 不猜, 宁可空着让用户看到明确提示 */ config_defaults(&c); - CHECK(load_temp_config(&c, "{\"base_url\":\"https://openrouter.ai/api/v1\"}") == 0, + CHECK(load_temp_config( + &c, "{\"base_url\":\"https://openrouter.ai/api/v1\"}") == 0, "加载配置失败"); config_apply_env(&c); CHECK(c.api_key[0] == '\0', @@ -410,9 +423,9 @@ static void test_env_key_precedence(void) /* 4) LIGENT_API_KEY 是显式覆盖, 任何时候都最高 */ setenv("LIGENT_API_KEY", "sk-explicit", 1); config_defaults(&c); - CHECK(load_temp_config(&c, - "{\"base_url\":\"https://openrouter.ai/api/v1\"," - "\"api_key\":\"sk-or-v1-from-file\"}") == 0, "加载配置失败"); + CHECK(load_temp_config(&c, "{\"base_url\":\"https://openrouter.ai/api/v1\"," + "\"api_key\":\"sk-or-v1-from-file\"}") == 0, + "加载配置失败"); config_apply_env(&c); CHECK(strcmp(c.api_key, "sk-explicit") == 0, "LIGENT_API_KEY 应最高优先, 实际 %s", c.api_key); @@ -421,41 +434,48 @@ static void test_env_key_precedence(void) unset_all(); } -/* 软链接: 配置目录要跟着"用户实际调用那个文件"走. - * 否则把 ligent 软链到 ~/bin 之后, 配置会被悄悄挪到源文件所在的目录, +/* 软链接: 配置目录要跟着"用户实际调用那个文件"走. + * 否则把 ligent 软链到 ~/bin 之后, 配置会被悄悄挪到源文件所在的目录, * 用户放在 ~/bin 旁边的 ligent.json 就永远读不到. */ -static void test_symlink_dir(void) -{ +static void test_symlink_dir(void) { puts("软链接: 配置目录跟着软链接走"); char self[PATH_MAX]; ssize_t n = readlink("/proc/self/exe", self, sizeof self - 1); - if (n <= 0) { puts(" (没有 /proc/self/exe, 跳过)"); return; } + if (n <= 0) { + puts(" (没有 /proc/self/exe, 跳过)"); + return; + } self[n] = '\0'; char tmpl[] = "/tmp/ligent_link_XXXXXX"; char *dir = mkdtemp(tmpl); - if (!dir) { puts(" (mkdtemp 失败, 跳过)"); return; } + if (!dir) { + puts(" (mkdtemp 失败, 跳过)"); + return; + } char *link = xasprintf("%s/ligent", dir); if (symlink(self, link) != 0) { puts(" (symlink 失败, 跳过)"); - free(link); rmdir(dir); + free(link); + rmdir(dir); return; } /* 核心: 指向软链接时取软链接所在目录, 而不是源文件所在目录 */ char *got = config_binary_dir(link); - CHECK(got && strcmp(got, dir) == 0, - "应取软链接所在目录 %s, 实际 %s", dir, got ? got : "(null)"); + CHECK(got && strcmp(got, dir) == 0, "应取软链接所在目录 %s, 实际 %s", dir, + got ? got : "(null)"); free(got); /* 路径里的 . 应被规范化, 但最后一段仍不解析 */ char *dotted = xasprintf("%s/./ligent", dir); got = config_binary_dir(dotted); - CHECK(got && strcmp(got, dir) == 0, - "带 . 的路径应规范化, 实际 %s", got ? got : "(null)"); - free(got); free(dotted); + CHECK(got && strcmp(got, dir) == 0, "带 . 的路径应规范化, 实际 %s", + got ? got : "(null)"); + free(got); + free(dotted); /* argv[0] 只是个名字时经 $PATH 找到的仍应是软链接本身 */ char *saved = getenv("PATH") ? xstrdup(getenv("PATH")) : NULL; @@ -465,25 +485,30 @@ static void test_symlink_dir(void) CHECK(got && strcmp(got, dir) == 0, "$PATH 找到软链接时应取它所在目录, 实际 %s", got ? got : "(null)"); free(got); - if (saved) setenv("PATH", saved, 1); else unsetenv("PATH"); - free(saved); free(newpath); + if (saved) + setenv("PATH", saved, 1); + else + unsetenv("PATH"); + free(saved); + free(newpath); /* 直接用真实文件时, 取的还是真实目录 */ char *selfdir = xstrdup(self); char *slash = strrchr(selfdir, '/'); - if (slash) *slash = '\0'; + if (slash) + *slash = '\0'; got = config_binary_dir(self); - CHECK(got && strcmp(got, selfdir) == 0, - "真实文件应取真实目录 %s, 实际 %s", selfdir, got ? got : "(null)"); - free(got); free(selfdir); + CHECK(got && strcmp(got, selfdir) == 0, "真实文件应取真实目录 %s, 实际 %s", + selfdir, got ? got : "(null)"); + free(got); + free(selfdir); unlink(link); free(link); rmdir(dir); } -int main(void) -{ +int main(void) { test_defaults(); test_env_key(); test_env_others(); diff --git a/tests/test_http.c b/tests/test_http.c index a42c4b8..81cf82c 100644 --- a/tests/test_http.c +++ b/tests/test_http.c @@ -9,33 +9,32 @@ static int failures = 0; static int checks = 0; -#define CHECK(cond, ...) \ - do { \ - checks++; \ - if (!(cond)) { \ - failures++; \ - printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ - printf(__VA_ARGS__); \ - printf("\n"); \ - } \ +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ } while (0) -int main(void) -{ +int main(void) { puts("重试判定: 只重试服务端明确报错的情况"); /* 应当重试 */ - long retryable[] = { 408, 409, 429, 500, 502, 503, 504 }; + long retryable[] = {408, 409, 429, 500, 502, 503, 504}; for (size_t i = 0; i < sizeof retryable / sizeof retryable[0]; i++) - CHECK(http_status_retryable(retryable[i]) == 1, - "HTTP %ld 应当可重试", retryable[i]); + CHECK(http_status_retryable(retryable[i]) == 1, "HTTP %ld 应当可重试", + retryable[i]); /* 绝不应当重试: 这些是确定性错误, 重试只是浪费时间和钱 */ - long permanent[] = { 400, 401, 403, 404, 405, 413, 415, 422, 501, - 200, 201, 204, 301, 0, -1 }; + long permanent[] = {400, 401, 403, 404, 405, 413, 415, 422, + 501, 200, 201, 204, 301, 0, -1}; for (size_t i = 0; i < sizeof permanent / sizeof permanent[0]; i++) - CHECK(http_status_retryable(permanent[i]) == 0, - "HTTP %ld 不应重试", permanent[i]); + CHECK(http_status_retryable(permanent[i]) == 0, "HTTP %ld 不应重试", + permanent[i]); /* 默认策略与上限的合理性 */ HttpRetry def = HTTP_RETRY_DEFAULT; @@ -53,19 +52,22 @@ int main(void) size_t sz = 0; FILE *f = open_memstream(&buf, &sz); - /* 这条正是曾经漏出去的: libcurl 的 HTTP/2 调试行走的是 CURLINFO_TEXT, + /* 这条正是曾经漏出去的: libcurl 的 HTTP/2 调试行走的是 CURLINFO_TEXT, * 不是 HEADER_OUT, 所以只处理 HEADER_OUT 的做法根本挡不住 */ - const char *h2 = "== Info: [HTTP/2] [1] [authorization: Bearer sk-0f114e3dac8249f0a36fd8fd5c517360]\n"; + const char *h2 = "== Info: [HTTP/2] [1] [authorization: Bearer " + "sk-0f114e3dac8249f0a36fd8fd5c517360]\n"; http_write_redacted(f, h2, strlen(h2), key); /* 整块请求头 (HEADER_OUT 是整块传来的, 不是逐行) */ - const char *hdrs = "POST /v1/chat/completions HTTP/2\r\n" - "Authorization: Bearer sk-0f114e3dac8249f0a36fd8fd5c517360\r\n" - "Content-Type: application/json\r\n"; + const char *hdrs = + "POST /v1/chat/completions HTTP/2\r\n" + "Authorization: Bearer sk-0f114e3dac8249f0a36fd8fd5c517360\r\n" + "Content-Type: application/json\r\n"; http_write_redacted(f, hdrs, strlen(hdrs), key); /* 其它内容不能被改动 */ - const char *plain = "== Info: TLSv1.3 (IN), TLS handshake, Finished (20):\n"; + const char *plain = + "== Info: TLSv1.3 (IN), TLS handshake, Finished (20):\n"; http_write_redacted(f, plain, strlen(plain), key); /* 没有密钥时原样输出 */ @@ -87,9 +89,9 @@ int main(void) puts("\n空请求体: POST 分支也要扛得住 body == NULL"); { /* body 允许为 NULL (http_probe 就是这么调的), 但 POST 分支以前写的是 - * 无条件的 strlen(body) —— 传 NULL 会在 curl_easy_setopt 期间就段错误. + * 无条件的 strlen(body) —— 传 NULL 会在 curl_easy_setopt 期间就段错误. * - * 这里故意用一个格式非法的 URL: libcurl 会在真正联网之前就拒绝它, + * 这里故意用一个格式非法的 URL: libcurl 会在真正联网之前就拒绝它, * 所以本测试仍然不碰网络. 而 strlen(NULL) 发生在设置 POSTFIELDS 期间、 * curl_easy_perform 之前, 也就是说旧代码在这里必崩 —— 这个断言对旧 * 代码是有效的回归守卫, 不是走过场. */ @@ -99,8 +101,8 @@ int main(void) long status = 0; http_global_init(); - int rc = http_post(":::", "sk-test", NULL, 5, 0, &no_retry, - NULL, &status, &attempts, errbuf, sizeof errbuf); + int rc = http_post(":::", "sk-test", NULL, 5, 0, &no_retry, NULL, + &status, &attempts, errbuf, sizeof errbuf); http_global_cleanup(); CHECK(rc == -1, "非法 URL 应当返回 -1 (实际返回 %d)", rc); diff --git a/tests/test_i18n.c b/tests/test_i18n.c index 43698b7..e8a32a3 100644 --- a/tests/test_i18n.c +++ b/tests/test_i18n.c @@ -1,10 +1,10 @@ /* test_i18n.c - 界面文案表 * - * 这张表是"每种语言一条". 它最容易出的错不是逻辑, 而是漏: 加了键忘了翻, - * 或者译文里少了个占位符 ({path} 写成 {paths})—— 用户看到的就是一句缺字的话. - * 所以这里的检查全部是机械的: 遍历表, 逐条比对. + * 这张表是"每种语言一条". 它最容易出的错不是逻辑, 而是漏: 加了键忘了翻, + * 或者译文里少了个占位符 ({path} 写成 {paths})—— 用户看到的就是一句缺字的话. + * 所以这里的检查全部是机械的: 遍历表, 逐条比对. * - * 文案本身好不好不在这里管; "英文界面是不是每句都有着落"才是. + * 文案本身好不好不在这里管; "英文界面是不是每句都有着落"才是. */ #include "i18n.h" @@ -15,27 +15,29 @@ static int failures = 0; static int checks = 0; -#define CHECK(cond, ...) \ - do { \ - checks++; \ - if (!(cond)) { \ - failures++; \ - printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ - printf(__VA_ARGS__); \ - printf("\n"); \ - } \ +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ } while (0) /* 收一份占位符名单 ({name} 里的 name), 按出现顺序放进 out */ -static int placeholders(const char *s, char (*out)[32], int max) -{ +static int placeholders(const char *s, char (*out)[32], int max) { int n = 0; for (const char *p = s; *p; p++) { - if (*p != '{') continue; + if (*p != '{') + continue; const char *e = strchr(p + 1, '}'); - if (!e) break; + if (!e) + break; size_t len = (size_t)(e - p - 1); - if (len == 0 || len >= 32 || n >= max) break; + if (len == 0 || len >= 32 || n >= max) + break; memcpy(out[n], p + 1, len); out[n][len] = '\0'; n++; @@ -44,20 +46,27 @@ static int placeholders(const char *s, char (*out)[32], int max) return n; } -/* 收一份 printf 转换符名单 (%zu、%c、%.20s …), 原样带走标志/宽度/长度. +/* 收一份 printf 转换符名单 (%zu、%c、%.20s …), 原样带走标志/宽度/长度. * json.* 那几条是 printf 模板而不是 {name}, 两种语言只要转换符顺序或类型 * 对不上, snprintf 就会读错参数 —— 比少字更糟, 得由机器盯住. */ -static int printf_specs(const char *s, char (*out)[16], int max) -{ +static int printf_specs(const char *s, char (*out)[16], int max) { int n = 0; for (const char *p = s; p && *p; p++) { - if (*p != '%' || n >= max) continue; + if (*p != '%' || n >= max) + continue; const char *q = p + 1; - while (*q && strchr("-+ #0", *q)) q++; /* 标志 */ - while (*q >= '0' && *q <= '9') q++; /* 宽度 */ - if (*q == '.') { q++; while (*q >= '0' && *q <= '9') q++; } - while (*q == 'h' || *q == 'l' || *q == 'L' || - *q == 'z' || *q == 'j' || *q == 't') q++; /* 长度修饰 */ + while (*q && strchr("-+ #0", *q)) + q++; /* 标志 */ + while (*q >= '0' && *q <= '9') + q++; /* 宽度 */ + if (*q == '.') { + q++; + while (*q >= '0' && *q <= '9') + q++; + } + while (*q == 'h' || *q == 'l' || *q == 'L' || *q == 'z' || *q == 'j' || + *q == 't') + q++; /* 长度修饰 */ if (strchr("diouxXcspfgeE", *q)) { size_t len = (size_t)(q + 1 - p); if (len < 16) { @@ -66,7 +75,8 @@ static int printf_specs(const char *s, char (*out)[16], int max) n++; } } - if (*q) p = q; + if (*q) + p = q; } return n; } @@ -74,24 +84,22 @@ static int printf_specs(const char *s, char (*out)[16], int max) /* 有没有中日韩字符 (含全角标点: 〔〕 ()). 英文那一列里出现它, 基本就是 * "照抄了中文"或者"美化用的框线忘了换" —— 用户看到的是英文句子中间卡着一对 * 中文括号. 顺带把"英文栏里混进日文假名"这种复制粘贴也一并挡住. */ -static int has_cjk(const char *s) -{ +static int has_cjk(const char *s) { for (const unsigned char *p = (const unsigned char *)s; p && *p; p++) { if (p[0] >= 0xE3 && p[0] <= 0xEF && p[1] >= 0x80 && p[2] >= 0x80) { - unsigned cp = (unsigned)((p[0] & 0x0Fu) << 12) - | (unsigned)((p[1] & 0x3Fu) << 6) - | (unsigned)(p[2] & 0x3Fu); - if ((cp >= 0x3000 && cp <= 0x30FF) || /* 标点 / 假名 */ - (cp >= 0x4E00 && cp <= 0x9FFF) || /* 汉字 */ - (cp >= 0xFF00 && cp <= 0xFFEF)) /* 全角 */ + unsigned cp = (unsigned)((p[0] & 0x0Fu) << 12) | + (unsigned)((p[1] & 0x3Fu) << 6) | + (unsigned)(p[2] & 0x3Fu); + if ((cp >= 0x3000 && cp <= 0x30FF) || /* 标点 / 假名 */ + (cp >= 0x4E00 && cp <= 0x9FFF) || /* 汉字 */ + (cp >= 0xFF00 && cp <= 0xFFEF)) /* 全角 */ return 1; } } return 0; } -int main(void) -{ +int main(void) { size_t n = 0; const LigentMsg *tab = i18n_table(&n); CHECK(tab != NULL && n > 0, "文案表不能是空的"); @@ -100,7 +108,8 @@ int main(void) for (size_t i = 0; i < n; i++) { const LigentMsg *m = &tab[i]; CHECK(m->key && *m->key, "第 %zu 条没有 key", i); - if (!m->key || !*m->key) continue; + if (!m->key || !*m->key) + continue; CHECK(m->en && *m->en, "%s 缺英文", m->key); CHECK(m->zh && *m->zh, "%s 缺中文", m->key); CHECK(!has_cjk(m->en ? m->en : ""), "%s 的英文里混进了中日韩字符", @@ -110,32 +119,35 @@ int main(void) * CHECK 的话, check-docs 数出来的"单元项数"就没意义了. */ int dup = 0; for (size_t j = i + 1; j < n; j++) - if (strcmp(tab[j].key, m->key) == 0) dup = 1; + if (strcmp(tab[j].key, m->key) == 0) + dup = 1; CHECK(!dup, "key 重复: %s", m->key); char pe[16][32], pz[16][32]; int ne = placeholders(m->en ? m->en : "", pe, 16); int nz = placeholders(m->zh ? m->zh : "", pz, 16); - CHECK(ne == nz, "%s 两种语言的占位符数量不同 (en %d / zh %d)", - m->key, ne, nz); + CHECK(ne == nz, "%s 两种语言的占位符数量不同 (en %d / zh %d)", m->key, + ne, nz); for (int a = 0; a < ne && a < nz; a++) { int found = 0; for (int b = 0; b < nz; b++) - if (strcmp(pe[a], pz[b]) == 0) found = 1; + if (strcmp(pe[a], pz[b]) == 0) + found = 1; CHECK(found, "%s: 英文的 {%s} 在中文里没有对应", m->key, pe[a]); } /* 译文里多出一个原文没有的占位符, 同样会在运行时漏字 */ for (int a = 0; a < nz && a < ne; a++) { int found = 0; for (int b = 0; b < ne; b++) - if (strcmp(pz[a], pe[b]) == 0) found = 1; + if (strcmp(pz[a], pe[b]) == 0) + found = 1; CHECK(found, "%s: 中文的 {%s} 在英文里没有对应", m->key, pz[a]); } } /* 表里有一部分键是**当 printf 模板用的** (repl.usage.*、repl.model.row、 * json.* …): 调用方直接把 t(key) 交给 printf/snprintf. 这些条目的 - * {name} 检查管不到, 得单独比转换符 —— 少一个 %s 会读到别的参数, + * {name} 检查管不到, 得单独比转换符 —— 少一个 %s 会读到别的参数, * 类型写错 (%d 对 %zu)更是直接踩内存. */ puts("printf 模板: 两种语言的转换符必须一模一样"); for (size_t i = 0; i < n; i++) { @@ -143,12 +155,13 @@ int main(void) char se[16][16], sz[16][16]; int ce = printf_specs(m->en ? m->en : "", se, 16); int cz = printf_specs(m->zh ? m->zh : "", sz, 16); - if (ce == 0 && cz == 0) continue; /* 不是模板, 跳过 */ + if (ce == 0 && cz == 0) + continue; /* 不是模板, 跳过 */ CHECK(ce == cz, "%s: 转换符个数不同 (en %d / zh %d)", m->key, ce, cz); for (int a = 0; a < ce && a < cz; a++) CHECK(strcmp(se[a], sz[a]) == 0, - "%s: 第 %d 个转换符不同 (en %s / zh %s)", - m->key, a + 1, se[a], sz[a]); + "%s: 第 %d 个转换符不同 (en %s / zh %s)", m->key, a + 1, + se[a], sz[a]); } puts("查表、切换语言、漏键可见"); @@ -169,15 +182,17 @@ int main(void) const char *r = tf("cli.err.unknown_option", "option", "--nope", NULL); CHECK(strstr(r, "--nope") != NULL, "值要填进去"); CHECK(strstr(r, "{option}") == NULL, "填过的占位符不该留下"); - CHECK(strstr(tf("cli.err.unknown_option", "wrong", "x", NULL), "{option}") != NULL, + CHECK(strstr(tf("cli.err.unknown_option", "wrong", "x", NULL), + "{option}") != NULL, "名字对不上时原样保留, 别静默吞掉"); i18n_set_lang(LIGENT_LANG_ZH); - CHECK(strstr(tf("cli.err.unknown_option", "option", "选项X", NULL), "选项X") != NULL, + CHECK(strstr(tf("cli.err.unknown_option", "option", "选项X", NULL), + "选项X") != NULL, "中文模板同样能填"); /* 同一句里多个占位符, 且顺序与调用顺序相反 */ i18n_set_lang(LIGENT_LANG_EN); - const char *two = tf("doctor.val.match_bad", "guess", "OpenAI", - "owner", "DeepSeek", NULL); + const char *two = tf("doctor.val.match_bad", "guess", "OpenAI", "owner", + "DeepSeek", NULL); CHECK(strstr(two, "DeepSeek") && strstr(two, "OpenAI"), "两个值都要在"); CHECK(strstr(two, "{") == NULL, "不该有没填上的占位符: %s", two); @@ -192,7 +207,8 @@ int main(void) CHECK(i18n_parse_lang("") < 0, "空串返回 -1"); CHECK(i18n_parse_lang(NULL) < 0, "NULL 返回 -1"); - puts("环境探测: LIGENT_LANG > LC_ALL > LC_MESSAGES > LANG, 认不出/没设 -> 英文"); + puts("环境探测: LIGENT_LANG > LC_ALL > LC_MESSAGES > LANG, 认不出/没设 -> " + "英文"); unsetenv("LIGENT_LANG"); unsetenv("LC_ALL"); unsetenv("LC_MESSAGES"); @@ -207,7 +223,8 @@ int main(void) setenv("LIGENT_LANG", "en", 1); CHECK(i18n_lang_from_env() == LIGENT_LANG_EN, "LIGENT_LANG 优先级最高"); setenv("LIGENT_LANG", "fr", 1); - CHECK(i18n_lang_from_env() == LIGENT_LANG_EN, "LIGENT_LANG 认不出时回退英文"); + CHECK(i18n_lang_from_env() == LIGENT_LANG_EN, + "LIGENT_LANG 认不出时回退英文"); unsetenv("LIGENT_LANG"); unsetenv("LC_ALL"); unsetenv("LC_MESSAGES"); diff --git a/tests/test_image.c b/tests/test_image.c index a71603e..e669388 100644 --- a/tests/test_image.c +++ b/tests/test_image.c @@ -5,69 +5,77 @@ #include #include #include -#include #include +#include static int failures = 0; static int checks = 0; -#define CHECK(cond, ...) \ - do { \ - checks++; \ - if (!(cond)) { \ - failures++; \ - printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ - printf(__VA_ARGS__); \ - printf("\n"); \ - } \ +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ } while (0) -static const char PNG[] = "\x89PNG\r\n\x1a\n rest"; +static const char PNG[] = "\x89PNG\r\n\x1a\n rest"; static const char JPEG[] = "\xff\xd8\xff\xe0 rest"; static const char GIF87[] = "GIF87a rest"; static const char GIF89[] = "GIF89a rest"; static const char WEBP[] = "RIFF\x00\x00\x00\x00WEBPVP8 "; -static void test_sniff(void) -{ +static void test_sniff(void) { puts("按文件内容认格式 (不看扩展名)"); const char *ext = NULL, *mime = NULL; CHECK(image_sniff((const unsigned char *)PNG, 14, &ext, &mime) == 0 && - strcmp(ext, "png") == 0 && strcmp(mime, "image/png") == 0, "PNG"); + strcmp(ext, "png") == 0 && strcmp(mime, "image/png") == 0, + "PNG"); CHECK(image_sniff((const unsigned char *)JPEG, 7, &ext, &mime) == 0 && - strcmp(ext, "jpg") == 0 && strcmp(mime, "image/jpeg") == 0, "JPEG"); + strcmp(ext, "jpg") == 0 && strcmp(mime, "image/jpeg") == 0, + "JPEG"); CHECK(image_sniff((const unsigned char *)GIF87, 10, &ext, &mime) == 0 && - strcmp(ext, "gif") == 0, "GIF87a"); + strcmp(ext, "gif") == 0, + "GIF87a"); CHECK(image_sniff((const unsigned char *)GIF89, 10, &ext, &mime) == 0 && - strcmp(ext, "gif") == 0, "GIF89a"); + strcmp(ext, "gif") == 0, + "GIF89a"); CHECK(image_sniff((const unsigned char *)WEBP, 16, &ext, &mime) == 0 && - strcmp(ext, "webp") == 0 && strcmp(mime, "image/webp") == 0, "WebP"); + strcmp(ext, "webp") == 0 && strcmp(mime, "image/webp") == 0, + "WebP"); /* 认不出来要明确失败, 别猜一个格式报上去 */ - CHECK(image_sniff((const unsigned char *)"not an image", 12, &ext, &mime) != 0, + CHECK(image_sniff((const unsigned char *)"not an image", 12, &ext, &mime) != + 0, "杂文本不该被认成图片"); CHECK(image_sniff((const unsigned char *)"\x89PNG", 4, &ext, &mime) != 0, "截断的 PNG 头不该被认出来"); - CHECK(image_sniff((const unsigned char *)"RIFFxxxxNOPE", 12, &ext, &mime) != 0, + CHECK(image_sniff((const unsigned char *)"RIFFxxxxNOPE", 12, &ext, &mime) != + 0, "RIFF 但不是 WEBP 不该被认成 WebP"); CHECK(image_sniff(NULL, 0, &ext, &mime) != 0, "NULL 不该崩"); } -static void test_backup(void) -{ +static void test_backup(void) { puts("本地备份: 命名、内容、不重复"); char tmpl[] = "/tmp/ligent_img_XXXXXX"; char *dir = mkdtemp(tmpl); CHECK(dir != NULL, "建临时目录失败"); - if (!dir) return; + if (!dir) + return; const unsigned char data[] = "\x89PNG\r\n\x1a\n payload"; size_t n = sizeof data - 1; char *b1 = image_backup_save(dir, data, n, "png"); CHECK(b1 != NULL, "备份应成功"); - if (!b1) { return; } + if (!b1) { + return; + } /* 名字形如 <上传时间戳>_<4位随机>. */ CHECK(strlen(b1) > 5, "名字不该太短: %s", b1); @@ -79,34 +87,39 @@ static void test_backup(void) char *p1 = image_backup_path(dir, b1); size_t len = 0; char *back = read_entire_file(p1, &len); - CHECK(back && len == n && memcmp(back, data, n) == 0, "备份内容应逐字节一致"); - free(back); free(p1); + CHECK(back && len == n && memcmp(back, data, n) == 0, + "备份内容应逐字节一致"); + free(back); + free(p1); /* 再存一次应该拿到不同的名字 (随机后缀), 而不是互相覆盖 */ char *b2 = image_backup_save(dir, data, n, "png"); - CHECK(b2 && strcmp(b1, b2) != 0, "两次备份不该同号: %s / %s", - b1, b2 ? b2 : "(null)"); + CHECK(b2 && strcmp(b1, b2) != 0, "两次备份不该同号: %s / %s", b1, + b2 ? b2 : "(null)"); free(b2); free(b1); /* 清场 */ char *cmd = xasprintf("rm -rf %s", dir); - if (system(cmd) != 0) { /* 清理失败不影响断言 */ } + if (system(cmd) != 0) { /* 清理失败不影响断言 */ + } free(cmd); } -static void test_base64(void) -{ +static void test_base64(void) { puts("base64: 长度、补位、往返"); /* 用几组长度覆盖三种补位情况: 3n、3n+1、3n+2 */ - struct { const char *in; const char *want; } cases[] = { - { "", "" }, - { "f", "Zg==" }, - { "fo", "Zm8=" }, - { "foo", "Zm9v" }, - { "foob", "Zm9vYg==" }, - { "fooba", "Zm9vYmE=" }, - { "foobar", "Zm9vYmFy" }, + struct { + const char *in; + const char *want; + } cases[] = { + {"", ""}, + {"f", "Zg=="}, + {"fo", "Zm8="}, + {"foo", "Zm9v"}, + {"foob", "Zm9vYg=="}, + {"fooba", "Zm9vYmE="}, + {"foobar", "Zm9vYmFy"}, }; for (size_t i = 0; i < sizeof cases / sizeof cases[0]; i++) { size_t n = strlen(cases[i].in); @@ -119,7 +132,7 @@ static void test_base64(void) free(got); } /* 二进制字节 (0x00 / 0xFF 这类不能被当字符串处理) */ - const unsigned char bin[] = { 0x00, 0xFF, 0x10, 0x80 }; + const unsigned char bin[] = {0x00, 0xFF, 0x10, 0x80}; char *b = base64_encode(bin, sizeof bin); CHECK(b && strcmp(b, "AP8QgA==") == 0, "二进制应正确编码, 实际 %s", b ? b : "(null)"); @@ -127,21 +140,21 @@ static void test_base64(void) } /* 造一张内容合法 (靠文件头就能嗅探出来)的假图, 返回字节数 */ -static size_t fake_png(unsigned char *out, size_t n) -{ +static size_t fake_png(unsigned char *out, size_t n) { static const char magic[] = "\x89PNG\r\n\x1a\n"; size_t m = sizeof magic - 1; - if (n < m) return 0; + if (n < m) + return 0; memcpy(out, magic, m); - for (size_t i = m; i < n; i++) out[i] = (unsigned char)(i & 0xFF); + for (size_t i = m; i < n; i++) + out[i] = (unsigned char)(i & 0xFF); return n; } /* 把一段备份引用塞进一条 user 消息, 模拟 read_image 之后的历史 */ static const char *ref_message_type = "ligent_image"; -static JsonValue *history_with_ref(const char *backup) -{ +static JsonValue *history_with_ref(const char *backup) { JsonValue *msgs = json_new_array(); JsonValue *sys = json_new_object(); @@ -162,20 +175,23 @@ static JsonValue *history_with_ref(const char *backup) return msgs; } -static void test_expand(void) -{ +static void test_expand(void) { puts("请求前展开: 引用 -> image_url + base64"); char tmpl[] = "/tmp/ligent_imgx_XXXXXX"; char *dir = mkdtemp(tmpl); CHECK(dir != NULL, "建临时目录失败"); - if (!dir) return; + if (!dir) + return; image_set_bindir(dir); unsigned char png[256]; size_t n = fake_png(png, sizeof png); char *backup = image_backup_save(dir, png, n, "png"); CHECK(backup != NULL, "备份应成功"); - if (!backup) { image_set_bindir("."); return; } + if (!backup) { + image_set_bindir("."); + return; + } /* --- 正常展开 --- */ JsonValue *msgs = history_with_ref(backup); @@ -191,14 +207,14 @@ static void test_expand(void) "附件消息应有一个 content 块"); JsonValue *wp = json_array_get(wc, 0); CHECK(strcmp(json_get_string(wp, "type"), "image_url") == 0, - "发送形态的类型应是 image_url, 实际 %s", - json_get_string(wp, "type")); + "发送形态的类型应是 image_url, 实际 %s", json_get_string(wp, "type")); JsonValue *iu = json_object_get(wp, "image_url"); const char *url = iu ? json_get_string(iu, "url") : NULL; CHECK(url && strncmp(url, "data:image/png;base64,", 22) == 0, "应是 data: URL, 实际 %.40s", url ? url : "(null)"); - /* base64 段应能解回原字节 —— 这里只验长度与首尾, 避免在测试里再写一个解码器 */ + /* base64 段应能解回原字节 —— 这里只验长度与首尾, 避免在测试里再写一个解码器 + */ CHECK(url && strlen(url) == 22 + base64_encoded_len(n), "data URL 长度应等于前缀 + base64 长度"); @@ -274,14 +290,14 @@ static void test_expand(void) free(backup); image_set_bindir("."); char *cmd = xasprintf("rm -rf %s", tmpl); - if (system(cmd) != 0) { } + if (system(cmd) != 0) { + } free(cmd); } -static void test_pending(void) -{ +static void test_pending(void) { puts("待发附件: 取走即清空"); - image_pending_take(); /* 先清干净 */ + image_pending_take(); /* 先清干净 */ image_pending_add("1700000000_aaaa.png"); image_pending_add("1700000001_bbbb.png"); @@ -296,7 +312,8 @@ static void test_pending(void) CHECK(json_get_string(p0, "file_id") == NULL, "不该再有 file_id 这种协议字段"); CHECK(strcmp(json_get_string(json_array_get(arr, 1), "backup"), - "1700000001_bbbb.png") == 0, "第二个备份名"); + "1700000001_bbbb.png") == 0, + "第二个备份名"); } json_free(arr); @@ -306,8 +323,7 @@ static void test_pending(void) CHECK(image_pending_take() == NULL, "空备份名不该被记下"); } -int main(void) -{ +int main(void) { test_sniff(); test_backup(); test_base64(); diff --git a/tests/test_json.c b/tests/test_json.c index 54b6560..49a0665 100644 --- a/tests/test_json.c +++ b/tests/test_json.c @@ -9,25 +9,26 @@ static int failures = 0; static int checks = 0; -#define CHECK(cond, ...) \ - do { \ - checks++; \ - if (!(cond)) { \ - failures++; \ - printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ - printf(__VA_ARGS__); \ - printf("\n"); \ - } \ +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ } while (0) -static void test_basic(void) -{ +static void test_basic(void) { puts("test_basic"); char err[256]; - JsonValue *v = json_parse("{\"a\": 1, \"b\": [true, false, null], \"c\": \"hi\"}", - err, sizeof err); + JsonValue *v = + json_parse("{\"a\": 1, \"b\": [true, false, null], \"c\": \"hi\"}", err, + sizeof err); CHECK(v != NULL, "解析失败: %s", err); - if (!v) return; + if (!v) + return; CHECK(json_get_number(v, "a", -1) == 1, "a 应为 1"); CHECK(json_array_size(json_object_get(v, "b")) == 3, "b 应有 3 个元素"); CHECK(json_bool_value(json_array_get(json_object_get(v, "b"), 0), 0) == 1, @@ -41,18 +42,20 @@ static void test_basic(void) CHECK(v2 != NULL, "往返解析失败: %s", err); char *dump2 = json_dump(v2); CHECK(strcmp(dump, dump2) == 0, "往返不一致:\n %s\n %s", dump, dump2); - free(dump); free(dump2); - json_free(v); json_free(v2); + free(dump); + free(dump2); + json_free(v); + json_free(v2); } -static void test_escapes(void) -{ +static void test_escapes(void) { puts("test_escapes"); char err[256]; - JsonValue *v = json_parse("\"a\\nb\\t\\\"c\\\\d\\u4e2d\\u6587\\uD83D\\uDE00\"", - err, sizeof err); + JsonValue *v = json_parse( + "\"a\\nb\\t\\\"c\\\\d\\u4e2d\\u6587\\uD83D\\uDE00\"", err, sizeof err); CHECK(v != NULL, "解析失败: %s", err); - if (!v) return; + if (!v) + return; const char *s = json_string_value(v); CHECK(strcmp(s, "a\nb\t\"c\\d中文😀") == 0, "转义解码错误: [%s]", s); @@ -61,16 +64,18 @@ static void test_escapes(void) CHECK(v2 != NULL, "转义往返失败: %s / %s", q, err); CHECK(v2 && strcmp(json_string_value(v2), s) == 0, "转义往返内容不一致"); free(q); - json_free(v); json_free(v2); + json_free(v); + json_free(v2); } -static void test_numbers(void) -{ +static void test_numbers(void) { puts("test_numbers"); char err[256]; - JsonValue *v = json_parse("[0, -1, 3.5, 1e3, -2.5e-2, 123456789012]", err, sizeof err); + JsonValue *v = + json_parse("[0, -1, 3.5, 1e3, -2.5e-2, 123456789012]", err, sizeof err); CHECK(v != NULL, "解析失败: %s", err); - if (!v) return; + if (!v) + return; CHECK(json_number_value(json_array_get(v, 0), -1) == 0, "[0]"); CHECK(json_number_value(json_array_get(v, 1), 0) == -1, "[1]"); CHECK(json_number_value(json_array_get(v, 2), 0) == 3.5, "[2]"); @@ -92,24 +97,34 @@ static void test_numbers(void) } /* 边界上合法的写法不能被误伤 */ const char *ok_nums[] = { - "0", "-0", "0.0", "10", "-1.5", "1e3", "1E3", "1e+3", "1e-3", "0.1e10", - "123456789012345678901234567890", "1e308", + "0", + "-0", + "0.0", + "10", + "-1.5", + "1e3", + "1E3", + "1e+3", + "1e-3", + "0.1e10", + "123456789012345678901234567890", + "1e308", }; for (size_t i = 0; i < sizeof ok_nums / sizeof ok_nums[0]; i++) { JsonValue *g = json_parse(ok_nums[i], err, sizeof err); - CHECK(g != NULL && g->type == JSON_NUMBER, - "合法数字被拒: [%s] (%s)", ok_nums[i], err); + CHECK(g != NULL && g->type == JSON_NUMBER, "合法数字被拒: [%s] (%s)", + ok_nums[i], err); json_free(g); } } -static void test_invalid(void) -{ +static void test_invalid(void) { puts("test_invalid"); char err[256]; const char *bad[] = { - "{", "}", "[1,]", "{\"a\":}", "\"unterminated", "tru", "{\"a\" 1}", - "[1 2]", "01abc", "", " ", "{\"a\":1}}", "[1,2] extra", + "{", "}", "[1,]", "{\"a\":}", "\"unterminated", + "tru", "{\"a\" 1}", "[1 2]", "01abc", "", + " ", "{\"a\":1}}", "[1,2] extra", }; for (size_t i = 0; i < sizeof bad / sizeof bad[0]; i++) { JsonValue *v = json_parse(bad[i], err, sizeof err); @@ -118,12 +133,12 @@ static void test_invalid(void) } /* 空字符串是合法 JSON 字符串 */ JsonValue *e = json_parse("\"\"", err, sizeof err); - CHECK(e != NULL && strcmp(json_string_value(e), "") == 0, "空字符串应为合法"); + CHECK(e != NULL && strcmp(json_string_value(e), "") == 0, + "空字符串应为合法"); json_free(e); } -static void test_builders(void) -{ +static void test_builders(void) { puts("test_builders"); JsonValue *o = json_new_object(); json_object_set_string(o, "model", "gpt-x"); @@ -133,30 +148,34 @@ static void test_builders(void) json_array_push(arr, json_new_string("a")); json_array_push(arr, json_new_string("b")); json_object_set(o, "items", arr); - json_object_set_string(o, "model", "gpt-y"); /* 覆盖 */ + json_object_set_string(o, "model", "gpt-y"); /* 覆盖 */ char *s = json_dump(o); - CHECK(strcmp(s, "{\"model\":\"gpt-y\",\"n\":3,\"ok\":true,\"items\":[\"a\",\"b\"]}") == 0, + CHECK(strcmp(s, "{\"model\":\"gpt-y\",\"n\":3,\"ok\":true,\"items\":[\"a\"," + "\"b\"]}") == 0, "构造结果不对: %s", s); free(s); /* clone 独立性 */ JsonValue *c = json_clone(o); json_object_set_string(o, "model", "changed"); - CHECK(strcmp(json_get_string(c, "model"), "gpt-y") == 0, "clone 应相互独立"); + CHECK(strcmp(json_get_string(c, "model"), "gpt-y") == 0, + "clone 应相互独立"); json_object_remove(o, "model"); CHECK(json_object_get(o, "model") == NULL, "remove 应删除键"); - json_free(o); json_free(c); + json_free(o); + json_free(c); } -static void test_nested_depth(void) -{ +static void test_nested_depth(void) { puts("test_nested_depth"); char err[256]; size_t n = 300; char *deep = xmalloc(n * 2 + 1); - for (size_t i = 0; i < n; i++) deep[i] = '['; - for (size_t i = 0; i < n; i++) deep[n + i] = ']'; + for (size_t i = 0; i < n; i++) + deep[i] = '['; + for (size_t i = 0; i < n; i++) + deep[n + i] = ']'; deep[n * 2] = '\0'; JsonValue *v = json_parse(deep, err, sizeof err); CHECK(v == NULL, "超深嵌套应被拒绝"); @@ -168,8 +187,7 @@ static void test_nested_depth(void) } /* 报错必须能定位: 只有"语法错了"而不知道错在哪, 等于没有报错 */ -static void test_error_location(void) -{ +static void test_error_location(void) { puts("test_error_location"); char err[256]; JsonValue *v; @@ -192,7 +210,8 @@ static void test_error_location(void) /* 缺冒号 */ v = json_parse("{\"a\" 1}", err, sizeof err); CHECK(v == NULL, "缺冒号应报错"); - CHECK(strstr(err, "第 1 行") && strstr(err, "':'") != NULL, "缺冒号: %s", err); + CHECK(strstr(err, "第 1 行") && strstr(err, "':'") != NULL, "缺冒号: %s", + err); json_free(v); /* 尾部多余内容 */ @@ -220,30 +239,29 @@ static void test_error_location(void) json_free(v); } -/* 序列化出去的东西必须是**合法 UTF-8**. +/* 序列化出去的东西必须是**合法 UTF-8**. * * 服务端会因为请求体里有非法 UTF-8 直接回 * 400 ... invalid unicode code point - * 而那段坏数据一旦进了对话历史, 之后每次请求都会失败 —— 除非丢掉整轮对话. + * 而那段坏数据一旦进了对话历史, 之后每次请求都会失败 —— 除非丢掉整轮对话. * 坏字节的来源很日常: 某条命令在非 UTF-8 locale 下打出来的路径、被截断的多 - * 字节字符、二进制垃圾. 所以这条不能只靠"在入口清理", 必须在序列化器这层兜住: - * 出口是唯一能保证"发出去的永远合法"的地方. + * 字节字符、二进制垃圾. 所以这条不能只靠"在入口清理", 必须在序列化器这层兜住: + * 出口是唯一能保证"发出去的永远合法"的地方. * * 这个校验函数故意用 utf8_seq_len 自己走一遍, 而不是信任 dump 的实现 —— * 断言的是**产物**的性质, 不是实现的自述. */ -static int utf8_all_valid(const char *s) -{ +static int utf8_all_valid(const char *s) { size_t n = strlen(s), i = 0; while (i < n) { size_t adv = utf8_seq_len(s + i, n - i); - if (adv == 0) return 0; + if (adv == 0) + return 0; i += adv; } return 1; } -static void test_dump_utf8_safety(void) -{ +static void test_dump_utf8_safety(void) { puts("test_dump_utf8_safety"); /* utf8_seq_len: 合法序列返回长度 */ @@ -262,19 +280,25 @@ static void test_dump_utf8_safety(void) /* 各种坏字节经 json_dump 之后, 整份输出必须是合法 UTF-8 */ static const char *bad[] = { - "a\x80" "b", /* 孤立续字节 */ - "a\xe4\xb8" "b", /* 截断的 3 字节序列 */ - "a\xc0\xaf" "b", /* 过长编码 (历史上被用来夹带 '/') */ - "a\xed\xa0\x80" "b", /* UTF-16 代理区 */ - "a\xf5\x80\x80\x80" "b", /* 超出 U+10FFFF */ - "\xff\xfe", /* 二进制垃圾 */ - "\xe4\xb8\xad\x80\xe6", /* 合法中文里夹一个坏字节 */ + "a\x80" + "b", /* 孤立续字节 */ + "a\xe4\xb8" + "b", /* 截断的 3 字节序列 */ + "a\xc0\xaf" + "b", /* 过长编码 (历史上被用来夹带 '/') */ + "a\xed\xa0\x80" + "b", /* UTF-16 代理区 */ + "a\xf5\x80\x80\x80" + "b", /* 超出 U+10FFFF */ + "\xff\xfe", /* 二进制垃圾 */ + "\xe4\xb8\xad\x80\xe6", /* 合法中文里夹一个坏字节 */ }; for (size_t k = 0; k < sizeof bad / sizeof bad[0]; k++) { JsonValue *o = json_new_object(); json_object_set_string(o, "content", bad[k]); char *d = json_dump(o); - CHECK(utf8_all_valid(d), "第 %zu 个坏输入序列化后仍含非法 UTF-8: %s", k, d); + CHECK(utf8_all_valid(d), "第 %zu 个坏输入序列化后仍含非法 UTF-8: %s", k, + d); /* 而且我们的解析器 (和服务端一样严格)必须收得下 */ char err[256]; JsonValue *back = json_parse(d, err, sizeof err); @@ -294,14 +318,14 @@ static void test_dump_utf8_safety(void) json_object_set_string(o, "content", "中文 😀 tail"); char *d = json_dump(o); CHECK(strstr(d, "中文") != NULL, "合法中文不该被改动: %s", d); - CHECK(strstr(d, "\xf0\x9f\x98\x80") != NULL, "合法 emoji 不该被改动: %s", d); + CHECK(strstr(d, "\xf0\x9f\x98\x80") != NULL, "合法 emoji 不该被改动: %s", + d); CHECK(strstr(d, "\xef\xbf\xbd") == NULL, "合法输入不该出现替换字符: %s", d); free(d); json_free(o); } -int main(void) -{ +int main(void) { test_basic(); test_escapes(); test_numbers(); diff --git a/tests/test_lineedit.c b/tests/test_lineedit.c index df4a134..0317e4c 100644 --- a/tests/test_lineedit.c +++ b/tests/test_lineedit.c @@ -10,41 +10,41 @@ static int failures = 0; static int checks = 0; -#define CHECK(cond, ...) \ - do { \ - checks++; \ - if (!(cond)) { \ - failures++; \ - printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ - printf(__VA_ARGS__); \ - printf("\n"); \ - } \ +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ } while (0) -#define BUFEQ(lb, want) \ - CHECK(strcmp((lb).buf, (want)) == 0 && (lb).len == strlen(want), \ +#define BUFEQ(lb, want) \ + CHECK(strcmp((lb).buf, (want)) == 0 && (lb).len == strlen(want), \ "内容应为 [%s], 实际 [%s]", want, (lb).buf) /* 读一个文本文件的前 n 字节到 buf (用于检查落盘格式) */ -static void slurp(const char *path, char *buf, size_t buflen) -{ +static void slurp(const char *path, char *buf, size_t buflen) { buf[0] = '\0'; FILE *f = fopen(path, "r"); - if (!f) return; + if (!f) + return; size_t n = fread(buf, 1, buflen - 1, f); buf[n] = '\0'; fclose(f); } -static void test_history_file(void) -{ +static void test_history_file(void) { puts("输入历史的持久化"); char path[] = "/tmp/ligent_hist_XXXXXX"; int fd = mkstemp(path); CHECK(fd >= 0, "mkstemp 失败"); - if (fd < 0) return; + if (fd < 0) + return; close(fd); - unlink(path); /* 故意从"文件不存在"开始 */ + unlink(path); /* 故意从"文件不存在"开始 */ line_history_clear(); CHECK(line_history_load(path, 100) == 0, "文件不存在不算错误"); @@ -75,7 +75,7 @@ static void test_history_file(void) unlink(path); line_history_add("echo C:\\Users\\me"); line_history_append(path, "echo C:\\Users\\me"); - line_history_add("第一行\n第二行"); /* 反斜杠续行拼出来的多行输入 */ + line_history_add("第一行\n第二行"); /* 反斜杠续行拼出来的多行输入 */ line_history_append(path, "第一行\n第二行"); line_history_add("中文历史"); line_history_append(path, "中文历史"); @@ -84,7 +84,9 @@ static void test_history_file(void) CHECK(strchr(raw, '\n') != NULL && strstr(raw, "\n\n") == NULL, "每条只占一行, 实际 %s", raw); int newlines = 0; - for (const char *p = raw; *p; p++) if (*p == '\n') newlines++; + for (const char *p = raw; *p; p++) + if (*p == '\n') + newlines++; CHECK(newlines == 3, "3 条历史应占 3 行, 实际 %d 行", newlines); line_history_clear(); @@ -92,8 +94,10 @@ static void test_history_file(void) CHECK(line_history_len() == 3, "应读回 3 条, 实际 %zu", line_history_len()); CHECK(strcmp(line_history_get(0), "echo C:\\Users\\me") == 0, "反斜杠应原样还原, 实际 [%s]", line_history_get(0)); - CHECK(line_history_get(1) && strcmp(line_history_get(1), "第一行\n第二行") == 0, - "多行条目应整条还原, 实际 [%s]", line_history_get(1) ? line_history_get(1) : "(null)"); + CHECK(line_history_get(1) && + strcmp(line_history_get(1), "第一行\n第二行") == 0, + "多行条目应整条还原, 实际 [%s]", + line_history_get(1) ? line_history_get(1) : "(null)"); CHECK(strcmp(line_history_get(2), "中文历史") == 0, "中文应原样还原"); line_history_clear(); @@ -107,12 +111,15 @@ static void test_history_file(void) } int lines = 0; slurp(path, raw, sizeof raw); - for (const char *p = raw; *p; p++) if (*p == '\n') lines++; + for (const char *p = raw; *p; p++) + if (*p == '\n') + lines++; CHECK(lines == 10, "截短前应有 10 行, 实际 %d", lines); line_history_clear(); line_history_load(path, 4); - CHECK(line_history_len() == 4, "keep=4 时应只剩 4 条, 实际 %zu", line_history_len()); + CHECK(line_history_len() == 4, "keep=4 时应只剩 4 条, 实际 %zu", + line_history_len()); CHECK(line_history_get(0) && strcmp(line_history_get(0), "cmd-6") == 0, "应保留最新的 4 条, 第一条应是 cmd-6, 实际 [%s]", line_history_get(0) ? line_history_get(0) : "(null)"); @@ -122,20 +129,22 @@ static void test_history_file(void) slurp(path, raw, sizeof raw); lines = 0; - for (const char *p = raw; *p; p++) if (*p == '\n') lines++; + for (const char *p = raw; *p; p++) + if (*p == '\n') + lines++; CHECK(lines == 4, "文件应被就地截短成 4 行, 实际 %d", lines); /* 截短后的文件读回来还是那 4 条 */ line_history_clear(); line_history_load(path, 100); - CHECK(line_history_len() == 4, "截短后应只读到 4 条, 实际 %zu", line_history_len()); + CHECK(line_history_len() == 4, "截短后应只读到 4 条, 实际 %zu", + line_history_len()); line_history_clear(); unlink(path); } -int main(void) -{ +int main(void) { puts("插入与删除"); LineBuf lb; linebuf_init(&lb); @@ -164,13 +173,13 @@ int main(void) linebuf_delete(&lb); BUFEQ(lb, "eXllo"); linebuf_end(&lb); - linebuf_delete(&lb); /* 末尾 Delete 应无动作 */ + linebuf_delete(&lb); /* 末尾 Delete 应无动作 */ BUFEQ(lb, "eXllo"); linebuf_free(&lb); puts("UTF-8: 按字符而非字节移动"); linebuf_init(&lb); - linebuf_set(&lb, "中文abc"); /* 中(3) 文(3) a b c = 9 字节 */ + linebuf_set(&lb, "中文abc"); /* 中(3) 文(3) a b c = 9 字节 */ CHECK(lb.len == 9, "应为 9 字节, 实际 %zu", lb.len); linebuf_left(&lb); CHECK(lb.pos == 8, "左移一格应为 8, 实际 %zu", lb.pos); @@ -202,8 +211,8 @@ int main(void) /* 中间删除多字节字符 */ linebuf_set(&lb, "a中b"); linebuf_home(&lb); - linebuf_right(&lb); /* 光标到 'a' 之后 */ - linebuf_delete(&lb); /* 删掉 '中' */ + linebuf_right(&lb); /* 光标到 'a' 之后 */ + linebuf_delete(&lb); /* 删掉 '中' */ BUFEQ(lb, "ab"); CHECK(lb.pos == 1, "删除后光标不动, 实际 %zu", lb.pos); linebuf_free(&lb); @@ -217,41 +226,41 @@ int main(void) CHECK(lb.pos == lb.len, "单行时 End 到行尾"); /* 多行 (粘贴进来的那种): Home/End 只在本行内走 */ - linebuf_set(&lb, "aaa\nbbb\nccc"); /* 长度 11 */ + linebuf_set(&lb, "aaa\nbbb\nccc"); /* 长度 11 */ CHECK(lb.len == 11, "应为 11 字节, 实际 %zu", lb.len); linebuf_home(&lb); CHECK(lb.pos == 8, "末尾按 Home 应到最后一行的行首 (8), 实际 %zu", lb.pos); linebuf_end(&lb); CHECK(lb.pos == 11, "End 应到本行行尾 (11), 实际 %zu", lb.pos); - lb.pos = 5; /* 落在 "bbb" 中间 */ + lb.pos = 5; /* 落在 "bbb" 中间 */ linebuf_home(&lb); CHECK(lb.pos == 4, "中间行按 Home 应到该行行首 (4), 实际 %zu", lb.pos); linebuf_end(&lb); CHECK(lb.pos == 7, "中间行按 End 应到该行行尾 (7), 实际 %zu", lb.pos); - lb.pos = 4; /* 正好在行首 */ + lb.pos = 4; /* 正好在行首 */ linebuf_home(&lb); CHECK(lb.pos == 4, "已在行首时 Home 不应跳到整个缓冲区开头"); - lb.pos = 7; /* 正好在行尾 (换行符前) */ + lb.pos = 7; /* 正好在行尾 (换行符前) */ linebuf_end(&lb); CHECK(lb.pos == 7, "已在行尾时 End 不应跳过换行符"); linebuf_set(&lb, "one two three"); - lb.pos = 7; /* "one two" 之后 */ + lb.pos = 7; /* "one two" 之后 */ linebuf_kill_to_end(&lb); BUFEQ(lb, "one two"); linebuf_set(&lb, "one two three"); - lb.pos = 8; /* "one two " 之后 */ + lb.pos = 8; /* "one two " 之后 */ linebuf_kill_to_start(&lb); BUFEQ(lb, "three"); CHECK(lb.pos == 0, "删到行首后光标应在行首"); linebuf_set(&lb, "one two three"); - linebuf_kill_word(&lb); /* 从末尾回删一个词 */ + linebuf_kill_word(&lb); /* 从末尾回删一个词 */ BUFEQ(lb, "one two "); - linebuf_kill_word(&lb); /* 先跳过空格再删词 */ + linebuf_kill_word(&lb); /* 先跳过空格再删词 */ BUFEQ(lb, "one "); linebuf_free(&lb); @@ -260,8 +269,8 @@ int main(void) CHECK(line_history_len() == 0, "初始应为空"); line_history_add("first"); line_history_add("second"); - line_history_add("second"); /* 与上一条重复, 应忽略 */ - line_history_add(""); /* 空行不入历史 */ + line_history_add("second"); /* 与上一条重复, 应忽略 */ + line_history_add(""); /* 空行不入历史 */ CHECK(line_history_len() == 2, "应有 2 条, 实际 %zu", line_history_len()); CHECK(strcmp(line_history_get(0), "first") == 0, "第 0 条"); CHECK(strcmp(line_history_get(1), "second") == 0, "第 1 条"); diff --git a/tests/test_markdown.c b/tests/test_markdown.c index da6abda..5b1c271 100644 --- a/tests/test_markdown.c +++ b/tests/test_markdown.c @@ -1,7 +1,7 @@ /* test_markdown.c - 流式 Markdown 渲染器测试 * * 最关键的不变量: 把同一段文本任意切分后逐片喂入, 输出必须与一次性喂入 - * 完全一致. SSE 的分片位置是随机的, 这条不成立就会漏出"半个星号". + * 完全一致. SSE 的分片位置是随机的, 这条不成立就会漏出"半个星号". */ #include "markdown.h" #include "util.h" @@ -13,27 +13,28 @@ static int failures = 0; static int checks = 0; -#define CHECK(cond, ...) \ - do { \ - checks++; \ - if (!(cond)) { \ - failures++; \ - printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ - printf(__VA_ARGS__); \ - printf("\n"); \ - } \ +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ } while (0) /* 去掉 ANSI 转义, 得到"肉眼可见"的文本 */ -static char *strip_ansi(const char *s) -{ +static char *strip_ansi(const char *s) { StrBuf out; sb_init(&out); - for (const char *p = s; *p; ) { + for (const char *p = s; *p;) { if (*p == 0x1b && p[1] == '[') { p += 2; - while (*p && !(*p >= '@' && *p <= '~')) p++; - if (*p) p++; + while (*p && !(*p >= '@' && *p <= '~')) + p++; + if (*p) + p++; continue; } sb_putc(&out, *p++); @@ -41,22 +42,24 @@ static char *strip_ansi(const char *s) return sb_detach(&out); } -static void show_escaped(const char *s) -{ +static void show_escaped(const char *s) { for (const unsigned char *p = (const unsigned char *)s; *p; p++) { - if (*p == 0x1b) printf(""); - else if (*p == '\n') printf("\\n"); - else putchar(*p); + if (*p == 0x1b) + printf(""); + else if (*p == '\n') + printf("\\n"); + else + putchar(*p); } } /* 渲染到内存流; chunk == 0 表示一次性喂入 */ -static char *do_render(const char *input, int enabled, size_t chunk) -{ +static char *do_render(const char *input, int enabled, size_t chunk) { char *buf = NULL; size_t size = 0; FILE *f = open_memstream(&buf, &size); - if (!f) return NULL; + if (!f) + return NULL; MdRenderer *r = md_new(enabled); md_set_out(r, f); @@ -67,7 +70,8 @@ static char *do_render(const char *input, int enabled, size_t chunk) for (size_t i = 0; i < len; i += chunk) { size_t n = len - i < chunk ? len - i : chunk; char tmp[64]; - if (n >= sizeof tmp) n = sizeof tmp - 1; + if (n >= sizeof tmp) + n = sizeof tmp - 1; memcpy(tmp, input + i, n); tmp[n] = '\0'; md_feed(r, tmp); @@ -79,15 +83,13 @@ static char *do_render(const char *input, int enabled, size_t chunk) return buf; } -static char *render(const char *in, int enabled) -{ +static char *render(const char *in, int enabled) { return do_render(in, enabled, 0); } /* ------------------------------------------------------------------ */ -static void test_split_invariance(void) -{ +static void test_split_invariance(void) { puts("分片不变性 (一次性 / 3 字节 / 逐字节 必须一致)"); static const char *samples[] = { "**粗体**\n", @@ -121,36 +123,43 @@ static void test_split_invariance(void) "```\n# 不是标题\n[x](y)\n```\n", "ls -la | grep foo\n", "a | b\nc | d\n", - /* 以下三个是模糊测试找到并最小化出来的历史缺陷, + /* 以下三个是模糊测试找到并最小化出来的历史缺陷, * 每个都曾让"一次性喂"与"分片喂"结果不一致, 留作回归见证 */ - "1|`", /* 表格探测挂起整行 -> 行内代码被漏渲染 */ - "- |", /* try_block 提前 return 跳过了列表规则 */ - "s|\n-", /* 表格判断依赖分片边界 */ - "| a |\n|---|\n", /* 表头+分隔行后紧跟 EOF */ - "| a |\n|---|", /* 末尾无换行的表格 */ - "| a | b |\n|---|---|\n| 1 | 2 |\n- 列表\n", /* 表格后紧跟列表 */ - "| a |\n| b |\n| c |\n", /* 有续行但缺分隔行 */ + "1|`", /* 表格探测挂起整行 -> 行内代码被漏渲染 */ + "- |", /* try_block 提前 return 跳过了列表规则 */ + "s|\n-", /* 表格判断依赖分片边界 */ + "| a |\n|---|\n", /* 表头+分隔行后紧跟 EOF */ + "| a |\n|---|", /* 末尾无换行的表格 */ + "| a | b |\n|---|---|\n| 1 | 2 |\n- 列表\n", /* 表格后紧跟列表 */ + "| a |\n| b |\n| c |\n", /* 有续行但缺分隔行 */ }; for (size_t k = 0; k < sizeof samples / sizeof samples[0]; k++) { char *whole = render(samples[k], 1); char *by1 = do_render(samples[k], 1, 1); char *by3 = do_render(samples[k], 1, 3); - int ok = whole && by1 && by3 && - strcmp(whole, by1) == 0 && strcmp(whole, by3) == 0; + int ok = whole && by1 && by3 && strcmp(whole, by1) == 0 && + strcmp(whole, by3) == 0; checks++; if (!ok) { failures++; printf(" ✗ 分片不一致: [%s]\n", samples[k]); - printf(" 一次性: "); show_escaped(whole ? whole : "(null)"); printf("\n"); - printf(" 逐字节: "); show_escaped(by1 ? by1 : "(null)"); printf("\n"); - printf(" 3 字节: "); show_escaped(by3 ? by3 : "(null)"); printf("\n"); + printf(" 一次性: "); + show_escaped(whole ? whole : "(null)"); + printf("\n"); + printf(" 逐字节: "); + show_escaped(by1 ? by1 : "(null)"); + printf("\n"); + printf(" 3 字节: "); + show_escaped(by3 ? by3 : "(null)"); + printf("\n"); } - free(whole); free(by1); free(by3); + free(whole); + free(by1); + free(by3); } } -static void test_headings(void) -{ +static void test_headings(void) { puts("标题"); char *o = render("# 一\n## 二\n### 三\n#### 四\n", 1); CHECK(strstr(o, "\033[1;36m# 一\033[0m") != NULL, "H1 应为粗体青"); @@ -160,7 +169,8 @@ static void test_headings(void) char *vis = strip_ansi(o); CHECK(strcmp(vis, "# 一\n## 二\n### 三\n#### 四\n") == 0, "可见文本应保留 # 标记: [%s]", vis); - free(vis); free(o); + free(vis); + free(o); /* 不是标题的情况必须原样保留 */ o = render("#没有空格\n", 1); @@ -176,20 +186,21 @@ static void test_headings(void) free(o); } -static void test_bold_italic(void) -{ +static void test_bold_italic(void) { puts("粗体 / 斜体"); char *o = render("**粗**\n", 1); CHECK(strstr(o, "\033[1m粗\033[0m") != NULL, "粗体样式: [%s]", o); char *vis = strip_ansi(o); CHECK(strcmp(vis, "粗\n") == 0, "粗体标记应被吃掉: [%s]", vis); - free(vis); free(o); + free(vis); + free(o); o = render("*斜*\n", 1); CHECK(strstr(o, "\033[3m斜\033[0m") != NULL, "斜体样式: [%s]", o); vis = strip_ansi(o); CHECK(strcmp(vis, "斜\n") == 0, "斜体标记应被吃掉: [%s]", vis); - free(vis); free(o); + free(vis); + free(o); /* 字面星号: 乘法 */ o = render("2 * 3 * 4\n", 1); @@ -210,43 +221,46 @@ static void test_bold_italic(void) o = render("**粗 *斜* 粗**\n", 1); vis = strip_ansi(o); CHECK(strcmp(vis, "粗 斜 粗\n") == 0, "嵌套可见文本: [%s]", vis); - free(vis); free(o); + free(vis); + free(o); } -static void test_code(void) -{ +static void test_code(void) { puts("代码"); /* 行内代码: 保留反引号 (便于复制), 内容着色 */ char *o = render("用 `ls -la` 看看\n", 1); char *vis = strip_ansi(o); - CHECK(strcmp(vis, "用 `ls -la` 看看\n") == 0, - "行内代码应保留反引号: [%s]", vis); + CHECK(strcmp(vis, "用 `ls -la` 看看\n") == 0, "行内代码应保留反引号: [%s]", + vis); CHECK(strstr(o, "\033[36m") != NULL, "内容应着色: [%s]", o); - free(vis); free(o); + free(vis); + free(o); /* 围栏代码块内部不做行内解析 */ o = render("```\n**原样**\n```\n", 1); vis = strip_ansi(o); CHECK(strcmp(vis, "```\n**原样**\n```\n") == 0, "代码块内不应渲染粗体: [%s]", vis); - free(vis); free(o); + free(vis); + free(o); /* 带语言标识 */ o = render("```c\nx;\n```\n", 1); vis = strip_ansi(o); - CHECK(strcmp(vis, "```c\nx;\n```\n") == 0, - "语言标识与内容应完整保留: [%s]", vis); - free(vis); free(o); + CHECK(strcmp(vis, "```c\nx;\n```\n") == 0, "语言标识与内容应完整保留: [%s]", + vis); + free(vis); + free(o); /* 未闭合的代码块: 不能吞掉后面的内容 */ o = render("```\ncode\n", 1); vis = strip_ansi(o); CHECK(strcmp(vis, "```\ncode\n") == 0, "未闭合代码块: [%s]", vis); - free(vis); free(o); + free(vis); + free(o); } -static void test_blocks(void) -{ +static void test_blocks(void) { puts("列表 / 引用 / 分隔线"); /* 列表标记必须保留模型输出的原始字符, 只上色, 不换成 • */ char *o = render("- 甲\n- 乙\n", 1); @@ -254,32 +268,38 @@ static void test_blocks(void) CHECK(strcmp(vis, "- 甲\n- 乙\n") == 0, "列表应保留原始 - : [%s]", vis); CHECK(strstr(o, "\033[36m-\033[0m") != NULL, "列表标记 - 应着色: [%s]", o); CHECK(strstr(o, "•") == NULL, "不应出现非 ASCII 圆点: [%s]", o); - free(vis); free(o); + free(vis); + free(o); o = render("* 甲\n+ 乙\n", 1); vis = strip_ansi(o); - CHECK(strcmp(vis, "* 甲\n+ 乙\n") == 0, "列表应保留原始 * 和 + : [%s]", vis); + CHECK(strcmp(vis, "* 甲\n+ 乙\n") == 0, "列表应保留原始 * 和 + : [%s]", + vis); CHECK(strstr(o, "\033[36m*\033[0m") != NULL, "列表标记 * 应着色: [%s]", o); CHECK(strstr(o, "\033[36m+\033[0m") != NULL, "列表标记 + 应着色: [%s]", o); - free(vis); free(o); + free(vis); + free(o); /* 表格不受影响: 仍然是框线字符 */ o = render("| a |\n|---|\n| 1 |\n", 1); vis = strip_ansi(o); CHECK(strstr(vis, "┌") && strstr(vis, "│") && strstr(vis, "└"), "表格仍用框线字符: [%s]", vis); - free(vis); free(o); + free(vis); + free(o); o = render("> 引用\n", 1); vis = strip_ansi(o); CHECK(strcmp(vis, "│ 引用\n") == 0, "引用: [%s]", vis); - free(vis); free(o); + free(vis); + free(o); o = render("---\n", 1); vis = strip_ansi(o); CHECK(strcmp(vis, "---\n") == 0, "分隔线可见文本: [%s]", vis); CHECK(strstr(o, "\033[2m") != NULL, "分隔线应暗色: [%s]", o); - free(vis); free(o); + free(vis); + free(o); /* 非分隔线、非列表应原样 */ o = render("--x\n", 1); @@ -287,8 +307,7 @@ static void test_blocks(void) free(o); } -static void test_passthrough(void) -{ +static void test_passthrough(void) { puts("关闭渲染时逐字节透传"); static const char *samples[] = { "# 标题 **粗体** `code`\n", @@ -300,20 +319,19 @@ static void test_passthrough(void) for (size_t k = 0; k < sizeof samples / sizeof samples[0]; k++) { char *whole = render(samples[k], 0); char *by1 = do_render(samples[k], 0, 1); - CHECK(strcmp(whole, samples[k]) == 0, - "应完全透传: 期望 [%s] 实际 [%s]", samples[k], whole); + CHECK(strcmp(whole, samples[k]) == 0, "应完全透传: 期望 [%s] 实际 [%s]", + samples[k], whole); CHECK(strcmp(by1, samples[k]) == 0, "分片透传也应一致"); - free(whole); free(by1); + free(whole); + free(by1); } } -static void test_fence_verbatim(void) -{ +static void test_fence_verbatim(void) { puts("围栏代码块: 全部不渲染"); /* 纯代码块: 输出里不应出现任何 ANSI 转义 */ char *o = render("```c\nint x = 1; // **不是粗体**\n```\n", 1); - CHECK(strstr(o, "\033") == NULL, - "代码块内不应有任何 ANSI 转义: [%s]", o); + CHECK(strstr(o, "\033") == NULL, "代码块内不应有任何 ANSI 转义: [%s]", o); CHECK(strcmp(o, "```c\nint x = 1; // **不是粗体**\n```\n") == 0, "代码块应逐字节原样: [%s]", o); free(o); @@ -322,27 +340,26 @@ static void test_fence_verbatim(void) o = render("```\n# 不是标题\n- 不是列表\n> 不是引用\n[x](y)\n```\n", 1); CHECK(strstr(o, "\033") == NULL, "代码块内不应有转义: [%s]", o); CHECK(strstr(o, "# 不是标题") && strstr(o, "[x](y)") && - strstr(o, "- 不是列表") && strstr(o, "> 不是引用"), + strstr(o, "- 不是列表") && strstr(o, "> 不是引用"), "代码块内容应完整: [%s]", o); free(o); /* 代码块结束之后, 普通文本要恢复渲染 */ o = render("```\nx\n```\n**加粗**\n", 1); - CHECK(strstr(o, "\033[1m加粗\033[0m") != NULL, - "代码块之后应恢复渲染: [%s]", o); + CHECK(strstr(o, "\033[1m加粗\033[0m") != NULL, "代码块之后应恢复渲染: [%s]", + o); free(o); } -static void test_inline_code_protected(void) -{ +static void test_inline_code_protected(void) { puts("行内代码内容不受行内语法影响"); char *o = render("形如 `a**b**c` 的写法\n", 1); - CHECK(strstr(o, "\033[1m") == NULL, - "行内代码内不应渲染粗体: [%s]", o); + CHECK(strstr(o, "\033[1m") == NULL, "行内代码内不应渲染粗体: [%s]", o); char *vis = strip_ansi(o); CHECK(strcmp(vis, "形如 `a**b**c` 的写法\n") == 0, "行内代码可见文本应原样: [%s]", vis); - free(vis); free(o); + free(vis); + free(o); o = render("`[不是链接](x)` 和 `_不是斜体_`\n", 1); CHECK(strstr(o, "\033[94m") == NULL, "行内代码内不应高亮 []: [%s]", o); @@ -350,17 +367,18 @@ static void test_inline_code_protected(void) vis = strip_ansi(o); CHECK(strcmp(vis, "`[不是链接](x)` 和 `_不是斜体_`\n") == 0, "可见文本应原样: [%s]", vis); - free(vis); free(o); + free(vis); + free(o); } -static void test_underscore_italic(void) -{ +static void test_underscore_italic(void) { puts("_斜体_ 与词内下划线保护"); char *o = render("这是 _斜体_ 文字\n", 1); CHECK(strstr(o, "\033[3m斜体\033[0m") != NULL, "_斜体_ 应渲染: [%s]", o); char *vis = strip_ansi(o); CHECK(strcmp(vis, "这是 斜体 文字\n") == 0, "可见文本: [%s]", vis); - free(vis); free(o); + free(vis); + free(o); /* 中文紧邻下划线也应生效 (非 ASCII 不算词字符) */ o = render("中文_斜体_中文\n", 1); @@ -369,15 +387,14 @@ static void test_underscore_italic(void) /* 关键保护: 下划线标识符不能被当成斜体 */ o = render("foo_bar_baz 和 __init__ 和 a_b_c\n", 1); - CHECK(strstr(o, "\033[3m") == NULL, - "下划线标识符不应被渲染为斜体: [%s]", o); + CHECK(strstr(o, "\033[3m") == NULL, "下划线标识符不应被渲染为斜体: [%s]", + o); CHECK(strcmp(o, "foo_bar_baz 和 __init__ 和 a_b_c\n") == 0, "标识符应逐字节保留: [%s]", o); free(o); } -static void test_link_bracket(void) -{ +static void test_link_bracket(void) { puts("链接: [] 下划线+青色高亮, 其余原样"); char *o = render("见 [文档](https://example.com/a) 说明\n", 1); /* 这里是下划线 + 青 (SGR 4;36), 不是亮蓝. 整个项目只用 16 色范围内的 @@ -387,13 +404,15 @@ static void test_link_bracket(void) char *vis = strip_ansi(o); CHECK(strcmp(vis, "见 [文档](https://example.com/a) 说明\n") == 0, "可见文本应逐字节保留 (含 URL): [%s]", vis); - free(vis); free(o); + free(vis); + free(o); /* 图片也是 [...], 同样只高亮方括号部分, 整段保留 */ o = render("![图](a.png)\n", 1); vis = strip_ansi(o); CHECK(strcmp(vis, "![图](a.png)\n") == 0, "图片应保留原文: [%s]", vis); - free(vis); free(o); + free(vis); + free(o); /* 下标写法不应被当成链接 */ o = render("arr[i] = br[j]\n", 1); @@ -402,73 +421,79 @@ static void test_link_bracket(void) free(o); } -static void test_tables(void) -{ +static void test_tables(void) { puts("表格"); /* ASCII 表格: 逐字节比对完整边框输出 */ char *o = render("| a | b |\n|---|---|\n| 1 | 22 |\n", 1); char *vis = strip_ansi(o); - const char *want = - "┌───┬────┐\n" - "│ a │ b │\n" - "├───┼────┤\n" - "│ 1 │ 22 │\n" - "└───┴────┘\n"; - CHECK(strcmp(vis, want) == 0, "ASCII 表格:\n期望:\n%s\n实际:\n%s", want, vis); + const char *want = "┌───┬────┐\n" + "│ a │ b │\n" + "├───┼────┤\n" + "│ 1 │ 22 │\n" + "└───┴────┘\n"; + CHECK(strcmp(vis, want) == 0, "ASCII 表格:\n期望:\n%s\n实际:\n%s", want, + vis); CHECK(strstr(o, "\033[1m") != NULL, "表头应加粗: [%s]", o); - free(vis); free(o); + free(vis); + free(o); /* 中文列宽按 2 列计算, 边框必须对齐 */ o = render("| 名称 | 值 |\n|---|---|\n| 苹果 | 3 |\n", 1); vis = strip_ansi(o); - const char *want2 = - "┌──────┬────┐\n" - "│ 名称 │ 值 │\n" - "├──────┼────┤\n" - "│ 苹果 │ 3 │\n" - "└──────┴────┘\n"; - CHECK(strcmp(vis, want2) == 0, "中文表格:\n期望:\n%s\n实际:\n%s", want2, vis); - free(vis); free(o); + const char *want2 = "┌──────┬────┐\n" + "│ 名称 │ 值 │\n" + "├──────┼────┤\n" + "│ 苹果 │ 3 │\n" + "└──────┴────┘\n"; + CHECK(strcmp(vis, want2) == 0, "中文表格:\n期望:\n%s\n实际:\n%s", want2, + vis); + free(vis); + free(o); /* 对齐标记 */ o = render("| 左 | 中 | 右 |\n|:---|:---:|---:|\n| a | b | c |\n", 1); vis = strip_ansi(o); - const char *want3 = - "┌────┬────┬────┐\n" - "│ 左 │ 中 │ 右 │\n" - "├────┼────┼────┤\n" - "│ a │ b │ c │\n" - "└────┴────┴────┘\n"; - CHECK(strcmp(vis, want3) == 0, "对齐表格:\n期望:\n%s\n实际:\n%s", want3, vis); - free(vis); free(o); + const char *want3 = "┌────┬────┬────┐\n" + "│ 左 │ 中 │ 右 │\n" + "├────┼────┼────┤\n" + "│ a │ b │ c │\n" + "└────┴────┴────┘\n"; + CHECK(strcmp(vis, want3) == 0, "对齐表格:\n期望:\n%s\n实际:\n%s", want3, + vis); + free(vis); + free(o); /* 顶格必须写 '|'. 不带首竖线的写法按普通文本处理 —— - * 这是为保证"输出与分片方式无关"而做的取舍: - * 判断必须在行的第 0 个字节就能做出, 否则分片落在竖线之前时, + * 这是为保证"输出与分片方式无关"而做的取舍: + * 判断必须在行的第 0 个字节就能做出, 否则分片落在竖线之前时, * 行的前半段已被当作普通文本输出, 结果会随分片方式而变. */ o = render("a | b\n--- | ---\n1 | 2\n", 1); vis = strip_ansi(o); CHECK(strcmp(vis, "a | b\n--- | ---\n1 | 2\n") == 0, "无首竖线应原样输出: [%s]", vis); - free(vis); free(o); + free(vis); + free(o); /* 首竖线前有缩进同理: 不认 */ o = render(" | a |\n |---|\n", 1); vis = strip_ansi(o); CHECK(strstr(vis, "┌") == NULL, "缩进的表格不识别: [%s]", vis); - free(vis); free(o); + free(vis); + free(o); /* 误判防护: 普通文本里出现竖线 + 下一行是短横线, 不能当成表格 */ o = render("运行 ls |\n-\n", 1); vis = strip_ansi(o); CHECK(strstr(vis, "┌") == NULL, "竖线+短横线不应误判成表格: [%s]", vis); - free(vis); free(o); + free(vis); + free(o); /* 单列极简表 */ o = render("| x |\n|---|\n| 1 |\n", 1); vis = strip_ansi(o); CHECK(strstr(vis, "┌───┐") != NULL, "单列表格: [%s]", vis); - free(vis); free(o); + free(vis); + free(o); /* 没有分隔行就不是表格 */ o = render("a | b\nc | d\n", 1); @@ -485,17 +510,18 @@ static void test_tables(void) vis = strip_ansi(o); CHECK(strstr(vis, "└───┴───┘\n后续段落 粗体\n") != NULL, "表格后应恢复普通渲染: [%s]", vis); - free(vis); free(o); + free(vis); + free(o); } -static void test_long_line_bounded(void) -{ +static void test_long_line_bounded(void) { puts("超长待处理缓冲应有界"); /* 一个没有换行的超长 fence 信息行: 渲染器不能无限缓存 */ StrBuf sb; sb_init(&sb); sb_puts(&sb, "```"); - for (int i = 0; i < 200000; i++) sb_putc(&sb, 'x'); + for (int i = 0; i < 200000; i++) + sb_putc(&sb, 'x'); char *o = render(sb.data, 1); size_t n = strlen(o); CHECK(n > 100000, "应该已经把内容吐出, 而不是无限缓存 (实际 %zu 字节)", n); @@ -503,8 +529,7 @@ static void test_long_line_bounded(void) sb_free(&sb); } -int main(void) -{ +int main(void) { test_split_invariance(); test_headings(); test_bold_italic(); diff --git a/tests/test_meta.c b/tests/test_meta.c index 4ed7d1a..449b209 100644 --- a/tests/test_meta.c +++ b/tests/test_meta.c @@ -1,8 +1,8 @@ /* test_meta.c - meta 历史编辑: 轮次切分、结构安全、各工具的行为 */ -#include "meta.h" -#include "json.h" -#include "util.h" #include "agent.h" +#include "json.h" +#include "meta.h" +#include "util.h" #include #include @@ -11,20 +11,19 @@ static int failures = 0; static int checks = 0; -#define CHECK(cond, ...) \ - do { \ - checks++; \ - if (!(cond)) { \ - failures++; \ - printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ - printf(__VA_ARGS__); \ - printf("\n"); \ - } \ +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ } while (0) /* 造一条消息 */ -static JsonValue *msg(const char *role, const char *content) -{ +static JsonValue *msg(const char *role, const char *content) { JsonValue *m = json_new_object(); json_object_set_string(m, "role", role); json_object_set_string(m, "content", content); @@ -32,8 +31,8 @@ static JsonValue *msg(const char *role, const char *content) } /* assistant 带一个 tool_call */ -static JsonValue *assistant_call(const char *id, const char *name, const char *args) -{ +static JsonValue *assistant_call(const char *id, const char *name, + const char *args) { JsonValue *m = json_new_object(); json_object_set_string(m, "role", "assistant"); json_object_set_string(m, "content", ""); @@ -50,7 +49,7 @@ static JsonValue *assistant_call(const char *id, const char *name, const char *a return m; } -/* 造一段典型历史: +/* 造一段典型历史: * #0 system * #1 user A ← 轮 1 * #2 assistant(tool_calls) @@ -61,12 +60,12 @@ static JsonValue *assistant_call(const char *id, const char *name, const char *a * #7 user C ← 轮 3 * #8 assistant 回答 */ -static JsonValue *sample(void) -{ +static JsonValue *sample(void) { JsonValue *a = json_new_array(); json_array_push(a, msg("system", "你是助手")); json_array_push(a, msg("user", "帮我用 Python 写")); - json_array_push(a, assistant_call("call_1", "run_command", "{\"command\":\"ls\"}")); + json_array_push( + a, assistant_call("call_1", "run_command", "{\"command\":\"ls\"}")); json_array_push(a, msg("tool", "file1\nfile2")); json_array_push(a, msg("assistant", "写好了")); json_array_push(a, msg("user", "改用 C")); @@ -76,13 +75,13 @@ static JsonValue *sample(void) return a; } -static void test_rounds(void) -{ +static void test_rounds(void) { puts("轮次切分"); JsonValue *m = sample(); MetaRounds r; CHECK(meta_rounds_build(m, &r) == 1, "应该能切出轮次"); - CHECK(r.head == 1, "system 之后的 #1 才是第一条可编辑消息, 实际 %zu", r.head); + CHECK(r.head == 1, "system 之后的 #1 才是第一条可编辑消息, 实际 %zu", + r.head); CHECK(r.count == 3, "应切出 3 轮, 实际 %zu", r.count); CHECK(r.starts[0] == 1, "轮 1 从 #1 开始, 实际 %zu", r.starts[0]); CHECK(r.starts[1] == 5, "轮 2 从 #5 开始, 实际 %zu", r.starts[1]); @@ -105,14 +104,17 @@ static void test_rounds(void) meta_rounds_free(&r); json_free(only); - /* read_image 补的那条"只有图片附件、没有文字"的 user 消息**不算新的一轮** —— - * 判据必须与主循环 (agent_opens_round)一致, 否则 /meta 报的轮号会和主循环的 - * 口径对不上, 模型照轮号删下去, 删掉的不是它以为的那一轮. - * 结构: system / user(text) / assistant(tool_calls) / tool / user(只有图) / assistant */ + /* read_image 补的那条"只有图片附件、没有文字"的 user 消息**不算新的一轮** + * —— 判据必须与主循环 (agent_opens_round)一致, 否则 /meta + * 报的轮号会和主循环的 口径对不上, 模型照轮号删下去, + * 删掉的不是它以为的那一轮. 结构: system / user(text) / + * assistant(tool_calls) / tool / user(只有图) / assistant */ JsonValue *withimg = json_new_array(); json_array_push(withimg, msg("system", "s")); json_array_push(withimg, msg("user", "看图")); - json_array_push(withimg, msg("assistant", "ok")); /* 占位, 模拟带 tool_calls 的轮内消息 */ + json_array_push( + withimg, + msg("assistant", "ok")); /* 占位, 模拟带 tool_calls 的轮内消息 */ json_array_push(withimg, msg("tool", "结果")); { JsonValue *att = json_new_object(); @@ -126,15 +128,15 @@ static void test_rounds(void) } json_array_push(withimg, msg("assistant", "看到图了")); CHECK(meta_rounds_build(withimg, &r) == 1, "带附件的历史也应该能切"); - CHECK(r.count == 1, "只有附件的那条 user 不该算新的一轮, 实际 %zu 轮", r.count); + CHECK(r.count == 1, "只有附件的那条 user 不该算新的一轮, 实际 %zu 轮", + r.count); meta_rounds_free(&r); json_free(withimg); json_free(m); } -static void test_deletable(void) -{ +static void test_deletable(void) { puts("结构安全: 哪些消息能单独删"); JsonValue *m = sample(); CHECK(meta_deletable(m, 0) == 0, "system 不可删"); @@ -148,11 +150,13 @@ static void test_deletable(void) /* ---- 工具驱动 ---- */ -static char *call(Agent *a, const char *name, const char *json) -{ +static char *call(Agent *a, const char *name, const char *json) { char err[256]; JsonValue *args = json_parse(json, err, sizeof err); - if (!args) { printf(" (测试自身出错: %s)\n", err); return xstrdup(""); } + if (!args) { + printf(" (测试自身出错: %s)\n", err); + return xstrdup(""); + } int is_err = 0; char *r = meta_tools_call(name, args, &is_err); json_free(args); @@ -160,37 +164,43 @@ static char *call(Agent *a, const char *name, const char *json) } /* 把一段历史挂上, 跑 body, 再摘下来 */ -#define WITH_TOOLS(a) \ - do { \ - CHECK(meta_tools_attach(a) == 0, "attach 应成功"); \ - CHECK(meta_tools_attach(a) != 0, "重复 attach 应被拒绝"); \ +#define WITH_TOOLS(a) \ + do { \ + CHECK(meta_tools_attach(a) == 0, "attach 应成功"); \ + CHECK(meta_tools_attach(a) != 0, "重复 attach 应被拒绝"); \ } while (0) -static void test_replace(void) -{ +static void test_replace(void) { puts("replace_message"); - Agent a; agent_init(&a); - json_free(a.messages); a.messages = sample(); + Agent a; + agent_init(&a); + json_free(a.messages); + a.messages = sample(); meta_tools_attach(&a); int err = 0; - char *r = call(&a, "replace_message", "{\"index\":1,\"content\":\"帮我用 C 写\"}"); - CHECK(err == 0 && r && strstr(r, "已改写") != NULL, "应改写成功: %s", r ? r : ""); + char *r = call(&a, "replace_message", + "{\"index\":1,\"content\":\"帮我用 C 写\"}"); + CHECK(err == 0 && r && strstr(r, "已改写") != NULL, "应改写成功: %s", + r ? r : ""); JsonValue *m1 = json_array_get(a.messages, 1); - CHECK(strcmp(json_get_string(m1, "content"), "帮我用 C 写") == 0, "内容应被替换"); + CHECK(strcmp(json_get_string(m1, "content"), "帮我用 C 写") == 0, + "内容应被替换"); CHECK(strcmp(json_get_string(m1, "role"), "user") == 0, "角色不该变"); free(r); /* 改 assistant 说的话 —— 这是 meta 存在的理由 */ r = call(&a, "replace_message", "{\"index\":4,\"content\":\"改好了\"}"); CHECK(r && strstr(r, "已改写") != NULL, "应能改写 assistant 消息"); - CHECK(strcmp(json_get_string(json_array_get(a.messages, 4), "content"), "改好了") == 0, + CHECK(strcmp(json_get_string(json_array_get(a.messages, 4), "content"), + "改好了") == 0, "assistant 内容应被替换"); free(r); /* tool 结果和 system 都不该被动 */ r = call(&a, "replace_message", "{\"index\":3,\"content\":\"x\"}"); - CHECK(r && strstr(r, "tool 结果") != NULL, "改 tool 结果应被拒绝: %s", r ? r : ""); + CHECK(r && strstr(r, "tool 结果") != NULL, "改 tool 结果应被拒绝: %s", + r ? r : ""); free(r); r = call(&a, "replace_message", "{\"index\":0,\"content\":\"x\"}"); CHECK(r && strstr(r, "system") != NULL, "改 system 应被拒绝"); @@ -205,16 +215,18 @@ static void test_replace(void) agent_free(&a); } -static void test_delete_safety(void) -{ +static void test_delete_safety(void) { puts("delete_messages 的结构安全"); - Agent a; agent_init(&a); - json_free(a.messages); a.messages = sample(); + Agent a; + agent_init(&a); + json_free(a.messages); + a.messages = sample(); meta_tools_attach(&a); size_t before = json_array_size(a.messages); char *r = call(&a, "delete_messages", "{\"indices\":[2]}"); - CHECK(r && strstr(r, "不可拆") != NULL, "带 tool_calls 的 assistant 应被拒绝"); + CHECK(r && strstr(r, "不可拆") != NULL, + "带 tool_calls 的 assistant 应被拒绝"); CHECK(json_array_size(a.messages) == before, "被拒绝时不该改动任何东西"); free(r); @@ -231,8 +243,10 @@ static void test_delete_safety(void) /* 合法删除: 删掉第 1 轮里那句 user, 并给上下文提示 */ r = call(&a, "delete_messages", "{\"indices\":[1]}"); - CHECK(r && strstr(r, "已删除 1 条") != NULL, "合法删除应成功: %s", r ? r : ""); - CHECK(strstr(r, "没有对应的提问") != NULL, "删掉提问后应提示后续回答失去上下文"); + CHECK(r && strstr(r, "已删除 1 条") != NULL, "合法删除应成功: %s", + r ? r : ""); + CHECK(strstr(r, "没有对应的提问") != NULL, + "删掉提问后应提示后续回答失去上下文"); CHECK(json_array_size(a.messages) == before - 1, "条数应减一"); free(r); @@ -245,20 +259,23 @@ static void test_delete_safety(void) agent_free(&a); } -static void test_delete_rounds(void) -{ +static void test_delete_rounds(void) { puts("delete_rounds"); - Agent a; agent_init(&a); - json_free(a.messages); a.messages = sample(); + Agent a; + agent_init(&a); + json_free(a.messages); + a.messages = sample(); meta_tools_attach(&a); size_t before = json_array_size(a.messages); char *r = call(&a, "delete_rounds", "{\"from\":2,\"to\":2}"); - CHECK(r && strstr(r, "已删除轮 2..2") != NULL, "应删掉第 2 轮: %s", r ? r : ""); + CHECK(r && strstr(r, "已删除轮 2..2") != NULL, "应删掉第 2 轮: %s", + r ? r : ""); CHECK(json_array_size(a.messages) == before - 2, "第 2 轮是 2 条消息"); /* 剩下的结构必须还是自洽的: #2 的 tool_calls 后面仍跟着 tool */ CHECK(meta_deletable(a.messages, 2) == 0, "#2 仍应是结构消息"); - CHECK(strcmp(json_get_string(json_array_get(a.messages, 3), "role"), "tool") == 0, + CHECK(strcmp(json_get_string(json_array_get(a.messages, 3), "role"), + "tool") == 0, "tool 结果应仍紧跟它的调用"); free(r); @@ -276,24 +293,29 @@ static void test_delete_rounds(void) agent_free(&a); } -static void test_insert_and_undo(void) -{ +static void test_insert_and_undo(void) { puts("insert_message 与 undo"); - Agent a; agent_init(&a); - json_free(a.messages); a.messages = sample(); + Agent a; + agent_init(&a); + json_free(a.messages); + a.messages = sample(); meta_tools_attach(&a); size_t before = json_array_size(a.messages); - char *r = call(&a, "insert_message", - "{\"after\":8,\"role\":\"user\",\"content\":\"这个项目必须用 C99\"}"); - CHECK(r && strstr(r, "插入一条 user") != NULL, "应插入成功: %s", r ? r : ""); + char *r = call( + &a, "insert_message", + "{\"after\":8,\"role\":\"user\",\"content\":\"这个项目必须用 C99\"}"); + CHECK(r && strstr(r, "插入一条 user") != NULL, "应插入成功: %s", + r ? r : ""); CHECK(json_array_size(a.messages) == before + 1, "条数应加一"); CHECK(strcmp(json_get_string(json_array_get(a.messages, 9), "content"), - "这个项目必须用 C99") == 0, "应插在 #8 之后"); + "这个项目必须用 C99") == 0, + "应插在 #8 之后"); free(r); /* tool 角色不能凭空插 —— 没有配对的 tool_calls */ - r = call(&a, "insert_message", "{\"after\":0,\"role\":\"tool\",\"content\":\"x\"}"); + r = call(&a, "insert_message", + "{\"after\":0,\"role\":\"tool\",\"content\":\"x\"}"); CHECK(r && strstr(r, "role 只能是") != NULL, "插 tool 消息应被拒绝"); free(r); @@ -313,21 +335,24 @@ static void test_insert_and_undo(void) agent_free(&a); } -static void test_read_tools(void) -{ +static void test_read_tools(void) { puts("list_rounds / read_messages (只读)"); - Agent a; agent_init(&a); - json_free(a.messages); a.messages = sample(); + Agent a; + agent_init(&a); + json_free(a.messages); + a.messages = sample(); meta_tools_attach(&a); char *r = call(&a, "list_rounds", "{}"); CHECK(r && strstr(r, "共 3 轮") != NULL, "应报告 3 轮: %s", r ? r : ""); - CHECK(r && strstr(r, "轮 1") != NULL && strstr(r, "轮 3") != NULL, "应列出每轮"); + CHECK(r && strstr(r, "轮 1") != NULL && strstr(r, "轮 3") != NULL, + "应列出每轮"); CHECK(r && strstr(r, "Python") != NULL, "应带开头预览"); free(r); r = call(&a, "list_rounds", "{\"from\":2,\"to\":2}"); - CHECK(r && strstr(r, "轮 2") != NULL && strstr(r, "轮 3") == NULL, "应只列第 2 轮"); + CHECK(r && strstr(r, "轮 2") != NULL && strstr(r, "轮 3") == NULL, + "应只列第 2 轮"); free(r); r = call(&a, "read_messages", "{\"from\":2,\"to\":3}"); @@ -337,9 +362,11 @@ static void test_read_tools(void) free(r); CHECK(meta_tools_mutating("list_rounds") == 0, "list_rounds 不是写操作"); - CHECK(meta_tools_mutating("read_messages") == 0, "read_messages 不是写操作"); + CHECK(meta_tools_mutating("read_messages") == 0, + "read_messages 不是写操作"); CHECK(meta_tools_mutating("delete_rounds") == 1, "delete_rounds 是写操作"); - CHECK(meta_tools_mutating("replace_message") == 1, "replace_message 是写操作"); + CHECK(meta_tools_mutating("replace_message") == 1, + "replace_message 是写操作"); CHECK(meta_tools_mutating("不存在的工具") == 0, "未知工具不该被当成写操作"); r = call(&a, "delete_rounds_but_typo", "{}"); @@ -350,16 +377,17 @@ static void test_read_tools(void) agent_free(&a); } -static void test_reroot_flag(void) -{ +static void test_reroot_flag(void) { puts("重新存根标记"); - Agent a; agent_init(&a); - json_free(a.messages); a.messages = sample(); + Agent a; + agent_init(&a); + json_free(a.messages); + a.messages = sample(); meta_reroot_clear(); CHECK(meta_reroot_pending() == 0, "初始不该有标记"); meta_tools_attach(&a); - char *r = call(&a, "list_rounds", "{}"); /* 只读, 不该打标记 */ + char *r = call(&a, "list_rounds", "{}"); /* 只读, 不该打标记 */ free(r); CHECK(meta_reroot_pending() == 0, "只读操作不该打标记"); @@ -372,8 +400,7 @@ static void test_reroot_flag(void) meta_reroot_clear(); } -int main(void) -{ +int main(void) { test_rounds(); test_deletable(); test_read_tools(); diff --git a/tests/test_models.c b/tests/test_models.c index a42d101..958c0a7 100644 --- a/tests/test_models.c +++ b/tests/test_models.c @@ -1,12 +1,12 @@ /* test_models.c - /models 响应体的解析 * * 只测纯解析. 拉取 (GET {base_url}/models)要走网络, 由 e2e 第 52 段对着 - * mock 覆盖, 那里还顺带验了交互式选择本身 (方向键、确认、取消). + * mock 覆盖, 那里还顺带验了交互式选择本身 (方向键、确认、取消). * * 为什么要容忍这么多种形状: OpenAI 兼容是个很大的家庭, /models 的返回花样 - * 比文档里写的多 —— 见过裸数组、见过重复列同一模型、见过元素里没有 id. + * 比文档里写的多 —— 见过裸数组、见过重复列同一模型、见过元素里没有 id. * 解析器要么跳过, 要么去重, 但**不能**因此整个失败: 列表里少一个模型远好过 - * 一个都选不了. + * 一个都选不了. */ #include "models.h" @@ -16,19 +16,18 @@ static int failures = 0; static int checks = 0; -#define CHECK(cond, ...) \ - do { \ - checks++; \ - if (!(cond)) { \ - failures++; \ - printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ - printf(__VA_ARGS__); \ - printf("\n"); \ - } \ +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ } while (0) -static void test_standard(void) -{ +static void test_standard(void) { puts("标准形状: {\"data\":[{\"id\":...}]}"); const char *json = "{\"object\":\"list\",\"data\":[" @@ -39,20 +38,19 @@ static void test_standard(void) char **l = models_parse(json, &n); CHECK(l && n == 2, "应解析出 2 个, 实际 %zu", n); if (l && n == 2) { - CHECK(strcmp(l[0], "alpha") == 0, "顺序应保持服务端给的 (第一个 alpha)"); + CHECK(strcmp(l[0], "alpha") == 0, + "顺序应保持服务端给的 (第一个 alpha)"); CHECK(strcmp(l[1], "beta") == 0, "第二个 beta"); } models_free(l, n); } -static void test_dedupe_and_skip(void) -{ +static void test_dedupe_and_skip(void) { puts("重复项要去重, 缺 id 的要跳过"); - const char *json = - "{\"data\":[" - "{\"id\":\"a\"},{\"id\":\"a\"},{\"object\":\"model\"}," - "{\"id\":\"\"},{\"id\":\"b\"},{\"id\":42}" - "]}"; + const char *json = "{\"data\":[" + "{\"id\":\"a\"},{\"id\":\"a\"},{\"object\":\"model\"}," + "{\"id\":\"\"},{\"id\":\"b\"},{\"id\":42}" + "]}"; size_t n = 0; char **l = models_parse(json, &n); CHECK(l && n == 2, "去重 + 跳过后应剩 2 个, 实际 %zu", n); @@ -63,8 +61,7 @@ static void test_dedupe_and_skip(void) models_free(l, n); } -static void test_bare_array(void) -{ +static void test_bare_array(void) { puts("兼容裸数组 (字符串 / 对象都认)"); size_t n = 0; char **l = models_parse("[\"alpha\",\"beta\"]", &n); @@ -77,8 +74,7 @@ static void test_bare_array(void) models_free(l, n); } -static void test_bad_input(void) -{ +static void test_bad_input(void) { puts("拿不到模型时返回 NULL, 而不是空数组或崩溃"); size_t n = 123; CHECK(models_parse(NULL, &n) == NULL && n == 0, "NULL 输入"); @@ -96,7 +92,8 @@ static void test_bad_input(void) CHECK(models_parse("{\"data\":\"nope\"}", &n) == NULL && n == 0, "data 不是数组"); n = 123; - CHECK(models_parse("{\"data\":[{\"object\":\"model\"}]}", &n) == NULL && n == 0, + CHECK(models_parse("{\"data\":[{\"object\":\"model\"}]}", &n) == NULL && + n == 0, "全是坏条目"); /* out_n 也可为 NULL (调用方只关心数组) */ @@ -122,8 +119,7 @@ static void test_bad_input(void) CHECK(1, "释放 NULL 没崩"); } -int main(void) -{ +int main(void) { test_standard(); test_dedupe_and_skip(); test_bare_array(); diff --git a/tests/test_priv.c b/tests/test_priv.c index 23dde8d..65a0696 100644 --- a/tests/test_priv.c +++ b/tests/test_priv.c @@ -1,12 +1,12 @@ /* test_priv.c - 提权模块里那几条"不能搞错"的不变量 * * 这个模块摔过一跤: 上一版把认证路径也 setsid + 接管道, 于是 sudo 拿不到 - * 控制终端, 真机上认证 100% 失败 (而替身 sudo 无条件成功, 给了个假绿). - * 所以这里把两条硬不变量钉住: + * 控制终端, 真机上认证 100% 失败 (而替身 sudo 无条件成功, 给了个假绿). + * 所以这里把两条硬不变量钉住: * - * 1. **提权调用里不能有 -n**. 带 -n 就等于"不许提问", 需要密码时直接失败, - * 而用户要的恰恰是"提权要有身份验证". - * 2. **没有控制终端时能识别出来**. 识别不出来就会起一个等不到输入的进程. + * 1. **提权调用里不能有 -n**. 带 -n 就等于"不许提问", 需要密码时直接失败, + * 而用户要的恰恰是"提权要有身份验证". + * 2. **没有控制终端时能识别出来**. 识别不出来就会起一个等不到输入的进程. */ #include "priv.h" #include "util.h" @@ -20,31 +20,32 @@ static int failures = 0; static int checks = 0; -#define CHECK(cond, ...) \ - do { \ - checks++; \ - if (!(cond)) { \ - failures++; \ - printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ - printf(__VA_ARGS__); \ - printf("\n"); \ - } \ +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ } while (0) -static int argv_has(char *const *a, const char *want) -{ +static int argv_has(char *const *a, const char *want) { for (size_t i = 0; a[i]; i++) - if (strcmp(a[i], want) == 0) return 1; + if (strcmp(a[i], want) == 0) + return 1; return 0; } -int main(void) -{ +int main(void) { puts("包装成 sudo -u <用户> -- "); - /* 显式转换, 和 priv.c 里 w[0] = (char *)"sudo" 是同一个理由: + /* 显式转换, 和 priv.c 里 w[0] = (char *)"sudo" 是同一个理由: * priv_sudo_argv 的签名要 char *const[] (execvp 的要求), 而这里放进去的 - * 字面量没有任何人会改写. 把整条链路改成非 const 才是真的丢 const 正确性. */ - char *plain[] = { (char *)"/bin/sh", (char *)"-c", (char *)"rm -rf /tmp/x", NULL }; + * 字面量没有任何人会改写. 把整条链路改成非 const 才是真的丢 const 正确性. + */ + char *plain[] = {(char *)"/bin/sh", (char *)"-c", (char *)"rm -rf /tmp/x", + NULL}; char **w = priv_sudo_argv("root", plain); CHECK(w != NULL, "应当返回数组"); CHECK(strcmp(w[0], "sudo") == 0, "第一个是 sudo, 实际 %s", w[0]); @@ -53,7 +54,7 @@ int main(void) CHECK(strcmp(w[3], "--") == 0, "必须有 -- : 否则命令里的选项会被 sudo 当成自己的"); CHECK(strcmp(w[4], "/bin/sh") == 0 && strcmp(w[5], "-c") == 0 && - strcmp(w[6], "rm -rf /tmp/x") == 0, + strcmp(w[6], "rm -rf /tmp/x") == 0, "原 argv 原样接在后面, 不被拆开"); CHECK(w[7] == NULL, "数组要 NULL 结尾"); /* 命门外的那条不变量 */ @@ -75,7 +76,8 @@ int main(void) puts("sudo 的失败原话要换成能照做的中文"); char err[512]; memset(err, 0, sizeof err); - priv_explain(1, "sudo: no tty present and no askpass program specified", err, sizeof err); + priv_explain(1, "sudo: no tty present and no askpass program specified", + err, sizeof err); CHECK(strstr(err, "终端") != NULL, "缺终端要说终端, 实际 %s", err); priv_explain(1, "Sorry, try again.", err, sizeof err); CHECK(strstr(err, "认证") != NULL, "认证失败要说认证, 实际 %s", err); @@ -86,19 +88,27 @@ int main(void) priv_explain(1, "some brand new message", err, sizeof err); CHECK(strstr(err, "some brand new message") != NULL, "认不出来要透传原文, 实际 %s", err); - priv_explain(127, "无法执行 sudo: No such file or directory", err, sizeof err); - CHECK(strstr(err, "没有 sudo") != NULL, "没有 sudo 时要说清楚, 实际 %s", err); + priv_explain(127, "无法执行 sudo: No such file or directory", err, + sizeof err); + CHECK(strstr(err, "没有 sudo") != NULL, "没有 sudo 时要说清楚, 实际 %s", + err); puts("没有控制终端时能识别出来"); int fds[2]; - if (pipe(fds) != 0) { perror("pipe"); return 2; } + if (pipe(fds) != 0) { + perror("pipe"); + return 2; + } pid_t pid = fork(); - if (pid < 0) { perror("fork"); return 2; } + if (pid < 0) { + perror("fork"); + return 2; + } if (pid == 0) { close(fds[0]); int rc = -1; - if (setsid() >= 0) { /* 摘掉控制终端 */ - rc = priv_have_tty(); /* 必须是 0 */ + if (setsid() >= 0) { /* 摘掉控制终端 */ + rc = priv_have_tty(); /* 必须是 0 */ } ssize_t wr = write(fds[1], &rc, sizeof rc); (void)wr; @@ -109,7 +119,8 @@ int main(void) int got = -1; ssize_t rd = read(fds[0], &got, sizeof got); close(fds[0]); - while (waitpid(pid, NULL, 0) < 0) { } + while (waitpid(pid, NULL, 0) < 0) { + } CHECK(rd == (ssize_t)sizeof got && got == 0, "setsid() 之后不该认为有终端 (识别不出来就会卡住)"); diff --git a/tests/test_spinner.c b/tests/test_spinner.c index 54bcb32..37a723d 100644 --- a/tests/test_spinner.c +++ b/tests/test_spinner.c @@ -1,45 +1,47 @@ /* test_spinner.c - 等待动画的两段式标签 * * 动画写的是 stderr, 所以这里把 stderr 挪到一个临时文件, 驱动几轮之后 - * 再把内容读回来断言. spinner_init(1) 是显式开关, 测试里不依赖 isatty. + * 再把内容读回来断言. spinner_init(1) 是显式开关, 测试里不依赖 isatty. */ #include "spinner.h" #include #include #include -#include #include +#include static int failures = 0; static int checks = 0; -#define CHECK(cond, ...) \ - do { \ - checks++; \ - if (!(cond)) { \ - failures++; \ - printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ - printf(__VA_ARGS__); \ - printf("\n"); \ - } \ +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ } while (0) -/* 把 stderr 接到一个临时文件上, 返回文件描述符; *saved 收原来的 stderr. +/* 把 stderr 接到一个临时文件上, 返回文件描述符; *saved 收原来的 stderr. * 用完必须调用 capture_end 还原, 否则后面的失败信息也会被吞掉. */ -static int capture_begin(char *path, size_t pathlen, int *saved) -{ +static int capture_begin(char *path, size_t pathlen, int *saved) { snprintf(path, pathlen, "/tmp/ligent_spin_XXXXXX"); int fd = mkstemp(path); - if (fd < 0) return -1; + if (fd < 0) + return -1; *saved = dup(STDERR_FILENO); - if (*saved < 0) { close(fd); return -1; } + if (*saved < 0) { + close(fd); + return -1; + } dup2(fd, STDERR_FILENO); return fd; } -static void capture_end(int fd, int saved, char *buf, size_t buflen) -{ +static void capture_end(int fd, int saved, char *buf, size_t buflen) { fflush(stderr); dup2(saved, STDERR_FILENO); close(saved); @@ -49,14 +51,14 @@ static void capture_end(int fd, int saved, char *buf, size_t buflen) close(fd); } -static void test_two_phase(void) -{ +static void test_two_phase(void) { puts("两段式标签: 等待响应 → 思考中"); char path[64]; int saved = -1; int fd = capture_begin(path, sizeof path, &saved); CHECK(fd >= 0, "无法重定向 stderr"); - if (fd < 0) return; + if (fd < 0) + return; spinner_init(1); spinner_start("等待响应"); @@ -81,17 +83,18 @@ static void test_two_phase(void) CHECK(a && b && a < b, "先\"等待响应\"后\"思考中\", 顺序反了"); CHECK(strstr(buf, "\r\033[K") != NULL, "每帧应先回车清行再打印"); /* 停在"思考中"之后就不该再有"等待响应"冒出来 */ - if (b) CHECK(strstr(b, "等待响应") == NULL, "换标签后不应回退到\"等待响应\""); + if (b) + CHECK(strstr(b, "等待响应") == NULL, "换标签后不应回退到\"等待响应\""); } -static void test_idempotent(void) -{ +static void test_idempotent(void) { puts("未开始/未启用时的行为"); char path[64]; int saved = -1; int fd = capture_begin(path, sizeof path, &saved); CHECK(fd >= 0, "无法重定向 stderr"); - if (fd < 0) return; + if (fd < 0) + return; /* 没 start 就 set: 只记标签, 不该打印任何东西, 也不该崩 */ spinner_init(1); @@ -121,24 +124,23 @@ static void test_idempotent(void) CHECK(1, "关闭状态下调用不崩溃"); } -static double now_sec(void) -{ +static double now_sec(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9; } -/* spinner_stop 要 pthread_join worker. 如果 worker 是 nanosleep 一整帧, +/* spinner_stop 要 pthread_join worker. 如果 worker 是 nanosleep 一整帧, * 这里就得干等最多 250ms —— 而这笔账会加在每一轮模型往返、每一次工具 * 来回的用户感知延迟上. */ -static void test_stop_is_prompt(void) -{ +static void test_stop_is_prompt(void) { puts("stop 不该等满一帧 (250ms)"); char path[64]; int saved = -1; int fd = capture_begin(path, sizeof path, &saved); CHECK(fd >= 0, "无法重定向 stderr"); - if (fd < 0) return; + if (fd < 0) + return; spinner_init(1); double worst = 0.0; @@ -147,31 +149,33 @@ static void test_stop_is_prompt(void) spinner_start("等待响应"); spinner_stop(); double dt = now_sec() - t0; - if (dt > worst) worst = dt; + if (dt > worst) + worst = dt; } char buf[4096]; capture_end(fd, saved, buf, sizeof buf); unlink(path); - CHECK(worst < 0.10, "stop 应立即返回, 最慢一次 %.3fs (帧间隔是 0.25s)", worst); + CHECK(worst < 0.10, "stop 应立即返回, 最慢一次 %.3fs (帧间隔是 0.25s)", + worst); } /* 帧率必须受 250ms 限制. 曾经踩过一次: pthread_cond_timedwait 默认按 * CLOCK_REALTIME 解释超时, 而传进去的是 CLOCK_MONOTONIC 的绝对时间 * (开机时长永远小于 epoch 时间), 于是超时永远"已经过了" —— worker * 变成疯狂重绘的忙循环, 把 pty 刷屏. */ -static void test_frame_rate_is_bounded(void) -{ +static void test_frame_rate_is_bounded(void) { puts("帧率受 250ms 限制, 不能狂刷"); char path[64]; int saved = -1; int fd = capture_begin(path, sizeof path, &saved); CHECK(fd >= 0, "无法重定向 stderr"); - if (fd < 0) return; + if (fd < 0) + return; spinner_init(1); spinner_start("等待响应"); - usleep(600 * 1000); /* 0.6 秒 ≈ 2~3 帧 */ + usleep(600 * 1000); /* 0.6 秒 ≈ 2~3 帧 */ spinner_stop(); char buf[65536]; @@ -179,7 +183,8 @@ static void test_frame_rate_is_bounded(void) unlink(path); int frames = 0; - for (const char *p = buf; (p = strstr(p, "等待响应")); p++) frames++; + for (const char *p = buf; (p = strstr(p, "等待响应")); p++) + frames++; CHECK(frames >= 1, "至少该画一帧, 实际 %d", frames); CHECK(frames <= 5, "0.6 秒内不该超过 5 帧, 实际 %d (忙循环?)", frames); } @@ -188,18 +193,18 @@ static void test_frame_rate_is_bounded(void) * 自己 —— 而 spinner_start 曾经先执行 g_label[0] = '\0' 再读那个 label, 于是 * 标签被自己清空、退化成默认的"思考中". 症状: 每次 HTTP 重试退避之后, 屏幕上 * 的"等待响应"都会变成"思考中", 而这两段等待的含义恰恰是它要区分的. */ -static void test_notice_keeps_label(void) -{ +static void test_notice_keeps_label(void) { puts("notice 之后标签保持不变"); char path[64]; int saved = -1; int fd = capture_begin(path, sizeof path, &saved); CHECK(fd >= 0, "无法重定向 stderr"); - if (fd < 0) return; + if (fd < 0) + return; spinner_init(1); spinner_start("自定义标签A"); - usleep(300 * 1000); /* 让它画至少一帧 */ + usleep(300 * 1000); /* 让它画至少一帧 */ spinner_notice("一条插进来的提示"); usleep(300 * 1000); spinner_stop(); @@ -217,8 +222,7 @@ static void test_notice_keeps_label(void) noticed); } -int main(void) -{ +int main(void) { test_two_phase(); test_idempotent(); test_stop_is_prompt(); diff --git a/tests/test_tools.c b/tests/test_tools.c index 9789e6d..b818ed4 100644 --- a/tests/test_tools.c +++ b/tests/test_tools.c @@ -1,31 +1,30 @@ /* test_tools.c - 工具层测试 */ -#include "tools.h" #include "image.h" #include "json.h" +#include "tools.h" #include "util.h" #include #include #include -#include #include +#include static int failures = 0; static int checks = 0; -#define CHECK(cond, ...) \ - do { \ - checks++; \ - if (!(cond)) { \ - failures++; \ - printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ - printf(__VA_ARGS__); \ - printf("\n"); \ - } \ +#define CHECK(cond, ...) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ } while (0) -static char *call(const char *name, const char *args_json, int *is_error) -{ +static char *call(const char *name, const char *args_json, int *is_error) { char err[256]; JsonValue *args = json_parse(args_json, err, sizeof err); if (!args) { @@ -37,12 +36,17 @@ static char *call(const char *name, const char *args_json, int *is_error) return r; } -int main(void) -{ +int main(void) { char tmpl[] = "/tmp/ligent_test_XXXXXX"; char *dir = mkdtemp(tmpl); - if (!dir) { perror("mkdtemp"); return 2; } - if (chdir(dir) != 0) { perror("chdir"); return 2; } + if (!dir) { + perror("mkdtemp"); + return 2; + } + if (chdir(dir) != 0) { + perror("chdir"); + return 2; + } /* ---- 工具 schema 合法性 ---- */ puts("tools_schema"); @@ -62,19 +66,22 @@ int main(void) { int has_image = 0; for (size_t i = 0; i < json_array_size(schema); i++) { - JsonValue *fn = json_object_get(json_array_get(schema, i), "function"); + JsonValue *fn = + json_object_get(json_array_get(schema, i), "function"); const char *nm = json_get_string(fn, "name"); - if (nm && strcmp(nm, "read_image") == 0) has_image = 1; + if (nm && strcmp(nm, "read_image") == 0) + has_image = 1; } CHECK(has_image, "schema 里应有 read_image"); } { - /* line 是集合型参数: schema 里必须是 array (元素 integer), + /* line 是集合型参数: schema 里必须是 array (元素 integer), * 否则模型根本不会想到可以给数组 —— 参数表的 "integer[]" 写法 * 只有 schema 展开对了才作数 */ JsonValue *props = NULL, *reqd = NULL; for (size_t i = 0; i < json_array_size(schema); i++) { - JsonValue *fn = json_object_get(json_array_get(schema, i), "function"); + JsonValue *fn = + json_object_get(json_array_get(schema, i), "function"); const char *nm = json_get_string(fn, "name"); if (nm && strcmp(nm, "edit_file") == 0) { JsonValue *par = json_object_get(fn, "parameters"); @@ -86,18 +93,19 @@ int main(void) int line_required = 0; for (size_t i = 0; i < json_array_size(reqd); i++) { const char *nm = json_string_value(json_array_get(reqd, i)); - if (nm && strcmp(nm, "line") == 0) line_required = 1; + if (nm && strcmp(nm, "line") == 0) + line_required = 1; } CHECK(!line_required, "line 可以省略 (= 全文件替换), 不该是 required"); JsonValue *line = props ? json_object_get(props, "line") : NULL; const char *lt = line ? json_get_string(line, "type") : NULL; - CHECK(lt && strcmp(lt, "array") == 0, "edit_file 的 line 应是 array, 实际 %s", - lt ? lt : "(缺)"); + CHECK(lt && strcmp(lt, "array") == 0, + "edit_file 的 line 应是 array, 实际 %s", lt ? lt : "(缺)"); JsonValue *items = line ? json_object_get(line, "items") : NULL; const char *it = items ? json_get_string(items, "type") : NULL; - CHECK(it && strcmp(it, "integer") == 0, "line 的元素应是 integer, 实际 %s", - it ? it : "(缺)"); + CHECK(it && strcmp(it, "integer") == 0, + "line 的元素应是 integer, 实际 %s", it ? it : "(缺)"); } /* 序列化一遍确保可发给服务端 */ @@ -137,8 +145,10 @@ int main(void) /* ---- write_file / read_file ---- */ puts("write_file / read_file"); - r = call("write_file", - "{\"path\":\"sub/dir/a.txt\",\"content\":\"line1\\nline2\\nline3\\n\"}", &err); + r = call( + "write_file", + "{\"path\":\"sub/dir/a.txt\",\"content\":\"line1\\nline2\\nline3\\n\"}", + &err); CHECK(err == 0, "写入应成功: %s", r); CHECK(access("sub/dir/a.txt", R_OK) == 0, "父目录应被自动创建"); free(r); @@ -149,7 +159,8 @@ int main(void) CHECK(strstr(r, "1\t") != NULL, "应带行号: %s", r); free(r); - r = call("read_file", "{\"path\":\"sub/dir/a.txt\",\"offset\":2,\"limit\":1}", &err); + r = call("read_file", + "{\"path\":\"sub/dir/a.txt\",\"offset\":2,\"limit\":1}", &err); CHECK(strstr(r, "line2") && !strstr(r, "line1") && !strstr(r, "line3"), "offset/limit 应生效: %s", r); free(r); @@ -162,7 +173,8 @@ int main(void) puts("edit_file"); r = call("edit_file", "{\"path\":\"sub/dir/a.txt\",\"line\":2,\"old_string\":\"line2\"," - "\"new_string\":\"LINE-TWO\"}", &err); + "\"new_string\":\"LINE-TWO\"}", + &err); CHECK(err == 0, "按行号替换应成功: %s", r); CHECK(strstr(r, "第 2 行") != NULL, "回执里应带上行号: %s", r); free(r); @@ -173,9 +185,11 @@ int main(void) /* 省略 line 不再是错误: 那是"全文件替换"这条逃生门 (见下面那组用例) */ /* 行号对不上: 必须报出它**真正在哪一行**, 否则模型只能反复猜 */ - r = call("edit_file", - "{\"path\":\"sub/dir/a.txt\",\"line\":3,\"old_string\":\"LINE-TWO\"," - "\"new_string\":\"x\"}", &err); + r = call( + "edit_file", + "{\"path\":\"sub/dir/a.txt\",\"line\":3,\"old_string\":\"LINE-TWO\"," + "\"new_string\":\"x\"}", + &err); CHECK(err == 1, "行号不对应报错: %s", r); CHECK(strstr(r, "第 2 行") != NULL, "应指出它真正的行号: %s", r); free(r); @@ -183,7 +197,8 @@ int main(void) /* 越界行号: 要连文件总行数一起说清楚 */ r = call("edit_file", "{\"path\":\"sub/dir/a.txt\",\"line\":99,\"old_string\":\"x\"," - "\"new_string\":\"y\"}", &err); + "\"new_string\":\"y\"}", + &err); CHECK(err == 1 && strstr(r, "只有 3 行") != NULL, "越界行号应报错并给出总行数: %s", r); free(r); @@ -192,16 +207,19 @@ int main(void) * (这正是"全文唯一"最烦人的地方——文件一长, 随便一个短串都不唯一) */ r = call("edit_file", "{\"path\":\"sub/dir/a.txt\",\"line\":1,\"old_string\":\"line\"," - "\"new_string\":\"LINE\"}", &err); + "\"new_string\":\"LINE\"}", + &err); CHECK(err == 0, "同一行里唯一即可, 别的行的重复不该拦: %s", r); free(r); /* 同一行里出现两处 → 仍然拒绝 (行内也要定得下来) */ - r = call("write_file", "{\"path\":\"dup.txt\",\"content\":\"foo foo\\n\"}", &err); + r = call("write_file", "{\"path\":\"dup.txt\",\"content\":\"foo foo\\n\"}", + &err); CHECK(err == 0, "写入 dup.txt: %s", r); free(r); r = call("edit_file", - "{\"path\":\"dup.txt\",\"line\":1,\"old_string\":\"foo\",\"new_string\":\"bar\"}", + "{\"path\":\"dup.txt\",\"line\":1,\"old_string\":\"foo\",\"new_" + "string\":\"bar\"}", &err); CHECK(err == 1 && strstr(r, "第 1 行里有 2 处") != NULL, "同一行里多匹配应拒绝: %s", r); @@ -209,8 +227,10 @@ int main(void) /* replace_all 仍然换全文件, 但 line 必须指向其中真实的一处 */ r = call("edit_file", - "{\"path\":\"dup.txt\",\"line\":1,\"old_string\":\"foo\",\"new_string\":\"bar\"," - "\"replace_all\":true}", &err); + "{\"path\":\"dup.txt\",\"line\":1,\"old_string\":\"foo\",\"new_" + "string\":\"bar\"," + "\"replace_all\":true}", + &err); CHECK(err == 0, "replace_all 应成功: %s", r); free(r); r = call("read_file", "{\"path\":\"dup.txt\"}", &err); @@ -218,8 +238,10 @@ int main(void) "replace_all 应换掉文件里全部匹配: %s", r); free(r); r = call("edit_file", - "{\"path\":\"dup.txt\",\"line\":7,\"old_string\":\"bar\",\"new_string\":\"x\"," - "\"replace_all\":true}", &err); + "{\"path\":\"dup.txt\",\"line\":7,\"old_string\":\"bar\",\"new_" + "string\":\"x\"," + "\"replace_all\":true}", + &err); CHECK(err == 1, "replace_all 也必须锚定在真实的一行上: %s", r); free(r); @@ -228,14 +250,16 @@ int main(void) * "每行都改一次": 真改动的仍是唯一那一处. */ puts("edit_file: line 集合"); r = call("write_file", - "{\"path\":\"set.txt\",\"content\":\"aaa\\nbbb\\nccc\\nddd\\n\"}", &err); + "{\"path\":\"set.txt\",\"content\":\"aaa\\nbbb\\nccc\\nddd\\n\"}", + &err); CHECK(err == 0, "写入 set.txt: %s", r); free(r); /* 数组写法: 起点落在集合里的哪一行都算命中; 回执要带上整个集合 */ r = call("edit_file", "{\"path\":\"set.txt\",\"line\":[1,2],\"old_string\":\"aaa\"," - "\"new_string\":\"AAA\"}", &err); + "\"new_string\":\"AAA\"}", + &err); CHECK(err == 0, "集合圈住的替换应成功: %s", r); CHECK(strstr(r, "第 1、2 行") != NULL, "回执里应带上整个集合: %s", r); free(r); @@ -244,40 +268,48 @@ int main(void) free(r); /* old_string 跨行时, 把覆盖到的几行都填进集合——不必赌起点是第几行 */ - r = call("edit_file", - "{\"path\":\"set.txt\",\"line\":[2,3],\"old_string\":\"bbb\\nccc\"," - "\"new_string\":\"B\\nC\"}", &err); + r = call( + "edit_file", + "{\"path\":\"set.txt\",\"line\":[2,3],\"old_string\":\"bbb\\nccc\"," + "\"new_string\":\"B\\nC\"}", + &err); CHECK(err == 0, "跨行替换 (集合给两行)应成功: %s", r); free(r); r = call("read_file", "{\"path\":\"set.txt\"}", &err); - CHECK(strstr(r, "B") && strstr(r, "C") && !strstr(r, "bbb"), "跨行替换应落盘: %s", r); + CHECK(strstr(r, "B") && strstr(r, "C") && !strstr(r, "bbb"), + "跨行替换应落盘: %s", r); free(r); /* 集合里有多处匹配 → 仍然拒绝 (集合不是"每行改一次"的授权) */ - r = call("write_file", "{\"path\":\"two.txt\",\"content\":\"foo\\nx\\nfoo\\n\"}", &err); + r = call("write_file", + "{\"path\":\"two.txt\",\"content\":\"foo\\nx\\nfoo\\n\"}", &err); CHECK(err == 0, "写入 two.txt: %s", r); free(r); r = call("edit_file", "{\"path\":\"two.txt\",\"line\":[1,3],\"old_string\":\"foo\"," - "\"new_string\":\"bar\"}", &err); + "\"new_string\":\"bar\"}", + &err); CHECK(err == 1 && strstr(r, "第 1、3 行里有 2 处") != NULL, "集合里多处匹配应拒绝: %s", r); free(r); /* 重复的行号要去重: 不然同一处匹配会被数成两处 */ - r = call("write_file", "{\"path\":\"dup3.txt\",\"content\":\"foo\\n\"}", &err); + r = call("write_file", "{\"path\":\"dup3.txt\",\"content\":\"foo\\n\"}", + &err); CHECK(err == 0, "写入 dup3.txt: %s", r); free(r); r = call("edit_file", "{\"path\":\"dup3.txt\",\"line\":[1,1],\"old_string\":\"foo\"," - "\"new_string\":\"bar\"}", &err); + "\"new_string\":\"bar\"}", + &err); CHECK(err == 0, "重复行号应去重, 而不是当成两处: %s", r); free(r); /* 集合里有一行越界 → 和单个行号时一样, 报出文件总行数 */ r = call("edit_file", "{\"path\":\"set.txt\",\"line\":[1,99],\"old_string\":\"x\"," - "\"new_string\":\"y\"}", &err); + "\"new_string\":\"y\"}", + &err); CHECK(err == 1 && strstr(r, "只有 4 行") != NULL, "集合里的越界行号应报错并给出总行数: %s", r); free(r); @@ -285,25 +317,30 @@ int main(void) /* 写法本身不对的三种: 空数组、非整数元素、一次给太多 */ r = call("edit_file", "{\"path\":\"set.txt\",\"line\":[],\"old_string\":\"x\"," - "\"new_string\":\"y\"}", &err); + "\"new_string\":\"y\"}", + &err); CHECK(err == 1 && strstr(r, "空数组") != NULL, "空数组应报错: %s", r); - CHECK(strstr(r, "省略") != NULL, "空数组的报错要提示可以整个省略 line: %s", r); + CHECK(strstr(r, "省略") != NULL, "空数组的报错要提示可以整个省略 line: %s", + r); free(r); r = call("edit_file", "{\"path\":\"set.txt\",\"line\":[1,1.5],\"old_string\":\"x\"," - "\"new_string\":\"y\"}", &err); + "\"new_string\":\"y\"}", + &err); CHECK(err == 1 && strstr(r, "第 2 个") != NULL, "非整数元素应报错并指出是第几个: %s", r); free(r); /* ---- 省略 line = 全文件替换所有匹配 ---- * 这是 range 那样的集合参数该有的默认值: 不给范围就是不设范围. */ - r = call("write_file", "{\"path\":\"all.txt\",\"content\":\"k\\nx\\nk\\n\"}", &err); + r = call("write_file", + "{\"path\":\"all.txt\",\"content\":\"k\\nx\\nk\\n\"}", &err); CHECK(err == 0, "写入 all.txt: %s", r); free(r); r = call("edit_file", - "{\"path\":\"all.txt\",\"old_string\":\"k\",\"new_string\":\"K\"}", &err); + "{\"path\":\"all.txt\",\"old_string\":\"k\",\"new_string\":\"K\"}", + &err); CHECK(err == 0, "省略 line 应做全文件替换: %s", r); CHECK(strstr(r, "全文件替换") != NULL && strstr(r, "2 处") != NULL, "回执要说清是全文件替换、换了几处: %s", r); @@ -312,29 +349,39 @@ int main(void) CHECK(strstr(r, "K") != NULL && !strstr(r, "k"), "两处都该被换掉: %s", r); free(r); - /* 省略 line 时文件里没有 old_string: 只说整个文件里没有 (没有"哪几行"可说) */ - r = call("edit_file", - "{\"path\":\"all.txt\",\"old_string\":\"zzz\",\"new_string\":\"y\"}", &err); - CHECK(err == 1 && strstr(r, "整个文件里没有") != NULL, "该报整个文件里没有: %s", r); + /* 省略 line 时文件里没有 old_string: 只说整个文件里没有 (没有"哪几行"可说) + */ + r = call( + "edit_file", + "{\"path\":\"all.txt\",\"old_string\":\"zzz\",\"new_string\":\"y\"}", + &err); + CHECK(err == 1 && strstr(r, "整个文件里没有") != NULL, + "该报整个文件里没有: %s", r); free(r); /* line 给错了类型 (不是"想全局"): 仍然当场报错, 并告诉它可以省略 */ r = call("edit_file", "{\"path\":\"all.txt\",\"line\":\"1\",\"old_string\":\"K\"," - "\"new_string\":\"z\"}", &err); - CHECK(err == 1 && strstr(r, "整数或整数数组") != NULL, "line 类型错应报错: %s", r); + "\"new_string\":\"z\"}", + &err); + CHECK(err == 1 && strstr(r, "整数或整数数组") != NULL, + "line 类型错应报错: %s", r); CHECK(strstr(r, "整个省略") != NULL, "报错要指出可以省略 line: %s", r); free(r); { /* 65 个行号: 超过一次能圈住的范围, 要明说 */ char big[600]; - size_t o = (size_t)snprintf(big, sizeof big, "{\"path\":\"set.txt\",\"line\":["); + size_t o = (size_t)snprintf(big, sizeof big, + "{\"path\":\"set.txt\",\"line\":["); for (int i = 1; i <= 65; i++) - o += (size_t)snprintf(big + o, sizeof big - o, "%s%d", i > 1 ? "," : "", i); - snprintf(big + o, sizeof big - o, "],\"old_string\":\"x\",\"new_string\":\"y\"}"); + o += (size_t)snprintf(big + o, sizeof big - o, "%s%d", + i > 1 ? "," : "", i); + snprintf(big + o, sizeof big - o, + "],\"old_string\":\"x\",\"new_string\":\"y\"}"); r = call("edit_file", big, &err); - CHECK(err == 1 && strstr(r, "一次最多") != NULL, "行号过多应报错: %s", r); + CHECK(err == 1 && strstr(r, "一次最多") != NULL, "行号过多应报错: %s", + r); free(r); } @@ -348,21 +395,23 @@ int main(void) CHECK(err == 1, "不存在的目录应报错: %s", r); free(r); - /* 这两个工具都已经删掉: 搜索交给模型自己用 run_command 里的真 grep, + /* 这两个工具都已经删掉: 搜索交给模型自己用 run_command 里的真 grep, * 子代理 (spawn_subagent)整个功能也去掉了. 留着断言是为了钉住 * "不要再冒出一个半吊子的替代品". */ r = call("grep", "{\"pattern\":\"x\",\"path\":\".\"}", &err); - CHECK(err == 1 && strstr(r, "未知工具") != NULL, "不该再有 grep 工具: %s", r); + CHECK(err == 1 && strstr(r, "未知工具") != NULL, "不该再有 grep 工具: %s", + r); free(r); r = call("spawn_subagent", "{}", &err); - CHECK(err == 1 && strstr(r, "未知工具") != NULL, "不该再有子代理工具: %s", r); + CHECK(err == 1 && strstr(r, "未知工具") != NULL, "不该再有子代理工具: %s", + r); free(r); /* ---- 超大文件不能把内存吃干 ---- */ puts("超大文件防护"); { - /* 稀疏文件, 创建几乎不耗时; 关键是客户端要在读之前就发现它太大. - * 开头 16 KB 写成 'x': 这样它是"文本" (探测的前 8 KB 里没有 NUL), + /* 稀疏文件, 创建几乎不耗时; 关键是客户端要在读之前就发现它太大. + * 开头 16 KB 写成 'x': 这样它是"文本" (探测的前 8 KB 里没有 NUL), * 否则连 read_file 都会先被二进制检测拦掉 —— 这个用例就测不到 * "分段读大文件"了. */ FILE *big = fopen("huge.txt", "wb"); @@ -376,7 +425,8 @@ int main(void) fclose(big); } r = call("edit_file", - "{\"path\":\"huge.txt\",\"line\":1,\"old_string\":\"x\",\"new_string\":\"y\"}", + "{\"path\":\"huge.txt\",\"line\":1,\"old_string\":\"x\",\"new_" + "string\":\"y\"}", &err); CHECK(err == 1, "超大文件的 edit_file 应被拒绝: %.120s", r); CHECK(strstr(r, "上限") != NULL, "应说明是超上限: %.120s", r); @@ -389,12 +439,16 @@ int main(void) unlink("huge.txt"); } - /* ---- 二进制文件: 所有处理文本的工具都必须拒绝 (而且要说明该用什么) ---- */ + /* ---- 二进制文件: 所有处理文本的工具都必须拒绝 (而且要说明该用什么) ---- + */ puts("二进制拒绝"); { FILE *bf = fopen("bin.dat", "wb"); CHECK(bf != NULL, "创建二进制测试文件失败"); - if (bf) { fwrite("PNG\0\1\2\3", 1, 8, bf); fclose(bf); } + if (bf) { + fwrite("PNG\0\1\2\3", 1, 8, bf); + fclose(bf); + } } r = call("read_file", "{\"path\":\"bin.dat\"}", &err); @@ -404,9 +458,11 @@ int main(void) free(r); r = call("edit_file", - "{\"path\":\"bin.dat\",\"line\":1,\"old_string\":\"PNG\",\"new_string\":\"JPG\"}", + "{\"path\":\"bin.dat\",\"line\":1,\"old_string\":\"PNG\",\"new_" + "string\":\"JPG\"}", &err); - CHECK(err == 1 && strstr(r, "二进制") != NULL, "edit_file 改二进制应被拒绝: %s", r); + CHECK(err == 1 && strstr(r, "二进制") != NULL, + "edit_file 改二进制应被拒绝: %s", r); free(r); r = call("write_file", "{\"path\":\"bin.dat\",\"content\":\"text\"}", &err); @@ -420,30 +476,35 @@ int main(void) char keep[16]; memset(keep, 0, sizeof keep); size_t kn = bf ? fread(keep, 1, sizeof keep, bf) : 0; - if (bf) fclose(bf); + if (bf) + fclose(bf); CHECK(kn == 8 && memcmp(keep, "PNG\0\1\2\3", 8) == 0, "被拒绝的读/写不能改动二进制文件的字节"); } /* 确认提示里要能看出改的是哪一行 */ - r = call("write_file", "{\"path\":\"confirm.txt\",\"content\":\"aaa\\nbbb\\n\"}", &err); + r = call("write_file", + "{\"path\":\"confirm.txt\",\"content\":\"aaa\\nbbb\\n\"}", &err); free(r); r = call("edit_file", - "{\"path\":\"confirm.txt\",\"line\":2,\"old_string\":\"bbb\",\"new_string\":\"ccc\"}", + "{\"path\":\"confirm.txt\",\"line\":2,\"old_string\":\"bbb\"," + "\"new_string\":\"ccc\"}", &err); CHECK(err == 0 && strstr(r, "第 2 行") != NULL, "回执带行号: %s", r); free(r); /* ---- 哪些工具会改动磁盘 (决定是否弹确认) ---- */ puts("写操作分类"); - CHECK(tool_mutating("run_command") == 1, "run_command 会执行命令, 必须确认"); + CHECK(tool_mutating("run_command") == 1, + "run_command 会执行命令, 必须确认"); CHECK(tool_mutating("write_file") == 1, "write_file 会写盘, 必须确认"); CHECK(tool_mutating("edit_file") == 1, "edit_file 会改盘, 必须确认"); CHECK(tool_mutating("read_file") == 0, "read_file 只读, 不该打扰"); CHECK(tool_mutating("list_dir") == 0, "list_dir 只读, 不该打扰"); - /* read_image 会在 ligent 自己的目录里留备份 (副作用), + /* read_image 会在 ligent 自己的目录里留备份 (副作用), * 但不改用户的任何东西 —— 它是"读", 不该每次都拦一道 */ - CHECK(tool_mutating("read_image") == 0, "read_image 不改用户的文件, 不该打扰"); + CHECK(tool_mutating("read_image") == 0, + "read_image 不改用户的文件, 不该打扰"); CHECK(tool_mutating("没有这个工具") == 0, "未知工具按只读处理"); CHECK(tool_mutating(NULL) == 0, "空名字不应崩"); @@ -458,18 +519,21 @@ int main(void) CHECK(bdir != NULL, "建备份临时目录失败"); if (bdir) { image_set_bindir(bdir); - image_pending_take(); /* 清干净, 以免上一段残留 */ + image_pending_take(); /* 清干净, 以免上一段残留 */ /* 1) 内容是文本、扩展名骗人 */ FILE *f = fopen("notanimage.png", "wb"); - if (f) { fputs("这不是图片", f); fclose(f); } + if (f) { + fputs("这不是图片", f); + fclose(f); + } r = call("read_image", "{\"path\":\"notanimage.png\"}", &err); CHECK(err == 1 && strstr(r, "不认识") != NULL, "非图片内容应被拒: %s", r); CHECK(strstr(r, "按文件内容判断") != NULL, "应说明是看内容判断的"); free(r); - /* 2) 体积超限: base64 后超过 IMAGE_MAX_B64. + /* 2) 体积超限: base64 后超过 IMAGE_MAX_B64. * 4/3 * 原始大小 = 编码后大小, 所以原始要 > 3/4 * 上限. */ size_t raw_needed = (size_t)(IMAGE_MAX_B64 / 4 * 3) + 4096; f = fopen("huge.png", "wb"); @@ -479,7 +543,8 @@ int main(void) size_t left = raw_needed - 8; while (left > 0) { size_t chunk = left > 65536 ? 65536 : left; - if (fwrite(pad, 1, chunk, f) != chunk) break; + if (fwrite(pad, 1, chunk, f) != chunk) + break; left -= chunk; } free(pad); @@ -493,7 +558,11 @@ int main(void) /* 3) 合法的小图: 成功, 并且挂上待发附件 */ f = fopen("ok.png", "wb"); - if (f) { fwrite("\x89PNG\r\n\x1a\n", 1, 8, f); fwrite("payload", 1, 7, f); fclose(f); } + if (f) { + fwrite("\x89PNG\r\n\x1a\n", 1, 8, f); + fwrite("payload", 1, 7, f); + fclose(f); + } r = call("read_image", "{\"path\":\"ok.png\"}", &err); CHECK(err == 0 && strstr(r, "已读入图片") != NULL, "合法图片应成功: %s", r); @@ -504,14 +573,20 @@ int main(void) CHECK(att && json_array_size(att) == 1, "应挂上 1 个待发附件"); if (att && json_array_size(att) == 1) CHECK(strcmp(json_get_string(json_array_get(att, 0), "type"), - "ligent_image") == 0, "附件应是内部引用块"); + "ligent_image") == 0, + "附件应是内部引用块"); json_free(att); /* 只有成功那一张留了备份 —— 两条拒绝路径都不该留下东西 */ - char *ls = xasprintf("ls %s/ligent.archive/images/*.png | wc -l", bdir); + char *ls = + xasprintf("ls %s/ligent.archive/images/*.png | wc -l", bdir); FILE *p = popen(ls, "r"); int cnt = -1; - if (p) { if (fscanf(p, "%d", &cnt) != 1) cnt = -1; pclose(p); } + if (p) { + if (fscanf(p, "%d", &cnt) != 1) + cnt = -1; + pclose(p); + } free(ls); CHECK(cnt == 1, "只该留 1 份备份 (成功那张), 实际 %d", cnt); @@ -522,13 +597,15 @@ int main(void) /* ---- 超长单行: read_file 报出的行号必须与 edit_file 的口径一致 ---- * * read_file 以前用 fgets + 固定 64 KB 缓冲, 一行比缓冲长时会被切成"多行"、 - * 每块各带一个行号. 于是模型按 read_file 看到的行号去 edit_file 改, 要么改错 - * 地方、要么报越界 —— 而它没有任何办法察觉. 工具描述和 README 都写着 - * "read_file 打出来的行号可以直接喂给 edit_file 的 line", 这里把它钉住. */ + * 每块各带一个行号. 于是模型按 read_file 看到的行号去 edit_file 改, + * 要么改错 地方、要么报越界 —— 而它没有任何办法察觉. 工具描述和 README + * 都写着 "read_file 打出来的行号可以直接喂给 edit_file 的 line", + * 这里把它钉住. */ puts("超长单行的行号口径"); { FILE *lf = fopen("longline.txt", "wb"); - for (int i = 0; i < 70000; i++) fputc('a', lf); /* 跨过 64 KB 那块缓冲 */ + for (int i = 0; i < 70000; i++) + fputc('a', lf); /* 跨过 64 KB 那块缓冲 */ fputc('\n', lf); fputs("second line\nthird line\n", lf); fclose(lf); @@ -537,17 +614,22 @@ int main(void) CHECK(err == 0, "读超长行文件失败: %s", r); CHECK(strstr(r, " 1\ta") != NULL, "第 1 行应以 a 开头:\n%.120s", r); CHECK(strstr(r, " 2\tsecond line") != NULL, - "超长行之后的第 2 行应是 second line (它不能被算成好几行): \n%.200s", r); - CHECK(strstr(r, " 3\tthird line") != NULL, "第 3 行应是 third line"); + "超长行之后的第 2 行应是 second line (它不能被算成好几行): " + "\n%.200s", + r); + CHECK(strstr(r, " 3\tthird line") != NULL, + "第 3 行应是 third line"); CHECK(strstr(r, " 4\t") == NULL, "不应该出现第 4 行"); free(r); /* 拿 read_file 报出的行号去 edit_file 改, 必须命中同一处 */ r = call("edit_file", "{\"path\":\"longline.txt\",\"line\":3," - "\"old_string\":\"third line\",\"new_string\":\"THIRD LINE\"}", &err); + "\"old_string\":\"third line\",\"new_string\":\"THIRD LINE\"}", + &err); CHECK(err == 0, "用 read_file 的行号改第 3 行失败: %s", r); - CHECK(strstr(r, "第 3 行替换 1 处") != NULL, "回执应说清改的是第 3 行: %s", r); + CHECK(strstr(r, "第 3 行替换 1 处") != NULL, + "回执应说清改的是第 3 行: %s", r); free(r); char *after = read_entire_file("longline.txt", NULL); CHECK(after && strstr(after, "THIRD LINE") != NULL, "改动应落到文件里"); @@ -556,7 +638,8 @@ int main(void) /* 单行本身就超过输出上限: 必须说清"这一行只给了开头", 不能说成 * "已截断, 从下一行继续" —— 那会让调用方以为拿到的是完整的行. */ FILE *hf = fopen("hugeline.txt", "wb"); - for (int i = 0; i < 200000; i++) fputc('b', hf); + for (int i = 0; i < 200000; i++) + fputc('b', hf); fputc('\n', hf); fputs("tail\n", hf); fclose(hf); @@ -571,7 +654,8 @@ int main(void) /* ---- 空目录: qsort 的第一个参数会拿到 NULL ---- */ puts("list_dir 空目录"); - if (mkdir("emptydir", 0755) != 0) { /* 可能已存在 */ } + if (mkdir("emptydir", 0755) != 0) { /* 可能已存在 */ + } r = call("list_dir", "{\"path\":\"emptydir\"}", &err); CHECK(err == 0 && strstr(r, "0 个条目") != NULL, "空目录应报 0 个条目, 实际 %s", r); @@ -586,7 +670,8 @@ int main(void) /* ---- 输出截断 ---- */ puts("output truncation"); r = call("run_command", - "{\"command\":\"head -c 300000 /dev/zero | tr '\\\\0' 'a'\"}", &err); + "{\"command\":\"head -c 300000 /dev/zero | tr '\\\\0' 'a'\"}", + &err); CHECK(strlen(r) <= LIGENT_TOOL_OUTPUT_LIMIT + 200, "输出应被截断, 实际 %zu 字节", strlen(r)); CHECK(strstr(r, "截断") != NULL, "应提示已截断"); diff --git a/tools/md_demo.c b/tools/md_demo.c index 0cc1dc8..e51a5e3 100644 --- a/tools/md_demo.c +++ b/tools/md_demo.c @@ -1,7 +1,7 @@ /* md_demo.c - Markdown 渲染效果演示 * * 一个独立的小程序: 把内置示例 (或你指定的文件 / stdin)喂进 ligent 的 - * 流式 Markdown 渲染器, 直观看到每种语法的渲染结果. + * 流式 Markdown 渲染器, 直观看到每种语法的渲染结果. * * 编译: make md_demo * 运行: ./build/md_demo @@ -20,71 +20,71 @@ /* ------------------------------------------------------------------ */ static const char *SAMPLE = -"# ligent 渲染演示\n" -"\n" -"## 行内样式\n" -"\n" -"这是 **粗体**、*斜体*、_下划线斜体_ 和 `行内代码` 的混排效果. \n" -"标识符不会被误伤: foo_bar_baz、__init__、arr[i]、a_b_c 都保持原样. \n" -"链接只高亮方括号: [官方文档](https://example.com/doc) 后面还有普通文字. \n" -"图片保留原文: ![架构图](./arch.png)\n" -"\n" -"## 列表与引用\n" -"\n" -"- 减号列表项\n" -"- 第二项\n" -"* 星号也能当列表\n" -"+ 加号也可以\n" -"\n" -"> 这是一段引用, 前缀是暗色的竖线. \n" -"> 引用可以有多行. \n" -"\n" -"---\n" -"\n" -"## 代码块 (整块不渲染)\n" -"\n" -"```c\n" -"#include \n" -"int main(void) {\n" -" // **这里不会加粗**, [方括号] 也不会高亮\n" -" printf(\"hello %s\\n\", \"world\");\n" -" return 0;\n" -"}\n" -"```\n" -"\n" -"行内代码同理: `a ** b` 里的星号不会被当成粗体标记. \n" -"\n" -"## 表格\n" -"\n" -"| 语言 | 类型系统 | 典型用途 | 份额 |\n" -"|:-----|:--------:|----------|-----:|\n" -"| C | 静态弱类型 | 系统编程 | 11.4% |\n" -"| Rust | 静态强类型 | 系统编程 | 3.2% |\n" -"| Python | 动态强类型 | 脚本 / AI | 23.1% |\n" -"\n" -"| 单列表格 |\n" -"|---|\n" -"| 也能渲染 |\n" -"\n" -"## 边界情况\n" -"\n" -"乘法星号原样保留: 2 * 3 * 4 = 24\n" -"管道命令不会被误判成表格: ls -la | grep foo\n" -"未闭合的 **粗体标记 会在行尾被复位\n" -"#井号后没有空格就不是标题\n" -"####### 七个井号也不是标题\n" -"\n"; + "# ligent 渲染演示\n" + "\n" + "## 行内样式\n" + "\n" + "这是 **粗体**、*斜体*、_下划线斜体_ 和 `行内代码` 的混排效果. \n" + "标识符不会被误伤: foo_bar_baz、__init__、arr[i]、a_b_c 都保持原样. \n" + "链接只高亮方括号: [官方文档](https://example.com/doc) 后面还有普通文字. \n" + "图片保留原文: ![架构图](./arch.png)\n" + "\n" + "## 列表与引用\n" + "\n" + "- 减号列表项\n" + "- 第二项\n" + "* 星号也能当列表\n" + "+ 加号也可以\n" + "\n" + "> 这是一段引用, 前缀是暗色的竖线. \n" + "> 引用可以有多行. \n" + "\n" + "---\n" + "\n" + "## 代码块 (整块不渲染)\n" + "\n" + "```c\n" + "#include \n" + "int main(void) {\n" + " // **这里不会加粗**, [方括号] 也不会高亮\n" + " printf(\"hello %s\\n\", \"world\");\n" + " return 0;\n" + "}\n" + "```\n" + "\n" + "行内代码同理: `a ** b` 里的星号不会被当成粗体标记. \n" + "\n" + "## 表格\n" + "\n" + "| 语言 | 类型系统 | 典型用途 | 份额 |\n" + "|:-----|:--------:|----------|-----:|\n" + "| C | 静态弱类型 | 系统编程 | 11.4% |\n" + "| Rust | 静态强类型 | 系统编程 | 3.2% |\n" + "| Python | 动态强类型 | 脚本 / AI | 23.1% |\n" + "\n" + "| 单列表格 |\n" + "|---|\n" + "| 也能渲染 |\n" + "\n" + "## 边界情况\n" + "\n" + "乘法星号原样保留: 2 * 3 * 4 = 24\n" + "管道命令不会被误判成表格: ls -la | grep foo\n" + "未闭合的 **粗体标记 会在行尾被复位\n" + "#井号后没有空格就不是标题\n" + "####### 七个井号也不是标题\n" + "\n"; /* ------------------------------------------------------------------ */ /* 渲染到内存 (用于 --compare 自检) */ /* ------------------------------------------------------------------ */ -static char *render_to_mem(const char *src, int enabled, size_t chunk) -{ +static char *render_to_mem(const char *src, int enabled, size_t chunk) { char *buf = NULL; size_t size = 0; FILE *f = open_memstream(&buf, &size); - if (!f) return NULL; + if (!f) + return NULL; MdRenderer *r = md_new(enabled); md_set_out(r, f); @@ -110,14 +110,14 @@ static char *render_to_mem(const char *src, int enabled, size_t chunk) /* 渲染到终端 (可带分片与延时, 模拟 SSE 流式) */ /* ------------------------------------------------------------------ */ -static void render_to_tty(const char *src, int enabled, - size_t chunk, long delay_ms) -{ +static void render_to_tty(const char *src, int enabled, size_t chunk, + long delay_ms) { MdRenderer *r = md_new(enabled); md_set_out(r, stdout); size_t len = strlen(src); - if (chunk == 0) chunk = len ? len : 1; + if (chunk == 0) + chunk = len ? len : 1; struct timespec ts; ts.tv_sec = delay_ms / 1000; @@ -129,7 +129,8 @@ static void render_to_tty(const char *src, int enabled, md_feed(r, tmp); free(tmp); fflush(stdout); - if (delay_ms > 0) nanosleep(&ts, NULL); + if (delay_ms > 0) + nanosleep(&ts, NULL); } md_finish(r); md_free(r); @@ -139,36 +140,35 @@ static void render_to_tty(const char *src, int enabled, /* 参数 */ /* ------------------------------------------------------------------ */ -static void usage(FILE *out) -{ - fputs( -"md_demo - 演示 ligent 的流式 Markdown 渲染效果\n" -"\n" -"用法: md_demo [选项] [文件...]\n" -"\n" -"不指定文件时使用内置示例; 文件名为 - 或 stdin 不是终端时从标准输入读取. \n" -"\n" -"选项: \n" -" -p, --plain 关闭渲染, 输出原始 Markdown (对照用)\n" -" --color 强制开启渲染 (默认仅在输出到终端时开启)\n" -" -c, --chunk N 按每 N 字节一片喂入 (默认 0 = 一次性喂完)\n" -" -d, --delay MS 每片之间延时毫秒 (默认 0)\n" -" -s, --stream 流式预设: 等价于 -c 5 -d 12\n" -" --compare 自检: 把同一段文本按多种分片方式渲染, 比对结果是否一致\n" -" -h, --help 显示本帮助\n" -"\n" -"例子: \n" -" md_demo 看渲染效果\n" -" md_demo --plain 看原始 Markdown, 对照差异\n" -" md_demo -c 5 -d 12 模拟 SSE 流式 (标记会被随机切断)\n" -" md_demo --compare 验证分片不变性\n" -" md_demo README.md 渲染任意 Markdown 文件\n" -" echo '# hi **there**' | md_demo\n", - out); +static void usage(FILE *out) { + fputs("md_demo - 演示 ligent 的流式 Markdown 渲染效果\n" + "\n" + "用法: md_demo [选项] [文件...]\n" + "\n" + "不指定文件时使用内置示例; 文件名为 - 或 stdin " + "不是终端时从标准输入读取. \n" + "\n" + "选项: \n" + " -p, --plain 关闭渲染, 输出原始 Markdown (对照用)\n" + " --color 强制开启渲染 (默认仅在输出到终端时开启)\n" + " -c, --chunk N 按每 N 字节一片喂入 (默认 0 = 一次性喂完)\n" + " -d, --delay MS 每片之间延时毫秒 (默认 0)\n" + " -s, --stream 流式预设: 等价于 -c 5 -d 12\n" + " --compare 自检: 把同一段文本按多种分片方式渲染, " + "比对结果是否一致\n" + " -h, --help 显示本帮助\n" + "\n" + "例子: \n" + " md_demo 看渲染效果\n" + " md_demo --plain 看原始 Markdown, 对照差异\n" + " md_demo -c 5 -d 12 模拟 SSE 流式 (标记会被随机切断)\n" + " md_demo --compare 验证分片不变性\n" + " md_demo README.md 渲染任意 Markdown 文件\n" + " echo '# hi **there**' | md_demo\n", + out); } -static char *read_stream(FILE *f) -{ +static char *read_stream(FILE *f) { StrBuf sb; sb_init(&sb); char buf[8192]; @@ -178,8 +178,7 @@ static char *read_stream(FILE *f) return sb_detach(&sb); } -int main(int argc, char **argv) -{ +int main(int argc, char **argv) { int plain = 0, force_color = 0, compare = 0; long delay_ms = 0; size_t chunk = 0; @@ -195,10 +194,16 @@ int main(int argc, char **argv) } else if (strcmp(a, "--color") == 0) { force_color = 1; } else if (strcmp(a, "-c") == 0 || strcmp(a, "--chunk") == 0) { - if (++i >= argc) { fputs("缺少 -c 的参数\n", stderr); return 2; } + if (++i >= argc) { + fputs("缺少 -c 的参数\n", stderr); + return 2; + } chunk = (size_t)strtoul(argv[i], NULL, 10); } else if (strcmp(a, "-d") == 0 || strcmp(a, "--delay") == 0) { - if (++i >= argc) { fputs("缺少 -d 的参数\n", stderr); return 2; } + if (++i >= argc) { + fputs("缺少 -d 的参数\n", stderr); + return 2; + } delay_ms = strtol(argv[i], NULL, 10); } else if (strcmp(a, "-s") == 0 || strcmp(a, "--stream") == 0) { chunk = 5; @@ -237,7 +242,8 @@ int main(int argc, char **argv) src = xstrdup(SAMPLE); } - int enabled = plain ? 0 : (force_color ? 1 : (isatty(fileno(stdout)) ? 1 : 0)); + int enabled = + plain ? 0 : (force_color ? 1 : (isatty(fileno(stdout)) ? 1 : 0)); if (compare) { /* 分片不变性自检: 不同分片大小必须得到完全相同的结果 */ @@ -245,13 +251,15 @@ int main(int argc, char **argv) printf("输入: %s (%zu 字节)\n\n", source_name, strlen(src)); char *whole = render_to_mem(src, 1, 0); - size_t sizes[] = { 1, 2, 3, 5, 7, 13, 64 }; + size_t sizes[] = {1, 2, 3, 5, 7, 13, 64}; int all_ok = 1; for (size_t k = 0; k < sizeof sizes / sizeof sizes[0]; k++) { char *part = render_to_mem(src, 1, sizes[k]); int ok = part && whole && strcmp(whole, part) == 0; - printf(" %-5zu 字节/片 %s\n", sizes[k], ok ? "一致 ✓" : "不一致 ✗"); - if (!ok) all_ok = 0; + printf(" %-5zu 字节/片 %s\n", sizes[k], + ok ? "一致 ✓" : "不一致 ✗"); + if (!ok) + all_ok = 0; free(part); } /* 随机分片 (模拟真实 SSE 的抖动) */ @@ -264,7 +272,8 @@ int main(int argc, char **argv) size_t len = strlen(src), i = 0; while (i < len) { size_t n = (size_t)(rand() % 11) + 1; - if (i + n > len) n = len - i; + if (i + n > len) + n = len - i; char *tmp = xstrndup(src + i, n); md_feed(r, tmp); free(tmp); @@ -275,20 +284,20 @@ int main(int argc, char **argv) fclose(f); int ok = buf && whole && strcmp(whole, buf) == 0; printf(" %-5s %s\n", "随机", ok ? "一致 ✓" : "不一致 ✗"); - if (!ok) all_ok = 0; + if (!ok) + all_ok = 0; free(buf); free(whole); printf("\n结论: %s\n", all_ok ? "全部一致, 标记跨分片处理正确. " - : "存在不一致, 渲染器有 bug. "); + : "存在不一致, 渲染器有 bug. "); free(src); return all_ok ? 0 : 1; } if (isatty(fileno(stdout))) { - fprintf(stderr, "── 输入: %s 渲染: %s%s ──\n\n", - source_name, enabled ? "开" : "关", - chunk ? " (分片模式)" : ""); + fprintf(stderr, "── 输入: %s 渲染: %s%s ──\n\n", source_name, + enabled ? "开" : "关", chunk ? " (分片模式)" : ""); } render_to_tty(src, enabled, chunk, delay_ms);