Files
ligent/src/main.c
T
2026-10-03 14:13:21 +08:00

1252 lines
49 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* main.c - 入口:命令行解析、单次任务模式、交互式 REPL */
#include "config.h"
#include "agent.h"
#include "tools.h"
#include "llm.h"
#include "util.h"
#include "http.h"
#include "lineedit.h"
#include "meta.h"
#include "doctor.h"
#include "spinner.h"
#include "archive.h"
#include "attach.h"
#include "image.h"
#include "models.h"
#include "confirm.h"
#include "i18n.h"
#include "shell.h"
#include "version.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <errno.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;
}