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