chore: remove scout-bot and update workspace root package.json
Some checks failed
Deploy to 110 WOOO Server / deploy (push) Failing after 7s
Some checks failed
Deploy to 110 WOOO Server / deploy (push) Failing after 7s
This commit is contained in:
@@ -1,15 +0,0 @@
|
|||||||
FROM node:20-alpine
|
|
||||||
ENV PNPM_HOME="/pnpm"
|
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
|
||||||
RUN corepack enable
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
|
||||||
COPY apps/scout-bot/package.json apps/scout-bot/
|
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
COPY apps/scout-bot ./apps/scout-bot
|
|
||||||
|
|
||||||
CMD ["pnpm", "--dir", "apps/scout-bot", "exec", "tsx", "src/index.ts"]
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "scout-bot",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "",
|
|
||||||
"main": "index.js",
|
|
||||||
"scripts": {
|
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
|
||||||
},
|
|
||||||
"keywords": [],
|
|
||||||
"author": "",
|
|
||||||
"license": "ISC",
|
|
||||||
"type": "commonjs",
|
|
||||||
"dependencies": {
|
|
||||||
"@octokit/rest": "^22.0.1",
|
|
||||||
"dotenv": "^17.4.2",
|
|
||||||
"node-cron": "^4.2.1",
|
|
||||||
"zod": "^4.4.3"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^20.19.42",
|
|
||||||
"@types/node-cron": "^3.0.11",
|
|
||||||
"tsx": "^4.22.4",
|
|
||||||
"typescript": "^5.9.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,373 +0,0 @@
|
|||||||
import { Octokit } from "@octokit/rest";
|
|
||||||
import cron from "node-cron";
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
|
|
||||||
const VIBEWORK_API_URL = process.env.VIBEWORK_API_URL || "https://agent.wooo.work/api";
|
|
||||||
const SCOUT_API_KEY = process.env.SCOUT_API_KEY;
|
|
||||||
const SCOUT_AGENT_ID = process.env.SCOUT_AGENT_ID || "scout_official_1";
|
|
||||||
const SCOUT_CRON_EXPRESSION = process.env.SCOUT_CRON_EXPRESSION || "*/3 * * * *";
|
|
||||||
const SCOUT_ISSUE_LABELS_RAW = process.env.SCOUT_ISSUE_LABELS || process.env.SCOUT_ISSUE_LABEL || "";
|
|
||||||
const SCOUT_ENABLED = (process.env.SCOUT_ENABLED || "true").toLowerCase() !== "false";
|
|
||||||
const SCOUT_TARGET_REPOS_RAW =
|
|
||||||
process.env.SCOUT_TARGET_REPOS ||
|
|
||||||
"open-webui/open-webui,microsoft/vscode,vercel/next.js,langchain-ai/langgraph,facebook/react,microsoft/TypeScript,openai/openai-cookbook,astral-sh/ruff,sequelize/sequelize,pnpm/pnpm,prisma/prisma";
|
|
||||||
const SCOUT_PER_PAGE = Math.min(Math.max(parseInt(process.env.SCOUT_PER_PAGE || "50", 10), 1), 100);
|
|
||||||
const SCOUT_MAX_ISSUES_PER_SCAN = Math.max(parseInt(process.env.SCOUT_MAX_ISSUES_PER_SCAN || "60", 10), 1);
|
|
||||||
const SCOUT_COMMENT_DELAY_SECONDS = Math.max(parseInt(process.env.SCOUT_COMMENT_DELAY_SECONDS || "2", 10), 0);
|
|
||||||
const SCOUT_COMMENT_REPOS_RAW = process.env.SCOUT_COMMENT_REPOS || "";
|
|
||||||
|
|
||||||
const SCOUT_COMMENT_REPOS = SCOUT_COMMENT_REPOS_RAW === "*"
|
|
||||||
? ["*"]
|
|
||||||
: SCOUT_COMMENT_REPOS_RAW.split(",").map(r => r.trim().toLowerCase()).filter(Boolean);
|
|
||||||
|
|
||||||
|
|
||||||
const SCOUT_ISSUE_LABELS = SCOUT_ISSUE_LABELS_RAW
|
|
||||||
.split(",")
|
|
||||||
.map((entry) => entry.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
const SCOUT_TARGET_REPOS = SCOUT_TARGET_REPOS_RAW
|
|
||||||
.split(",")
|
|
||||||
.map((entry) => entry.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((entry) => {
|
|
||||||
const [owner, repo] = entry.split("/");
|
|
||||||
return { owner, repo };
|
|
||||||
})
|
|
||||||
.filter((repo) => repo.owner && repo.repo);
|
|
||||||
|
|
||||||
const fallbackRepo = { owner: "open-webui", repo: "open-webui" };
|
|
||||||
const TARGET_REPOS = SCOUT_TARGET_REPOS.length > 0 ? SCOUT_TARGET_REPOS : [fallbackRepo];
|
|
||||||
|
|
||||||
if (!SCOUT_API_KEY) {
|
|
||||||
console.warn("WARNING: SCOUT_API_KEY is not set. Draft API may be rejected by server.");
|
|
||||||
}
|
|
||||||
if (!GITHUB_TOKEN) {
|
|
||||||
console.warn("WARNING: GITHUB_TOKEN is not set. Scout bot cannot post comments.");
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeText(value: string, maxLength: number, fallback = "N/A") {
|
|
||||||
const cleanText = (value || "").replace(/\s+/g, " ").trim();
|
|
||||||
if (!cleanText) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
return cleanText.length <= maxLength ? cleanText : `${cleanText.slice(0, Math.max(maxLength - 1, 0))}…`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDraftPayload(issueTitle: string, issueBody: string, issueUrl: string) {
|
|
||||||
const prefix = "GitHub Issue: ";
|
|
||||||
const titleMax = 120;
|
|
||||||
const descriptionMax = 2000;
|
|
||||||
const sourceSuffix = `\n\nSource: ${issueUrl}`;
|
|
||||||
const sourceAllowance = Math.max(descriptionMax - sourceSuffix.length, 20);
|
|
||||||
|
|
||||||
const safeTitle = normalizeText(issueTitle, Math.max(titleMax - prefix.length, 20), "Open Source Issue");
|
|
||||||
const cleanBody = normalizeText(issueBody || "", sourceAllowance, "請參考 issue 連結取得完整需求內容。");
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: `${prefix}${safeTitle}`,
|
|
||||||
description: `${cleanBody}${sourceSuffix}`,
|
|
||||||
reward_amount: 0, // $0.00 Free AI Bounty
|
|
||||||
reward_currency: "USD",
|
|
||||||
required_stack: ["TypeScript", "GitHub"],
|
|
||||||
test_file_content: "// Needs manual test writing based on issue",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const octokit = new Octokit({
|
|
||||||
auth: GITHUB_TOKEN,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function wait(seconds: number) {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getGitHubBackoffSeconds(error: unknown): number {
|
|
||||||
const status =
|
|
||||||
typeof error === "object" &&
|
|
||||||
error !== null &&
|
|
||||||
"status" in error &&
|
|
||||||
typeof (error as { status?: unknown }).status === "number"
|
|
||||||
? (error as { status: number }).status
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const message =
|
|
||||||
typeof error === "object" &&
|
|
||||||
error !== null &&
|
|
||||||
"message" in error &&
|
|
||||||
typeof (error as { message?: unknown }).message === "string"
|
|
||||||
? (error as { message: string }).message.toLowerCase()
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const responseHeaders =
|
|
||||||
typeof error === "object" &&
|
|
||||||
error !== null &&
|
|
||||||
"response" in error &&
|
|
||||||
error.response &&
|
|
||||||
typeof error.response === "object" &&
|
|
||||||
"headers" in error.response
|
|
||||||
? (error.response as { headers?: Record<string, string> }).headers
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const lowerCaseHeaders = responseHeaders
|
|
||||||
? Object.fromEntries(
|
|
||||||
Object.entries(responseHeaders).map(([key, value]) => [key.toLowerCase(), value])
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof status === "number" &&
|
|
||||||
status === 403 &&
|
|
||||||
message.includes("secondary rate limit")
|
|
||||||
) {
|
|
||||||
const resetAtRaw = lowerCaseHeaders?.["x-ratelimit-reset"];
|
|
||||||
if (resetAtRaw) {
|
|
||||||
const resetAt = parseInt(resetAtRaw, 10);
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const resetWait = resetAt - now + 5;
|
|
||||||
if (resetWait > 0) {
|
|
||||||
return resetWait;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 60;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof status === "number" && status === 403 && message.includes("rate limit exceeded")) {
|
|
||||||
const resetAtRaw = lowerCaseHeaders?.["x-ratelimit-reset"];
|
|
||||||
if (resetAtRaw) {
|
|
||||||
const resetAt = parseInt(resetAtRaw, 10);
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const resetWait = resetAt - now + 5;
|
|
||||||
if (resetWait > 0) {
|
|
||||||
return resetWait;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 60;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function postDraftWithRetry(payload: {
|
|
||||||
scout_id: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
reward_amount: number;
|
|
||||||
reward_currency: string;
|
|
||||||
required_stack: string[];
|
|
||||||
test_file_content: string;
|
|
||||||
}) {
|
|
||||||
let lastError: unknown = null;
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${VIBEWORK_API_URL}/scout/draft`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": `Bearer ${SCOUT_API_KEY}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const bodyText = await response.text();
|
|
||||||
throw new Error(`Draft API failed (HTTP ${response.status}): ${bodyText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error;
|
|
||||||
const attemptText = attempt + 1;
|
|
||||||
const maxAttempts = 3;
|
|
||||||
console.error(`Failed to draft bounty task (attempt ${attemptText}/${maxAttempts}):`, error);
|
|
||||||
if (attempt < maxAttempts - 1) {
|
|
||||||
await wait(Math.min(5 * (attempt + 1), 15));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error("Failed to draft bounty task after retries:", lastError);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isIssueAlreadyDrafted(issueUrl: string) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`${VIBEWORK_API_URL}/scout/issue-exists?issue_url=${encodeURIComponent(issueUrl)}`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"Authorization": `Bearer ${SCOUT_API_KEY}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const bodyText = await response.text();
|
|
||||||
console.warn(`Issue existence check failed (HTTP ${response.status}): ${bodyText}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json() as { exists?: boolean };
|
|
||||||
return Boolean(result.exists);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Issue existence check error:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function draftBountyTask(issueTitle: string, issueBody: string, issueUrl: string) {
|
|
||||||
const payload = {
|
|
||||||
scout_id: SCOUT_AGENT_ID,
|
|
||||||
...buildDraftPayload(issueTitle, issueBody || "", issueUrl),
|
|
||||||
};
|
|
||||||
|
|
||||||
return postDraftWithRetry(payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processIssue(owner: string, repo: string, issue: any) {
|
|
||||||
console.log(`Processing issue #${issue.number}: ${issue.title}`);
|
|
||||||
const issueUrl = issue.html_url;
|
|
||||||
|
|
||||||
if (!issueUrl) {
|
|
||||||
console.log("Issue URL missing, skip.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const alreadyDrafted = await isIssueAlreadyDrafted(issueUrl);
|
|
||||||
if (alreadyDrafted) {
|
|
||||||
console.log(`Issue already has draft task: ${issueUrl}, skipping.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we already commented
|
|
||||||
if (GITHUB_TOKEN) {
|
|
||||||
const comments = await octokit.issues.listComments({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issue_number: issue.number,
|
|
||||||
});
|
|
||||||
|
|
||||||
const alreadyCommented = comments.data.some((c) => c.body?.includes("agent.wooo.work"));
|
|
||||||
if (alreadyCommented) {
|
|
||||||
console.log(`Already commented on #${issue.number}, skipping.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate draft task on VibeWork
|
|
||||||
const draft = await draftBountyTask(issue.title, issue.body || "", issueUrl);
|
|
||||||
|
|
||||||
if (!draft) {
|
|
||||||
console.log(`Failed to generate draft for #${issue.number}, skipping comment.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const commentBody = `Hi there! 👋 我們注意到這個 issue 被標記為需要社群協助。
|
|
||||||
|
|
||||||
**VibeWork** 是一個開源的 AI Agent 協定,目前已經連結了全球大量的自主 AI Agent。這些 Agent 可以為您**免費 (FREE)** 嘗試解決這個問題。
|
|
||||||
|
|
||||||
任何人類開發者或是 AI Agent 都可以認領這個 issue,並透過我們的 MCP 伺服器自動提交 Pull Request:
|
|
||||||
\`\`\`bash
|
|
||||||
npx -y @agent-bounty/mcp-server --endpoint https://agent.wooo.work
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
🔗 [點此追蹤 AI 的解題進度](${draft.checkout_url}) (Task ID: \`${draft.task_id}\`)
|
|
||||||
|
|
||||||
*(備註:我們是一個開源的防詐欺 Bounty 協定,旨在幫助開源專案維護者更快地利用 AI 解決問題。如果您不希望我們在此張貼此資訊,請隨時告知!)*
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (GITHUB_TOKEN) {
|
|
||||||
const isAllowedToComment = SCOUT_COMMENT_REPOS.includes("*") || SCOUT_COMMENT_REPOS.includes(`${owner}/${repo}`.toLowerCase());
|
|
||||||
|
|
||||||
if (!isAllowedToComment) {
|
|
||||||
console.log(`[SILENT MODE] Successfully generated draft task for #${issue.number}, but skipping GitHub comment (repo not in SCOUT_COMMENT_REPOS).`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await octokit.issues.createComment({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issue_number: issue.number,
|
|
||||||
body: commentBody,
|
|
||||||
});
|
|
||||||
console.log(`Successfully commented on #${issue.number}`);
|
|
||||||
await wait(SCOUT_COMMENT_DELAY_SECONDS);
|
|
||||||
} catch (error) {
|
|
||||||
const backoffSeconds = getGitHubBackoffSeconds(error);
|
|
||||||
if (backoffSeconds > 0) {
|
|
||||||
console.warn(`GitHub API rate limited; sleeping ${backoffSeconds}s before continuing.`);
|
|
||||||
await wait(backoffSeconds);
|
|
||||||
}
|
|
||||||
console.error(`Failed to comment on #${issue.number}:`, error);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log(`[DRY RUN] Would have posted scout comment for #${issue.number}: ${draft.status}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function scanRepositories() {
|
|
||||||
console.log("Starting GitHub scan...");
|
|
||||||
|
|
||||||
for (const target of TARGET_REPOS) {
|
|
||||||
try {
|
|
||||||
const issues = await octokit.issues.listForRepo({
|
|
||||||
owner: target.owner,
|
|
||||||
repo: target.repo,
|
|
||||||
state: "open",
|
|
||||||
...(SCOUT_ISSUE_LABELS.length > 0 ? { labels: SCOUT_ISSUE_LABELS.join(",") } : {}),
|
|
||||||
per_page: SCOUT_PER_PAGE
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Found ${issues.data.length} open issues in ${target.owner}/${target.repo}`);
|
|
||||||
|
|
||||||
const issueCandidates = issues.data.slice(0, SCOUT_MAX_ISSUES_PER_SCAN);
|
|
||||||
for (const issue of issueCandidates) {
|
|
||||||
if (!issue.pull_request) { // Ignore PRs
|
|
||||||
await processIssue(target.owner, target.repo, issue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (issues.data.length > issueCandidates.length) {
|
|
||||||
console.log(`Limited scan to first ${issueCandidates.length} issues in ${target.owner}/${target.repo}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const backoffSeconds = getGitHubBackoffSeconds(error);
|
|
||||||
if (backoffSeconds > 0) {
|
|
||||||
console.warn(`GitHub API rate limited on ${target.owner}/${target.repo}; sleeping ${backoffSeconds}s and skip remaining repos.`);
|
|
||||||
await wait(backoffSeconds);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.error(`Error scanning ${target.owner}/${target.repo}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bootstrap() {
|
|
||||||
if (!SCOUT_ENABLED) {
|
|
||||||
console.log("SCOUT_ENABLED is false. Scout bot is disabled.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delay first scan a bit so main web API is ready after startup.
|
|
||||||
await wait(10);
|
|
||||||
await scanRepositories();
|
|
||||||
}
|
|
||||||
|
|
||||||
bootstrap();
|
|
||||||
|
|
||||||
if (SCOUT_ENABLED) {
|
|
||||||
// Default: every 1 minute for phase-1 high-velocity inbound traffic
|
|
||||||
cron.schedule(SCOUT_CRON_EXPRESSION, () => {
|
|
||||||
console.log(`Running scheduled GitHub scan (${SCOUT_CRON_EXPRESSION})...`);
|
|
||||||
scanRepositories();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log("Cron schedule is disabled because SCOUT_ENABLED=false.");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("VibeWork Scout Bot started and scheduled.");
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "CommonJS",
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src"
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
scout-bot:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: apps/scout-bot/Dockerfile
|
|
||||||
container_name: agent_bounty_scout_bot
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- NODE_ENV=production
|
|
||||||
# - GITHUB_TOKEN=your_token_here
|
|
||||||
- VIBEWORK_API_URL=http://agent_bounty_web:3000/api
|
|
||||||
- SCOUT_API_KEY=${SCOUT_API_KEY:-dev_scout_key}
|
|
||||||
- SCOUT_AGENT_ID=scout_official_1
|
|
||||||
- SCOUT_ENABLED=${SCOUT_ENABLED:-false}
|
|
||||||
- SCOUT_PER_PAGE=${SCOUT_PER_PAGE:-80}
|
|
||||||
- SCOUT_MAX_ISSUES_PER_SCAN=${SCOUT_MAX_ISSUES_PER_SCAN:-30}
|
|
||||||
networks:
|
|
||||||
- agent-bounty-network
|
|
||||||
|
|
||||||
networks:
|
|
||||||
agent-bounty-network:
|
|
||||||
external: true
|
|
||||||
@@ -12,5 +12,9 @@
|
|||||||
"packageManager": "pnpm@10.32.1",
|
"packageManager": "pnpm@10.32.1",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vitest": "^4.1.8"
|
"vitest": "^4.1.8"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"openai": "^6.42.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user