mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-08-05 09:30:49 -05:00
ui: CWD for agent (#26518)
* server : extend file_glob_search for UI pickers * ui : add per-conversation working directory with picker * ui : add path navigation and search scope to cwd picker Treat path-like queries (starting with / or ~) as directory navigation instead of glob-matching the whole query: search the parent for the last segment, and descend into an exactly-typed directory by listing its children. Show the effective search scope in the footer and auto-search on open so the current directory and its siblings appear immediately. Assisted-by: Claude * db : persist per-call tool cwd on tool result messages * ui : abbreviate tool paths under home with a tilde * ui : show the per-call cwd on exec shell rows * ui : clarify the synthetic cwd message for the model * ui : reuse the trailing cwd row on a repeated pick * ui : don't jump when a cwd row is injected mid-chat * chore: Formatting * refactor: Cleanup comments * ui : unify working directory naming and add a synthetic-message flag * ui : render synthetic cwd rows without a scroll jump * ui : decouple the working directory picker into utils and sub-components * ui : add get_info tool call block * chore: Formatting * refactor: Cleanup * refactor: Cleanup * refactor: Cleanup * fix: UI * server : harden file_glob_search listing (kind enum, timeout, symlink guard, absolute base) * ui : use persisted isSynthetic flag for cwd rows, drop legacy formats * ui : cache picker search, fail visibly on native resolve * ui : escape glob metacharacters in picker search glob * ui : simplify auto-scroll pin * chore: Format * fix: Use `SvelteMap` * refactor: Post-review fixes * ui: accept Windows roots in the working directory picker recognize a drive root (C:) and a UNC share (//host/share) as path navigation, alongside the POSIX root and ~, so a query like D:\repos lists that directory instead of glob-matching it under the home dir split below the root, so a bare drive resolves to its root rather than to a drive-relative prefix rewrite backslashes into forward slashes only when the query carries a Windows root, since a backslash is a legal POSIX filename character paths keep travelling with forward slashes, which is what the server returns and what Windows accepts --------- Co-authored-by: Pascal <admin@serveurperso.com>
This commit is contained in:
committed by
GitHub
parent
0713275082
commit
2f56fc3431
@@ -10,8 +10,10 @@
|
||||
#include <ctime>
|
||||
#include <atomic>
|
||||
#include <cstring>
|
||||
#include <cstdlib>
|
||||
#include <algorithm>
|
||||
#include <unordered_set>
|
||||
#include <tuple>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
@@ -34,7 +36,40 @@ json server_tool::to_json() const {
|
||||
}
|
||||
|
||||
static constexpr size_t SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT = 8 * 1024 * 1024; // 8 MB
|
||||
static constexpr int SERVER_TOOL_GIT_LS_FILES_TIMEOUT = 15; // seconds
|
||||
// budget for one listing call, shared by the git and walker paths
|
||||
static constexpr int SERVER_TOOL_LIST_ENTRIES_TIMEOUT = 15; // seconds
|
||||
|
||||
// entry kinds a directory listing may return
|
||||
enum class list_kind {
|
||||
files, // regular files only
|
||||
dirs, // directories only
|
||||
all, // both
|
||||
};
|
||||
|
||||
// home directory, read once at first use (getenv is not thread safe against setenv)
|
||||
static const std::string & home_dir() {
|
||||
static const std::string home = [] {
|
||||
const char * h = getenv("HOME");
|
||||
#ifdef _WIN32
|
||||
if (h == nullptr) h = getenv("USERPROFILE");
|
||||
#endif
|
||||
return h ? std::string(h) : std::string();
|
||||
}();
|
||||
return home;
|
||||
}
|
||||
|
||||
static std::string expand_home(const std::string & path) {
|
||||
if (path.empty() || path[0] != '~') return path;
|
||||
if (path.size() > 1 && path[1] != '/' && path[1] != '\\') return path;
|
||||
const std::string & home = home_dir();
|
||||
if (home.empty()) return path;
|
||||
return home + path.substr(1);
|
||||
}
|
||||
|
||||
// depth of a '/'-separated relative path: "a/b/c" is 3
|
||||
static int entry_depth(const std::string & rel) {
|
||||
return 1 + (int) std::count(rel.begin(), rel.end(), '/');
|
||||
}
|
||||
|
||||
class tools_io {
|
||||
public:
|
||||
@@ -51,8 +86,17 @@ public:
|
||||
virtual bool file_size(const std::string & path, uintmax_t & out_size) const = 0;
|
||||
virtual bool read_file(const std::string & path, std::string & out) const = 0;
|
||||
virtual bool write_file(const std::string & path, const std::string & content) const = 0;
|
||||
// paths relative to `base`, '/'-separated; sets `err` if `base` isn't a directory
|
||||
virtual std::vector<std::string> list_files(const std::string & base, std::string & err) const = 0;
|
||||
// resolve `path` against the IO's working directory; absolute paths are returned unchanged
|
||||
virtual std::string resolve(const std::string & path) const = 0;
|
||||
struct list_entry {
|
||||
std::string rel; // '/'-separated, relative to `base`
|
||||
bool is_dir = false;
|
||||
};
|
||||
// entries relative to `base`; sets `err` if `base` isn't a directory
|
||||
// max_depth == 0 means unlimited, 1 means direct children of `base` only
|
||||
// `base` must already be resolved (absolute); `caller_path` is the path the
|
||||
// caller passed, used only for error messages
|
||||
virtual std::vector<list_entry> list_entries(const std::string & base, const std::string & caller_path, int max_depth, list_kind kind, std::string & err, bool & truncated) const = 0;
|
||||
// on_chunk, if set, is called with each chunk of output as it is read (before truncation cuts in);
|
||||
// returning false terminates the process early (e.g. the client disconnected)
|
||||
virtual exec_result run(
|
||||
@@ -67,6 +111,22 @@ public:
|
||||
// cwd, if non-empty, is used to resolve relative paths and as the working directory for run()
|
||||
explicit tools_io_basic(std::string cwd = "") : cwd(std::move(cwd)) {}
|
||||
|
||||
// expands a leading `~`, then resolves `path` against `cwd` (or the server
|
||||
// working directory when `cwd` is unset); the result is always absolute
|
||||
std::string resolve(const std::string & path) const override {
|
||||
std::string p = expand_home(path);
|
||||
if (fs::path(p).is_absolute()) {
|
||||
return p;
|
||||
}
|
||||
if (cwd.empty()) {
|
||||
std::error_code ec;
|
||||
fs::path cur = fs::current_path(ec);
|
||||
if (ec) return p;
|
||||
return (cur / p).string();
|
||||
}
|
||||
return (fs::path(cwd) / p).string();
|
||||
}
|
||||
|
||||
bool is_directory(const std::string & path) const override {
|
||||
std::error_code ec;
|
||||
return fs::is_directory(resolve(path), ec) && !ec;
|
||||
@@ -105,34 +165,41 @@ public:
|
||||
return (bool) f;
|
||||
}
|
||||
|
||||
std::vector<std::string> list_files(const std::string & base, std::string & err) const override {
|
||||
std::vector<list_entry> list_entries(const std::string & base, const std::string & caller_path, int max_depth, list_kind kind, std::string & err, bool & truncated) const override {
|
||||
err.clear();
|
||||
std::string abs_base = resolve(base);
|
||||
if (!is_directory(base)) {
|
||||
err = "path does not exist or is not a directory: " + base;
|
||||
truncated = false;
|
||||
std::error_code ec;
|
||||
if (!fs::is_directory(base, ec) || ec) {
|
||||
err = "path does not exist or is not a directory: " + caller_path;
|
||||
return {};
|
||||
}
|
||||
|
||||
auto res = run(
|
||||
{"git", "-C", abs_base, "ls-files", "--cached", "--others", "--exclude-standard"},
|
||||
SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, SERVER_TOOL_GIT_LS_FILES_TIMEOUT);
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(SERVER_TOOL_LIST_ENTRIES_TIMEOUT);
|
||||
|
||||
if (res.exit_code == 0 && !res.timed_out) {
|
||||
std::vector<std::string> result;
|
||||
std::istringstream iss(res.output);
|
||||
std::string line;
|
||||
while (std::getline(iss, line)) {
|
||||
if (!line.empty() && line.back() == '\r') line.pop_back();
|
||||
if (line.empty()) continue;
|
||||
std::replace(line.begin(), line.end(), '\\', '/');
|
||||
if (is_regular_file((fs::path(base) / line).string())) {
|
||||
result.push_back(line);
|
||||
// git ls-files cannot list directories; use the walker when they are requested
|
||||
if (kind == list_kind::files) {
|
||||
auto res = run(
|
||||
{"git", "-C", base, "ls-files", "--cached", "--others", "--exclude-standard"},
|
||||
SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, SERVER_TOOL_LIST_ENTRIES_TIMEOUT);
|
||||
|
||||
if (res.exit_code == 0 && !res.timed_out) {
|
||||
std::vector<list_entry> result;
|
||||
std::istringstream iss(res.output);
|
||||
std::string line;
|
||||
while (std::getline(iss, line)) {
|
||||
if (!line.empty() && line.back() == '\r') line.pop_back();
|
||||
if (line.empty()) continue;
|
||||
std::replace(line.begin(), line.end(), '\\', '/');
|
||||
if (max_depth > 0 && entry_depth(line) > max_depth) continue;
|
||||
if (is_regular_file((fs::path(base) / line).string())) {
|
||||
result.push_back({line, false});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return list_files_fallback(abs_base);
|
||||
return list_entries_fallback(base, max_depth, kind, deadline, truncated);
|
||||
}
|
||||
|
||||
exec_result run(
|
||||
@@ -211,14 +278,6 @@ public:
|
||||
private:
|
||||
std::string cwd;
|
||||
|
||||
// resolves `path` against `cwd` if `path` is relative and `cwd` is set; otherwise returns `path` unchanged
|
||||
std::string resolve(const std::string & path) const {
|
||||
if (cwd.empty() || fs::path(path).is_absolute()) {
|
||||
return path;
|
||||
}
|
||||
return (fs::path(cwd) / path).string();
|
||||
}
|
||||
|
||||
static const std::unordered_set<std::string> & junk_dir_names() {
|
||||
static const std::unordered_set<std::string> names = {
|
||||
".git", ".svn", ".hg", "node_modules", "__pycache__",
|
||||
@@ -227,28 +286,50 @@ private:
|
||||
return names;
|
||||
}
|
||||
|
||||
std::vector<std::string> list_files_fallback(const std::string & base) const {
|
||||
std::vector<std::string> result;
|
||||
std::vector<list_entry> list_entries_fallback(const std::string & base, int max_depth, list_kind kind,
|
||||
std::chrono::steady_clock::time_point deadline, bool & truncated) const {
|
||||
std::vector<list_entry> result;
|
||||
std::error_code ec;
|
||||
|
||||
std::vector<std::pair<fs::path, fs::path>> stack;
|
||||
stack.emplace_back(fs::path(base), fs::path());
|
||||
std::vector<std::tuple<fs::path, fs::path, int>> stack;
|
||||
stack.emplace_back(fs::path(base), fs::path(), 0);
|
||||
|
||||
while (!stack.empty()) {
|
||||
auto [dir, rel_dir] = stack.back();
|
||||
auto [dir, rel_dir, depth] = stack.back();
|
||||
stack.pop_back();
|
||||
|
||||
for (const auto & entry : fs::directory_iterator(dir, fs::directory_options::skip_permission_denied, ec)) {
|
||||
// the throwing increment would escape the tool on a directory that
|
||||
// goes away mid walk, so step the iterator explicitly
|
||||
fs::directory_iterator it(dir, fs::directory_options::skip_permission_denied, ec);
|
||||
for (const fs::directory_iterator end; it != end; it.increment(ec)) {
|
||||
if (ec) break;
|
||||
if (std::chrono::steady_clock::now() >= deadline) {
|
||||
truncated = true;
|
||||
return result;
|
||||
}
|
||||
const fs::directory_entry & entry = *it;
|
||||
std::string fname = entry.path().filename().string();
|
||||
std::error_code tec;
|
||||
if (entry.is_directory(tec)) {
|
||||
std::string rel = (rel_dir / fname).string();
|
||||
std::replace(rel.begin(), rel.end(), '\\', '/');
|
||||
if (kind == list_kind::dirs || kind == list_kind::all) {
|
||||
result.push_back({rel, true});
|
||||
}
|
||||
// junk directories stay selectable but are never walked: they
|
||||
// hold nothing worth searching and can be enormous
|
||||
if (junk_dir_names().count(fname) > 0) continue;
|
||||
stack.emplace_back(entry.path(), rel_dir / fname);
|
||||
// do not descend into symlinks: a link can point back to an
|
||||
// ancestor and loop forever
|
||||
if (!entry.is_symlink(tec) && (max_depth == 0 || depth + 1 < max_depth)) {
|
||||
stack.emplace_back(entry.path(), rel_dir / fname, depth + 1);
|
||||
}
|
||||
} else if (entry.is_regular_file(tec)) {
|
||||
std::string rel = (rel_dir / fname).string();
|
||||
std::replace(rel.begin(), rel.end(), '\\', '/');
|
||||
result.push_back(rel);
|
||||
if (kind == list_kind::files || kind == list_kind::all) {
|
||||
result.push_back({rel, false});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -363,6 +444,9 @@ struct server_tool_read_file : server_tool {
|
||||
//
|
||||
|
||||
static constexpr size_t SERVER_TOOL_FILE_SEARCH_MAX_RESULTS = 100;
|
||||
static constexpr const char * SERVER_TOOL_FILE_SEARCH_TYPE_FILE = "file";
|
||||
static constexpr const char * SERVER_TOOL_FILE_SEARCH_TYPE_DIR = "dir";
|
||||
static constexpr const char * SERVER_TOOL_FILE_SEARCH_TYPE_ALL = "all";
|
||||
|
||||
struct server_tool_file_glob_search : server_tool {
|
||||
server_tool_file_glob_search() {
|
||||
@@ -382,13 +466,18 @@ struct server_tool_file_glob_search : server_tool {
|
||||
"and common junk directories (.git, node_modules, build, dist, etc.) otherwise. "
|
||||
"A pattern with no '/' (e.g. \"*.cpp\") matches the file's basename at any depth. "
|
||||
"A pattern containing '/' matches the full relative path; unless already anchored with "
|
||||
"\"**/\" or a leading '/', it is automatically prefixed with \"**/\"."},
|
||||
"\"**/\" or a leading '/', it is automatically prefixed with \"**/\". "
|
||||
"Use type=\"dir\" or \"all\" to also list directories; directory entries are suffixed with '/' in the output. "
|
||||
"Note: directory listings do not apply .gitignore filtering."},
|
||||
{"parameters", {
|
||||
{"type", "object"},
|
||||
{"properties", {
|
||||
{"path", {{"type", "string"}, {"description", "Base directory to search in"}}},
|
||||
{"include", {{"type", "string"}, {"description", "Glob pattern for files to include (e.g. \"*.cpp\" or \"src/**/*.cpp\"). Default: **"}}},
|
||||
{"exclude", {{"type", "string"}, {"description", "Glob pattern for files to exclude"}}},
|
||||
{"path", {{"type", "string"}, {"description", "Base directory to search in"}}},
|
||||
{"include", {{"type", "string"}, {"description", "Glob pattern for files to include (e.g. \"*.cpp\" or \"src/**/*.cpp\"). Default: **"}}},
|
||||
{"exclude", {{"type", "string"}, {"description", "Glob pattern for files to exclude"}}},
|
||||
{"type", {{"type", "string"}, {"description", "Entry type to return: \"file\" (default), \"dir\" or \"all\""}}},
|
||||
{"max_depth", {{"type", "integer"}, {"description", "Maximum depth to descend into subdirectories (default: 0 = unlimited; 1 = direct children only)"}}},
|
||||
{"limit", {{"type", "integer"}, {"description", string_format("Maximum number of results to return (default %zu; values below 1 fall back to the default)", SERVER_TOOL_FILE_SEARCH_MAX_RESULTS)}}},
|
||||
}},
|
||||
{"required", json::array({"path"})},
|
||||
}},
|
||||
@@ -397,30 +486,56 @@ struct server_tool_file_glob_search : server_tool {
|
||||
}
|
||||
|
||||
json invoke(json params, server_tool::stream *) const override {
|
||||
std::string base = params.at("path").get<std::string>();
|
||||
std::string include = json_value(params, "include", std::string("**"));
|
||||
std::string exclude = json_value(params, "exclude", std::string(""));
|
||||
|
||||
auto io = make_tools_io(params);
|
||||
|
||||
std::string base = io->resolve(params.at("path").get<std::string>());
|
||||
// normalize to forward slashes so the web UI (which assumes '/') can
|
||||
// join the relative entries into absolute paths on Windows too
|
||||
std::replace(base.begin(), base.end(), '\\', '/');
|
||||
std::string include = json_value(params, "include", std::string("**"));
|
||||
std::string exclude = json_value(params, "exclude", std::string(""));
|
||||
std::string type = json_value(params, "type", std::string("file"));
|
||||
int max_depth = std::max(0, json_value(params, "max_depth", 0));
|
||||
int limit = json_value(params, "limit", (int) SERVER_TOOL_FILE_SEARCH_MAX_RESULTS);
|
||||
if (limit < 1) limit = SERVER_TOOL_FILE_SEARCH_MAX_RESULTS;
|
||||
limit = std::min(limit, (int) SERVER_TOOL_FILE_SEARCH_MAX_RESULTS);
|
||||
|
||||
list_kind kind;
|
||||
if (type == SERVER_TOOL_FILE_SEARCH_TYPE_FILE) {
|
||||
kind = list_kind::files;
|
||||
} else if (type == SERVER_TOOL_FILE_SEARCH_TYPE_DIR) {
|
||||
kind = list_kind::dirs;
|
||||
} else if (type == SERVER_TOOL_FILE_SEARCH_TYPE_ALL) {
|
||||
kind = list_kind::all;
|
||||
} else {
|
||||
return {{"error", "invalid type: " + type + " (expected \"file\", \"dir\" or \"all\")"}};
|
||||
}
|
||||
|
||||
std::string err;
|
||||
auto files = io->list_files(base, err);
|
||||
bool truncated = false;
|
||||
auto entries = io->list_entries(base, params.at("path").get<std::string>(), max_depth, kind, err, truncated);
|
||||
if (!err.empty()) {
|
||||
return {{"error", err}};
|
||||
}
|
||||
|
||||
std::vector<std::string> matches;
|
||||
for (const auto & rel : files) {
|
||||
if (!path_glob_match(include, rel)) continue;
|
||||
if (!exclude.empty() && path_glob_match(exclude, rel)) continue;
|
||||
matches.push_back(rel);
|
||||
std::vector<tools_io::list_entry> matches;
|
||||
for (const auto & entry : entries) {
|
||||
if (!path_glob_match(include, entry.rel)) continue;
|
||||
if (!exclude.empty() && path_glob_match(exclude, entry.rel)) continue;
|
||||
matches.push_back(entry);
|
||||
}
|
||||
|
||||
size_t total = matches.size();
|
||||
size_t shown = std::min(total, SERVER_TOOL_FILE_SEARCH_MAX_RESULTS);
|
||||
size_t shown = std::min(total, (size_t) limit);
|
||||
|
||||
std::ostringstream output_text;
|
||||
json entries_json = json::array();
|
||||
for (size_t i = 0; i < shown; i++) {
|
||||
output_text << matches[i] << "\n";
|
||||
output_text << matches[i].rel << (matches[i].is_dir ? "/" : "") << "\n";
|
||||
entries_json.push_back({
|
||||
{"path", matches[i].rel},
|
||||
{"type", matches[i].is_dir ? "dir" : "file"},
|
||||
});
|
||||
}
|
||||
|
||||
output_text << "\n---\nTotal matches: " << total << "\n";
|
||||
@@ -429,8 +544,16 @@ struct server_tool_file_glob_search : server_tool {
|
||||
"[%zu results limit reached (%zu total matches). Refine the glob pattern to narrow the search.]\n",
|
||||
shown, total);
|
||||
}
|
||||
if (truncated) {
|
||||
output_text << "[search timed out, results truncated]\n";
|
||||
}
|
||||
|
||||
return {{"plain_text_response", output_text.str()}};
|
||||
// `base` is always absolute (resolve falls back to the server cwd), so
|
||||
// API clients (e.g. the web UI picker) can join the relative entries
|
||||
// into absolute paths. `plain_text_response` is what the model sees;
|
||||
// `entries` is the same data as structured JSON for the UI picker,
|
||||
// which reads `entries`/`base` instead of re-parsing the text.
|
||||
return {{"plain_text_response", output_text.str()}, {"entries", entries_json}, {"base", base}};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -513,18 +636,20 @@ struct server_tool_grep_search : server_tool {
|
||||
// collect (absolute_path, display_path) pairs to search
|
||||
std::vector<std::pair<std::string, std::string>> files;
|
||||
|
||||
if (io->is_regular_file(path)) {
|
||||
files.emplace_back(path, path);
|
||||
} else if (io->is_directory(path)) {
|
||||
const std::string abs_path = io->resolve(path);
|
||||
if (io->is_regular_file(abs_path)) {
|
||||
files.emplace_back(abs_path, path);
|
||||
} else if (io->is_directory(abs_path)) {
|
||||
std::string err;
|
||||
auto candidates = io->list_files(path, err);
|
||||
bool truncated = false;
|
||||
auto candidates = io->list_entries(abs_path, path, 0, list_kind::files, err, truncated);
|
||||
if (!err.empty()) {
|
||||
return {{"error", err}};
|
||||
}
|
||||
for (const auto & rel : candidates) {
|
||||
if (!path_glob_match(include, rel)) continue;
|
||||
if (!exclude.empty() && path_glob_match(exclude, rel)) continue;
|
||||
files.emplace_back((fs::path(path) / rel).string(), rel);
|
||||
for (const auto & entry : candidates) {
|
||||
if (!path_glob_match(include, entry.rel)) continue;
|
||||
if (!exclude.empty() && path_glob_match(exclude, entry.rel)) continue;
|
||||
files.emplace_back((fs::path(abs_path) / entry.rel).string(), entry.rel);
|
||||
}
|
||||
} else {
|
||||
return {{"error", "path does not exist: " + path}};
|
||||
@@ -1094,6 +1219,9 @@ struct server_tool_get_datetime : server_tool {
|
||||
// get_info: returns runtime info (OS name/version and cwd)
|
||||
//
|
||||
|
||||
static constexpr size_t SERVER_TOOL_GET_INFO_MAX_OUTPUT = 4096;
|
||||
static constexpr int SERVER_TOOL_GET_INFO_TIMEOUT = 5; // seconds
|
||||
|
||||
struct server_tool_get_info : server_tool {
|
||||
server_tool_get_info() {
|
||||
name = "get_info";
|
||||
@@ -1119,9 +1247,9 @@ struct server_tool_get_info : server_tool {
|
||||
auto io = make_tools_io(params);
|
||||
|
||||
#ifdef _WIN32
|
||||
auto res = io->run({"cmd", "/c", "ver"}, 4096, 5);
|
||||
auto res = io->run({"cmd", "/c", "ver"}, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT);
|
||||
#else
|
||||
auto res = io->run({"uname", "-a"}, 4096, 5);
|
||||
auto res = io->run({"uname", "-a"}, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT);
|
||||
#endif
|
||||
// "ver" prints a blank line before the version, so the output is stripped on both ends;
|
||||
// a failed spawn or a timeout leaves a diagnostic in res.output, which is not an OS name
|
||||
|
||||
Reference in New Issue
Block a user