mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
12 Commits
improve-cl
...
xw/cli-doc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
312993339f | ||
|
|
6f21b6700a | ||
|
|
af49b615b1 | ||
|
|
4651edd5b3 | ||
|
|
d7f72fec9c | ||
|
|
09011c91f8 | ||
|
|
e56fabfc5e | ||
|
|
56f752557c | ||
|
|
5f2ad7fbb0 | ||
|
|
758e30c9a8 | ||
|
|
28017f232e | ||
|
|
3302c31c60 |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -2,7 +2,7 @@
|
||||
# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
||||
|
||||
# Frontend code owners
|
||||
/frontend/ @rbren @amanape
|
||||
/frontend/ @amanape
|
||||
/openhands-ui/ @amanape
|
||||
|
||||
# Evaluation code owners
|
||||
|
||||
@@ -40,7 +40,7 @@ repos:
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies:
|
||||
[types-requests, types-setuptools, types-pyyaml, types-toml, types-docker, pydantic, lxml, types-Markdown]
|
||||
[types-requests, types-setuptools, types-pyyaml, types-toml, types-docker, types-Markdown, pydantic, lxml]
|
||||
# To see gaps add `--html-report mypy-report/`
|
||||
entry: mypy --config-file dev_config/python/mypy.ini openhands/
|
||||
always_run: true
|
||||
|
||||
@@ -80,7 +80,7 @@ openhands
|
||||
<Note>
|
||||
If you have cloned the repository, you can also run the CLI directly using Poetry:
|
||||
|
||||
poetry run python -m openhands.cli.main
|
||||
poetry run openhands
|
||||
</Note>
|
||||
|
||||
3. Set your model, API key, and other preferences using the UI (or alternatively environment variables, below).
|
||||
|
||||
@@ -10,7 +10,6 @@ import huggingface_hub
|
||||
import pandas as pd
|
||||
from datasets import load_dataset
|
||||
from PIL import Image
|
||||
from pydantic import SecretStr
|
||||
|
||||
from evaluation.benchmarks.gaia.scorer import question_scorer
|
||||
from evaluation.benchmarks.gaia.utils import (
|
||||
@@ -80,8 +79,7 @@ def get_config(
|
||||
|
||||
config_copy = copy.deepcopy(config)
|
||||
load_from_toml(config_copy)
|
||||
if config_copy.search_api_key:
|
||||
config.search_api_key = SecretStr(config_copy.search_api_key)
|
||||
config.search_api_key = config_copy.search_api_key
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -85,9 +85,10 @@ describe("ConversationPanel", () => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
// Setup default mock for getUserConversations
|
||||
vi.spyOn(OpenHands, "getUserConversations").mockResolvedValue([
|
||||
...mockConversations,
|
||||
]);
|
||||
vi.spyOn(OpenHands, "getUserConversations").mockResolvedValue({
|
||||
results: [...mockConversations],
|
||||
next_page_id: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the conversations", async () => {
|
||||
@@ -101,7 +102,10 @@ describe("ConversationPanel", () => {
|
||||
|
||||
it("should display an empty state when there are no conversations", async () => {
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockResolvedValue([]);
|
||||
getUserConversationsSpy.mockResolvedValue({
|
||||
results: [],
|
||||
next_page_id: null,
|
||||
});
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
@@ -195,7 +199,10 @@ describe("ConversationPanel", () => {
|
||||
];
|
||||
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockImplementation(async () => mockData);
|
||||
getUserConversationsSpy.mockImplementation(async () => ({
|
||||
results: mockData,
|
||||
next_page_id: null,
|
||||
}));
|
||||
|
||||
const deleteUserConversationSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
@@ -249,7 +256,10 @@ describe("ConversationPanel", () => {
|
||||
it("should refetch data on rerenders", async () => {
|
||||
const user = userEvent.setup();
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockResolvedValue([...mockConversations]);
|
||||
getUserConversationsSpy.mockResolvedValue({
|
||||
results: [...mockConversations],
|
||||
next_page_id: null,
|
||||
});
|
||||
|
||||
function PanelWithToggle() {
|
||||
const [isOpen, setIsOpen] = React.useState(true);
|
||||
@@ -343,7 +353,10 @@ describe("ConversationPanel", () => {
|
||||
];
|
||||
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockResolvedValue(mockRunningConversations);
|
||||
getUserConversationsSpy.mockResolvedValue({
|
||||
results: mockRunningConversations,
|
||||
next_page_id: null,
|
||||
});
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
@@ -407,7 +420,10 @@ describe("ConversationPanel", () => {
|
||||
];
|
||||
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockImplementation(async () => mockData);
|
||||
getUserConversationsSpy.mockImplementation(async () => ({
|
||||
results: mockData,
|
||||
next_page_id: null,
|
||||
}));
|
||||
|
||||
const stopConversationSpy = vi.spyOn(OpenHands, "stopConversation");
|
||||
stopConversationSpy.mockImplementation(async (id: string) => {
|
||||
@@ -492,7 +508,10 @@ describe("ConversationPanel", () => {
|
||||
];
|
||||
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockResolvedValue(mockMixedStatusConversations);
|
||||
getUserConversationsSpy.mockResolvedValue({
|
||||
results: mockMixedStatusConversations,
|
||||
next_page_id: null,
|
||||
});
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
|
||||
328
frontend/package-lock.json
generated
328
frontend/package-lock.json
generated
@@ -12,16 +12,16 @@
|
||||
"@heroui/use-infinite-scroll": "^2.2.10",
|
||||
"@microlink/react-json-view": "^1.26.2",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@react-router/node": "^7.7.1",
|
||||
"@react-router/serve": "^7.7.1",
|
||||
"@react-router/node": "^7.8.0",
|
||||
"@react-router/serve": "^7.8.0",
|
||||
"@react-types/shared": "^3.31.0",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@stripe/react-stripe-js": "^3.9.0",
|
||||
"@stripe/stripe-js": "^7.8.0",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/react-query": "^5.84.1",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"@tanstack/react-query": "^5.84.2",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"axios": "^1.11.0",
|
||||
@@ -33,9 +33,9 @@
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.29",
|
||||
"jose": "^6.0.12",
|
||||
"lucide-react": "^0.536.0",
|
||||
"lucide-react": "^0.539.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.258.5",
|
||||
"posthog-js": "^1.259.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -44,7 +44,7 @@
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.7.1",
|
||||
"react-router": "^7.8.0",
|
||||
"react-select": "^5.10.2",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
@@ -53,7 +53,7 @@
|
||||
"sirv-cli": "^3.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vite": "^7.0.6",
|
||||
"vite": "^7.1.1",
|
||||
"web-vitals": "^5.1.0",
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
@@ -63,7 +63,7 @@
|
||||
"@babel/types": "^7.28.2",
|
||||
"@mswjs/socket.io-binding": "^0.2.0",
|
||||
"@playwright/test": "^1.54.2",
|
||||
"@react-router/dev": "^7.7.1",
|
||||
"@react-router/dev": "^7.8.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.83.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
@@ -4388,11 +4388,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-router/dev": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.7.1.tgz",
|
||||
"integrity": "sha512-ByfgHmAyfx/JQYN/QwUx1sFJlBA5Z3HQAZ638wHSb+m6khWtHqSaKCvPqQh1P00wdEAeV3tX5L1aUM/ceCF6+w==",
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.8.0.tgz",
|
||||
"integrity": "sha512-5NA9yLZComM+kCD3zNPL3rjrAFjzzODY8hjAJlpz/6jpyXoF28W8QTSo8rxc56XVNLONM75Y5nq1wzeEcWFFKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.27.7",
|
||||
"@babel/generator": "^7.27.5",
|
||||
@@ -4402,7 +4401,9 @@
|
||||
"@babel/traverse": "^7.27.7",
|
||||
"@babel/types": "^7.27.7",
|
||||
"@npmcli/package-json": "^4.0.1",
|
||||
"@react-router/node": "7.7.1",
|
||||
"@react-router/node": "7.8.0",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"@vitejs/plugin-rsc": "0.4.11",
|
||||
"arg": "^5.0.1",
|
||||
"babel-dead-code-elimination": "^1.0.6",
|
||||
"chokidar": "^4.0.0",
|
||||
@@ -4429,8 +4430,8 @@
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-router/serve": "^7.7.1",
|
||||
"react-router": "^7.7.1",
|
||||
"@react-router/serve": "^7.8.0",
|
||||
"react-router": "^7.8.0",
|
||||
"typescript": "^5.1.0",
|
||||
"vite": "^5.1.0 || ^6.0.0 || ^7.0.0",
|
||||
"wrangler": "^3.28.2 || ^4.0.0"
|
||||
@@ -4447,6 +4448,41 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@react-router/dev/node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@react-router/dev/node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.28.0",
|
||||
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
|
||||
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
|
||||
"@rolldown/pluginutils": "1.0.0-beta.27",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"react-refresh": "^0.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-router/dev/node_modules/@vitejs/plugin-react/node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-router/dev/node_modules/jsesc": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz",
|
||||
@@ -4460,33 +4496,10 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-router/express": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-router/express/-/express-7.7.1.tgz",
|
||||
"integrity": "sha512-OEZwIM7i/KPSDjwVRg3LqeNIwG41U+SeFOwMjhZRFfyrnwghHfvWsDajf73r4ccMh+RRHcP1GIN6VSU3XZk7MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-router/node": "7.7.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": "^4.17.1 || ^5",
|
||||
"react-router": "7.7.1",
|
||||
"typescript": "^5.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@react-router/node": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.7.1.tgz",
|
||||
"integrity": "sha512-EHd6PEcw2nmcJmcYTPA0MmRWSqOaJ/meycfCp0ADA9T/6b7+fUHfr9XcNyf7UeZtYwu4zGyuYfPmLU5ic6Ugyg==",
|
||||
"license": "MIT",
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.8.0.tgz",
|
||||
"integrity": "sha512-/FFN9vqI2EHPwDCHTvsMInhrYvwJ5SlCeyUr1oWUxH47JyYkooVFks5++M4VkrTgj2ZBsMjPPKy0xRNTQdtBDA==",
|
||||
"dependencies": {
|
||||
"@mjackson/node-fetch-server": "^0.2.0"
|
||||
},
|
||||
@@ -4494,7 +4507,7 @@
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-router": "7.7.1",
|
||||
"react-router": "7.8.0",
|
||||
"typescript": "^5.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -4504,13 +4517,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-router/serve": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-router/serve/-/serve-7.7.1.tgz",
|
||||
"integrity": "sha512-LyAiX+oI+6O6j2xWPUoKW+cgayUf3USBosSMv73Jtwi99XUhSDu2MUhM+BB+AbrYRubauZ83QpZTROiXoaf8jA==",
|
||||
"license": "MIT",
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-router/serve/-/serve-7.8.0.tgz",
|
||||
"integrity": "sha512-DokCv1GfOMt9KHu+k3WYY9sP5nOEzq7za+Vi3dWPHoY5oP0wgv8S4DnTPU08ASY8iFaF38NAzapbSFfu6Xfr0Q==",
|
||||
"dependencies": {
|
||||
"@react-router/express": "7.7.1",
|
||||
"@react-router/node": "7.7.1",
|
||||
"@react-router/express": "7.8.0",
|
||||
"@react-router/node": "7.8.0",
|
||||
"compression": "^1.7.4",
|
||||
"express": "^4.19.2",
|
||||
"get-port": "5.1.1",
|
||||
@@ -4524,7 +4536,28 @@
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-router": "7.7.1"
|
||||
"react-router": "7.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-router/serve/node_modules/@react-router/express": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-router/express/-/express-7.8.0.tgz",
|
||||
"integrity": "sha512-lNUwux5IfMqczIL3gXZ/mauPUoVz65fSLPnUTkP7hkh/P7fcsPtYkmcixuaWb+882lY+Glf157OdoIMbcSMBaA==",
|
||||
"dependencies": {
|
||||
"@react-router/node": "7.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": "^4.17.1 || ^5",
|
||||
"react-router": "7.8.0",
|
||||
"typescript": "^5.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@react-stately/calendar": {
|
||||
@@ -5226,10 +5259,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
|
||||
"license": "MIT"
|
||||
"version": "1.0.0-beta.30",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.30.tgz",
|
||||
"integrity": "sha512-whXaSoNUFiyDAjkUF8OBpOm77Szdbk5lGNqFe6CbVbJFrhCCPinCbRA3NjawwlNHla1No7xvXXh+CpSxnPfUEw=="
|
||||
},
|
||||
"node_modules/@rollup/pluginutils": {
|
||||
"version": "5.2.0",
|
||||
@@ -6229,10 +6261,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.84.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.84.1.tgz",
|
||||
"integrity": "sha512-zo7EUygcWJMQfFNWDSG7CBhy8irje/XY0RDVKKV4IQJAysb+ZJkkJPcnQi+KboyGUgT+SQebRFoTqLuTtfoDLw==",
|
||||
"license": "MIT",
|
||||
"version": "5.84.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.84.2.tgz",
|
||||
"integrity": "sha512-cZadySzROlD2+o8zIfbD978p0IphuQzRWiiH3I2ugnTmz4jbjc0+TdibpwqxlzynEen8OulgAg+rzdNF37s7XQ==",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.83.1"
|
||||
},
|
||||
@@ -7005,20 +7036,19 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
|
||||
"license": "MIT",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.0.tgz",
|
||||
"integrity": "sha512-Jx9JfsTa05bYkS9xo0hkofp2dCmp1blrKjw9JONs5BTHOvJCgLbaPSuZLGSVJW6u2qe0tc4eevY0+gSNNi0YCw==",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.28.0",
|
||||
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
|
||||
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
|
||||
"@rolldown/pluginutils": "1.0.0-beta.27",
|
||||
"@rolldown/pluginutils": "1.0.0-beta.30",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"react-refresh": "^0.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
@@ -7033,6 +7063,32 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-rsc": {
|
||||
"version": "0.4.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-rsc/-/plugin-rsc-0.4.11.tgz",
|
||||
"integrity": "sha512-+4H4wLi+Y9yF58znBfKgGfX8zcqUGt8ngnmNgzrdGdF1SVz7EO0sg7WnhK5fFVHt6fUxsVEjmEabsCWHKPL1Tw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@mjackson/node-fetch-server": "^0.7.0",
|
||||
"es-module-lexer": "^1.7.0",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.17",
|
||||
"periscopic": "^4.0.2",
|
||||
"turbo-stream": "^3.1.0",
|
||||
"vitefu": "^1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-dom": "*",
|
||||
"vite": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-rsc/node_modules/@mjackson/node-fetch-server": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@mjackson/node-fetch-server/-/node-fetch-server-0.7.0.tgz",
|
||||
"integrity": "sha512-un8diyEBKU3BTVj3GzlTPA1kIjCkGdD+AMYQy31Gf9JCkfoZzwgJ79GUtHrF2BN3XPNMLpubbzPcxys+a3uZEw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
|
||||
@@ -7215,7 +7271,6 @@
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
@@ -7228,7 +7283,6 @@
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -7369,8 +7423,7 @@
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
|
||||
},
|
||||
"node_modules/array-includes": {
|
||||
"version": "3.1.9",
|
||||
@@ -7735,7 +7788,6 @@
|
||||
"version": "1.20.3",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
@@ -7759,7 +7811,6 @@
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
@@ -7767,8 +7818,7 @@
|
||||
"node_modules/body-parser/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
@@ -8394,7 +8444,6 @@
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
@@ -8406,7 +8455,6 @@
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -8421,7 +8469,6 @@
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -8429,8 +8476,7 @@
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.45.0",
|
||||
@@ -8785,7 +8831,6 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
@@ -8911,7 +8956,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -9264,8 +9308,7 @@
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
@@ -9938,7 +9981,6 @@
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -9977,7 +10019,6 @@
|
||||
"version": "4.21.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
@@ -10023,7 +10064,6 @@
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
@@ -10031,8 +10071,7 @@
|
||||
"node_modules/express/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
@@ -10157,7 +10196,6 @@
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
|
||||
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
@@ -10175,7 +10213,6 @@
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
@@ -10183,8 +10220,7 @@
|
||||
"node_modules/finalhandler/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
||||
},
|
||||
"node_modules/find-root": {
|
||||
"version": "1.1.0",
|
||||
@@ -10321,7 +10357,6 @@
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -10371,7 +10406,6 @@
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -10984,7 +11018,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "2.0.0",
|
||||
"inherits": "2.0.4",
|
||||
@@ -11093,7 +11126,6 @@
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
@@ -11222,7 +11254,6 @@
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
@@ -11571,6 +11602,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-reference": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/is-regex": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||
@@ -12679,10 +12719,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.536.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.536.0.tgz",
|
||||
"integrity": "sha512-2PgvNa9v+qz4Jt/ni8vPLt4jwoFybXHuubQT8fv4iCW5TjDxkbZjNZZHa485ad73NSEn/jdsEtU57eE1g+ma8A==",
|
||||
"license": "ISC",
|
||||
"version": "0.539.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.539.0.tgz",
|
||||
"integrity": "sha512-VVISr+VF2krO91FeuCrm1rSOLACQUYVy7NQkzrOty52Y8TlTPcXcMdQFj9bYzBgXbWCiywlwSZ3Z8u6a+6bMlg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
@@ -13053,7 +13092,6 @@
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -13068,7 +13106,6 @@
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
@@ -13087,7 +13124,6 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -13673,7 +13709,6 @@
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
@@ -14278,7 +14313,6 @@
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
@@ -14481,7 +14515,6 @@
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -14549,8 +14582,7 @@
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "4.0.0",
|
||||
@@ -14578,6 +14610,17 @@
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/periscopic": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/periscopic/-/periscopic-4.0.2.tgz",
|
||||
"integrity": "sha512-sqpQDUy8vgB7ycLkendSKS6HnVz1Rneoc3Rc+ZBUCe2pbqlVuCC5vF52l0NJ1aiMg/r1qfYF9/myz8CZeI2rjA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "*",
|
||||
"is-reference": "^3.0.2",
|
||||
"zimmerframe": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -14702,10 +14745,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.258.6",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.258.6.tgz",
|
||||
"integrity": "sha512-vL5AGG+rOoRg3LGquMfBPO55jD4bGl0CiV44SHdHAoBnOVDDAqxczRGDqMdxor+VLx3/ofTFOJ2FNprfAHp70Q==",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"version": "1.259.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.259.0.tgz",
|
||||
"integrity": "sha512-6usLnJshky8fQ82ask7PIJh4BSFOU0VkRbFg8Zanm/HIlYMG1VOdRWlToA63JXeO7Bzm9TuREq1wFm5U2VEVCg==",
|
||||
"dependencies": {
|
||||
"core-js": "^3.38.1",
|
||||
"fflate": "^0.4.8",
|
||||
@@ -14879,7 +14921,6 @@
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
@@ -14964,7 +15005,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -14973,7 +15013,6 @@
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
|
||||
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"http-errors": "2.0.0",
|
||||
@@ -15180,10 +15219,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.7.1.tgz",
|
||||
"integrity": "sha512-jVKHXoWRIsD/qS6lvGveckwb862EekvapdHJN/cGmzw40KnJH5gg53ujOJ4qX6EKIK9LSBfFed/xiQ5yeXNrUA==",
|
||||
"license": "MIT",
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.0.tgz",
|
||||
"integrity": "sha512-r15M3+LHKgM4SOapNmsH3smAizWds1vJ0Z9C4mWaKnT9/wD7+d/0jYcj6LmOvonkrO4Rgdyp4KQ/29gWN2i1eg==",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
@@ -15952,7 +15990,6 @@
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
|
||||
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
@@ -15976,7 +16013,6 @@
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
@@ -15984,14 +16020,12 @@
|
||||
"node_modules/send/node_modules/debug/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
||||
},
|
||||
"node_modules/send/node_modules/encodeurl": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
|
||||
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -16000,7 +16034,6 @@
|
||||
"version": "1.16.2",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
|
||||
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
@@ -16069,8 +16102,7 @@
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
@@ -17108,7 +17140,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
@@ -17234,6 +17265,12 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/turbo-stream": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-3.1.0.tgz",
|
||||
"integrity": "sha512-tVI25WEXl4fckNEmrq70xU1XumxUwEx/FZD5AgEcV8ri7Wvrg2o7GEq8U7htrNx3CajciGm+kDyhRf5JB6t7/A==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -17264,7 +17301,6 @@
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
@@ -17492,7 +17528,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -17613,7 +17648,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
@@ -17692,16 +17726,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.6.tgz",
|
||||
"integrity": "sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==",
|
||||
"license": "MIT",
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.1.tgz",
|
||||
"integrity": "sha512-yJ+Mp7OyV+4S+afWo+QyoL9jFWD11QFH0i5i7JypnfTcA1rmgxCbiA8WwAICDEtZ1Z1hzrVhN8R8rGTqkTY8ZQ==",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.6",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
"rollup": "^4.40.0",
|
||||
"rollup": "^4.43.0",
|
||||
"tinyglobby": "^0.2.14"
|
||||
},
|
||||
"bin": {
|
||||
@@ -17870,6 +17903,25 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/vitefu": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz",
|
||||
"integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
|
||||
"dev": true,
|
||||
"workspaces": [
|
||||
"tests/deps/*",
|
||||
"tests/projects/*",
|
||||
"tests/projects/workspace/packages/*"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
@@ -18472,6 +18524,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zimmerframe": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
|
||||
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/zwitch": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
"@heroui/use-infinite-scroll": "^2.2.10",
|
||||
"@microlink/react-json-view": "^1.26.2",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@react-router/node": "^7.7.1",
|
||||
"@react-router/serve": "^7.7.1",
|
||||
"@react-router/node": "^7.8.0",
|
||||
"@react-router/serve": "^7.8.0",
|
||||
"@react-types/shared": "^3.31.0",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@stripe/react-stripe-js": "^3.9.0",
|
||||
"@stripe/stripe-js": "^7.8.0",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/react-query": "^5.84.1",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"@tanstack/react-query": "^5.84.2",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"axios": "^1.11.0",
|
||||
@@ -32,9 +32,9 @@
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.29",
|
||||
"jose": "^6.0.12",
|
||||
"lucide-react": "^0.536.0",
|
||||
"lucide-react": "^0.539.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.258.5",
|
||||
"posthog-js": "^1.259.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -43,7 +43,7 @@
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.7.1",
|
||||
"react-router": "^7.8.0",
|
||||
"react-select": "^5.10.2",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
@@ -52,7 +52,7 @@
|
||||
"sirv-cli": "^3.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vite": "^7.0.6",
|
||||
"vite": "^7.1.1",
|
||||
"web-vitals": "^5.1.0",
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
@@ -87,7 +87,7 @@
|
||||
"@babel/types": "^7.28.2",
|
||||
"@mswjs/socket.io-binding": "^0.2.0",
|
||||
"@playwright/test": "^1.54.2",
|
||||
"@react-router/dev": "^7.7.1",
|
||||
"@react-router/dev": "^7.8.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.83.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
|
||||
@@ -283,17 +283,27 @@ class OpenHands {
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getUserConversations(): Promise<Conversation[]> {
|
||||
static async getUserConversations(
|
||||
limit: number = 20,
|
||||
pageId?: string,
|
||||
): Promise<ResultSet<Conversation>> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("limit", limit.toString());
|
||||
|
||||
if (pageId) {
|
||||
params.append("page_id", pageId);
|
||||
}
|
||||
|
||||
const { data } = await openHands.get<ResultSet<Conversation>>(
|
||||
"/api/conversations?limit=100",
|
||||
`/api/conversations?${params.toString()}`,
|
||||
);
|
||||
return data.results;
|
||||
return data;
|
||||
}
|
||||
|
||||
static async searchConversations(
|
||||
selectedRepository?: string,
|
||||
conversationTrigger?: string,
|
||||
limit: number = 20,
|
||||
limit: number = 100,
|
||||
): Promise<Conversation[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("limit", limit.toString());
|
||||
|
||||
@@ -3,7 +3,8 @@ import { NavLink, useParams, useNavigate } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { ConversationCard } from "./conversation-card";
|
||||
import { useUserConversations } from "#/hooks/query/use-user-conversations";
|
||||
import { usePaginatedConversations } from "#/hooks/query/use-paginated-conversations";
|
||||
import { useInfiniteScroll } from "#/hooks/use-infinite-scroll";
|
||||
import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation";
|
||||
import { useStopConversation } from "#/hooks/mutation/use-stop-conversation";
|
||||
import { ConfirmDeleteModal } from "./confirm-delete-modal";
|
||||
@@ -40,12 +41,30 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const { data: conversations, isFetching, error } = useUserConversations();
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
error,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
} = usePaginatedConversations();
|
||||
|
||||
// Flatten all pages into a single array of conversations
|
||||
const conversations = data?.pages.flatMap((page) => page.results) ?? [];
|
||||
|
||||
const { mutate: deleteConversation } = useDeleteConversation();
|
||||
const { mutate: stopConversation } = useStopConversation();
|
||||
const { mutate: updateConversation } = useUpdateConversation();
|
||||
|
||||
// Set up infinite scroll
|
||||
const scrollContainerRef = useInfiniteScroll({
|
||||
hasNextPage: !!hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
threshold: 200, // Load more when 200px from bottom
|
||||
});
|
||||
|
||||
const handleDeleteProject = (conversationId: string) => {
|
||||
setConfirmDeleteModalVisible(true);
|
||||
setSelectedConversationId(conversationId);
|
||||
@@ -102,11 +121,16 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
ref={(node) => {
|
||||
// TODO: Combine both refs somehow
|
||||
if (ref.current !== node) ref.current = node;
|
||||
if (scrollContainerRef.current !== node)
|
||||
scrollContainerRef.current = node;
|
||||
}}
|
||||
data-testid="conversation-panel"
|
||||
className="w-[350px] h-full border border-neutral-700 bg-base-secondary rounded-xl overflow-y-auto absolute"
|
||||
>
|
||||
{isFetching && (
|
||||
{isFetching && conversations.length === 0 && (
|
||||
<div className="w-full h-full absolute flex justify-center items-center">
|
||||
<LoadingSpinner size="small" />
|
||||
</div>
|
||||
@@ -156,6 +180,13 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{/* Loading indicator for fetching more conversations */}
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex justify-center py-4">
|
||||
<LoadingSpinner size="small" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmDeleteModalVisible && (
|
||||
<ConfirmDeleteModal
|
||||
onConfirm={() => {
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
isStatusUpdate,
|
||||
} from "#/types/core/guards";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import EventLogger from "#/utils/event-logger";
|
||||
import {
|
||||
renderConversationErroredToast,
|
||||
renderConversationCreatedToast,
|
||||
|
||||
@@ -25,6 +25,7 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
|
||||
mcp_config: settings.MCP_CONFIG,
|
||||
enable_proactive_conversation_starters:
|
||||
settings.ENABLE_PROACTIVE_CONVERSATION_STARTERS,
|
||||
enable_solvability_analysis: settings.ENABLE_SOLVABILITY_ANALYSIS,
|
||||
search_api_key: settings.SEARCH_API_KEY?.trim() || "",
|
||||
max_budget_per_task: settings.MAX_BUDGET_PER_TASK,
|
||||
git_user_name:
|
||||
|
||||
16
frontend/src/hooks/query/use-paginated-conversations.ts
Normal file
16
frontend/src/hooks/query/use-paginated-conversations.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useIsAuthed } from "./use-is-authed";
|
||||
|
||||
export const usePaginatedConversations = (limit: number = 20) => {
|
||||
const { data: userIsAuthenticated } = useIsAuthed();
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["user", "conversations", "paginated", limit],
|
||||
queryFn: ({ pageParam }) =>
|
||||
OpenHands.getUserConversations(limit, pageParam),
|
||||
enabled: !!userIsAuthenticated,
|
||||
getNextPageParam: (lastPage) => lastPage.next_page_id,
|
||||
initialPageParam: undefined as string | undefined,
|
||||
});
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import OpenHands from "#/api/open-hands";
|
||||
export const useSearchConversations = (
|
||||
selectedRepository?: string,
|
||||
conversationTrigger?: string,
|
||||
limit: number = 20,
|
||||
limit: number = 100,
|
||||
cacheDisabled: boolean = false,
|
||||
) =>
|
||||
useQuery({
|
||||
|
||||
@@ -25,6 +25,7 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
|
||||
ENABLE_SOUND_NOTIFICATIONS: apiSettings.enable_sound_notifications,
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS:
|
||||
apiSettings.enable_proactive_conversation_starters,
|
||||
ENABLE_SOLVABILITY_ANALYSIS: apiSettings.enable_solvability_analysis,
|
||||
USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics,
|
||||
SEARCH_API_KEY: apiSettings.search_api_key || "",
|
||||
MAX_BUDGET_PER_TASK: apiSettings.max_budget_per_task,
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useIsAuthed } from "./use-is-authed";
|
||||
|
||||
export const useUserConversations = () => {
|
||||
const { data: userIsAuthenticated } = useIsAuthed();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["user", "conversations"],
|
||||
queryFn: OpenHands.getUserConversations,
|
||||
enabled: !!userIsAuthenticated,
|
||||
});
|
||||
};
|
||||
42
frontend/src/hooks/use-infinite-scroll.ts
Normal file
42
frontend/src/hooks/use-infinite-scroll.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
|
||||
interface UseInfiniteScrollOptions {
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
fetchNextPage: () => void;
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
export const useInfiniteScroll = ({
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
threshold = 100,
|
||||
}: UseInfiniteScrollOptions) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!containerRef.current || isFetchingNextPage || !hasNextPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||
const isNearBottom = scrollTop + clientHeight >= scrollHeight - threshold;
|
||||
|
||||
if (isNearBottom) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage, threshold]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return undefined;
|
||||
|
||||
container.addEventListener("scroll", handleScroll);
|
||||
return () => {
|
||||
container.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, [handleScroll]);
|
||||
|
||||
return containerRef;
|
||||
};
|
||||
@@ -151,6 +151,7 @@ export enum I18nKey {
|
||||
SETTINGS$MAX_BUDGET_PER_TASK = "SETTINGS$MAX_BUDGET_PER_TASK",
|
||||
SETTINGS$MAX_BUDGET_PER_CONVERSATION = "SETTINGS$MAX_BUDGET_PER_CONVERSATION",
|
||||
SETTINGS$PROACTIVE_CONVERSATION_STARTERS = "SETTINGS$PROACTIVE_CONVERSATION_STARTERS",
|
||||
SETTINGS$SOLVABILITY_ANALYSIS = "SETTINGS$SOLVABILITY_ANALYSIS",
|
||||
SETTINGS$SEARCH_API_KEY = "SETTINGS$SEARCH_API_KEY",
|
||||
SETTINGS$SEARCH_API_KEY_OPTIONAL = "SETTINGS$SEARCH_API_KEY_OPTIONAL",
|
||||
SETTINGS$SEARCH_API_KEY_INSTRUCTIONS = "SETTINGS$SEARCH_API_KEY_INSTRUCTIONS",
|
||||
|
||||
@@ -2415,6 +2415,22 @@
|
||||
"tr": "GitHub'da Görevler Öner",
|
||||
"uk": "Запропонувати завдання на GitHub"
|
||||
},
|
||||
"SETTINGS$SOLVABILITY_ANALYSIS": {
|
||||
"en": "Enable Solvability Analysis",
|
||||
"ja": "解決可能性分析を有効にする",
|
||||
"zh-CN": "启用可解决性分析",
|
||||
"zh-TW": "啟用可解決性分析",
|
||||
"ko-KR": "해결 가능성 분석 활성화",
|
||||
"de": "Lösbarkeitsanalyse aktivieren",
|
||||
"no": "Aktiver løsningsanalyse",
|
||||
"it": "Abilita analisi di risolvibilità",
|
||||
"pt": "Ativar análise de solucionabilidade",
|
||||
"es": "Habilitar análisis de solvencia",
|
||||
"ar": "تمكين تحليل القابلية للحل",
|
||||
"fr": "Activer l'analyse de solvabilité",
|
||||
"tr": "Çözünürlük Analizini Etkinleştir",
|
||||
"uk": "Увімкнути аналіз розв'язності"
|
||||
},
|
||||
"SETTINGS$SEARCH_API_KEY": {
|
||||
"en": "Search API Key (Tavily)",
|
||||
"ja": "検索APIキー (Tavily)",
|
||||
|
||||
@@ -30,6 +30,7 @@ export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
|
||||
enable_sound_notifications: DEFAULT_SETTINGS.ENABLE_SOUND_NOTIFICATIONS,
|
||||
enable_proactive_conversation_starters:
|
||||
DEFAULT_SETTINGS.ENABLE_PROACTIVE_CONVERSATION_STARTERS,
|
||||
enable_solvability_analysis: DEFAULT_SETTINGS.ENABLE_SOLVABILITY_ANALYSIS,
|
||||
user_consents_to_analytics: DEFAULT_SETTINGS.USER_CONSENTS_TO_ANALYTICS,
|
||||
max_budget_per_task: DEFAULT_SETTINGS.MAX_BUDGET_PER_TASK,
|
||||
};
|
||||
|
||||
@@ -38,6 +38,10 @@ function AppSettingsScreen() {
|
||||
proactiveConversationsSwitchHasChanged,
|
||||
setProactiveConversationsSwitchHasChanged,
|
||||
] = React.useState(false);
|
||||
const [
|
||||
solvabilityAnalysisSwitchHasChanged,
|
||||
setSolvabilityAnalysisSwitchHasChanged,
|
||||
] = React.useState(false);
|
||||
const [maxBudgetPerTaskHasChanged, setMaxBudgetPerTaskHasChanged] =
|
||||
React.useState(false);
|
||||
const [gitUserNameHasChanged, setGitUserNameHasChanged] =
|
||||
@@ -61,6 +65,9 @@ function AppSettingsScreen() {
|
||||
formData.get("enable-proactive-conversations-switch")?.toString() ===
|
||||
"on";
|
||||
|
||||
const enableSolvabilityAnalysis =
|
||||
formData.get("enable-solvability-analysis-switch")?.toString() === "on";
|
||||
|
||||
const maxBudgetPerTaskValue = formData
|
||||
.get("max-budget-per-task-input")
|
||||
?.toString();
|
||||
@@ -79,6 +86,7 @@ function AppSettingsScreen() {
|
||||
user_consents_to_analytics: enableAnalytics,
|
||||
ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications,
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS: enableProactiveConversations,
|
||||
ENABLE_SOLVABILITY_ANALYSIS: enableSolvabilityAnalysis,
|
||||
MAX_BUDGET_PER_TASK: maxBudgetPerTask,
|
||||
GIT_USER_NAME: gitUserName,
|
||||
GIT_USER_EMAIL: gitUserEmail,
|
||||
@@ -136,6 +144,13 @@ function AppSettingsScreen() {
|
||||
);
|
||||
};
|
||||
|
||||
const checkIfSolvabilityAnalysisSwitchHasChanged = (checked: boolean) => {
|
||||
const currentSolvabilityAnalysis = !!settings?.ENABLE_SOLVABILITY_ANALYSIS;
|
||||
setSolvabilityAnalysisSwitchHasChanged(
|
||||
checked !== currentSolvabilityAnalysis,
|
||||
);
|
||||
};
|
||||
|
||||
const checkIfMaxBudgetPerTaskHasChanged = (value: string) => {
|
||||
const newValue = parseMaxBudgetPerTask(value);
|
||||
const currentValue = settings?.MAX_BUDGET_PER_TASK;
|
||||
@@ -157,6 +172,7 @@ function AppSettingsScreen() {
|
||||
!analyticsSwitchHasChanged &&
|
||||
!soundNotificationsSwitchHasChanged &&
|
||||
!proactiveConversationsSwitchHasChanged &&
|
||||
!solvabilityAnalysisSwitchHasChanged &&
|
||||
!maxBudgetPerTaskHasChanged &&
|
||||
!gitUserNameHasChanged &&
|
||||
!gitUserEmailHasChanged;
|
||||
@@ -209,6 +225,17 @@ function AppSettingsScreen() {
|
||||
</SettingsSwitch>
|
||||
)}
|
||||
|
||||
{config?.APP_MODE === "saas" && (
|
||||
<SettingsSwitch
|
||||
testId="enable-solvability-analysis-switch"
|
||||
name="enable-solvability-analysis-switch"
|
||||
defaultIsToggled={!!settings.ENABLE_SOLVABILITY_ANALYSIS}
|
||||
onToggle={checkIfSolvabilityAnalysisSwitchHasChanged}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$SOLVABILITY_ANALYSIS)}
|
||||
</SettingsSwitch>
|
||||
)}
|
||||
|
||||
<SettingsInput
|
||||
testId="max-budget-per-task-input"
|
||||
name="max-budget-per-task-input"
|
||||
|
||||
@@ -17,6 +17,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
ENABLE_SOUND_NOTIFICATIONS: false,
|
||||
USER_CONSENTS_TO_ANALYTICS: false,
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS: false,
|
||||
ENABLE_SOLVABILITY_ANALYSIS: false,
|
||||
SEARCH_API_KEY: "",
|
||||
IS_NEW_USER: true,
|
||||
MAX_BUDGET_PER_TASK: null,
|
||||
|
||||
@@ -43,6 +43,7 @@ export type Settings = {
|
||||
ENABLE_DEFAULT_CONDENSER: boolean;
|
||||
ENABLE_SOUND_NOTIFICATIONS: boolean;
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS: boolean;
|
||||
ENABLE_SOLVABILITY_ANALYSIS: boolean;
|
||||
USER_CONSENTS_TO_ANALYTICS: boolean | null;
|
||||
SEARCH_API_KEY?: string;
|
||||
IS_NEW_USER?: boolean;
|
||||
@@ -68,6 +69,7 @@ export type ApiSettings = {
|
||||
enable_default_condenser: boolean;
|
||||
enable_sound_notifications: boolean;
|
||||
enable_proactive_conversation_starters: boolean;
|
||||
enable_solvability_analysis: boolean;
|
||||
user_consents_to_analytics: boolean | null;
|
||||
search_api_key?: string;
|
||||
provider_tokens_set: Partial<Record<Provider, string | null>>;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import re
|
||||
import sys
|
||||
|
||||
from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk
|
||||
|
||||
from openhands.agenthub.codeact_agent.tools.prompt import refine_prompt
|
||||
from openhands.llm.tool_names import EXECUTE_BASH_TOOL_NAME
|
||||
|
||||
_DETAILED_BASH_DESCRIPTION = """Execute a bash command in the terminal within a persistent shell session.
|
||||
@@ -36,21 +34,6 @@ _SHORT_BASH_DESCRIPTION = """Execute a bash command in the terminal.
|
||||
* One command at a time: You can only execute one bash command at a time. If you need to run multiple commands sequentially, you can use `&&` or `;` to chain them together."""
|
||||
|
||||
|
||||
def refine_prompt(prompt: str):
|
||||
if sys.platform == 'win32':
|
||||
# Replace 'bash' with 'powershell' including tool names like 'execute_bash'
|
||||
# First replace 'execute_bash' with 'execute_powershell' to handle tool names
|
||||
result = re.sub(
|
||||
r'\bexecute_bash\b', 'execute_powershell', prompt, flags=re.IGNORECASE
|
||||
)
|
||||
# Then replace standalone 'bash' with 'powershell'
|
||||
result = re.sub(
|
||||
r'(?<!execute_)(?<!_)\bbash\b', 'powershell', result, flags=re.IGNORECASE
|
||||
)
|
||||
return result
|
||||
return prompt
|
||||
|
||||
|
||||
def create_cmd_run_tool(
|
||||
use_short_description: bool = False,
|
||||
) -> ChatCompletionToolParam:
|
||||
|
||||
29
openhands/agenthub/codeact_agent/tools/prompt.py
Normal file
29
openhands/agenthub/codeact_agent/tools/prompt.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def refine_prompt(prompt: str):
|
||||
"""
|
||||
Refines the prompt based on the platform.
|
||||
|
||||
On Windows systems, replaces 'bash' with 'powershell' and 'execute_bash' with 'execute_powershell'
|
||||
to ensure commands work correctly on the Windows platform.
|
||||
|
||||
Args:
|
||||
prompt: The prompt text to refine
|
||||
|
||||
Returns:
|
||||
The refined prompt text
|
||||
"""
|
||||
if sys.platform == 'win32':
|
||||
# Replace 'bash' with 'powershell' including tool names like 'execute_bash'
|
||||
# First replace 'execute_bash' with 'execute_powershell' to handle tool names
|
||||
result = re.sub(
|
||||
r'\bexecute_bash\b', 'execute_powershell', prompt, flags=re.IGNORECASE
|
||||
)
|
||||
# Then replace standalone 'bash' with 'powershell'
|
||||
result = re.sub(
|
||||
r'(?<!execute_)(?<!_)\bbash\b', 'powershell', result, flags=re.IGNORECASE
|
||||
)
|
||||
return result
|
||||
return prompt
|
||||
@@ -6,12 +6,12 @@ import asyncio
|
||||
import contextlib
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Generator
|
||||
|
||||
import markdown # type: ignore
|
||||
from prompt_toolkit import PromptSession, print_formatted_text
|
||||
from prompt_toolkit.application import Application
|
||||
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
|
||||
@@ -37,7 +37,6 @@ from openhands.events import EventSource, EventStream
|
||||
from openhands.events.action import (
|
||||
Action,
|
||||
ActionConfirmationStatus,
|
||||
AgentFinishAction,
|
||||
ChangeAgentStateAction,
|
||||
CmdRunAction,
|
||||
MCPAction,
|
||||
@@ -67,16 +66,11 @@ MAX_RECENT_THOUGHTS = 5
|
||||
# Color and styling constants
|
||||
COLOR_GOLD = '#FFD700'
|
||||
COLOR_GREY = '#808080'
|
||||
COLOR_SUCCESS_GREEN = '#00D787' # Bright green for finish actions
|
||||
COLOR_AGENT_BLUE = '#5FAFFF' # Soft blue for agent messages
|
||||
COLOR_FINISH_FRAME = '#00AF87' # Darker green for finish action frames
|
||||
COLOR_AGENT_BLUE = '#4682B4' # Steel blue - less saturated, works well on both light and dark backgrounds
|
||||
DEFAULT_STYLE = Style.from_dict(
|
||||
{
|
||||
'gold': COLOR_GOLD,
|
||||
'grey': COLOR_GREY,
|
||||
'success-green': COLOR_SUCCESS_GREEN,
|
||||
'agent-blue': COLOR_AGENT_BLUE,
|
||||
'finish-frame': COLOR_FINISH_FRAME,
|
||||
'prompt': f'{COLOR_GOLD} bold',
|
||||
}
|
||||
)
|
||||
@@ -244,13 +238,19 @@ def display_mcp_errors() -> None:
|
||||
|
||||
|
||||
# Prompt output display functions
|
||||
def display_thought_if_new(thought: str) -> None:
|
||||
"""Display a thought only if it hasn't been displayed recently."""
|
||||
def display_thought_if_new(thought: str, is_agent_message: bool = False) -> None:
|
||||
"""
|
||||
Display a thought only if it hasn't been displayed recently.
|
||||
|
||||
Args:
|
||||
thought: The thought to display
|
||||
is_agent_message: If True, apply agent styling and markdown rendering
|
||||
"""
|
||||
global recent_thoughts
|
||||
if thought and thought.strip():
|
||||
# Check if this thought was recently displayed
|
||||
if thought not in recent_thoughts:
|
||||
display_message(thought)
|
||||
display_message(thought, is_agent_message=is_agent_message)
|
||||
recent_thoughts.append(thought)
|
||||
# Keep only the most recent thoughts
|
||||
if len(recent_thoughts) > MAX_RECENT_THOUGHTS:
|
||||
@@ -260,13 +260,10 @@ def display_thought_if_new(thought: str) -> None:
|
||||
def display_event(event: Event, config: OpenHandsConfig) -> None:
|
||||
global streaming_output_text_area
|
||||
with print_lock:
|
||||
if isinstance(event, AgentFinishAction):
|
||||
# Handle agent finish actions with special styling
|
||||
display_agent_finish(event)
|
||||
elif isinstance(event, CmdRunAction):
|
||||
if isinstance(event, CmdRunAction):
|
||||
# For CmdRunAction, display thought first, then command
|
||||
if hasattr(event, 'thought') and event.thought:
|
||||
display_message(event.thought)
|
||||
display_thought_if_new(event.thought)
|
||||
|
||||
# Only display the command if it's not already confirmed
|
||||
# Commands are always shown when AWAITING_CONFIRMATION, so we don't need to show them again when CONFIRMED
|
||||
@@ -280,14 +277,15 @@ def display_event(event: Event, config: OpenHandsConfig) -> None:
|
||||
elif isinstance(event, Action):
|
||||
# For other actions, display thoughts normally
|
||||
if hasattr(event, 'thought') and event.thought:
|
||||
display_message(event.thought)
|
||||
display_thought_if_new(event.thought)
|
||||
if hasattr(event, 'final_thought') and event.final_thought:
|
||||
display_message(event.final_thought)
|
||||
# Display final thoughts with agent styling
|
||||
display_message(event.final_thought, is_agent_message=True)
|
||||
|
||||
if isinstance(event, MessageAction):
|
||||
if event.source == EventSource.AGENT:
|
||||
# Display agent messages with distinctive styling
|
||||
display_agent_message(event.content)
|
||||
# Display agent messages with styling and markdown rendering
|
||||
display_thought_if_new(event.content, is_agent_message=True)
|
||||
elif isinstance(event, CmdOutputObservation):
|
||||
display_command_output(event.content)
|
||||
elif isinstance(event, FileEditObservation):
|
||||
@@ -302,104 +300,76 @@ def display_event(event: Event, config: OpenHandsConfig) -> None:
|
||||
display_error(event.content)
|
||||
|
||||
|
||||
def process_markdown_for_terminal(text: str) -> str:
|
||||
def display_message(message: str, is_agent_message: bool = False) -> None:
|
||||
"""
|
||||
Process markdown syntax for terminal display.
|
||||
This function handles common markdown patterns like bold, italic, code blocks, etc.
|
||||
Display a message in the terminal with markdown rendering.
|
||||
|
||||
Args:
|
||||
message: The message to display
|
||||
is_agent_message: If True, apply agent styling (blue color)
|
||||
"""
|
||||
message = message.strip()
|
||||
|
||||
if message:
|
||||
# Add spacing before the message
|
||||
print_formatted_text('')
|
||||
|
||||
try:
|
||||
# Convert markdown to HTML for all messages
|
||||
html_content = convert_markdown_to_html(message)
|
||||
|
||||
if is_agent_message:
|
||||
# Use prompt_toolkit's HTML renderer with the agent color
|
||||
print_formatted_text(
|
||||
HTML(f'<style fg="{COLOR_AGENT_BLUE}">{html_content}</style>')
|
||||
)
|
||||
else:
|
||||
# Regular message display with HTML rendering but default color
|
||||
print_formatted_text(HTML(html_content))
|
||||
except Exception as e:
|
||||
# If HTML rendering fails, fall back to plain text
|
||||
print(f'Warning: HTML rendering failed: {str(e)}', file=sys.stderr)
|
||||
if is_agent_message:
|
||||
print_formatted_text(
|
||||
FormattedText([('fg:' + COLOR_AGENT_BLUE, message)])
|
||||
)
|
||||
else:
|
||||
print_formatted_text(message)
|
||||
|
||||
|
||||
def convert_markdown_to_html(text: str) -> str:
|
||||
"""
|
||||
Convert markdown to HTML for prompt_toolkit's HTML renderer using the markdown library.
|
||||
|
||||
Args:
|
||||
text: Markdown text to convert
|
||||
|
||||
Returns:
|
||||
HTML formatted text with custom styling for headers and bullet points
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
# Process bold text (**text**)
|
||||
text = re.sub(r'\*\*(.*?)\*\*', r'\1', text)
|
||||
# Use the markdown library to convert markdown to HTML
|
||||
# Enable the 'extra' extension for tables, fenced code, etc.
|
||||
html = markdown.markdown(text, extensions=['extra'])
|
||||
|
||||
# Process italic text (*text*)
|
||||
text = re.sub(r'\*(.*?)\*', r'\1', text)
|
||||
# Customize headers
|
||||
for i in range(1, 7):
|
||||
# Get the appropriate number of # characters for this heading level
|
||||
prefix = '#' * i + ' '
|
||||
|
||||
# Process inline code (`code`)
|
||||
text = re.sub(r'`(.*?)`', r'\1', text)
|
||||
# Replace <h1> with the prefix and bold text
|
||||
html = html.replace(f'<h{i}>', f'<b>{prefix}')
|
||||
html = html.replace(f'</h{i}>', '</b>\n')
|
||||
|
||||
# Process code blocks
|
||||
text = re.sub(r'```(?:\w+)?\n(.*?)\n```', r'\1', text, flags=re.DOTALL)
|
||||
# Customize bullet points to use dashes instead of dots with compact spacing
|
||||
html = html.replace('<ul>', '')
|
||||
html = html.replace('</ul>', '')
|
||||
html = html.replace('<li>', '- ')
|
||||
html = html.replace('</li>', '')
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def display_message(message: str) -> None:
|
||||
message = message.strip()
|
||||
|
||||
if message:
|
||||
print_formatted_text(f'\n{message}')
|
||||
|
||||
|
||||
def display_agent_message(message: str) -> None:
|
||||
"""Display a message from the agent with distinctive styling and markdown rendering."""
|
||||
message = message.strip()
|
||||
|
||||
if message:
|
||||
# Process markdown in the message
|
||||
try:
|
||||
# Process markdown for terminal display
|
||||
processed_message = process_markdown_for_terminal(message)
|
||||
except Exception:
|
||||
# If markdown processing fails, use the original message
|
||||
processed_message = message
|
||||
|
||||
container = Frame(
|
||||
TextArea(
|
||||
text=processed_message,
|
||||
read_only=True,
|
||||
style=COLOR_AGENT_BLUE,
|
||||
wrap_lines=True,
|
||||
),
|
||||
title='Agent Message',
|
||||
style=f'fg:{COLOR_AGENT_BLUE}',
|
||||
)
|
||||
print_formatted_text('')
|
||||
print_container(container)
|
||||
|
||||
|
||||
def display_agent_finish(event: AgentFinishAction) -> None:
|
||||
"""Display an agent finish action with distinctive styling and markdown rendering."""
|
||||
# Determine the message to display
|
||||
if event.final_thought:
|
||||
message = event.final_thought
|
||||
elif event.thought:
|
||||
message = event.thought
|
||||
else:
|
||||
message = "All done! What's next on the agenda?"
|
||||
|
||||
# Add task completion status if available
|
||||
if event.task_completed:
|
||||
status_map = {
|
||||
'true': '✅ Task completed successfully',
|
||||
'partial': '⚠️ Task partially completed',
|
||||
'false': '❌ Task could not be completed',
|
||||
}
|
||||
status_text = status_map.get(event.task_completed.value, '')
|
||||
if status_text:
|
||||
message = f'{status_text}\n\n{message}'
|
||||
|
||||
# Process markdown in the message
|
||||
try:
|
||||
# Process markdown for terminal display
|
||||
processed_message = process_markdown_for_terminal(message)
|
||||
except Exception:
|
||||
# If markdown processing fails, use the original message
|
||||
processed_message = message
|
||||
|
||||
container = Frame(
|
||||
TextArea(
|
||||
text=processed_message,
|
||||
read_only=True,
|
||||
style=COLOR_SUCCESS_GREEN,
|
||||
wrap_lines=True,
|
||||
),
|
||||
title='🎯 Agent Finished',
|
||||
style=f'fg:{COLOR_FINISH_FRAME}',
|
||||
)
|
||||
print_formatted_text('')
|
||||
print_container(container)
|
||||
return html
|
||||
|
||||
|
||||
def display_error(error: str) -> None:
|
||||
|
||||
@@ -72,6 +72,7 @@ class OpenHandsConfig(BaseModel):
|
||||
file_store_path: str = Field(default='~/.openhands')
|
||||
file_store_web_hook_url: str | None = Field(default=None)
|
||||
file_store_web_hook_headers: dict | None = Field(default=None)
|
||||
file_store_web_hook_batch: bool = Field(default=False)
|
||||
enable_browser: bool = Field(default=True)
|
||||
save_trajectory_path: str | None = Field(default=None)
|
||||
save_screenshots_in_trajectory: bool = Field(default=False)
|
||||
|
||||
@@ -234,7 +234,7 @@ async def run_controller(
|
||||
file_path = config.save_trajectory_path
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
histories = controller.get_trajectory(config.save_screenshots_in_trajectory)
|
||||
with open(file_path, 'w') as f: # noqa
|
||||
with open(file_path, 'w') as f: # noqa: ASYNC101
|
||||
json.dump(histories, f, indent=4)
|
||||
|
||||
return state
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.memory.memory import Memory
|
||||
|
||||
|
||||
from mcp import McpError
|
||||
@@ -20,7 +21,6 @@ from openhands.events.observation.mcp import MCPObservation
|
||||
from openhands.events.observation.observation import Observation
|
||||
from openhands.mcp.client import MCPClient
|
||||
from openhands.mcp.error_collector import mcp_error_collector
|
||||
from openhands.memory.memory import Memory
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.impl.cli.cli_runtime import CLIRuntime
|
||||
|
||||
|
||||
@@ -571,7 +571,7 @@ class IssueResolver:
|
||||
# checkout the repo
|
||||
repo_dir = os.path.join(self.output_dir, 'repo')
|
||||
if not os.path.exists(repo_dir):
|
||||
checkout_output = subprocess.check_output( # noqa
|
||||
checkout_output = subprocess.check_output( # noqa: ASYNC101
|
||||
[
|
||||
'git',
|
||||
'clone',
|
||||
@@ -584,7 +584,7 @@ class IssueResolver:
|
||||
|
||||
# get the commit id of current repo for reproducibility
|
||||
base_commit = (
|
||||
subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo_dir) # noqa
|
||||
subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo_dir) # noqa: ASYNC101
|
||||
.decode('utf-8')
|
||||
.strip()
|
||||
)
|
||||
@@ -596,7 +596,7 @@ class IssueResolver:
|
||||
repo_dir, '.openhands_instructions'
|
||||
)
|
||||
if os.path.exists(openhands_instructions_path):
|
||||
with open(openhands_instructions_path, 'r') as f: # noqa
|
||||
with open(openhands_instructions_path, 'r') as f: # noqa: ASYNC101
|
||||
self.repo_instruction = f.read()
|
||||
|
||||
# OUTPUT FILE
|
||||
@@ -605,7 +605,7 @@ class IssueResolver:
|
||||
|
||||
# Check if this issue was already processed
|
||||
if os.path.exists(output_file):
|
||||
with open(output_file, 'r') as f: # noqa
|
||||
with open(output_file, 'r') as f: # noqa: ASYNC101
|
||||
for line in f:
|
||||
data = ResolverOutput.model_validate_json(line)
|
||||
if data.issue.number == self.issue_number:
|
||||
@@ -614,7 +614,7 @@ class IssueResolver:
|
||||
)
|
||||
return
|
||||
|
||||
output_fp = open(output_file, 'a') # noqa
|
||||
output_fp = open(output_file, 'a') # noqa: ASYNC101
|
||||
|
||||
logger.info(
|
||||
f'Resolving issue {self.issue_number} with Agent {AGENT_CLASS}, model {model_name}, max iterations {self.max_iterations}.'
|
||||
@@ -633,20 +633,20 @@ class IssueResolver:
|
||||
|
||||
# Fetch the branch first to ensure it exists locally
|
||||
fetch_cmd = ['git', 'fetch', 'origin', branch_to_use]
|
||||
subprocess.check_output( # noqa
|
||||
subprocess.check_output( # noqa: ASYNC101
|
||||
fetch_cmd,
|
||||
cwd=repo_dir,
|
||||
)
|
||||
|
||||
# Checkout the branch
|
||||
checkout_cmd = ['git', 'checkout', branch_to_use]
|
||||
subprocess.check_output( # noqa
|
||||
subprocess.check_output( # noqa: ASYNC101
|
||||
checkout_cmd,
|
||||
cwd=repo_dir,
|
||||
)
|
||||
|
||||
base_commit = (
|
||||
subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo_dir) # noqa
|
||||
subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo_dir) # noqa: ASYNC101
|
||||
.decode('utf-8')
|
||||
.strip()
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ import docker
|
||||
import httpx
|
||||
import tenacity
|
||||
from docker.models.containers import Container
|
||||
from docker.types import DriverConfig, Mount
|
||||
|
||||
from openhands.core.config import OpenHandsConfig
|
||||
from openhands.core.exceptions import (
|
||||
@@ -258,6 +259,9 @@ class DockerRuntime(ActionExecutionClient):
|
||||
container_path = parts[1]
|
||||
# Default mode is 'rw' if not specified
|
||||
mount_mode = parts[2] if len(parts) > 2 else 'rw'
|
||||
# Skip overlay mounts here; they will be handled separately via Mount objects
|
||||
if 'overlay' in mount_mode:
|
||||
continue
|
||||
|
||||
volumes[host_path] = {
|
||||
'bind': container_path,
|
||||
@@ -286,6 +290,72 @@ class DockerRuntime(ActionExecutionClient):
|
||||
|
||||
return volumes
|
||||
|
||||
def _process_overlay_mounts(self) -> list[Mount]:
|
||||
"""Process overlay mounts specified in sandbox.volumes with mode containing 'overlay'.
|
||||
|
||||
Returns:
|
||||
List of docker.types.Mount objects configured with overlay driver providing
|
||||
read-only lowerdir with per-container copy-on-write upper/work layers.
|
||||
"""
|
||||
overlay_mounts: list[Mount] = []
|
||||
|
||||
# No volumes configured
|
||||
if self.config.sandbox.volumes is None:
|
||||
return overlay_mounts
|
||||
|
||||
# Base directory for overlay upper/work layers from env var
|
||||
overlay_base = os.environ.get('SANDBOX_VOLUME_OVERLAYS')
|
||||
if not overlay_base:
|
||||
# If no base path provided, skip overlay processing
|
||||
return overlay_mounts
|
||||
|
||||
os.makedirs(overlay_base, exist_ok=True)
|
||||
|
||||
mount_specs = self.config.sandbox.volumes.split(',')
|
||||
|
||||
for idx, mount_spec in enumerate(mount_specs):
|
||||
parts = mount_spec.split(':')
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
host_path = os.path.abspath(parts[0])
|
||||
container_path = parts[1]
|
||||
mount_mode = parts[2] if len(parts) > 2 else 'rw'
|
||||
|
||||
if 'overlay' not in mount_mode:
|
||||
continue
|
||||
|
||||
# Prepare upper and work directories unique to this container and mount
|
||||
overlay_dir = os.path.join(overlay_base, self.container_name, f'{idx}')
|
||||
upper_dir = os.path.join(overlay_dir, 'upper')
|
||||
work_dir = os.path.join(overlay_dir, 'work')
|
||||
os.makedirs(upper_dir, exist_ok=True)
|
||||
os.makedirs(work_dir, exist_ok=True)
|
||||
|
||||
driver_cfg = DriverConfig(
|
||||
name='local',
|
||||
options={
|
||||
'type': 'overlay',
|
||||
'device': 'overlay',
|
||||
'o': f'lowerdir={host_path},upperdir={upper_dir},workdir={work_dir}',
|
||||
},
|
||||
)
|
||||
|
||||
mount = Mount(
|
||||
target=container_path,
|
||||
source='', # Anonymous volume
|
||||
type='volume',
|
||||
labels={
|
||||
'app': 'openhands',
|
||||
'role': 'worker',
|
||||
'container': self.container_name,
|
||||
},
|
||||
driver_config=driver_cfg,
|
||||
)
|
||||
|
||||
overlay_mounts.append(mount)
|
||||
|
||||
return overlay_mounts
|
||||
|
||||
def init_container(self) -> None:
|
||||
self.log('debug', 'Preparing to start container...')
|
||||
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
|
||||
@@ -409,6 +479,9 @@ class DockerRuntime(ActionExecutionClient):
|
||||
try:
|
||||
if self.runtime_container_image is None:
|
||||
raise ValueError('Runtime container image is not set')
|
||||
# Process overlay mounts (read-only lower with per-container COW)
|
||||
overlay_mounts = self._process_overlay_mounts()
|
||||
|
||||
self.container = self.docker_client.containers.run(
|
||||
self.runtime_container_image,
|
||||
command=command,
|
||||
@@ -421,6 +494,7 @@ class DockerRuntime(ActionExecutionClient):
|
||||
detach=True,
|
||||
environment=environment,
|
||||
volumes=volumes, # type: ignore
|
||||
mounts=overlay_mounts, # type: ignore
|
||||
device_requests=device_requests,
|
||||
**(self.config.sandbox.docker_runtime_kwargs or {}),
|
||||
)
|
||||
@@ -609,7 +683,8 @@ class DockerRuntime(ActionExecutionClient):
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Pause the runtime by stopping the container.
|
||||
This is different from container.stop() as it ensures environment variables are properly preserved."""
|
||||
This is different from container.stop() as it ensures environment variables are properly preserved.
|
||||
"""
|
||||
if not self.container:
|
||||
raise RuntimeError('Container not initialized')
|
||||
|
||||
@@ -622,7 +697,8 @@ class DockerRuntime(ActionExecutionClient):
|
||||
|
||||
def resume(self) -> None:
|
||||
"""Resume the runtime by starting the container.
|
||||
This is different from container.start() as it ensures environment variables are properly restored."""
|
||||
This is different from container.start() as it ensures environment variables are properly restored.
|
||||
"""
|
||||
if not self.container:
|
||||
raise RuntimeError('Container not initialized')
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ class JupyterPlugin(Plugin):
|
||||
|
||||
# Using synchronous subprocess.Popen for Windows as asyncio.create_subprocess_shell
|
||||
# has limitations on Windows platforms
|
||||
self.gateway_process = subprocess.Popen( # type: ignore[ASYNC101] # noqa
|
||||
self.gateway_process = subprocess.Popen( # type: ignore[ASYNC101] # noqa: ASYNC101
|
||||
jupyter_launch_command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
@@ -82,19 +82,19 @@ class JupyterPlugin(Plugin):
|
||||
output = ''
|
||||
while should_continue():
|
||||
if self.gateway_process.stdout is None:
|
||||
time.sleep(1) # type: ignore[ASYNC101] # noqa
|
||||
time.sleep(1) # type: ignore[ASYNC101] # noqa: ASYNC101
|
||||
continue
|
||||
|
||||
line = self.gateway_process.stdout.readline()
|
||||
if not line:
|
||||
time.sleep(1) # type: ignore[ASYNC101] # noqa
|
||||
time.sleep(1) # type: ignore[ASYNC101] # noqa: ASYNC101
|
||||
continue
|
||||
|
||||
output += line
|
||||
if 'at' in line:
|
||||
break
|
||||
|
||||
time.sleep(1) # type: ignore[ASYNC101] # noqa
|
||||
time.sleep(1) # type: ignore[ASYNC101] # noqa: ASYNC101
|
||||
logger.debug('Waiting for jupyter kernel gateway to start...')
|
||||
|
||||
logger.debug(
|
||||
|
||||
@@ -86,7 +86,7 @@ async def read_file(
|
||||
)
|
||||
|
||||
try:
|
||||
with open(whole_path, 'r', encoding='utf-8') as file: # noqa
|
||||
with open(whole_path, 'r', encoding='utf-8') as file: # noqa: ASYNC101
|
||||
lines = read_lines(file.readlines(), start, end)
|
||||
except FileNotFoundError:
|
||||
return ErrorObservation(f'File not found: {path}')
|
||||
@@ -127,7 +127,7 @@ async def write_file(
|
||||
os.makedirs(os.path.dirname(whole_path))
|
||||
mode = 'w' if not os.path.exists(whole_path) else 'r+'
|
||||
try:
|
||||
with open(whole_path, mode, encoding='utf-8') as file: # noqa
|
||||
with open(whole_path, mode, encoding='utf-8') as file: # noqa: ASYNC101
|
||||
if mode != 'w':
|
||||
all_lines = file.readlines()
|
||||
new_file = insert_lines(insert, all_lines, start, end)
|
||||
|
||||
@@ -27,10 +27,11 @@ assert isinstance(server_config_interface, ServerConfig), (
|
||||
)
|
||||
server_config: ServerConfig = server_config_interface
|
||||
file_store: FileStore = get_file_store(
|
||||
config.file_store,
|
||||
config.file_store_path,
|
||||
config.file_store_web_hook_url,
|
||||
config.file_store_web_hook_headers,
|
||||
file_store_type=config.file_store,
|
||||
file_store_path=config.file_store_path,
|
||||
file_store_web_hook_url=config.file_store_web_hook_url,
|
||||
file_store_web_hook_headers=config.file_store_web_hook_headers,
|
||||
file_store_web_hook_batch=config.file_store_web_hook_batch,
|
||||
)
|
||||
|
||||
client_manager = None
|
||||
|
||||
@@ -61,9 +61,12 @@ The `WebHookFileStore` wraps another `FileStore` implementation and sends HTTP r
|
||||
**Configuration Options:**
|
||||
- `file_store_web_hook_url`: The base URL for webhook requests
|
||||
- `file_store_web_hook_headers`: HTTP headers to include in webhook requests
|
||||
- `file_store_web_hook_batch`: Whether to use batched webhook requests (default: false)
|
||||
|
||||
### Protocol Details
|
||||
|
||||
#### Standard Webhook Protocol (Non-Batched)
|
||||
|
||||
1. **File Write Operation**:
|
||||
- When a file is written, a POST request is sent to `{base_url}{path}`
|
||||
- The request body contains the file contents
|
||||
@@ -73,6 +76,27 @@ The `WebHookFileStore` wraps another `FileStore` implementation and sends HTTP r
|
||||
- When a file is deleted, a DELETE request is sent to `{base_url}{path}`
|
||||
- The operation is retried up to 3 times with a 1-second delay between attempts
|
||||
|
||||
#### Batched Webhook Protocol
|
||||
|
||||
The `BatchedWebHookFileStore` extends the webhook functionality by batching multiple file operations into a single request, which can significantly improve performance when many files are being modified in a short period of time.
|
||||
|
||||
1. **Batch Request**:
|
||||
- A single POST request is sent to `{base_url}` with a JSON array in the body
|
||||
- Each item in the array contains:
|
||||
- `method`: "POST" for write operations, "DELETE" for delete operations
|
||||
- `path`: The file path
|
||||
- `content`: The file contents (for write operations only)
|
||||
- `encoding`: "base64" if binary content was base64-encoded (optional)
|
||||
|
||||
2. **Batch Triggering**:
|
||||
- Batches are sent when one of the following conditions is met:
|
||||
- A timeout period has elapsed (defaults to 5 seconds, configurable via constructor parameter)
|
||||
- The total size of batched content exceeds a size limit (defaults to 1MB, configurable via constructor parameter)
|
||||
- The `flush()` method is explicitly called
|
||||
|
||||
3. **Error Handling**:
|
||||
- The batch request is retried up to 3 times with a 1-second delay between attempts
|
||||
|
||||
## Configuration
|
||||
|
||||
To configure the storage module in OpenHands, use the following configuration options:
|
||||
@@ -90,4 +114,14 @@ file_store_web_hook_url = "https://example.com/api/files"
|
||||
|
||||
# Optional webhook headers (JSON string)
|
||||
file_store_web_hook_headers = '{"Authorization": "Bearer token"}'
|
||||
|
||||
# Optional batched webhook mode (default: false)
|
||||
file_store_web_hook_batch = true
|
||||
```
|
||||
|
||||
**Batched Webhook Configuration:**
|
||||
The batched webhook behavior uses predefined constants with the following default values:
|
||||
- Batch timeout: 5 seconds
|
||||
- Batch size limit: 1MB (1048576 bytes)
|
||||
|
||||
These values can be customized by passing `batch_timeout_seconds` and `batch_size_limit_bytes` parameters to the `BatchedWebHookFileStore` constructor.
|
||||
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
|
||||
import httpx
|
||||
|
||||
from openhands.storage.batched_web_hook import BatchedWebHookFileStore
|
||||
from openhands.storage.files import FileStore
|
||||
from openhands.storage.google_cloud import GoogleCloudFileStore
|
||||
from openhands.storage.local import LocalFileStore
|
||||
@@ -15,6 +16,7 @@ def get_file_store(
|
||||
file_store_path: str | None = None,
|
||||
file_store_web_hook_url: str | None = None,
|
||||
file_store_web_hook_headers: dict | None = None,
|
||||
file_store_web_hook_batch: bool = False,
|
||||
) -> FileStore:
|
||||
store: FileStore
|
||||
if file_store_type == 'local':
|
||||
@@ -35,9 +37,21 @@ def get_file_store(
|
||||
file_store_web_hook_headers['X-Session-API-Key'] = os.getenv(
|
||||
'SESSION_API_KEY'
|
||||
)
|
||||
store = WebHookFileStore(
|
||||
store,
|
||||
file_store_web_hook_url,
|
||||
httpx.Client(headers=file_store_web_hook_headers or {}),
|
||||
)
|
||||
|
||||
client = httpx.Client(headers=file_store_web_hook_headers or {})
|
||||
|
||||
if file_store_web_hook_batch:
|
||||
# Use batched webhook file store
|
||||
store = BatchedWebHookFileStore(
|
||||
store,
|
||||
file_store_web_hook_url,
|
||||
client,
|
||||
)
|
||||
else:
|
||||
# Use regular webhook file store
|
||||
store = WebHookFileStore(
|
||||
store,
|
||||
file_store_web_hook_url,
|
||||
client,
|
||||
)
|
||||
return store
|
||||
|
||||
274
openhands/storage/batched_web_hook.py
Normal file
274
openhands/storage/batched_web_hook.py
Normal file
@@ -0,0 +1,274 @@
|
||||
import threading
|
||||
from typing import Optional, Union
|
||||
|
||||
import httpx
|
||||
import tenacity
|
||||
|
||||
from openhands.storage.files import FileStore
|
||||
from openhands.utils.async_utils import EXECUTOR
|
||||
|
||||
# Constants for batching configuration
|
||||
WEBHOOK_BATCH_TIMEOUT_SECONDS = 5.0
|
||||
WEBHOOK_BATCH_SIZE_LIMIT_BYTES = 1048576 # 1MB
|
||||
|
||||
|
||||
class BatchedWebHookFileStore(FileStore):
|
||||
"""
|
||||
File store which batches updates before sending them to a webhook.
|
||||
|
||||
This class wraps another FileStore implementation and sends HTTP requests
|
||||
to a specified URL when files are written or deleted. Updates are batched
|
||||
and sent together after a certain amount of time passes or if the content
|
||||
size exceeds a threshold.
|
||||
|
||||
Attributes:
|
||||
file_store: The underlying FileStore implementation
|
||||
base_url: The base URL for webhook requests
|
||||
client: The HTTP client used to make webhook requests
|
||||
batch_timeout_seconds: Time in seconds after which a batch is sent (default: WEBHOOK_BATCH_TIMEOUT_SECONDS)
|
||||
batch_size_limit_bytes: Size limit in bytes after which a batch is sent (default: WEBHOOK_BATCH_SIZE_LIMIT_BYTES)
|
||||
_batch_lock: Lock for thread-safe access to the batch
|
||||
_batch: Dictionary of pending file updates
|
||||
_batch_timer: Timer for sending batches after timeout
|
||||
_batch_size: Current size of the batch in bytes
|
||||
"""
|
||||
|
||||
file_store: FileStore
|
||||
base_url: str
|
||||
client: httpx.Client
|
||||
batch_timeout_seconds: float
|
||||
batch_size_limit_bytes: int
|
||||
_batch_lock: threading.Lock
|
||||
_batch: dict[str, tuple[str, Optional[Union[str, bytes]]]]
|
||||
_batch_timer: Optional[threading.Timer]
|
||||
_batch_size: int
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_store: FileStore,
|
||||
base_url: str,
|
||||
client: Optional[httpx.Client] = None,
|
||||
batch_timeout_seconds: Optional[float] = None,
|
||||
batch_size_limit_bytes: Optional[int] = None,
|
||||
):
|
||||
"""
|
||||
Initialize a BatchedWebHookFileStore.
|
||||
|
||||
Args:
|
||||
file_store: The underlying FileStore implementation
|
||||
base_url: The base URL for webhook requests
|
||||
client: Optional HTTP client to use for requests. If None, a new client will be created.
|
||||
batch_timeout_seconds: Time in seconds after which a batch is sent.
|
||||
If None, uses the default constant WEBHOOK_BATCH_TIMEOUT_SECONDS.
|
||||
batch_size_limit_bytes: Size limit in bytes after which a batch is sent.
|
||||
If None, uses the default constant WEBHOOK_BATCH_SIZE_LIMIT_BYTES.
|
||||
"""
|
||||
self.file_store = file_store
|
||||
self.base_url = base_url
|
||||
if client is None:
|
||||
client = httpx.Client()
|
||||
self.client = client
|
||||
|
||||
# Use provided values or default constants
|
||||
self.batch_timeout_seconds = (
|
||||
batch_timeout_seconds or WEBHOOK_BATCH_TIMEOUT_SECONDS
|
||||
)
|
||||
self.batch_size_limit_bytes = (
|
||||
batch_size_limit_bytes or WEBHOOK_BATCH_SIZE_LIMIT_BYTES
|
||||
)
|
||||
|
||||
# Initialize batch state
|
||||
self._batch_lock = threading.Lock()
|
||||
self._batch = {} # Maps path -> (operation, content)
|
||||
self._batch_timer = None
|
||||
self._batch_size = 0
|
||||
|
||||
def write(self, path: str, contents: Union[str, bytes]) -> None:
|
||||
"""
|
||||
Write contents to a file and queue a webhook update.
|
||||
|
||||
Args:
|
||||
path: The path to write to
|
||||
contents: The contents to write
|
||||
"""
|
||||
self.file_store.write(path, contents)
|
||||
self._queue_update(path, 'write', contents)
|
||||
|
||||
def read(self, path: str) -> str:
|
||||
"""
|
||||
Read contents from a file.
|
||||
|
||||
Args:
|
||||
path: The path to read from
|
||||
|
||||
Returns:
|
||||
The contents of the file
|
||||
"""
|
||||
return self.file_store.read(path)
|
||||
|
||||
def list(self, path: str) -> list[str]:
|
||||
"""
|
||||
List files in a directory.
|
||||
|
||||
Args:
|
||||
path: The directory path to list
|
||||
|
||||
Returns:
|
||||
A list of file paths
|
||||
"""
|
||||
return self.file_store.list(path)
|
||||
|
||||
def delete(self, path: str) -> None:
|
||||
"""
|
||||
Delete a file and queue a webhook update.
|
||||
|
||||
Args:
|
||||
path: The path to delete
|
||||
"""
|
||||
self.file_store.delete(path)
|
||||
self._queue_update(path, 'delete', None)
|
||||
|
||||
def _queue_update(
|
||||
self, path: str, operation: str, contents: Optional[Union[str, bytes]]
|
||||
) -> None:
|
||||
"""
|
||||
Queue an update to be sent to the webhook.
|
||||
|
||||
Args:
|
||||
path: The path that was modified
|
||||
operation: The operation performed ("write" or "delete")
|
||||
contents: The contents that were written (None for delete operations)
|
||||
"""
|
||||
with self._batch_lock:
|
||||
# Calculate content size
|
||||
content_size = 0
|
||||
if contents is not None:
|
||||
if isinstance(contents, str):
|
||||
content_size = len(contents.encode('utf-8'))
|
||||
else:
|
||||
content_size = len(contents)
|
||||
|
||||
# Update batch size calculation
|
||||
# If this path already exists in the batch, subtract its previous size
|
||||
if path in self._batch:
|
||||
prev_op, prev_contents = self._batch[path]
|
||||
if prev_contents is not None:
|
||||
if isinstance(prev_contents, str):
|
||||
self._batch_size -= len(prev_contents.encode('utf-8'))
|
||||
else:
|
||||
self._batch_size -= len(prev_contents)
|
||||
|
||||
# Add new content size
|
||||
self._batch_size += content_size
|
||||
|
||||
# Add to batch
|
||||
self._batch[path] = (operation, contents)
|
||||
|
||||
# Check if we need to send the batch due to size limit
|
||||
if self._batch_size >= self.batch_size_limit_bytes:
|
||||
# Submit to executor to avoid blocking
|
||||
EXECUTOR.submit(self._send_batch)
|
||||
return
|
||||
|
||||
# Start or reset the timer for sending the batch
|
||||
if self._batch_timer is not None:
|
||||
self._batch_timer.cancel()
|
||||
self._batch_timer = None
|
||||
|
||||
timer = threading.Timer(
|
||||
self.batch_timeout_seconds, self._send_batch_from_timer
|
||||
)
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
self._batch_timer = timer
|
||||
|
||||
def _send_batch_from_timer(self) -> None:
|
||||
"""
|
||||
Send the batch from the timer thread.
|
||||
This method is called by the timer and submits the actual sending to the executor.
|
||||
"""
|
||||
EXECUTOR.submit(self._send_batch)
|
||||
|
||||
def _send_batch(self) -> None:
|
||||
"""
|
||||
Send the current batch of updates to the webhook as a single request.
|
||||
This method acquires the batch lock and processes all pending updates in one batch.
|
||||
"""
|
||||
batch_to_send: dict[str, tuple[str, Optional[Union[str, bytes]]]] = {}
|
||||
|
||||
with self._batch_lock:
|
||||
if not self._batch:
|
||||
return
|
||||
|
||||
# Copy the batch and clear the current one
|
||||
batch_to_send = self._batch.copy()
|
||||
self._batch.clear()
|
||||
self._batch_size = 0
|
||||
|
||||
# Cancel any pending timer
|
||||
if self._batch_timer is not None:
|
||||
self._batch_timer.cancel()
|
||||
self._batch_timer = None
|
||||
|
||||
# Process the entire batch in a single request
|
||||
if batch_to_send:
|
||||
try:
|
||||
self._send_batch_request(batch_to_send)
|
||||
except Exception as e:
|
||||
# Log the error
|
||||
print(f'Error sending webhook batch: {e}')
|
||||
|
||||
@tenacity.retry(
|
||||
wait=tenacity.wait_fixed(1),
|
||||
stop=tenacity.stop_after_attempt(3),
|
||||
)
|
||||
def _send_batch_request(
|
||||
self, batch: dict[str, tuple[str, Optional[Union[str, bytes]]]]
|
||||
) -> None:
|
||||
"""
|
||||
Send a single batch request to the webhook URL with all updates.
|
||||
|
||||
This method is retried up to 3 times with a 1-second delay between attempts.
|
||||
|
||||
Args:
|
||||
batch: Dictionary mapping paths to (operation, contents) tuples
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the webhook request fails
|
||||
"""
|
||||
# Prepare the batch payload
|
||||
batch_payload = []
|
||||
|
||||
for path, (operation, contents) in batch.items():
|
||||
item = {
|
||||
'method': 'POST' if operation == 'write' else 'DELETE',
|
||||
'path': path,
|
||||
}
|
||||
|
||||
if operation == 'write' and contents is not None:
|
||||
# Convert bytes to string if needed
|
||||
if isinstance(contents, bytes):
|
||||
try:
|
||||
# Try to decode as UTF-8
|
||||
item['content'] = contents.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
# If not UTF-8, use base64 encoding
|
||||
import base64
|
||||
|
||||
item['content'] = base64.b64encode(contents).decode('ascii')
|
||||
item['encoding'] = 'base64'
|
||||
else:
|
||||
item['content'] = contents
|
||||
|
||||
batch_payload.append(item)
|
||||
|
||||
# Send the batch as a single request
|
||||
response = self.client.post(self.base_url, json=batch_payload)
|
||||
response.raise_for_status()
|
||||
|
||||
def flush(self) -> None:
|
||||
"""
|
||||
Immediately send any pending updates to the webhook.
|
||||
This can be called to ensure all updates are sent before shutting down.
|
||||
"""
|
||||
self._send_batch()
|
||||
@@ -106,10 +106,11 @@ class FileConversationStore(ConversationStore):
|
||||
cls, config: OpenHandsConfig, user_id: str | None
|
||||
) -> FileConversationStore:
|
||||
file_store = get_file_store(
|
||||
config.file_store,
|
||||
config.file_store_path,
|
||||
config.file_store_web_hook_url,
|
||||
config.file_store_web_hook_headers,
|
||||
file_store_type=config.file_store,
|
||||
file_store_path=config.file_store_path,
|
||||
file_store_web_hook_url=config.file_store_web_hook_url,
|
||||
file_store_web_hook_headers=config.file_store_web_hook_headers,
|
||||
file_store_web_hook_batch=config.file_store_web_hook_batch,
|
||||
)
|
||||
return FileConversationStore(file_store)
|
||||
|
||||
|
||||
@@ -40,9 +40,10 @@ class FileSecretsStore(SecretsStore):
|
||||
cls, config: OpenHandsConfig, user_id: str | None
|
||||
) -> FileSecretsStore:
|
||||
file_store = get_file_store(
|
||||
config.file_store,
|
||||
config.file_store_path,
|
||||
config.file_store_web_hook_url,
|
||||
config.file_store_web_hook_headers,
|
||||
file_store_type=config.file_store,
|
||||
file_store_path=config.file_store_path,
|
||||
file_store_web_hook_url=config.file_store_web_hook_url,
|
||||
file_store_web_hook_headers=config.file_store_web_hook_headers,
|
||||
file_store_web_hook_batch=config.file_store_web_hook_batch,
|
||||
)
|
||||
return FileSecretsStore(file_store)
|
||||
|
||||
@@ -34,9 +34,10 @@ class FileSettingsStore(SettingsStore):
|
||||
cls, config: OpenHandsConfig, user_id: str | None
|
||||
) -> FileSettingsStore:
|
||||
file_store = get_file_store(
|
||||
config.file_store,
|
||||
config.file_store_path,
|
||||
config.file_store_web_hook_url,
|
||||
config.file_store_web_hook_headers,
|
||||
file_store_type=config.file_store,
|
||||
file_store_path=config.file_store_path,
|
||||
file_store_web_hook_url=config.file_store_web_hook_url,
|
||||
file_store_web_hook_headers=config.file_store_web_hook_headers,
|
||||
file_store_web_hook_batch=config.file_store_web_hook_batch,
|
||||
)
|
||||
return FileSettingsStore(file_store)
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
"""
|
||||
LiteLLM currently have an issue where HttpHandlers are being created but not
|
||||
closed. We have submitted a PR to them, (https://github.com/BerriAI/litellm/pull/8711)
|
||||
and their dev team say they are in the process of a refactor that will fix this, but
|
||||
in the meantime, we need to manage the lifecycle of the httpx.Client manually.
|
||||
|
||||
We can't simply pass in our own client object, because all the different implementations use
|
||||
different types of client object.
|
||||
|
||||
So we monkey patch the httpx.Client class to track newly created instances and close these
|
||||
when the operations complete. (Since some paths create a single shared client and reuse these,
|
||||
we actually need to create a proxy object that allows these clients to be reusable.)
|
||||
|
||||
Hopefully, this will be fixed soon and we can remove this abomination.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
from typing import Callable
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def ensure_httpx_close():
|
||||
wrapped_class = httpx.Client
|
||||
proxys = []
|
||||
|
||||
class ClientProxy:
|
||||
"""
|
||||
Sometimes LiteLLM opens a new httpx client for each connection, and does not close them.
|
||||
Sometimes it does close them. Sometimes, it reuses a client between connections. For cases
|
||||
where a client is reused, we need to be able to reuse the client even after closing it.
|
||||
"""
|
||||
|
||||
client_constructor: Callable
|
||||
args: tuple
|
||||
kwargs: dict
|
||||
client: httpx.Client
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
self.client = wrapped_class(*self.args, **self.kwargs)
|
||||
proxys.append(self)
|
||||
|
||||
def __getattr__(self, name):
|
||||
# Invoke a method on the proxied client - create one if required
|
||||
if self.client is None:
|
||||
self.client = wrapped_class(*self.args, **self.kwargs)
|
||||
return getattr(self.client, name)
|
||||
|
||||
def close(self):
|
||||
# Close the client if it is open
|
||||
if self.client:
|
||||
self.client.close()
|
||||
self.client = None
|
||||
|
||||
def __iter__(self, *args, **kwargs):
|
||||
# We have to override this as debuggers invoke it causing the client to reopen
|
||||
if self.client:
|
||||
return self.client.iter(*args, **kwargs)
|
||||
return object.__getattribute__(self, 'iter')(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def is_closed(self):
|
||||
# Check if closed
|
||||
if self.client is None:
|
||||
return True
|
||||
return self.client.is_closed
|
||||
|
||||
httpx.Client = ClientProxy
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
httpx.Client = wrapped_class
|
||||
while proxys:
|
||||
proxy = proxys.pop()
|
||||
proxy.close()
|
||||
@@ -4,7 +4,6 @@ from itertools import islice
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
from openhands.agenthub.codeact_agent.tools.bash import refine_prompt
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.message import Message, TextContent
|
||||
from openhands.events.observation.agent import MicroagentKnowledge
|
||||
@@ -92,6 +91,8 @@ class PromptManager:
|
||||
return Template(file.read())
|
||||
|
||||
def get_system_message(self) -> str:
|
||||
from openhands.agenthub.codeact_agent.tools.prompt import refine_prompt
|
||||
|
||||
system_message = self.system_template.render().strip()
|
||||
return refine_prompt(system_message)
|
||||
|
||||
|
||||
12
poetry.lock
generated
12
poetry.lock
generated
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
@@ -10467,14 +10467,14 @@ markers = {main = "extra == \"third-party-runtimes\""}
|
||||
|
||||
[[package]]
|
||||
name = "types-markdown"
|
||||
version = "3.8.0.20250708"
|
||||
version = "3.8.0.20250809"
|
||||
description = "Typing stubs for Markdown"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "types_markdown-3.8.0.20250708-py3-none-any.whl", hash = "sha256:d1f634931b463adf7603c012724b7e9e5eff976eb517dc700ebece2d6189b1ce"},
|
||||
{file = "types_markdown-3.8.0.20250708.tar.gz", hash = "sha256:28690251fe90757f5a99cd671c79502bc2de07aef2d35fe54117c3b1c799804a"},
|
||||
{file = "types_markdown-3.8.0.20250809-py3-none-any.whl", hash = "sha256:3f34a38c2259a3158e90ab0cb058cd8f4fdd3d75e2a0b335cb57f25dc2bc77d3"},
|
||||
{file = "types_markdown-3.8.0.20250809.tar.gz", hash = "sha256:fa619e735878a244332a4bbe16bcfc44e49ff6264c2696056278f0642cdfa223"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11797,4 +11797,4 @@ third-party-runtimes = ["daytona", "e2b", "modal", "runloop-api-client"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "0a2be134709df49a9e5132fdf0ec887f2a8cb99be0ed244349be638cbb48364b"
|
||||
content-hash = "9fd177a2dfa1eebb9212e515db93c58f82d6126cc2d131de5321d68772bc2a59"
|
||||
|
||||
@@ -42,8 +42,7 @@ numpy = "*"
|
||||
json-repair = "*"
|
||||
browsergym-core = "0.13.3" # integrate browsergym-core as the browsing interface
|
||||
html2text = "*"
|
||||
markdown = "*" # For markdown processing in CLI
|
||||
types-Markdown = "*" # Type stubs for markdown
|
||||
markdown = "*" # For markdown to HTML conversion
|
||||
deprecated = "*"
|
||||
pexpect = "*"
|
||||
jinja2 = "^3.1.3"
|
||||
@@ -116,6 +115,7 @@ pre-commit = "4.2.0"
|
||||
build = "*"
|
||||
types-setuptools = "*"
|
||||
pytest = "^8.4.0"
|
||||
types-markdown = "^3.8.0.20250809"
|
||||
|
||||
[tool.poetry.group.test]
|
||||
optional = true
|
||||
|
||||
@@ -260,10 +260,11 @@ def _load_runtime(
|
||||
config.mcp = override_mcp_config
|
||||
|
||||
file_store = file_store = get_file_store(
|
||||
config.file_store,
|
||||
config.file_store_path,
|
||||
config.file_store_web_hook_url,
|
||||
config.file_store_web_hook_headers,
|
||||
file_store_type=config.file_store,
|
||||
file_store_path=config.file_store_path,
|
||||
file_store_web_hook_url=config.file_store_web_hook_url,
|
||||
file_store_web_hook_headers=config.file_store_web_hook_headers,
|
||||
file_store_web_hook_batch=config.file_store_web_hook_batch,
|
||||
)
|
||||
event_stream = EventStream(sid, file_store)
|
||||
|
||||
|
||||
235
tests/unit/test_batched_web_hook.py
Normal file
235
tests/unit/test_batched_web_hook.py
Normal file
@@ -0,0 +1,235 @@
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from openhands.storage.batched_web_hook import BatchedWebHookFileStore
|
||||
from openhands.storage.files import FileStore
|
||||
|
||||
|
||||
class MockFileStore(FileStore):
|
||||
def __init__(self):
|
||||
self.files = {}
|
||||
|
||||
def write(self, path: str, contents: str | bytes) -> None:
|
||||
self.files[path] = contents
|
||||
|
||||
def read(self, path: str) -> str:
|
||||
return self.files.get(path, '')
|
||||
|
||||
def list(self, path: str) -> list[str]:
|
||||
return [k for k in self.files.keys() if k.startswith(path)]
|
||||
|
||||
def delete(self, path: str) -> None:
|
||||
if path in self.files:
|
||||
del self.files[path]
|
||||
|
||||
|
||||
class TestBatchedWebHookFileStore:
|
||||
@pytest.fixture
|
||||
def mock_client(self):
|
||||
client = MagicMock(spec=httpx.Client)
|
||||
client.post.return_value.raise_for_status = MagicMock()
|
||||
client.delete.return_value.raise_for_status = MagicMock()
|
||||
return client
|
||||
|
||||
@pytest.fixture
|
||||
def file_store(self):
|
||||
return MockFileStore()
|
||||
|
||||
@pytest.fixture
|
||||
def batched_store(self, file_store, mock_client):
|
||||
# Use a short timeout for testing
|
||||
return BatchedWebHookFileStore(
|
||||
file_store=file_store,
|
||||
base_url='http://example.com',
|
||||
client=mock_client,
|
||||
batch_timeout_seconds=0.1, # Short timeout for testing
|
||||
batch_size_limit_bytes=1000,
|
||||
)
|
||||
|
||||
def test_write_operation_batched(self, batched_store, mock_client):
|
||||
# Write a file
|
||||
batched_store.write('/test.txt', 'Hello, world!')
|
||||
|
||||
# The client should not have been called yet
|
||||
mock_client.post.assert_not_called()
|
||||
|
||||
# Wait for the batch timeout
|
||||
time.sleep(0.2)
|
||||
|
||||
# Now the client should have been called with a batch payload
|
||||
mock_client.post.assert_called_once()
|
||||
args, kwargs = mock_client.post.call_args
|
||||
assert args[0] == 'http://example.com'
|
||||
assert 'json' in kwargs
|
||||
|
||||
# Check the batch payload
|
||||
batch_payload = kwargs['json']
|
||||
assert isinstance(batch_payload, list)
|
||||
assert len(batch_payload) == 1
|
||||
assert batch_payload[0]['method'] == 'POST'
|
||||
assert batch_payload[0]['path'] == '/test.txt'
|
||||
assert batch_payload[0]['content'] == 'Hello, world!'
|
||||
|
||||
def test_delete_operation_batched(self, batched_store, mock_client):
|
||||
# Write and then delete a file
|
||||
batched_store.write('/test.txt', 'Hello, world!')
|
||||
batched_store.delete('/test.txt')
|
||||
|
||||
# The client should not have been called yet
|
||||
mock_client.post.assert_not_called()
|
||||
|
||||
# Wait for the batch timeout
|
||||
time.sleep(0.2)
|
||||
|
||||
# Now the client should have been called with a batch payload
|
||||
mock_client.post.assert_called_once()
|
||||
args, kwargs = mock_client.post.call_args
|
||||
assert args[0] == 'http://example.com'
|
||||
assert 'json' in kwargs
|
||||
|
||||
# Check the batch payload
|
||||
batch_payload = kwargs['json']
|
||||
assert isinstance(batch_payload, list)
|
||||
assert len(batch_payload) == 1
|
||||
assert batch_payload[0]['method'] == 'DELETE'
|
||||
assert batch_payload[0]['path'] == '/test.txt'
|
||||
assert 'content' not in batch_payload[0]
|
||||
|
||||
def test_batch_size_limit_triggers_send(self, batched_store, mock_client):
|
||||
# Write a large file that exceeds the batch size limit
|
||||
large_content = 'x' * 1001 # Exceeds the 1000 byte limit
|
||||
batched_store.write('/large.txt', large_content)
|
||||
|
||||
# The batch might be sent asynchronously, so we need to wait a bit
|
||||
time.sleep(0.2)
|
||||
|
||||
# The client should have been called due to size limit
|
||||
mock_client.post.assert_called_once()
|
||||
args, kwargs = mock_client.post.call_args
|
||||
assert args[0] == 'http://example.com'
|
||||
assert 'json' in kwargs
|
||||
|
||||
# Check the batch payload
|
||||
batch_payload = kwargs['json']
|
||||
assert isinstance(batch_payload, list)
|
||||
assert len(batch_payload) == 1
|
||||
assert batch_payload[0]['method'] == 'POST'
|
||||
assert batch_payload[0]['path'] == '/large.txt'
|
||||
assert batch_payload[0]['content'] == large_content
|
||||
|
||||
def test_multiple_updates_same_file(self, batched_store, mock_client):
|
||||
# Write to the same file multiple times
|
||||
batched_store.write('/test.txt', 'Version 1')
|
||||
batched_store.write('/test.txt', 'Version 2')
|
||||
batched_store.write('/test.txt', 'Version 3')
|
||||
|
||||
# Wait for the batch timeout
|
||||
time.sleep(0.2)
|
||||
|
||||
# Only the latest version should be sent
|
||||
mock_client.post.assert_called_once()
|
||||
args, kwargs = mock_client.post.call_args
|
||||
assert args[0] == 'http://example.com'
|
||||
assert 'json' in kwargs
|
||||
|
||||
# Check the batch payload
|
||||
batch_payload = kwargs['json']
|
||||
assert isinstance(batch_payload, list)
|
||||
assert len(batch_payload) == 1
|
||||
assert batch_payload[0]['method'] == 'POST'
|
||||
assert batch_payload[0]['path'] == '/test.txt'
|
||||
assert batch_payload[0]['content'] == 'Version 3'
|
||||
|
||||
def test_flush_sends_immediately(self, batched_store, mock_client):
|
||||
# Write a file
|
||||
batched_store.write('/test.txt', 'Hello, world!')
|
||||
|
||||
# The client should not have been called yet
|
||||
mock_client.post.assert_not_called()
|
||||
|
||||
# Flush the batch
|
||||
batched_store.flush()
|
||||
|
||||
# Now the client should have been called without waiting for timeout
|
||||
mock_client.post.assert_called_once()
|
||||
args, kwargs = mock_client.post.call_args
|
||||
assert args[0] == 'http://example.com'
|
||||
assert 'json' in kwargs
|
||||
|
||||
# Check the batch payload
|
||||
batch_payload = kwargs['json']
|
||||
assert isinstance(batch_payload, list)
|
||||
assert len(batch_payload) == 1
|
||||
assert batch_payload[0]['method'] == 'POST'
|
||||
assert batch_payload[0]['path'] == '/test.txt'
|
||||
assert batch_payload[0]['content'] == 'Hello, world!'
|
||||
|
||||
def test_multiple_operations_in_single_batch(self, batched_store, mock_client):
|
||||
# Perform multiple operations
|
||||
batched_store.write('/file1.txt', 'Content 1')
|
||||
batched_store.write('/file2.txt', 'Content 2')
|
||||
batched_store.delete('/file3.txt')
|
||||
|
||||
# Wait for the batch timeout
|
||||
time.sleep(0.2)
|
||||
|
||||
# Check that only one POST request was made with all operations
|
||||
mock_client.post.assert_called_once()
|
||||
args, kwargs = mock_client.post.call_args
|
||||
assert args[0] == 'http://example.com'
|
||||
assert 'json' in kwargs
|
||||
|
||||
# Check the batch payload
|
||||
batch_payload = kwargs['json']
|
||||
assert isinstance(batch_payload, list)
|
||||
assert len(batch_payload) == 3
|
||||
|
||||
# Check each operation in the batch
|
||||
operations = {item['path']: item for item in batch_payload}
|
||||
|
||||
assert '/file1.txt' in operations
|
||||
assert operations['/file1.txt']['method'] == 'POST'
|
||||
assert operations['/file1.txt']['content'] == 'Content 1'
|
||||
|
||||
assert '/file2.txt' in operations
|
||||
assert operations['/file2.txt']['method'] == 'POST'
|
||||
assert operations['/file2.txt']['content'] == 'Content 2'
|
||||
|
||||
assert '/file3.txt' in operations
|
||||
assert operations['/file3.txt']['method'] == 'DELETE'
|
||||
assert 'content' not in operations['/file3.txt']
|
||||
|
||||
def test_binary_content_handling(self, batched_store, mock_client):
|
||||
# Write binary content
|
||||
binary_content = b'\x00\x01\x02\x03\xff\xfe\xfd\xfc'
|
||||
batched_store.write('/binary.bin', binary_content)
|
||||
|
||||
# Wait for the batch timeout
|
||||
time.sleep(0.2)
|
||||
|
||||
# Check that the client was called
|
||||
mock_client.post.assert_called_once()
|
||||
args, kwargs = mock_client.post.call_args
|
||||
assert args[0] == 'http://example.com'
|
||||
assert 'json' in kwargs
|
||||
|
||||
# Check the batch payload
|
||||
batch_payload = kwargs['json']
|
||||
assert isinstance(batch_payload, list)
|
||||
assert len(batch_payload) == 1
|
||||
|
||||
# Binary content should be base64 encoded
|
||||
assert batch_payload[0]['method'] == 'POST'
|
||||
assert batch_payload[0]['path'] == '/binary.bin'
|
||||
assert 'content' in batch_payload[0]
|
||||
assert 'encoding' in batch_payload[0]
|
||||
assert batch_payload[0]['encoding'] == 'base64'
|
||||
|
||||
# Verify the content can be decoded back to the original binary
|
||||
import base64
|
||||
|
||||
decoded = base64.b64decode(batch_payload[0]['content'].encode('ascii'))
|
||||
assert decoded == binary_content
|
||||
@@ -15,10 +15,10 @@ from openhands.events.action.message import MessageAction
|
||||
class TestThoughtDisplayOrder:
|
||||
"""Test that thoughts are displayed in the correct order relative to commands."""
|
||||
|
||||
@patch('openhands.cli.tui.display_message')
|
||||
@patch('openhands.cli.tui.display_thought_if_new')
|
||||
@patch('openhands.cli.tui.display_command')
|
||||
def test_cmd_run_action_thought_before_command(
|
||||
self, mock_display_command, mock_display_message
|
||||
self, mock_display_command, mock_display_thought_if_new
|
||||
):
|
||||
"""Test that for CmdRunAction, thought is displayed before command."""
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
@@ -32,8 +32,8 @@ class TestThoughtDisplayOrder:
|
||||
|
||||
display_event(cmd_action, config)
|
||||
|
||||
# Verify that display_message (for thought) was called before display_command
|
||||
mock_display_message.assert_called_once_with(
|
||||
# Verify that display_thought_if_new (for thought) was called before display_command
|
||||
mock_display_thought_if_new.assert_called_once_with(
|
||||
'I need to install the dependencies first before running the tests.'
|
||||
)
|
||||
mock_display_command.assert_called_once_with(cmd_action)
|
||||
@@ -41,21 +41,24 @@ class TestThoughtDisplayOrder:
|
||||
# Check the call order by examining the mock call history
|
||||
all_calls = []
|
||||
all_calls.extend(
|
||||
[('display_message', call) for call in mock_display_message.call_args_list]
|
||||
[
|
||||
('display_thought_if_new', call)
|
||||
for call in mock_display_thought_if_new.call_args_list
|
||||
]
|
||||
)
|
||||
all_calls.extend(
|
||||
[('display_command', call) for call in mock_display_command.call_args_list]
|
||||
)
|
||||
|
||||
# Sort by the order they were called (this is a simplified check)
|
||||
# In practice, we know display_message should be called first based on our code
|
||||
assert mock_display_message.called
|
||||
# In practice, we know display_thought_if_new should be called first based on our code
|
||||
assert mock_display_thought_if_new.called
|
||||
assert mock_display_command.called
|
||||
|
||||
@patch('openhands.cli.tui.display_message')
|
||||
@patch('openhands.cli.tui.display_thought_if_new')
|
||||
@patch('openhands.cli.tui.display_command')
|
||||
def test_cmd_run_action_no_thought(
|
||||
self, mock_display_command, mock_display_message
|
||||
self, mock_display_command, mock_display_thought_if_new
|
||||
):
|
||||
"""Test that CmdRunAction without thought only displays command."""
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
@@ -66,14 +69,14 @@ class TestThoughtDisplayOrder:
|
||||
|
||||
display_event(cmd_action, config)
|
||||
|
||||
# Verify that display_message was not called (no thought)
|
||||
mock_display_message.assert_not_called()
|
||||
# Verify that display_thought_if_new was not called (no thought)
|
||||
mock_display_thought_if_new.assert_not_called()
|
||||
mock_display_command.assert_called_once_with(cmd_action)
|
||||
|
||||
@patch('openhands.cli.tui.display_message')
|
||||
@patch('openhands.cli.tui.display_thought_if_new')
|
||||
@patch('openhands.cli.tui.display_command')
|
||||
def test_cmd_run_action_empty_thought(
|
||||
self, mock_display_command, mock_display_message
|
||||
self, mock_display_command, mock_display_thought_if_new
|
||||
):
|
||||
"""Test that CmdRunAction with empty thought only displays command."""
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
@@ -84,15 +87,15 @@ class TestThoughtDisplayOrder:
|
||||
|
||||
display_event(cmd_action, config)
|
||||
|
||||
# Verify that display_message was not called (empty thought)
|
||||
mock_display_message.assert_not_called()
|
||||
# Verify that display_thought_if_new was not called (empty thought)
|
||||
mock_display_thought_if_new.assert_not_called()
|
||||
mock_display_command.assert_called_once_with(cmd_action)
|
||||
|
||||
@patch('openhands.cli.tui.display_message')
|
||||
@patch('openhands.cli.tui.display_thought_if_new')
|
||||
@patch('openhands.cli.tui.display_command')
|
||||
@patch('openhands.cli.tui.initialize_streaming_output')
|
||||
def test_cmd_run_action_confirmed_no_display(
|
||||
self, mock_init_streaming, mock_display_command, mock_display_message
|
||||
self, mock_init_streaming, mock_display_command, mock_display_thought_if_new
|
||||
):
|
||||
"""Test that confirmed CmdRunAction doesn't display command again but initializes streaming."""
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
@@ -107,7 +110,7 @@ class TestThoughtDisplayOrder:
|
||||
display_event(cmd_action, config)
|
||||
|
||||
# Verify that thought is still displayed
|
||||
mock_display_message.assert_called_once_with(
|
||||
mock_display_thought_if_new.assert_called_once_with(
|
||||
'I need to install the dependencies first before running the tests.'
|
||||
)
|
||||
# But command should not be displayed again (already shown when awaiting confirmation)
|
||||
@@ -115,8 +118,8 @@ class TestThoughtDisplayOrder:
|
||||
# Streaming should be initialized
|
||||
mock_init_streaming.assert_called_once()
|
||||
|
||||
@patch('openhands.cli.tui.display_message')
|
||||
def test_other_action_thought_display(self, mock_display_message):
|
||||
@patch('openhands.cli.tui.display_thought_if_new')
|
||||
def test_other_action_thought_display(self, mock_display_thought_if_new):
|
||||
"""Test that other Action types still display thoughts normally."""
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
|
||||
@@ -127,13 +130,13 @@ class TestThoughtDisplayOrder:
|
||||
display_event(action, config)
|
||||
|
||||
# Verify that thought is displayed
|
||||
mock_display_message.assert_called_once_with(
|
||||
mock_display_thought_if_new.assert_called_once_with(
|
||||
'This is a thought for a generic action.'
|
||||
)
|
||||
|
||||
@patch('openhands.cli.tui.display_message')
|
||||
def test_other_action_final_thought_display(self, mock_display_message):
|
||||
"""Test that other Action types display final thoughts."""
|
||||
"""Test that other Action types display final thoughts as agent messages."""
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
|
||||
# Create a generic Action with final thought
|
||||
@@ -142,11 +145,13 @@ class TestThoughtDisplayOrder:
|
||||
|
||||
display_event(action, config)
|
||||
|
||||
# Verify that final thought is displayed
|
||||
mock_display_message.assert_called_once_with('This is a final thought.')
|
||||
# Verify that final thought is displayed as an agent message
|
||||
mock_display_message.assert_called_once_with(
|
||||
'This is a final thought.', is_agent_message=True
|
||||
)
|
||||
|
||||
@patch('openhands.cli.tui.display_agent_message')
|
||||
def test_message_action_from_agent(self, mock_display_agent_message):
|
||||
@patch('openhands.cli.tui.display_thought_if_new')
|
||||
def test_message_action_from_agent(self, mock_display_thought_if_new):
|
||||
"""Test that MessageAction from agent is displayed."""
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
|
||||
@@ -156,11 +161,13 @@ class TestThoughtDisplayOrder:
|
||||
|
||||
display_event(message_action, config)
|
||||
|
||||
# Verify that agent message is displayed
|
||||
mock_display_agent_message.assert_called_once_with('Hello from agent')
|
||||
# Verify that agent message is displayed with agent styling
|
||||
mock_display_thought_if_new.assert_called_once_with(
|
||||
'Hello from agent', is_agent_message=True
|
||||
)
|
||||
|
||||
@patch('openhands.cli.tui.display_message')
|
||||
def test_message_action_from_user_not_displayed(self, mock_display_message):
|
||||
@patch('openhands.cli.tui.display_thought_if_new')
|
||||
def test_message_action_from_user_not_displayed(self, mock_display_thought_if_new):
|
||||
"""Test that MessageAction from user is not displayed."""
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
|
||||
@@ -171,12 +178,12 @@ class TestThoughtDisplayOrder:
|
||||
display_event(message_action, config)
|
||||
|
||||
# Verify that message is not displayed (only agent messages are shown)
|
||||
mock_display_message.assert_not_called()
|
||||
mock_display_thought_if_new.assert_not_called()
|
||||
|
||||
@patch('openhands.cli.tui.display_message')
|
||||
@patch('openhands.cli.tui.display_thought_if_new')
|
||||
@patch('openhands.cli.tui.display_command')
|
||||
def test_cmd_run_action_with_both_thoughts(
|
||||
self, mock_display_command, mock_display_message
|
||||
self, mock_display_command, mock_display_thought_if_new
|
||||
):
|
||||
"""Test CmdRunAction with both thought and final_thought."""
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
@@ -190,7 +197,7 @@ class TestThoughtDisplayOrder:
|
||||
|
||||
# For CmdRunAction, only the regular thought should be displayed
|
||||
# (final_thought is handled by the general Action case, but CmdRunAction is handled first)
|
||||
mock_display_message.assert_called_once_with('Initial thought')
|
||||
mock_display_thought_if_new.assert_called_once_with('Initial thought')
|
||||
mock_display_command.assert_called_once_with(cmd_action)
|
||||
|
||||
|
||||
@@ -204,7 +211,7 @@ class TestThoughtDisplayIntegration:
|
||||
# Track the order of calls
|
||||
call_order = []
|
||||
|
||||
def track_display_message(message):
|
||||
def track_display_message(message, is_agent_message=False):
|
||||
call_order.append(f'THOUGHT: {message}')
|
||||
|
||||
def track_display_command(event):
|
||||
|
||||
@@ -6,8 +6,6 @@ from openhands.cli.tui import (
|
||||
CustomDiffLexer,
|
||||
UsageMetrics,
|
||||
UserCancelledError,
|
||||
display_agent_finish,
|
||||
display_agent_message,
|
||||
display_banner,
|
||||
display_command,
|
||||
display_event,
|
||||
@@ -28,7 +26,6 @@ from openhands.events import EventSource
|
||||
from openhands.events.action import (
|
||||
Action,
|
||||
ActionConfirmationStatus,
|
||||
AgentFinishAction,
|
||||
CmdRunAction,
|
||||
MCPAction,
|
||||
MessageAction,
|
||||
@@ -110,16 +107,14 @@ class TestDisplayFunctions:
|
||||
assert 'What do you want to build?' in message_text
|
||||
assert 'Type /help for help' in message_text
|
||||
|
||||
@patch('openhands.cli.tui.display_agent_message')
|
||||
def test_display_event_message_action(self, mock_display_agent_message):
|
||||
def test_display_event_message_action(self):
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
message = MessageAction(content='Test message')
|
||||
message._source = EventSource.AGENT
|
||||
|
||||
# Directly test the function without mocking
|
||||
display_event(message, config)
|
||||
|
||||
mock_display_agent_message.assert_called_once_with('Test message')
|
||||
|
||||
@patch('openhands.cli.tui.display_command')
|
||||
def test_display_event_cmd_action(self, mock_display_command):
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
@@ -175,25 +170,14 @@ class TestDisplayFunctions:
|
||||
|
||||
mock_display_file_read.assert_called_once_with(file_read)
|
||||
|
||||
@patch('openhands.cli.tui.display_message')
|
||||
def test_display_event_thought(self, mock_display_message):
|
||||
def test_display_event_thought(self):
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
action = Action()
|
||||
action.thought = 'Thinking about this...'
|
||||
|
||||
# Directly test the function without mocking
|
||||
display_event(action, config)
|
||||
|
||||
mock_display_message.assert_called_once_with('Thinking about this...')
|
||||
|
||||
@patch('openhands.cli.tui.display_agent_finish')
|
||||
def test_display_event_agent_finish(self, mock_display_agent_finish):
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
finish_action = AgentFinishAction(final_thought='Task completed')
|
||||
|
||||
display_event(finish_action, config)
|
||||
|
||||
mock_display_agent_finish.assert_called_once_with(finish_action)
|
||||
|
||||
@patch('openhands.cli.tui.display_mcp_action')
|
||||
def test_display_event_mcp_action(self, mock_display_mcp_action):
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
@@ -264,46 +248,10 @@ class TestDisplayFunctions:
|
||||
message = 'Test message'
|
||||
display_message(message)
|
||||
|
||||
mock_print.assert_called_once()
|
||||
mock_print.assert_called()
|
||||
args, kwargs = mock_print.call_args
|
||||
assert message in str(args[0])
|
||||
|
||||
@patch('openhands.cli.tui.print_container')
|
||||
@patch('openhands.cli.tui.print_formatted_text')
|
||||
def test_display_agent_message(self, mock_print_formatted, mock_print_container):
|
||||
message = 'Agent message'
|
||||
display_agent_message(message)
|
||||
|
||||
mock_print_formatted.assert_called_once()
|
||||
mock_print_container.assert_called_once()
|
||||
|
||||
@patch('openhands.cli.tui.print_container')
|
||||
@patch('openhands.cli.tui.print_formatted_text')
|
||||
def test_display_agent_finish_with_thought(
|
||||
self, mock_print_formatted, mock_print_container
|
||||
):
|
||||
finish_action = AgentFinishAction(thought='Final thought')
|
||||
|
||||
display_agent_finish(finish_action)
|
||||
|
||||
mock_print_formatted.assert_called_once()
|
||||
mock_print_container.assert_called_once()
|
||||
|
||||
@patch('openhands.cli.tui.print_container')
|
||||
@patch('openhands.cli.tui.print_formatted_text')
|
||||
def test_display_agent_finish_with_task_completed(
|
||||
self, mock_print_formatted, mock_print_container
|
||||
):
|
||||
from openhands.events.action.agent import AgentFinishTaskCompleted
|
||||
|
||||
finish_action = AgentFinishAction()
|
||||
finish_action.task_completed = AgentFinishTaskCompleted.TRUE
|
||||
|
||||
display_agent_finish(finish_action)
|
||||
|
||||
mock_print_formatted.assert_called_once()
|
||||
mock_print_container.assert_called_once()
|
||||
|
||||
@patch('openhands.cli.tui.print_container')
|
||||
def test_display_command_awaiting_confirmation(self, mock_print_container):
|
||||
cmd_action = CmdRunAction(command='echo test')
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import httpx
|
||||
|
||||
from openhands.utils.ensure_httpx_close import ensure_httpx_close
|
||||
|
||||
|
||||
def test_ensure_httpx_close_basic():
|
||||
"""Test basic functionality of ensure_httpx_close."""
|
||||
ctx = ensure_httpx_close()
|
||||
with ctx:
|
||||
# Create a client - should be tracked
|
||||
client = httpx.Client()
|
||||
|
||||
# After context exit, client should be closed
|
||||
assert client.is_closed
|
||||
|
||||
|
||||
def test_ensure_httpx_close_multiple_clients():
|
||||
"""Test ensure_httpx_close with multiple clients."""
|
||||
ctx = ensure_httpx_close()
|
||||
with ctx:
|
||||
client1 = httpx.Client()
|
||||
client2 = httpx.Client()
|
||||
|
||||
assert client1.is_closed
|
||||
assert client2.is_closed
|
||||
|
||||
|
||||
def test_ensure_httpx_close_nested():
|
||||
"""Test nested usage of ensure_httpx_close."""
|
||||
with ensure_httpx_close():
|
||||
client1 = httpx.Client()
|
||||
|
||||
with ensure_httpx_close():
|
||||
client2 = httpx.Client()
|
||||
assert not client2.is_closed
|
||||
|
||||
# After inner context, client2 should be closed
|
||||
assert client2.is_closed
|
||||
# client1 should still be open since outer context is still active
|
||||
assert not client1.is_closed
|
||||
|
||||
# After outer context, both clients should be closed
|
||||
assert client1.is_closed
|
||||
assert client2.is_closed
|
||||
|
||||
|
||||
def test_ensure_httpx_close_exception():
|
||||
"""Test ensure_httpx_close when an exception occurs."""
|
||||
client = None
|
||||
try:
|
||||
with ensure_httpx_close():
|
||||
client = httpx.Client()
|
||||
raise ValueError('Test exception')
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Client should be closed even if an exception occurred
|
||||
assert client is not None
|
||||
assert client.is_closed
|
||||
|
||||
|
||||
def test_ensure_httpx_close_restore_client():
|
||||
"""Test that the original client is restored after context exit."""
|
||||
original_client = httpx.Client
|
||||
with ensure_httpx_close():
|
||||
assert httpx.Client != original_client
|
||||
|
||||
# Original __init__ should be restored
|
||||
assert httpx.Client == original_client
|
||||
@@ -86,4 +86,10 @@ async def test_get_instance():
|
||||
|
||||
assert isinstance(store, FileSettingsStore)
|
||||
assert store.file_store == mock_store
|
||||
mock_get_store.assert_called_once_with('local', '/test/path', None, None)
|
||||
mock_get_store.assert_called_once_with(
|
||||
file_store_type='local',
|
||||
file_store_path='/test/path',
|
||||
file_store_web_hook_url=None,
|
||||
file_store_web_hook_headers=None,
|
||||
file_store_web_hook_batch=False,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user