From 9a4e98e6badad63968efe5adc6adc7c25a46e516 Mon Sep 17 00:00:00 2001 From: tiye Date: Thu, 18 Dec 2025 01:41:20 +0800 Subject: [PATCH 01/13] initial prototype --- .gitattributes | 5 + .gitignore | 4 + APIs.md | 49 ++ Agents.md | 263 +++++++++++ package.json | 14 +- src/index.mts | 616 ++++++++++++++++++++++++ src/index.ts | 325 ------------- yarn.lock | 1221 ++++++++++++++++++++---------------------------- 8 files changed, 1462 insertions(+), 1035 deletions(-) create mode 100644 .gitattributes create mode 100644 APIs.md create mode 100644 Agents.md create mode 100644 src/index.mts delete mode 100644 src/index.ts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..aeff4d0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ + +calcit.cirru -diff linguist-generated +yarn.lock -diff linguist-generated +Cargo.lock -diff linguist-generated +lib -diff linguist-generated diff --git a/.gitignore b/.gitignore index 397b0d9..7113ecd 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ dist/ *.log .env .DS_Store + +source/ + +tests/ \ No newline at end of file diff --git a/APIs.md b/APIs.md new file mode 100644 index 0000000..b927ee6 --- /dev/null +++ b/APIs.md @@ -0,0 +1,49 @@ +# Moonverse HTTP API + +A minimal JSON API that replaces the previous MCP server. Supports async query tasks with polling. All responses are JSON. + +## Endpoints + +### Health + +- `GET /healthz` +- **Response**: `{ "ok": true }` + +### Create query task + +- `POST /query` +- **Body**: + +```json +{ "type": "docs" | "source", "query": "" } +``` + +- **Responses**: + - `202 Accepted`: `{ "id": "", "nextPollSec": 2 }` + - `400 Bad Request`: `{ "error": "..." }` + +### Get query task status/result + +- `GET /query/{id}` +- **Responses**: + - In progress: `{ "status": "queued" | "running", "etaSeconds": , "nextPollSec": 2, "message": "Processing in background. Please poll again." }` + - Completed: `{ "status": "done", "content": "" }` + - Error: `{ "status": "error", "error": "" }` + - Not found: `404 { "error": "task not found" }` + +## Behavior + +- Tasks execute asynchronously; clients should poll `GET /query/{id}` every ~2s using `nextPollSec` as a hint. +- Queries run against uploaded MoonBit files: `type=docs` uses `store/` files, `type=source` uses `source/` files. +- Supported file types for upload are `.md`, `.mbt`, `.json`, `.txt`. +- The server streams nothing; results are returned when ready. + +## Environment + +- `GEMINI_API_KEY` (required): Google Generative AI key. +- `PORT` (optional): defaults to 8080. + +## Notes + +- Health endpoint is for readiness checks. +- Ensure `store/` and `source/` folders exist with allowed files; otherwise responses will explain missing files. diff --git a/Agents.md b/Agents.md new file mode 100644 index 0000000..158f37e --- /dev/null +++ b/Agents.md @@ -0,0 +1,263 @@ +# Moonverse.ts 开发指南 + +## 项目概述 + +MoonBit 文档搜索服务,基于 Google Gemini API,提供 HTTP JSON API。 + +- **Docs 查询**:上传 Markdown/txt 文件到 Gemini,使用 AI 回答文档问题 +- **Source 查询**:本地搜索源代码 + LLM 组织结果,生成高质量文档 + +## 常用命令 + +### 开发环境 + +```bash +# 安装依赖 +yarn install + +# 构建项目 +yarn build + +# 开发模式运行(热重载) +yarn dev + +# 生产模式运行 +yarn start +``` + +### 代理配置 + +项目使用 `undici` 的 ProxyAgent 配置代理(默认 `localhost:7890`): + +```bash +# 设置代理环境变量(可选) +export HTTP_PROXY=http://localhost:7890 +export HTTPS_PROXY=http://localhost:7890 + +# 设置 Gemini API Key +export GEMINI_API_KEY=your-api-key-here +``` + +### 测试 + +```bash +# 测试文件上传 +npx tsx test_file_upload.mts + +# 测试客户端(完整流程) +node test_client_simple.mts + +# 单独测试文档查询 +curl -X POST http://localhost:8080/query \ + -H "Content-Type: application/json" \ + -d '{"type": "docs", "query": "How to declare a mutable variable in MoonBit?"}' + +# 单独测试源代码查询 +curl -X POST http://localhost:8080/query \ + -H "Content-Type: application/json" \ + -d '{"type": "source", "query": "mutable variable"}' + +# 轮询任务状态 +curl http://localhost:8080/query/ + +# 健康检查 +curl http://localhost:8080/healthz +``` + +### 服务器管理 + +```bash +# 启动服务器(前台) +npx tsx src/index.mts + +# 启动服务器(后台 + 日志) +npx tsx src/index.mts > /tmp/moonverse.log 2>&1 & + +# 查看日志 +tail -f /tmp/moonverse.log + +# 查看最近日志 +tail -50 /tmp/moonverse.log + +# 停止服务器 +pkill -f "tsx src/index.mts" + +# 查看端口占用 +lsof -i :8080 + +# 杀死占用端口的进程 +lsof -ti :8080 | xargs kill -9 +``` + +### 目录结构 + +``` +moonverse.ts/ +├── src/ +│ └── index.mts # 主服务器代码 +├── store/ # Markdown 文档(会上传到 Gemini) +│ ├── Agents.mbt.md +│ ├── ide.md +│ └── llms.txt +├── source/ # MoonBit 源代码(本地搜索) +│ └── core/ # MoonBit core 库源码 +├── test_file_upload.mts # 文件上传测试 +├── test_client.mts # 客户端测试(原版) +└── test_client_simple.mts # 客户端测试(简化版) +``` + +## API 接口 + +### POST /query + +创建查询任务(异步) + +**请求体:** + +```json +{ + "type": "docs" | "source", + "query": "your question here" +} +``` + +**响应:** + +```json +{ + "id": "uuid", + "status": "queued", + "message": "Query queued" +} +``` + +### GET /query/:id + +轮询任务状态 + +**响应(运行中):** + +```json +{ + "status": "running", + "etaSeconds": 10, + "nextPollSec": 2, + "message": "Processing in background. Please poll again." +} +``` + +**响应(完成):** + +```json +{ + "status": "done", + "content": "AI response here..." +} +``` + +**响应(错误):** + +```json +{ + "status": "error", + "error": "error message" +} +``` + +### GET /healthz + +健康检查 + +**响应:** + +```json +{ + "ok": true +} +``` + +## 技术栈 + +- **运行时**: Node.js 20+ +- **语言**: TypeScript (ES Modules) +- **AI SDK**: `@google/genai` v1.34.0 +- **HTTP 代理**: `undici` ProxyAgent +- **验证**: `zod` + +## 工作流程 + +### Docs 查询流程 + +1. 服务器启动时,自动上传 `store/` 目录的 Markdown 文件到 Gemini Files API +2. 收到 docs 查询请求,创建异步任务 +3. 使用 `ai.models.generateContent` 调用 Gemini API +4. 将上传的文件 URI 和用户问题一起发送 +5. 返回 AI 生成的答案 + +### Source 查询流程 + +1. 收到 source 查询请求,创建异步任务 +2. 使用 `searchLocalCode()` 在 `source/core/` 目录搜索 +3. 递归遍历 `.mbt` 文件 +4. 使用简单的文本匹配查找包含查询关键词的行 +5. 返回匹配的文件路径和代码行 + +## 注意事项 + +1. **代理配置**:所有网络请求(包括文件上传到 Google Storage)都通过 `localhost:7890` 代理 +2. **文件上传**:只上传 Markdown 文件(`.md`, `.txt`),源代码不上传 +3. **异步处理**:查询采用任务队列模式,客户端需要轮询结果 +4. **错误处理**:所有错误会记录到任务状态中,客户端通过轮询获取 +5. **日志**:使用 ISO 时间戳的详细日志,便于调试 + +## 故障排查 + +### 文件上传失败 + +```bash +# 检查代理是否运行 +curl -x http://localhost:7890 https://www.google.com + +# 测试文件上传 +npx tsx test_file_upload.mts +``` + +### API 调用失败 + +```bash +# 检查 API Key +echo $GEMINI_API_KEY + +# 测试代理配置的 API 访问 +curl -x http://localhost:7890 \ + "https://generativelanguage.googleapis.com/v1beta/models?key=$GEMINI_API_KEY" +``` + +### 服务器无响应 + +```bash +# 检查进程 +ps aux | grep tsx + +# 检查端口 +lsof -i :8080 + +# 重启服务器 +pkill -f "tsx src/index.mts" +npx tsx src/index.mts > /tmp/moonverse.log 2>&1 & +``` + +## 性能优化建议 + +1. **文件缓存**:已上传的文件会缓存,避免重复上传 +2. **本地搜索**:源代码查询使用本地搜索,速度快 +3. **异步处理**:避免阻塞 HTTP 请求 +4. **日志轮转**:定期清理日志文件 + +## 未来改进 + +- [ ] 添加 Redis 缓存查询结果 +- [ ] 支持更多文件类型(PDF、DOCX) +- [ ] 实现更智能的代码搜索(AST 解析) +- [ ] 添加 WebSocket 支持实时返回结果 +- [ ] 集成向量数据库进行语义搜索 diff --git a/package.json b/package.json index 13955f6..091a9fd 100644 --- a/package.json +++ b/package.json @@ -10,16 +10,22 @@ "scripts": { "build": "tsc", "start": "node dist/index.js", - "dev": "tsx src/index.ts", + "dev": "tsx src/index.mts", "prepublishOnly": "yarn build", "test": "echo \"Error: no test specified\" && exit 1" }, - "keywords": ["mcp", "moonbit", "gemini", "ai", "documentation"], + "keywords": [ + "moonbit", + "gemini", + "ai", + "documentation" + ], "author": "", "license": "ISC", "dependencies": { - "@google/generative-ai": "^0.24.1", - "@modelcontextprotocol/sdk": "^1.21.1", + "@google/genai": "^1.34.0", + "https-proxy-agent": "^7.0.6", + "undici": "^7.16.0", "zod": "^3.25.76" }, "devDependencies": { diff --git a/src/index.mts b/src/index.mts new file mode 100644 index 0000000..36a40bb --- /dev/null +++ b/src/index.mts @@ -0,0 +1,616 @@ +import http from "http"; +import { randomUUID } from "crypto"; +import { GoogleGenAI, createPartFromUri } from "@google/genai"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { z } from "zod"; +import { ProxyAgent, setGlobalDispatcher } from "undici"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// ---------- Types ---------- +interface FileInfo { + uri: string; + mimeType: string; + name: string; +} + +type QueryKind = "docs" | "source"; + +type TaskStatus = "queued" | "running" | "done" | "error"; + +interface Task { + id: string; + kind: QueryKind; + query: string; + status: TaskStatus; + createdAt: number; + updatedAt: number; + etaSeconds: number; + result?: string; + error?: string; +} + +// ---------- Validation ---------- +const CreateQuerySchema = z.object({ + type: z.enum(["docs", "source"]), + query: z.string().min(1), +}); + +// ---------- Globals ---------- +const apiKey = process.env.GEMINI_API_KEY || ""; +if (!apiKey) { + console.error("Error: GEMINI_API_KEY environment variable is not set"); + process.exit(1); +} + +// Configure proxy using undici +const proxyUrl = + process.env.HTTP_PROXY || process.env.HTTPS_PROXY || "http://localhost:7890"; +const proxyAgent = new ProxyAgent(proxyUrl); +setGlobalDispatcher(proxyAgent); + +console.log(`[Proxy] Using proxy: ${proxyUrl}`); + +// Since we're using localhost:7890 proxy, no need for baseUrl override +// const httpOptions = { +// }; + +const ai = new GoogleGenAI({ + apiKey, + // httpOptions, +}); + +// Check connection on startup +(async () => { + console.log(`[${new Date().toISOString()}] Checking AI connection...`); + try { + const models = await ai.models.list(); + console.log( + `[${new Date().toISOString()}] AI Connection successful. Models available (first page): ${ + models.page?.length || 0 + }` + ); + + // Try a simple interaction to verify interactions API specifically + console.log(`[${new Date().toISOString()}] Testing Interactions API...`); + const response = await ai.interactions.create({ + model: "gemini-2.5-flash", + input: [{ type: "text", text: "Hello" }], + }); + console.log( + `[${new Date().toISOString()}] Interactions API test successful.` + ); + } catch (error) { + console.error(`[${new Date().toISOString()}] AI Connection failed:`, error); + } +})(); + +const uploadedStoreFiles: FileInfo[] = []; +let storeInitPromise: Promise | null = null; + +const tasks = new Map(); + +// ---------- Helpers ---------- +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function getMimeType(ext: string): string { + switch (ext.toLowerCase()) { + case ".md": + return "text/markdown"; + case ".json": + return "application/json"; + case ".mbt": + return "text/plain"; + default: + return "text/plain"; + } +} + +function getAllFiles(dirPath: string, acc: string[] = []): string[] { + const files = fs.readdirSync(dirPath); + for (const file of files) { + const filePath = path.join(dirPath, file); + const stat = fs.statSync(filePath); + if (stat.isDirectory()) { + getAllFiles(filePath, acc); + } else { + acc.push(filePath); + } + } + return acc; +} + +async function initializeFiles(dirName: string, fileList: FileInfo[]) { + console.log( + `[${new Date().toISOString()}] Initializing files from ${dirName}...` + ); + const dir = path.join(path.dirname(__dirname), dirName); + if (!fs.existsSync(dir)) { + console.error( + `[${new Date().toISOString()}] ${dirName} directory not found. Skipping...` + ); + return; + } + + if (fileList.length > 0) { + console.log( + `[${new Date().toISOString()}] Files already initialized for ${dirName}. Count: ${ + fileList.length + }` + ); + return; + } + + const files = getAllFiles(dir); + // Upload Markdown and text files to Gemini + const validFiles = files.filter((file) => + [".md", ".txt"].includes(path.extname(file).toLowerCase()) + ); + if (validFiles.length === 0) { + console.error( + `[${new Date().toISOString()}] No valid files found in ${dirName} directory` + ); + return; + } + + console.log( + `[${new Date().toISOString()}] Found ${ + validFiles.length + } files to upload from ${dirName}` + ); + + for (const filePath of validFiles) { + const relativePath = path.relative(dir, filePath); + console.log(`[${new Date().toISOString()}] Uploading ${relativePath}...`); + + const fileContent = fs.readFileSync(filePath); + const ext = path.extname(filePath).toLowerCase(); + const mimeType = getMimeType(ext); + const blob = new Blob([fileContent], { type: mimeType }); + + try { + console.log( + `[${new Date().toISOString()}] Starting upload for ${relativePath}...` + ); + const uploadedFile = await ai.files.upload({ + file: blob, + config: { + mimeType, + displayName: relativePath, + }, + }); + console.log( + `[${new Date().toISOString()}] Upload request completed for ${relativePath}. Name: ${ + uploadedFile.name + }` + ); + + let processed = await ai.files.get({ + name: uploadedFile.name as string, + }); + let retries = 0; + const maxRetries = 60; + while (processed.state === "PROCESSING" && retries < maxRetries) { + await sleep(5000); + processed = await ai.files.get({ + name: uploadedFile.name as string, + }); + retries++; + } + + if (processed.state === "FAILED") { + console.error( + `[${new Date().toISOString()}] Failed to process file: ${relativePath}` + ); + continue; + } + + fileList.push({ + uri: processed.uri as string, + mimeType: processed.mimeType as string, + name: processed.name as string, + }); + console.log( + `[${new Date().toISOString()}] Uploaded ${relativePath} - URI: ${ + processed.uri + }` + ); + } catch (error) { + console.error( + `[${new Date().toISOString()}] Error uploading ${relativePath}:`, + error + ); + } + } + + console.log( + `[${new Date().toISOString()}] Initialized ${ + fileList.length + } files from ${dirName}` + ); +} + +// Search in a specific directory with file extensions +function searchInDirectory( + baseDir: string, + extensions: string[], + queryTerms: string[], + maxResults: number = 15 +): { relativePath: string; matchingLines: string[]; fullContent?: string }[] { + if (!fs.existsSync(baseDir)) { + return []; + } + + const results: { + relativePath: string; + matchingLines: string[]; + fullContent?: string; + }[] = []; + const allFiles = getAllFiles(baseDir); + const targetFiles = allFiles.filter((file) => + extensions.includes(path.extname(file).toLowerCase()) + ); + + for (const filePath of targetFiles) { + try { + const content = fs.readFileSync(filePath, "utf-8"); + const contentLower = content.toLowerCase(); + + // Check if any query term matches + const hasMatch = queryTerms.some((term) => contentLower.includes(term)); + if (hasMatch) { + const relativePath = path.relative(baseDir, filePath); + const lines = content.split("\n"); + + // Find matching lines with context + const matchingLines: string[] = []; + lines.forEach((line, idx) => { + const lineLower = line.toLowerCase(); + if (queryTerms.some((term) => lineLower.includes(term))) { + // Include surrounding context (2 lines before and after) + const start = Math.max(0, idx - 2); + const end = Math.min(lines.length, idx + 3); + for (let i = start; i < end; i++) { + const prefix = i === idx ? ">>> " : " "; + matchingLines.push(`${prefix}Line ${i + 1}: ${lines[i]}`); + } + matchingLines.push(""); // separator + } + }); + + if (matchingLines.length > 0) { + results.push({ + relativePath, + matchingLines: matchingLines.slice(0, 30), + fullContent: content.length < 5000 ? content : undefined, + }); + } + + if (results.length >= maxResults) break; + } + } catch (error) { + // Skip files that can't be read + } + } + + return results; +} + +async function searchLocalCode(query: string): Promise { + console.log( + `[${new Date().toISOString()}] Searching local code for: "${query}"` + ); + const sourceDir = path.join(path.dirname(__dirname), "source"); + + if (!fs.existsSync(sourceDir)) { + return "Source directory not found."; + } + + const queryTerms = query + .toLowerCase() + .split(/\s+/) + .filter((t) => t.length > 2); + + if (queryTerms.length === 0) { + return "Query too short. Please provide more specific search terms."; + } + + // Step 1: Search in genai-sdk-samples for TypeScript examples + console.log(`[${new Date().toISOString()}] Searching SDK samples...`); + const sdkSamplesDir = path.join(sourceDir, "genai-sdk-samples"); + const sdkResults = searchInDirectory(sdkSamplesDir, [".ts"], queryTerms, 10); + + // Step 2: Search in core for MoonBit source code + console.log(`[${new Date().toISOString()}] Searching MoonBit core...`); + const coreDir = path.join(sourceDir, "core"); + const coreResults = searchInDirectory(coreDir, [".mbt"], queryTerms, 10); + + // Step 3: Search README and documentation files + console.log(`[${new Date().toISOString()}] Searching documentation...`); + const docResults = searchInDirectory( + sourceDir, + [".md", ".json"], + queryTerms, + 5 + ); + + // Combine raw results + let rawResults = ""; + + if (sdkResults.length > 0) { + rawResults += "\n## SDK Samples (TypeScript)\n"; + for (const r of sdkResults) { + rawResults += `\n### ${ + r.relativePath + }\n\`\`\`typescript\n${r.matchingLines.join("\n")}\n\`\`\`\n`; + } + } + + if (coreResults.length > 0) { + rawResults += "\n## MoonBit Core Source Code\n"; + for (const r of coreResults) { + rawResults += `\n### ${ + r.relativePath + }\n\`\`\`moonbit\n${r.matchingLines.join("\n")}\n\`\`\`\n`; + } + } + + if (docResults.length > 0) { + rawResults += "\n## Documentation\n"; + for (const r of docResults) { + rawResults += `\n### ${r.relativePath}\n${r.matchingLines + .slice(0, 15) + .join("\n")}\n`; + } + } + + if (!rawResults) { + return `No matching code found for query: "${query}". Try different search terms.`; + } + + // Step 4: Use LLM to organize and verify the results + console.log( + `[${new Date().toISOString()}] Using LLM to organize search results...` + ); + + const prompt = `You are a documentation assistant for MoonBit programming language. + +The user asked: "${query}" + +Here are the raw search results from the codebase: +${rawResults} + +Please analyze these search results and provide a well-organized response that: +1. Directly answers the user's question based on the code found +2. Includes relevant code examples with proper syntax highlighting +3. Explains how the code works if helpful +4. If the search results don't fully answer the question, say so and suggest what else the user might look for + +Format your response as clean documentation that could be used as a reference.`; + + try { + const response = await ai.models.generateContent({ + model: "gemini-2.5-flash", + contents: prompt, + }); + + const result = response.text; + if (result) { + console.log(`[${new Date().toISOString()}] LLM processing complete.`); + return result; + } + } catch (error) { + console.error( + `[${new Date().toISOString()}] LLM processing failed:`, + error + ); + } + + // Fallback to raw results if LLM fails + return `Raw search results for "${query}":\n${rawResults}`; +} + +async function runQuery(kind: QueryKind, query: string): Promise { + console.log( + `[${new Date().toISOString()}] Starting query processing. Kind: ${kind}, Query: "${query}"` + ); + + // For source code queries, use local search + if (kind === "source") { + return await searchLocalCode(query); + } + + // For docs queries, use Gemini with uploaded Markdown files + const files = uploadedStoreFiles; + if (files.length === 0) { + console.warn( + `[${new Date().toISOString()}] No files found for kind: ${kind}` + ); + return "No documentation files available. Please add Markdown files to the 'store' directory."; + } + + console.log( + `[${new Date().toISOString()}] Preparing content for Gemini. File count: ${ + files.length + }` + ); + + // Build parts array for generateContent + const parts: any[] = []; + for (const file of files) { + parts.push(createPartFromUri(file.uri, file.mimeType)); + } + + const promptText = `Based on the MoonBit documentation files provided, please answer the following question:\n\n${query}\n\nPlease provide a detailed and accurate answer based only on the information in the documentation.`; + + parts.push({ text: promptText }); + + console.log(`[${new Date().toISOString()}] Sending request to Gemini...`); + const response = await ai.models.generateContent({ + model: "gemini-2.5-flash", + contents: parts, + }); + console.log(`[${new Date().toISOString()}] Received response from Gemini.`); + + return response.text || "No response generated"; +} + +function respondJson(res: http.ServerResponse, status: number, body: unknown) { + res.statusCode = status; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(body)); +} + +async function handleCreateQuery( + req: http.IncomingMessage, + res: http.ServerResponse +) { + let raw = ""; + req.on("data", (chunk) => (raw += chunk)); + req.on("end", () => { + try { + const parsed = raw ? JSON.parse(raw) : {}; + const data = CreateQuerySchema.parse(parsed); + + const id = randomUUID(); + const now = Date.now(); + const task: Task = { + id, + kind: data.type, + query: data.query, + status: "queued", + createdAt: now, + updatedAt: now, + etaSeconds: 3, + }; + + tasks.set(id, task); + console.log( + `[${new Date().toISOString()}] Task created. ID: ${id}, Kind: ${ + data.type + }` + ); + processTask(task).catch((err) => { + console.error( + `[${new Date().toISOString()}] Task processing failed. ID: ${id}, Error:`, + err + ); + const t = tasks.get(id); + if (t) { + t.status = "error"; + t.error = err instanceof Error ? err.message : String(err); + t.updatedAt = Date.now(); + } + }); + + respondJson(res, 202, { id, nextPollSec: 2 }); + } catch (error) { + console.error( + `[${new Date().toISOString()}] Error handling create query:`, + error + ); + respondJson(res, 400, { + error: error instanceof Error ? error.message : String(error), + }); + } + }); +} + +async function handleGetQuery( + req: http.IncomingMessage, + res: http.ServerResponse, + id: string +) { + console.log(`[${new Date().toISOString()}] Handling get query. ID: ${id}`); + const task = tasks.get(id); + if (!task) { + console.warn(`[${new Date().toISOString()}] Task not found. ID: ${id}`); + respondJson(res, 404, { error: "task not found" }); + return; + } + + console.log( + `[${new Date().toISOString()}] Task status. ID: ${id}, Status: ${ + task.status + }` + ); + if (task.status === "done") { + respondJson(res, 200, { status: "done", content: task.result }); + } else if (task.status === "error") { + respondJson(res, 200, { status: "error", error: task.error }); + } else { + respondJson(res, 200, { + status: task.status, + etaSeconds: task.etaSeconds, + nextPollSec: 2, + message: "Processing in background. Please poll again.", + debug: { + createdAt: new Date(task.createdAt).toISOString(), + updatedAt: new Date(task.updatedAt).toISOString(), + elapsedSeconds: (Date.now() - task.createdAt) / 1000, + }, + }); + } +} + +async function processTask(task: Task) { + console.log( + `[${new Date().toISOString()}] Processing task. ID: ${task.id}, Kind: ${ + task.kind + }` + ); + task.status = "running"; + task.updatedAt = Date.now(); + try { + const result = await runQuery(task.kind, task.query); + task.result = result; + task.status = "done"; + task.updatedAt = Date.now(); + console.log(`[${new Date().toISOString()}] Task completed. ID: ${task.id}`); + } catch (error) { + console.error( + `[${new Date().toISOString()}] Task failed. ID: ${task.id}, Error:`, + error + ); + task.status = "error"; + task.error = error instanceof Error ? error.message : String(error); + task.updatedAt = Date.now(); + } +} + +// ---------- HTTP Server ---------- +const server = http.createServer((req, res) => { + const url = req.url || "/"; + const method = req.method || "GET"; + + if (method === "GET" && url === "/healthz") { + respondJson(res, 200, { ok: true }); + return; + } + + if (method === "POST" && url === "/query") { + handleCreateQuery(req, res); + return; + } + + if (method === "GET" && url.startsWith("/query/")) { + const id = url.split("/query/")[1]; + handleGetQuery(req, res, id); + return; + } + + respondJson(res, 404, { error: "not found" }); +}); + +const port = Number(process.env.PORT || 8080); +server.listen(port, () => { + console.error(`Moonverse HTTP server listening on port ${port}`); +}); + +// Start uploading store files immediately on server startup +initializeFiles("store", uploadedStoreFiles).catch((error) => { + console.error("[Startup] Failed to initialize store files:", error); +}); diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index d43e236..0000000 --- a/src/index.ts +++ /dev/null @@ -1,325 +0,0 @@ -#!/usr/bin/env node - -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { - CallToolRequestSchema, - ListToolsRequestSchema, -} from "@modelcontextprotocol/sdk/types.js"; -import { GoogleGenerativeAI } from "@google/generative-ai"; -import { GoogleAIFileManager, FileMetadataResponse } from "@google/generative-ai/server"; -import fs from "fs"; -import path from "path"; -import { fileURLToPath } from "url"; -import { z } from "zod"; - -// ES module compatibility -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// Schema for documentation query -const DocQuerySchema = z.object({ - query: z.string().describe("The question to ask about the MoonBit documentation"), -}); - -// Schema for source code query -const SourceQuerySchema = z.object({ - query: z.string().describe("The question to ask about MoonBit source code, API, or latest features"), -}); - -class MoonverseMCPServer { - private server: Server; - private genAI: GoogleGenerativeAI; - private fileManager: GoogleAIFileManager; - private uploadedStoreFiles: FileMetadataResponse[] = []; - private uploadedSourceFiles: FileMetadataResponse[] = []; - private apiKey: string; - - constructor() { - // Get API key from environment variable - this.apiKey = process.env.GEMINI_API_KEY || ""; - if (!this.apiKey) { - console.error("Error: GEMINI_API_KEY environment variable is not set"); - process.exit(1); - } - - this.genAI = new GoogleGenerativeAI(this.apiKey); - this.fileManager = new GoogleAIFileManager(this.apiKey); - - this.server = new Server( - { - name: "moonverse-mcp-server", - version: "2.0.0", - }, - { - capabilities: { - tools: {}, - }, - } - ); - - this.setupHandlers(); - } - - private setupHandlers() { - // List available tools - this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: "query_moonbit_docs", - description: "Query the MoonBit documentation using AI-powered semantic search. Ask questions about MoonBit language features, syntax, best practices, and more from the official documentation.", - inputSchema: { - type: "object", - properties: { - query: { - type: "string", - description: "The question to ask about the MoonBit documentation", - }, - }, - required: ["query"], - }, - }, - { - name: "query_moonbit_source", - description: "Query the MoonBit core source code repository to understand latest API features, implementations, and pre-release functionality. Use this for questions about source code, internal implementations, or bleeding-edge features.", - inputSchema: { - type: "object", - properties: { - query: { - type: "string", - description: "The question to ask about MoonBit source code or latest features", - }, - }, - required: ["query"], - }, - }, - ], - })); - - // Handle tool calls - this.server.setRequestHandler(CallToolRequestSchema, async (request) => { - if (request.params.name === "query_moonbit_docs") { - const args = DocQuerySchema.parse(request.params.arguments); - return await this.handleDocQuery(args.query); - } else if (request.params.name === "query_moonbit_source") { - const args = SourceQuerySchema.parse(request.params.arguments); - return await this.handleSourceQuery(args.query); - } - - throw new Error(`Unknown tool: ${request.params.name}`); - }); - } - - private async initializeFiles(dirName: string, fileList: FileMetadataResponse[]) { - console.error(`Initializing files from ${dirName}...`); - - // Use the directory where the package is installed - const dir = path.join(path.dirname(__dirname), dirName); - - if (!fs.existsSync(dir)) { - console.error(`${dirName} directory not found. Skipping...`); - return; - } - - // Read all files from directory (including subdirectories) - const files = this.getAllFiles(dir); - const validFiles = files.filter(file => { - const ext = path.extname(file).toLowerCase(); - return [".txt", ".md", ".mbt", ".json"].includes(ext); - }); - - if (validFiles.length === 0) { - console.error(`No valid files found in ${dirName} directory`); - return; - } - - console.error(`Found ${validFiles.length} files to upload from ${dirName}`); - - // Upload files to Gemini using Interactions API - for (const filePath of validFiles) { - const relativePath = path.relative(dir, filePath); - console.error(`Uploading ${relativePath}...`); - - const fileContent = fs.readFileSync(filePath); - const ext = path.extname(filePath).toLowerCase(); - let mimeType = "text/plain"; - if (ext === ".md") mimeType = "text/markdown"; - else if (ext === ".json") mimeType = "application/json"; - else if (ext === ".mbt") mimeType = "text/plain"; - - const uploadResult = await this.fileManager.uploadFile(fileContent, { - mimeType: mimeType, - displayName: relativePath, - }); - - fileList.push(uploadResult.file); - console.error(`Uploaded ${relativePath} - URI: ${uploadResult.file.uri}`); - } - - console.error(`Initialized ${fileList.length} files from ${dirName}`); - } - - private getAllFiles(dirPath: string, arrayOfFiles: string[] = []): string[] { - const files = fs.readdirSync(dirPath); - - files.forEach((file) => { - const filePath = path.join(dirPath, file); - if (fs.statSync(filePath).isDirectory()) { - arrayOfFiles = this.getAllFiles(filePath, arrayOfFiles); - } else { - arrayOfFiles.push(filePath); - } - }); - - return arrayOfFiles; - } - - private async handleDocQuery(query: string) { - try { - // Initialize store files if not already done - if (this.uploadedStoreFiles.length === 0) { - await this.initializeFiles("store", this.uploadedStoreFiles); - } - - if (this.uploadedStoreFiles.length === 0) { - return { - content: [ - { - type: "text", - text: "No documentation files available. Please add documentation files to the 'store' directory.", - }, - ], - isError: true, - }; - } - - console.error(`Processing documentation query: ${query}`); - - // Use Gemini Interactions API with file context - const model = this.genAI.getGenerativeModel({ - model: "gemini-1.5-flash", - }); - - // Create prompt with file context - const prompt = `Based on the MoonBit documentation files provided, please answer the following question: - -${query} - -Please provide a detailed and accurate answer based only on the information in the documentation.`; - - const result = await model.generateContent([ - ...this.uploadedStoreFiles.map(file => ({ - fileData: { - mimeType: file.mimeType, - fileUri: file.uri, - }, - })), - { text: prompt }, - ]); - - const response = result.response; - const text = response.text(); - - return { - content: [ - { - type: "text", - text: text, - }, - ], - }; - } catch (error) { - console.error("Error processing documentation query:", error); - return { - content: [ - { - type: "text", - text: `Error: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - isError: true, - }; - } - } - - private async handleSourceQuery(query: string) { - try { - // Initialize source files if not already done - if (this.uploadedSourceFiles.length === 0) { - await this.initializeFiles("source", this.uploadedSourceFiles); - } - - if (this.uploadedSourceFiles.length === 0) { - return { - content: [ - { - type: "text", - text: "No source code files available. Please add the MoonBit core repository to the 'source' directory.", - }, - ], - isError: true, - }; - } - - console.error(`Processing source code query: ${query}`); - - // Use Gemini Interactions API with source code context - const model = this.genAI.getGenerativeModel({ - model: "gemini-1.5-flash", - }); - - // Create prompt focused on source code and implementation - const prompt = `Based on the MoonBit source code repository provided, please answer the following question about the implementation, API, or latest features: - -${query} - -Please provide a detailed answer based on the source code. Include relevant code references, API details, and implementation insights where applicable.`; - - const result = await model.generateContent([ - ...this.uploadedSourceFiles.map(file => ({ - fileData: { - mimeType: file.mimeType, - fileUri: file.uri, - }, - })), - { text: prompt }, - ]); - - const response = result.response; - const text = response.text(); - - return { - content: [ - { - type: "text", - text: text, - }, - ], - }; - } catch (error) { - console.error("Error processing source query:", error); - return { - content: [ - { - type: "text", - text: `Error: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - isError: true, - }; - } - } - - async run() { - const transport = new StdioServerTransport(); - await this.server.connect(transport); - console.error("Moonverse MCP Server running on stdio"); - } -} - -// Main execution -const server = new MoonverseMCPServer(); -server.run().catch((error) => { - console.error("Server error:", error); - process.exit(1); -}); diff --git a/yarn.lock b/yarn.lock index 9ac94c4..e911f92 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,259 +2,235 @@ # yarn lockfile v1 -"@esbuild/aix-ppc64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz#80fcbe36130e58b7670511e888b8e88a259ed76c" - integrity sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA== - -"@esbuild/android-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz#8aa4965f8d0a7982dc21734bf6601323a66da752" - integrity sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg== - -"@esbuild/android-arm@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz#300712101f7f50f1d2627a162e6e09b109b6767a" - integrity sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg== - -"@esbuild/android-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz#87dfb27161202bdc958ef48bb61b09c758faee16" - integrity sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg== - -"@esbuild/darwin-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz#79197898ec1ff745d21c071e1c7cc3c802f0c1fd" - integrity sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg== - -"@esbuild/darwin-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz#146400a8562133f45c4d2eadcf37ddd09718079e" - integrity sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA== - -"@esbuild/freebsd-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz#1c5f9ba7206e158fd2b24c59fa2d2c8bb47ca0fe" - integrity sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg== - -"@esbuild/freebsd-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz#ea631f4a36beaac4b9279fa0fcc6ca29eaeeb2b3" - integrity sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ== - -"@esbuild/linux-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz#e1066bce58394f1b1141deec8557a5f0a22f5977" - integrity sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ== - -"@esbuild/linux-arm@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz#452cd66b20932d08bdc53a8b61c0e30baf4348b9" - integrity sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw== - -"@esbuild/linux-ia32@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz#b24f8acc45bcf54192c7f2f3be1b53e6551eafe0" - integrity sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA== - -"@esbuild/linux-loong64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz#f9cfffa7fc8322571fbc4c8b3268caf15bd81ad0" - integrity sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng== - -"@esbuild/linux-mips64el@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz#575a14bd74644ffab891adc7d7e60d275296f2cd" - integrity sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw== - -"@esbuild/linux-ppc64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz#75b99c70a95fbd5f7739d7692befe60601591869" - integrity sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA== - -"@esbuild/linux-riscv64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz#2e3259440321a44e79ddf7535c325057da875cd6" - integrity sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w== - -"@esbuild/linux-s390x@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz#17676cabbfe5928da5b2a0d6df5d58cd08db2663" - integrity sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg== - -"@esbuild/linux-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz#0583775685ca82066d04c3507f09524d3cd7a306" - integrity sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw== - -"@esbuild/netbsd-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz#f04c4049cb2e252fe96b16fed90f70746b13f4a4" - integrity sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg== - -"@esbuild/netbsd-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz#77da0d0a0d826d7c921eea3d40292548b258a076" - integrity sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ== - -"@esbuild/openbsd-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz#6296f5867aedef28a81b22ab2009c786a952dccd" - integrity sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A== - -"@esbuild/openbsd-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz#f8d23303360e27b16cf065b23bbff43c14142679" - integrity sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw== - -"@esbuild/openharmony-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz#49e0b768744a3924be0d7fd97dd6ce9b2923d88d" - integrity sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg== - -"@esbuild/sunos-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz#a6ed7d6778d67e528c81fb165b23f4911b9b13d6" - integrity sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w== - -"@esbuild/win32-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz#9ac14c378e1b653af17d08e7d3ce34caef587323" - integrity sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg== - -"@esbuild/win32-ia32@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz#918942dcbbb35cc14fca39afb91b5e6a3d127267" - integrity sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ== - -"@esbuild/win32-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz#9bdad8176be7811ad148d1f8772359041f46c6c5" - integrity sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA== - -"@google/generative-ai@^0.24.1": - version "0.24.1" - resolved "https://registry.yarnpkg.com/@google/generative-ai/-/generative-ai-0.24.1.tgz#634a3c06f8ea7a6125c1b0d6c1e66bb11afb52c9" - integrity sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q== - -"@modelcontextprotocol/sdk@^1.21.1": - version "1.21.1" - resolved "https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-1.21.1.tgz#8fba02e7581d49cc9b047aab0cfd334043321fe5" - integrity sha512-UyLFcJLDvUuZbGnaQqXFT32CpPpGj7VS19roLut6gkQVhb439xUzYWbsUvdI3ZPL+2hnFosuugtYWE0Mcs1rmQ== - dependencies: - ajv "^8.17.1" - ajv-formats "^3.0.1" - content-type "^1.0.5" - cors "^2.8.5" - cross-spawn "^7.0.5" - eventsource "^3.0.2" - eventsource-parser "^3.0.0" - express "^5.0.1" - express-rate-limit "^7.5.0" - pkce-challenge "^5.0.0" - raw-body "^3.0.0" - zod "^3.23.8" - zod-to-json-schema "^3.24.1" +"@esbuild/aix-ppc64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz#521cbd968dcf362094034947f76fa1b18d2d403c" + integrity sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw== + +"@esbuild/android-arm64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz#61ea550962d8aa12a9b33194394e007657a6df57" + integrity sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA== + +"@esbuild/android-arm@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.2.tgz#554887821e009dd6d853f972fde6c5143f1de142" + integrity sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA== + +"@esbuild/android-x64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.2.tgz#a7ce9d0721825fc578f9292a76d9e53334480ba2" + integrity sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A== + +"@esbuild/darwin-arm64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz#2cb7659bd5d109803c593cfc414450d5430c8256" + integrity sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg== + +"@esbuild/darwin-x64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz#e741fa6b1abb0cd0364126ba34ca17fd5e7bf509" + integrity sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA== + +"@esbuild/freebsd-arm64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz#2b64e7116865ca172d4ce034114c21f3c93e397c" + integrity sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g== + +"@esbuild/freebsd-x64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz#e5252551e66f499e4934efb611812f3820e990bb" + integrity sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA== + +"@esbuild/linux-arm64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz#dc4acf235531cd6984f5d6c3b13dbfb7ddb303cb" + integrity sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw== + +"@esbuild/linux-arm@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz#56a900e39240d7d5d1d273bc053daa295c92e322" + integrity sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw== + +"@esbuild/linux-ia32@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz#d4a36d473360f6870efcd19d52bbfff59a2ed1cc" + integrity sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w== + +"@esbuild/linux-loong64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz#fcf0ab8c3eaaf45891d0195d4961cb18b579716a" + integrity sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg== + +"@esbuild/linux-mips64el@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz#598b67d34048bb7ee1901cb12e2a0a434c381c10" + integrity sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw== + +"@esbuild/linux-ppc64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz#3846c5df6b2016dab9bc95dde26c40f11e43b4c0" + integrity sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ== + +"@esbuild/linux-riscv64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz#173d4475b37c8d2c3e1707e068c174bb3f53d07d" + integrity sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA== + +"@esbuild/linux-s390x@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz#f7a4790105edcab8a5a31df26fbfac1aa3dacfab" + integrity sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w== + +"@esbuild/linux-x64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz#2ecc1284b1904aeb41e54c9ddc7fcd349b18f650" + integrity sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA== + +"@esbuild/netbsd-arm64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz#e2863c2cd1501845995cb11adf26f7fe4be527b0" + integrity sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw== + +"@esbuild/netbsd-x64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz#93f7609e2885d1c0b5a1417885fba8d1fcc41272" + integrity sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA== + +"@esbuild/openbsd-arm64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz#a1985604a203cdc325fd47542e106fafd698f02e" + integrity sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA== + +"@esbuild/openbsd-x64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz#8209e46c42f1ffbe6e4ef77a32e1f47d404ad42a" + integrity sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg== + +"@esbuild/openharmony-arm64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz#8fade4441893d9cc44cbd7dcf3776f508ab6fb2f" + integrity sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag== + +"@esbuild/sunos-x64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz#980d4b9703a16f0f07016632424fc6d9a789dfc2" + integrity sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg== + +"@esbuild/win32-arm64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz#1c09a3633c949ead3d808ba37276883e71f6111a" + integrity sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg== + +"@esbuild/win32-ia32@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz#1b1e3a63ad4bef82200fef4e369e0fff7009eee5" + integrity sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ== + +"@esbuild/win32-x64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz#9e585ab6086bef994c6e8a5b3a0481219ada862b" + integrity sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ== + +"@google/genai@^1.34.0": + version "1.34.0" + resolved "https://registry.yarnpkg.com/@google/genai/-/genai-1.34.0.tgz#8a6a85c2c7eb94afbb1a999967e828cae43ee6dd" + integrity sha512-vu53UMPvjmb7PGzlYu6Tzxso8Dfhn+a7eQFaS2uNemVtDZKwzSpJ5+ikqBbXplF7RGB1STcVDqCkPvquiwb2sw== + dependencies: + google-auth-library "^10.3.0" + ws "^8.18.0" + +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== "@types/node@^24.10.1": - version "24.10.1" - resolved "https://registry.yarnpkg.com/@types/node/-/node-24.10.1.tgz#91e92182c93db8bd6224fca031e2370cef9a8f01" - integrity sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ== + version "24.10.4" + resolved "https://registry.yarnpkg.com/@types/node/-/node-24.10.4.tgz#9d27c032a1b2c42a4eab8fb65c5856a8b8e098c4" + integrity sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg== dependencies: undici-types "~7.16.0" -accepts@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" - integrity sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng== - dependencies: - mime-types "^3.0.0" - negotiator "^1.0.0" - -ajv-formats@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-3.0.1.tgz#3d5dc762bca17679c3c2ea7e90ad6b7532309578" - integrity sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ== - dependencies: - ajv "^8.0.0" - -ajv@^8.0.0, ajv@^8.17.1: - version "8.17.1" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6" - integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g== - dependencies: - fast-deep-equal "^3.1.3" - fast-uri "^3.0.1" - json-schema-traverse "^1.0.0" - require-from-string "^2.0.2" - -body-parser@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.2.0.tgz#f7a9656de305249a715b549b7b8fd1ab9dfddcfa" - integrity sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg== - dependencies: - bytes "^3.1.2" - content-type "^1.0.5" - debug "^4.4.0" - http-errors "^2.0.0" - iconv-lite "^0.6.3" - on-finished "^2.4.1" - qs "^6.14.0" - raw-body "^3.0.0" - type-is "^2.0.0" - -bytes@3.1.2, bytes@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" - integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== - -call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" - integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== - dependencies: - es-errors "^1.3.0" - function-bind "^1.1.2" +agent-base@^7.1.2: + version "7.1.4" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8" + integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== -call-bound@^1.0.2: - version "1.0.4" - resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" - integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== - dependencies: - call-bind-apply-helpers "^1.0.2" - get-intrinsic "^1.3.0" +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== -content-disposition@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.0.0.tgz#844426cb398f934caefcbb172200126bc7ceace2" - integrity sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg== +ansi-regex@^6.0.1: + version "6.2.2" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" + integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== + +ansi-styles@^4.0.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== dependencies: - safe-buffer "5.2.1" + color-convert "^2.0.1" -content-type@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" - integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== +ansi-styles@^6.1.0: + version "6.2.3" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" + integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== -cookie-signature@^1.2.1: - version "1.2.2" - resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793" - integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== -cookie@^0.7.1: - version "0.7.2" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" - integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== +base64-js@^1.3.0: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== -cors@^2.8.5: - version "2.8.5" - resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" - integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== +bignumber.js@^9.0.0: + version "9.3.1" + resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.3.1.tgz#759c5aaddf2ffdc4f154f7b493e1c8770f88c4d7" + integrity sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ== + +brace-expansion@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7" + integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== dependencies: - object-assign "^4" - vary "^1" + balanced-match "^1.0.0" + +buffer-equal-constant-time@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== -cross-spawn@^7.0.5: +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== @@ -263,211 +239,123 @@ cross-spawn@^7.0.5: shebang-command "^2.0.0" which "^2.0.1" -debug@^4.3.5, debug@^4.4.0: +data-uri-to-buffer@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e" + integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A== + +debug@4: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== dependencies: ms "^2.1.3" -depd@2.0.0, depd@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" - integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== - -dunder-proto@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" - integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== - dependencies: - call-bind-apply-helpers "^1.0.1" - es-errors "^1.3.0" - gopd "^1.2.0" - -ee-first@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" - integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== - -encodeurl@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" - integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== - -es-define-property@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" - integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== - -es-errors@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" - integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== - -es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" - integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== - dependencies: - es-errors "^1.3.0" - -esbuild@~0.25.0: - version "0.25.12" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.12.tgz#97a1d041f4ab00c2fce2f838d2b9969a2d2a97a5" - integrity sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg== - optionalDependencies: - "@esbuild/aix-ppc64" "0.25.12" - "@esbuild/android-arm" "0.25.12" - "@esbuild/android-arm64" "0.25.12" - "@esbuild/android-x64" "0.25.12" - "@esbuild/darwin-arm64" "0.25.12" - "@esbuild/darwin-x64" "0.25.12" - "@esbuild/freebsd-arm64" "0.25.12" - "@esbuild/freebsd-x64" "0.25.12" - "@esbuild/linux-arm" "0.25.12" - "@esbuild/linux-arm64" "0.25.12" - "@esbuild/linux-ia32" "0.25.12" - "@esbuild/linux-loong64" "0.25.12" - "@esbuild/linux-mips64el" "0.25.12" - "@esbuild/linux-ppc64" "0.25.12" - "@esbuild/linux-riscv64" "0.25.12" - "@esbuild/linux-s390x" "0.25.12" - "@esbuild/linux-x64" "0.25.12" - "@esbuild/netbsd-arm64" "0.25.12" - "@esbuild/netbsd-x64" "0.25.12" - "@esbuild/openbsd-arm64" "0.25.12" - "@esbuild/openbsd-x64" "0.25.12" - "@esbuild/openharmony-arm64" "0.25.12" - "@esbuild/sunos-x64" "0.25.12" - "@esbuild/win32-arm64" "0.25.12" - "@esbuild/win32-ia32" "0.25.12" - "@esbuild/win32-x64" "0.25.12" - -escape-html@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" - integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== - -etag@^1.8.1: - version "1.8.1" - resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" - integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== - -eventsource-parser@^3.0.0, eventsource-parser@^3.0.1: - version "3.0.6" - resolved "https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-3.0.6.tgz#292e165e34cacbc936c3c92719ef326d4aeb4e90" - integrity sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg== - -eventsource@^3.0.2: - version "3.0.7" - resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-3.0.7.tgz#1157622e2f5377bb6aef2114372728ba0c156989" - integrity sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA== - dependencies: - eventsource-parser "^3.0.1" - -express-rate-limit@^7.5.0: - version "7.5.1" - resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-7.5.1.tgz#8c3a42f69209a3a1c969890070ece9e20a879dec" - integrity sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw== - -express@^5.0.1: - version "5.1.0" - resolved "https://registry.yarnpkg.com/express/-/express-5.1.0.tgz#d31beaf715a0016f0d53f47d3b4d7acf28c75cc9" - integrity sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA== - dependencies: - accepts "^2.0.0" - body-parser "^2.2.0" - content-disposition "^1.0.0" - content-type "^1.0.5" - cookie "^0.7.1" - cookie-signature "^1.2.1" - debug "^4.4.0" - encodeurl "^2.0.0" - escape-html "^1.0.3" - etag "^1.8.1" - finalhandler "^2.1.0" - fresh "^2.0.0" - http-errors "^2.0.0" - merge-descriptors "^2.0.0" - mime-types "^3.0.0" - on-finished "^2.4.1" - once "^1.4.0" - parseurl "^1.3.3" - proxy-addr "^2.0.7" - qs "^6.14.0" - range-parser "^1.2.1" - router "^2.2.0" - send "^1.1.0" - serve-static "^2.2.0" - statuses "^2.0.1" - type-is "^2.0.1" - vary "^1.1.2" - -fast-deep-equal@^3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" - integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== - -fast-uri@^3.0.1: - version "3.1.0" - resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.0.tgz#66eecff6c764c0df9b762e62ca7edcfb53b4edfa" - integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA== - -finalhandler@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-2.1.0.tgz#72306373aa89d05a8242ed569ed86a1bff7c561f" - integrity sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q== - dependencies: - debug "^4.4.0" - encodeurl "^2.0.0" - escape-html "^1.0.3" - on-finished "^2.4.1" - parseurl "^1.3.3" - statuses "^2.0.1" - -forwarded@0.2.0: +eastasianwidth@^0.2.0: version "0.2.0" - resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" - integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== - -fresh@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4" - integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + +esbuild@~0.27.0: + version "0.27.2" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.2.tgz#d83ed2154d5813a5367376bb2292a9296fc83717" + integrity sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw== + optionalDependencies: + "@esbuild/aix-ppc64" "0.27.2" + "@esbuild/android-arm" "0.27.2" + "@esbuild/android-arm64" "0.27.2" + "@esbuild/android-x64" "0.27.2" + "@esbuild/darwin-arm64" "0.27.2" + "@esbuild/darwin-x64" "0.27.2" + "@esbuild/freebsd-arm64" "0.27.2" + "@esbuild/freebsd-x64" "0.27.2" + "@esbuild/linux-arm" "0.27.2" + "@esbuild/linux-arm64" "0.27.2" + "@esbuild/linux-ia32" "0.27.2" + "@esbuild/linux-loong64" "0.27.2" + "@esbuild/linux-mips64el" "0.27.2" + "@esbuild/linux-ppc64" "0.27.2" + "@esbuild/linux-riscv64" "0.27.2" + "@esbuild/linux-s390x" "0.27.2" + "@esbuild/linux-x64" "0.27.2" + "@esbuild/netbsd-arm64" "0.27.2" + "@esbuild/netbsd-x64" "0.27.2" + "@esbuild/openbsd-arm64" "0.27.2" + "@esbuild/openbsd-x64" "0.27.2" + "@esbuild/openharmony-arm64" "0.27.2" + "@esbuild/sunos-x64" "0.27.2" + "@esbuild/win32-arm64" "0.27.2" + "@esbuild/win32-ia32" "0.27.2" + "@esbuild/win32-x64" "0.27.2" + +extend@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + +fetch-blob@^3.1.2, fetch-blob@^3.1.4: + version "3.2.0" + resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9" + integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ== + dependencies: + node-domexception "^1.0.0" + web-streams-polyfill "^3.0.3" + +foreground-child@^3.1.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" + integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== + dependencies: + cross-spawn "^7.0.6" + signal-exit "^4.0.1" + +formdata-polyfill@^4.0.10: + version "4.0.10" + resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423" + integrity sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g== + dependencies: + fetch-blob "^3.1.2" fsevents@~2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== -function-bind@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" - integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== - -get-intrinsic@^1.2.5, get-intrinsic@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" - integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== - dependencies: - call-bind-apply-helpers "^1.0.2" - es-define-property "^1.0.1" - es-errors "^1.3.0" - es-object-atoms "^1.1.1" - function-bind "^1.1.2" - get-proto "^1.0.1" - gopd "^1.2.0" - has-symbols "^1.1.0" - hasown "^2.0.2" - math-intrinsics "^1.1.0" - -get-proto@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" - integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== +gaxios@^7.0.0: + version "7.1.3" + resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-7.1.3.tgz#c5312f4254abc1b8ab53aef30c22c5229b80b1e1" + integrity sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ== + dependencies: + extend "^3.0.2" + https-proxy-agent "^7.0.1" + node-fetch "^3.3.2" + rimraf "^5.0.1" + +gcp-metadata@^8.0.0: + version "8.1.2" + resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-8.1.2.tgz#e62e3373ddf41fc727ccc31c55c687b798bee898" + integrity sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg== dependencies: - dunder-proto "^1.0.1" - es-object-atoms "^1.0.0" + gaxios "^7.0.0" + google-logging-utils "^1.0.0" + json-bigint "^1.0.0" get-tsconfig@^4.7.5: version "4.13.0" @@ -476,247 +364,166 @@ get-tsconfig@^4.7.5: dependencies: resolve-pkg-maps "^1.0.0" -gopd@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" - integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== - -has-symbols@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" - integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== - -hasown@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" - integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== - dependencies: - function-bind "^1.1.2" - -http-errors@2.0.0, http-errors@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" - integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== - dependencies: - depd "2.0.0" - inherits "2.0.4" - setprototypeof "1.2.0" - statuses "2.0.1" - toidentifier "1.0.1" - -iconv-lite@0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.0.tgz#c50cd80e6746ca8115eb98743afa81aa0e147a3e" - integrity sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ== - dependencies: - safer-buffer ">= 2.1.2 < 3.0.0" - -iconv-lite@^0.6.3: - version "0.6.3" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" - integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== +glob@^10.3.7: + version "10.5.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" + integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + +google-auth-library@^10.3.0: + version "10.5.0" + resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-10.5.0.tgz#3f0ebd47173496b91d2868f572bb8a8180c4b561" + integrity sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w== + dependencies: + base64-js "^1.3.0" + ecdsa-sig-formatter "^1.0.11" + gaxios "^7.0.0" + gcp-metadata "^8.0.0" + google-logging-utils "^1.0.0" + gtoken "^8.0.0" + jws "^4.0.0" + +google-logging-utils@^1.0.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/google-logging-utils/-/google-logging-utils-1.1.3.tgz#17b71f1f95d266d2ddd356b8f00178433f041b17" + integrity sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA== + +gtoken@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-8.0.0.tgz#d67a0e346dd441bfb54ad14040ddc3b632886575" + integrity sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw== + dependencies: + gaxios "^7.0.0" + jws "^4.0.0" + +https-proxy-agent@^7.0.1, https-proxy-agent@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9" + integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== dependencies: - safer-buffer ">= 2.1.2 < 3.0.0" + agent-base "^7.1.2" + debug "4" -inherits@2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" - integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== - -ipaddr.js@1.9.1: - version "1.9.1" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" - integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== - -is-promise@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3" - integrity sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ== +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== isexe@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== -json-schema-traverse@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" - integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" -math-intrinsics@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" - integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== +json-bigint@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-bigint/-/json-bigint-1.0.0.tgz#ae547823ac0cad8398667f8cd9ef4730f5b01ff1" + integrity sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ== + dependencies: + bignumber.js "^9.0.0" -media-typer@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-1.1.0.tgz#6ab74b8f2d3320f2064b2a87a38e7931ff3a5561" - integrity sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw== +jwa@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.1.tgz#bf8176d1ad0cd72e0f3f58338595a13e110bc804" + integrity sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg== + dependencies: + buffer-equal-constant-time "^1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" -merge-descriptors@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz#ea922f660635a2249ee565e0449f951e6b603808" - integrity sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g== +jws@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.1.tgz#07edc1be8fac20e677b283ece261498bd38f0690" + integrity sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA== + dependencies: + jwa "^2.0.1" + safe-buffer "^5.0.1" -mime-db@^1.54.0: - version "1.54.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5" - integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== -mime-types@^3.0.0, mime-types@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-3.0.1.tgz#b1d94d6997a9b32fd69ebaed0db73de8acb519ce" - integrity sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA== +minimatch@^9.0.4: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== dependencies: - mime-db "^1.54.0" + brace-expansion "^2.0.1" + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -negotiator@^1.0.0: +node-domexception@^1.0.0: version "1.0.0" - resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-1.0.0.tgz#b6c91bb47172d69f93cfd7c357bbb529019b5f6a" - integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg== + resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" + integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== -object-assign@^4: - version "4.1.1" - resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" - integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== - -object-inspect@^1.13.3: - version "1.13.4" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" - integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== - -on-finished@^2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" - integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== +node-fetch@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.3.2.tgz#d1e889bacdf733b4ff3b2b243eb7a12866a0b78b" + integrity sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA== dependencies: - ee-first "1.1.1" + data-uri-to-buffer "^4.0.0" + fetch-blob "^3.1.4" + formdata-polyfill "^4.0.10" -once@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== - dependencies: - wrappy "1" - -parseurl@^1.3.3: - version "1.3.3" - resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" - integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== +package-json-from-dist@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" + integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== path-key@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== -path-to-regexp@^8.0.0: - version "8.3.0" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.3.0.tgz#aa818a6981f99321003a08987d3cec9c3474cd1f" - integrity sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA== - -pkce-challenge@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/pkce-challenge/-/pkce-challenge-5.0.0.tgz#c3a405cb49e272094a38e890a2b51da0228c4d97" - integrity sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ== - -proxy-addr@^2.0.7: - version "2.0.7" - resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" - integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== - dependencies: - forwarded "0.2.0" - ipaddr.js "1.9.1" - -qs@^6.14.0: - version "6.14.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.0.tgz#c63fa40680d2c5c941412a0e899c89af60c0a930" - integrity sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w== +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== dependencies: - side-channel "^1.1.0" - -range-parser@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" - integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== - -raw-body@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.1.tgz#ced5cd79a77bbb0496d707f2a0f9e1ae3aecdcb1" - integrity sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA== - dependencies: - bytes "3.1.2" - http-errors "2.0.0" - iconv-lite "0.7.0" - unpipe "1.0.0" - -require-from-string@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" - integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" resolve-pkg-maps@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f" integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== -router@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/router/-/router-2.2.0.tgz#019be620b711c87641167cc79b99090f00b146ef" - integrity sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ== +rimraf@^5.0.1: + version "5.0.10" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-5.0.10.tgz#23b9843d3dc92db71f96e1a2ce92e39fd2a8221c" + integrity sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ== dependencies: - debug "^4.4.0" - depd "^2.0.0" - is-promise "^4.0.0" - parseurl "^1.3.3" - path-to-regexp "^8.0.0" + glob "^10.3.7" -safe-buffer@5.2.1: +safe-buffer@^5.0.1: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== -"safer-buffer@>= 2.1.2 < 3.0.0": - version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" - integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== - -send@^1.1.0, send@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/send/-/send-1.2.0.tgz#32a7554fb777b831dfa828370f773a3808d37212" - integrity sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw== - dependencies: - debug "^4.3.5" - encodeurl "^2.0.0" - escape-html "^1.0.3" - etag "^1.8.1" - fresh "^2.0.0" - http-errors "^2.0.0" - mime-types "^3.0.1" - ms "^2.1.3" - on-finished "^2.4.1" - range-parser "^1.2.1" - statuses "^2.0.1" - -serve-static@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-2.2.0.tgz#9c02564ee259bdd2251b82d659a2e7e1938d66f9" - integrity sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ== - dependencies: - encodeurl "^2.0.0" - escape-html "^1.0.3" - parseurl "^1.3.3" - send "^1.2.0" - -setprototypeof@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" - integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== - shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -729,80 +536,69 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== -side-channel-list@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" - integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== dependencies: - es-errors "^1.3.0" - object-inspect "^1.13.3" + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" -side-channel-map@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" - integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== +string-width@^4.1.0: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== dependencies: - call-bound "^1.0.2" - es-errors "^1.3.0" - get-intrinsic "^1.2.5" - object-inspect "^1.13.3" + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" -side-channel-weakmap@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" - integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== - dependencies: - call-bound "^1.0.2" - es-errors "^1.3.0" - get-intrinsic "^1.2.5" - object-inspect "^1.13.3" - side-channel-map "^1.0.1" - -side-channel@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" - integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== - dependencies: - es-errors "^1.3.0" - object-inspect "^1.13.3" - side-channel-list "^1.0.0" - side-channel-map "^1.0.1" - side-channel-weakmap "^1.0.2" - -statuses@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" - integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" -statuses@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" - integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" -toidentifier@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" - integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.0.1: + version "7.1.2" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" + integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA== + dependencies: + ansi-regex "^6.0.1" tsx@^4.20.6: - version "4.20.6" - resolved "https://registry.yarnpkg.com/tsx/-/tsx-4.20.6.tgz#8fb803fd9c1f70e8ccc93b5d7c5e03c3979ccb2e" - integrity sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg== + version "4.21.0" + resolved "https://registry.yarnpkg.com/tsx/-/tsx-4.21.0.tgz#32aa6cf17481e336f756195e6fe04dae3e6308b1" + integrity sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw== dependencies: - esbuild "~0.25.0" + esbuild "~0.27.0" get-tsconfig "^4.7.5" optionalDependencies: fsevents "~2.3.3" -type-is@^2.0.0, type-is@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.0.1.tgz#64f6cf03f92fce4015c2b224793f6bdd4b068c97" - integrity sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw== - dependencies: - content-type "^1.0.5" - media-typer "^1.1.0" - mime-types "^3.0.0" - typescript@^5.9.3: version "5.9.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" @@ -813,15 +609,15 @@ undici-types@~7.16.0: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46" integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== -unpipe@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" - integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== +undici@^7.16.0: + version "7.16.0" + resolved "https://registry.yarnpkg.com/undici/-/undici-7.16.0.tgz#cb2a1e957726d458b536e3f076bf51f066901c1a" + integrity sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g== -vary@^1, vary@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" - integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== +web-streams-polyfill@^3.0.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b" + integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw== which@^2.0.1: version "2.0.2" @@ -830,17 +626,30 @@ which@^2.0.1: dependencies: isexe "^2.0.0" -wrappy@1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" -zod-to-json-schema@^3.24.1: - version "3.24.6" - resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz#5920f020c4d2647edfbb954fa036082b92c9e12d" - integrity sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg== +ws@^8.18.0: + version "8.18.3" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472" + integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg== -zod@^3.23.8, zod@^3.25.76: +zod@^3.25.76: version "3.25.76" resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== From 90d8c5979cf8f8845c9a3a28b55e0886ef201462 Mon Sep 17 00:00:00 2001 From: tiye Date: Thu, 18 Dec 2025 19:47:56 +0800 Subject: [PATCH 02/13] temporary fix for upload base url --- src/index.mts | 80 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 75 insertions(+), 5 deletions(-) diff --git a/src/index.mts b/src/index.mts index 36a40bb..d57c04e 100644 --- a/src/index.mts +++ b/src/index.mts @@ -52,17 +52,27 @@ const proxyUrl = const proxyAgent = new ProxyAgent(proxyUrl); setGlobalDispatcher(proxyAgent); -console.log(`[Proxy] Using proxy: ${proxyUrl}`); +const baseUrl = process.env.GENAI_BASE_URL || "https://ja.chenyong.life"; +const uploadBaseUrl = process.env.GENAI_UPLOAD_BASE_URL || baseUrl; +const uploadBaseOrigin = getUrlOrigin(uploadBaseUrl); -// Since we're using localhost:7890 proxy, no need for baseUrl override -// const httpOptions = { -// }; +console.log(`[Proxy] Using proxy: ${proxyUrl}`); +console.log(`[GenAI] Using base URL: ${baseUrl}`); +if (uploadBaseOrigin) { + console.log(`[GenAI] Rewriting upload origin to: ${uploadBaseOrigin}`); +} const ai = new GoogleGenAI({ apiKey, - // httpOptions, + httpOptions: { + baseUrl, + }, }); +if (uploadBaseOrigin) { + forceUploadBaseForFiles(ai, uploadBaseOrigin); +} + // Check connection on startup (async () => { console.log(`[${new Date().toISOString()}] Checking AI connection...`); @@ -96,6 +106,66 @@ const tasks = new Map(); // ---------- Helpers ---------- const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +function getUrlOrigin(url: string): string | null { + try { + return new URL(url).origin; + } catch (error) { + console.warn(`[GenAI] Invalid URL for upload origin: ${url}`, error); + return null; + } +} + +function rewriteUploadUrlHost( + originalUrl: string, + preferredOrigin: string +): string { + try { + const target = new URL(originalUrl); + const preferred = new URL(preferredOrigin); + target.protocol = preferred.protocol; + target.hostname = preferred.hostname; + target.port = preferred.port; + return target.toString(); + } catch (error) { + console.warn(`[GenAI] Failed to rewrite upload URL ${originalUrl}:`, error); + return originalUrl; + } +} + +function forceUploadBaseForFiles( + aiClient: GoogleGenAI, + preferredOrigin: string +) { + const filesAny = aiClient.files as unknown as { + apiClient?: { + fetchUploadUrl?: (...args: unknown[]) => Promise; + __uploadBasePatched?: boolean; + }; + }; + const apiClient = filesAny?.apiClient; + if (!apiClient || typeof apiClient.fetchUploadUrl !== "function") { + console.warn( + "[GenAI] Unable to patch upload origin; apiClient missing fetchUploadUrl." + ); + return; + } + if (apiClient.__uploadBasePatched) { + return; + } + + const originalFetch = apiClient.fetchUploadUrl.bind(apiClient); + apiClient.fetchUploadUrl = async (...args: unknown[]) => { + const uploadUrl = await originalFetch(...args); + const rewritten = rewriteUploadUrlHost(uploadUrl, preferredOrigin); + if (uploadUrl !== rewritten) { + console.log(`[GenAI] Upload URL rewritten to ${rewritten}`); + } + return rewritten; + }; + + apiClient.__uploadBasePatched = true; +} + function getMimeType(ext: string): string { switch (ext.toLowerCase()) { case ".md": From 598e9a012554c9908efbb439344160e2ddc6020b Mon Sep 17 00:00:00 2001 From: tiye Date: Thu, 18 Dec 2025 22:58:55 +0800 Subject: [PATCH 03/13] use proxy from variable --- src/index.mts | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/index.mts b/src/index.mts index d57c04e..decf643 100644 --- a/src/index.mts +++ b/src/index.mts @@ -46,27 +46,28 @@ if (!apiKey) { process.exit(1); } -// Configure proxy using undici -const proxyUrl = - process.env.HTTP_PROXY || process.env.HTTPS_PROXY || "http://localhost:7890"; -const proxyAgent = new ProxyAgent(proxyUrl); -setGlobalDispatcher(proxyAgent); - -const baseUrl = process.env.GENAI_BASE_URL || "https://ja.chenyong.life"; -const uploadBaseUrl = process.env.GENAI_UPLOAD_BASE_URL || baseUrl; -const uploadBaseOrigin = getUrlOrigin(uploadBaseUrl); - -console.log(`[Proxy] Using proxy: ${proxyUrl}`); -console.log(`[GenAI] Using base URL: ${baseUrl}`); +// Configure proxy using undici (optional) +const proxyUrl = process.env.HTTP_PROXY || process.env.HTTPS_PROXY; +if (proxyUrl) { + const proxyAgent = new ProxyAgent(proxyUrl); + setGlobalDispatcher(proxyAgent); + console.log(`[Proxy] Using proxy: ${proxyUrl}`); +} + +const baseUrl = process.env.GEMINI_BASE_URL; +const uploadBaseUrl = process.env.GEMINI_UPLOAD_BASE_URL || baseUrl; +const uploadBaseOrigin = baseUrl ? getUrlOrigin(uploadBaseUrl || baseUrl) : undefined; + +if (baseUrl) { + console.log(`[GenAI] Using base URL: ${baseUrl}`); +} if (uploadBaseOrigin) { console.log(`[GenAI] Rewriting upload origin to: ${uploadBaseOrigin}`); } const ai = new GoogleGenAI({ apiKey, - httpOptions: { - baseUrl, - }, + httpOptions: baseUrl ? { baseUrl } : undefined, }); if (uploadBaseOrigin) { From 0100d57ad459868eb08e27d281e4412eff4fd693 Mon Sep 17 00:00:00 2001 From: tiye Date: Thu, 18 Dec 2025 23:11:02 +0800 Subject: [PATCH 04/13] split files, include tests --- .gitignore | 2 - examples/test_client.mts | 70 ++++ examples/test_client_simple.mts | 73 ++++ examples/test_file_upload.mts | 47 +++ examples/test_part.mts | 8 + package.json | 2 +- src/files.ts | 47 +++ src/genai.ts | 134 +++++++ src/index.mts | 687 -------------------------------- src/index.ts | 37 ++ src/query.ts | 54 +++ src/search.ts | 179 +++++++++ src/server.ts | 172 ++++++++ src/store.ts | 127 ++++++ src/types.ts | 29 ++ src/utils.ts | 20 + 16 files changed, 998 insertions(+), 690 deletions(-) create mode 100644 examples/test_client.mts create mode 100644 examples/test_client_simple.mts create mode 100644 examples/test_file_upload.mts create mode 100644 examples/test_part.mts create mode 100644 src/files.ts create mode 100644 src/genai.ts delete mode 100644 src/index.mts create mode 100644 src/index.ts create mode 100644 src/query.ts create mode 100644 src/search.ts create mode 100644 src/server.ts create mode 100644 src/store.ts create mode 100644 src/types.ts create mode 100644 src/utils.ts diff --git a/.gitignore b/.gitignore index 7113ecd..53afb16 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,3 @@ dist/ .DS_Store source/ - -tests/ \ No newline at end of file diff --git a/examples/test_client.mts b/examples/test_client.mts new file mode 100644 index 0000000..2a2d7b5 --- /dev/null +++ b/examples/test_client.mts @@ -0,0 +1,70 @@ +const BASE_URL = "http://localhost:8080"; + +async function pollTask(id: string) { + console.log(`Polling task ${id}...`); + while (true) { + const res = await fetch(`${BASE_URL}/query/${id}`); + if (!res.ok) { + throw new Error(`Failed to poll task: ${res.status} ${res.statusText}`); + } + const data = (await res.json()) as any; + + if (data.status === "done") { + console.log(`Task ${id} completed!`); + return data.content; + } else if (data.status === "error") { + throw new Error(`Task failed: ${data.error}`); + } else { + console.log( + `Task ${id} status: ${data.status}. Next poll in ${data.nextPollSec}s...` + ); + await new Promise((resolve) => + setTimeout(resolve, (data.nextPollSec || 2) * 1000) + ); + } + } +} + +async function createQuery(type: "docs" | "source", query: string) { + console.log(`Creating ${type} query: "${query}"`); + const res = await fetch(`${BASE_URL}/query`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type, query }), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to create query: ${res.status} ${text}`); + } + + const data = (await res.json()) as any; + console.log(`Query created, task ID: ${data.id}`); + return data.id; +} + +async function main() { + try { + // Test 1: Docs Query + console.log("\n--- Test 1: Docs Query ---"); + const docsId = await createQuery( + "docs", + "How to declare a mutable variable in MoonBit?" + ); + const docsResult = await pollTask(docsId); + console.log("\nResult:\n", docsResult); + + // Test 2: Source Query + console.log("\n--- Test 2: Source Query ---"); + const sourceId = await createQuery( + "source", + "How is the Array sort implemented?" + ); + const sourceResult = await pollTask(sourceId); + console.log("\nResult:\n", sourceResult); + } catch (error) { + console.error("Test failed:", error); + } +} + +main(); diff --git a/examples/test_client_simple.mts b/examples/test_client_simple.mts new file mode 100644 index 0000000..92a8a84 --- /dev/null +++ b/examples/test_client_simple.mts @@ -0,0 +1,73 @@ +// Simple test client without proxy configuration +const BASE_URL = "http://localhost:8080"; + +async function pollTask(id: string) { + console.log(`Polling task ${id}...`); + while (true) { + const res = await fetch(`${BASE_URL}/query/${id}`); + if (!res.ok) { + throw new Error(`Failed to poll task: ${res.status} ${res.statusText}`); + } + const data = (await res.json()) as any; + + if (data.status === "done") { + console.log(`Task ${id} completed!`); + return data.content; + } else if (data.status === "error") { + throw new Error(`Task failed: ${data.error}`); + } else { + console.log( + `Task ${id} status: ${data.status}. Next poll in ${data.nextPollSec}s...` + ); + await new Promise((resolve) => + setTimeout(resolve, (data.nextPollSec || 2) * 1000) + ); + } + } +} + +async function createQuery(type: "docs" | "source", query: string) { + console.log(`Creating ${type} query: "${query}"`); + const res = await fetch(`${BASE_URL}/query`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type, query }), + }); + + if (!res.ok) { + throw new Error(`Failed to create query: ${res.status} ${res.statusText}`); + } + + const data = (await res.json()) as any; + console.log(`Query created with ID: ${data.id}`); + return data.id; +} + +async function main() { + try { + console.log("\n--- Test 1: Docs Query ---"); + const docsQueryId = await createQuery( + "docs", + "How to declare a mutable variable in MoonBit?" + ); + const docsResult = await pollTask(docsQueryId); + console.log("Docs query result:"); + console.log(docsResult); + + console.log("\n--- Test 2: Source Query ---"); + const sourceQueryId = await createQuery( + "source", + "Show me the implementation of array sorting" + ); + const sourceResult = await pollTask(sourceQueryId); + console.log("Source query result:"); + console.log(sourceResult); + + console.log("\n✅ All tests passed!"); + } catch (error) { + console.error("Test failed:", error); + process.exit(1); + } +} + +main(); diff --git a/examples/test_file_upload.mts b/examples/test_file_upload.mts new file mode 100644 index 0000000..3c7299c --- /dev/null +++ b/examples/test_file_upload.mts @@ -0,0 +1,47 @@ +import { GoogleGenAI } from "@google/genai"; +import { ProxyAgent, setGlobalDispatcher } from "undici"; + +// Configure proxy using undici +const proxyUrl = + process.env.HTTP_PROXY || process.env.HTTPS_PROXY || "http://localhost:7890"; +const proxyAgent = new ProxyAgent(proxyUrl); +setGlobalDispatcher(proxyAgent); + +console.log(`Using proxy: ${proxyUrl}`); + +const apiKey = process.env.GEMINI_API_KEY || ""; +const ai = new GoogleGenAI({ + apiKey, + httpOptions: { + baseUrl: process.env.GEMINI_BASE_URL, + }, +}); + +async function test() { + console.log("Testing file upload through proxy..."); + + try { + const testContent = "This is a test file."; + const blob = new Blob([testContent], { type: "text/plain" }); + + const uploadedFile = await ai.files.upload({ + file: blob, + config: { + mimeType: "text/plain", + displayName: "test.txt", + }, + }); + + console.log("Upload successful!"); + console.log("File name:", uploadedFile.name); + console.log("File URI:", uploadedFile.uri); + + // Get file status + const fileInfo = await ai.files.get({ name: uploadedFile.name as string }); + console.log("File state:", fileInfo.state); + } catch (error) { + console.error("Upload failed:", error); + } +} + +test(); diff --git a/examples/test_part.mts b/examples/test_part.mts new file mode 100644 index 0000000..2d5a824 --- /dev/null +++ b/examples/test_part.mts @@ -0,0 +1,8 @@ +import { createPartFromUri } from "@google/genai"; + +const part = createPartFromUri( + "https://generativelanguage.googleapis.com/v1beta/files/test123", + "text/plain" +); + +console.log("Part structure:", JSON.stringify(part, null, 2)); diff --git a/package.json b/package.json index 091a9fd..95abcdf 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "scripts": { "build": "tsc", "start": "node dist/index.js", - "dev": "tsx src/index.mts", + "dev": "tsx src/index.ts", "prepublishOnly": "yarn build", "test": "echo \"Error: no test specified\" && exit 1" }, diff --git a/src/files.ts b/src/files.ts new file mode 100644 index 0000000..aac4df9 --- /dev/null +++ b/src/files.ts @@ -0,0 +1,47 @@ +import fs from "fs"; +import path from "path"; + +// ---------- File System Helpers ---------- + +export function getMimeType(ext: string): string { + switch (ext.toLowerCase()) { + case ".md": + return "text/markdown"; + case ".txt": + return "text/plain"; + case ".json": + return "application/json"; + case ".mbt": + return "text/plain"; + case ".ts": + return "text/typescript"; + default: + return "text/plain"; + } +} + +export function getAllFiles(dirPath: string, acc: string[] = []): string[] { + const files = fs.readdirSync(dirPath); + for (const file of files) { + const filePath = path.join(dirPath, file); + const stat = fs.statSync(filePath); + if (stat.isDirectory()) { + getAllFiles(filePath, acc); + } else { + acc.push(filePath); + } + } + return acc; +} + +export function fileExists(filePath: string): boolean { + return fs.existsSync(filePath); +} + +export function readFileContent(filePath: string): string { + return fs.readFileSync(filePath, "utf-8"); +} + +export function readFileBuffer(filePath: string): Buffer { + return fs.readFileSync(filePath); +} diff --git a/src/genai.ts b/src/genai.ts new file mode 100644 index 0000000..72aa742 --- /dev/null +++ b/src/genai.ts @@ -0,0 +1,134 @@ +import { GoogleGenAI } from "@google/genai"; +import { ProxyAgent, setGlobalDispatcher } from "undici"; + +// ---------- URL Helpers ---------- + +export function getUrlOrigin(url: string): string | null { + try { + return new URL(url).origin; + } catch (error) { + console.warn(`[GenAI] Invalid URL for upload origin: ${url}`, error); + return null; + } +} + +function rewriteUploadUrlHost( + originalUrl: string, + preferredOrigin: string +): string { + try { + const target = new URL(originalUrl); + const preferred = new URL(preferredOrigin); + target.protocol = preferred.protocol; + target.hostname = preferred.hostname; + target.port = preferred.port; + return target.toString(); + } catch (error) { + console.warn(`[GenAI] Failed to rewrite upload URL ${originalUrl}:`, error); + return originalUrl; + } +} + +export function forceUploadBaseForFiles( + aiClient: GoogleGenAI, + preferredOrigin: string +) { + const filesAny = aiClient.files as unknown as { + apiClient?: { + fetchUploadUrl?: (...args: unknown[]) => Promise; + __uploadBasePatched?: boolean; + }; + }; + const apiClient = filesAny?.apiClient; + if (!apiClient || typeof apiClient.fetchUploadUrl !== "function") { + console.warn( + "[GenAI] Unable to patch upload origin; apiClient missing fetchUploadUrl." + ); + return; + } + if (apiClient.__uploadBasePatched) { + return; + } + + const originalFetch = apiClient.fetchUploadUrl.bind(apiClient); + apiClient.fetchUploadUrl = async (...args: unknown[]) => { + const uploadUrl = await originalFetch(...args); + const rewritten = rewriteUploadUrlHost(uploadUrl, preferredOrigin); + if (uploadUrl !== rewritten) { + console.log(`[GenAI] Upload URL rewritten to ${rewritten}`); + } + return rewritten; + }; + + apiClient.__uploadBasePatched = true; +} + +// ---------- Proxy Setup ---------- + +export function setupProxy(): void { + const proxyUrl = process.env.HTTP_PROXY || process.env.HTTPS_PROXY; + if (proxyUrl) { + const proxyAgent = new ProxyAgent(proxyUrl); + setGlobalDispatcher(proxyAgent); + console.log(`[Proxy] Using proxy: ${proxyUrl}`); + } +} + +// ---------- AI Client Factory ---------- + +export function createAIClient(): GoogleGenAI { + const apiKey = process.env.GEMINI_API_KEY || ""; + if (!apiKey) { + console.error("Error: GEMINI_API_KEY environment variable is not set"); + process.exit(1); + } + + const baseUrl = process.env.GEMINI_BASE_URL; + const uploadBaseUrl = process.env.GEMINI_UPLOAD_BASE_URL || baseUrl; + const uploadBaseOrigin = baseUrl + ? getUrlOrigin(uploadBaseUrl || baseUrl) + : undefined; + + if (baseUrl) { + console.log(`[GenAI] Using base URL: ${baseUrl}`); + } + if (uploadBaseOrigin) { + console.log(`[GenAI] Rewriting upload origin to: ${uploadBaseOrigin}`); + } + + const ai = new GoogleGenAI({ + apiKey, + httpOptions: baseUrl ? { baseUrl } : undefined, + }); + + if (uploadBaseOrigin) { + forceUploadBaseForFiles(ai, uploadBaseOrigin); + } + + return ai; +} + +// ---------- Connection Test ---------- + +export async function testConnection(ai: GoogleGenAI): Promise { + console.log(`[${new Date().toISOString()}] Checking AI connection...`); + try { + const models = await ai.models.list(); + console.log( + `[${new Date().toISOString()}] AI Connection successful. Models available (first page): ${ + models.page?.length || 0 + }` + ); + + console.log(`[${new Date().toISOString()}] Testing Interactions API...`); + await ai.interactions.create({ + model: "gemini-2.5-flash", + input: [{ type: "text", text: "Hello" }], + }); + console.log( + `[${new Date().toISOString()}] Interactions API test successful.` + ); + } catch (error) { + console.error(`[${new Date().toISOString()}] AI Connection failed:`, error); + } +} diff --git a/src/index.mts b/src/index.mts deleted file mode 100644 index decf643..0000000 --- a/src/index.mts +++ /dev/null @@ -1,687 +0,0 @@ -import http from "http"; -import { randomUUID } from "crypto"; -import { GoogleGenAI, createPartFromUri } from "@google/genai"; -import fs from "fs"; -import path from "path"; -import { fileURLToPath } from "url"; -import { z } from "zod"; -import { ProxyAgent, setGlobalDispatcher } from "undici"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// ---------- Types ---------- -interface FileInfo { - uri: string; - mimeType: string; - name: string; -} - -type QueryKind = "docs" | "source"; - -type TaskStatus = "queued" | "running" | "done" | "error"; - -interface Task { - id: string; - kind: QueryKind; - query: string; - status: TaskStatus; - createdAt: number; - updatedAt: number; - etaSeconds: number; - result?: string; - error?: string; -} - -// ---------- Validation ---------- -const CreateQuerySchema = z.object({ - type: z.enum(["docs", "source"]), - query: z.string().min(1), -}); - -// ---------- Globals ---------- -const apiKey = process.env.GEMINI_API_KEY || ""; -if (!apiKey) { - console.error("Error: GEMINI_API_KEY environment variable is not set"); - process.exit(1); -} - -// Configure proxy using undici (optional) -const proxyUrl = process.env.HTTP_PROXY || process.env.HTTPS_PROXY; -if (proxyUrl) { - const proxyAgent = new ProxyAgent(proxyUrl); - setGlobalDispatcher(proxyAgent); - console.log(`[Proxy] Using proxy: ${proxyUrl}`); -} - -const baseUrl = process.env.GEMINI_BASE_URL; -const uploadBaseUrl = process.env.GEMINI_UPLOAD_BASE_URL || baseUrl; -const uploadBaseOrigin = baseUrl ? getUrlOrigin(uploadBaseUrl || baseUrl) : undefined; - -if (baseUrl) { - console.log(`[GenAI] Using base URL: ${baseUrl}`); -} -if (uploadBaseOrigin) { - console.log(`[GenAI] Rewriting upload origin to: ${uploadBaseOrigin}`); -} - -const ai = new GoogleGenAI({ - apiKey, - httpOptions: baseUrl ? { baseUrl } : undefined, -}); - -if (uploadBaseOrigin) { - forceUploadBaseForFiles(ai, uploadBaseOrigin); -} - -// Check connection on startup -(async () => { - console.log(`[${new Date().toISOString()}] Checking AI connection...`); - try { - const models = await ai.models.list(); - console.log( - `[${new Date().toISOString()}] AI Connection successful. Models available (first page): ${ - models.page?.length || 0 - }` - ); - - // Try a simple interaction to verify interactions API specifically - console.log(`[${new Date().toISOString()}] Testing Interactions API...`); - const response = await ai.interactions.create({ - model: "gemini-2.5-flash", - input: [{ type: "text", text: "Hello" }], - }); - console.log( - `[${new Date().toISOString()}] Interactions API test successful.` - ); - } catch (error) { - console.error(`[${new Date().toISOString()}] AI Connection failed:`, error); - } -})(); - -const uploadedStoreFiles: FileInfo[] = []; -let storeInitPromise: Promise | null = null; - -const tasks = new Map(); - -// ---------- Helpers ---------- -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -function getUrlOrigin(url: string): string | null { - try { - return new URL(url).origin; - } catch (error) { - console.warn(`[GenAI] Invalid URL for upload origin: ${url}`, error); - return null; - } -} - -function rewriteUploadUrlHost( - originalUrl: string, - preferredOrigin: string -): string { - try { - const target = new URL(originalUrl); - const preferred = new URL(preferredOrigin); - target.protocol = preferred.protocol; - target.hostname = preferred.hostname; - target.port = preferred.port; - return target.toString(); - } catch (error) { - console.warn(`[GenAI] Failed to rewrite upload URL ${originalUrl}:`, error); - return originalUrl; - } -} - -function forceUploadBaseForFiles( - aiClient: GoogleGenAI, - preferredOrigin: string -) { - const filesAny = aiClient.files as unknown as { - apiClient?: { - fetchUploadUrl?: (...args: unknown[]) => Promise; - __uploadBasePatched?: boolean; - }; - }; - const apiClient = filesAny?.apiClient; - if (!apiClient || typeof apiClient.fetchUploadUrl !== "function") { - console.warn( - "[GenAI] Unable to patch upload origin; apiClient missing fetchUploadUrl." - ); - return; - } - if (apiClient.__uploadBasePatched) { - return; - } - - const originalFetch = apiClient.fetchUploadUrl.bind(apiClient); - apiClient.fetchUploadUrl = async (...args: unknown[]) => { - const uploadUrl = await originalFetch(...args); - const rewritten = rewriteUploadUrlHost(uploadUrl, preferredOrigin); - if (uploadUrl !== rewritten) { - console.log(`[GenAI] Upload URL rewritten to ${rewritten}`); - } - return rewritten; - }; - - apiClient.__uploadBasePatched = true; -} - -function getMimeType(ext: string): string { - switch (ext.toLowerCase()) { - case ".md": - return "text/markdown"; - case ".json": - return "application/json"; - case ".mbt": - return "text/plain"; - default: - return "text/plain"; - } -} - -function getAllFiles(dirPath: string, acc: string[] = []): string[] { - const files = fs.readdirSync(dirPath); - for (const file of files) { - const filePath = path.join(dirPath, file); - const stat = fs.statSync(filePath); - if (stat.isDirectory()) { - getAllFiles(filePath, acc); - } else { - acc.push(filePath); - } - } - return acc; -} - -async function initializeFiles(dirName: string, fileList: FileInfo[]) { - console.log( - `[${new Date().toISOString()}] Initializing files from ${dirName}...` - ); - const dir = path.join(path.dirname(__dirname), dirName); - if (!fs.existsSync(dir)) { - console.error( - `[${new Date().toISOString()}] ${dirName} directory not found. Skipping...` - ); - return; - } - - if (fileList.length > 0) { - console.log( - `[${new Date().toISOString()}] Files already initialized for ${dirName}. Count: ${ - fileList.length - }` - ); - return; - } - - const files = getAllFiles(dir); - // Upload Markdown and text files to Gemini - const validFiles = files.filter((file) => - [".md", ".txt"].includes(path.extname(file).toLowerCase()) - ); - if (validFiles.length === 0) { - console.error( - `[${new Date().toISOString()}] No valid files found in ${dirName} directory` - ); - return; - } - - console.log( - `[${new Date().toISOString()}] Found ${ - validFiles.length - } files to upload from ${dirName}` - ); - - for (const filePath of validFiles) { - const relativePath = path.relative(dir, filePath); - console.log(`[${new Date().toISOString()}] Uploading ${relativePath}...`); - - const fileContent = fs.readFileSync(filePath); - const ext = path.extname(filePath).toLowerCase(); - const mimeType = getMimeType(ext); - const blob = new Blob([fileContent], { type: mimeType }); - - try { - console.log( - `[${new Date().toISOString()}] Starting upload for ${relativePath}...` - ); - const uploadedFile = await ai.files.upload({ - file: blob, - config: { - mimeType, - displayName: relativePath, - }, - }); - console.log( - `[${new Date().toISOString()}] Upload request completed for ${relativePath}. Name: ${ - uploadedFile.name - }` - ); - - let processed = await ai.files.get({ - name: uploadedFile.name as string, - }); - let retries = 0; - const maxRetries = 60; - while (processed.state === "PROCESSING" && retries < maxRetries) { - await sleep(5000); - processed = await ai.files.get({ - name: uploadedFile.name as string, - }); - retries++; - } - - if (processed.state === "FAILED") { - console.error( - `[${new Date().toISOString()}] Failed to process file: ${relativePath}` - ); - continue; - } - - fileList.push({ - uri: processed.uri as string, - mimeType: processed.mimeType as string, - name: processed.name as string, - }); - console.log( - `[${new Date().toISOString()}] Uploaded ${relativePath} - URI: ${ - processed.uri - }` - ); - } catch (error) { - console.error( - `[${new Date().toISOString()}] Error uploading ${relativePath}:`, - error - ); - } - } - - console.log( - `[${new Date().toISOString()}] Initialized ${ - fileList.length - } files from ${dirName}` - ); -} - -// Search in a specific directory with file extensions -function searchInDirectory( - baseDir: string, - extensions: string[], - queryTerms: string[], - maxResults: number = 15 -): { relativePath: string; matchingLines: string[]; fullContent?: string }[] { - if (!fs.existsSync(baseDir)) { - return []; - } - - const results: { - relativePath: string; - matchingLines: string[]; - fullContent?: string; - }[] = []; - const allFiles = getAllFiles(baseDir); - const targetFiles = allFiles.filter((file) => - extensions.includes(path.extname(file).toLowerCase()) - ); - - for (const filePath of targetFiles) { - try { - const content = fs.readFileSync(filePath, "utf-8"); - const contentLower = content.toLowerCase(); - - // Check if any query term matches - const hasMatch = queryTerms.some((term) => contentLower.includes(term)); - if (hasMatch) { - const relativePath = path.relative(baseDir, filePath); - const lines = content.split("\n"); - - // Find matching lines with context - const matchingLines: string[] = []; - lines.forEach((line, idx) => { - const lineLower = line.toLowerCase(); - if (queryTerms.some((term) => lineLower.includes(term))) { - // Include surrounding context (2 lines before and after) - const start = Math.max(0, idx - 2); - const end = Math.min(lines.length, idx + 3); - for (let i = start; i < end; i++) { - const prefix = i === idx ? ">>> " : " "; - matchingLines.push(`${prefix}Line ${i + 1}: ${lines[i]}`); - } - matchingLines.push(""); // separator - } - }); - - if (matchingLines.length > 0) { - results.push({ - relativePath, - matchingLines: matchingLines.slice(0, 30), - fullContent: content.length < 5000 ? content : undefined, - }); - } - - if (results.length >= maxResults) break; - } - } catch (error) { - // Skip files that can't be read - } - } - - return results; -} - -async function searchLocalCode(query: string): Promise { - console.log( - `[${new Date().toISOString()}] Searching local code for: "${query}"` - ); - const sourceDir = path.join(path.dirname(__dirname), "source"); - - if (!fs.existsSync(sourceDir)) { - return "Source directory not found."; - } - - const queryTerms = query - .toLowerCase() - .split(/\s+/) - .filter((t) => t.length > 2); - - if (queryTerms.length === 0) { - return "Query too short. Please provide more specific search terms."; - } - - // Step 1: Search in genai-sdk-samples for TypeScript examples - console.log(`[${new Date().toISOString()}] Searching SDK samples...`); - const sdkSamplesDir = path.join(sourceDir, "genai-sdk-samples"); - const sdkResults = searchInDirectory(sdkSamplesDir, [".ts"], queryTerms, 10); - - // Step 2: Search in core for MoonBit source code - console.log(`[${new Date().toISOString()}] Searching MoonBit core...`); - const coreDir = path.join(sourceDir, "core"); - const coreResults = searchInDirectory(coreDir, [".mbt"], queryTerms, 10); - - // Step 3: Search README and documentation files - console.log(`[${new Date().toISOString()}] Searching documentation...`); - const docResults = searchInDirectory( - sourceDir, - [".md", ".json"], - queryTerms, - 5 - ); - - // Combine raw results - let rawResults = ""; - - if (sdkResults.length > 0) { - rawResults += "\n## SDK Samples (TypeScript)\n"; - for (const r of sdkResults) { - rawResults += `\n### ${ - r.relativePath - }\n\`\`\`typescript\n${r.matchingLines.join("\n")}\n\`\`\`\n`; - } - } - - if (coreResults.length > 0) { - rawResults += "\n## MoonBit Core Source Code\n"; - for (const r of coreResults) { - rawResults += `\n### ${ - r.relativePath - }\n\`\`\`moonbit\n${r.matchingLines.join("\n")}\n\`\`\`\n`; - } - } - - if (docResults.length > 0) { - rawResults += "\n## Documentation\n"; - for (const r of docResults) { - rawResults += `\n### ${r.relativePath}\n${r.matchingLines - .slice(0, 15) - .join("\n")}\n`; - } - } - - if (!rawResults) { - return `No matching code found for query: "${query}". Try different search terms.`; - } - - // Step 4: Use LLM to organize and verify the results - console.log( - `[${new Date().toISOString()}] Using LLM to organize search results...` - ); - - const prompt = `You are a documentation assistant for MoonBit programming language. - -The user asked: "${query}" - -Here are the raw search results from the codebase: -${rawResults} - -Please analyze these search results and provide a well-organized response that: -1. Directly answers the user's question based on the code found -2. Includes relevant code examples with proper syntax highlighting -3. Explains how the code works if helpful -4. If the search results don't fully answer the question, say so and suggest what else the user might look for - -Format your response as clean documentation that could be used as a reference.`; - - try { - const response = await ai.models.generateContent({ - model: "gemini-2.5-flash", - contents: prompt, - }); - - const result = response.text; - if (result) { - console.log(`[${new Date().toISOString()}] LLM processing complete.`); - return result; - } - } catch (error) { - console.error( - `[${new Date().toISOString()}] LLM processing failed:`, - error - ); - } - - // Fallback to raw results if LLM fails - return `Raw search results for "${query}":\n${rawResults}`; -} - -async function runQuery(kind: QueryKind, query: string): Promise { - console.log( - `[${new Date().toISOString()}] Starting query processing. Kind: ${kind}, Query: "${query}"` - ); - - // For source code queries, use local search - if (kind === "source") { - return await searchLocalCode(query); - } - - // For docs queries, use Gemini with uploaded Markdown files - const files = uploadedStoreFiles; - if (files.length === 0) { - console.warn( - `[${new Date().toISOString()}] No files found for kind: ${kind}` - ); - return "No documentation files available. Please add Markdown files to the 'store' directory."; - } - - console.log( - `[${new Date().toISOString()}] Preparing content for Gemini. File count: ${ - files.length - }` - ); - - // Build parts array for generateContent - const parts: any[] = []; - for (const file of files) { - parts.push(createPartFromUri(file.uri, file.mimeType)); - } - - const promptText = `Based on the MoonBit documentation files provided, please answer the following question:\n\n${query}\n\nPlease provide a detailed and accurate answer based only on the information in the documentation.`; - - parts.push({ text: promptText }); - - console.log(`[${new Date().toISOString()}] Sending request to Gemini...`); - const response = await ai.models.generateContent({ - model: "gemini-2.5-flash", - contents: parts, - }); - console.log(`[${new Date().toISOString()}] Received response from Gemini.`); - - return response.text || "No response generated"; -} - -function respondJson(res: http.ServerResponse, status: number, body: unknown) { - res.statusCode = status; - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify(body)); -} - -async function handleCreateQuery( - req: http.IncomingMessage, - res: http.ServerResponse -) { - let raw = ""; - req.on("data", (chunk) => (raw += chunk)); - req.on("end", () => { - try { - const parsed = raw ? JSON.parse(raw) : {}; - const data = CreateQuerySchema.parse(parsed); - - const id = randomUUID(); - const now = Date.now(); - const task: Task = { - id, - kind: data.type, - query: data.query, - status: "queued", - createdAt: now, - updatedAt: now, - etaSeconds: 3, - }; - - tasks.set(id, task); - console.log( - `[${new Date().toISOString()}] Task created. ID: ${id}, Kind: ${ - data.type - }` - ); - processTask(task).catch((err) => { - console.error( - `[${new Date().toISOString()}] Task processing failed. ID: ${id}, Error:`, - err - ); - const t = tasks.get(id); - if (t) { - t.status = "error"; - t.error = err instanceof Error ? err.message : String(err); - t.updatedAt = Date.now(); - } - }); - - respondJson(res, 202, { id, nextPollSec: 2 }); - } catch (error) { - console.error( - `[${new Date().toISOString()}] Error handling create query:`, - error - ); - respondJson(res, 400, { - error: error instanceof Error ? error.message : String(error), - }); - } - }); -} - -async function handleGetQuery( - req: http.IncomingMessage, - res: http.ServerResponse, - id: string -) { - console.log(`[${new Date().toISOString()}] Handling get query. ID: ${id}`); - const task = tasks.get(id); - if (!task) { - console.warn(`[${new Date().toISOString()}] Task not found. ID: ${id}`); - respondJson(res, 404, { error: "task not found" }); - return; - } - - console.log( - `[${new Date().toISOString()}] Task status. ID: ${id}, Status: ${ - task.status - }` - ); - if (task.status === "done") { - respondJson(res, 200, { status: "done", content: task.result }); - } else if (task.status === "error") { - respondJson(res, 200, { status: "error", error: task.error }); - } else { - respondJson(res, 200, { - status: task.status, - etaSeconds: task.etaSeconds, - nextPollSec: 2, - message: "Processing in background. Please poll again.", - debug: { - createdAt: new Date(task.createdAt).toISOString(), - updatedAt: new Date(task.updatedAt).toISOString(), - elapsedSeconds: (Date.now() - task.createdAt) / 1000, - }, - }); - } -} - -async function processTask(task: Task) { - console.log( - `[${new Date().toISOString()}] Processing task. ID: ${task.id}, Kind: ${ - task.kind - }` - ); - task.status = "running"; - task.updatedAt = Date.now(); - try { - const result = await runQuery(task.kind, task.query); - task.result = result; - task.status = "done"; - task.updatedAt = Date.now(); - console.log(`[${new Date().toISOString()}] Task completed. ID: ${task.id}`); - } catch (error) { - console.error( - `[${new Date().toISOString()}] Task failed. ID: ${task.id}, Error:`, - error - ); - task.status = "error"; - task.error = error instanceof Error ? error.message : String(error); - task.updatedAt = Date.now(); - } -} - -// ---------- HTTP Server ---------- -const server = http.createServer((req, res) => { - const url = req.url || "/"; - const method = req.method || "GET"; - - if (method === "GET" && url === "/healthz") { - respondJson(res, 200, { ok: true }); - return; - } - - if (method === "POST" && url === "/query") { - handleCreateQuery(req, res); - return; - } - - if (method === "GET" && url.startsWith("/query/")) { - const id = url.split("/query/")[1]; - handleGetQuery(req, res, id); - return; - } - - respondJson(res, 404, { error: "not found" }); -}); - -const port = Number(process.env.PORT || 8080); -server.listen(port, () => { - console.error(`Moonverse HTTP server listening on port ${port}`); -}); - -// Start uploading store files immediately on server startup -initializeFiles("store", uploadedStoreFiles).catch((error) => { - console.error("[Startup] Failed to initialize store files:", error); -}); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..74130c3 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,37 @@ +import path from "path"; +import { fileURLToPath } from "url"; +import { setupProxy, createAIClient, testConnection } from "./genai.js"; +import { initializeStore } from "./store.js"; +import { createServer, startServer } from "./server.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const rootDir = path.dirname(__dirname); + +// ---------- Main ---------- + +async function main() { + // Setup proxy if configured + setupProxy(); + + // Create AI client + const ai = createAIClient(); + + // Test connection + testConnection(ai); + + // Create and start HTTP server + const port = Number(process.env.PORT || 8080); + const server = createServer(ai, rootDir); + startServer(server, port); + + // Initialize store files in background + initializeStore(ai, rootDir).catch((error) => { + console.error("[Startup] Failed to initialize store files:", error); + }); +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/src/query.ts b/src/query.ts new file mode 100644 index 0000000..84770ce --- /dev/null +++ b/src/query.ts @@ -0,0 +1,54 @@ +import { GoogleGenAI, createPartFromUri } from "@google/genai"; +import { QueryKind } from "./types.js"; +import { getUploadedStoreFiles } from "./store.js"; +import { searchSourceCode } from "./search.js"; + +// ---------- Query Execution ---------- + +export async function runQuery( + ai: GoogleGenAI, + rootDir: string, + kind: QueryKind, + query: string +): Promise { + console.log( + `[${new Date().toISOString()}] Starting query processing. Kind: ${kind}, Query: "${query}"` + ); + + // For source code queries, use local search + LLM + if (kind === "source") { + return await searchSourceCode(ai, rootDir, query); + } + + // For docs queries, use Gemini with uploaded Markdown files + const files = getUploadedStoreFiles(); + if (files.length === 0) { + console.warn( + `[${new Date().toISOString()}] No files found for kind: ${kind}` + ); + return "No documentation files available. Please add Markdown files to the 'store' directory."; + } + + console.log( + `[${new Date().toISOString()}] Preparing content for Gemini. File count: ${files.length}` + ); + + // Build parts array for generateContent + const parts: any[] = []; + for (const file of files) { + parts.push(createPartFromUri(file.uri, file.mimeType)); + } + + const promptText = `Based on the MoonBit documentation files provided, please answer the following question:\n\n${query}\n\nPlease provide a detailed and accurate answer based only on the information in the documentation.`; + + parts.push({ text: promptText }); + + console.log(`[${new Date().toISOString()}] Sending request to Gemini...`); + const response = await ai.models.generateContent({ + model: "gemini-2.5-flash", + contents: parts, + }); + console.log(`[${new Date().toISOString()}] Received response from Gemini.`); + + return response.text || "No response generated"; +} diff --git a/src/search.ts b/src/search.ts new file mode 100644 index 0000000..e5297d8 --- /dev/null +++ b/src/search.ts @@ -0,0 +1,179 @@ +import path from "path"; +import { GoogleGenAI } from "@google/genai"; +import { SearchResult } from "./types.js"; +import { getAllFiles, fileExists, readFileContent } from "./files.js"; + +// ---------- Local Search ---------- + +export function searchInDirectory( + baseDir: string, + extensions: string[], + queryTerms: string[], + maxResults: number = 15 +): SearchResult[] { + if (!fileExists(baseDir)) { + return []; + } + + const results: SearchResult[] = []; + const allFiles = getAllFiles(baseDir); + const targetFiles = allFiles.filter((file) => + extensions.includes(path.extname(file).toLowerCase()) + ); + + for (const filePath of targetFiles) { + try { + const content = readFileContent(filePath); + const contentLower = content.toLowerCase(); + + // Check if any query term matches + const hasMatch = queryTerms.some((term) => contentLower.includes(term)); + if (hasMatch) { + const relativePath = path.relative(baseDir, filePath); + const lines = content.split("\n"); + + // Find matching lines with context + const matchingLines: string[] = []; + lines.forEach((line, idx) => { + const lineLower = line.toLowerCase(); + if (queryTerms.some((term) => lineLower.includes(term))) { + // Include surrounding context (2 lines before and after) + const start = Math.max(0, idx - 2); + const end = Math.min(lines.length, idx + 3); + for (let i = start; i < end; i++) { + const prefix = i === idx ? ">>> " : " "; + matchingLines.push(`${prefix}Line ${i + 1}: ${lines[i]}`); + } + matchingLines.push(""); // separator + } + }); + + if (matchingLines.length > 0) { + results.push({ + relativePath, + matchingLines: matchingLines.slice(0, 30), + fullContent: content.length < 5000 ? content : undefined, + }); + } + + if (results.length >= maxResults) break; + } + } catch (error) { + // Skip files that can't be read + } + } + + return results; +} + +export async function searchSourceCode( + ai: GoogleGenAI, + rootDir: string, + query: string +): Promise { + console.log( + `[${new Date().toISOString()}] Searching local code for: "${query}"` + ); + const sourceDir = path.join(rootDir, "source"); + + if (!fileExists(sourceDir)) { + return "Source directory not found."; + } + + const queryTerms = query + .toLowerCase() + .split(/\s+/) + .filter((t) => t.length > 2); + + if (queryTerms.length === 0) { + return "Query too short. Please provide more specific search terms."; + } + + // Step 1: Search in genai-sdk-samples for TypeScript examples + console.log(`[${new Date().toISOString()}] Searching SDK samples...`); + const sdkSamplesDir = path.join(sourceDir, "genai-sdk-samples"); + const sdkResults = searchInDirectory(sdkSamplesDir, [".ts"], queryTerms, 10); + + // Step 2: Search in core for MoonBit source code + console.log(`[${new Date().toISOString()}] Searching MoonBit core...`); + const coreDir = path.join(sourceDir, "core"); + const coreResults = searchInDirectory(coreDir, [".mbt"], queryTerms, 10); + + // Step 3: Search README and documentation files + console.log(`[${new Date().toISOString()}] Searching documentation...`); + const docResults = searchInDirectory( + sourceDir, + [".md", ".json"], + queryTerms, + 5 + ); + + // Combine raw results + let rawResults = ""; + + if (sdkResults.length > 0) { + rawResults += "\n## SDK Samples (TypeScript)\n"; + for (const r of sdkResults) { + rawResults += `\n### ${r.relativePath}\n\`\`\`typescript\n${r.matchingLines.join("\n")}\n\`\`\`\n`; + } + } + + if (coreResults.length > 0) { + rawResults += "\n## MoonBit Core Source Code\n"; + for (const r of coreResults) { + rawResults += `\n### ${r.relativePath}\n\`\`\`moonbit\n${r.matchingLines.join("\n")}\n\`\`\`\n`; + } + } + + if (docResults.length > 0) { + rawResults += "\n## Documentation\n"; + for (const r of docResults) { + rawResults += `\n### ${r.relativePath}\n${r.matchingLines.slice(0, 15).join("\n")}\n`; + } + } + + if (!rawResults) { + return `No matching code found for query: "${query}". Try different search terms.`; + } + + // Step 4: Use LLM to organize and verify the results + console.log( + `[${new Date().toISOString()}] Using LLM to organize search results...` + ); + + const prompt = `You are a documentation assistant for MoonBit programming language. + +The user asked: "${query}" + +Here are the raw search results from the codebase: +${rawResults} + +Please analyze these search results and provide a well-organized response that: +1. Directly answers the user's question based on the code found +2. Includes relevant code examples with proper syntax highlighting +3. Explains how the code works if helpful +4. If the search results don't fully answer the question, say so and suggest what else the user might look for + +Format your response as clean documentation that could be used as a reference.`; + + try { + const response = await ai.models.generateContent({ + model: "gemini-2.5-flash", + contents: prompt, + }); + + const result = response.text; + if (result) { + console.log(`[${new Date().toISOString()}] LLM processing complete.`); + return result; + } + } catch (error) { + console.error( + `[${new Date().toISOString()}] LLM processing failed:`, + error + ); + } + + // Fallback to raw results if LLM fails + return `Raw search results for "${query}":\n${rawResults}`; +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..ba65f3a --- /dev/null +++ b/src/server.ts @@ -0,0 +1,172 @@ +import http from "http"; +import { randomUUID } from "crypto"; +import { z } from "zod"; +import { GoogleGenAI } from "@google/genai"; +import { Task } from "./types.js"; +import { runQuery } from "./query.js"; + +// ---------- Validation ---------- + +const CreateQuerySchema = z.object({ + type: z.enum(["docs", "source"]), + query: z.string().min(1), +}); + +// ---------- Task Management ---------- + +const tasks = new Map(); + +function respondJson(res: http.ServerResponse, status: number, body: unknown) { + res.statusCode = status; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(body)); +} + +async function handleCreateQuery( + req: http.IncomingMessage, + res: http.ServerResponse, + ai: GoogleGenAI, + rootDir: string +) { + let raw = ""; + req.on("data", (chunk) => (raw += chunk)); + req.on("end", () => { + try { + const parsed = raw ? JSON.parse(raw) : {}; + const data = CreateQuerySchema.parse(parsed); + + const id = randomUUID(); + const now = Date.now(); + const task: Task = { + id, + kind: data.type, + query: data.query, + status: "queued", + createdAt: now, + updatedAt: now, + etaSeconds: 3, + }; + + tasks.set(id, task); + console.log( + `[${new Date().toISOString()}] Task created. ID: ${id}, Kind: ${data.type}` + ); + + processTask(task, ai, rootDir).catch((err) => { + console.error( + `[${new Date().toISOString()}] Task processing failed. ID: ${id}, Error:`, + err + ); + const t = tasks.get(id); + if (t) { + t.status = "error"; + t.error = err instanceof Error ? err.message : String(err); + t.updatedAt = Date.now(); + } + }); + + respondJson(res, 202, { id, nextPollSec: 2 }); + } catch (error) { + console.error( + `[${new Date().toISOString()}] Error handling create query:`, + error + ); + respondJson(res, 400, { + error: error instanceof Error ? error.message : String(error), + }); + } + }); +} + +async function handleGetQuery( + req: http.IncomingMessage, + res: http.ServerResponse, + id: string +) { + console.log(`[${new Date().toISOString()}] Handling get query. ID: ${id}`); + const task = tasks.get(id); + if (!task) { + console.warn(`[${new Date().toISOString()}] Task not found. ID: ${id}`); + respondJson(res, 404, { error: "task not found" }); + return; + } + + console.log( + `[${new Date().toISOString()}] Task status. ID: ${id}, Status: ${task.status}` + ); + if (task.status === "done") { + respondJson(res, 200, { status: "done", content: task.result }); + } else if (task.status === "error") { + respondJson(res, 200, { status: "error", error: task.error }); + } else { + respondJson(res, 200, { + status: task.status, + etaSeconds: task.etaSeconds, + nextPollSec: 2, + message: "Processing in background. Please poll again.", + debug: { + createdAt: new Date(task.createdAt).toISOString(), + updatedAt: new Date(task.updatedAt).toISOString(), + elapsedSeconds: (Date.now() - task.createdAt) / 1000, + }, + }); + } +} + +async function processTask(task: Task, ai: GoogleGenAI, rootDir: string) { + console.log( + `[${new Date().toISOString()}] Processing task. ID: ${task.id}, Kind: ${task.kind}` + ); + task.status = "running"; + task.updatedAt = Date.now(); + try { + const result = await runQuery(ai, rootDir, task.kind, task.query); + task.result = result; + task.status = "done"; + task.updatedAt = Date.now(); + console.log(`[${new Date().toISOString()}] Task completed. ID: ${task.id}`); + } catch (error) { + console.error( + `[${new Date().toISOString()}] Task failed. ID: ${task.id}, Error:`, + error + ); + task.status = "error"; + task.error = error instanceof Error ? error.message : String(error); + task.updatedAt = Date.now(); + } +} + +// ---------- Server Factory ---------- + +export function createServer(ai: GoogleGenAI, rootDir: string): http.Server { + const server = http.createServer((req, res) => { + const url = req.url || "/"; + const method = req.method || "GET"; + + if (method === "GET" && url === "/healthz") { + respondJson(res, 200, { ok: true }); + return; + } + + if (method === "POST" && url === "/query") { + handleCreateQuery(req, res, ai, rootDir); + return; + } + + if (method === "GET" && url.startsWith("/query/")) { + const id = url.split("/query/")[1]; + handleGetQuery(req, res, id); + return; + } + + respondJson(res, 404, { error: "not found" }); + }); + + return server; +} + +export function startServer(server: http.Server, port: number): void { + server.listen(port, () => { + console.error(`Moonverse HTTP server listening on port ${port}`); + }); +} diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000..8e82207 --- /dev/null +++ b/src/store.ts @@ -0,0 +1,127 @@ +import path from "path"; +import { GoogleGenAI } from "@google/genai"; +import { FileInfo } from "./types.js"; +import { getAllFiles, getMimeType, fileExists, readFileBuffer } from "./files.js"; +import { sleep } from "./utils.js"; + +// ---------- Store State ---------- + +const uploadedStoreFiles: FileInfo[] = []; +let storeInitialized = false; + +export function getUploadedStoreFiles(): FileInfo[] { + return uploadedStoreFiles; +} + +export function isStoreInitialized(): boolean { + return storeInitialized; +} + +// ---------- File Upload ---------- + +export async function initializeStore( + ai: GoogleGenAI, + rootDir: string +): Promise { + const dirName = "store"; + console.log( + `[${new Date().toISOString()}] Initializing files from ${dirName}...` + ); + + const dir = path.join(rootDir, dirName); + if (!fileExists(dir)) { + console.error( + `[${new Date().toISOString()}] ${dirName} directory not found. Skipping...` + ); + return; + } + + if (uploadedStoreFiles.length > 0) { + console.log( + `[${new Date().toISOString()}] Files already initialized for ${dirName}. Count: ${uploadedStoreFiles.length}` + ); + return; + } + + const files = getAllFiles(dir); + // Upload Markdown and text files to Gemini + const validFiles = files.filter((file) => + [".md", ".txt"].includes(path.extname(file).toLowerCase()) + ); + + if (validFiles.length === 0) { + console.error( + `[${new Date().toISOString()}] No valid files found in ${dirName} directory` + ); + return; + } + + console.log( + `[${new Date().toISOString()}] Found ${validFiles.length} files to upload from ${dirName}` + ); + + for (const filePath of validFiles) { + const relativePath = path.relative(dir, filePath); + console.log(`[${new Date().toISOString()}] Uploading ${relativePath}...`); + + const fileContent = readFileBuffer(filePath); + const ext = path.extname(filePath).toLowerCase(); + const mimeType = getMimeType(ext); + const blob = new Blob([fileContent], { type: mimeType }); + + try { + console.log( + `[${new Date().toISOString()}] Starting upload for ${relativePath}...` + ); + const uploadedFile = await ai.files.upload({ + file: blob, + config: { + mimeType, + displayName: relativePath, + }, + }); + console.log( + `[${new Date().toISOString()}] Upload request completed for ${relativePath}. Name: ${uploadedFile.name}` + ); + + let processed = await ai.files.get({ + name: uploadedFile.name as string, + }); + let retries = 0; + const maxRetries = 60; + while (processed.state === "PROCESSING" && retries < maxRetries) { + await sleep(5000); + processed = await ai.files.get({ + name: uploadedFile.name as string, + }); + retries++; + } + + if (processed.state === "FAILED") { + console.error( + `[${new Date().toISOString()}] Failed to process file: ${relativePath}` + ); + continue; + } + + uploadedStoreFiles.push({ + uri: processed.uri as string, + mimeType: processed.mimeType as string, + name: processed.name as string, + }); + console.log( + `[${new Date().toISOString()}] Uploaded ${relativePath} - URI: ${processed.uri}` + ); + } catch (error) { + console.error( + `[${new Date().toISOString()}] Error uploading ${relativePath}:`, + error + ); + } + } + + storeInitialized = true; + console.log( + `[${new Date().toISOString()}] Initialized ${uploadedStoreFiles.length} files from ${dirName}` + ); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..b75ff61 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,29 @@ +// ---------- Types ---------- + +export interface FileInfo { + uri: string; + mimeType: string; + name: string; +} + +export type QueryKind = "docs" | "source"; + +export type TaskStatus = "queued" | "running" | "done" | "error"; + +export interface Task { + id: string; + kind: QueryKind; + query: string; + status: TaskStatus; + createdAt: number; + updatedAt: number; + etaSeconds: number; + result?: string; + error?: string; +} + +export interface SearchResult { + relativePath: string; + matchingLines: string[]; + fullContent?: string; +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..8b090ad --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,20 @@ +// ---------- Utility Functions ---------- + +export const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +export function timestamp(): string { + return new Date().toISOString(); +} + +export function log(message: string, ...args: unknown[]): void { + console.log(`[${timestamp()}] ${message}`, ...args); +} + +export function logError(message: string, ...args: unknown[]): void { + console.error(`[${timestamp()}] ${message}`, ...args); +} + +export function logWarn(message: string, ...args: unknown[]): void { + console.warn(`[${timestamp()}] ${message}`, ...args); +} From f785422cf1a21107d64b5c6aafe8106e5d0e3fda Mon Sep 17 00:00:00 2001 From: tiye Date: Thu, 18 Dec 2025 23:50:58 +0800 Subject: [PATCH 05/13] refactor to hybrid usage --- .gitignore | 3 + ARCHITECTURE.md | 453 +++++++++++++++++++++++++++++++----------------- Agents.md | 247 +++++++++++++++++--------- README.md | 182 +++++++------------ USAGE.md | 257 +++++++++++++++++++-------- src/cache.ts | 115 ++++++++++++ src/query.ts | 162 +++++++++++++++-- src/search.ts | 300 ++++++++++++++++++++++++-------- src/server.ts | 83 +++++++-- src/store.ts | 74 +++++++- src/types.ts | 37 +++- 11 files changed, 1383 insertions(+), 530 deletions(-) create mode 100644 src/cache.ts diff --git a/.gitignore b/.gitignore index 53afb16..815b393 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ dist/ .DS_Store source/ + +# Gemini file cache +.gemini-cache.json diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e507e1f..5cde355 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,176 +1,319 @@ -# Architecture - -## Overview - -Moonverse.ts is a Model Context Protocol (MCP) server that provides AI-powered search over MoonBit documentation and source code using Google's Gemini Interactions API. - -## Components - -### 1. MCP Server (`MoonverseMCPServer`) - -The main server class that implements the MCP protocol using the `@modelcontextprotocol/sdk`. - -**Key Responsibilities:** -- Manage server lifecycle -- Handle tool registration and requests (two tools: docs and source) -- Coordinate between file management and query processing -- Maintain separate caches for documentation and source files - -### 2. File Management - -Uses Google's `GoogleAIFileManager` to upload and manage both documentation and source code files. +# 架构设计 -**Flow:** -1. Scan `store/` directory for documentation files (`.txt`, `.md`) -2. Scan `source/` directory recursively for source files (`.mbt`, `.md`, `.json`, `.txt`) -3. Upload files lazily on first query to Gemini's Interactions API -4. Store file metadata in separate lists for documentation and source code -5. Cache uploaded files in memory to avoid re-uploads +## 概述 -### 3. Query Processing +Moonverse.ts 是一个基于 Google Gemini API 的 MoonBit 文档和源码搜索服务,提供 HTTP JSON API。 -Handles two types of queries using Gemini's Interactions API with uploaded files as context. +## 系统架构 -**Documentation Queries (`query_moonbit_docs`):** -- Uses files from `store/` directory -- Focuses on answering based on official documentation -- Provides user-facing feature information +``` +┌─────────────────────────────────────────────────────────────┐ +│ HTTP Client │ +└─────────────────────────┬───────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ HTTP Server (server.ts) │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ POST /query │ │GET /query/:id│ │ GET /healthz │ │ +│ └──────┬──────┘ └──────┬──────┘ └─────────────────────┘ │ +└─────────┼────────────────┼──────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Task Queue (in-memory) │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Task { id, kind, query, status, progress, result } │ │ +│ └──────────────────────────────────────────────────────┘ │ +└─────────────────────────┬───────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Query Router (query.ts) │ +│ ┌─────────┐ ┌──────────┐ ┌────────────────────────────┐ │ +│ │ docs │ │ source │ │ hybrid │ │ +│ └────┬────┘ └────┬─────┘ └─────────────┬──────────────┘ │ +└───────┼────────────┼──────────────────────┼─────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌───────────┐ ┌─────────────┐ ┌────────────────────────────┐ +│store.ts │ │ search.ts │ │ docs + source combined │ +│ │ │ │ │ │ +│ Gemini │ │ Local Search│ │ ┌────────┐ ┌──────────┐ │ +│ Files API │ │ + LLM │ │ │search │ │ store │ │ +└─────┬─────┘ └──────┬──────┘ │ └────┬───┘ └────┬─────┘ │ + │ │ │ │ │ │ + └──────────────┴─────────┴───────┴───────────┘ │ + │ │ + ▼ │ +┌─────────────────────────────────────────────────────────────┐ +│ Gemini API (genai.ts) │ +│ ┌──────────────────┐ ┌─────────────────────────────────┐ │ +│ │ models.generate │ │ files.upload │ │ +│ │ Content │ │ files.get │ │ +│ └──────────────────┘ └─────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## 模块职责 + +### 核心模块 + +| 模块 | 文件 | 职责 | +|------|------|------| +| **入口** | `index.ts` | 初始化流程:代理设置 → AI 客户端 → 服务器启动 → 文件上传 | +| **类型** | `types.ts` | TypeScript 类型定义 | +| **服务器** | `server.ts` | HTTP 路由、任务管理、轮询响应 | +| **查询** | `query.ts` | 查询路由、进度更新、hybrid 逻辑 | -**Source Code Queries (`query_moonbit_source`):** -- Uses files from `source/` directory -- Focuses on implementation details and API insights -- Provides information about latest features and internal code +### 数据模块 -**Flow:** -1. Receive query via MCP tool call (`query_moonbit_docs` or `query_moonbit_source`) -2. Initialize appropriate file set (documentation or source) if not already done -3. Create context-aware prompt -4. Send to Gemini Interactions API with file references -5. Return AI-generated response +| 模块 | 文件 | 职责 | +|------|------|------| +| **文件操作** | `files.ts` | 文件读取、遍历、MIME 类型判断 | +| **缓存** | `cache.ts` | Gemini 文件缓存(`.gemini-cache.json`) | +| **Store** | `store.ts` | 文档文件上传到 Gemini | +| **搜索** | `search.ts` | 本地代码搜索 + LLM 结果组织 | -## Data Flow +### 基础模块 + +| 模块 | 文件 | 职责 | +|------|------|------| +| **AI 客户端** | `genai.ts` | Gemini 客户端配置、代理设置、URL 重写 | +| **工具函数** | `utils.ts` | sleep、日志辅助函数 | + +## 数据流 + +### Docs 查询流程 ``` -User (Claude Desktop) - ↓ -MCP Protocol - ↓ -Tool Selection (docs or source) - ↓ -MoonverseMCPServer.handleDocQuery() or handleSourceQuery() - ↓ -Initialize files (first time only) - ↓ -Gemini Interactions API (with file context) - ↓ -AI Response - ↓ -MCP Response - ↓ -User sees answer +1. POST /query {type: "docs", query: "..."} + │ + ▼ +2. 创建 Task,状态 = "queued" + │ + ▼ +3. 异步执行 runDocsQuery() + │ + ├── 获取已上传的 store 文件 + │ + ├── 构建 Gemini 请求(文件 URI + prompt) + │ + ├── 调用 ai.models.generateContent() + │ + └── 更新 Task 状态 = "done",保存结果 + │ + ▼ +4. GET /query/:id → 返回结果 ``` -## File Structure +### Source 查询流程 ``` -moonverse.ts/ -├── src/ -│ └── index.ts # Main server implementation -├── store/ # Documentation files -│ ├── Agents.mbt.md -│ ├── ide.md -│ └── llms.txt -├── source/ # MoonBit core repository (optional) -│ └── (MoonBit source files) -├── dist/ # Compiled JavaScript -│ ├── index.js -│ └── index.d.ts -├── README.md # Project overview -├── USAGE.md # Usage guide -├── ARCHITECTURE.md # This file -├── package.json # Dependencies and scripts -├── tsconfig.json # TypeScript configuration -└── claude_desktop_config.example.json # Example config +1. POST /query {type: "source", query: ".."} + │ + ▼ +2. 创建 Task,状态 = "queued" + │ + ▼ +3. 异步执行 searchSourceCode() + │ + ├── 预处理查询(符号识别) + │ + ├── 搜索 source/core/*.mbt 文件 + │ + ├── 收集匹配行 + 上下文 + │ + ├── 调用 LLM 组织结果 + │ + └── 更新 Task 状态 = "done" + │ + ▼ +4. GET /query/:id → 返回结果 ``` -## Key Technologies +### Hybrid 查询流程 -### MCP (Model Context Protocol) -- Protocol for AI assistants to access external tools -- Uses stdio for communication -- Supports tools, resources, and prompts -- This project implements two tools for different query types +``` +1. POST /query {type: "hybrid", query: "..."} + │ + ▼ +2. 创建 Task,状态 = "queued" + │ + ▼ +3. 异步执行 runHybridQuery() + │ + ├── 搜索源代码(searchSourceCodeRaw) + │ └── 更新进度:searching-code + │ + ├── 获取 store 文件 + │ └── 更新进度:searching-docs + │ + ├── 整合代码和文档 + │ └── 更新进度:analyzing + │ + ├── 调用 Gemini(文件 + 代码 + prompt) + │ └── 更新进度:generating + │ + └── 更新 Task 状态 = "done" + │ + ▼ +4. GET /query/:id → 返回结果(含进度信息) +``` + +## 文件缓存机制 -### Gemini Interactions API -- Google's AI platform with advanced file handling -- File upload and management -- Context-aware generation with multiple files -- Supports multiple file types including source code - -### TypeScript -- Type-safe development -- ES2020 modules -- Strict mode for better error checking - -## Design Decisions - -### 1. Lazy Initialization -Files are only uploaded on the first query to avoid startup delays and API costs. Separate initialization for docs and source. - -### 2. Dual Query System -Two separate tools allow users to explicitly choose between documentation queries (user-facing) and source code queries (implementation details). - -### 3. Recursive File Discovery -The source directory is scanned recursively to handle complex repository structures with nested directories. - -### 4. In-Memory File Cache -Uploaded file metadata is stored in memory in separate lists (docs and source) to avoid re-uploading on subsequent queries within the same session. - -### 5. Extended File Type Support -Beyond just `.txt` and `.md`, the system now supports `.mbt` (MoonBit source) and `.json` files for comprehensive source code analysis. - -### 3. Error Handling -All errors are caught and returned as MCP responses with `isError: true`, ensuring the MCP client can handle failures gracefully. - -### 4. ES Modules -Uses ES modules (`type: "module"`) for compatibility with the MCP SDK and modern Node.js practices. - -### 5. Stdio Transport -Uses stdio for MCP communication, making it compatible with Claude Desktop and other MCP clients. - -## Security Considerations - -1. **API Key Protection**: API key is read from environment variables, never hardcoded -2. **File System Access**: Limited to the `store/` directory -3. **Input Validation**: All tool inputs are validated using Zod schemas -4. **Error Messages**: Generic error messages to avoid leaking sensitive information - -## Scalability - -### Current Limitations -- Files are uploaded on each server start -- No caching between sessions -- Single-threaded Node.js process - -### Future Improvements -- Persistent file store to avoid re-uploads -- Support for file search stores API (when available in SDK) -- Batch file uploads -- Caching layer for common queries -- Support for incremental document updates - -## Testing +``` +┌─────────────────────────────────────────────────────────────┐ +│ 服务器启动 │ +└─────────────────────────┬───────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 读取 .gemini-cache.json │ +└─────────────────────────┬───────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 清理过期条目(47h TTL) │ +└─────────────────────────┬───────────────────────────────────┘ + │ + ┌───────────┴───────────┐ + ▼ ▼ +┌─────────────────────┐ ┌─────────────────────────────────┐ +│ 缓存命中 │ │ 缓存未命中 / 哈希变化 │ +│ ↓ │ │ ↓ │ +│ 验证 Gemini 文件 │ │ 上传文件到 Gemini │ +│ ↓ │ │ ↓ │ +│ 文件存在? │ │ 更新缓存条目 │ +│ ↓ │ │ │ +│ 使用缓存 URI │ │ │ +└─────────────────────┘ └─────────────────────────────────┘ + │ │ + └───────────┬───────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 保存 .gemini-cache.json │ +└─────────────────────────────────────────────────────────────┘ +``` -Currently, the project requires manual testing with a valid Gemini API key. Future improvements could include: -- Unit tests for file processing logic -- Integration tests with mock Gemini API -- End-to-end tests with MCP client simulator +## 任务状态机 -## Monitoring +``` + ┌──────────┐ + │ queued │ + └────┬─────┘ + │ processTask() + ▼ + ┌──────────┐ + ┌────│ running │────┐ + │ └────┬─────┘ │ + │ │ │ + │ 成功 │ 失败 │ + ▼ ▼ ▼ +┌──────┐ ┌──────┐ ┌───────┐ +│ done │ │ done │ │ error │ +└──────┘ └──────┘ └───────┘ +``` -The server logs important events to stderr: -- File upload progress -- Query processing -- Errors and exceptions +### 运行中的进度阶段 -These logs can be captured by the MCP host (e.g., Claude Desktop) for debugging. +``` +initializing → searching-code → searching-docs → analyzing → generating → complete +``` + +## 轮询响应结构 + +### 运行中 + +```json +{ + "status": "running", + "phase": "generating", + "message": "正在使用 AI 分析和整理搜索结果...", + "pollCount": 2, + "elapsedSeconds": 8.5, + "codeResultsCount": 15, + "docsResultsCount": 3, + "partialContent": "正在处理 15 个匹配文件的内容...", + "nextPollSec": 4, + "etaSeconds": 5 +} +``` + +### 完成 + +```json +{ + "status": "done", + "content": "...", + "stats": { + "pollCount": 3, + "totalTimeSeconds": 12.5 + } +} +``` + +## 代理配置 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 环境变量检测 │ +│ │ +│ HTTP_PROXY / HTTPS_PROXY → 配置 undici ProxyAgent │ +│ GEMINI_BASE_URL → API 请求 URL 重写 │ +│ GEMINI_UPLOAD_BASE_URL → 文件上传 URL 重写 │ +└─────────────────────────────────────────────────────────────┘ +``` + +## 特殊查询处理 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 查询预处理 │ +└─────────────────────────┬───────────────────────────────────┘ + │ + ┌───────────┴───────────┐ + ▼ ▼ +┌─────────────────────┐ ┌─────────────────────────────────┐ +│ 符号查询 │ │ 普通查询 │ +│ (.. / ... / |>) │ │ (长度 > 3) │ +│ ↓ │ │ ↓ │ +│ 字面搜索 │ │ 分词 │ +│ + 语义词扩展 │ │ ↓ │ +│ │ │ 语义搜索 │ +└─────────────────────┘ └─────────────────────────────────┘ +``` + +## 依赖关系 + +``` +index.ts + ├── genai.ts (setupProxy, createAIClient, testConnection) + ├── server.ts (createServer, startServer) + └── store.ts (initializeStore) + +server.ts + ├── types.ts (Task, TaskProgress) + └── query.ts (runQuery, updateTaskProgress) + +query.ts + ├── types.ts (QueryKind, Task, TaskPhase) + ├── store.ts (getUploadedStoreFiles) + └── search.ts (searchSourceCode, searchSourceCodeRaw) + +search.ts + ├── types.ts (SearchResult) + ├── files.ts (getAllFiles, fileExists, readFileContent) + └── query.ts (updateTaskProgress) [循环依赖,通过函数参数解决] + +store.ts + ├── types.ts (FileInfo, CacheData) + ├── files.ts (getAllFiles, getMimeType, fileExists, readFileBuffer) + ├── utils.ts (sleep) + └── cache.ts (loadCache, saveCache, ...) + +cache.ts + └── types.ts (CacheData, CachedFileInfo) +``` diff --git a/Agents.md b/Agents.md index 158f37e..d0f7dbf 100644 --- a/Agents.md +++ b/Agents.md @@ -6,6 +6,7 @@ MoonBit 文档搜索服务,基于 Google Gemini API,提供 HTTP JSON API。 - **Docs 查询**:上传 Markdown/txt 文件到 Gemini,使用 AI 回答文档问题 - **Source 查询**:本地搜索源代码 + LLM 组织结果,生成高质量文档 +- **Hybrid 查询**:结合文档和代码示例,提供综合回答 ## 常用命令 @@ -25,37 +26,41 @@ yarn dev yarn start ``` -### 代理配置 - -项目使用 `undici` 的 ProxyAgent 配置代理(默认 `localhost:7890`): +### 环境变量 ```bash -# 设置代理环境变量(可选) +# 必需:Gemini API Key +export GEMINI_API_KEY=your-api-key-here + +# 可选:代理配置 export HTTP_PROXY=http://localhost:7890 export HTTPS_PROXY=http://localhost:7890 -# 设置 Gemini API Key -export GEMINI_API_KEY=your-api-key-here +# 可选:自定义 Gemini 端点 +export GEMINI_BASE_URL=https://your-proxy.example.com +export GEMINI_UPLOAD_BASE_URL=https://your-proxy.example.com + +# 可选:服务器端口(默认 8080) +export PORT=8080 ``` -### 测试 +### 测试命令 ```bash -# 测试文件上传 -npx tsx test_file_upload.mts - -# 测试客户端(完整流程) -node test_client_simple.mts +# 测试文档查询(中文回答) +curl -X POST http://localhost:8080/query \ + -H "Content-Type: application/json" \ + -d '{"type": "docs", "query": "MoonBit 如何声明可变变量?"}' -# 单独测试文档查询 +# 测试源代码查询 curl -X POST http://localhost:8080/query \ -H "Content-Type: application/json" \ - -d '{"type": "docs", "query": "How to declare a mutable variable in MoonBit?"}' + -d '{"type": "source", "query": "Array"}' -# 单独测试源代码查询 +# 测试混合查询(推荐) curl -X POST http://localhost:8080/query \ -H "Content-Type: application/json" \ - -d '{"type": "source", "query": "mutable variable"}' + -d '{"type": "hybrid", "query": "MoonBit 当中 .. 两个点有哪些用法?"}' # 轮询任务状态 curl http://localhost:8080/query/ @@ -68,25 +73,19 @@ curl http://localhost:8080/healthz ```bash # 启动服务器(前台) -npx tsx src/index.mts +npx tsx src/index.ts # 启动服务器(后台 + 日志) -npx tsx src/index.mts > /tmp/moonverse.log 2>&1 & +npx tsx src/index.ts > /tmp/moonverse.log 2>&1 & # 查看日志 tail -f /tmp/moonverse.log -# 查看最近日志 -tail -50 /tmp/moonverse.log - # 停止服务器 -pkill -f "tsx src/index.mts" +pkill -f "tsx src/index" # 查看端口占用 lsof -i :8080 - -# 杀死占用端口的进程 -lsof -ti :8080 | xargs kill -9 ``` ### 目录结构 @@ -94,16 +93,26 @@ lsof -ti :8080 | xargs kill -9 ``` moonverse.ts/ ├── src/ -│ └── index.mts # 主服务器代码 -├── store/ # Markdown 文档(会上传到 Gemini) +│ ├── index.ts # 主入口 +│ ├── types.ts # 类型定义 +│ ├── utils.ts # 工具函数 +│ ├── files.ts # 文件系统操作 +│ ├── genai.ts # AI 客户端配置 +│ ├── cache.ts # 文件缓存管理 +│ ├── store.ts # Store 文件上传 +│ ├── search.ts # 源码搜索 + LLM +│ ├── query.ts # 查询执行逻辑 +│ └── server.ts # HTTP 服务器 +├── store/ # Markdown 文档(上传到 Gemini) │ ├── Agents.mbt.md │ ├── ide.md │ └── llms.txt -├── source/ # MoonBit 源代码(本地搜索) -│ └── core/ # MoonBit core 库源码 -├── test_file_upload.mts # 文件上传测试 -├── test_client.mts # 客户端测试(原版) -└── test_client_simple.mts # 客户端测试(简化版) +├── source/ # MoonBit 源代码(本地搜索) +│ └── core/ # MoonBit core 库源码 +├── tests/ # 测试文件(已 gitignore) +├── .gemini-cache.json # Gemini 文件缓存(已 gitignore) +├── package.json +└── tsconfig.json ``` ## API 接口 @@ -116,18 +125,23 @@ moonverse.ts/ ```json { - "type": "docs" | "source", - "query": "your question here" + "type": "docs" | "source" | "hybrid", + "query": "你的问题" } ``` +| 类型 | 说明 | +|------|------| +| `docs` | 仅使用上传的文档回答 | +| `source` | 本地搜索源代码 + LLM 组织 | +| `hybrid` | 结合文档和代码,综合回答(推荐) | + **响应:** ```json { "id": "uuid", - "status": "queued", - "message": "Query queued" + "nextPollSec": 3 } ``` @@ -140,18 +154,39 @@ moonverse.ts/ ```json { "status": "running", - "etaSeconds": 10, - "nextPollSec": 2, - "message": "Processing in background. Please poll again." + "phase": "searching-code", + "message": "正在搜索 MoonBit 核心库... (已找到 5 个 SDK 示例)", + "pollCount": 1, + "elapsedSeconds": 5.2, + "codeResultsCount": 15, + "docsResultsCount": 3, + "partialContent": "匹配文件: array/array.mbt, buffer/buffer.mbt 等", + "nextPollSec": 4, + "etaSeconds": 10 } ``` +**任务阶段(phase):** + +| 阶段 | 说明 | +|------|------| +| `initializing` | 任务初始化中 | +| `searching-code` | 正在搜索源代码 | +| `searching-docs` | 正在准备文档 | +| `analyzing` | 正在分析和整合 | +| `generating` | 正在生成回答 | +| `complete` | 完成 | + **响应(完成):** ```json { "status": "done", - "content": "AI response here..." + "content": "AI 生成的中文回答...", + "stats": { + "pollCount": 3, + "totalTimeSeconds": 12.5 + } } ``` @@ -160,7 +195,11 @@ moonverse.ts/ ```json { "status": "error", - "error": "error message" + "error": "错误信息", + "stats": { + "pollCount": 2, + "totalTimeSeconds": 5.0 + } } ``` @@ -168,8 +207,6 @@ moonverse.ts/ 健康检查 -**响应:** - ```json { "ok": true @@ -181,56 +218,102 @@ moonverse.ts/ - **运行时**: Node.js 20+ - **语言**: TypeScript (ES Modules) - **AI SDK**: `@google/genai` v1.34.0 -- **HTTP 代理**: `undici` ProxyAgent +- **HTTP 代理**: `undici` ProxyAgent(可选) - **验证**: `zod` ## 工作流程 ### Docs 查询流程 -1. 服务器启动时,自动上传 `store/` 目录的 Markdown 文件到 Gemini Files API -2. 收到 docs 查询请求,创建异步任务 -3. 使用 `ai.models.generateContent` 调用 Gemini API -4. 将上传的文件 URI 和用户问题一起发送 -5. 返回 AI 生成的答案 +1. 服务器启动时,检查本地缓存 `.gemini-cache.json` +2. 对比文件哈希,仅上传新增/修改的文件到 Gemini +3. 收到 docs 查询请求,创建异步任务 +4. 使用上传的文件 URI 和用户问题调用 Gemini +5. 返回中文 AI 回答 ### Source 查询流程 1. 收到 source 查询请求,创建异步任务 -2. 使用 `searchLocalCode()` 在 `source/core/` 目录搜索 -3. 递归遍历 `.mbt` 文件 -4. 使用简单的文本匹配查找包含查询关键词的行 -5. 返回匹配的文件路径和代码行 +2. 预处理查询(支持符号搜索如 `..`) +3. 在 `source/core/` 目录递归搜索 `.mbt` 文件 +4. 收集匹配的代码行和上下文 +5. 使用 LLM 组织和解释搜索结果 +6. 返回中文格式化回答 + +### Hybrid 查询流程(推荐) + +1. 收到 hybrid 查询请求,创建异步任务 +2. 并行搜索源代码和准备文档 +3. 将代码示例和文档一起发送给 Gemini +4. AI 综合两种来源生成全面回答 +5. 返回带代码示例的中文回答 + +## 文件缓存机制 + +服务器使用 `.gemini-cache.json` 缓存已上传的文件: + +```json +{ + "version": 1, + "files": [ + { + "uri": "https://generativelanguage.googleapis.com/v1beta/files/xxx", + "mimeType": "text/markdown", + "name": "files/xxx", + "displayName": "Agents.mbt.md", + "fileHash": "md5-hash", + "uploadedAt": 1702900000000, + "expiresAt": 1703069200000 + } + ] +} +``` + +**特性:** +- 文件哈希校验:内容变化时自动重新上传 +- 47 小时有效期(Gemini 文件 48 小时过期) +- 启动时验证文件是否仍在 Gemini 存在 +- 自动清理过期缓存条目 + +## 特殊搜索支持 + +支持搜索特殊符号和短查询: + +| 查询 | 搜索策略 | +|------|---------| +| `..` | 字面搜索 + 语义词(range, cascade, update) | +| `...` | 字面搜索 + 语义词(spread, rest) | +| `\|>` | 字面搜索 + 语义词(pipe, pipeline) | +| 普通查询 | 分词后语义搜索 | ## 注意事项 -1. **代理配置**:所有网络请求(包括文件上传到 Google Storage)都通过 `localhost:7890` 代理 -2. **文件上传**:只上传 Markdown 文件(`.md`, `.txt`),源代码不上传 +1. **代理配置**:通过环境变量 `HTTP_PROXY`/`HTTPS_PROXY` 配置(可选) +2. **文件上传**:只上传 `.md` 和 `.txt` 文件,源代码不上传 3. **异步处理**:查询采用任务队列模式,客户端需要轮询结果 -4. **错误处理**:所有错误会记录到任务状态中,客户端通过轮询获取 -5. **日志**:使用 ISO 时间戳的详细日志,便于调试 +4. **中文回答**:所有 AI 回答默认使用中文 +5. **缓存文件**:`.gemini-cache.json` 已加入 `.gitignore` ## 故障排查 ### 文件上传失败 ```bash -# 检查代理是否运行 +# 检查代理是否运行(如果配置了代理) curl -x http://localhost:7890 https://www.google.com -# 测试文件上传 -npx tsx test_file_upload.mts +# 检查 API Key +echo $GEMINI_API_KEY ``` -### API 调用失败 +### 缓存问题 ```bash -# 检查 API Key -echo $GEMINI_API_KEY +# 删除缓存,强制重新上传 +rm .gemini-cache.json -# 测试代理配置的 API 访问 -curl -x http://localhost:7890 \ - "https://generativelanguage.googleapis.com/v1beta/models?key=$GEMINI_API_KEY" +# 重启服务器 +pkill -f "tsx src/index" && npx tsx src/index.ts ``` ### 服务器无响应 @@ -243,21 +326,21 @@ ps aux | grep tsx lsof -i :8080 # 重启服务器 -pkill -f "tsx src/index.mts" -npx tsx src/index.mts > /tmp/moonverse.log 2>&1 & +pkill -f "tsx src/index" +npx tsx src/index.ts > /tmp/moonverse.log 2>&1 & ``` -## 性能优化建议 - -1. **文件缓存**:已上传的文件会缓存,避免重复上传 -2. **本地搜索**:源代码查询使用本地搜索,速度快 -3. **异步处理**:避免阻塞 HTTP 请求 -4. **日志轮转**:定期清理日志文件 - -## 未来改进 - -- [ ] 添加 Redis 缓存查询结果 -- [ ] 支持更多文件类型(PDF、DOCX) -- [ ] 实现更智能的代码搜索(AST 解析) -- [ ] 添加 WebSocket 支持实时返回结果 -- [ ] 集成向量数据库进行语义搜索 +## 模块说明 + +| 模块 | 职责 | +|------|------| +| `types.ts` | 类型定义(Task, FileInfo, CacheData 等) | +| `utils.ts` | 工具函数(sleep, log) | +| `files.ts` | 文件系统操作(读取、遍历、MIME 类型) | +| `genai.ts` | AI 客户端配置和代理设置 | +| `cache.ts` | Gemini 文件缓存管理 | +| `store.ts` | Store 文件上传到 Gemini | +| `search.ts` | 本地代码搜索 + LLM 组织 | +| `query.ts` | 查询执行和进度更新 | +| `server.ts` | HTTP 服务器和路由 | +| `index.ts` | 主入口,初始化流程 | diff --git a/README.md b/README.md index 7066a72..eddf75c 100644 --- a/README.md +++ b/README.md @@ -1,158 +1,106 @@ # moonverse.ts -MoonBit documentation and source code search powered by Gemini's Interactions API and MCP (Model Context Protocol). +MoonBit 文档和源代码搜索服务,基于 Google Gemini API。 -## Overview +## 功能特性 -This is a TypeScript command-line tool that implements an MCP Server. It reads text files from the `store/` directory (documentation) and `source/` directory (MoonBit core repository), uses Gemini's Interactions API to provide AI-powered search and question answering. +- 🔍 **三种查询模式**:文档查询、源码查询、混合查询 +- 📚 **文档搜索**:上传 Markdown 文件到 Gemini,AI 回答问题 +- 💻 **源码搜索**:本地搜索 MoonBit 源代码 + LLM 组织结果 +- 🔗 **混合查询**:结合文档和代码示例,提供综合回答 +- 💾 **文件缓存**:自动缓存已上传文件,避免重复上传 +- 🌐 **HTTP API**:异步任务队列,支持轮询获取进度 +- 🇨🇳 **中文回答**:所有 AI 回答默认使用中文 -## Features +## 快速开始 -- 🚀 MCP Server implementation for integration with Claude Desktop and other MCP clients -- 📚 Automatic document ingestion from `store/` directory -- 💻 Source code analysis from `source/` directory (MoonBit core repository) -- 🔍 AI-powered semantic search using Gemini's Interactions API -- 💬 Dual query modes: documentation and source code -- 📝 Support for text, markdown, JSON, and MoonBit source files +### 1. 安装依赖 -## Prerequisites - -- Node.js 18.0.0 or higher -- A Google AI (Gemini) API key -- MoonBit core repository (optional, for source code queries) - -## Installation - -1. Clone the repository: -```bash -git clone -cd moonverse.ts -``` - -2. Install dependencies: ```bash yarn install ``` -3. Build the project: -```bash -yarn build -``` - -## Configuration - -Set your Gemini API key as an environment variable: +### 2. 配置环境变量 ```bash export GEMINI_API_KEY='your-api-key-here' ``` -You can get a Gemini API key from [Google AI Studio](https://makersuite.google.com/app/apikey). - -## Usage - -### As an MCP Server - -Add to your Claude Desktop configuration (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS): - -```json -{ - "mcpServers": { - "moonverse": { - "command": "node", - "args": ["/path/to/moonverse.ts/dist/index.js"], - "env": { - "GEMINI_API_KEY": "your-api-key-here" - } - } - } -} -``` - -### Running Directly +### 3. 构建并运行 ```bash -# Development mode with tsx -yarn dev - -# Production mode +yarn build yarn start ``` -## Document Store - -Place your documentation files in the `store/` directory. Supported file types: -- `.txt` - Plain text files -- `.md` - Markdown files - -The project includes sample MoonBit documentation: -- `llms.txt` - General MoonBit language information -- `Agents.mbt.md` - MoonBit agent documentation -- `ide.md` - IDE integration information - -## Source Code Repository +### 4. 测试查询 -Place the MoonBit core repository in the `source/` directory for source code queries. Supported file types: -- `.mbt` - MoonBit source files -- `.md` - Markdown documentation -- `.json` - JSON configuration files -- `.txt` - Plain text files +```bash +# 混合查询(推荐) +curl -X POST http://localhost:8080/query \ + -H "Content-Type: application/json" \ + -d '{"type": "hybrid", "query": "MoonBit 当中 .. 两个点有哪些用法?"}' -This enables querying about: -- Latest API features -- Implementation details -- Pre-release functionality -- Internal code structure +# 轮询结果 +curl http://localhost:8080/query/ +``` -## MCP Tools +## API 接口 -The server provides the following tools: +| 端点 | 方法 | 说明 | +|------|------|------| +| `/query` | POST | 创建查询任务 | +| `/query/:id` | GET | 轮询任务状态 | +| `/healthz` | GET | 健康检查 | -### `query_moonbit_docs` +### 查询类型 -Query the MoonBit documentation using AI-powered semantic search. +| 类型 | 说明 | 适用场景 | +|------|------|---------| +| `docs` | 仅文档查询 | 查询官方文档内容 | +| `source` | 源码搜索 | 查找代码示例和实现 | +| `hybrid` | 混合查询 | 综合文档和代码回答(推荐) | -**Parameters:** -- `query` (string): The question to ask about the MoonBit documentation +## 项目结构 -**Example:** ``` -Use the query_moonbit_docs tool to ask: "What are the key features of MoonBit?" +moonverse.ts/ +├── src/ # 源代码 +│ ├── index.ts # 主入口 +│ ├── types.ts # 类型定义 +│ ├── genai.ts # AI 客户端 +│ ├── cache.ts # 文件缓存 +│ ├── store.ts # 文件上传 +│ ├── search.ts # 源码搜索 +│ ├── query.ts # 查询执行 +│ └── server.ts # HTTP 服务器 +├── store/ # 文档目录(上传到 Gemini) +├── source/ # 源代码目录(本地搜索) +└── .gemini-cache.json # 文件缓存 ``` -### `query_moonbit_source` +## 环境变量 -Query the MoonBit core source code repository to understand latest API features and implementations. +| 变量 | 必需 | 说明 | +|------|------|------| +| `GEMINI_API_KEY` | ✅ | Gemini API 密钥 | +| `HTTP_PROXY` | ❌ | HTTP 代理地址 | +| `HTTPS_PROXY` | ❌ | HTTPS 代理地址 | +| `GEMINI_BASE_URL` | ❌ | 自定义 Gemini API 端点 | +| `PORT` | ❌ | 服务器端口(默认 8080) | -**Parameters:** -- `query` (string): The question to ask about MoonBit source code or latest features +## 技术栈 -**Example:** -``` -Use the query_moonbit_source tool to ask: "How is the type inference system implemented?" -``` +- **运行时**: Node.js 20+ +- **语言**: TypeScript (ES Modules) +- **AI SDK**: `@google/genai` v1.34.0 +- **验证**: `zod` -## API References +## 文档 -This project uses: -- [Gemini Interactions API](https://ai.google.dev/gemini-api/docs/interactions) -- [Google AI JavaScript SDK](https://github.com/googleapis/js-genai) -- [Model Context Protocol SDK](https://github.com/modelcontextprotocol/sdk) - -## Development - -```bash -# Build the project -yarn build - -# Run in development mode -yarn dev -``` +- [开发指南](Agents.md) - 详细的开发文档和 API 说明 +- [架构设计](ARCHITECTURE.md) - 系统架构和模块说明 ## License ISC - -## Contributing - -Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/USAGE.md b/USAGE.md index df1eb60..67166c5 100644 --- a/USAGE.md +++ b/USAGE.md @@ -1,120 +1,233 @@ -# Usage Guide +# 使用指南 -## Quick Start +## 快速开始 + +### 1. 设置 API Key -1. **Set up your API key**: ```bash export GEMINI_API_KEY='your-api-key-here' ``` -2. **Build the project**: +可从 [Google AI Studio](https://makersuite.google.com/app/apikey) 获取 API Key。 + +### 2. 构建项目 + ```bash yarn install yarn build ``` -3. **Configure Claude Desktop**: - -Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or the equivalent on your OS: +### 3. 启动服务器 -```json -{ - "mcpServers": { - "moonverse": { - "command": "node", - "args": ["/absolute/path/to/moonverse.ts/dist/index.js"], - "env": { - "GEMINI_API_KEY": "your-api-key-here" - } - } - } -} +```bash +yarn start ``` -4. **Restart Claude Desktop** +服务器默认运行在 `http://localhost:8080`。 -## Using the Tool +## 查询示例 -Once configured, you can use both tools in Claude Desktop: +### 混合查询(推荐) -### Documentation Queries +结合文档和代码示例,提供最全面的回答: -**Basic questions:** -``` -Use query_moonbit_docs to ask: "What is MoonBit?" +```bash +# 查询语法 +curl -X POST http://localhost:8080/query \ + -H "Content-Type: application/json" \ + -d '{"type": "hybrid", "query": "MoonBit 当中 .. 两个点有哪些用法?"}' + +# 查询数据结构 +curl -X POST http://localhost:8080/query \ + -H "Content-Type: application/json" \ + -d '{"type": "hybrid", "query": "如何在 MoonBit 中使用 Array?"}' + +# 查询特定功能 +curl -X POST http://localhost:8080/query \ + -H "Content-Type: application/json" \ + -d '{"type": "hybrid", "query": "MoonBit 的模式匹配怎么用?"}' ``` -**Technical questions:** -``` -Use query_moonbit_docs to ask: "How do I use the moon ide command for code navigation?" -``` +### 文档查询 + +仅基于上传的文档回答: -**Feature inquiries:** +```bash +curl -X POST http://localhost:8080/query \ + -H "Content-Type: application/json" \ + -d '{"type": "docs", "query": "MoonBit 如何声明可变变量?"}' ``` -Use query_moonbit_docs to ask: "What are the key features of MoonBit's type system?" + +### 源码查询 + +搜索 MoonBit 源代码并用 AI 组织结果: + +```bash +curl -X POST http://localhost:8080/query \ + -H "Content-Type: application/json" \ + -d '{"type": "source", "query": "Buffer"}' ``` -### Source Code Queries +### 轮询结果 -**API questions:** +查询是异步的,需要轮询获取结果: + +```bash +# 创建查询 +RESPONSE=$(curl -s -X POST http://localhost:8080/query \ + -H "Content-Type: application/json" \ + -d '{"type": "hybrid", "query": "什么是 MoonBit?"}') + +# 提取任务 ID +TASK_ID=$(echo $RESPONSE | jq -r '.id') +echo "Task ID: $TASK_ID" + +# 轮询结果 +while true; do + RESULT=$(curl -s "http://localhost:8080/query/$TASK_ID") + STATUS=$(echo $RESULT | jq -r '.status') + + if [ "$STATUS" = "done" ]; then + echo $RESULT | jq -r '.content' + break + elif [ "$STATUS" = "error" ]; then + echo "Error: $(echo $RESULT | jq -r '.error')" + break + else + echo "Status: $STATUS - $(echo $RESULT | jq -r '.message')" + sleep $(echo $RESULT | jq -r '.nextPollSec') + fi +done ``` -Use query_moonbit_source to ask: "What are the latest API additions in the core library?" + +## 添加文档 + +### 添加新文档 + +将 `.md` 或 `.txt` 文件放入 `store/` 目录: + +```bash +# 添加新文档 +cp my-doc.md store/ + +# 重启服务器 +pkill -f "tsx src/index" && yarn start ``` -**Implementation questions:** +### 添加源代码 + +将 MoonBit 源代码放入 `source/` 目录: + +```bash +# 克隆 MoonBit core +git clone https://github.com/moonbitlang/core.git source/core ``` -Use query_moonbit_source to ask: "How is pattern matching implemented?" + +## 环境变量 + +| 变量 | 说明 | 示例 | +|------|------|------| +| `GEMINI_API_KEY` | Gemini API 密钥(必需) | `AIza...` | +| `HTTP_PROXY` | HTTP 代理地址 | `http://localhost:7890` | +| `HTTPS_PROXY` | HTTPS 代理地址 | `http://localhost:7890` | +| `GEMINI_BASE_URL` | 自定义 API 端点 | `https://proxy.example.com` | +| `PORT` | 服务器端口 | `8080` | + +## 响应格式 + +### 运行中 + +```json +{ + "status": "running", + "phase": "generating", + "message": "正在使用 AI 分析和整理搜索结果...", + "pollCount": 1, + "elapsedSeconds": 5.2, + "codeResultsCount": 15, + "partialContent": "匹配文件: array/array.mbt, buffer/buffer.mbt 等", + "nextPollSec": 4 +} ``` -**Pre-release features:** +### 完成 + +```json +{ + "status": "done", + "content": "在 MoonBit 中,`..` 运算符有以下几种用法...", + "stats": { + "pollCount": 3, + "totalTimeSeconds": 12.5 + } +} ``` -Use query_moonbit_source to ask: "What new features are in development?" + +### 错误 + +```json +{ + "status": "error", + "error": "API quota exceeded", + "stats": { + "pollCount": 1, + "totalTimeSeconds": 2.0 + } +} ``` -## Adding More Documents +## 特殊查询 -To add more documentation to the search: +支持搜索特殊符号: -1. Place `.txt` or `.md` files in the `store/` directory for documentation -2. Place MoonBit source files (`.mbt`, `.md`, `.json`) in the `source/` directory for source code queries -3. Restart the MCP server (restart Claude Desktop) -4. The new files will be automatically uploaded and included in searches +| 查询 | 说明 | +|------|------| +| `..` | 范围操作符、记录更新、方法链 | +| `...` | 展开操作符 | +| `\|>` | 管道操作符 | +| `::` | 方法访问 | -## Supported File Types +示例: -**Documentation (store/ directory):** -- `.txt` - Plain text files -- `.md` - Markdown files +```bash +curl -X POST http://localhost:8080/query \ + -H "Content-Type: application/json" \ + -d '{"type": "hybrid", "query": ".."}' +``` -**Source Code (source/ directory):** -- `.mbt` - MoonBit source files -- `.md` - Markdown documentation -- `.json` - JSON configuration -- `.txt` - Plain text files +## 故障排查 -## How It Works +### 服务器无法启动 -1. On startup, the server prepares to read from both `store/` (documentation) and `source/` (code) directories -2. When you query docs, files from `store/` are uploaded to Gemini's Interactions API -3. When you query source, files from `source/` are uploaded to Gemini's Interactions API -4. Files are uploaded lazily on first query and cached in memory -5. The AI provides context-aware answers based on the uploaded files +```bash +# 检查端口占用 +lsof -i :8080 -## Troubleshooting +# 杀死占用进程 +lsof -ti :8080 | xargs kill -9 +``` -### "GEMINI_API_KEY environment variable is not set" -Make sure you've set the `GEMINI_API_KEY` in your environment or in the Claude Desktop config. +### API 调用失败 -### "No text or markdown files found in store directory" -Ensure you have `.txt` or `.md` files in the `store/` directory. +```bash +# 检查 API Key +echo $GEMINI_API_KEY -### Files not updating -Restart Claude Desktop to reload the MCP server and re-upload files. +# 测试 API 连接 +curl "https://generativelanguage.googleapis.com/v1beta/models?key=$GEMINI_API_KEY" +``` -## API Rate Limits +### 文件上传失败 -Be aware of Gemini API rate limits. The free tier has: -- 60 requests per minute -- 1,500 requests per day +```bash +# 删除缓存重试 +rm .gemini-cache.json +yarn start +``` -For production use, consider the paid tier for higher limits. +### 代理问题 + +```bash +# 测试代理 +curl -x http://localhost:7890 https://www.google.com +``` diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..e071ae8 --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,115 @@ +import fs from "fs"; +import path from "path"; +import crypto from "crypto"; +import { CacheData, CachedFileInfo } from "./types.js"; + +// ---------- Constants ---------- + +const CACHE_VERSION = 1; +const CACHE_FILENAME = ".gemini-cache.json"; + +// Gemini Files API: files expire after 48 hours +// Use 47 hours to be safe +const FILE_TTL_MS = 47 * 60 * 60 * 1000; + +// ---------- Cache Operations ---------- + +function getCachePath(rootDir: string): string { + return path.join(rootDir, CACHE_FILENAME); +} + +export function loadCache(rootDir: string): CacheData { + const cachePath = getCachePath(rootDir); + try { + if (fs.existsSync(cachePath)) { + const raw = fs.readFileSync(cachePath, "utf-8"); + const data = JSON.parse(raw) as CacheData; + if (data.version === CACHE_VERSION) { + return data; + } + console.log(`[Cache] Version mismatch, creating new cache`); + } + } catch (error) { + console.log(`[Cache] Failed to load cache, creating new one:`, error); + } + return { version: CACHE_VERSION, files: [] }; +} + +export function saveCache(rootDir: string, cache: CacheData): void { + const cachePath = getCachePath(rootDir); + try { + fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), "utf-8"); + console.log(`[Cache] Saved ${cache.files.length} entries to ${CACHE_FILENAME}`); + } catch (error) { + console.error(`[Cache] Failed to save cache:`, error); + } +} + +// ---------- File Hash ---------- + +export function computeFileHash(filePath: string): string { + const content = fs.readFileSync(filePath); + return crypto.createHash("md5").update(content).digest("hex"); +} + +// ---------- Cache Lookup ---------- + +export function findValidCachedFile( + cache: CacheData, + displayName: string, + fileHash: string +): CachedFileInfo | null { + const now = Date.now(); + const entry = cache.files.find( + (f) => f.displayName === displayName && f.fileHash === fileHash + ); + + if (entry && entry.expiresAt > now) { + return entry; + } + return null; +} + +// ---------- Cache Update ---------- + +export function addOrUpdateCacheEntry( + cache: CacheData, + entry: Omit +): CachedFileInfo { + const now = Date.now(); + const newEntry: CachedFileInfo = { + ...entry, + uploadedAt: now, + expiresAt: now + FILE_TTL_MS, + }; + + // Remove old entry with same displayName if exists + cache.files = cache.files.filter((f) => f.displayName !== entry.displayName); + cache.files.push(newEntry); + + return newEntry; +} + +// ---------- Cache Cleanup ---------- + +export function cleanExpiredEntries(cache: CacheData): number { + const now = Date.now(); + const before = cache.files.length; + cache.files = cache.files.filter((f) => f.expiresAt > now); + return before - cache.files.length; +} + +// ---------- Time Helpers ---------- + +export function formatTimeRemaining(expiresAt: number): string { + const remaining = expiresAt - Date.now(); + if (remaining <= 0) return "expired"; + + const hours = Math.floor(remaining / (60 * 60 * 1000)); + const minutes = Math.floor((remaining % (60 * 60 * 1000)) / (60 * 1000)); + + if (hours > 0) { + return `${hours}h ${minutes}m remaining`; + } + return `${minutes}m remaining`; +} diff --git a/src/query.ts b/src/query.ts index 84770ce..af19fbb 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1,7 +1,32 @@ import { GoogleGenAI, createPartFromUri } from "@google/genai"; -import { QueryKind } from "./types.js"; +import { QueryKind, Task, TaskProgress, TaskPhase } from "./types.js"; import { getUploadedStoreFiles } from "./store.js"; -import { searchSourceCode } from "./search.js"; +import { searchSourceCode, searchSourceCodeRaw } from "./search.js"; + +// ---------- Task Progress Update ---------- + +// Global task reference for progress updates +let currentTask: Task | null = null; + +export function setCurrentTask(task: Task) { + currentTask = task; +} + +export function updateTaskProgress( + phase: TaskPhase, + message: string, + extra?: Partial +) { + if (currentTask) { + currentTask.progress = { + phase, + message, + ...extra, + }; + currentTask.updatedAt = Date.now(); + console.log(`[${new Date().toISOString()}] Progress: [${phase}] ${message}`); + } +} // ---------- Query Execution ---------- @@ -9,8 +34,14 @@ export async function runQuery( ai: GoogleGenAI, rootDir: string, kind: QueryKind, - query: string + query: string, + task?: Task ): Promise { + // Set current task for progress updates + if (task) { + setCurrentTask(task); + } + console.log( `[${new Date().toISOString()}] Starting query processing. Kind: ${kind}, Query: "${query}"` ); @@ -20,18 +51,30 @@ export async function runQuery( return await searchSourceCode(ai, rootDir, query); } + // For hybrid queries, combine docs + code search + if (kind === "hybrid") { + return await runHybridQuery(ai, rootDir, query); + } + // For docs queries, use Gemini with uploaded Markdown files + return await runDocsQuery(ai, query); +} + +// ---------- Docs Query ---------- + +async function runDocsQuery(ai: GoogleGenAI, query: string): Promise { + updateTaskProgress("searching-docs", "正在准备文档文件..."); + const files = getUploadedStoreFiles(); if (files.length === 0) { - console.warn( - `[${new Date().toISOString()}] No files found for kind: ${kind}` - ); - return "No documentation files available. Please add Markdown files to the 'store' directory."; + console.warn(`[${new Date().toISOString()}] No files found for docs query`); + return "没有可用的文档文件。请在 'store' 目录中添加 Markdown 文件。"; } - console.log( - `[${new Date().toISOString()}] Preparing content for Gemini. File count: ${files.length}` - ); + updateTaskProgress("generating", `正在使用 ${files.length} 个文档文件查询 Gemini...`, { + docsResultsCount: files.length, + partialContent: `文档: ${files.map(f => f.name.split('/').pop()).join(', ')}`, + }); // Build parts array for generateContent const parts: any[] = []; @@ -39,10 +82,19 @@ export async function runQuery( parts.push(createPartFromUri(file.uri, file.mimeType)); } - const promptText = `Based on the MoonBit documentation files provided, please answer the following question:\n\n${query}\n\nPlease provide a detailed and accurate answer based only on the information in the documentation.`; + const promptText = `根据提供的 MoonBit 文档文件,请回答以下问题: + +${query} + +请用中文提供详细且准确的回答,仅基于文档中的信息。代码示例请使用 \`\`\`moonbit 代码块。`; parts.push({ text: promptText }); + updateTaskProgress("generating", "正在等待 Gemini 生成回答...", { + docsResultsCount: files.length, + partialContent: "正在分析文档内容并组织答案...", + }); + console.log(`[${new Date().toISOString()}] Sending request to Gemini...`); const response = await ai.models.generateContent({ model: "gemini-2.5-flash", @@ -50,5 +102,91 @@ export async function runQuery( }); console.log(`[${new Date().toISOString()}] Received response from Gemini.`); - return response.text || "No response generated"; + updateTaskProgress("complete", "回答生成完成"); + return response.text || "未生成回答"; +} + +// ---------- Hybrid Query (Docs + Code) ---------- + +async function runHybridQuery( + ai: GoogleGenAI, + rootDir: string, + query: string +): Promise { + updateTaskProgress("initializing", "正在分析查询并确定搜索策略..."); + + // Step 1: Search code first + updateTaskProgress("searching-code", "正在搜索源代码中的相关示例..."); + const codeResults = await searchSourceCodeRaw(rootDir, query); + + updateTaskProgress("searching-code", `找到 ${codeResults.totalMatches} 个代码匹配`, { + codeResultsCount: codeResults.totalMatches, + partialContent: codeResults.totalMatches > 0 + ? `匹配文件: ${codeResults.files.slice(0, 3).join(", ")}${codeResults.files.length > 3 ? " 等" : ""}` + : "未找到代码匹配", + }); + + // Step 2: Get docs files + updateTaskProgress("searching-docs", "正在准备文档上下文..."); + const files = getUploadedStoreFiles(); + + updateTaskProgress("analyzing", `正在结合 ${codeResults.totalMatches} 个代码示例和 ${files.length} 个文档文件...`, { + codeResultsCount: codeResults.totalMatches, + docsResultsCount: files.length, + partialContent: "正在整合代码和文档信息...", + }); + + // Step 3: Build comprehensive prompt with both code and docs + const parts: any[] = []; + + // Add documentation files + for (const file of files) { + parts.push(createPartFromUri(file.uri, file.mimeType)); + } + + // Build the prompt + let promptText = `你是一位 MoonBit 编程语言专家。用户正在询问: + +"${query}" + +`; + + if (codeResults.rawContent) { + promptText += `以下是在 MoonBit 核心库中找到的相关代码示例: + +${codeResults.rawContent} + +`; + } + + promptText += `请基于上面提供的文档文件和代码示例,用中文提供全面的回答: + +1. 清晰地解释概念/语法 +2. 展示代码库中的实际代码示例(如果找到) +3. 提供文档中的额外上下文 +4. 如果是关于特定语法(如操作符),请列出所有不同的用法/含义 + +请使用清晰的段落和 \`\`\`moonbit 代码块来格式化你的回答。`; + + parts.push({ text: promptText }); + + updateTaskProgress("generating", "正在使用 Gemini 生成综合回答...", { + codeResultsCount: codeResults.totalMatches, + docsResultsCount: files.length, + partialContent: "正在综合文档和代码示例生成答案...", + }); + + console.log(`[${new Date().toISOString()}] Sending hybrid request to Gemini...`); + const response = await ai.models.generateContent({ + model: "gemini-2.5-flash", + contents: parts, + }); + console.log(`[${new Date().toISOString()}] Received hybrid response from Gemini.`); + + updateTaskProgress("complete", "回答生成完成", { + codeResultsCount: codeResults.totalMatches, + docsResultsCount: files.length, + }); + + return response.text || "未生成回答"; } diff --git a/src/search.ts b/src/search.ts index e5297d8..2195824 100644 --- a/src/search.ts +++ b/src/search.ts @@ -2,6 +2,68 @@ import path from "path"; import { GoogleGenAI } from "@google/genai"; import { SearchResult } from "./types.js"; import { getAllFiles, fileExists, readFileContent } from "./files.js"; +import { updateTaskProgress } from "./query.js"; + +// ---------- Search Result Types ---------- + +export interface RawSearchResults { + totalMatches: number; + files: string[]; + rawContent: string; +} + +// ---------- Query Preprocessing ---------- + +/** + * Preprocess query to handle special characters and symbols + * Returns both literal patterns and semantic terms + */ +function preprocessQuery(query: string): { + literalPatterns: string[]; + semanticTerms: string[]; + isSymbolQuery: boolean; +} { + const trimmed = query.trim(); + + // Check if this is primarily a symbol/operator query + const symbolPatterns = [ + /^\.\.$/, // exactly ".." + /^\.\.\.$/, // exactly "..." + /^[+\-*\/%&|^~<>=!]+$/, // operators + /^::/, // method access + /^\|>/, // pipe operator + ]; + + const isSymbolQuery = symbolPatterns.some(p => p.test(trimmed)); + + const literalPatterns: string[] = []; + const semanticTerms: string[] = []; + + if (isSymbolQuery || trimmed.length <= 3) { + // For short/symbol queries, search literally + literalPatterns.push(trimmed); + + // Also add semantic context based on the symbol + if (trimmed === "..") { + semanticTerms.push("range", "cascade", "update", "method chain"); + } else if (trimmed === "...") { + semanticTerms.push("spread", "rest", "array spread"); + } else if (trimmed === "|>") { + semanticTerms.push("pipe", "pipeline"); + } + } else { + // Normal query: split into words but preserve quoted phrases + const words = trimmed.toLowerCase().split(/\s+/).filter(t => t.length > 0); + semanticTerms.push(...words); + + // Also keep the original as a literal pattern if it contains special chars + if (/[^\w\s]/.test(trimmed)) { + literalPatterns.push(trimmed); + } + } + + return { literalPatterns, semanticTerms, isSymbolQuery }; +} // ---------- Local Search ---------- @@ -9,6 +71,7 @@ export function searchInDirectory( baseDir: string, extensions: string[], queryTerms: string[], + literalPatterns: string[] = [], maxResults: number = 15 ): SearchResult[] { if (!fileExists(baseDir)) { @@ -26,38 +89,60 @@ export function searchInDirectory( const content = readFileContent(filePath); const contentLower = content.toLowerCase(); - // Check if any query term matches - const hasMatch = queryTerms.some((term) => contentLower.includes(term)); - if (hasMatch) { - const relativePath = path.relative(baseDir, filePath); - const lines = content.split("\n"); - - // Find matching lines with context - const matchingLines: string[] = []; - lines.forEach((line, idx) => { - const lineLower = line.toLowerCase(); - if (queryTerms.some((term) => lineLower.includes(term))) { - // Include surrounding context (2 lines before and after) - const start = Math.max(0, idx - 2); - const end = Math.min(lines.length, idx + 3); - for (let i = start; i < end; i++) { - const prefix = i === idx ? ">>> " : " "; - matchingLines.push(`${prefix}Line ${i + 1}: ${lines[i]}`); - } - matchingLines.push(""); // separator - } - }); + // Check for literal pattern matches first (exact string) + let hasLiteralMatch = false; + if (literalPatterns.length > 0) { + hasLiteralMatch = literalPatterns.some((pattern) => content.includes(pattern)); + } + + // Check for semantic term matches + const hasSemanticMatch = queryTerms.length > 0 && + queryTerms.some((term) => contentLower.includes(term)); + + if (!hasLiteralMatch && !hasSemanticMatch) continue; + + const relativePath = path.relative(baseDir, filePath); + const lines = content.split("\n"); + + // Find matching lines with context + const matchingLines: string[] = []; + const matchedLineIndices = new Set(); - if (matchingLines.length > 0) { - results.push({ - relativePath, - matchingLines: matchingLines.slice(0, 30), - fullContent: content.length < 5000 ? content : undefined, - }); + lines.forEach((line, idx) => { + // Check literal patterns (case-sensitive for symbols) + const hasLiteral = literalPatterns.some((pattern) => line.includes(pattern)); + // Check semantic terms (case-insensitive) + const lineLower = line.toLowerCase(); + const hasSemantic = queryTerms.some((term) => lineLower.includes(term)); + + if (hasLiteral || hasSemantic) { + matchedLineIndices.add(idx); + } + }); + + // Add context around matched lines + for (const idx of matchedLineIndices) { + const start = Math.max(0, idx - 2); + const end = Math.min(lines.length, idx + 3); + for (let i = start; i < end; i++) { + const prefix = i === idx ? ">>> " : " "; + const lineContent = `${prefix}Line ${i + 1}: ${lines[i]}`; + if (!matchingLines.includes(lineContent)) { + matchingLines.push(lineContent); + } } + matchingLines.push(""); // separator + } - if (results.length >= maxResults) break; + if (matchingLines.length > 0) { + results.push({ + relativePath, + matchingLines: matchingLines.slice(0, 50), + fullContent: content.length < 8000 ? content : undefined, + }); } + + if (results.length >= maxResults) break; } catch (error) { // Skip files that can't be read } @@ -66,47 +151,123 @@ export function searchInDirectory( return results; } +// ---------- Raw Search (for hybrid queries) ---------- + +export async function searchSourceCodeRaw( + rootDir: string, + query: string +): Promise { + console.log(`[${new Date().toISOString()}] Raw search for: "${query}"`); + + const sourceDir = path.join(rootDir, "source"); + if (!fileExists(sourceDir)) { + return { totalMatches: 0, files: [], rawContent: "" }; + } + + const { literalPatterns, semanticTerms, isSymbolQuery } = preprocessQuery(query); + + console.log(`[${new Date().toISOString()}] Query analysis: literal=[${literalPatterns.join(", ")}], semantic=[${semanticTerms.join(", ")}], isSymbol=${isSymbolQuery}`); + + // Search in MoonBit core + const coreDir = path.join(sourceDir, "core"); + const coreResults = searchInDirectory( + coreDir, + [".mbt"], + semanticTerms, + literalPatterns, + 20 + ); + + // Search documentation + const docResults = searchInDirectory( + sourceDir, + [".md"], + semanticTerms, + literalPatterns, + 10 + ); + + // Combine results + let rawContent = ""; + const files: string[] = []; + + if (coreResults.length > 0) { + rawContent += "\n## MoonBit Core Source Code\n"; + for (const r of coreResults) { + files.push(r.relativePath); + rawContent += `\n### ${r.relativePath}\n\`\`\`moonbit\n${r.matchingLines.join("\n")}\n\`\`\`\n`; + } + } + + if (docResults.length > 0) { + rawContent += "\n## Documentation\n"; + for (const r of docResults) { + files.push(r.relativePath); + rawContent += `\n### ${r.relativePath}\n${r.matchingLines.slice(0, 20).join("\n")}\n`; + } + } + + return { + totalMatches: coreResults.length + docResults.length, + files, + rawContent, + }; +} + +// ---------- Source Code Search with LLM ---------- + export async function searchSourceCode( ai: GoogleGenAI, rootDir: string, query: string ): Promise { - console.log( - `[${new Date().toISOString()}] Searching local code for: "${query}"` - ); - const sourceDir = path.join(rootDir, "source"); + console.log(`[${new Date().toISOString()}] Searching local code for: "${query}"`); + + updateTaskProgress("searching-code", "正在分析查询并搜索源代码..."); + const sourceDir = path.join(rootDir, "source"); if (!fileExists(sourceDir)) { - return "Source directory not found."; + return "未找到源代码目录。"; } - const queryTerms = query - .toLowerCase() - .split(/\s+/) - .filter((t) => t.length > 2); + const { literalPatterns, semanticTerms, isSymbolQuery } = preprocessQuery(query); + + console.log(`[${new Date().toISOString()}] Query analysis: literal=[${literalPatterns.join(", ")}], semantic=[${semanticTerms.join(", ")}], isSymbol=${isSymbolQuery}`); - if (queryTerms.length === 0) { - return "Query too short. Please provide more specific search terms."; + // For symbol queries, we don't require minimum length + if (semanticTerms.length === 0 && literalPatterns.length === 0) { + return "查询太短。请提供更具体的搜索词。"; } // Step 1: Search in genai-sdk-samples for TypeScript examples - console.log(`[${new Date().toISOString()}] Searching SDK samples...`); + updateTaskProgress("searching-code", "正在搜索 SDK 示例...", { + partialContent: "扫描 TypeScript 示例文件...", + }); const sdkSamplesDir = path.join(sourceDir, "genai-sdk-samples"); - const sdkResults = searchInDirectory(sdkSamplesDir, [".ts"], queryTerms, 10); + const sdkResults = searchInDirectory(sdkSamplesDir, [".ts"], semanticTerms, literalPatterns, 10); // Step 2: Search in core for MoonBit source code - console.log(`[${new Date().toISOString()}] Searching MoonBit core...`); + updateTaskProgress("searching-code", `正在搜索 MoonBit 核心库... (已找到 ${sdkResults.length} 个 SDK 示例)`, { + codeResultsCount: sdkResults.length, + partialContent: "扫描 .mbt 源文件...", + }); const coreDir = path.join(sourceDir, "core"); - const coreResults = searchInDirectory(coreDir, [".mbt"], queryTerms, 10); + const coreResults = searchInDirectory(coreDir, [".mbt"], semanticTerms, literalPatterns, 15); // Step 3: Search README and documentation files - console.log(`[${new Date().toISOString()}] Searching documentation...`); - const docResults = searchInDirectory( - sourceDir, - [".md", ".json"], - queryTerms, - 5 - ); + updateTaskProgress("searching-code", `正在搜索文档文件... (已找到 ${sdkResults.length + coreResults.length} 个代码匹配)`, { + codeResultsCount: sdkResults.length + coreResults.length, + partialContent: "扫描 README 和文档...", + }); + const docResults = searchInDirectory(sourceDir, [".md", ".json"], semanticTerms, literalPatterns, 8); + + const totalResults = sdkResults.length + coreResults.length + docResults.length; + updateTaskProgress("searching-code", `搜索完成,找到 ${totalResults} 个匹配文件`, { + codeResultsCount: totalResults, + partialContent: totalResults > 0 + ? `匹配文件: ${[...coreResults, ...docResults].slice(0, 3).map(r => r.relativePath).join(", ")}${totalResults > 3 ? " 等" : ""}` + : "未找到匹配", + }); // Combine raw results let rawResults = ""; @@ -133,28 +294,31 @@ export async function searchSourceCode( } if (!rawResults) { - return `No matching code found for query: "${query}". Try different search terms.`; + return `未找到与 "${query}" 匹配的代码。请尝试不同的搜索词。`; } // Step 4: Use LLM to organize and verify the results - console.log( - `[${new Date().toISOString()}] Using LLM to organize search results...` - ); + updateTaskProgress("generating", "正在使用 AI 分析和整理搜索结果...", { + codeResultsCount: totalResults, + partialContent: `正在处理 ${totalResults} 个匹配文件的内容...`, + }); + + const prompt = `你是 MoonBit 编程语言的文档助手。 - const prompt = `You are a documentation assistant for MoonBit programming language. +用户询问: "${query}" -The user asked: "${query}" +${isSymbolQuery ? `注意: 这是关于特定符号/操作符 "${query}" 的查询。请重点解释该符号在 MoonBit 中的所有不同用法和含义。` : ""} -Here are the raw search results from the codebase: +以下是从代码库中找到的原始搜索结果: ${rawResults} -Please analyze these search results and provide a well-organized response that: -1. Directly answers the user's question based on the code found -2. Includes relevant code examples with proper syntax highlighting -3. Explains how the code works if helpful -4. If the search results don't fully answer the question, say so and suggest what else the user might look for +请分析这些搜索结果,用中文提供组织良好的回答: +1. 根据找到的代码直接回答用户的问题 +2. ${isSymbolQuery ? "列出该符号的所有不同用法/含义,并配上示例" : "包含相关的代码示例,使用正确的语法高亮"} +3. 如有帮助,解释代码的工作原理 +4. 如果搜索结果不能完全回答问题,请说明并建议用户可以找什么 -Format your response as clean documentation that could be used as a reference.`; +请使用 \`\`\`moonbit 代码块格式化 MoonBit 代码。`; try { const response = await ai.models.generateContent({ @@ -164,16 +328,14 @@ Format your response as clean documentation that could be used as a reference.`; const result = response.text; if (result) { - console.log(`[${new Date().toISOString()}] LLM processing complete.`); + updateTaskProgress("complete", "搜索和分析完成"); return result; } } catch (error) { - console.error( - `[${new Date().toISOString()}] LLM processing failed:`, - error - ); + console.error(`[${new Date().toISOString()}] LLM processing failed:`, error); } // Fallback to raw results if LLM fails - return `Raw search results for "${query}":\n${rawResults}`; + updateTaskProgress("complete", "返回原始搜索结果 (LLM 不可用)"); + return `"${query}" 的原始搜索结果:\n${rawResults}`; } diff --git a/src/server.ts b/src/server.ts index ba65f3a..4439993 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,13 +2,13 @@ import http from "http"; import { randomUUID } from "crypto"; import { z } from "zod"; import { GoogleGenAI } from "@google/genai"; -import { Task } from "./types.js"; -import { runQuery } from "./query.js"; +import { Task, TaskProgress } from "./types.js"; +import { runQuery, updateTaskProgress } from "./query.js"; // ---------- Validation ---------- const CreateQuerySchema = z.object({ - type: z.enum(["docs", "source"]), + type: z.enum(["docs", "source", "hybrid"]), query: z.string().min(1), }); @@ -45,6 +45,11 @@ async function handleCreateQuery( createdAt: now, updatedAt: now, etaSeconds: 3, + pollCount: 0, + progress: { + phase: "initializing", + message: "任务已加入队列,等待开始...", + }, }; tasks.set(id, task); @@ -65,7 +70,7 @@ async function handleCreateQuery( } }); - respondJson(res, 202, { id, nextPollSec: 2 }); + respondJson(res, 202, { id, nextPollSec: 3 }); } catch (error) { console.error( `[${new Date().toISOString()}] Error handling create query:`, @@ -91,25 +96,60 @@ async function handleGetQuery( return; } + // Increment poll count + task.pollCount++; + const elapsedSeconds = (Date.now() - task.createdAt) / 1000; + console.log( - `[${new Date().toISOString()}] Task status. ID: ${id}, Status: ${task.status}` + `[${new Date().toISOString()}] Task status. ID: ${id}, Status: ${task.status}, Poll #${task.pollCount}` ); + if (task.status === "done") { - respondJson(res, 200, { status: "done", content: task.result }); + respondJson(res, 200, { + status: "done", + content: task.result, + stats: { + pollCount: task.pollCount, + totalTimeSeconds: elapsedSeconds, + }, + }); } else if (task.status === "error") { - respondJson(res, 200, { status: "error", error: task.error }); - } else { respondJson(res, 200, { - status: task.status, - etaSeconds: task.etaSeconds, - nextPollSec: 2, - message: "Processing in background. Please poll again.", - debug: { - createdAt: new Date(task.createdAt).toISOString(), - updatedAt: new Date(task.updatedAt).toISOString(), - elapsedSeconds: (Date.now() - task.createdAt) / 1000, + status: "error", + error: task.error, + stats: { + pollCount: task.pollCount, + totalTimeSeconds: elapsedSeconds, }, }); + } else { + // Build progress response - dynamic poll interval based on elapsed time + const dynamicPollSec = elapsedSeconds < 5 ? 3 : elapsedSeconds < 15 ? 4 : 5; + const progressResponse: any = { + status: task.status, + etaSeconds: Math.max(1, task.etaSeconds - Math.floor(elapsedSeconds)), + nextPollSec: dynamicPollSec, + pollCount: task.pollCount, + elapsedSeconds: Math.round(elapsedSeconds * 10) / 10, + }; + + // Include progress details if available + if (task.progress) { + progressResponse.phase = task.progress.phase; + progressResponse.message = task.progress.message; + + if (task.progress.codeResultsCount !== undefined) { + progressResponse.codeResultsCount = task.progress.codeResultsCount; + } + if (task.progress.docsResultsCount !== undefined) { + progressResponse.docsResultsCount = task.progress.docsResultsCount; + } + if (task.progress.partialContent) { + progressResponse.partialContent = task.progress.partialContent; + } + } + + respondJson(res, 200, progressResponse); } } @@ -119,11 +159,20 @@ async function processTask(task: Task, ai: GoogleGenAI, rootDir: string) { ); task.status = "running"; task.updatedAt = Date.now(); + task.progress = { + phase: "initializing", + message: "开始处理查询...", + }; + try { - const result = await runQuery(ai, rootDir, task.kind, task.query); + const result = await runQuery(ai, rootDir, task.kind, task.query, task); task.result = result; task.status = "done"; task.updatedAt = Date.now(); + task.progress = { + phase: "complete", + message: "查询完成", + }; console.log(`[${new Date().toISOString()}] Task completed. ID: ${task.id}`); } catch (error) { console.error( diff --git a/src/store.ts b/src/store.ts index 8e82207..077c143 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,8 +1,17 @@ import path from "path"; import { GoogleGenAI } from "@google/genai"; -import { FileInfo } from "./types.js"; +import { FileInfo, CacheData } from "./types.js"; import { getAllFiles, getMimeType, fileExists, readFileBuffer } from "./files.js"; import { sleep } from "./utils.js"; +import { + loadCache, + saveCache, + computeFileHash, + findValidCachedFile, + addOrUpdateCacheEntry, + cleanExpiredEntries, + formatTimeRemaining, +} from "./cache.js"; // ---------- Store State ---------- @@ -43,6 +52,13 @@ export async function initializeStore( return; } + // Load cache + const cache: CacheData = loadCache(rootDir); + const expiredCount = cleanExpiredEntries(cache); + if (expiredCount > 0) { + console.log(`[${new Date().toISOString()}] Cleaned ${expiredCount} expired cache entries`); + } + const files = getAllFiles(dir); // Upload Markdown and text files to Gemini const validFiles = files.filter((file) => @@ -57,11 +73,43 @@ export async function initializeStore( } console.log( - `[${new Date().toISOString()}] Found ${validFiles.length} files to upload from ${dirName}` + `[${new Date().toISOString()}] Found ${validFiles.length} files to process from ${dirName}` ); + let uploadedCount = 0; + let cachedCount = 0; + for (const filePath of validFiles) { const relativePath = path.relative(dir, filePath); + const fileHash = computeFileHash(filePath); + + // Check cache first + const cachedEntry = findValidCachedFile(cache, relativePath, fileHash); + if (cachedEntry) { + // Verify the file still exists on Gemini + try { + const remoteFile = await ai.files.get({ name: cachedEntry.name }); + if (remoteFile && remoteFile.state === "ACTIVE") { + uploadedStoreFiles.push({ + uri: cachedEntry.uri, + mimeType: cachedEntry.mimeType, + name: cachedEntry.name, + }); + cachedCount++; + console.log( + `[${new Date().toISOString()}] ✓ Cache hit: ${relativePath} (${formatTimeRemaining(cachedEntry.expiresAt)})` + ); + continue; + } + } catch { + // File no longer exists on Gemini, need to re-upload + console.log( + `[${new Date().toISOString()}] Cache invalid: ${relativePath} - file no longer exists on Gemini` + ); + } + } + + // Upload file console.log(`[${new Date().toISOString()}] Uploading ${relativePath}...`); const fileContent = readFileBuffer(filePath); @@ -104,13 +152,26 @@ export async function initializeStore( continue; } - uploadedStoreFiles.push({ + const fileInfo: FileInfo = { uri: processed.uri as string, mimeType: processed.mimeType as string, name: processed.name as string, + }; + + uploadedStoreFiles.push(fileInfo); + uploadedCount++; + + // Update cache + addOrUpdateCacheEntry(cache, { + uri: fileInfo.uri, + mimeType: fileInfo.mimeType, + name: fileInfo.name, + displayName: relativePath, + fileHash, }); + console.log( - `[${new Date().toISOString()}] Uploaded ${relativePath} - URI: ${processed.uri}` + `[${new Date().toISOString()}] ✓ Uploaded ${relativePath} - URI: ${processed.uri}` ); } catch (error) { console.error( @@ -120,8 +181,11 @@ export async function initializeStore( } } + // Save updated cache + saveCache(rootDir, cache); + storeInitialized = true; console.log( - `[${new Date().toISOString()}] Initialized ${uploadedStoreFiles.length} files from ${dirName}` + `[${new Date().toISOString()}] Initialized ${uploadedStoreFiles.length} files from ${dirName} (${cachedCount} from cache, ${uploadedCount} uploaded)` ); } diff --git a/src/types.ts b/src/types.ts index b75ff61..74ddce5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,10 +6,26 @@ export interface FileInfo { name: string; } -export type QueryKind = "docs" | "source"; +export type QueryKind = "docs" | "source" | "hybrid"; export type TaskStatus = "queued" | "running" | "done" | "error"; +export type TaskPhase = + | "initializing" + | "searching-code" + | "searching-docs" + | "analyzing" + | "generating" + | "complete"; + +export interface TaskProgress { + phase: TaskPhase; + message: string; + partialContent?: string; // Incremental content available + codeResultsCount?: number; // Number of code matches found + docsResultsCount?: number; // Number of doc matches found +} + export interface Task { id: string; kind: QueryKind; @@ -18,6 +34,8 @@ export interface Task { createdAt: number; updatedAt: number; etaSeconds: number; + pollCount: number; // Number of times polled + progress?: TaskProgress; // Current progress details result?: string; error?: string; } @@ -27,3 +45,20 @@ export interface SearchResult { matchingLines: string[]; fullContent?: string; } + +// ---------- Cache Types ---------- + +export interface CachedFileInfo { + uri: string; + mimeType: string; + name: string; + displayName: string; + uploadedAt: number; // Unix timestamp (ms) + expiresAt: number; // Unix timestamp (ms) + fileHash: string; // MD5 hash of file content +} + +export interface CacheData { + version: number; + files: CachedFileInfo[]; +} From 4e6751f22c2aa2791830558b9eed302b2ee875cb Mon Sep 17 00:00:00 2001 From: tiye Date: Fri, 19 Dec 2025 00:08:43 +0800 Subject: [PATCH 06/13] run format --- .prettierignore | 6 ++ .prettierrc | 8 ++ ARCHITECTURE.md | 30 +++---- Agents.md | 59 ++++++------- README.md | 34 ++++---- USAGE.md | 28 +++---- package.json | 5 +- src/cache.ts | 28 +++---- src/files.ts | 28 +++---- src/genai.ts | 36 +++----- src/index.ts | 14 ++-- src/query.ts | 75 +++++++++-------- src/search.ts | 217 +++++++++++++++++++++++++++--------------------- src/server.ts | 98 ++++++++++------------ src/store.ts | 68 +++++++-------- src/types.ts | 34 ++++---- src/utils.ts | 3 +- yarn.lock | 5 ++ 18 files changed, 399 insertions(+), 377 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..b96da80 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +dist/ +node_modules/ +.gemini-cache.json +*.md +source/ +store/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..ace6e02 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "arrowParens": "always" +} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5cde355..58e1d00 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -59,28 +59,28 @@ Moonverse.ts 是一个基于 Google Gemini API 的 MoonBit 文档和源码搜索 ### 核心模块 -| 模块 | 文件 | 职责 | -|------|------|------| -| **入口** | `index.ts` | 初始化流程:代理设置 → AI 客户端 → 服务器启动 → 文件上传 | -| **类型** | `types.ts` | TypeScript 类型定义 | -| **服务器** | `server.ts` | HTTP 路由、任务管理、轮询响应 | -| **查询** | `query.ts` | 查询路由、进度更新、hybrid 逻辑 | +| 模块 | 文件 | 职责 | +| ---------- | ----------- | -------------------------------------------------------- | +| **入口** | `index.ts` | 初始化流程:代理设置 → AI 客户端 → 服务器启动 → 文件上传 | +| **类型** | `types.ts` | TypeScript 类型定义 | +| **服务器** | `server.ts` | HTTP 路由、任务管理、轮询响应 | +| **查询** | `query.ts` | 查询路由、进度更新、hybrid 逻辑 | ### 数据模块 -| 模块 | 文件 | 职责 | -|------|------|------| -| **文件操作** | `files.ts` | 文件读取、遍历、MIME 类型判断 | -| **缓存** | `cache.ts` | Gemini 文件缓存(`.gemini-cache.json`) | -| **Store** | `store.ts` | 文档文件上传到 Gemini | -| **搜索** | `search.ts` | 本地代码搜索 + LLM 结果组织 | +| 模块 | 文件 | 职责 | +| ------------ | ----------- | --------------------------------------- | +| **文件操作** | `files.ts` | 文件读取、遍历、MIME 类型判断 | +| **缓存** | `cache.ts` | Gemini 文件缓存(`.gemini-cache.json`) | +| **Store** | `store.ts` | 文档文件上传到 Gemini | +| **搜索** | `search.ts` | 本地代码搜索 + LLM 结果组织 | ### 基础模块 -| 模块 | 文件 | 职责 | -|------|------|------| +| 模块 | 文件 | 职责 | +| ------------- | ---------- | ------------------------------------- | | **AI 客户端** | `genai.ts` | Gemini 客户端配置、代理设置、URL 重写 | -| **工具函数** | `utils.ts` | sleep、日志辅助函数 | +| **工具函数** | `utils.ts` | sleep、日志辅助函数 | ## 数据流 diff --git a/Agents.md b/Agents.md index d0f7dbf..ac37e71 100644 --- a/Agents.md +++ b/Agents.md @@ -130,10 +130,10 @@ moonverse.ts/ } ``` -| 类型 | 说明 | -|------|------| -| `docs` | 仅使用上传的文档回答 | -| `source` | 本地搜索源代码 + LLM 组织 | +| 类型 | 说明 | +| -------- | -------------------------------- | +| `docs` | 仅使用上传的文档回答 | +| `source` | 本地搜索源代码 + LLM 组织 | | `hybrid` | 结合文档和代码,综合回答(推荐) | **响应:** @@ -168,14 +168,14 @@ moonverse.ts/ **任务阶段(phase):** -| 阶段 | 说明 | -|------|------| -| `initializing` | 任务初始化中 | +| 阶段 | 说明 | +| ---------------- | -------------- | +| `initializing` | 任务初始化中 | | `searching-code` | 正在搜索源代码 | -| `searching-docs` | 正在准备文档 | -| `analyzing` | 正在分析和整合 | -| `generating` | 正在生成回答 | -| `complete` | 完成 | +| `searching-docs` | 正在准备文档 | +| `analyzing` | 正在分析和整合 | +| `generating` | 正在生成回答 | +| `complete` | 完成 | **响应(完成):** @@ -270,6 +270,7 @@ moonverse.ts/ ``` **特性:** + - 文件哈希校验:内容变化时自动重新上传 - 47 小时有效期(Gemini 文件 48 小时过期) - 启动时验证文件是否仍在 Gemini 存在 @@ -279,12 +280,12 @@ moonverse.ts/ 支持搜索特殊符号和短查询: -| 查询 | 搜索策略 | -|------|---------| -| `..` | 字面搜索 + 语义词(range, cascade, update) | -| `...` | 字面搜索 + 语义词(spread, rest) | -| `\|>` | 字面搜索 + 语义词(pipe, pipeline) | -| 普通查询 | 分词后语义搜索 | +| 查询 | 搜索策略 | +| -------- | ------------------------------------------- | +| `..` | 字面搜索 + 语义词(range, cascade, update) | +| `...` | 字面搜索 + 语义词(spread, rest) | +| `\|>` | 字面搜索 + 语义词(pipe, pipeline) | +| 普通查询 | 分词后语义搜索 | ## 注意事项 @@ -332,15 +333,15 @@ npx tsx src/index.ts > /tmp/moonverse.log 2>&1 & ## 模块说明 -| 模块 | 职责 | -|------|------| -| `types.ts` | 类型定义(Task, FileInfo, CacheData 等) | -| `utils.ts` | 工具函数(sleep, log) | -| `files.ts` | 文件系统操作(读取、遍历、MIME 类型) | -| `genai.ts` | AI 客户端配置和代理设置 | -| `cache.ts` | Gemini 文件缓存管理 | -| `store.ts` | Store 文件上传到 Gemini | -| `search.ts` | 本地代码搜索 + LLM 组织 | -| `query.ts` | 查询执行和进度更新 | -| `server.ts` | HTTP 服务器和路由 | -| `index.ts` | 主入口,初始化流程 | +| 模块 | 职责 | +| ----------- | ---------------------------------------- | +| `types.ts` | 类型定义(Task, FileInfo, CacheData 等) | +| `utils.ts` | 工具函数(sleep, log) | +| `files.ts` | 文件系统操作(读取、遍历、MIME 类型) | +| `genai.ts` | AI 客户端配置和代理设置 | +| `cache.ts` | Gemini 文件缓存管理 | +| `store.ts` | Store 文件上传到 Gemini | +| `search.ts` | 本地代码搜索 + LLM 组织 | +| `query.ts` | 查询执行和进度更新 | +| `server.ts` | HTTP 服务器和路由 | +| `index.ts` | 主入口,初始化流程 | diff --git a/README.md b/README.md index eddf75c..46af9f8 100644 --- a/README.md +++ b/README.md @@ -47,19 +47,19 @@ curl http://localhost:8080/query/ ## API 接口 -| 端点 | 方法 | 说明 | -|------|------|------| -| `/query` | POST | 创建查询任务 | -| `/query/:id` | GET | 轮询任务状态 | -| `/healthz` | GET | 健康检查 | +| 端点 | 方法 | 说明 | +| ------------ | ---- | ------------ | +| `/query` | POST | 创建查询任务 | +| `/query/:id` | GET | 轮询任务状态 | +| `/healthz` | GET | 健康检查 | ### 查询类型 -| 类型 | 说明 | 适用场景 | -|------|------|---------| -| `docs` | 仅文档查询 | 查询官方文档内容 | -| `source` | 源码搜索 | 查找代码示例和实现 | -| `hybrid` | 混合查询 | 综合文档和代码回答(推荐) | +| 类型 | 说明 | 适用场景 | +| -------- | ---------- | -------------------------- | +| `docs` | 仅文档查询 | 查询官方文档内容 | +| `source` | 源码搜索 | 查找代码示例和实现 | +| `hybrid` | 混合查询 | 综合文档和代码回答(推荐) | ## 项目结构 @@ -81,13 +81,13 @@ moonverse.ts/ ## 环境变量 -| 变量 | 必需 | 说明 | -|------|------|------| -| `GEMINI_API_KEY` | ✅ | Gemini API 密钥 | -| `HTTP_PROXY` | ❌ | HTTP 代理地址 | -| `HTTPS_PROXY` | ❌ | HTTPS 代理地址 | -| `GEMINI_BASE_URL` | ❌ | 自定义 Gemini API 端点 | -| `PORT` | ❌ | 服务器端口(默认 8080) | +| 变量 | 必需 | 说明 | +| ----------------- | ---- | ----------------------- | +| `GEMINI_API_KEY` | ✅ | Gemini API 密钥 | +| `HTTP_PROXY` | ❌ | HTTP 代理地址 | +| `HTTPS_PROXY` | ❌ | HTTPS 代理地址 | +| `GEMINI_BASE_URL` | ❌ | 自定义 Gemini API 端点 | +| `PORT` | ❌ | 服务器端口(默认 8080) | ## 技术栈 diff --git a/USAGE.md b/USAGE.md index 67166c5..e681b4b 100644 --- a/USAGE.md +++ b/USAGE.md @@ -86,7 +86,7 @@ echo "Task ID: $TASK_ID" while true; do RESULT=$(curl -s "http://localhost:8080/query/$TASK_ID") STATUS=$(echo $RESULT | jq -r '.status') - + if [ "$STATUS" = "done" ]; then echo $RESULT | jq -r '.content' break @@ -125,13 +125,13 @@ git clone https://github.com/moonbitlang/core.git source/core ## 环境变量 -| 变量 | 说明 | 示例 | -|------|------|------| -| `GEMINI_API_KEY` | Gemini API 密钥(必需) | `AIza...` | -| `HTTP_PROXY` | HTTP 代理地址 | `http://localhost:7890` | -| `HTTPS_PROXY` | HTTPS 代理地址 | `http://localhost:7890` | -| `GEMINI_BASE_URL` | 自定义 API 端点 | `https://proxy.example.com` | -| `PORT` | 服务器端口 | `8080` | +| 变量 | 说明 | 示例 | +| ----------------- | ----------------------- | --------------------------- | +| `GEMINI_API_KEY` | Gemini API 密钥(必需) | `AIza...` | +| `HTTP_PROXY` | HTTP 代理地址 | `http://localhost:7890` | +| `HTTPS_PROXY` | HTTPS 代理地址 | `http://localhost:7890` | +| `GEMINI_BASE_URL` | 自定义 API 端点 | `https://proxy.example.com` | +| `PORT` | 服务器端口 | `8080` | ## 响应格式 @@ -180,12 +180,12 @@ git clone https://github.com/moonbitlang/core.git source/core 支持搜索特殊符号: -| 查询 | 说明 | -|------|------| -| `..` | 范围操作符、记录更新、方法链 | -| `...` | 展开操作符 | -| `\|>` | 管道操作符 | -| `::` | 方法访问 | +| 查询 | 说明 | +| ----- | ---------------------------- | +| `..` | 范围操作符、记录更新、方法链 | +| `...` | 展开操作符 | +| `\|>` | 管道操作符 | +| `::` | 方法访问 | 示例: diff --git a/package.json b/package.json index 95abcdf..6ea71d4 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "start": "node dist/index.js", "dev": "tsx src/index.ts", "prepublishOnly": "yarn build", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "format": "prettier --write \"src/**/*.ts\"", + "format:check": "prettier --check \"src/**/*.ts\"" }, "keywords": [ "moonbit", @@ -30,6 +32,7 @@ }, "devDependencies": { "@types/node": "^24.10.1", + "prettier": "^3.7.4", "tsx": "^4.20.6", "typescript": "^5.9.3" } diff --git a/src/cache.ts b/src/cache.ts index e071ae8..e6a86d4 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -1,12 +1,12 @@ -import fs from "fs"; -import path from "path"; -import crypto from "crypto"; -import { CacheData, CachedFileInfo } from "./types.js"; +import fs from 'fs'; +import path from 'path'; +import crypto from 'crypto'; +import { CacheData, CachedFileInfo } from './types.js'; // ---------- Constants ---------- const CACHE_VERSION = 1; -const CACHE_FILENAME = ".gemini-cache.json"; +const CACHE_FILENAME = '.gemini-cache.json'; // Gemini Files API: files expire after 48 hours // Use 47 hours to be safe @@ -22,7 +22,7 @@ export function loadCache(rootDir: string): CacheData { const cachePath = getCachePath(rootDir); try { if (fs.existsSync(cachePath)) { - const raw = fs.readFileSync(cachePath, "utf-8"); + const raw = fs.readFileSync(cachePath, 'utf-8'); const data = JSON.parse(raw) as CacheData; if (data.version === CACHE_VERSION) { return data; @@ -38,7 +38,7 @@ export function loadCache(rootDir: string): CacheData { export function saveCache(rootDir: string, cache: CacheData): void { const cachePath = getCachePath(rootDir); try { - fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), "utf-8"); + fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8'); console.log(`[Cache] Saved ${cache.files.length} entries to ${CACHE_FILENAME}`); } catch (error) { console.error(`[Cache] Failed to save cache:`, error); @@ -49,7 +49,7 @@ export function saveCache(rootDir: string, cache: CacheData): void { export function computeFileHash(filePath: string): string { const content = fs.readFileSync(filePath); - return crypto.createHash("md5").update(content).digest("hex"); + return crypto.createHash('md5').update(content).digest('hex'); } // ---------- Cache Lookup ---------- @@ -60,9 +60,7 @@ export function findValidCachedFile( fileHash: string ): CachedFileInfo | null { const now = Date.now(); - const entry = cache.files.find( - (f) => f.displayName === displayName && f.fileHash === fileHash - ); + const entry = cache.files.find((f) => f.displayName === displayName && f.fileHash === fileHash); if (entry && entry.expiresAt > now) { return entry; @@ -74,7 +72,7 @@ export function findValidCachedFile( export function addOrUpdateCacheEntry( cache: CacheData, - entry: Omit + entry: Omit ): CachedFileInfo { const now = Date.now(); const newEntry: CachedFileInfo = { @@ -103,11 +101,11 @@ export function cleanExpiredEntries(cache: CacheData): number { export function formatTimeRemaining(expiresAt: number): string { const remaining = expiresAt - Date.now(); - if (remaining <= 0) return "expired"; - + if (remaining <= 0) return 'expired'; + const hours = Math.floor(remaining / (60 * 60 * 1000)); const minutes = Math.floor((remaining % (60 * 60 * 1000)) / (60 * 1000)); - + if (hours > 0) { return `${hours}h ${minutes}m remaining`; } diff --git a/src/files.ts b/src/files.ts index aac4df9..735b46a 100644 --- a/src/files.ts +++ b/src/files.ts @@ -1,22 +1,22 @@ -import fs from "fs"; -import path from "path"; +import fs from 'fs'; +import path from 'path'; // ---------- File System Helpers ---------- export function getMimeType(ext: string): string { switch (ext.toLowerCase()) { - case ".md": - return "text/markdown"; - case ".txt": - return "text/plain"; - case ".json": - return "application/json"; - case ".mbt": - return "text/plain"; - case ".ts": - return "text/typescript"; + case '.md': + return 'text/markdown'; + case '.txt': + return 'text/plain'; + case '.json': + return 'application/json'; + case '.mbt': + return 'text/plain'; + case '.ts': + return 'text/typescript'; default: - return "text/plain"; + return 'text/plain'; } } @@ -39,7 +39,7 @@ export function fileExists(filePath: string): boolean { } export function readFileContent(filePath: string): string { - return fs.readFileSync(filePath, "utf-8"); + return fs.readFileSync(filePath, 'utf-8'); } export function readFileBuffer(filePath: string): Buffer { diff --git a/src/genai.ts b/src/genai.ts index 72aa742..4f7c6ed 100644 --- a/src/genai.ts +++ b/src/genai.ts @@ -1,5 +1,5 @@ -import { GoogleGenAI } from "@google/genai"; -import { ProxyAgent, setGlobalDispatcher } from "undici"; +import { GoogleGenAI } from '@google/genai'; +import { ProxyAgent, setGlobalDispatcher } from 'undici'; // ---------- URL Helpers ---------- @@ -12,10 +12,7 @@ export function getUrlOrigin(url: string): string | null { } } -function rewriteUploadUrlHost( - originalUrl: string, - preferredOrigin: string -): string { +function rewriteUploadUrlHost(originalUrl: string, preferredOrigin: string): string { try { const target = new URL(originalUrl); const preferred = new URL(preferredOrigin); @@ -29,10 +26,7 @@ function rewriteUploadUrlHost( } } -export function forceUploadBaseForFiles( - aiClient: GoogleGenAI, - preferredOrigin: string -) { +export function forceUploadBaseForFiles(aiClient: GoogleGenAI, preferredOrigin: string) { const filesAny = aiClient.files as unknown as { apiClient?: { fetchUploadUrl?: (...args: unknown[]) => Promise; @@ -40,10 +34,8 @@ export function forceUploadBaseForFiles( }; }; const apiClient = filesAny?.apiClient; - if (!apiClient || typeof apiClient.fetchUploadUrl !== "function") { - console.warn( - "[GenAI] Unable to patch upload origin; apiClient missing fetchUploadUrl." - ); + if (!apiClient || typeof apiClient.fetchUploadUrl !== 'function') { + console.warn('[GenAI] Unable to patch upload origin; apiClient missing fetchUploadUrl.'); return; } if (apiClient.__uploadBasePatched) { @@ -77,17 +69,15 @@ export function setupProxy(): void { // ---------- AI Client Factory ---------- export function createAIClient(): GoogleGenAI { - const apiKey = process.env.GEMINI_API_KEY || ""; + const apiKey = process.env.GEMINI_API_KEY || ''; if (!apiKey) { - console.error("Error: GEMINI_API_KEY environment variable is not set"); + console.error('Error: GEMINI_API_KEY environment variable is not set'); process.exit(1); } const baseUrl = process.env.GEMINI_BASE_URL; const uploadBaseUrl = process.env.GEMINI_UPLOAD_BASE_URL || baseUrl; - const uploadBaseOrigin = baseUrl - ? getUrlOrigin(uploadBaseUrl || baseUrl) - : undefined; + const uploadBaseOrigin = baseUrl ? getUrlOrigin(uploadBaseUrl || baseUrl) : undefined; if (baseUrl) { console.log(`[GenAI] Using base URL: ${baseUrl}`); @@ -122,12 +112,10 @@ export async function testConnection(ai: GoogleGenAI): Promise { console.log(`[${new Date().toISOString()}] Testing Interactions API...`); await ai.interactions.create({ - model: "gemini-2.5-flash", - input: [{ type: "text", text: "Hello" }], + model: 'gemini-2.5-flash', + input: [{ type: 'text', text: 'Hello' }], }); - console.log( - `[${new Date().toISOString()}] Interactions API test successful.` - ); + console.log(`[${new Date().toISOString()}] Interactions API test successful.`); } catch (error) { console.error(`[${new Date().toISOString()}] AI Connection failed:`, error); } diff --git a/src/index.ts b/src/index.ts index 74130c3..b61e564 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ -import path from "path"; -import { fileURLToPath } from "url"; -import { setupProxy, createAIClient, testConnection } from "./genai.js"; -import { initializeStore } from "./store.js"; -import { createServer, startServer } from "./server.js"; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { setupProxy, createAIClient, testConnection } from './genai.js'; +import { initializeStore } from './store.js'; +import { createServer, startServer } from './server.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -27,11 +27,11 @@ async function main() { // Initialize store files in background initializeStore(ai, rootDir).catch((error) => { - console.error("[Startup] Failed to initialize store files:", error); + console.error('[Startup] Failed to initialize store files:', error); }); } main().catch((error) => { - console.error("Fatal error:", error); + console.error('Fatal error:', error); process.exit(1); }); diff --git a/src/query.ts b/src/query.ts index af19fbb..e5bb295 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1,7 +1,7 @@ -import { GoogleGenAI, createPartFromUri } from "@google/genai"; -import { QueryKind, Task, TaskProgress, TaskPhase } from "./types.js"; -import { getUploadedStoreFiles } from "./store.js"; -import { searchSourceCode, searchSourceCodeRaw } from "./search.js"; +import { GoogleGenAI, createPartFromUri } from '@google/genai'; +import { QueryKind, Task, TaskProgress, TaskPhase } from './types.js'; +import { getUploadedStoreFiles } from './store.js'; +import { searchSourceCode, searchSourceCodeRaw } from './search.js'; // ---------- Task Progress Update ---------- @@ -47,12 +47,12 @@ export async function runQuery( ); // For source code queries, use local search + LLM - if (kind === "source") { + if (kind === 'source') { return await searchSourceCode(ai, rootDir, query); } // For hybrid queries, combine docs + code search - if (kind === "hybrid") { + if (kind === 'hybrid') { return await runHybridQuery(ai, rootDir, query); } @@ -63,7 +63,7 @@ export async function runQuery( // ---------- Docs Query ---------- async function runDocsQuery(ai: GoogleGenAI, query: string): Promise { - updateTaskProgress("searching-docs", "正在准备文档文件..."); + updateTaskProgress('searching-docs', '正在准备文档文件...'); const files = getUploadedStoreFiles(); if (files.length === 0) { @@ -71,9 +71,9 @@ async function runDocsQuery(ai: GoogleGenAI, query: string): Promise { return "没有可用的文档文件。请在 'store' 目录中添加 Markdown 文件。"; } - updateTaskProgress("generating", `正在使用 ${files.length} 个文档文件查询 Gemini...`, { + updateTaskProgress('generating', `正在使用 ${files.length} 个文档文件查询 Gemini...`, { docsResultsCount: files.length, - partialContent: `文档: ${files.map(f => f.name.split('/').pop()).join(', ')}`, + partialContent: `文档: ${files.map((f) => f.name.split('/').pop()).join(', ')}`, }); // Build parts array for generateContent @@ -90,51 +90,54 @@ ${query} parts.push({ text: promptText }); - updateTaskProgress("generating", "正在等待 Gemini 生成回答...", { + updateTaskProgress('generating', '正在等待 Gemini 生成回答...', { docsResultsCount: files.length, - partialContent: "正在分析文档内容并组织答案...", + partialContent: '正在分析文档内容并组织答案...', }); console.log(`[${new Date().toISOString()}] Sending request to Gemini...`); const response = await ai.models.generateContent({ - model: "gemini-2.5-flash", + model: 'gemini-2.5-flash', contents: parts, }); console.log(`[${new Date().toISOString()}] Received response from Gemini.`); - updateTaskProgress("complete", "回答生成完成"); - return response.text || "未生成回答"; + updateTaskProgress('complete', '回答生成完成'); + return response.text || '未生成回答'; } // ---------- Hybrid Query (Docs + Code) ---------- -async function runHybridQuery( - ai: GoogleGenAI, - rootDir: string, - query: string -): Promise { - updateTaskProgress("initializing", "正在分析查询并确定搜索策略..."); +async function runHybridQuery(ai: GoogleGenAI, rootDir: string, query: string): Promise { + updateTaskProgress('initializing', '正在分析查询并确定搜索策略...'); // Step 1: Search code first - updateTaskProgress("searching-code", "正在搜索源代码中的相关示例..."); + updateTaskProgress('searching-code', '正在搜索源代码中的相关示例...'); const codeResults = await searchSourceCodeRaw(rootDir, query); - updateTaskProgress("searching-code", `找到 ${codeResults.totalMatches} 个代码匹配`, { + updateTaskProgress('searching-code', `找到 ${codeResults.totalMatches} 个代码匹配`, { codeResultsCount: codeResults.totalMatches, - partialContent: codeResults.totalMatches > 0 - ? `匹配文件: ${codeResults.files.slice(0, 3).join(", ")}${codeResults.files.length > 3 ? " 等" : ""}` - : "未找到代码匹配", + partialContent: + codeResults.totalMatches > 0 + ? `匹配文件: ${codeResults.files.slice(0, 3).join(', ')}${ + codeResults.files.length > 3 ? ' 等' : '' + }` + : '未找到代码匹配', }); // Step 2: Get docs files - updateTaskProgress("searching-docs", "正在准备文档上下文..."); + updateTaskProgress('searching-docs', '正在准备文档上下文...'); const files = getUploadedStoreFiles(); - updateTaskProgress("analyzing", `正在结合 ${codeResults.totalMatches} 个代码示例和 ${files.length} 个文档文件...`, { - codeResultsCount: codeResults.totalMatches, - docsResultsCount: files.length, - partialContent: "正在整合代码和文档信息...", - }); + updateTaskProgress( + 'analyzing', + `正在结合 ${codeResults.totalMatches} 个代码示例和 ${files.length} 个文档文件...`, + { + codeResultsCount: codeResults.totalMatches, + docsResultsCount: files.length, + partialContent: '正在整合代码和文档信息...', + } + ); // Step 3: Build comprehensive prompt with both code and docs const parts: any[] = []; @@ -170,23 +173,23 @@ ${codeResults.rawContent} parts.push({ text: promptText }); - updateTaskProgress("generating", "正在使用 Gemini 生成综合回答...", { + updateTaskProgress('generating', '正在使用 Gemini 生成综合回答...', { codeResultsCount: codeResults.totalMatches, docsResultsCount: files.length, - partialContent: "正在综合文档和代码示例生成答案...", + partialContent: '正在综合文档和代码示例生成答案...', }); console.log(`[${new Date().toISOString()}] Sending hybrid request to Gemini...`); const response = await ai.models.generateContent({ - model: "gemini-2.5-flash", + model: 'gemini-2.5-flash', contents: parts, }); console.log(`[${new Date().toISOString()}] Received hybrid response from Gemini.`); - updateTaskProgress("complete", "回答生成完成", { + updateTaskProgress('complete', '回答生成完成', { codeResultsCount: codeResults.totalMatches, docsResultsCount: files.length, }); - return response.text || "未生成回答"; + return response.text || '未生成回答'; } diff --git a/src/search.ts b/src/search.ts index 2195824..e0874a4 100644 --- a/src/search.ts +++ b/src/search.ts @@ -1,8 +1,8 @@ -import path from "path"; -import { GoogleGenAI } from "@google/genai"; -import { SearchResult } from "./types.js"; -import { getAllFiles, fileExists, readFileContent } from "./files.js"; -import { updateTaskProgress } from "./query.js"; +import path from 'path'; +import { GoogleGenAI } from '@google/genai'; +import { SearchResult } from './types.js'; +import { getAllFiles, fileExists, readFileContent } from './files.js'; +import { updateTaskProgress } from './query.js'; // ---------- Search Result Types ---------- @@ -24,44 +24,47 @@ function preprocessQuery(query: string): { isSymbolQuery: boolean; } { const trimmed = query.trim(); - + // Check if this is primarily a symbol/operator query const symbolPatterns = [ - /^\.\.$/, // exactly ".." - /^\.\.\.$/, // exactly "..." + /^\.\.$/, // exactly ".." + /^\.\.\.$/, // exactly "..." /^[+\-*\/%&|^~<>=!]+$/, // operators - /^::/, // method access - /^\|>/, // pipe operator + /^::/, // method access + /^\|>/, // pipe operator ]; - - const isSymbolQuery = symbolPatterns.some(p => p.test(trimmed)); - + + const isSymbolQuery = symbolPatterns.some((p) => p.test(trimmed)); + const literalPatterns: string[] = []; const semanticTerms: string[] = []; - + if (isSymbolQuery || trimmed.length <= 3) { // For short/symbol queries, search literally literalPatterns.push(trimmed); - + // Also add semantic context based on the symbol - if (trimmed === "..") { - semanticTerms.push("range", "cascade", "update", "method chain"); - } else if (trimmed === "...") { - semanticTerms.push("spread", "rest", "array spread"); - } else if (trimmed === "|>") { - semanticTerms.push("pipe", "pipeline"); + if (trimmed === '..') { + semanticTerms.push('range', 'cascade', 'update', 'method chain'); + } else if (trimmed === '...') { + semanticTerms.push('spread', 'rest', 'array spread'); + } else if (trimmed === '|>') { + semanticTerms.push('pipe', 'pipeline'); } } else { // Normal query: split into words but preserve quoted phrases - const words = trimmed.toLowerCase().split(/\s+/).filter(t => t.length > 0); + const words = trimmed + .toLowerCase() + .split(/\s+/) + .filter((t) => t.length > 0); semanticTerms.push(...words); - + // Also keep the original as a literal pattern if it contains special chars if (/[^\w\s]/.test(trimmed)) { literalPatterns.push(trimmed); } } - + return { literalPatterns, semanticTerms, isSymbolQuery }; } @@ -96,13 +99,13 @@ export function searchInDirectory( } // Check for semantic term matches - const hasSemanticMatch = queryTerms.length > 0 && - queryTerms.some((term) => contentLower.includes(term)); + const hasSemanticMatch = + queryTerms.length > 0 && queryTerms.some((term) => contentLower.includes(term)); if (!hasLiteralMatch && !hasSemanticMatch) continue; const relativePath = path.relative(baseDir, filePath); - const lines = content.split("\n"); + const lines = content.split('\n'); // Find matching lines with context const matchingLines: string[] = []; @@ -125,13 +128,13 @@ export function searchInDirectory( const start = Math.max(0, idx - 2); const end = Math.min(lines.length, idx + 3); for (let i = start; i < end; i++) { - const prefix = i === idx ? ">>> " : " "; + const prefix = i === idx ? '>>> ' : ' '; const lineContent = `${prefix}Line ${i + 1}: ${lines[i]}`; if (!matchingLines.includes(lineContent)) { matchingLines.push(lineContent); } } - matchingLines.push(""); // separator + matchingLines.push(''); // separator } if (matchingLines.length > 0) { @@ -158,52 +161,46 @@ export async function searchSourceCodeRaw( query: string ): Promise { console.log(`[${new Date().toISOString()}] Raw search for: "${query}"`); - - const sourceDir = path.join(rootDir, "source"); + + const sourceDir = path.join(rootDir, 'source'); if (!fileExists(sourceDir)) { - return { totalMatches: 0, files: [], rawContent: "" }; + return { totalMatches: 0, files: [], rawContent: '' }; } const { literalPatterns, semanticTerms, isSymbolQuery } = preprocessQuery(query); - - console.log(`[${new Date().toISOString()}] Query analysis: literal=[${literalPatterns.join(", ")}], semantic=[${semanticTerms.join(", ")}], isSymbol=${isSymbolQuery}`); - // Search in MoonBit core - const coreDir = path.join(sourceDir, "core"); - const coreResults = searchInDirectory( - coreDir, - [".mbt"], - semanticTerms, - literalPatterns, - 20 + console.log( + `[${new Date().toISOString()}] Query analysis: literal=[${literalPatterns.join( + ', ' + )}], semantic=[${semanticTerms.join(', ')}], isSymbol=${isSymbolQuery}` ); + // Search in MoonBit core + const coreDir = path.join(sourceDir, 'core'); + const coreResults = searchInDirectory(coreDir, ['.mbt'], semanticTerms, literalPatterns, 20); + // Search documentation - const docResults = searchInDirectory( - sourceDir, - [".md"], - semanticTerms, - literalPatterns, - 10 - ); + const docResults = searchInDirectory(sourceDir, ['.md'], semanticTerms, literalPatterns, 10); // Combine results - let rawContent = ""; + let rawContent = ''; const files: string[] = []; if (coreResults.length > 0) { - rawContent += "\n## MoonBit Core Source Code\n"; + rawContent += '\n## MoonBit Core Source Code\n'; for (const r of coreResults) { files.push(r.relativePath); - rawContent += `\n### ${r.relativePath}\n\`\`\`moonbit\n${r.matchingLines.join("\n")}\n\`\`\`\n`; + rawContent += `\n### ${ + r.relativePath + }\n\`\`\`moonbit\n${r.matchingLines.join('\n')}\n\`\`\`\n`; } } if (docResults.length > 0) { - rawContent += "\n## Documentation\n"; + rawContent += '\n## Documentation\n'; for (const r of docResults) { files.push(r.relativePath); - rawContent += `\n### ${r.relativePath}\n${r.matchingLines.slice(0, 20).join("\n")}\n`; + rawContent += `\n### ${r.relativePath}\n${r.matchingLines.slice(0, 20).join('\n')}\n`; } } @@ -222,74 +219,100 @@ export async function searchSourceCode( query: string ): Promise { console.log(`[${new Date().toISOString()}] Searching local code for: "${query}"`); - - updateTaskProgress("searching-code", "正在分析查询并搜索源代码..."); - const sourceDir = path.join(rootDir, "source"); + updateTaskProgress('searching-code', '正在分析查询并搜索源代码...'); + + const sourceDir = path.join(rootDir, 'source'); if (!fileExists(sourceDir)) { - return "未找到源代码目录。"; + return '未找到源代码目录。'; } const { literalPatterns, semanticTerms, isSymbolQuery } = preprocessQuery(query); - - console.log(`[${new Date().toISOString()}] Query analysis: literal=[${literalPatterns.join(", ")}], semantic=[${semanticTerms.join(", ")}], isSymbol=${isSymbolQuery}`); + + console.log( + `[${new Date().toISOString()}] Query analysis: literal=[${literalPatterns.join( + ', ' + )}], semantic=[${semanticTerms.join(', ')}], isSymbol=${isSymbolQuery}` + ); // For symbol queries, we don't require minimum length if (semanticTerms.length === 0 && literalPatterns.length === 0) { - return "查询太短。请提供更具体的搜索词。"; + return '查询太短。请提供更具体的搜索词。'; } // Step 1: Search in genai-sdk-samples for TypeScript examples - updateTaskProgress("searching-code", "正在搜索 SDK 示例...", { - partialContent: "扫描 TypeScript 示例文件...", + updateTaskProgress('searching-code', '正在搜索 SDK 示例...', { + partialContent: '扫描 TypeScript 示例文件...', }); - const sdkSamplesDir = path.join(sourceDir, "genai-sdk-samples"); - const sdkResults = searchInDirectory(sdkSamplesDir, [".ts"], semanticTerms, literalPatterns, 10); + const sdkSamplesDir = path.join(sourceDir, 'genai-sdk-samples'); + const sdkResults = searchInDirectory(sdkSamplesDir, ['.ts'], semanticTerms, literalPatterns, 10); // Step 2: Search in core for MoonBit source code - updateTaskProgress("searching-code", `正在搜索 MoonBit 核心库... (已找到 ${sdkResults.length} 个 SDK 示例)`, { - codeResultsCount: sdkResults.length, - partialContent: "扫描 .mbt 源文件...", - }); - const coreDir = path.join(sourceDir, "core"); - const coreResults = searchInDirectory(coreDir, [".mbt"], semanticTerms, literalPatterns, 15); + updateTaskProgress( + 'searching-code', + `正在搜索 MoonBit 核心库... (已找到 ${sdkResults.length} 个 SDK 示例)`, + { + codeResultsCount: sdkResults.length, + partialContent: '扫描 .mbt 源文件...', + } + ); + const coreDir = path.join(sourceDir, 'core'); + const coreResults = searchInDirectory(coreDir, ['.mbt'], semanticTerms, literalPatterns, 15); // Step 3: Search README and documentation files - updateTaskProgress("searching-code", `正在搜索文档文件... (已找到 ${sdkResults.length + coreResults.length} 个代码匹配)`, { - codeResultsCount: sdkResults.length + coreResults.length, - partialContent: "扫描 README 和文档...", - }); - const docResults = searchInDirectory(sourceDir, [".md", ".json"], semanticTerms, literalPatterns, 8); + updateTaskProgress( + 'searching-code', + `正在搜索文档文件... (已找到 ${sdkResults.length + coreResults.length} 个代码匹配)`, + { + codeResultsCount: sdkResults.length + coreResults.length, + partialContent: '扫描 README 和文档...', + } + ); + const docResults = searchInDirectory( + sourceDir, + ['.md', '.json'], + semanticTerms, + literalPatterns, + 8 + ); const totalResults = sdkResults.length + coreResults.length + docResults.length; - updateTaskProgress("searching-code", `搜索完成,找到 ${totalResults} 个匹配文件`, { + updateTaskProgress('searching-code', `搜索完成,找到 ${totalResults} 个匹配文件`, { codeResultsCount: totalResults, - partialContent: totalResults > 0 - ? `匹配文件: ${[...coreResults, ...docResults].slice(0, 3).map(r => r.relativePath).join(", ")}${totalResults > 3 ? " 等" : ""}` - : "未找到匹配", + partialContent: + totalResults > 0 + ? `匹配文件: ${[...coreResults, ...docResults] + .slice(0, 3) + .map((r) => r.relativePath) + .join(', ')}${totalResults > 3 ? ' 等' : ''}` + : '未找到匹配', }); // Combine raw results - let rawResults = ""; + let rawResults = ''; if (sdkResults.length > 0) { - rawResults += "\n## SDK Samples (TypeScript)\n"; + rawResults += '\n## SDK Samples (TypeScript)\n'; for (const r of sdkResults) { - rawResults += `\n### ${r.relativePath}\n\`\`\`typescript\n${r.matchingLines.join("\n")}\n\`\`\`\n`; + rawResults += `\n### ${ + r.relativePath + }\n\`\`\`typescript\n${r.matchingLines.join('\n')}\n\`\`\`\n`; } } if (coreResults.length > 0) { - rawResults += "\n## MoonBit Core Source Code\n"; + rawResults += '\n## MoonBit Core Source Code\n'; for (const r of coreResults) { - rawResults += `\n### ${r.relativePath}\n\`\`\`moonbit\n${r.matchingLines.join("\n")}\n\`\`\`\n`; + rawResults += `\n### ${ + r.relativePath + }\n\`\`\`moonbit\n${r.matchingLines.join('\n')}\n\`\`\`\n`; } } if (docResults.length > 0) { - rawResults += "\n## Documentation\n"; + rawResults += '\n## Documentation\n'; for (const r of docResults) { - rawResults += `\n### ${r.relativePath}\n${r.matchingLines.slice(0, 15).join("\n")}\n`; + rawResults += `\n### ${r.relativePath}\n${r.matchingLines.slice(0, 15).join('\n')}\n`; } } @@ -298,7 +321,7 @@ export async function searchSourceCode( } // Step 4: Use LLM to organize and verify the results - updateTaskProgress("generating", "正在使用 AI 分析和整理搜索结果...", { + updateTaskProgress('generating', '正在使用 AI 分析和整理搜索结果...', { codeResultsCount: totalResults, partialContent: `正在处理 ${totalResults} 个匹配文件的内容...`, }); @@ -307,14 +330,22 @@ export async function searchSourceCode( 用户询问: "${query}" -${isSymbolQuery ? `注意: 这是关于特定符号/操作符 "${query}" 的查询。请重点解释该符号在 MoonBit 中的所有不同用法和含义。` : ""} +${ + isSymbolQuery + ? `注意: 这是关于特定符号/操作符 "${query}" 的查询。请重点解释该符号在 MoonBit 中的所有不同用法和含义。` + : '' +} 以下是从代码库中找到的原始搜索结果: ${rawResults} 请分析这些搜索结果,用中文提供组织良好的回答: 1. 根据找到的代码直接回答用户的问题 -2. ${isSymbolQuery ? "列出该符号的所有不同用法/含义,并配上示例" : "包含相关的代码示例,使用正确的语法高亮"} +2. ${ + isSymbolQuery + ? '列出该符号的所有不同用法/含义,并配上示例' + : '包含相关的代码示例,使用正确的语法高亮' + } 3. 如有帮助,解释代码的工作原理 4. 如果搜索结果不能完全回答问题,请说明并建议用户可以找什么 @@ -322,13 +353,13 @@ ${rawResults} try { const response = await ai.models.generateContent({ - model: "gemini-2.5-flash", + model: 'gemini-2.5-flash', contents: prompt, }); const result = response.text; if (result) { - updateTaskProgress("complete", "搜索和分析完成"); + updateTaskProgress('complete', '搜索和分析完成'); return result; } } catch (error) { @@ -336,6 +367,6 @@ ${rawResults} } // Fallback to raw results if LLM fails - updateTaskProgress("complete", "返回原始搜索结果 (LLM 不可用)"); + updateTaskProgress('complete', '返回原始搜索结果 (LLM 不可用)'); return `"${query}" 的原始搜索结果:\n${rawResults}`; } diff --git a/src/server.ts b/src/server.ts index 4439993..374059a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,14 +1,14 @@ -import http from "http"; -import { randomUUID } from "crypto"; -import { z } from "zod"; -import { GoogleGenAI } from "@google/genai"; -import { Task, TaskProgress } from "./types.js"; -import { runQuery, updateTaskProgress } from "./query.js"; +import http from 'http'; +import { randomUUID } from 'crypto'; +import { z } from 'zod'; +import { GoogleGenAI } from '@google/genai'; +import { Task, TaskProgress } from './types.js'; +import { runQuery, updateTaskProgress } from './query.js'; // ---------- Validation ---------- const CreateQuerySchema = z.object({ - type: z.enum(["docs", "source", "hybrid"]), + type: z.enum(['docs', 'source', 'hybrid']), query: z.string().min(1), }); @@ -18,7 +18,7 @@ const tasks = new Map(); function respondJson(res: http.ServerResponse, status: number, body: unknown) { res.statusCode = status; - res.setHeader("Content-Type", "application/json"); + res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(body)); } @@ -28,9 +28,9 @@ async function handleCreateQuery( ai: GoogleGenAI, rootDir: string ) { - let raw = ""; - req.on("data", (chunk) => (raw += chunk)); - req.on("end", () => { + let raw = ''; + req.on('data', (chunk) => (raw += chunk)); + req.on('end', () => { try { const parsed = raw ? JSON.parse(raw) : {}; const data = CreateQuerySchema.parse(parsed); @@ -41,21 +41,19 @@ async function handleCreateQuery( id, kind: data.type, query: data.query, - status: "queued", + status: 'queued', createdAt: now, updatedAt: now, etaSeconds: 3, pollCount: 0, progress: { - phase: "initializing", - message: "任务已加入队列,等待开始...", + phase: 'initializing', + message: '任务已加入队列,等待开始...', }, }; tasks.set(id, task); - console.log( - `[${new Date().toISOString()}] Task created. ID: ${id}, Kind: ${data.type}` - ); + console.log(`[${new Date().toISOString()}] Task created. ID: ${id}, Kind: ${data.type}`); processTask(task, ai, rootDir).catch((err) => { console.error( @@ -64,7 +62,7 @@ async function handleCreateQuery( ); const t = tasks.get(id); if (t) { - t.status = "error"; + t.status = 'error'; t.error = err instanceof Error ? err.message : String(err); t.updatedAt = Date.now(); } @@ -72,10 +70,7 @@ async function handleCreateQuery( respondJson(res, 202, { id, nextPollSec: 3 }); } catch (error) { - console.error( - `[${new Date().toISOString()}] Error handling create query:`, - error - ); + console.error(`[${new Date().toISOString()}] Error handling create query:`, error); respondJson(res, 400, { error: error instanceof Error ? error.message : String(error), }); @@ -83,16 +78,12 @@ async function handleCreateQuery( }); } -async function handleGetQuery( - req: http.IncomingMessage, - res: http.ServerResponse, - id: string -) { +async function handleGetQuery(req: http.IncomingMessage, res: http.ServerResponse, id: string) { console.log(`[${new Date().toISOString()}] Handling get query. ID: ${id}`); const task = tasks.get(id); if (!task) { console.warn(`[${new Date().toISOString()}] Task not found. ID: ${id}`); - respondJson(res, 404, { error: "task not found" }); + respondJson(res, 404, { error: 'task not found' }); return; } @@ -101,21 +92,23 @@ async function handleGetQuery( const elapsedSeconds = (Date.now() - task.createdAt) / 1000; console.log( - `[${new Date().toISOString()}] Task status. ID: ${id}, Status: ${task.status}, Poll #${task.pollCount}` + `[${new Date().toISOString()}] Task status. ID: ${id}, Status: ${ + task.status + }, Poll #${task.pollCount}` ); - if (task.status === "done") { + if (task.status === 'done') { respondJson(res, 200, { - status: "done", + status: 'done', content: task.result, stats: { pollCount: task.pollCount, totalTimeSeconds: elapsedSeconds, }, }); - } else if (task.status === "error") { + } else if (task.status === 'error') { respondJson(res, 200, { - status: "error", + status: 'error', error: task.error, stats: { pollCount: task.pollCount, @@ -154,32 +147,27 @@ async function handleGetQuery( } async function processTask(task: Task, ai: GoogleGenAI, rootDir: string) { - console.log( - `[${new Date().toISOString()}] Processing task. ID: ${task.id}, Kind: ${task.kind}` - ); - task.status = "running"; + console.log(`[${new Date().toISOString()}] Processing task. ID: ${task.id}, Kind: ${task.kind}`); + task.status = 'running'; task.updatedAt = Date.now(); task.progress = { - phase: "initializing", - message: "开始处理查询...", + phase: 'initializing', + message: '开始处理查询...', }; - + try { const result = await runQuery(ai, rootDir, task.kind, task.query, task); task.result = result; - task.status = "done"; + task.status = 'done'; task.updatedAt = Date.now(); task.progress = { - phase: "complete", - message: "查询完成", + phase: 'complete', + message: '查询完成', }; console.log(`[${new Date().toISOString()}] Task completed. ID: ${task.id}`); } catch (error) { - console.error( - `[${new Date().toISOString()}] Task failed. ID: ${task.id}, Error:`, - error - ); - task.status = "error"; + console.error(`[${new Date().toISOString()}] Task failed. ID: ${task.id}, Error:`, error); + task.status = 'error'; task.error = error instanceof Error ? error.message : String(error); task.updatedAt = Date.now(); } @@ -189,26 +177,26 @@ async function processTask(task: Task, ai: GoogleGenAI, rootDir: string) { export function createServer(ai: GoogleGenAI, rootDir: string): http.Server { const server = http.createServer((req, res) => { - const url = req.url || "/"; - const method = req.method || "GET"; + const url = req.url || '/'; + const method = req.method || 'GET'; - if (method === "GET" && url === "/healthz") { + if (method === 'GET' && url === '/healthz') { respondJson(res, 200, { ok: true }); return; } - if (method === "POST" && url === "/query") { + if (method === 'POST' && url === '/query') { handleCreateQuery(req, res, ai, rootDir); return; } - if (method === "GET" && url.startsWith("/query/")) { - const id = url.split("/query/")[1]; + if (method === 'GET' && url.startsWith('/query/')) { + const id = url.split('/query/')[1]; handleGetQuery(req, res, id); return; } - respondJson(res, 404, { error: "not found" }); + respondJson(res, 404, { error: 'not found' }); }); return server; diff --git a/src/store.ts b/src/store.ts index 077c143..4880dfd 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,8 +1,8 @@ -import path from "path"; -import { GoogleGenAI } from "@google/genai"; -import { FileInfo, CacheData } from "./types.js"; -import { getAllFiles, getMimeType, fileExists, readFileBuffer } from "./files.js"; -import { sleep } from "./utils.js"; +import path from 'path'; +import { GoogleGenAI } from '@google/genai'; +import { FileInfo, CacheData } from './types.js'; +import { getAllFiles, getMimeType, fileExists, readFileBuffer } from './files.js'; +import { sleep } from './utils.js'; import { loadCache, saveCache, @@ -11,7 +11,7 @@ import { addOrUpdateCacheEntry, cleanExpiredEntries, formatTimeRemaining, -} from "./cache.js"; +} from './cache.js'; // ---------- Store State ---------- @@ -28,26 +28,21 @@ export function isStoreInitialized(): boolean { // ---------- File Upload ---------- -export async function initializeStore( - ai: GoogleGenAI, - rootDir: string -): Promise { - const dirName = "store"; - console.log( - `[${new Date().toISOString()}] Initializing files from ${dirName}...` - ); +export async function initializeStore(ai: GoogleGenAI, rootDir: string): Promise { + const dirName = 'store'; + console.log(`[${new Date().toISOString()}] Initializing files from ${dirName}...`); const dir = path.join(rootDir, dirName); if (!fileExists(dir)) { - console.error( - `[${new Date().toISOString()}] ${dirName} directory not found. Skipping...` - ); + console.error(`[${new Date().toISOString()}] ${dirName} directory not found. Skipping...`); return; } if (uploadedStoreFiles.length > 0) { console.log( - `[${new Date().toISOString()}] Files already initialized for ${dirName}. Count: ${uploadedStoreFiles.length}` + `[${new Date().toISOString()}] Files already initialized for ${dirName}. Count: ${ + uploadedStoreFiles.length + }` ); return; } @@ -62,13 +57,11 @@ export async function initializeStore( const files = getAllFiles(dir); // Upload Markdown and text files to Gemini const validFiles = files.filter((file) => - [".md", ".txt"].includes(path.extname(file).toLowerCase()) + ['.md', '.txt'].includes(path.extname(file).toLowerCase()) ); if (validFiles.length === 0) { - console.error( - `[${new Date().toISOString()}] No valid files found in ${dirName} directory` - ); + console.error(`[${new Date().toISOString()}] No valid files found in ${dirName} directory`); return; } @@ -89,7 +82,7 @@ export async function initializeStore( // Verify the file still exists on Gemini try { const remoteFile = await ai.files.get({ name: cachedEntry.name }); - if (remoteFile && remoteFile.state === "ACTIVE") { + if (remoteFile && remoteFile.state === 'ACTIVE') { uploadedStoreFiles.push({ uri: cachedEntry.uri, mimeType: cachedEntry.mimeType, @@ -97,7 +90,9 @@ export async function initializeStore( }); cachedCount++; console.log( - `[${new Date().toISOString()}] ✓ Cache hit: ${relativePath} (${formatTimeRemaining(cachedEntry.expiresAt)})` + `[${new Date().toISOString()}] ✓ Cache hit: ${relativePath} (${formatTimeRemaining( + cachedEntry.expiresAt + )})` ); continue; } @@ -118,9 +113,7 @@ export async function initializeStore( const blob = new Blob([fileContent], { type: mimeType }); try { - console.log( - `[${new Date().toISOString()}] Starting upload for ${relativePath}...` - ); + console.log(`[${new Date().toISOString()}] Starting upload for ${relativePath}...`); const uploadedFile = await ai.files.upload({ file: blob, config: { @@ -129,7 +122,9 @@ export async function initializeStore( }, }); console.log( - `[${new Date().toISOString()}] Upload request completed for ${relativePath}. Name: ${uploadedFile.name}` + `[${new Date().toISOString()}] Upload request completed for ${relativePath}. Name: ${ + uploadedFile.name + }` ); let processed = await ai.files.get({ @@ -137,7 +132,7 @@ export async function initializeStore( }); let retries = 0; const maxRetries = 60; - while (processed.state === "PROCESSING" && retries < maxRetries) { + while (processed.state === 'PROCESSING' && retries < maxRetries) { await sleep(5000); processed = await ai.files.get({ name: uploadedFile.name as string, @@ -145,10 +140,8 @@ export async function initializeStore( retries++; } - if (processed.state === "FAILED") { - console.error( - `[${new Date().toISOString()}] Failed to process file: ${relativePath}` - ); + if (processed.state === 'FAILED') { + console.error(`[${new Date().toISOString()}] Failed to process file: ${relativePath}`); continue; } @@ -174,10 +167,7 @@ export async function initializeStore( `[${new Date().toISOString()}] ✓ Uploaded ${relativePath} - URI: ${processed.uri}` ); } catch (error) { - console.error( - `[${new Date().toISOString()}] Error uploading ${relativePath}:`, - error - ); + console.error(`[${new Date().toISOString()}] Error uploading ${relativePath}:`, error); } } @@ -186,6 +176,8 @@ export async function initializeStore( storeInitialized = true; console.log( - `[${new Date().toISOString()}] Initialized ${uploadedStoreFiles.length} files from ${dirName} (${cachedCount} from cache, ${uploadedCount} uploaded)` + `[${new Date().toISOString()}] Initialized ${ + uploadedStoreFiles.length + } files from ${dirName} (${cachedCount} from cache, ${uploadedCount} uploaded)` ); } diff --git a/src/types.ts b/src/types.ts index 74ddce5..9e0117e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,24 +6,24 @@ export interface FileInfo { name: string; } -export type QueryKind = "docs" | "source" | "hybrid"; +export type QueryKind = 'docs' | 'source' | 'hybrid'; -export type TaskStatus = "queued" | "running" | "done" | "error"; +export type TaskStatus = 'queued' | 'running' | 'done' | 'error'; -export type TaskPhase = - | "initializing" - | "searching-code" - | "searching-docs" - | "analyzing" - | "generating" - | "complete"; +export type TaskPhase = + | 'initializing' + | 'searching-code' + | 'searching-docs' + | 'analyzing' + | 'generating' + | 'complete'; export interface TaskProgress { phase: TaskPhase; message: string; - partialContent?: string; // Incremental content available - codeResultsCount?: number; // Number of code matches found - docsResultsCount?: number; // Number of doc matches found + partialContent?: string; // Incremental content available + codeResultsCount?: number; // Number of code matches found + docsResultsCount?: number; // Number of doc matches found } export interface Task { @@ -34,8 +34,8 @@ export interface Task { createdAt: number; updatedAt: number; etaSeconds: number; - pollCount: number; // Number of times polled - progress?: TaskProgress; // Current progress details + pollCount: number; // Number of times polled + progress?: TaskProgress; // Current progress details result?: string; error?: string; } @@ -53,9 +53,9 @@ export interface CachedFileInfo { mimeType: string; name: string; displayName: string; - uploadedAt: number; // Unix timestamp (ms) - expiresAt: number; // Unix timestamp (ms) - fileHash: string; // MD5 hash of file content + uploadedAt: number; // Unix timestamp (ms) + expiresAt: number; // Unix timestamp (ms) + fileHash: string; // MD5 hash of file content } export interface CacheData { diff --git a/src/utils.ts b/src/utils.ts index 8b090ad..87d02e8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,7 +1,6 @@ // ---------- Utility Functions ---------- -export const sleep = (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)); +export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export function timestamp(): string { return new Date().toISOString(); diff --git a/yarn.lock b/yarn.lock index e911f92..0c3bb6f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -507,6 +507,11 @@ path-scurry@^1.11.1: lru-cache "^10.2.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" +prettier@^3.7.4: + version "3.7.4" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.7.4.tgz#d2f8335d4b1cec47e1c8098645411b0c9dff9c0f" + integrity sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA== + resolve-pkg-maps@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f" From 93425c1e55d4360149d514b68643a1ea84ecb654 Mon Sep 17 00:00:00 2001 From: tiye Date: Fri, 19 Dec 2025 00:51:22 +0800 Subject: [PATCH 07/13] getting more scripts --- .dockerignore | 35 +++++++ .github/workflows/docker-pr.yaml | 65 ++++++++++++ .github/workflows/docker-release.yaml | 58 +++++++++++ ARCHITECTURE.md | 22 ++-- Agents.md | 33 +++++- Dockerfile | 47 +++++++++ README.md | 24 ++++- USAGE.md | 27 +++++ k8s.yaml | 140 ++++++++++++++++++++++++++ src/genai.ts | 19 ++++ src/server.ts | 98 +++++++++++++++++- src/types.ts | 9 ++ 12 files changed, 560 insertions(+), 17 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker-pr.yaml create mode 100644 .github/workflows/docker-release.yaml create mode 100644 Dockerfile create mode 100644 k8s.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fd54fd4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,35 @@ +# Dependencies +node_modules/ +.yarn/ + +# Build outputs +dist/ + +# Development files +*.log +.DS_Store + +# IDE +.vscode/ +.idea/ + +# Test files +tests/ + +# Cache files +.gemini-cache.json + +# Git +.git/ +.gitignore + +# Documentation (optional, comment out if needed in image) +# *.md + +# TypeScript source (already built) +src/ +tsconfig.json + +# Dev dependencies files +.prettierrc +.prettierignore diff --git a/.github/workflows/docker-pr.yaml b/.github/workflows/docker-pr.yaml new file mode 100644 index 0000000..bb0ea1b --- /dev/null +++ b/.github/workflows/docker-pr.yaml @@ -0,0 +1,65 @@ +name: Docker Build on PR + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - "src/**" + - "package.json" + - "yarn.lock" + - "tsconfig.json" + - "Dockerfile" + - ".dockerignore" + - "store/**" + - "source/**" + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=pr + type=sha,prefix=pr- + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Set retention policy + run: | + # This is a placeholder for setting retention policy + # GitHub Container Registry doesn't support direct retention policy via API + # The retention will be handled by GitHub's built-in retention policies + echo "Retention policy: PR images will be retained for 7 days" diff --git a/.github/workflows/docker-release.yaml b/.github/workflows/docker-release.yaml new file mode 100644 index 0000000..5689a0e --- /dev/null +++ b/.github/workflows/docker-release.yaml @@ -0,0 +1,58 @@ +name: Docker Build on Release + +on: + release: + types: [published] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=tag + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Set retention policy + run: | + # Release images are kept indefinitely by default + # This step documents the retention policy + echo "Retention policy: Release images will be retained for at least 1 year" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 58e1d00..603e6db 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -9,17 +9,27 @@ Moonverse.ts 是一个基于 Google Gemini API 的 MoonBit 文档和源码搜索 ``` ┌─────────────────────────────────────────────────────────────┐ │ HTTP Client │ +│ (with X-API-Key header) │ └─────────────────────────┬───────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ HTTP Server (server.ts) │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ -│ │ POST /query │ │GET /query/:id│ │ GET /healthz │ │ -│ └──────┬──────┘ └──────┬──────┘ └─────────────────────┘ │ -└─────────┼────────────────┼──────────────────────────────────┘ - │ │ - ▼ ▼ +│ ┌─────────────┐ ┌─────────────┐ ┌──────────┐ ┌───────┐ │ +│ │ POST /query │ │GET /query/:id│ │GET /usage│ │/healthz│ │ +│ └──────┬──────┘ └──────┬──────┘ └────┬─────┘ └───────┘ │ +└─────────┼────────────────┼──────────────┼───────────────────┘ + │ │ │ + ▼ │ ▼ +┌─────────────────────────────────────────────────────────────┐ +│ API Key & Quota Check │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ X-API-Key → User AI Client │ │ +│ │ No Key → Global AI Client (100/day limit) │ │ +│ └──────────────────────────────────────────────────────┘ │ +└─────────────────────────┬───────────────────────────────────┘ + │ + ▼ ┌─────────────────────────────────────────────────────────────┐ │ Task Queue (in-memory) │ │ ┌──────────────────────────────────────────────────────┐ │ diff --git a/Agents.md b/Agents.md index ac37e71..ba74a9a 100644 --- a/Agents.md +++ b/Agents.md @@ -47,12 +47,13 @@ export PORT=8080 ### 测试命令 ```bash -# 测试文档查询(中文回答) +# 测试文档查询(带自己的 API Key,推荐) curl -X POST http://localhost:8080/query \ -H "Content-Type: application/json" \ + -H "X-API-Key: your-gemini-api-key" \ -d '{"type": "docs", "query": "MoonBit 如何声明可变变量?"}' -# 测试源代码查询 +# 测试源代码查询(使用全局 Key,每天限 100 次) curl -X POST http://localhost:8080/query \ -H "Content-Type: application/json" \ -d '{"type": "source", "query": "Array"}' @@ -60,6 +61,7 @@ curl -X POST http://localhost:8080/query \ # 测试混合查询(推荐) curl -X POST http://localhost:8080/query \ -H "Content-Type: application/json" \ + -H "X-API-Key: your-gemini-api-key" \ -d '{"type": "hybrid", "query": "MoonBit 当中 .. 两个点有哪些用法?"}' # 轮询任务状态 @@ -67,6 +69,9 @@ curl http://localhost:8080/query/ # 健康检查 curl http://localhost:8080/healthz + +# 查看全局 API 使用情况 +curl http://localhost:8080/usage ``` ### 服务器管理 @@ -121,6 +126,13 @@ moonverse.ts/ 创建查询任务(异步) +**请求头:** + +| Header | 必需 | 说明 | +| ------------- | ---- | -------------------------------------------- | +| Content-Type | 是 | `application/json` | +| X-API-Key | 否 | 用户自己的 Gemini API Key(推荐携带) | + **请求体:** ```json @@ -141,7 +153,22 @@ moonverse.ts/ ```json { "id": "uuid", - "nextPollSec": 3 + "nextPollSec": 3, + "globalUsage": { // 仅当使用全局 Key 时返回 + "date": "2025-12-18", + "used": 5, + "remaining": 95 + } +} +``` + +**429 错误(全局配额已用完):** + +```json +{ + "error": "全局 API 配额已用完,请在请求头中提供 X-API-Key", + "hint": "请在 Header 中添加: X-API-Key: your-gemini-api-key", + "usage": { "date": "2025-12-18", "used": 100, "remaining": 0 } } ``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..569c2a6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,47 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package.json yarn.lock ./ + +# Install dependencies +RUN yarn install --frozen-lockfile + +# Copy source code +COPY tsconfig.json ./ +COPY src/ ./src/ + +# Build TypeScript +RUN yarn build + +# Production stage +FROM node:20-alpine AS runner + +WORKDIR /app + +# Copy package files and install production dependencies only +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile --production && yarn cache clean + +# Copy built files +COPY --from=builder /app/dist ./dist + +# Copy store and source directories +COPY store/ ./store/ +COPY source/ ./source/ + +# Environment variables (can be overridden at runtime) +ENV PORT=8080 +ENV NODE_ENV=production + +# Expose port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/healthz || exit 1 + +# Run the server +CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md index 46af9f8..dcabf76 100644 --- a/README.md +++ b/README.md @@ -47,11 +47,25 @@ curl http://localhost:8080/query/ ## API 接口 -| 端点 | 方法 | 说明 | -| ------------ | ---- | ------------ | -| `/query` | POST | 创建查询任务 | -| `/query/:id` | GET | 轮询任务状态 | -| `/healthz` | GET | 健康检查 | +| 端点 | 方法 | 说明 | +| ------------ | ---- | ------------------ | +| `/query` | POST | 创建查询任务 | +| `/query/:id` | GET | 轮询任务状态 | +| `/healthz` | GET | 健康检查 | +| `/usage` | GET | 全局 API 使用情况 | + +### API Key 认证 + +推荐在请求头中携带自己的 Gemini API Key: + +```bash +curl -X POST http://localhost:8080/query \ + -H "Content-Type: application/json" \ + -H "X-API-Key: your-gemini-api-key" \ + -d '{"type": "hybrid", "query": "..."}' +``` + +不携带 `X-API-Key` 时,将使用全局 Key(每天限 100 次)。 ### 查询类型 diff --git a/USAGE.md b/USAGE.md index e681b4b..be026cf 100644 --- a/USAGE.md +++ b/USAGE.md @@ -27,6 +27,33 @@ yarn start ## 查询示例 +### API Key 认证 + +**推荐方式**:在请求头中携带自己的 Gemini API Key + +```bash +# 带自己的 API Key(无使用限制) +curl -X POST http://localhost:8080/query \ + -H "Content-Type: application/json" \ + -H "X-API-Key: your-gemini-api-key" \ + -d '{"type": "hybrid", "query": "..."}' +``` + +**备用方式**:使用服务器全局 Key(每天限 100 次) + +```bash +# 不带 API Key,使用全局配额 +curl -X POST http://localhost:8080/query \ + -H "Content-Type: application/json" \ + -d '{"type": "hybrid", "query": "..."}' + +# 响应中会包含使用情况 +# {"id": "xxx", "nextPollSec": 3, "globalUsage": {"used": 5, "remaining": 95}} + +# 查看全局使用情况 +curl http://localhost:8080/usage +``` + ### 混合查询(推荐) 结合文档和代码示例,提供最全面的回答: diff --git a/k8s.yaml b/k8s.yaml new file mode 100644 index 0000000..1e10aa1 --- /dev/null +++ b/k8s.yaml @@ -0,0 +1,140 @@ +# Moonverse K8s Deployment +# Usage: +# kubectl apply -f k8s.yaml +# kubectl create secret generic moonverse-secrets --from-literal=GEMINI_API_KEY=your-api-key + +--- +apiVersion: v1 +kind: Namespace +metadata: + name: moonverse + labels: + app: moonverse + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: moonverse-config + namespace: moonverse +data: + PORT: "8080" + NODE_ENV: "production" + # Optional: Custom Gemini endpoint + # GEMINI_BASE_URL: "https://your-proxy.example.com" + +--- +apiVersion: v1 +kind: Secret +metadata: + name: moonverse-secrets + namespace: moonverse +type: Opaque +stringData: + # Replace with your actual API key or create via: + # kubectl create secret generic moonverse-secrets -n moonverse --from-literal=GEMINI_API_KEY=your-key + GEMINI_API_KEY: "YOUR_GEMINI_API_KEY_HERE" + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: moonverse + namespace: moonverse + labels: + app: moonverse +spec: + replicas: 1 + selector: + matchLabels: + app: moonverse + template: + metadata: + labels: + app: moonverse + spec: + containers: + - name: moonverse + # TODO: Replace with your actual image registry + image: YOUR_REGISTRY/moonverse:latest + imagePullPolicy: Always + ports: + - containerPort: 8080 + protocol: TCP + envFrom: + - configMapRef: + name: moonverse-config + - secretRef: + name: moonverse-secrets + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 3 + readinessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + +--- +apiVersion: v1 +kind: Service +metadata: + name: moonverse + namespace: moonverse + labels: + app: moonverse +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + name: http + selector: + app: moonverse + +--- +# Optional: Ingress for external access +# Uncomment and configure based on your ingress controller +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: moonverse + namespace: moonverse + annotations: + # For nginx ingress controller + # nginx.ingress.kubernetes.io/cors-allow-origin: "*" + # nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, OPTIONS" + # nginx.ingress.kubernetes.io/cors-allow-headers: "Content-Type, X-API-Key" + # nginx.ingress.kubernetes.io/enable-cors: "true" + kubernetes.io/ingress.class: nginx +spec: + rules: + - host: moonverse.example.com # TODO: Replace with your domain + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: moonverse + port: + number: 80 + # Optional: TLS configuration + # tls: + # - hosts: + # - moonverse.example.com + # secretName: moonverse-tls diff --git a/src/genai.ts b/src/genai.ts index 4f7c6ed..3b2145e 100644 --- a/src/genai.ts +++ b/src/genai.ts @@ -68,6 +68,25 @@ export function setupProxy(): void { // ---------- AI Client Factory ---------- +// Create AI client with custom API key (for per-request usage) +export function createAIClientWithKey(apiKey: string): GoogleGenAI { + const baseUrl = process.env.GEMINI_BASE_URL; + const uploadBaseUrl = process.env.GEMINI_UPLOAD_BASE_URL || baseUrl; + const uploadBaseOrigin = baseUrl ? getUrlOrigin(uploadBaseUrl || baseUrl) : undefined; + + const ai = new GoogleGenAI({ + apiKey, + httpOptions: baseUrl ? { baseUrl } : undefined, + }); + + if (uploadBaseOrigin) { + forceUploadBaseForFiles(ai, uploadBaseOrigin); + } + + return ai; +} + +// Create AI client with environment API key (default/fallback) export function createAIClient(): GoogleGenAI { const apiKey = process.env.GEMINI_API_KEY || ''; if (!apiKey) { diff --git a/src/server.ts b/src/server.ts index 374059a..9a5fe98 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,8 +2,44 @@ import http from 'http'; import { randomUUID } from 'crypto'; import { z } from 'zod'; import { GoogleGenAI } from '@google/genai'; -import { Task, TaskProgress } from './types.js'; +import { Task, TaskProgress, DailyUsage, DAILY_LIMIT } from './types.js'; import { runQuery, updateTaskProgress } from './query.js'; +import { createAIClientWithKey } from './genai.js'; + +// ---------- Global API Usage Tracking ---------- + +let globalUsage: DailyUsage = { + date: new Date().toISOString().split('T')[0], + count: 0, +}; + +function checkAndIncrementGlobalUsage(): { allowed: boolean; remaining: number } { + const today = new Date().toISOString().split('T')[0]; + + // Reset count if it's a new day + if (globalUsage.date !== today) { + globalUsage = { date: today, count: 0 }; + } + + if (globalUsage.count >= DAILY_LIMIT) { + return { allowed: false, remaining: 0 }; + } + + globalUsage.count++; + return { allowed: true, remaining: DAILY_LIMIT - globalUsage.count }; +} + +function getGlobalUsageInfo(): { date: string; used: number; remaining: number } { + const today = new Date().toISOString().split('T')[0]; + if (globalUsage.date !== today) { + return { date: today, used: 0, remaining: DAILY_LIMIT }; + } + return { + date: globalUsage.date, + used: globalUsage.count, + remaining: DAILY_LIMIT - globalUsage.count, + }; +} // ---------- Validation ---------- @@ -16,7 +52,17 @@ const CreateQuerySchema = z.object({ const tasks = new Map(); +// ---------- CORS Support ---------- + +function setCorsHeaders(res: http.ServerResponse) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-API-Key'); + res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours +} + function respondJson(res: http.ServerResponse, status: number, body: unknown) { + setCorsHeaders(res); res.statusCode = status; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(body)); @@ -25,9 +71,12 @@ function respondJson(res: http.ServerResponse, status: number, body: unknown) { async function handleCreateQuery( req: http.IncomingMessage, res: http.ServerResponse, - ai: GoogleGenAI, + defaultAI: GoogleGenAI, rootDir: string ) { + // Extract API key from header + const headerApiKey = req.headers['x-api-key'] as string | undefined; + let raw = ''; req.on('data', (chunk) => (raw += chunk)); req.on('end', () => { @@ -35,6 +84,32 @@ async function handleCreateQuery( const parsed = raw ? JSON.parse(raw) : {}; const data = CreateQuerySchema.parse(parsed); + // Determine which AI client to use + let ai: GoogleGenAI; + let usingGlobalKey = false; + + if (headerApiKey) { + // Use user-provided API key + ai = createAIClientWithKey(headerApiKey); + console.log(`[${new Date().toISOString()}] Using user-provided API key`); + } else { + // Check global usage limit + const usage = checkAndIncrementGlobalUsage(); + if (!usage.allowed) { + respondJson(res, 429, { + error: '全局 API 配额已用完,请在请求头中提供 X-API-Key', + hint: '请在 Header 中添加: X-API-Key: your-gemini-api-key', + usage: getGlobalUsageInfo(), + }); + return; + } + ai = defaultAI; + usingGlobalKey = true; + console.log( + `[${new Date().toISOString()}] Using global API key (${usage.remaining} remaining today)` + ); + } + const id = randomUUID(); const now = Date.now(); const task: Task = { @@ -68,7 +143,11 @@ async function handleCreateQuery( } }); - respondJson(res, 202, { id, nextPollSec: 3 }); + const responseBody: any = { id, nextPollSec: 3 }; + if (usingGlobalKey) { + responseBody.globalUsage = getGlobalUsageInfo(); + } + respondJson(res, 202, responseBody); } catch (error) { console.error(`[${new Date().toISOString()}] Error handling create query:`, error); respondJson(res, 400, { @@ -180,11 +259,24 @@ export function createServer(ai: GoogleGenAI, rootDir: string): http.Server { const url = req.url || '/'; const method = req.method || 'GET'; + // Handle CORS preflight requests + if (method === 'OPTIONS') { + setCorsHeaders(res); + res.statusCode = 204; + res.end(); + return; + } + if (method === 'GET' && url === '/healthz') { respondJson(res, 200, { ok: true }); return; } + if (method === 'GET' && url === '/usage') { + respondJson(res, 200, getGlobalUsageInfo()); + return; + } + if (method === 'POST' && url === '/query') { handleCreateQuery(req, res, ai, rootDir); return; diff --git a/src/types.ts b/src/types.ts index 9e0117e..a4cf7f8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -62,3 +62,12 @@ export interface CacheData { version: number; files: CachedFileInfo[]; } + +// ---------- API Usage Types ---------- + +export interface DailyUsage { + date: string; // YYYY-MM-DD + count: number; +} + +export const DAILY_LIMIT = 100; From 039dec61468bf18055c7158a9039d4abccbb9725 Mon Sep 17 00:00:00 2001 From: tiye Date: Fri, 19 Dec 2025 00:56:10 +0800 Subject: [PATCH 08/13] retry building --- .dockerignore | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.dockerignore b/.dockerignore index fd54fd4..4a2fb3c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -23,13 +23,16 @@ tests/ .git/ .gitignore -# Documentation (optional, comment out if needed in image) -# *.md - -# TypeScript source (already built) -src/ -tsconfig.json +# Documentation (not needed in image) +*.md +!README.md # Dev dependencies files .prettierrc .prettierignore + +# GitHub workflows +.github/ + +# K8s config +k8s.yaml From fc9cf51b911cdf810744bee940bc9218d1d96004 Mon Sep 17 00:00:00 2001 From: tiye Date: Fri, 19 Dec 2025 01:59:17 +0800 Subject: [PATCH 09/13] try deploy scripts --- .dockerignore | 15 +++++++++++++-- .gitignore | 2 ++ k8s.yaml | 44 ++++++++++++++------------------------------ 3 files changed, 29 insertions(+), 32 deletions(-) diff --git a/.dockerignore b/.dockerignore index 4a2fb3c..8425006 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,38 +1,49 @@ # Dependencies + node_modules/ .yarn/ # Build outputs + dist/ # Development files -*.log + +\*.log .DS_Store # IDE + .vscode/ .idea/ # Test files + tests/ # Cache files + .gemini-cache.json # Git + .git/ .gitignore # Documentation (not needed in image) -*.md + +\*.md !README.md # Dev dependencies files + .prettierrc .prettierignore # GitHub workflows + .github/ # K8s config + k8s.yaml diff --git a/.gitignore b/.gitignore index 815b393..1203a0e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ source/ # Gemini file cache .gemini-cache.json +# K8s secrets (contains API keys) +k8s-secrets.yaml \ No newline at end of file diff --git a/k8s.yaml b/k8s.yaml index 1e10aa1..89dba3f 100644 --- a/k8s.yaml +++ b/k8s.yaml @@ -1,7 +1,7 @@ # Moonverse K8s Deployment # Usage: -# kubectl apply -f k8s.yaml -# kubectl create secret generic moonverse-secrets --from-literal=GEMINI_API_KEY=your-api-key +# kubectl apply -f k8s-secrets.yaml # Apply secrets first (not in git) +# kubectl apply -f k8s.yaml # Apply main deployment --- apiVersion: v1 @@ -21,19 +21,7 @@ data: PORT: "8080" NODE_ENV: "production" # Optional: Custom Gemini endpoint - # GEMINI_BASE_URL: "https://your-proxy.example.com" - ---- -apiVersion: v1 -kind: Secret -metadata: - name: moonverse-secrets - namespace: moonverse -type: Opaque -stringData: - # Replace with your actual API key or create via: - # kubectl create secret generic moonverse-secrets -n moonverse --from-literal=GEMINI_API_KEY=your-key - GEMINI_API_KEY: "YOUR_GEMINI_API_KEY_HERE" + GEMINI_BASE_URL: "https://ja.chenyong.life" --- apiVersion: apps/v1 @@ -53,10 +41,12 @@ spec: labels: app: moonverse spec: + imagePullSecrets: + - name: ghcr-login-secret containers: - name: moonverse # TODO: Replace with your actual image registry - image: YOUR_REGISTRY/moonverse:latest + image: ghcr.io/termina/moonverse.ts:pr-2f10378 imagePullPolicy: Always ports: - containerPort: 8080 @@ -107,23 +97,18 @@ spec: app: moonverse --- -# Optional: Ingress for external access -# Uncomment and configure based on your ingress controller +# Ingress for external access with HTTPS (using Traefik + cert-manager) apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: moonverse namespace: moonverse annotations: - # For nginx ingress controller - # nginx.ingress.kubernetes.io/cors-allow-origin: "*" - # nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, OPTIONS" - # nginx.ingress.kubernetes.io/cors-allow-headers: "Content-Type, X-API-Key" - # nginx.ingress.kubernetes.io/enable-cors: "true" - kubernetes.io/ingress.class: nginx + cert-manager.io/cluster-issuer: letsencrypt-prod spec: + ingressClassName: traefik rules: - - host: moonverse.example.com # TODO: Replace with your domain + - host: moonverse.tiye.me http: paths: - path: / @@ -133,8 +118,7 @@ spec: name: moonverse port: number: 80 - # Optional: TLS configuration - # tls: - # - hosts: - # - moonverse.example.com - # secretName: moonverse-tls + tls: + - hosts: + - moonverse.tiye.me + secretName: moonverse-tls-cert From e69c6c40fd0e6a8689c82f03c7484b4839f79318 Mon Sep 17 00:00:00 2001 From: tiye Date: Sun, 21 Dec 2025 00:56:31 +0800 Subject: [PATCH 10/13] working on search details --- 1 | 0 Dockerfile | 17 +- claude_desktop_config.example.json | 13 -- k8s.yaml | 4 +- src/query.ts | 298 +++++++++++++++++++++++++---- src/search.ts | 60 +++++- src/server.ts | 276 +++++++++++++++++++++++++- src/types.ts | 28 +++ src/utils.ts | 10 + 9 files changed, 640 insertions(+), 66 deletions(-) create mode 100644 1 delete mode 100644 claude_desktop_config.example.json diff --git a/1 b/1 new file mode 100644 index 0000000..e69de29 diff --git a/Dockerfile b/Dockerfile index 569c2a6..27a7526 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM node:20-alpine AS builder +FROM node:20-slim AS builder WORKDIR /app @@ -17,7 +17,7 @@ COPY src/ ./src/ RUN yarn build # Production stage -FROM node:20-alpine AS runner +FROM node:20-slim AS runner WORKDIR /app @@ -32,6 +32,19 @@ COPY --from=builder /app/dist ./dist COPY store/ ./store/ COPY source/ ./source/ +# Install necessary tools for MoonBit setup +RUN apt-get update && apt-get install -y \ + git \ + curl \ + wget \ + tree \ + && rm -rf /var/lib/apt/lists/* + +# Install MoonBit related dependencies +RUN cd source && git clone https://github.com/moonbitlang/core.git --depth=1 moonbit-core +RUN cd source && git clone https://github.com/moonbitlang/async.git --depth=1 moonbit-async +RUN curl -fsSL https://cli.moonbitlang.com/install/unix.sh | bash + # Environment variables (can be overridden at runtime) ENV PORT=8080 ENV NODE_ENV=production diff --git a/claude_desktop_config.example.json b/claude_desktop_config.example.json deleted file mode 100644 index e7b03d0..0000000 --- a/claude_desktop_config.example.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "mcpServers": { - "moonverse": { - "command": "node", - "args": [ - "/absolute/path/to/moonverse.ts/dist/index.js" - ], - "env": { - "GEMINI_API_KEY": "your-gemini-api-key-here" - } - } - } -} diff --git a/k8s.yaml b/k8s.yaml index 89dba3f..6bac6f2 100644 --- a/k8s.yaml +++ b/k8s.yaml @@ -108,7 +108,7 @@ metadata: spec: ingressClassName: traefik rules: - - host: moonverse.tiye.me + - host: moonverse-api.tiye.me http: paths: - path: / @@ -120,5 +120,5 @@ spec: number: 80 tls: - hosts: - - moonverse.tiye.me + - moonverse-api.tiye.me secretName: moonverse-tls-cert diff --git a/src/query.ts b/src/query.ts index e5bb295..9856a93 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1,7 +1,83 @@ import { GoogleGenAI, createPartFromUri } from '@google/genai'; +import fs from 'fs'; +import path from 'path'; import { QueryKind, Task, TaskProgress, TaskPhase } from './types.js'; import { getUploadedStoreFiles } from './store.js'; import { searchSourceCode, searchSourceCodeRaw } from './search.js'; +import { log, truncateText } from './utils.js'; + +// ---------- Standard Library Directory Structure ---------- + +/** + * Generate a concise directory structure of the MoonBit core library. + * This helps LLM understand what packages are available. + */ +function generateCoreLibraryStructure(rootDir: string): string { + const corePath = path.join(rootDir, 'source', 'core'); + const packages: string[] = []; + + function findPackages(dir: string, prefix: string = '') { + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const hasPkg = entries.some((e) => e.name === 'moon.pkg.json'); + + if (hasPkg && prefix) { + packages.push(prefix); + } + + for (const entry of entries) { + if ( + entry.isDirectory() && + !entry.name.startsWith('.') && + entry.name !== 'target' && + entry.name !== 'internal' + ) { + const newPrefix = prefix ? `${prefix}/${entry.name}` : entry.name; + findPackages(path.join(dir, entry.name), newPrefix); + } + } + } catch (e) { + // Ignore errors + } + } + + findPackages(corePath); + + if (packages.length === 0) { + return ''; + } + + // Group packages by top-level category + const grouped: Map = new Map(); + for (const pkg of packages.sort()) { + const parts = pkg.split('/'); + const category = parts[0]; + if (!grouped.has(category)) { + grouped.set(category, []); + } + if (parts.length > 1) { + grouped.get(category)!.push(parts.slice(1).join('/')); + } + } + + // Build concise structure text + let structure = `MoonBit 标准库包列表 (moonbitlang/core): +`; + + for (const [category, subPackages] of grouped) { + if (subPackages.length === 0) { + structure += ` @${category}\n`; + } else { + structure += ` @${category} (子包: ${subPackages.join(', ')})\n`; + } + } + + structure += ` +提示: 使用 \`moon doc "@包名"\` 可以查看包的详细 API,例如 \`moon doc "@json"\` 或 \`moon doc "String::*"\`。 +`; + + return structure; +} // ---------- Task Progress Update ---------- @@ -67,12 +143,18 @@ async function runDocsQuery(ai: GoogleGenAI, query: string): Promise { const files = getUploadedStoreFiles(); if (files.length === 0) { - console.warn(`[${new Date().toISOString()}] No files found for docs query`); + log('[文档查询] 没有找到文档文件'); return "没有可用的文档文件。请在 'store' 目录中添加 Markdown 文件。"; } + log(`[文档查询] 准备使用 ${files.length} 个文档文件:`); + for (const file of files) { + log(` - ${file.name}: ${file.mimeType}, URI: ${truncateText(file.uri, 50)}`); + } + updateTaskProgress('generating', `正在使用 ${files.length} 个文档文件查询 Gemini...`, { docsResultsCount: files.length, + docsFiles: files.map((f) => f.name.split('/').pop() || f.name), partialContent: `文档: ${files.map((f) => f.name.split('/').pop()).join(', ')}`, }); @@ -95,51 +177,142 @@ ${query} partialContent: '正在分析文档内容并组织答案...', }); - console.log(`[${new Date().toISOString()}] Sending request to Gemini...`); + log(`[文档查询] 发送请求到 Gemini, prompt 长度: ${promptText.length}`); + log(`[文档查询] Prompt: ${truncateText(promptText, 100)}`); const response = await ai.models.generateContent({ model: 'gemini-2.5-flash', contents: parts, }); - console.log(`[${new Date().toISOString()}] Received response from Gemini.`); + const responseText = response.text || '未生成回答'; + log(`[文档查询] 收到 Gemini 回答, 长度: ${responseText.length}`); + log(`[文档查询] 回答内容: ${truncateText(responseText, 100)}`); updateTaskProgress('complete', '回答生成完成'); - return response.text || '未生成回答'; + return responseText; +} + +// ---------- Query Classification ---------- + +type QueryType = 'tutorial' | 'api-lookup' | 'syntax' | 'general'; + +/** + * Classify query to determine what context is needed. + * - tutorial: Wants step-by-step guide, doesn't need code search + * - api-lookup: Looking for specific API/package info, needs stdlib list + * - syntax: Asking about operators/syntax, needs code examples + * - general: General question, docs only + */ +function classifyQuery(query: string): QueryType { + const lowerQuery = query.toLowerCase(); + + // Tutorial patterns - explicit step-by-step requests + if ( + /教程|入门|学习|怎么写|完整.*步骤|从.*开始|tutorial|guide|how to (write|build|create)/i.test( + query + ) + ) { + return 'tutorial'; + } + + // API lookup patterns - looking for specific functionality + if (/哪些.*包|什么包|有没有.*函数|api|库|标准库|package|module|@\w+/i.test(query)) { + return 'api-lookup'; + } + + // Syntax patterns - operators, special symbols + if (/[.]{2,3}|[|]>|\?\?|语法|操作符|符号|operator|syntax/i.test(query)) { + return 'syntax'; + } + + return 'general'; } // ---------- Hybrid Query (Docs + Code) ---------- async function runHybridQuery(ai: GoogleGenAI, rootDir: string, query: string): Promise { - updateTaskProgress('initializing', '正在分析查询并确定搜索策略...'); - - // Step 1: Search code first - updateTaskProgress('searching-code', '正在搜索源代码中的相关示例...'); - const codeResults = await searchSourceCodeRaw(rootDir, query); + // Initialize stats + const stats = { llmCalls: 0, searchCalls: 0, toolCalls: 0 }; + + // Step 0: Classify query to determine what context is needed + const queryType = classifyQuery(query); + log(`[混合查询] 查询分类: ${queryType}, query: "${query}"`); + + updateTaskProgress('initializing', `正在分析查询类型: ${queryType}...`, { stats }); + + // Determine what context to include based on query type + const needsCodeSearch = queryType === 'syntax' || queryType === 'api-lookup'; + const needsStdlibList = queryType === 'api-lookup'; + + let codeResults = { + totalMatches: 0, + files: [] as string[], + rawContent: '', + codeDetails: [] as any[], + docDetails: [] as any[], + }; + + // Step 1: Only search code if needed + if (needsCodeSearch) { + updateTaskProgress('searching-code', '正在搜索源代码中的相关示例...', { stats }); + log(`[混合查询] 开始搜索代码, query: "${query}"`); + stats.searchCalls++; + codeResults = await searchSourceCodeRaw(rootDir, query); + log( + `[混合查询] 代码搜索完成: ${codeResults.totalMatches} 个匹配, ${codeResults.files.length} 个文件` + ); + + // Limit code content to avoid bloating the prompt + if (codeResults.rawContent && codeResults.rawContent.length > 8000) { + log(`[混合查询] rawContent 过长 (${codeResults.rawContent.length}),截断到 8000 字符`); + codeResults.rawContent = codeResults.rawContent.substring(0, 8000) + '\n... (更多内容已省略)'; + } - updateTaskProgress('searching-code', `找到 ${codeResults.totalMatches} 个代码匹配`, { - codeResultsCount: codeResults.totalMatches, - partialContent: - codeResults.totalMatches > 0 - ? `匹配文件: ${codeResults.files.slice(0, 3).join(', ')}${ - codeResults.files.length > 3 ? ' 等' : '' - }` - : '未找到代码匹配', - }); + updateTaskProgress('searching-code', `找到 ${codeResults.totalMatches} 个代码匹配`, { + codeResultsCount: codeResults.totalMatches, + codeFiles: codeResults.files, + codeSearchDetails: codeResults.codeDetails, + docsSearchDetails: codeResults.docDetails, + stats, + partialContent: + codeResults.totalMatches > 0 + ? `匹配文件: ${codeResults.files.slice(0, 3).join(', ')}${codeResults.files.length > 3 ? ' 等' : ''}` + : '未找到代码匹配', + }); + } else { + log(`[混合查询] 跳过代码搜索 (queryType=${queryType})`); + } // Step 2: Get docs files - updateTaskProgress('searching-docs', '正在准备文档上下文...'); + updateTaskProgress('searching-docs', '正在准备文档上下文...', { stats }); const files = getUploadedStoreFiles(); + log(`[混合查询] 文档文件数: ${files.length}`); + + // Combine uploaded docs with local doc search results + const uploadedDocsDetails = files.map((f) => ({ + name: f.name.split('/').pop() || f.name, + type: f.mimeType, + preview: '(上传的文档文件)', + })); + const allDocsDetails = [...codeResults.docDetails, ...uploadedDocsDetails]; updateTaskProgress( 'analyzing', - `正在结合 ${codeResults.totalMatches} 个代码示例和 ${files.length} 个文档文件...`, + needsCodeSearch + ? `正在结合 ${codeResults.totalMatches} 个代码示例和 ${files.length} 个文档文件...` + : `正在使用 ${files.length} 个文档文件生成回答...`, { codeResultsCount: codeResults.totalMatches, - docsResultsCount: files.length, - partialContent: '正在整合代码和文档信息...', + docsResultsCount: files.length + codeResults.docDetails.length, + codeFiles: codeResults.files, + docsFiles: files.map((f) => f.name.split('/').pop() || f.name), + codeSearchDetails: codeResults.codeDetails, + docsSearchDetails: allDocsDetails, + stats, + partialContent: '正在整合信息...', } ); - // Step 3: Build comprehensive prompt with both code and docs + // Step 3: Build prompt based on query type const parts: any[] = []; // Add documentation files @@ -147,14 +320,24 @@ async function runHybridQuery(ai: GoogleGenAI, rootDir: string, query: string): parts.push(createPartFromUri(file.uri, file.mimeType)); } - // Build the prompt + // Build the prompt - keep it minimal and focused let promptText = `你是一位 MoonBit 编程语言专家。用户正在询问: "${query}" `; - if (codeResults.rawContent) { + // Only add stdlib list for api-lookup queries + if (needsStdlibList) { + const coreLibStructure = generateCoreLibraryStructure(rootDir); + if (coreLibStructure) { + promptText += `${coreLibStructure} +`; + } + } + + // Only add code results if we searched and found something + if (needsCodeSearch && codeResults.rawContent) { promptText += `以下是在 MoonBit 核心库中找到的相关代码示例: ${codeResults.rawContent} @@ -162,34 +345,73 @@ ${codeResults.rawContent} `; } - promptText += `请基于上面提供的文档文件和代码示例,用中文提供全面的回答: + // Tailored instructions based on query type + switch (queryType) { + case 'tutorial': + promptText += `请基于文档提供一个清晰的分步教程: + +1. 从最基础的概念开始解释 +2. 提供完整可运行的代码示例 +3. 每一步都解释清楚原理 +4. 使用 \`\`\`moonbit 代码块 + +注意:专注于教学,保持示例简单明了。`; + break; -1. 清晰地解释概念/语法 -2. 展示代码库中的实际代码示例(如果找到) -3. 提供文档中的额外上下文 -4. 如果是关于特定语法(如操作符),请列出所有不同的用法/含义 + case 'api-lookup': + promptText += `请基于文档和包列表回答: -请使用清晰的段落和 \`\`\`moonbit 代码块来格式化你的回答。`; +1. 指出相关的标准库包 +2. 说明主要的 API 和用法 +3. 提供简短的代码示例 +4. 使用 \`\`\`moonbit 代码块`; + break; + + case 'syntax': + promptText += `请基于文档和代码示例回答: + +1. 解释该语法/操作符的含义 +2. 列出所有不同的用法场景 +3. 展示代码示例 +4. 使用 \`\`\`moonbit 代码块`; + break; + + default: + promptText += `请基于文档用中文回答,使用 \`\`\`moonbit 代码块展示代码示例。`; + } parts.push({ text: promptText }); - updateTaskProgress('generating', '正在使用 Gemini 生成综合回答...', { + updateTaskProgress('generating', '正在使用 Gemini 生成回答...', { codeResultsCount: codeResults.totalMatches, - docsResultsCount: files.length, - partialContent: '正在综合文档和代码示例生成答案...', + docsResultsCount: files.length + codeResults.docDetails.length, + codeFiles: codeResults.files, + docsFiles: files.map((f) => f.name.split('/').pop() || f.name), + codeSearchDetails: codeResults.codeDetails, + docsSearchDetails: allDocsDetails, + stats, + partialContent: `查询类型: ${queryType}`, }); - console.log(`[${new Date().toISOString()}] Sending hybrid request to Gemini...`); + log(`[混合查询] 发送请求到 Gemini, prompt 长度: ${promptText.length}, 类型: ${queryType}`); + log(`[混合查询] Prompt 预览: ${truncateText(promptText, 200)}`); + stats.llmCalls++; const response = await ai.models.generateContent({ model: 'gemini-2.5-flash', contents: parts, }); - console.log(`[${new Date().toISOString()}] Received hybrid response from Gemini.`); + const responseText = response.text || '未生成回答'; + log(`[混合查询] 收到 Gemini 回答, 长度: ${responseText.length}`); updateTaskProgress('complete', '回答生成完成', { codeResultsCount: codeResults.totalMatches, - docsResultsCount: files.length, + docsResultsCount: files.length + codeResults.docDetails.length, + codeFiles: codeResults.files, + docsFiles: files.map((f) => f.name.split('/').pop() || f.name), + codeSearchDetails: codeResults.codeDetails, + docsSearchDetails: allDocsDetails, + stats, }); - return response.text || '未生成回答'; + return responseText; } diff --git a/src/search.ts b/src/search.ts index e0874a4..bcabd4a 100644 --- a/src/search.ts +++ b/src/search.ts @@ -3,13 +3,28 @@ import { GoogleGenAI } from '@google/genai'; import { SearchResult } from './types.js'; import { getAllFiles, fileExists, readFileContent } from './files.js'; import { updateTaskProgress } from './query.js'; +import { log, truncateText } from './utils.js'; // ---------- Search Result Types ---------- +export interface CodeFileDetail { + path: string; + matchCount: number; + preview: string; +} + +export interface DocFileDetail { + name: string; + type: string; + preview: string; +} + export interface RawSearchResults { totalMatches: number; files: string[]; rawContent: string; + codeDetails: CodeFileDetail[]; // Detailed info for each code file + docDetails: DocFileDetail[]; // Detailed info for each doc file } // ---------- Query Preprocessing ---------- @@ -164,7 +179,7 @@ export async function searchSourceCodeRaw( const sourceDir = path.join(rootDir, 'source'); if (!fileExists(sourceDir)) { - return { totalMatches: 0, files: [], rawContent: '' }; + return { totalMatches: 0, files: [], rawContent: '', codeDetails: [], docDetails: [] }; } const { literalPatterns, semanticTerms, isSymbolQuery } = preprocessQuery(query); @@ -185,29 +200,64 @@ export async function searchSourceCodeRaw( // Combine results let rawContent = ''; const files: string[] = []; + const codeDetails: CodeFileDetail[] = []; + const docDetails: DocFileDetail[] = []; if (coreResults.length > 0) { rawContent += '\n## MoonBit Core Source Code\n'; + log(`[代码搜索] 找到 ${coreResults.length} 个 Core 文件:`); for (const r of coreResults) { files.push(r.relativePath); - rawContent += `\n### ${ - r.relativePath - }\n\`\`\`moonbit\n${r.matchingLines.join('\n')}\n\`\`\`\n`; + const content = r.matchingLines.join('\n'); + const preview = truncateText(content, 200); + log(` - ${r.relativePath}: ${r.matchingLines.length} 行匹配`); + log(` 内容: ${truncateText(content, 100)}`); + rawContent += `\n### ${r.relativePath}\n\`\`\`moonbit\n${content}\n\`\`\`\n`; + + // Add detailed info + codeDetails.push({ + path: r.relativePath, + matchCount: + r.matchingLines.filter((l) => l.startsWith('>>>')).length || r.matchingLines.length, + preview, + }); } + } else { + log(`[代码搜索] Core 目录未找到匹配`); } if (docResults.length > 0) { rawContent += '\n## Documentation\n'; + log(`[文档搜索] 找到 ${docResults.length} 个文档文件:`); for (const r of docResults) { files.push(r.relativePath); - rawContent += `\n### ${r.relativePath}\n${r.matchingLines.slice(0, 20).join('\n')}\n`; + const content = r.matchingLines.slice(0, 20).join('\n'); + const preview = truncateText(content, 200); + log(` - ${r.relativePath}: ${r.matchingLines.length} 行匹配`); + log(` 内容: ${truncateText(content, 100)}`); + rawContent += `\n### ${r.relativePath}\n${content}\n`; + + // Add doc details with preview + docDetails.push({ + name: r.relativePath, + type: 'text/markdown', + preview, + }); } + } else { + log(`[文档搜索] 未找到匹配的文档`); } + log( + `[搜索汇总] 总计: ${coreResults.length + docResults.length} 个匹配, rawContent 长度: ${rawContent.length}` + ); + return { totalMatches: coreResults.length + docResults.length, files, rawContent, + codeDetails, + docDetails, }; } diff --git a/src/server.ts b/src/server.ts index 9a5fe98..e338f3e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,5 +1,8 @@ import http from 'http'; import { randomUUID } from 'crypto'; +import { spawn } from 'child_process'; +import path from 'path'; +import fs from 'fs'; import { z } from 'zod'; import { GoogleGenAI } from '@google/genai'; import { Task, TaskProgress, DailyUsage, DAILY_LIMIT } from './types.js'; @@ -48,6 +51,11 @@ const CreateQuerySchema = z.object({ query: z.string().min(1), }); +const MoonCommandSchema = z.object({ + command: z.string().min(1), // e.g., "doc", "doc @json", "doc String::*rev*" + cwd: z.string().optional(), // Optional working directory, defaults to source/core +}); + // ---------- Task Management ---------- const tasks = new Map(); @@ -157,6 +165,206 @@ async function handleCreateQuery( }); } +// ---------- Moon Command Execution ---------- + +async function handleMoonCommand( + req: http.IncomingMessage, + res: http.ServerResponse, + rootDir: string +) { + let raw = ''; + req.on('data', (chunk) => (raw += chunk)); + req.on('end', async () => { + try { + const parsed = raw ? JSON.parse(raw) : {}; + const data = MoonCommandSchema.parse(parsed); + + // Default to source/core directory + const cwd = data.cwd ? path.resolve(rootDir, data.cwd) : path.join(rootDir, 'source', 'core'); + + console.log( + `[${new Date().toISOString()}] Running moon command: moon ${data.command} in ${cwd}` + ); + + // Execute moon command + const result = await runMoonCommand(data.command, cwd); + respondJson(res, 200, result); + } catch (error) { + console.error(`[${new Date().toISOString()}] Error handling moon command:`, error); + respondJson(res, 400, { + error: error instanceof Error ? error.message : String(error), + }); + } + }); +} + +// ---------- Source Tree Listing ---------- + +interface TreeNode { + name: string; + type: 'file' | 'directory'; + path: string; + children?: TreeNode[]; +} + +function buildDirectoryTree( + dirPath: string, + relativeTo: string, + maxDepth: number = 3, + currentDepth: number = 0 +): TreeNode | null { + try { + const stat = fs.statSync(dirPath); + const name = path.basename(dirPath); + const relPath = path.relative(relativeTo, dirPath); + + // Skip hidden files/directories and common non-essential directories + if (name.startsWith('.') || name === 'node_modules' || name === 'target') { + return null; + } + + if (stat.isFile()) { + // Only include relevant file types + const ext = path.extname(name).toLowerCase(); + if (['.mbt', '.mbti', '.md', '.json', '.txt'].includes(ext)) { + return { name, type: 'file', path: relPath }; + } + return null; + } + + if (stat.isDirectory() && currentDepth < maxDepth) { + const entries = fs.readdirSync(dirPath); + const children: TreeNode[] = []; + + for (const entry of entries.sort()) { + const childPath = path.join(dirPath, entry); + const childNode = buildDirectoryTree(childPath, relativeTo, maxDepth, currentDepth + 1); + if (childNode) { + children.push(childNode); + } + } + + // Only include directories that have relevant children or are package roots + const hasPkgJson = entries.includes('moon.pkg.json'); + if (children.length > 0 || hasPkgJson) { + return { name, type: 'directory', path: relPath, children }; + } + } + + return null; + } catch (error) { + console.error(`Error reading ${dirPath}:`, error); + return null; + } +} + +function handleSourceTree(res: http.ServerResponse, rootDir: string) { + console.log(`[${new Date().toISOString()}] Handling source tree request`); + + const sourcePath = path.join(rootDir, 'source'); + + try { + // Build the tree with depth 3 + const tree = buildDirectoryTree(sourcePath, sourcePath, 3); + + // Also provide a flat list of all packages (directories with moon.pkg.json) + const packages: string[] = []; + + function findPackages(dir: string, prefix: string = '') { + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const hasPkg = entries.some((e) => e.name === 'moon.pkg.json'); + + if (hasPkg) { + packages.push(prefix || '(root)'); + } + + for (const entry of entries) { + if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'target') { + const newPrefix = prefix ? `${prefix}/${entry.name}` : entry.name; + findPackages(path.join(dir, entry.name), newPrefix); + } + } + } catch (e) { + // Ignore errors for individual directories + } + } + + findPackages(path.join(rootDir, 'source', 'core')); + + respondJson(res, 200, { + tree, + packages: packages.sort(), + sourcePath: 'source/', + }); + } catch (error) { + console.error(`[${new Date().toISOString()}] Error building source tree:`, error); + respondJson(res, 500, { + error: error instanceof Error ? error.message : String(error), + }); + } +} + +function runMoonCommand( + command: string, + cwd: string +): Promise<{ success: boolean; stdout: string; stderr: string; exitCode: number }> { + return new Promise((resolve) => { + // Split command into args (e.g., "doc @json" -> ["doc", "@json"]) + const args = command.split(/\s+/).filter(Boolean); + + console.log(`[${new Date().toISOString()}] Spawning: moon ${args.join(' ')} in ${cwd}`); + + const proc = spawn('moon', args, { + cwd, + env: { ...process.env }, + shell: true, + }); + + let stdout = ''; + let stderr = ''; + + proc.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + proc.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + proc.on('close', (code) => { + console.log(`[${new Date().toISOString()}] Moon command completed with exit code: ${code}`); + resolve({ + success: code === 0, + stdout, + stderr, + exitCode: code ?? 1, + }); + }); + + proc.on('error', (err) => { + console.error(`[${new Date().toISOString()}] Moon command spawn error:`, err); + resolve({ + success: false, + stdout, + stderr: err.message, + exitCode: 1, + }); + }); + + // Timeout after 30 seconds + setTimeout(() => { + proc.kill(); + resolve({ + success: false, + stdout, + stderr: 'Command timed out after 30 seconds', + exitCode: 124, + }); + }, 30000); + }); +} + async function handleGetQuery(req: http.IncomingMessage, res: http.ServerResponse, id: string) { console.log(`[${new Date().toISOString()}] Handling get query. ID: ${id}`); const task = tasks.get(id); @@ -177,14 +385,41 @@ async function handleGetQuery(req: http.IncomingMessage, res: http.ServerRespons ); if (task.status === 'done') { - respondJson(res, 200, { + const doneResponse: any = { status: 'done', content: task.result, stats: { pollCount: task.pollCount, totalTimeSeconds: elapsedSeconds, }, - }); + }; + // Include final counts and files if available + if (task.progress) { + if (task.progress.codeResultsCount !== undefined) { + doneResponse.codeResultsCount = task.progress.codeResultsCount; + } + if (task.progress.docsResultsCount !== undefined) { + doneResponse.docsResultsCount = task.progress.docsResultsCount; + } + if (task.progress.codeFiles && task.progress.codeFiles.length > 0) { + doneResponse.codeFiles = task.progress.codeFiles; + } + if (task.progress.docsFiles && task.progress.docsFiles.length > 0) { + doneResponse.docsFiles = task.progress.docsFiles; + } + // Include detailed search results + if (task.progress.codeSearchDetails && task.progress.codeSearchDetails.length > 0) { + doneResponse.codeSearchDetails = task.progress.codeSearchDetails; + } + if (task.progress.docsSearchDetails && task.progress.docsSearchDetails.length > 0) { + doneResponse.docsSearchDetails = task.progress.docsSearchDetails; + } + // Include query stats (LLM calls, search calls, etc.) + if (task.progress.stats) { + doneResponse.queryStats = task.progress.stats; + } + } + respondJson(res, 200, doneResponse); } else if (task.status === 'error') { respondJson(res, 200, { status: 'error', @@ -219,6 +454,24 @@ async function handleGetQuery(req: http.IncomingMessage, res: http.ServerRespons if (task.progress.partialContent) { progressResponse.partialContent = task.progress.partialContent; } + // Include file lists + if (task.progress.codeFiles && task.progress.codeFiles.length > 0) { + progressResponse.codeFiles = task.progress.codeFiles; + } + if (task.progress.docsFiles && task.progress.docsFiles.length > 0) { + progressResponse.docsFiles = task.progress.docsFiles; + } + // Include detailed search results + if (task.progress.codeSearchDetails && task.progress.codeSearchDetails.length > 0) { + progressResponse.codeSearchDetails = task.progress.codeSearchDetails; + } + if (task.progress.docsSearchDetails && task.progress.docsSearchDetails.length > 0) { + progressResponse.docsSearchDetails = task.progress.docsSearchDetails; + } + // Include query stats (LLM calls, search calls, etc.) + if (task.progress.stats) { + progressResponse.queryStats = task.progress.stats; + } } respondJson(res, 200, progressResponse); @@ -239,10 +492,11 @@ async function processTask(task: Task, ai: GoogleGenAI, rootDir: string) { task.result = result; task.status = 'done'; task.updatedAt = Date.now(); - task.progress = { - phase: 'complete', - message: '查询完成', - }; + // Keep the progress info (with counts and files) but update phase + if (task.progress) { + task.progress.phase = 'complete'; + task.progress.message = '查询完成'; + } console.log(`[${new Date().toISOString()}] Task completed. ID: ${task.id}`); } catch (error) { console.error(`[${new Date().toISOString()}] Task failed. ID: ${task.id}, Error:`, error); @@ -282,6 +536,16 @@ export function createServer(ai: GoogleGenAI, rootDir: string): http.Server { return; } + if (method === 'POST' && url === '/moon') { + handleMoonCommand(req, res, rootDir); + return; + } + + if (method === 'GET' && url === '/source-tree') { + handleSourceTree(res, rootDir); + return; + } + if (method === 'GET' && url.startsWith('/query/')) { const id = url.split('/query/')[1]; handleGetQuery(req, res, id); diff --git a/src/types.ts b/src/types.ts index a4cf7f8..55f5982 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,6 +24,34 @@ export interface TaskProgress { partialContent?: string; // Incremental content available codeResultsCount?: number; // Number of code matches found docsResultsCount?: number; // Number of doc matches found + codeFiles?: string[]; // List of matched code files + docsFiles?: string[]; // List of docs files used + // Detailed search results for display + codeSearchDetails?: CodeFileDetail[]; // Detailed code search results + docsSearchDetails?: DocFileDetail[]; // Detailed doc search info + // Statistics + stats?: QueryStats; // Query process statistics +} + +// Detailed info for a matched code file +export interface CodeFileDetail { + path: string; // Relative file path + matchCount: number; // Number of matching lines + preview: string; // Preview of matched content (truncated) +} + +// Detailed info for a doc file +export interface DocFileDetail { + name: string; // Display name + type: string; // MIME type or description + preview?: string; // Preview of doc content +} + +// Statistics for the query process +export interface QueryStats { + llmCalls: number; // Number of LLM API calls + searchCalls: number; // Number of search operations + toolCalls: number; // Number of tool/command calls } export interface Task { diff --git a/src/utils.ts b/src/utils.ts index 87d02e8..0efb997 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -17,3 +17,13 @@ export function logError(message: string, ...args: unknown[]): void { export function logWarn(message: string, ...args: unknown[]): void { console.warn(`[${timestamp()}] ${message}`, ...args); } + +/** + * Truncate long text for logging, showing first and last N characters + */ +export function truncateText(text: string, headTail: number = 100): string { + if (text.length <= headTail * 2 + 10) { + return text; + } + return `${text.slice(0, headTail)}...【省略 ${text.length - headTail * 2} 字符】...${text.slice(-headTail)}`; +} From b0ed70ee75f48a6986d18cd25bc9b285c266b8c7 Mon Sep 17 00:00:00 2001 From: tiye Date: Sun, 21 Dec 2025 01:07:55 +0800 Subject: [PATCH 11/13] prefer gemini-3 --- k8s.yaml | 2 +- src/genai.ts | 2 +- src/query.ts | 4 ++-- src/search.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/k8s.yaml b/k8s.yaml index 6bac6f2..f85d101 100644 --- a/k8s.yaml +++ b/k8s.yaml @@ -46,7 +46,7 @@ spec: containers: - name: moonverse # TODO: Replace with your actual image registry - image: ghcr.io/termina/moonverse.ts:pr-2f10378 + image: ghcr.io/termina/moonverse.ts:pr-80488c3 imagePullPolicy: Always ports: - containerPort: 8080 diff --git a/src/genai.ts b/src/genai.ts index 3b2145e..1f72d99 100644 --- a/src/genai.ts +++ b/src/genai.ts @@ -131,7 +131,7 @@ export async function testConnection(ai: GoogleGenAI): Promise { console.log(`[${new Date().toISOString()}] Testing Interactions API...`); await ai.interactions.create({ - model: 'gemini-2.5-flash', + model: 'gemini-3-flash-preview', input: [{ type: 'text', text: 'Hello' }], }); console.log(`[${new Date().toISOString()}] Interactions API test successful.`); diff --git a/src/query.ts b/src/query.ts index 9856a93..ad8e8b0 100644 --- a/src/query.ts +++ b/src/query.ts @@ -180,7 +180,7 @@ ${query} log(`[文档查询] 发送请求到 Gemini, prompt 长度: ${promptText.length}`); log(`[文档查询] Prompt: ${truncateText(promptText, 100)}`); const response = await ai.models.generateContent({ - model: 'gemini-2.5-flash', + model: 'gemini-3-flash-preview', contents: parts, }); const responseText = response.text || '未生成回答'; @@ -397,7 +397,7 @@ ${codeResults.rawContent} log(`[混合查询] Prompt 预览: ${truncateText(promptText, 200)}`); stats.llmCalls++; const response = await ai.models.generateContent({ - model: 'gemini-2.5-flash', + model: 'gemini-3-flash-preview', contents: parts, }); const responseText = response.text || '未生成回答'; diff --git a/src/search.ts b/src/search.ts index bcabd4a..5c0da65 100644 --- a/src/search.ts +++ b/src/search.ts @@ -403,7 +403,7 @@ ${rawResults} try { const response = await ai.models.generateContent({ - model: 'gemini-2.5-flash', + model: 'gemini-3-flash-preview', contents: prompt, }); From b1b87f8fe8325be2fb24aa0fe6f7272782b86631 Mon Sep 17 00:00:00 2001 From: tiye Date: Sun, 21 Dec 2025 01:18:22 +0800 Subject: [PATCH 12/13] llm rate limit --- src/server.ts | 97 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 3 deletions(-) diff --git a/src/server.ts b/src/server.ts index e338f3e..bcb9fec 100644 --- a/src/server.ts +++ b/src/server.ts @@ -44,6 +44,80 @@ function getGlobalUsageInfo(): { date: string; used: number; remaining: number } }; } +// ---------- IP Rate Limiting (per hour) ---------- + +const IP_HOURLY_LIMIT = 20; + +interface IPUsageRecord { + hour: string; // Format: "YYYY-MM-DD-HH" + count: number; +} + +const ipUsage = new Map(); + +function getCurrentHour(): string { + const now = new Date(); + return `${now.toISOString().split('T')[0]}-${String(now.getUTCHours()).padStart(2, '0')}`; +} + +function getClientIP(req: http.IncomingMessage): string { + // Check X-Forwarded-For header first (for proxied requests) + const forwarded = req.headers['x-forwarded-for']; + if (forwarded) { + const ips = (Array.isArray(forwarded) ? forwarded[0] : forwarded).split(','); + return ips[0].trim(); + } + // Fall back to socket remote address + return req.socket.remoteAddress || 'unknown'; +} + +function checkIPRateLimit(ip: string): { allowed: boolean; remaining: number; resetIn: number } { + const currentHour = getCurrentHour(); + + // Clean up old entries periodically (simple cleanup: remove if hour changed) + const record = ipUsage.get(ip); + if (record && record.hour !== currentHour) { + ipUsage.delete(ip); + } + + const usage = ipUsage.get(ip); + + if (!usage) { + // First request this hour + ipUsage.set(ip, { hour: currentHour, count: 1 }); + return { allowed: true, remaining: IP_HOURLY_LIMIT - 1, resetIn: getMinutesToNextHour() }; + } + + if (usage.count >= IP_HOURLY_LIMIT) { + return { allowed: false, remaining: 0, resetIn: getMinutesToNextHour() }; + } + + usage.count++; + return { allowed: true, remaining: IP_HOURLY_LIMIT - usage.count, resetIn: getMinutesToNextHour() }; +} + +function getMinutesToNextHour(): number { + const now = new Date(); + return 60 - now.getMinutes(); +} + +function getIPUsageInfo(ip: string): { ip: string; hour: string; used: number; remaining: number; resetInMinutes: number } { + const currentHour = getCurrentHour(); + const record = ipUsage.get(ip); + + if (!record || record.hour !== currentHour) { + return { ip, hour: currentHour, used: 0, remaining: IP_HOURLY_LIMIT, resetInMinutes: getMinutesToNextHour() }; + } + + return { + ip, + hour: currentHour, + used: record.count, + remaining: IP_HOURLY_LIMIT - record.count, + resetInMinutes: getMinutesToNextHour(), + }; +} + // ---------- Validation ---------- const CreateQuerySchema = z.object({ @@ -84,6 +158,22 @@ async function handleCreateQuery( ) { // Extract API key from header const headerApiKey = req.headers['x-api-key'] as string | undefined; + const clientIP = getClientIP(req); + + // If user provides their own API key, skip IP rate limiting + if (!headerApiKey) { + // Check IP rate limit only for requests using global key + const ipLimit = checkIPRateLimit(clientIP); + if (!ipLimit.allowed) { + console.log(`[${new Date().toISOString()}] IP rate limit exceeded for ${clientIP}`); + respondJson(res, 429, { + error: `IP 请求频率超限,每小时最多 ${IP_HOURLY_LIMIT} 次`, + hint: `请等待 ${ipLimit.resetIn} 分钟后重试,或提供自己的 API Key 绕过限制`, + ipUsage: getIPUsageInfo(clientIP), + }); + return; + } + } let raw = ''; req.on('data', (chunk) => (raw += chunk)); @@ -97,9 +187,9 @@ async function handleCreateQuery( let usingGlobalKey = false; if (headerApiKey) { - // Use user-provided API key + // Use user-provided API key - no rate limiting ai = createAIClientWithKey(headerApiKey); - console.log(`[${new Date().toISOString()}] Using user-provided API key`); + console.log(`[${new Date().toISOString()}] Using user-provided API key from IP ${clientIP} (no rate limit)`); } else { // Check global usage limit const usage = checkAndIncrementGlobalUsage(); @@ -113,8 +203,9 @@ async function handleCreateQuery( } ai = defaultAI; usingGlobalKey = true; + const ipInfo = getIPUsageInfo(clientIP); console.log( - `[${new Date().toISOString()}] Using global API key (${usage.remaining} remaining today)` + `[${new Date().toISOString()}] Using global API key from IP ${clientIP} (${usage.remaining} global, ${ipInfo.remaining} IP remaining)` ); } From c6a75c5f0ef69c7426aa6b366a559d105792b02a Mon Sep 17 00:00:00 2001 From: tiye Date: Sun, 21 Dec 2025 10:17:48 +0800 Subject: [PATCH 13/13] some protection on token usage --- k8s.yaml | 2 +- src/query.ts | 7 ++++++- src/server.ts | 35 ++++++++++++++++++++++++++++++----- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/k8s.yaml b/k8s.yaml index f85d101..488ddbe 100644 --- a/k8s.yaml +++ b/k8s.yaml @@ -46,7 +46,7 @@ spec: containers: - name: moonverse # TODO: Replace with your actual image registry - image: ghcr.io/termina/moonverse.ts:pr-80488c3 + image: ghcr.io/termina/moonverse.ts:pr-0ed76c2 imagePullPolicy: Always ports: - containerPort: 8080 diff --git a/src/query.ts b/src/query.ts index ad8e8b0..2f8e28c 100644 --- a/src/query.ts +++ b/src/query.ts @@ -321,7 +321,12 @@ async function runHybridQuery(ai: GoogleGenAI, rootDir: string, query: string): } // Build the prompt - keep it minimal and focused - let promptText = `你是一位 MoonBit 编程语言专家。用户正在询问: + // System constraint: only answer programming-related questions + let promptText = `你是一位 MoonBit 编程语言专家。 + +**重要约束**:你只回答与 MoonBit 编程语言相关的问题。如果用户的问题与编程无关(如闲聊、其他领域的问题),请礼貌地拒绝并说明你只能回答 MoonBit 编程相关的问题。 + +用户正在询问: "${query}" diff --git a/src/server.ts b/src/server.ts index bcb9fec..2a357cf 100644 --- a/src/server.ts +++ b/src/server.ts @@ -93,7 +93,11 @@ function checkIPRateLimit(ip: string): { allowed: boolean; remaining: number; re } usage.count++; - return { allowed: true, remaining: IP_HOURLY_LIMIT - usage.count, resetIn: getMinutesToNextHour() }; + return { + allowed: true, + remaining: IP_HOURLY_LIMIT - usage.count, + resetIn: getMinutesToNextHour(), + }; } function getMinutesToNextHour(): number { @@ -101,12 +105,24 @@ function getMinutesToNextHour(): number { return 60 - now.getMinutes(); } -function getIPUsageInfo(ip: string): { ip: string; hour: string; used: number; remaining: number; resetInMinutes: number } { +function getIPUsageInfo(ip: string): { + ip: string; + hour: string; + used: number; + remaining: number; + resetInMinutes: number; +} { const currentHour = getCurrentHour(); const record = ipUsage.get(ip); if (!record || record.hour !== currentHour) { - return { ip, hour: currentHour, used: 0, remaining: IP_HOURLY_LIMIT, resetInMinutes: getMinutesToNextHour() }; + return { + ip, + hour: currentHour, + used: 0, + remaining: IP_HOURLY_LIMIT, + resetInMinutes: getMinutesToNextHour(), + }; } return { @@ -120,9 +136,16 @@ function getIPUsageInfo(ip: string): { ip: string; hour: string; used: number; r // ---------- Validation ---------- +const MAX_QUERY_LENGTH = 1000; + const CreateQuerySchema = z.object({ type: z.enum(['docs', 'source', 'hybrid']), - query: z.string().min(1), + query: z + .string() + .min(1) + .max(MAX_QUERY_LENGTH, { + message: `查询内容过长,最多支持 ${MAX_QUERY_LENGTH} 个字符`, + }), }); const MoonCommandSchema = z.object({ @@ -189,7 +212,9 @@ async function handleCreateQuery( if (headerApiKey) { // Use user-provided API key - no rate limiting ai = createAIClientWithKey(headerApiKey); - console.log(`[${new Date().toISOString()}] Using user-provided API key from IP ${clientIP} (no rate limit)`); + console.log( + `[${new Date().toISOString()}] Using user-provided API key from IP ${clientIP} (no rate limit)` + ); } else { // Check global usage limit const usage = checkAndIncrementGlobalUsage();