|
| 1 | +package org.example.vicky.tool.builtin |
| 2 | + |
| 3 | +import kotlinx.coroutines.Dispatchers |
| 4 | +import kotlinx.coroutines.withContext |
| 5 | +import kotlinx.serialization.json.Json |
| 6 | +import kotlinx.serialization.json.JsonArray |
| 7 | +import kotlinx.serialization.json.JsonObject |
| 8 | +import kotlinx.serialization.json.buildJsonArray |
| 9 | +import kotlinx.serialization.json.buildJsonObject |
| 10 | +import kotlinx.serialization.json.int |
| 11 | +import kotlinx.serialization.json.jsonPrimitive |
| 12 | +import kotlinx.serialization.json.put |
| 13 | +import kotlinx.serialization.json.putJsonObject |
| 14 | +import org.example.vicky.tool.Tool |
| 15 | +import org.example.vicky.tool.ToolResult |
| 16 | +import java.net.URI |
| 17 | +import java.net.URLEncoder |
| 18 | +import java.net.http.HttpClient |
| 19 | +import java.net.http.HttpRequest |
| 20 | +import java.net.http.HttpResponse |
| 21 | +import java.util.Base64 |
| 22 | + |
| 23 | +/** |
| 24 | + * 访问 GitHub 仓库:列目录 / 读文件。基于 GitHub Contents API,单端点按 path 自动判断。 |
| 25 | + * |
| 26 | + * - path 为目录 (或留空=根目录) -> 返回该目录的直接子项列表,agent 可逐级深入。 |
| 27 | + * - path 为文件 -> 返回文件内容 (Base64 解码)。 |
| 28 | + * |
| 29 | + * token 非空时带 Authorization 头 (5000 次/小时),否则匿名 (60 次/小时)。 |
| 30 | + */ |
| 31 | +class GithubTool( |
| 32 | + private val token: String? = System.getenv("GITHUB_TOKEN"), |
| 33 | +) : Tool() { |
| 34 | + private val http: HttpClient = HttpClient.newHttpClient() |
| 35 | + private val json = Json { ignoreUnknownKeys = true } |
| 36 | + |
| 37 | + override val name = "github" |
| 38 | + override val description = |
| 39 | + "Browse a GitHub repository. Built-in suggestions: 'ZenthXSin/Vicky', 'Anuken/Mindustry' " + |
| 40 | + "(but any 'owner/repo' works). Pass 'repo' and an optional 'path': if 'path' is a directory " + |
| 41 | + "(or empty = repo root) it returns that directory's immediate entries so you can drill down " + |
| 42 | + "level by level; if 'path' is a file it returns the file content. Optional 'ref' = branch/tag/commit." |
| 43 | + |
| 44 | + override val parameters: JsonObject = buildJsonObject { |
| 45 | + put("type", "object") |
| 46 | + putJsonObject("properties") { |
| 47 | + putJsonObject("repo") { |
| 48 | + put("type", "string") |
| 49 | + put("description", "Repository as 'owner/repo', e.g. 'Anuken/Mindustry'.") |
| 50 | + } |
| 51 | + putJsonObject("path") { |
| 52 | + put("type", "string") |
| 53 | + put("description", "Directory or file path inside the repo. Empty = repo root.") |
| 54 | + } |
| 55 | + putJsonObject("ref") { |
| 56 | + put("type", "string") |
| 57 | + put("description", "Branch/tag/commit. Omit to use the repo default branch.") |
| 58 | + } |
| 59 | + } |
| 60 | + put("required", buildJsonArray { add(kotlinx.serialization.json.JsonPrimitive("repo")) }) |
| 61 | + } |
| 62 | + |
| 63 | + override suspend fun execute(userId: String, args: JsonObject): ToolResult { |
| 64 | + val repo = args["repo"]?.jsonPrimitive?.content?.trim()?.trim('/') |
| 65 | + ?: return ToolResult(toAgent = "Error: missing 'repo'.") |
| 66 | + val parts = repo.split("/") |
| 67 | + if (parts.size != 2 || parts.any { it.isBlank() }) { |
| 68 | + return ToolResult(toAgent = "Error: 'repo' must be 'owner/repo', got '$repo'.") |
| 69 | + } |
| 70 | + val (owner, name) = parts |
| 71 | + val path = args["path"]?.jsonPrimitive?.content?.trim()?.trim('/').orEmpty() |
| 72 | + val ref = args["ref"]?.jsonPrimitive?.content?.trim()?.takeIf { it.isNotEmpty() } |
| 73 | + |
| 74 | + return runCatching { fetch(owner, name, path, ref) } |
| 75 | + .getOrElse { ToolResult(toAgent = "Error accessing GitHub: ${it.message}") } |
| 76 | + } |
| 77 | + |
| 78 | + private suspend fun fetch(owner: String, name: String, path: String, ref: String?): ToolResult { |
| 79 | + val encodedPath = path.split("/").filter { it.isNotEmpty() } |
| 80 | + .joinToString("/") { URLEncoder.encode(it, Charsets.UTF_8).replace("+", "%20") } |
| 81 | + val base = "https://api.github.com/repos/$owner/$name/contents/$encodedPath" |
| 82 | + val url = if (ref != null) "$base?ref=${URLEncoder.encode(ref, Charsets.UTF_8)}" else base |
| 83 | + |
| 84 | + val reqBuilder = HttpRequest.newBuilder(URI.create(url)) |
| 85 | + .header("Accept", "application/vnd.github+json") |
| 86 | + .header("User-Agent", "Vicky") |
| 87 | + .header("X-GitHub-Api-Version", "2022-11-28") |
| 88 | + .GET() |
| 89 | + if (!token.isNullOrBlank()) reqBuilder.header("Authorization", "Bearer $token") |
| 90 | + |
| 91 | + val resp = withContext(Dispatchers.IO) { |
| 92 | + http.send(reqBuilder.build(), HttpResponse.BodyHandlers.ofString()) |
| 93 | + } |
| 94 | + |
| 95 | + if (resp.statusCode() !in 200..299) { |
| 96 | + return ToolResult(toAgent = errorFor(resp, "$owner/$name", path)) |
| 97 | + } |
| 98 | + |
| 99 | + val element = json.parseToJsonElement(resp.body()) |
| 100 | + return when { |
| 101 | + element is JsonArray -> ToolResult(toAgent = formatListing(owner, name, path, element)) |
| 102 | + element is JsonObject -> formatFile(element) |
| 103 | + else -> ToolResult(toAgent = "Error: unexpected GitHub response.") |
| 104 | + } |
| 105 | + } |
| 106 | + |
| 107 | + private fun formatListing(owner: String, name: String, path: String, arr: JsonArray): String { |
| 108 | + val here = if (path.isEmpty()) "(root)" else path |
| 109 | + val entries = arr.mapNotNull { it as? JsonObject } |
| 110 | + .map { o -> |
| 111 | + val type = o["type"]?.jsonPrimitive?.content ?: "?" |
| 112 | + val entryName = o["name"]?.jsonPrimitive?.content ?: "?" |
| 113 | + val size = o["size"]?.jsonPrimitive?.int ?: 0 |
| 114 | + Triple(type, entryName, size) |
| 115 | + } |
| 116 | + .sortedWith(compareBy({ if (it.first == "dir") 0 else 1 }, { it.second.lowercase() })) |
| 117 | + |
| 118 | + if (entries.isEmpty()) return "$owner/$name : $here is empty." |
| 119 | + |
| 120 | + val lines = entries.joinToString("\n") { (type, entryName, size) -> |
| 121 | + if (type == "dir") "[dir] $entryName" else "[file] $entryName ($size B)" |
| 122 | + } |
| 123 | + return "$owner/$name : listing of $here\n$lines" |
| 124 | + } |
| 125 | + |
| 126 | + private fun formatFile(obj: JsonObject): ToolResult { |
| 127 | + val type = obj["type"]?.jsonPrimitive?.content |
| 128 | + if (type != "file") { |
| 129 | + return ToolResult(toAgent = "Error: path is not a file (type=$type).") |
| 130 | + } |
| 131 | + val filePath = obj["path"]?.jsonPrimitive?.content ?: "?" |
| 132 | + val size = obj["size"]?.jsonPrimitive?.int ?: 0 |
| 133 | + val encoding = obj["encoding"]?.jsonPrimitive?.content |
| 134 | + val rawContent = obj["content"]?.jsonPrimitive?.content.orEmpty() |
| 135 | + |
| 136 | + if (encoding != "base64" || rawContent.isBlank()) { |
| 137 | + val download = obj["download_url"]?.jsonPrimitive?.content |
| 138 | + return ToolResult( |
| 139 | + toAgent = "File '$filePath' ($size B) is too large to inline or is binary. " + |
| 140 | + (download?.let { "Download: $it" } ?: "No inline content available."), |
| 141 | + ) |
| 142 | + } |
| 143 | + |
| 144 | + val decoded = runCatching { |
| 145 | + String(Base64.getMimeDecoder().decode(rawContent), Charsets.UTF_8) |
| 146 | + }.getOrElse { |
| 147 | + return ToolResult(toAgent = "Error decoding '$filePath': ${it.message}") |
| 148 | + } |
| 149 | + |
| 150 | + val capped = if (decoded.length > MAX_FILE_CHARS) { |
| 151 | + decoded.take(MAX_FILE_CHARS) + "\n... [truncated, ${decoded.length - MAX_FILE_CHARS} more chars]" |
| 152 | + } else { |
| 153 | + decoded |
| 154 | + } |
| 155 | + return ToolResult(toAgent = "File '$filePath' ($size B):\n$capped") |
| 156 | + } |
| 157 | + |
| 158 | + private fun errorFor(resp: HttpResponse<String>, repo: String, path: String): String = when (resp.statusCode()) { |
| 159 | + 404 -> "Error: '$repo' or path '${path.ifEmpty { "(root)" }}' not found (404)." |
| 160 | + 403 -> "Error: GitHub returned 403 (likely rate limited; set GITHUB_TOKEN to raise the limit)." |
| 161 | + else -> "Error: GitHub returned ${resp.statusCode()}: ${resp.body().take(300)}" |
| 162 | + } |
| 163 | + |
| 164 | + private companion object { |
| 165 | + const val MAX_FILE_CHARS = 20_000 |
| 166 | + } |
| 167 | +} |
0 commit comments