Files
ligent/src/main.c
T
2026-10-04 13:50:33 +08:00

1348 lines
50 KiB
C

/* main.c - 入口: 命令行解析、单次任务模式、交互式 REPL */
#include "agent.h"
#include "archive.h"
#include "attach.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 <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
/* ------------------------------------------------------------------ */
/* 信号 */
/* ------------------------------------------------------------------ */
static void on_sigint(int sig) {
(void)sig;
g_ligent_interrupt = 1;
}
static void install_signals(void) {
struct sigaction sa;
memset(&sa, 0, sizeof sa);
sa.sa_handler = on_sigint;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0; /* 不设 SA_RESTART: 让 read/fgets 被 EINTR 打断 */
sigaction(SIGINT, &sa, NULL);
signal(SIGPIPE, SIG_IGN);
}
/* ------------------------------------------------------------------ */
/* 帮助 */
/* ------------------------------------------------------------------ */
static void print_usage(FILE *out) { fputs(t("cli.help"), out); }
/* ------------------------------------------------------------------ */
/* 把配置灌进 agent */
/* ------------------------------------------------------------------ */
static void apply_config(Agent *a, LigentConfig *c) {
a->llm.base_url = c->base_url; /* 有意借用, 配置对象活得和进程一样久 */
a->llm.api_key = c->api_key;
a->llm.model = c->model;
a->llm.temperature = c->temperature;
a->llm.max_tokens = c->max_tokens;
a->llm.thinking = c->thinking;
a->llm.reasoning_effort = c->reasoning_effort;
a->llm.timeout = c->timeout;
a->llm.stream = c->stream;
a->llm.verbose = 0;
a->max_steps = c->max_steps;
a->use_tools = c->use_tools;
a->llm.retries = c->retries;
a->confirm = c->confirm;
a->tools = c->use_tools ? tools_schema() : NULL;
a->probe_caps = c->probe;
}
/* ------------------------------------------------------------------ */
/* 读取全部 stdin */
/* ------------------------------------------------------------------ */
static char *read_stdin_all(void) {
StrBuf sb;
sb_init(&sb);
char buf[4096];
size_t n;
while ((n = fread(buf, 1, sizeof buf, stdin)) > 0)
sb_append(&sb, buf, n);
return sb_detach(&sb);
}
/* ------------------------------------------------------------------ */
/* REPL */
/* ------------------------------------------------------------------ */
static void print_banner(const LigentConfig *c, const Agent *a) {
char cwd[1024];
if (!getcwd(cwd, sizeof cwd))
snprintf(cwd, sizeof cwd, ".");
printf("%sligent %s%s %s(model: %s, cwd: %s)%s\n",
a->color ? "\033[1m" : "", ligent_version_string(),
a->color ? "\033[0m" : "", a->color ? "\033[2m" : "", c->model, cwd,
a->color ? "\033[0m" : "");
printf("%s%s%s\n\n", a->color ? "\033[2m" : "", t("repl.banner.hint"),
a->color ? "\033[0m" : "");
}
/* 存档目录跟二进制走, 和配置同一个原则 */
static const char *g_bindir;
/* 当前对话的谱系: 最近一次写出或载入的存档名 (不含 .json).
* 下次存档就在它后面接一级分支, 于是文件名本身就记录了分支关系. */
static char *g_lineage;
static void set_lineage(const char *name) {
free(g_lineage);
g_lineage = (name && *name) ? xstrdup(name) : NULL;
}
/* /back 摘掉的消息, 供 /unback 撤销; g_back_len 记录摘完之后的长度,
* 用来判断"之后是不是又开了新对话" */
static JsonValue *g_back_undo;
static size_t g_back_len;
static void back_undo_clear(void) {
json_free(g_back_undo);
g_back_undo = NULL;
g_back_len = 0;
}
/* 上次自动存档时的状态, 用来避免重复存同一段对话 */
static size_t g_saved_count;
static time_t g_saved_start;
static int g_auto_save_on;
/* 自动存档. 只在"有内容"且"自上次存档后又变了"时才写,
* 所以退出、重置、导入前各调一次不会产生重复文件. */
static void auto_save(Agent *a, LigentConfig *c, const char *why,
const char *prefix) {
if (!g_auto_save_on)
return;
size_t n = agent_history_len(a);
if (n <= 1)
return; /* 只有 system, 没内容 */
if (n == g_saved_count && a->started_at == g_saved_start)
return; /* 内容没变 */
char *path = NULL, *name = NULL;
char err[512];
/* meta 改过就另起一根: <原名>_m<3位>, parent 记空 */
int meta = meta_reroot_pending();
if (archive_export(a->messages, g_bindir ? g_bindir : ".", g_lineage,
prefix, meta, a->started_at, time(NULL), c->model,
c->base_url, &path, &name, err, sizeof err) != 0) {
fprintf(stderr, "%s",
tf("main.err.auto_save_failed", "error", err, NULL));
return;
}
g_saved_count = n;
g_saved_start = a->started_at;
/* 临时存档只是"换掉当前对话前留一手", 不该成为以后分支的祖先 */
if (!prefix || !*prefix) {
set_lineage(name);
if (meta)
meta_reroot_clear(); /* 新根已经落名, 标记用掉了 */
}
fprintf(stderr, "\033[2m%s\033[0m\n",
tf("main.auto_saved", "why", why, "path", path, "temp",
(prefix && *prefix) ? t("main.auto_saved.temp") : "", NULL));
free(path);
free(name);
}
/* 真正落地一次模型切换. cap 缓存是按 base_url+model 索引的, 所以下次请求
* 会自动重新探测新模型的能力, 这里不用额外处理. */
static void apply_model(Agent *a, LigentConfig *c, const char *name) {
if (strcmp(c->model, name) == 0) {
printf("%s", tf("repl.model.same", "model", name, NULL));
return;
}
free(c->model);
c->model = xstrdup(name);
a->llm.model = c->model;
printf("%s", tf("repl.model.switched", "model", c->model, NULL));
}
/* /model 不带参数: 拉一次在线列表, 打印出来, 然后用**普通输入行**问要哪个.
*
* 刻意不用"光标选择"那种菜单: 一是它会用 ANSI 反复重画、把 stdout 搅乱
* (重定向、日志、粘贴都会跟着遭殃), 二是**没法输入列表里没有的东西** ——
* 而"列表里没有"恰恰很常见: 内部模型、灰度模型、服务端没列出来的别名.
* 所以这里是 /import 那一套: 列表打在上面, 输入行里既可以填序号, 也可以
* 直接写模型名, 回车取消.
*
* 拉不到列表就明说原因, 并提示还能直接用 /model <名字> —— 不能因为网络不行
* 或者服务端不列, 就把这条路堵死. */
static void cmd_model_pick(Agent *a, LigentConfig *c) {
printf("%s", tf("repl.model.fetching", "url", c->base_url, NULL));
fflush(stdout);
char err[512];
size_t n = 0;
char **list = models_fetch(c, &n, a->llm.verbose, err, sizeof err);
if (!list) {
printf("%s", tf("repl.model.list_failed", "error",
err[0] ? err : t("repl.unknown_error"), NULL));
puts(t("repl.model.list_failed_hint"));
return;
}
printf("%s",
tf("repl.model.list_header", "n", i18n_num((long long)n), NULL));
for (size_t i = 0; i < n; i++) {
int cur = (strcmp(list[i], c->model) == 0);
printf(t("repl.model.row"), i + 1, list[i],
cur ? t("repl.model.current") : "");
}
int intr = 0;
char *line = line_edit(t("repl.model.pick_prompt"), &intr);
if (!line) {
models_free(list, n);
printf("\n%s\n", t("repl.cancelled"));
return;
}
char *ans = str_trim(line);
if (*ans == '\0') {
puts(t("repl.cancelled"));
free(line);
models_free(list, n);
return;
}
/* 纯数字按序号理解; 其它一律当模型名 —— 这条正是"内部模型"的入口,
* 所以不去列表里校验它, 服务端认不认由后端说了算. */
char *numend = NULL;
long idx = strtol(ans, &numend, 10);
if (numend != ans && *str_trim(numend) == '\0') {
if (idx < 1 || (size_t)idx > n) {
printf("%s", tf("repl.index_out_of_range", "n", i18n_num(idx),
"max", i18n_num((long long)n), NULL));
free(line);
models_free(list, n);
return;
}
apply_model(a, c, list[idx - 1]);
} else {
apply_model(a, c, ans);
}
free(line);
models_free(list, n);
}
/* /include 遇到图片时才会被问到. 这时候才去跑能力探测 —— 纯文本的附带
* 不该为了"可能出现的图"先花掉两个探测请求. 探测结论按 base_url+model
* 缓存, 所以一个模型只会真探测一次. */
static int include_can_image(void *ud) {
Agent *a = ud;
agent_ensure_caps(a);
/* 只有**明确不支持**才拦. 未测 (探测被关掉或结论不确定)按支持放行,
* 和别处一个原则: 宁可让服务端说不行, 也不错误地藏掉东西. */
return a->caps.vision != CAP_NO;
}
/* /include [glob|clear] —— 不带参数进交互式挑选 */
static void cmd_include(Agent *a, const char *arg) {
if (strcmp(arg, "clear") == 0) {
attach_clear();
puts(t("repl.include.cleared"));
return;
}
size_t skipped = 0;
int added;
if (*arg) {
char err[512];
added =
attach_glob(arg, include_can_image, a, &skipped, err, sizeof err);
if (added < 0) {
printf("%s", tf("repl.include.failed", "error", err, NULL));
return;
}
} else {
added = attach_pick(NULL, include_can_image, a, &skipped);
}
if (added > 0) {
printf("%s%s%s\n", tf("repl.include.added", "n", i18n_num(added), NULL),
skipped ? tf("repl.include.skipped_more", "n",
i18n_num((long long)skipped), NULL)
: "",
t("repl.colon"));
attach_print();
puts(t("repl.include.next_hint"));
} else if (skipped > 0) {
printf("%s", tf("repl.include.none", "n", i18n_num((long long)skipped),
NULL));
}
/* added == 0 且 skipped == 0 = 用户在交互式界面里取消了, 不用多嘴 */
}
static void cmd_export(Agent *a, LigentConfig *c) {
/* 只有 system 一条说明这段对话还没发生过什么, 导出没有意义 */
if (agent_history_len(a) <= 1) {
puts(t("repl.export.empty"));
return;
}
char *path = NULL, *name = NULL;
char err[512];
int meta = meta_reroot_pending();
if (archive_export(a->messages, g_bindir ? g_bindir : ".", g_lineage, NULL,
meta, a->started_at, time(NULL), c->model, c->base_url,
&path, &name, err, sizeof err) != 0) {
printf("%s", tf("repl.export.failed", "error", err, NULL));
return;
}
printf("%s", tf("repl.export.done", "n",
i18n_num((long long)agent_conversation_len(a)), "lineage",
g_lineage ? g_lineage : "", "arrow",
g_lineage ? " -> " : "", "name", name, NULL));
if (g_lineage &&
strlen(g_lineage) + 1 + ARCHIVE_BRANCH_LEN + strlen(".json") >
ARCHIVE_NAME_MAX)
printf("%s\n", t("repl.export.name_limit"));
/* 手动导出也算"已存档", 否则退出时的自动存档会把同样的内容
* 再分一级存一遍, 白占一个文件 */
g_saved_count = agent_history_len(a);
g_saved_start = a->started_at;
set_lineage(name);
if (meta)
meta_reroot_clear(); /* 新根已经落名, 标记用掉了 */
free(path);
free(name);
}
/* 按名字找一份存档: 支持完整文件名、不含 .json 的名字、以及唯一前缀 */
static const ArchiveEntry *find_entry(const ArchiveEntry *list, size_t n,
const char *want) {
size_t stemlen = strlen(want);
if (str_ends_with(want, ".json"))
stemlen -= strlen(".json");
const ArchiveEntry *hit = NULL;
for (size_t i = 0; i < n; i++) {
if (strncmp(list[i].name, want, stemlen) == 0) {
if (strlen(list[i].name) == stemlen)
return &list[i]; /* 精确匹配 */
if (!hit)
hit = &list[i]; /* 前缀匹配 */
}
}
return hit;
}
/* 载入存档: 给了名字就直接载入, 否则交互式选一份.
* 只把历史装回去, 不重放历史内容. */
/* 问一个是非题, (Y/n) 语义: 直接回车算"是".
* 非终端时没人能回答, 按默认值返回 1 —— 和 confirm_tool 一致. */
static int ask_yes_no(const char *question) {
if (!isatty(STDIN_FILENO))
return 1;
for (;;) {
int intr = 0;
char *line = line_edit(question, &intr);
if (!line) {
putchar('\n');
return 1;
} /* Ctrl-C / EOF 也按默认值 */
char *ans = str_trim(line);
int yes = (*ans == '\0' || *ans == 'y' || *ans == 'Y');
int no = (*ans == 'n' || *ans == 'N');
free(line);
if (yes)
return 1;
if (no)
return 0;
puts(t("repl.yes_no.hint"));
}
}
/* /import 载入后用户可能想看看这段历史到底聊了什么.
*
* 这里是**完整**转储: 不截断正文、不省略工具参数、不砍总量. 既然用户
* 明确回答了 Y, 就该看到全部 —— 截一半反而会让人以为历史里就这么点东西.
* 所以只做排版, 不做取舍.
*
* 唯一"少打印"的地方是没有 content 的消息 (工具调用型的 assistant),
* 那种消息本来就没有正文, 打出来的是它的 tool_calls. */
static void print_history(const Agent *a, const JsonValue *msgs) {
const char *dim = a->color ? "\033[2m" : "";
const char *off = a->color ? "\033[0m" : "";
const char *grn = a->color ? "\033[32m" : "";
const char *cyan = a->color ? "\033[36m" : "";
const char *yel = a->color ? "\033[33m" : "";
size_t n = json_array_size(msgs);
for (size_t i = 0; i < n; i++) {
JsonValue *m = json_array_get(msgs, i);
const char *role = json_get_string(m, "role");
if (!role)
role = "?";
const char *label, *color;
if (strcmp(role, "system") == 0) {
label = t("repl.role.system");
color = dim;
} else if (strcmp(role, "user") == 0) {
label = t("repl.role.user");
color = grn;
} else if (strcmp(role, "assistant") == 0) {
label = t("repl.role.assistant");
color = cyan;
} else if (strcmp(role, "tool") == 0) {
label = t("repl.role.tool");
color = yel;
} else {
label = role;
color = off;
}
printf("\n%s-- %zu/%zu %s --%s\n", dim, i + 1, n, label, off);
/* tool 结果要标出它回应的是哪次调用, 否则一堆工具输出看不出配对 */
if (strcmp(role, "tool") == 0) {
const char *id = json_get_string(m, "tool_call_id");
printf("%s[tool_call_id=%s]%s\n", dim, id ? id : "", off);
}
const char *content = json_get_string(m, "content");
if (content && *content) {
fputs(color, stdout);
fputs(content, stdout);
fputs(off, stdout);
if (content[strlen(content) - 1] != '\n')
putchar('\n');
} else if (!json_object_get(m, "tool_calls")) {
printf("%s%s%s\n", dim, t("repl.no_content"), off);
}
/* 工具调用的参数完整打出来 —— 这常常是整段历史里信息量最大的部分 */
JsonValue *tcs = json_object_get(m, "tool_calls");
for (size_t k = 0; k < json_array_size(tcs); k++) {
JsonValue *tc = json_array_get(tcs, k);
JsonValue *fn = json_object_get(tc, "function");
const char *name = json_get_string(fn, "name");
const char *args = json_get_string(fn, "arguments");
const char *id = json_get_string(tc, "id");
printf("%s%s%s\n", dim,
tf("repl.tool_call", "name", name ? name : "?", "id",
id ? id : "", NULL),
off);
if (args && *args) {
printf("%s %s%s\n", dim, args, off);
}
}
}
if (n == 0)
printf("%s%s%s\n", dim, t("repl.archive_empty"), off);
putchar('\n');
}
static void cmd_import(Agent *a, const char *arg) {
ArchiveEntry *list = NULL;
size_t n = archive_list(g_bindir ? g_bindir : ".", &list);
if (n == 0) {
char *dir = archive_dir(g_bindir ? g_bindir : ".");
printf("%s", tf("repl.import.none", "dir", dir, NULL));
free(dir);
return;
}
const ArchiveEntry *pick = NULL;
if (arg && *arg) {
pick = find_entry(list, n, arg);
if (!pick) {
printf("%s", tf("repl.import.not_found", "name", arg, NULL));
archive_list_free(list, n);
return;
}
} else {
char *dir = archive_dir(g_bindir ? g_bindir : ".");
printf("%s", tf("repl.import.list_header", "dir", dir, "n",
i18n_num((long long)n), NULL));
free(dir);
for (size_t i = 0; i < n; i++) {
if (list[i].count < 0)
printf(t("repl.import.row_failed"), i + 1, list[i].name);
else
printf(t("repl.import.row"), i + 1, list[i].name,
list[i].created ? list[i].created : "?", list[i].count,
list[i].model ? list[i].model : "");
}
int intr = 0;
char *line = line_edit(t("repl.import.pick_prompt"), &intr);
if (!line) {
archive_list_free(list, n);
return;
}
char *ans = str_trim(line);
if (*ans == '\0') {
puts(t("repl.cancelled"));
free(line);
archive_list_free(list, n);
return;
}
char *numend = NULL;
long idx = strtol(ans, &numend, 10);
if (numend != ans && *str_trim(numend) == '\0') {
if (idx < 1 || (size_t)idx > n) {
printf("%s", tf("repl.index_out_of_range_plain", "n",
i18n_num(idx), NULL));
free(line);
archive_list_free(list, n);
return;
}
pick = &list[idx - 1];
} else {
pick = find_entry(list, n, ans);
if (!pick) {
printf("%s", tf("repl.import.not_found_cancelled", "name", ans,
NULL));
free(line);
archive_list_free(list, n);
return;
}
}
free(line);
}
char err[512];
JsonValue *msgs =
archive_load(pick->path, NULL, NULL, NULL, NULL, NULL, err, sizeof err);
if (!msgs) {
printf("%s", tf("repl.import.load_failed", "error", err, NULL));
archive_list_free(list, n);
return;
}
char *loaded = xstrdup(pick->name);
agent_replace_history(a, msgs); /* 取得所有权 */
/* 载入之后再数: 换成和 /history 一样的口径 (不含 system),
* 否则"已载入 N 条"和"历史消息 M 条"会差 1, 看着像出了错 */
size_t count = agent_conversation_len(a);
/* 输入历史不跟着清: 它现在是一份持久的、跨会话的用户资源 (像 bash 的
* HISTFILE), 记的是"我在 ligent> 敲过什么", 和当前装的是哪段对话无关. */
set_lineage(loaded); /* 之后的分支都挂在它下面 */
printf("%s", tf("repl.import.done", "name", loaded, "n",
i18n_num((long long)count), NULL));
/* 问一句再决定要不要把这段历史打出来 —— 直接回车就输出 (默认 Y),
* 历史很长、不想被刷屏时回 n 即可, 条数随时可以用 /history 看. */
if (ask_yes_no(t("repl.import.print_ask")))
print_history(a, a->messages);
free(loaded);
archive_list_free(list, n);
}
static void repl_help(void) { fputs(t("repl.help"), stdout); }
/* 带参数命令的匹配: /name 之后必须是空白或字符串结束.
*
* 只用前缀匹配是不够的 —— 那样 "/modelxxx" 会被当成 "/model" 并把 xxx
* 当作模型名去切模型, "/thinkfoo" 也会落进 /think 分支. 命中返回参数起点
* (已跳过空白; 指进 dup 里, 调用方不要 free), 不命中返回 NULL. */
static char *cmd_arg(char *cmd, const char *name) {
size_t n = strlen(name);
if (strncmp(cmd, name, n) != 0)
return NULL;
char c = cmd[n];
if (c != '\0' && c != ' ' && c != '\t')
return NULL;
return str_trim(cmd + n);
}
/* 返回 0 继续, 1 退出 */
static int handle_command(Agent *a, LigentConfig *c, const char *line) {
char *dup = xstrdup(line);
char *cmd = str_trim(dup);
char *arg = NULL; /* 当前分支的参数; 指向 dup 内部, 不单独释放 */
if (strcmp(cmd, "/exit") == 0 || strcmp(cmd, "/quit") == 0) {
free(dup);
return 1;
}
if (strcmp(cmd, "/help") == 0 || strcmp(cmd, "/h") == 0 ||
strcmp(cmd, "/?") == 0) {
repl_help();
} else if (strcmp(cmd, "/reset") == 0 || strcmp(cmd, "/clear") == 0) {
auto_save(a, c, t("repl.why.before_reset"), NULL);
agent_reset(a);
/* 刚存的是"上一段"对话, 和新开的这段没关系了, 所以把判重状态清掉.
* 不清的话会漏存: 判重只看 (条数, started_at), 而 time() 只有秒级
* 精度 —— 同一秒里重置、又聊出同样多条消息, 退出时就会被当成
* "内容没变"而跳过存档. */
g_saved_count = 0;
g_saved_start = 0;
set_lineage(NULL); /* 新对话另起一个根 */
back_undo_clear(); /* 旧的回退记录不再适用 */
puts(t("repl.reset.done"));
} else if ((arg = cmd_arg(cmd, "/model")) != NULL) {
if (*arg)
apply_model(a, c, arg);
else
cmd_model_pick(a, c);
} else if ((arg = cmd_arg(cmd, "/include")) != NULL) {
cmd_include(a, arg);
} else if ((arg = cmd_arg(cmd, "/system")) != NULL) {
if (*arg) {
free(c->system);
c->system = xstrdup(arg);
agent_reset(a);
attach_clear(); /* 重新开始, 暂存的附件也一并丢掉 */
agent_add_system(a, c->system);
puts(t("repl.system.updated"));
} else {
printf("%s\n%s\n", t("repl.system.current"),
c->system ? c->system : t("repl.system.builtin"));
}
} else if ((arg = cmd_arg(cmd, "/think")) != NULL) {
if (*arg) {
int mode;
if (config_parse_thinking(arg, &mode) == 0) {
c->thinking = mode;
a->llm.thinking = mode;
printf(t("repl.think.mode"), config_thinking_name(mode));
} else if (config_effort_valid(arg)) {
free(c->reasoning_effort);
c->reasoning_effort = xstrdup(arg);
a->llm.reasoning_effort = c->reasoning_effort;
printf("%s", tf("repl.think.effort_set", "level", arg, NULL));
} else {
printf("%s", tf("repl.think.bad", "value", arg, "list",
config_effort_list(), NULL));
}
} else {
printf(t("repl.think.mode"), config_thinking_name(c->thinking));
printf(t("repl.think.effort"),
(c->reasoning_effort && *c->reasoning_effort)
? c->reasoning_effort
: t("cfg.val.server_default"));
}
} else if (strcmp(cmd, "/meta") == 0) {
/* 进 meta 前先留一份保底存档: 里面全是不可逆的删改.
* 用 tmp_ 前缀, 以免它变成以后分支的祖先. */
auto_save(a, c, t("repl.why.before_meta"), ARCHIVE_TMP_PREFIX);
int r = meta_session(a, c);
if (r == 1) {
/* 历史被改过: /back 的撤销栈基于旧历史, 作废;
* 存档判重也清掉, 保证退出时一定存下这个新版本. */
back_undo_clear();
g_saved_count = 0;
g_saved_start = 0;
}
} else if (strcmp(cmd, "/cls") == 0) {
/* 只清屏幕, 不碰对话历史 —— 和 /reset 是两回事.
* 3J 连回滚缓冲一起清, 跟 clear(1) 的行为对齐; 不支持 3J 的终端会
* 忽略它, 2J + H 照样把可见区域清干净. */
if (isatty(STDOUT_FILENO))
fputs("\033[2J\033[3J\033[H", stdout);
fflush(stdout);
} else if (strcmp(cmd, "/tools") == 0) {
if (a->use_tools) {
/* 只查缓存, 绝不为了显示这一行去发探测请求 */
Capabilities caps;
tools_print_list(cap_cached(&a->llm, &caps) &&
caps.vision == CAP_NO);
} else
puts(t("repl.tools.disabled"));
} else if (strcmp(cmd, "/probe") == 0) {
/* 手动测一次模型能力 (收不收图片 / 收不收 tools).
* 刻意绕开 --no-probe 与缓存 —— 理由见 agent_probe_now 的注释. */
agent_probe_now(a);
} else if (strcmp(cmd, "/config") == 0) {
config_dump(c);
} else if ((arg = cmd_arg(cmd, "/cd")) != NULL) {
if (*arg) {
if (chdir(arg) != 0)
perror(t("repl.cd_failed"));
else {
char cwd[1024];
if (getcwd(cwd, sizeof cwd))
printf("%s", tf("repl.cd.current", "dir", cwd, NULL));
}
} else {
char cwd[1024];
if (getcwd(cwd, sizeof cwd))
printf("%s\n", cwd);
}
} else if (strcmp(cmd, "/usage") == 0) {
if (!a->usage_reported) {
puts(t("repl.usage.none"));
} else {
printf("%s", t("repl.usage.header"));
printf(t("repl.usage.calls"), a->api_calls);
printf(t("repl.usage.prompt"), a->tok_prompt);
printf(t("repl.usage.completion"), a->tok_completion);
printf(t("repl.usage.total"), a->tok_total);
printf(t("repl.usage.last"), a->tok_last_prompt);
printf(t("repl.usage.history"), agent_conversation_len(a));
}
} else if (strcmp(cmd, "/yolo") == 0) {
int on = !confirm_enabled();
confirm_set_enabled(on);
a->confirm = on;
printf("%s", tf("repl.yolo.set", "state",
on ? t("repl.yolo.on") : t("repl.yolo.off"), NULL));
} else if (strcmp(cmd, "/back") == 0) {
JsonValue *removed = agent_take_last_round(a);
if (!removed) {
puts(t("repl.back.none"));
} else {
size_t rn = json_array_size(removed);
if (!g_back_undo)
g_back_undo = json_new_array();
for (size_t i = 0; i < rn; i++)
json_array_push(g_back_undo,
json_clone(json_array_get(removed, i)));
json_free(removed);
g_back_len = agent_history_len(a); /* 比对用, 保持数组真实长度 */
printf("%s",
tf("repl.back.done", "n", i18n_num((long long)rn), "left",
i18n_num((long long)agent_conversation_len(a)), NULL));
}
} else if (strcmp(cmd, "/unback") == 0) {
size_t n = g_back_undo ? json_array_size(g_back_undo) : 0;
if (n == 0) {
puts(t("repl.unback.none"));
} else if (agent_history_len(a) != g_back_len) {
puts(t("repl.unback.new_chat"));
} else {
for (size_t i = 0; i < n; i++)
json_array_push(a->messages,
json_clone(json_array_get(g_back_undo, i)));
printf("%s",
tf("repl.unback.done", "n", i18n_num((long long)n), "total",
i18n_num((long long)agent_conversation_len(a)), NULL));
back_undo_clear();
}
} else if ((arg = cmd_arg(cmd, "/shell")) != NULL) {
if (*arg) {
/* 带参数: 直接执行它, 不进入交互式 shell */
JsonValue *args = json_new_object();
json_object_set_string(args, "command", arg);
int is_err = 0;
char *r = tool_execute(TOOL_RUN_COMMAND, args, &is_err);
json_free(args);
puts(r);
char *msg =
xasprintf("我在 shell 里执行了: %s\n\n结果是: \n%s", arg, r);
agent_add_user(a, msg);
free(msg);
free(r);
puts(t("repl.shell.added_hint"));
} else {
puts(t("repl.shell.enter"));
char err[256];
int code = 0;
char *rec = shell_interactive(&code, err, sizeof err);
if (!rec) {
printf("%s", tf("repl.shell.start_failed", "error", err, NULL));
} else {
if (rec[0]) {
char *msg = xasprintf("我在交互式 shell 里做了以下操作 "
"(shell 退出码 %d): \n\n"
"```console\n%s\n```\n",
code, rec);
agent_add_user(a, msg);
printf("\n%s\n",
tf("repl.shell.recorded", "n",
i18n_num((long long)strlen(rec)), NULL));
free(msg);
} else {
printf("\n%s\n", t("repl.shell.nothing"));
}
free(rec);
}
}
} else if (strcmp(cmd, "/export") == 0) {
cmd_export(a, c);
} else if ((arg = cmd_arg(cmd, "/import")) != NULL) {
auto_save(a, c, t("repl.why.before_import"), ARCHIVE_TMP_PREFIX);
cmd_import(a, arg);
back_undo_clear();
} else if ((arg = cmd_arg(cmd, "/log")) != NULL) {
if (*arg == '\0') {
printf("%s", tf("repl.log.status", "n",
i18n_num((long long)http_log_size()), "trunc",
http_log_truncated() ? t("repl.log.truncated") : "",
NULL));
} else if (strcmp(arg, "clear") == 0 || strcmp(arg, "reset") == 0) {
http_log_clear();
puts(t("repl.log.cleared"));
} else {
size_t n = 0;
char err[256];
if (http_log_save(arg, &n, err, sizeof err) != 0)
printf("%s", tf("repl.log.save_failed", "error", err, NULL));
else if (n == 0)
printf("%s", tf("repl.log.empty", "file", arg, NULL));
else
printf("%s", tf("repl.log.saved", "n", i18n_num((long long)n),
"file", arg, NULL));
}
} else if (strcmp(cmd, "/history") == 0) {
/* 一行摘要 + 完整对话.
*
* 摘要那行报的是"不含 system"的口径 (和 /usage、/back、/export 一致),
* 而下面列出来的是**含** system 的全部消息 —— 两个数不一样, 所以必须
* 说清楚, 否则用户会以为打印漏了或者多了一条.
*
* 为什么要连 system 一起列: 那是"模型被要求怎么做"的原文, 想核对提示词
* 时只有这里能看到全文 (/config 里那行会把多行提示词挤成一团). */
printf("%s", tf("repl.history.count", "n",
i18n_num((long long)agent_conversation_len(a)), NULL));
printf("%s", tf("repl.history.full", "total",
i18n_num((long long)agent_history_len(a)), NULL));
print_history(a, a->messages);
} else {
printf("%s", tf("repl.unknown_command", "cmd", cmd, NULL));
}
free(dup);
return 0;
}
/* 输入历史最多保留多少条. 和 bash 一样有上限, 否则文件会一直长下去;
* 只保留最新的, 旧的在下次启动时被就地截掉. */
#define LIGENT_HISTORY_KEEP 1000
/* 输入历史文件; NULL 表示禁用 (--no-history). */
static char *g_hist_path = NULL;
/* 提示符保持纯文本: 绿色由行编辑器统一加在整个输入行 (提示符 + 你敲的字)上.
* 在这里塞颜色的话, 提示符自带的 reset 会让后面的输入掉回默认色. */
#define PROMPT_MAIN "ligent> "
#define PROMPT_CONT " ... "
/* 读一条完整输入. 支持行尾反斜杠续行: 每段都由行编辑器独立编辑,
* 因此方向键、Home/End、历史在续行的每一段里都可用. */
static char *read_prompt_line(const char *prompt, int *interrupted) {
StrBuf sb;
sb_init(&sb);
*interrupted = 0;
int first = 1;
for (;;) {
int seg_int = 0;
char *seg = line_edit(first ? prompt : PROMPT_CONT, &seg_int);
first = 0;
if (!seg) {
if (seg_int) {
g_ligent_interrupt = 0;
*interrupted = 1;
sb_free(&sb);
return NULL;
}
/* EOF: 缓冲区里还有内容就把它交出去, 否则终止 */
if (sb.len > 0) {
if (sb.data[sb.len - 1] != '\n')
sb_putc(&sb, '\n');
return sb_detach(&sb);
}
sb_free(&sb);
return NULL;
}
/* 整行空且不是续行状态 => 当作空输入返回 */
if (!*seg && sb.len == 0) {
free(seg);
sb_puts(&sb, "\n");
return sb_detach(&sb);
}
size_t n = strlen(seg);
int cont = 0;
if (n > 0 && seg[n - 1] == '\\') {
seg[n - 1] = '\0';
cont = 1;
}
sb_puts(&sb, seg);
free(seg);
if (!cont) {
sb_putc(&sb, '\n');
/* 历史记的是"这一整条输入": 多行续行算一条, 上下键能整条召回.
* 续行的中间段不单独记. 这里借尾部那个换行临时当结束符. */
if (sb.len > 1) {
sb.data[sb.len - 1] = '\0';
if (line_history_add(sb.data))
line_history_append(g_hist_path, sb.data);
sb.data[sb.len - 1] = '\n';
}
break;
}
sb_putc(&sb, '\n');
}
return sb_detach(&sb);
}
static int run_repl(Agent *a, LigentConfig *c) {
g_bindir = c->config_dir;
/* 图片备份也要落在二进制旁边. 这个函数一度**从来没有被调用过**,
* image_bindir() 于是退化成 ".", 备份跟着当前工作目录跑 —— /cd 到别的
* 目录再带图, 备份就写到那儿去了, 之后从别处发消息会因为找不到备份而
* 把图降级掉. 测试一直从二进制所在目录跑, "." 恰好等于正确值, 所以
* 一直没暴露. */
image_set_bindir(c->config_dir);
print_banner(c, a);
for (;;) {
int interrupted = 0;
char *line = read_prompt_line(PROMPT_MAIN, &interrupted);
if (!line) {
if (interrupted)
continue;
break; /* EOF */
}
char *trimmed = str_trim(line);
if (*trimmed == '\0') {
free(line);
continue;
}
if (*trimmed == '/') {
int quit = handle_command(a, c, trimmed);
free(line);
if (quit)
break;
continue;
}
/* 暂存的附件跟着这条消息一起发出去 */
agent_add_user_parts(a, trimmed, attach_take_parts());
free(line);
int rc = agent_run(a);
g_ligent_interrupt = 0;
if (rc < 0)
fprintf(stderr, "%s", t("repl.turn_failed"));
putchar('\n');
}
auto_save(a, c, t("repl.why.exit"), NULL);
puts(t("repl.bye"));
return 0;
}
/* ------------------------------------------------------------------ */
/* main */
/* ------------------------------------------------------------------ */
int main(int argc, char **argv) {
LigentConfig cfg;
const char *explicit_config = NULL;
char err[512];
/* 界面语言: --lang > LIGENT_LANG > LC_ALL/LC_MESSAGES/LANG > 英文.
* 先粗扫一遍 argv, 好让 --help 和 --lang 的先后顺序不影响结果 ——
* 解析循环一遇到 --help 就直接返回, 那之后再写 --lang 就来不及了. */
{
int lang = -1;
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "--lang") == 0 && i + 1 < argc) {
lang = i18n_parse_lang(argv[i + 1]);
break;
}
if (strncmp(argv[i], "--lang=", 7) == 0) {
lang = i18n_parse_lang(argv[i] + 7);
break;
}
}
i18n_init(lang); /* -1 = 按环境探测 */
}
/* 先用一个临时结构收集命令行覆盖项 */
struct {
const char *model, *base_url, *api_key, *system, *prompt_text;
int have_temperature, have_max_tokens, have_max_steps, have_timeout;
int have_retries;
int thinking, thinking_set;
const char *effort;
const char *history;
int history_set, history_off;
double temperature;
long max_tokens, timeout;
int max_steps;
int retries;
int stream_set, stream_val;
int probe_set, probe_val;
int confirm_set, confirm_val;
int autosave_set, autosave_val;
int doctor;
int offline;
int tools_set, tools_val;
int verbose, quiet;
int color_set, color_val;
int print_config;
StrBuf prompt;
} cli;
memset(&cli, 0, sizeof cli);
sb_init(&cli.prompt);
for (int i = 1; i < argc; i++) {
const char *arg = argv[i];
const char *val = NULL;
char optbuf[64];
if (arg[0] == '-' && arg[1] == '-') {
const char *eq = strchr(arg, '=');
if (eq) {
size_t n = (size_t)(eq - arg);
if (n >= sizeof optbuf)
n = sizeof optbuf - 1;
memcpy(optbuf, arg, n);
optbuf[n] = '\0';
arg = optbuf;
val = eq + 1;
}
}
#define NEED_VAL() \
do { \
if (!val) { \
if (i + 1 >= argc) { \
fprintf(stderr, "%s", \
tf("cli.err.missing_value", "option", arg, NULL)); \
return 2; \
} \
val = argv[++i]; \
} \
} while (0)
if (strcmp(arg, "-h") == 0 || strcmp(arg, "--help") == 0) {
print_usage(stdout);
return 0;
} else if (strcmp(arg, "-V") == 0 || strcmp(arg, "--version") == 0) {
printf("ligent %s\n", ligent_version_full());
return 0;
} else if (strcmp(arg, "-m") == 0 || strcmp(arg, "--model") == 0) {
NEED_VAL();
cli.model = val;
} else if (strcmp(arg, "-u") == 0 || strcmp(arg, "--base-url") == 0) {
NEED_VAL();
cli.base_url = val;
} else if (strcmp(arg, "-k") == 0 || strcmp(arg, "--api-key") == 0) {
NEED_VAL();
cli.api_key = val;
} else if (strcmp(arg, "-c") == 0 || strcmp(arg, "--config") == 0) {
NEED_VAL();
explicit_config = val;
} else if (strcmp(arg, "--history") == 0) {
NEED_VAL();
cli.history = val;
cli.history_set = 1;
cli.history_off = 0;
} else if (strcmp(arg, "--no-history") == 0) {
cli.history_set = 1;
cli.history_off = 1;
} else if (strcmp(arg, "-s") == 0 || strcmp(arg, "--system") == 0) {
NEED_VAL();
cli.system = val;
} else if (strcmp(arg, "-t") == 0 ||
strcmp(arg, "--temperature") == 0) {
NEED_VAL();
cli.temperature = atof(val);
cli.have_temperature = 1;
} else if (strcmp(arg, "--max-tokens") == 0) {
NEED_VAL();
cli.max_tokens = atol(val);
cli.have_max_tokens = 1;
} else if (strcmp(arg, "--thinking") == 0 ||
strcmp(arg, "--think") == 0) {
NEED_VAL();
if (config_parse_thinking(val, &cli.thinking) != 0) {
fprintf(stderr, "%s",
tf("cli.err.bad_thinking", "value", val, NULL));
return 2;
}
cli.thinking_set = 1;
} else if (strcmp(arg, "--effort") == 0 ||
strcmp(arg, "--reasoning-effort") == 0) {
NEED_VAL();
if (!config_effort_valid(val)) {
fprintf(stderr, "%s",
tf("cli.err.bad_effort", "list", config_effort_list(),
"value", val, NULL));
return 2;
}
cli.effort = val;
} else if (strcmp(arg, "--max-steps") == 0) {
NEED_VAL();
cli.max_steps = atoi(val);
cli.have_max_steps = 1;
} else if (strcmp(arg, "--timeout") == 0) {
NEED_VAL();
cli.timeout = atol(val);
cli.have_timeout = 1;
} else if (strcmp(arg, "--retries") == 0) {
NEED_VAL();
cli.retries = atoi(val);
cli.have_retries = 1;
} else if (strcmp(arg, "--no-stream") == 0) {
cli.stream_set = 1;
cli.stream_val = 0;
} else if (strcmp(arg, "--stream") == 0) {
cli.stream_set = 1;
cli.stream_val = 1;
} else if (strcmp(arg, "doctor") == 0 && !cli.doctor &&
cli.prompt.len == 0) {
cli.doctor = 1; /* 子命令: ligent doctor */
} else if (strcmp(arg, "--offline") == 0) {
cli.offline = 1;
} else if (strcmp(arg, "--no-confirm") == 0) {
cli.confirm_set = 1;
cli.confirm_val = 0;
} else if (strcmp(arg, "--confirm") == 0) {
cli.confirm_set = 1;
cli.confirm_val = 1;
} else if (strcmp(arg, "--no-auto-save") == 0) {
cli.autosave_set = 1;
cli.autosave_val = 0;
} else if (strcmp(arg, "--auto-save") == 0) {
cli.autosave_set = 1;
cli.autosave_val = 1;
} else if (strcmp(arg, "--no-probe") == 0) {
cli.probe_set = 1;
cli.probe_val = 0;
} else if (strcmp(arg, "--probe") == 0) {
cli.probe_set = 1;
cli.probe_val = 1;
} else if (strcmp(arg, "--no-tools") == 0) {
cli.tools_set = 1;
cli.tools_val = 0;
} else if (strcmp(arg, "--tools") == 0) {
cli.tools_set = 1;
cli.tools_val = 1;
} else if (strcmp(arg, "-q") == 0 || strcmp(arg, "--quiet") == 0) {
cli.quiet = 1;
} else if (strcmp(arg, "-v") == 0 || strcmp(arg, "--verbose") == 0) {
cli.verbose = 1;
} else if (strcmp(arg, "--color") == 0) {
cli.color_set = 1;
cli.color_val = 1;
} else if (strcmp(arg, "--no-color") == 0) {
cli.color_set = 1;
cli.color_val = 0;
} else if (strcmp(arg, "--lang") == 0) {
NEED_VAL();
int l = i18n_parse_lang(val);
if (l < 0 && strcmp(val, "auto") != 0) {
fprintf(stderr, "%s",
tf("cli.err.bad_lang", "value", val, NULL));
return 2;
}
i18n_init(l); /* "auto" 也是 -1: 交给环境探测 */
} else if (strcmp(arg, "--print-config") == 0) {
cli.print_config = 1;
} else if (strcmp(arg, "--") == 0) {
for (int j = i + 1; j < argc; j++) {
if (cli.prompt.len)
sb_putc(&cli.prompt, ' ');
sb_puts(&cli.prompt, argv[j]);
}
break;
} else if (arg[0] == '-' && arg[1] != '\0') {
fprintf(stderr, "%s",
tf("cli.err.unknown_option", "option", arg, NULL));
return 2;
} else {
if (cli.prompt.len)
sb_putc(&cli.prompt, ' ');
sb_puts(&cli.prompt, argv[i]);
}
#undef NEED_VAL
}
/* 配置: 默认值 → 文件 → 环境变量 → 命令行 */
/* doctor 要能在"配置本身坏了"的时候跑起来 —— 那正是最需要它的时刻 */
char cfg_error[512];
cfg_error[0] = '\0';
if (config_init(&cfg, explicit_config, argv[0], err, sizeof err) != 0) {
if (!cli.doctor) {
fprintf(stderr, "%s", tf("cli.err.config", "error", err, NULL));
return 2;
}
snprintf(cfg_error, sizeof cfg_error, "%s", err);
}
if (cli.model) {
free(cfg.model);
cfg.model = xstrdup(cli.model);
}
if (cli.base_url) {
free(cfg.base_url);
cfg.base_url = xstrdup(cli.base_url);
}
if (cli.api_key) {
free(cfg.api_key);
cfg.api_key = xstrdup(cli.api_key);
cfg.api_key_from_cli = 1;
}
if (cli.system && *cli.system) {
free(cfg.system);
cfg.system = xstrdup(cli.system);
}
if (cli.have_temperature)
cfg.temperature = cli.temperature;
if (cli.have_max_tokens)
cfg.max_tokens = cli.max_tokens;
if (cli.thinking_set)
cfg.thinking = cli.thinking;
if (cli.effort) {
free(cfg.reasoning_effort);
cfg.reasoning_effort = xstrdup(cli.effort);
}
if (cli.have_timeout)
cfg.timeout = cli.timeout;
if (cli.have_max_steps)
cfg.max_steps = cli.max_steps;
if (cli.have_retries)
cfg.retries = cli.retries;
if (cli.stream_set)
cfg.stream = cli.stream_val;
if (cli.tools_set)
cfg.use_tools = cli.tools_val;
if (cli.probe_set)
cfg.probe = cli.probe_val;
if (cli.confirm_set)
cfg.confirm = cli.confirm_val;
if (cli.autosave_set)
cfg.auto_save = cli.autosave_val;
/* doctor 要在"缺 key 直接退出"之前跑 —— 没配好正是它要诊断的场景 */
if (cli.doctor) {
int rc = doctor_run(&cfg, argv[0], !cli.offline, cfg_error);
config_free(&cfg);
sb_free(&cli.prompt);
return rc == 0 ? 0 : 1;
}
if (cli.print_config) {
config_dump(&cfg);
config_free(&cfg);
sb_free(&cli.prompt);
return 0;
}
/* 没有 key 时尽早报错, 而不是等第一次请求失败 */
if (!cfg.api_key || !*cfg.api_key) {
fprintf(stderr, "%s", t("cli.err.no_api_key"));
config_free(&cfg);
sb_free(&cli.prompt);
return 2;
}
if (http_global_init() != 0) {
fprintf(stderr, "%s", t("cli.err.curl_init"));
config_free(&cfg);
sb_free(&cli.prompt);
return 1;
}
Agent agent;
agent_init(&agent);
apply_config(&agent, &cfg);
agent.llm.verbose = cli.verbose;
agent.show_tool_output = !cli.quiet;
confirm_init(cfg.confirm);
g_auto_save_on = cfg.auto_save;
if (cli.color_set)
agent_set_color(&agent, cli.color_val);
else
agent_set_color(&agent, isatty(fileno(stdout)) ? 1 : 0);
line_edit_set_color(agent.color); /* 输入行跟正文用同一个配色开关 */
if (cfg.system) {
agent_add_system(&agent, cfg.system);
} else {
char *sp = agent_default_system_prompt();
agent_add_system(&agent, sp);
free(sp);
}
/* 只在 stderr 是终端时转 —— 重定向/管道不该收到动画 */
spinner_init(isatty(fileno(stderr)));
install_signals();
int exit_code = 0;
g_bindir = cfg.config_dir;
image_set_bindir(cfg.config_dir); /* 理由见 run_repl 里那段注释 */
/* 输入历史文件: 命令行 > 环境变量 > <二进制目录>/ligent.history.
* 和配置一样, 默认只放二进制旁边, 不碰用户级/系统级目录. */
{
const char *env = getenv("LIGENT_HISTORY");
if (cli.history_off) {
g_hist_path = NULL;
} else if (cli.history_set && cli.history) {
g_hist_path = xstrdup(cli.history);
} else if (env && *env) {
g_hist_path = xstrdup(env);
} else {
g_hist_path = xasprintf("%s/ligent.history", cfg.config_dir);
}
/* 读不回来不是错误: 第一次跑本来就没有这个文件, 只读的目录也不该
* 拦住 REPL. 加载失败静默降级成"这次没有历史". */
if (g_hist_path)
line_history_load(g_hist_path, LIGENT_HISTORY_KEEP);
}
if (cli.prompt.len > 0) {
/* 单次任务模式 */
agent_add_user(&agent, cli.prompt.data);
int rc = agent_run(&agent);
exit_code = rc == 0 ? 0 : 1;
auto_save(&agent, &cfg, t("repl.why.exit"), NULL);
} else if (!isatty(fileno(stdin))) {
/* 管道输入: 把 stdin 当任务 */
char *text = read_stdin_all();
char *trimmed = str_trim(text);
if (*trimmed) {
agent_add_user(&agent, trimmed);
int rc = agent_run(&agent);
exit_code = rc == 0 ? 0 : 1;
auto_save(&agent, &cfg, t("repl.why.exit"), NULL);
} else {
print_usage(stderr);
exit_code = 2;
}
free(text);
} else {
exit_code = run_repl(&agent, &cfg);
}
agent_free(&agent);
config_free(&cfg);
sb_free(&cli.prompt);
http_log_free();
free(g_lineage);
back_undo_clear();
http_global_cleanup();
return exit_code;
}